@ekkos/cli 1.4.2 → 1.5.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.
@@ -68,6 +68,8 @@ 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
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
+ const MAX_OUTPUT_1M_MODELS = 64000;
72
+ const MAX_OUTPUT_200K_OPUS_SONNET = 16384;
71
73
  // ── Pricing ──
72
74
  // Pricing per MTok from https://docs.anthropic.com/en/docs/about-claude/pricing
73
75
  // Includes prompt caching rates (5m cache write=1.25x input, cache read=0.1x input).
@@ -147,6 +149,55 @@ function calculateTurnCost(model, usage, options) {
147
149
  (usage.cache_creation_tokens / 1000000) * p.cacheWrite +
148
150
  (usage.cache_read_tokens / 1000000) * p.cacheRead);
149
151
  }
152
+ function countTools(toolStr) {
153
+ if (!toolStr || toolStr === '-')
154
+ return 0;
155
+ return toolStr.split(',').map(tool => tool.trim()).filter(Boolean).length;
156
+ }
157
+ function mergeToolStrings(...toolStrings) {
158
+ const merged = new Set();
159
+ for (const toolStr of toolStrings) {
160
+ if (!toolStr || toolStr === '-')
161
+ continue;
162
+ for (const tool of toolStr.split(',').map(value => value.trim()).filter(Boolean)) {
163
+ merged.add(tool);
164
+ }
165
+ }
166
+ return merged.size > 0 ? Array.from(merged).join(',') : '-';
167
+ }
168
+ function compareTurnCompleteness(left, right) {
169
+ const leftContext = left.input + left.cacheRead + left.cacheCreate;
170
+ const rightContext = right.input + right.cacheRead + right.cacheCreate;
171
+ if (left.output !== right.output)
172
+ return left.output - right.output;
173
+ if (leftContext !== rightContext)
174
+ return leftContext - rightContext;
175
+ const leftTools = countTools(left.tools);
176
+ const rightTools = countTools(right.tools);
177
+ if (leftTools !== rightTools)
178
+ return leftTools - rightTools;
179
+ const leftTs = Date.parse(left.timestamp || '');
180
+ const rightTs = Date.parse(right.timestamp || '');
181
+ const safeLeftTs = Number.isFinite(leftTs) ? leftTs : 0;
182
+ const safeRightTs = Number.isFinite(rightTs) ? rightTs : 0;
183
+ return safeLeftTs - safeRightTs;
184
+ }
185
+ function pickMoreCompleteTurn(left, right) {
186
+ const preferred = compareTurnCompleteness(left, right) >= 0 ? left : right;
187
+ const secondary = preferred === left ? right : left;
188
+ return {
189
+ ...preferred,
190
+ msgId: preferred.msgId || secondary.msgId,
191
+ tools: mergeToolStrings(left.tools, right.tools),
192
+ };
193
+ }
194
+ function turnsMatch(left, right) {
195
+ if (!left || !right)
196
+ return false;
197
+ if (left.msgId && right.msgId)
198
+ return left.msgId === right.msgId;
199
+ return left.turn === right.turn;
200
+ }
150
201
  function getModelCtxSize(model, contextTierHint) {
151
202
  const normalized = (model || '').toLowerCase();
152
203
  if (contextTierHint === '1m')
@@ -247,7 +298,7 @@ function formatContextLaneCompact(contextSize, metadata) {
247
298
  const launchSize = typeof metadata?.claudeContextSize === 'number' ? metadata.claudeContextSize : undefined;
248
299
  const resolvedSize = explicitSize || launchSize;
249
300
  const maxOutput = metadata?.claudeMaxOutputTokens
250
- || (resolvedSize && resolvedSize >= 1000000 ? 128000 : 32768);
301
+ || (resolvedSize && resolvedSize >= 1000000 ? MAX_OUTPUT_1M_MODELS : MAX_OUTPUT_200K_OPUS_SONNET);
251
302
  if (resolvedSize && resolvedSize >= 1000000) {
252
303
  return `1M / out ${fmtK(maxOutput)}`;
253
304
  }
@@ -264,6 +315,13 @@ function formatContextLaneCompact(contextSize, metadata) {
264
315
  function buildRuntimeSignal(label, color, value) {
265
316
  return `{${color}-fg}[${label}]{/${color}-fg} ${value}`;
266
317
  }
318
+ function buildPreTurnRuntimeLeadSignal(metadata) {
319
+ const compactSelected = formatCompactLaunchModel(metadata);
320
+ if (compactSelected) {
321
+ return buildRuntimeSignal('SEL', 'cyan', compactSelected);
322
+ }
323
+ return buildRuntimeSignal('CACHE', 'green', '0%');
324
+ }
267
325
  /** Model tag for dashboard display */
268
326
  function modelTag(model) {
269
327
  if (model.includes('opus'))
@@ -386,10 +444,10 @@ function parseJsonlFile(jsonlPath, sessionName, launchMetadata) {
386
444
  const toolStr = msgTools && msgTools.size > 0
387
445
  ? Array.from(msgTools).map(t => t.replace(/^mcp__ekkos-memory__/, '').replace(/^ekkOS_/, '')).join(',')
388
446
  : '-';
389
- // Last entry wins — final JSONL entry for a message has the real output_tokens
390
447
  const turnNum = isNew ? msgIdOrder.length + 1 : (turnsByMsgId.get(msgId).turn);
391
448
  const turnData = {
392
449
  turn: turnNum,
450
+ msgId,
393
451
  contextPct,
394
452
  modelContextSize: modelCtxSize,
395
453
  input: inputTokens,
@@ -410,66 +468,29 @@ function parseJsonlFile(jsonlPath, sessionName, launchMetadata) {
410
468
  if (msgId) {
411
469
  if (isNew)
412
470
  msgIdOrder.push(msgId);
413
- turnsByMsgId.set(msgId, turnData);
471
+ const existingTurn = turnsByMsgId.get(msgId);
472
+ turnsByMsgId.set(msgId, existingTurn ? pickMoreCompleteTurn(existingTurn, turnData) : turnData);
414
473
  }
415
474
  }
416
475
  }
417
476
  catch { /* skip bad lines */ }
418
477
  }
419
- // Build ordered turns array from the Map (last-entry-wins dedup)
478
+ // Build ordered turns array from the Map using the richest snapshot seen per message id.
420
479
  const turns = msgIdOrder.map(id => turnsByMsgId.get(id));
421
- const totalCost = turns.reduce((s, t) => s + t.cost, 0);
422
- const totalInput = turns.reduce((s, t) => s + t.input, 0);
423
- const totalCacheRead = turns.reduce((s, t) => s + t.cacheRead, 0);
424
- const totalCacheCreate = turns.reduce((s, t) => s + t.cacheCreate, 0);
425
- const totalOutput = turns.reduce((s, t) => s + t.output, 0);
426
- const maxContextPct = turns.length > 0 ? Math.max(...turns.map(t => t.contextPct)) : 0;
427
- const currentContextPct = turns.length > 0 ? turns[turns.length - 1].contextPct : 0;
428
- const avgCostPerTurn = turns.length > 0 ? totalCost / turns.length : 0;
429
- const cacheHitRate = (totalCacheRead + totalCacheCreate) > 0
430
- ? (totalCacheRead / (totalCacheRead + totalCacheCreate)) * 100
431
- : 0;
432
480
  const replayAppliedCount = turns.reduce((sum, t) => sum + (t.replayState === 'applied' ? 1 : 0), 0);
433
481
  const replaySkippedSizeCount = turns.reduce((sum, t) => sum + (t.replayState === 'skipped-size' ? 1 : 0), 0);
434
482
  const replaySkipStoreCount = turns.reduce((sum, t) => sum + (t.replayStore === 'skip-size' ? 1 : 0), 0);
435
- let duration = '0m';
436
- if (startedAt && turns.length > 0) {
437
- const start = new Date(startedAt).getTime();
438
- const end = new Date(turns[turns.length - 1].timestamp).getTime();
439
- const mins = Math.round((end - start) / 60000);
440
- duration = mins >= 60 ? `${Math.floor(mins / 60)}h${mins % 60}m` : `${mins}m`;
441
- }
442
- // Get current context tokens from the last turn's raw data
443
- const lastTurn = turns.length > 0 ? turns[turns.length - 1] : null;
444
- const currentContextTokens = lastTurn
445
- ? lastTurn.input + lastTurn.cacheRead + lastTurn.cacheCreate
446
- : 0;
447
- const modelContextSize = lastTurn
448
- ? lastTurn.modelContextSize
449
- : resolveDashboardContextSize(model, fallbackContextWindow, launchMetadata);
450
- return {
483
+ return buildDashboardData({
451
484
  sessionName,
452
485
  model,
453
- turnCount: turns.length,
454
- totalCost,
455
- totalTokens: totalInput + totalCacheRead + totalCacheCreate + totalOutput,
456
- totalInput,
457
- totalCacheRead,
458
- totalCacheCreate,
459
- totalOutput,
460
- avgCostPerTurn,
461
- maxContextPct,
462
- currentContextPct,
463
- currentContextTokens,
464
- modelContextSize,
465
- cacheHitRate,
486
+ startedAt,
487
+ turns,
466
488
  replayAppliedCount,
467
489
  replaySkippedSizeCount,
468
490
  replaySkipStoreCount,
469
- startedAt,
470
- duration,
471
- turns,
472
- };
491
+ fallbackModelContextSize: resolveDashboardContextSize(model, fallbackContextWindow, launchMetadata),
492
+ cacheHitMode: 'read-vs-write',
493
+ });
473
494
  }
474
495
  function parseGeminiSessionFile(sessionPath, sessionName, launchMetadata) {
475
496
  const raw = JSON.parse(fs.readFileSync(sessionPath, 'utf-8'));
@@ -505,6 +526,7 @@ function parseGeminiSessionFile(sessionPath, sessionName, launchMetadata) {
505
526
  };
506
527
  turns.push({
507
528
  turn: turns.length + 1,
529
+ msgId: typeof message.id === 'string' ? message.id : undefined,
508
530
  contextPct,
509
531
  modelContextSize: modelCtxSize,
510
532
  input: uncachedInput,
@@ -523,6 +545,38 @@ function parseGeminiSessionFile(sessionPath, sessionName, launchMetadata) {
523
545
  timestamp: message.timestamp || raw.lastUpdated || raw.startTime || new Date().toISOString(),
524
546
  });
525
547
  }
548
+ return buildDashboardData({
549
+ sessionName,
550
+ model,
551
+ startedAt,
552
+ turns,
553
+ replayAppliedCount: 0,
554
+ replaySkippedSizeCount: 0,
555
+ replaySkipStoreCount: 0,
556
+ fallbackModelContextSize: resolveDashboardContextSize(model, fallbackWindow, launchMetadata, launchMetadata?.claudeContextSize),
557
+ cacheHitMode: 'read-vs-total-input',
558
+ });
559
+ }
560
+ function tokenSafePercent(numerator, denominator) {
561
+ if (!Number.isFinite(denominator) || denominator <= 0)
562
+ return 0;
563
+ return (numerator / denominator) * 100;
564
+ }
565
+ function resolveCacheHitRate(turns, mode) {
566
+ const totalInput = turns.reduce((sum, turn) => sum + turn.input, 0);
567
+ const totalCacheRead = turns.reduce((sum, turn) => sum + turn.cacheRead, 0);
568
+ const totalCacheCreate = turns.reduce((sum, turn) => sum + turn.cacheCreate, 0);
569
+ if (mode === 'read-vs-write') {
570
+ return (totalCacheRead + totalCacheCreate) > 0
571
+ ? (totalCacheRead / (totalCacheRead + totalCacheCreate)) * 100
572
+ : 0;
573
+ }
574
+ return tokenSafePercent(totalCacheRead, totalInput + totalCacheRead);
575
+ }
576
+ function buildDashboardData(params) {
577
+ const turns = params.turns.map((turn, index) => (turn.turn === index + 1
578
+ ? turn
579
+ : { ...turn, turn: index + 1 }));
526
580
  const totalCost = turns.reduce((sum, turn) => sum + turn.cost, 0);
527
581
  const totalInput = turns.reduce((sum, turn) => sum + turn.input, 0);
528
582
  const totalCacheRead = turns.reduce((sum, turn) => sum + turn.cacheRead, 0);
@@ -531,21 +585,22 @@ function parseGeminiSessionFile(sessionPath, sessionName, launchMetadata) {
531
585
  const maxContextPct = turns.length > 0 ? Math.max(...turns.map(turn => turn.contextPct)) : 0;
532
586
  const currentContextPct = turns.length > 0 ? turns[turns.length - 1].contextPct : 0;
533
587
  const avgCostPerTurn = turns.length > 0 ? totalCost / turns.length : 0;
534
- const cacheHitRate = tokenSafePercent(totalCacheRead, totalInput + totalCacheRead);
588
+ const cacheHitRate = resolveCacheHitRate(turns, params.cacheHitMode);
535
589
  let duration = '0m';
536
- if (startedAt && turns.length > 0) {
537
- const start = new Date(startedAt).getTime();
590
+ if (params.startedAt && turns.length > 0) {
591
+ const start = new Date(params.startedAt).getTime();
538
592
  const end = new Date(turns[turns.length - 1].timestamp).getTime();
539
593
  const mins = Math.max(0, Math.round((end - start) / 60000));
540
594
  duration = mins >= 60 ? `${Math.floor(mins / 60)}h${mins % 60}m` : `${mins}m`;
541
595
  }
542
596
  const lastTurn = turns.length > 0 ? turns[turns.length - 1] : null;
543
597
  return {
544
- sessionName,
545
- model,
598
+ sessionName: params.sessionName,
599
+ model: params.model,
546
600
  turnCount: turns.length,
547
601
  totalCost,
548
- totalTokens: totalInput + totalCacheRead + totalCacheCreate + totalOutput,
602
+ // Unique content tokens: excludes cache_read (same prefix re-billed per turn)
603
+ totalTokens: totalInput + totalCacheCreate + totalOutput,
549
604
  totalInput,
550
605
  totalCacheRead,
551
606
  totalCacheCreate,
@@ -554,22 +609,62 @@ function parseGeminiSessionFile(sessionPath, sessionName, launchMetadata) {
554
609
  maxContextPct,
555
610
  currentContextPct,
556
611
  currentContextTokens: lastTurn ? lastTurn.input + lastTurn.cacheRead + lastTurn.cacheCreate : 0,
557
- modelContextSize: lastTurn
558
- ? lastTurn.modelContextSize
559
- : resolveDashboardContextSize(model, fallbackWindow, launchMetadata, launchMetadata?.claudeContextSize),
612
+ modelContextSize: lastTurn ? lastTurn.modelContextSize : params.fallbackModelContextSize,
560
613
  cacheHitRate,
561
- replayAppliedCount: 0,
562
- replaySkippedSizeCount: 0,
563
- replaySkipStoreCount: 0,
564
- startedAt,
614
+ replayAppliedCount: params.replayAppliedCount || 0,
615
+ replaySkippedSizeCount: params.replaySkippedSizeCount || 0,
616
+ replaySkipStoreCount: params.replaySkipStoreCount || 0,
617
+ startedAt: params.startedAt,
565
618
  duration,
566
619
  turns,
567
620
  };
568
621
  }
569
- function tokenSafePercent(numerator, denominator) {
570
- if (!Number.isFinite(denominator) || denominator <= 0)
571
- return 0;
572
- return (numerator / denominator) * 100;
622
+ function reconcileDashboardData(previous, next) {
623
+ if (!previous || previous.sessionName !== next.sessionName || previous.turns.length === 0) {
624
+ return next;
625
+ }
626
+ const mergedTurns = next.turns.map(turn => ({ ...turn }));
627
+ const previousTurns = previous.turns;
628
+ if (mergedTurns.length === 0) {
629
+ return previous;
630
+ }
631
+ const previousLast = previousTurns[previousTurns.length - 1];
632
+ const nextLast = mergedTurns[mergedTurns.length - 1];
633
+ const keepLastMutable = turnsMatch(previousLast, nextLast);
634
+ const freezeCount = Math.min(previousTurns.length - (keepLastMutable ? 1 : 0), mergedTurns.length);
635
+ for (let index = 0; index < freezeCount; index++) {
636
+ if (!turnsMatch(previousTurns[index], mergedTurns[index]))
637
+ continue;
638
+ mergedTurns[index] = pickMoreCompleteTurn(previousTurns[index], mergedTurns[index]);
639
+ }
640
+ if (keepLastMutable) {
641
+ const tailIndex = previousTurns.length - 1;
642
+ if (tailIndex < mergedTurns.length && turnsMatch(previousTurns[tailIndex], mergedTurns[tailIndex])) {
643
+ mergedTurns[tailIndex] = pickMoreCompleteTurn(previousTurns[tailIndex], mergedTurns[tailIndex]);
644
+ }
645
+ }
646
+ if (previousTurns.length > mergedTurns.length) {
647
+ const prefixMatches = mergedTurns.every((turn, index) => turnsMatch(previousTurns[index], turn));
648
+ if (prefixMatches) {
649
+ for (let index = mergedTurns.length; index < previousTurns.length; index++) {
650
+ mergedTurns.push({ ...previousTurns[index] });
651
+ }
652
+ }
653
+ }
654
+ const cacheHitMode = mergedTurns.some(turn => Boolean(turn.msgId))
655
+ ? 'read-vs-write'
656
+ : 'read-vs-total-input';
657
+ return buildDashboardData({
658
+ sessionName: next.sessionName,
659
+ model: next.model,
660
+ startedAt: next.startedAt || previous.startedAt,
661
+ turns: mergedTurns,
662
+ replayAppliedCount: Math.max(previous.replayAppliedCount, next.replayAppliedCount),
663
+ replaySkippedSizeCount: Math.max(previous.replaySkippedSizeCount, next.replaySkippedSizeCount),
664
+ replaySkipStoreCount: Math.max(previous.replaySkipStoreCount, next.replaySkipStoreCount),
665
+ fallbackModelContextSize: next.modelContextSize || previous.modelContextSize,
666
+ cacheHitMode,
667
+ });
573
668
  }
574
669
  function readJsonFile(filePath) {
575
670
  try {
@@ -661,9 +756,9 @@ function formatLaunchWindowLabel(metadata) {
661
756
  ? metadata.claudeContextSize
662
757
  : undefined;
663
758
  if (window === '200k')
664
- return `Window ${fmtK(exactSize || 200000)} · out ${fmtK(metadata?.claudeMaxOutputTokens || 32768)}`;
759
+ return `Window ${fmtK(exactSize || 200000)} · out ${fmtK(metadata?.claudeMaxOutputTokens || MAX_OUTPUT_200K_OPUS_SONNET)}`;
665
760
  if (window === '1m')
666
- return `Window ${fmtK(exactSize || 1000000)} · out ${fmtK(metadata?.claudeMaxOutputTokens || 128000)}`;
761
+ return `Window ${fmtK(exactSize || 1000000)} · out ${fmtK(metadata?.claudeMaxOutputTokens || MAX_OUTPUT_1M_MODELS)}`;
667
762
  if (typeof metadata?.claudeContextSize === 'number' && metadata.claudeContextSize > 0) {
668
763
  return `Window ${fmtK(metadata.claudeContextSize)}`;
669
764
  }
@@ -1034,6 +1129,7 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1034
1129
  const blessed = require('blessed');
1035
1130
  const contrib = require('blessed-contrib');
1036
1131
  const inTmux = process.env.TMUX !== undefined;
1132
+ const needsTmuxResizeRecovery = inTmux && process.platform === 'darwin';
1037
1133
  // ══════════════════════════════════════════════════════════════════════════
1038
1134
  // TMUX SPLIT PANE ISOLATION
1039
1135
  // When dashboard runs in a separate tmux pane from `ekkos run`, blessed must
@@ -1072,13 +1168,52 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1072
1168
  mouse: false, // Disable ALL mouse capture (allows terminal text selection)
1073
1169
  grabKeys: false, // Don't grab keyboard input from other panes
1074
1170
  sendFocus: false, // Don't send focus events (breaks paste)
1075
- ignoreLocked: ['C-c', 'C-q', 'f7'], // Capture Ctrl+C, Ctrl+Q for quit, F7 for hard refresh
1171
+ ignoreLocked: ['C-c', 'C-q', 'f7', 'C-f7'], // Capture quit + redraw keys even when widgets are passive
1076
1172
  input: ttyInput, // Use /dev/tty for input (isolated from stdout pipe)
1077
1173
  output: ttyOutput, // Use /dev/tty for output (isolated from stdout pipe)
1078
1174
  forceUnicode: true, // Better text rendering
1079
1175
  terminal: 'xterm-256color',
1080
1176
  resizeTimeout: 300, // Debounce resize events
1081
1177
  });
1178
+ function readTerminalSize() {
1179
+ const candidates = [ttyOutput, screen.program?.output, process.stdout];
1180
+ for (const candidate of candidates) {
1181
+ if (!candidate)
1182
+ continue;
1183
+ try {
1184
+ if (typeof candidate.getWindowSize === 'function') {
1185
+ const size = candidate.getWindowSize();
1186
+ if (Array.isArray(size) && size.length >= 2) {
1187
+ const [cols, rows] = size;
1188
+ if (cols > 0 && rows > 0)
1189
+ return { cols, rows };
1190
+ }
1191
+ }
1192
+ }
1193
+ catch { }
1194
+ const cols = Number(candidate.columns);
1195
+ const rows = Number(candidate.rows);
1196
+ if (cols > 0 && rows > 0)
1197
+ return { cols, rows };
1198
+ }
1199
+ return null;
1200
+ }
1201
+ function syncProgramSizeFromTTY(source) {
1202
+ const program = screen.program;
1203
+ if (!program)
1204
+ return false;
1205
+ const size = readTerminalSize();
1206
+ if (!size)
1207
+ return false;
1208
+ const cols = Math.max(20, size.cols);
1209
+ const rows = Math.max(10, size.rows);
1210
+ if (program.cols === cols && program.rows === rows)
1211
+ return false;
1212
+ dlog(`TTY geometry sync (${source}): ${program.cols}x${program.rows} -> ${cols}x${rows}`);
1213
+ program.cols = cols;
1214
+ program.rows = rows;
1215
+ return true;
1216
+ }
1082
1217
  // ══════════════════════════════════════════════════════════════════════════
1083
1218
  // DISABLE TERMINAL CONTROL SEQUENCE HIJACKING (TMUX ONLY)
1084
1219
  // When running in a tmux split pane alongside Claude Code, prevent blessed
@@ -1115,7 +1250,7 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1115
1250
  // No percentages = no gaps.
1116
1251
  //
1117
1252
  // header: 3 rows (session stats + animated logo)
1118
- // context: 5 rows (progress bar + cost breakdown + cache stats)
1253
+ // context: 5-6 rows (progress bar + runtime stats + cache/cost summary)
1119
1254
  // chart: ~25-34% of remaining (token usage graph)
1120
1255
  // table: ~66-75% of remaining (turn-by-turn breakdown)
1121
1256
  // usage: 3 rows (Anthropic rate limit window)
@@ -1158,11 +1293,19 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1158
1293
  }
1159
1294
  const W = '100%';
1160
1295
  const HEADER_H = 3;
1161
- const CONTEXT_H = 5;
1162
1296
  const USAGE_H = 4;
1163
1297
  const FOOTER_H = 3;
1164
1298
  const MASCOT_W = 16; // Width reserved for runtime mascot in context box
1165
- const FIXED_H = HEADER_H + CONTEXT_H + USAGE_H + FOOTER_H; // 15
1299
+ function resolveContextHeight(height) {
1300
+ return height >= 36 ? 6 : 5;
1301
+ }
1302
+ function resolveChartMinHeight(height) {
1303
+ if (height >= 28)
1304
+ return 6;
1305
+ if (height >= 26)
1306
+ return 5;
1307
+ return 4;
1308
+ }
1166
1309
  function resolveChartRatio(height) {
1167
1310
  if (height >= 62)
1168
1311
  return 0.25;
@@ -1174,9 +1317,14 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1174
1317
  }
1175
1318
  function calcLayout() {
1176
1319
  const H = Math.max(24, screen.height || 24);
1320
+ const CONTEXT_H = resolveContextHeight(H);
1321
+ const FIXED_H = HEADER_H + CONTEXT_H + USAGE_H + FOOTER_H;
1177
1322
  const remaining = Math.max(8, H - FIXED_H);
1178
- const chartH = Math.max(6, Math.floor(remaining * resolveChartRatio(H)));
1179
- const tableH = Math.max(5, remaining - chartH);
1323
+ const tableMin = 5;
1324
+ const chartMin = resolveChartMinHeight(H);
1325
+ const chartTarget = Math.floor(remaining * resolveChartRatio(H));
1326
+ const chartH = Math.max(chartMin, Math.min(chartTarget, remaining - tableMin));
1327
+ const tableH = Math.max(tableMin, remaining - chartH);
1180
1328
  return {
1181
1329
  header: { top: 0, height: HEADER_H },
1182
1330
  context: { top: HEADER_H, height: CONTEXT_H },
@@ -1378,13 +1526,17 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1378
1526
  // Track geometry so we can re-anchor widgets even if tmux resize events are flaky
1379
1527
  let lastLayoutW = screen.width || 0;
1380
1528
  let lastLayoutH = screen.height || 0;
1381
- function ensureLayoutSynced() {
1529
+ function ensureLayoutSynced(source = 'layout') {
1530
+ const geometryChanged = syncProgramSizeFromTTY(source);
1382
1531
  const w = screen.width || 0;
1383
1532
  const h = screen.height || 0;
1384
1533
  if (w === lastLayoutW && h === lastLayoutH)
1385
1534
  return;
1386
1535
  lastLayoutW = w;
1387
1536
  lastLayoutH = h;
1537
+ if (geometryChanged && needsTmuxResizeRecovery) {
1538
+ scheduleResizeRecovery(source);
1539
+ }
1388
1540
  try {
1389
1541
  screen.realloc?.();
1390
1542
  }
@@ -1478,11 +1630,14 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1478
1630
  try {
1479
1631
  const launchModelLabel = formatLaunchModelLabel(launchMetadata);
1480
1632
  const launchWindowLabel = formatLaunchWindowLabel(launchMetadata);
1481
- const compactSelected = formatCompactLaunchModel(launchMetadata) || 'Awaiting profile';
1482
1633
  const compactLane = formatContextLaneCompact(launchMetadata?.claudeContextSize, launchMetadata) || 'Awaiting lane';
1634
+ const extendedContext = (layout.context.height || 0) >= 6;
1483
1635
  contextBox.setContent(` {green-fg}Session active{/green-fg} {gray-fg}${sessionName}{/gray-fg}\n` +
1484
- ` ${buildRuntimeSignal('SEL', 'cyan', compactSelected)} {gray-fg}│{/gray-fg} ${buildRuntimeSignal('ACT', 'magenta', 'standby')} {gray-fg}│{/gray-fg} ${buildRuntimeSignal('LANE', 'yellow', compactLane)}\n` +
1485
- ` ${[launchModelLabel, launchWindowLabel].filter(Boolean).join(' ') || 'Token and cost metrics appear after the first assistant response.'}`);
1636
+ ` ${buildPreTurnRuntimeLeadSignal(launchMetadata)} {gray-fg}│{/gray-fg} ${buildRuntimeSignal('ACT', 'magenta', 'standby')} {gray-fg}│{/gray-fg} ${buildRuntimeSignal('LANE', 'yellow', compactLane)}\n` +
1637
+ ` ${[launchModelLabel, launchWindowLabel].filter(Boolean).join(' ') || 'Token and cost metrics appear after the first assistant response.'}` +
1638
+ (extendedContext
1639
+ ? `\n {gray-fg}Cache, peak context, and routed cost appear after the first assistant response.{/gray-fg}`
1640
+ : ''));
1486
1641
  turnBox.setContent(`{bold}Turns{/bold}\n` +
1487
1642
  `{gray-fg}—{/gray-fg}`);
1488
1643
  const timerStr = sessionStartMs
@@ -1491,8 +1646,8 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1491
1646
  footerBox.setLabel(` ${sessionName} `);
1492
1647
  footerBox.setContent(` ${timerStr}{green-fg}Ready{/green-fg}` +
1493
1648
  (inTmux
1494
- ? ` {gray-fg}Ctrl+Q quit F7 redraw{/gray-fg}`
1495
- : ` {gray-fg}? help q quit r refresh F7 redraw{/gray-fg}`));
1649
+ ? ` {gray-fg}Ctrl+Q quit Ctrl+F7/F7 redraw{/gray-fg}`
1650
+ : ` {gray-fg}? help q quit r refresh Ctrl+F7/F7 redraw{/gray-fg}`));
1496
1651
  }
1497
1652
  catch (err) {
1498
1653
  dlog(`Pre-turn render: ${err.message}`);
@@ -1562,7 +1717,7 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1562
1717
  launchMetadata = readSessionLaunchMetadata(sessionName) || launchMetadata;
1563
1718
  provider = inferDashboardProvider(initialProvider, launchMetadata, jsonlPath);
1564
1719
  renderRuntimeMascot();
1565
- data = parseTranscriptFile(jsonlPath, provider, sessionName, launchMetadata);
1720
+ data = reconcileDashboardData(lastData && lastData.sessionName === sessionName ? lastData : null, parseTranscriptFile(jsonlPath, provider, sessionName, launchMetadata));
1566
1721
  lastData = data;
1567
1722
  if (!sessionStartMs && data.startedAt) {
1568
1723
  sessionStartMs = new Date(data.startedAt).getTime();
@@ -1610,19 +1765,24 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1610
1765
  // Cache stats
1611
1766
  const hitColor = data.cacheHitRate >= 80 ? 'green' : data.cacheHitRate >= 50 ? 'yellow' : 'red';
1612
1767
  const cappedMax = Math.min(data.maxContextPct, 100);
1768
+ const extendedContext = (layout.context.height || 0) >= 6;
1613
1769
  contextBox.setContent(` ${bar}\n` +
1614
1770
  ` ${buildRuntimeSignal('SEL', 'cyan', selectedProfile)} {gray-fg}│{/gray-fg} ${buildRuntimeSignal('ACT', 'magenta', activeModel)} {gray-fg}│{/gray-fg} ${buildRuntimeSignal('LANE', 'yellow', laneLabel)}\n` +
1615
1771
  ` {${ctxColor}-fg}${ctxPct.toFixed(0)}%{/${ctxColor}-fg} ${tokensLabel}/${maxLabel}` +
1616
- ` {white-fg}In{/white-fg} $${breakdown.input.toFixed(2)}` +
1617
- ` {green-fg}Read{/green-fg} $${breakdown.read.toFixed(2)}` +
1618
- ` {yellow-fg}Write{/yellow-fg} $${breakdown.write.toFixed(2)}` +
1619
- ` {cyan-fg}Out{/cyan-fg} $${breakdown.output.toFixed(2)}` +
1620
1772
  ` {${hitColor}-fg}${data.cacheHitRate.toFixed(0)}% cache{/${hitColor}-fg}` +
1621
1773
  ` peak:${cappedMax.toFixed(0)}%` +
1622
1774
  ` avg:$${data.avgCostPerTurn.toFixed(2)}/t` +
1623
- ` replay A:${data.replayAppliedCount}` +
1624
- ` SZ:${data.replaySkippedSizeCount}` +
1625
- ` ST:${data.replaySkipStoreCount}`);
1775
+ (extendedContext
1776
+ ? `\n {white-fg}In{/white-fg} $${breakdown.input.toFixed(2)}` +
1777
+ ` {green-fg}Read{/green-fg} $${breakdown.read.toFixed(2)}` +
1778
+ ` {yellow-fg}Write{/yellow-fg} $${breakdown.write.toFixed(2)}` +
1779
+ ` {cyan-fg}Out{/cyan-fg} $${breakdown.output.toFixed(2)}` +
1780
+ ` replay A:${data.replayAppliedCount}` +
1781
+ ` SZ:${data.replaySkippedSizeCount}` +
1782
+ ` ST:${data.replaySkipStoreCount}`
1783
+ : ` {white-fg}In{/white-fg} $${breakdown.input.toFixed(2)}` +
1784
+ ` {cyan-fg}Out{/cyan-fg} $${breakdown.output.toFixed(2)}` +
1785
+ ` R:${data.replayAppliedCount}/${data.replaySkippedSizeCount}/${data.replaySkipStoreCount}`));
1626
1786
  }
1627
1787
  catch (err) {
1628
1788
  dlog(`Context: ${err.message}`);
@@ -1756,7 +1916,12 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1756
1916
  if (!d)
1757
1917
  return;
1758
1918
  try {
1759
- const totalTokensM = ((d.totalInput + d.totalCacheRead + d.totalCacheCreate + d.totalOutput) / 1000000).toFixed(2);
1919
+ // Show unique content tokens: output (model-generated) + cache_create (new content cached)
1920
+ // + uncached input. Excludes cache_read (same prefix re-billed each turn — NOT unique content).
1921
+ // Previously showed cumulative API total (input+read+create+output) which inflated by ~10-20x
1922
+ // because the same prefix was counted on every tool round-trip.
1923
+ const uniqueTokens = d.totalOutput + d.totalCacheCreate + d.totalInput;
1924
+ const totalTokensM = (uniqueTokens / 1000000).toFixed(2);
1760
1925
  const totalSavings = d.turns.reduce((s, t) => s + t.savings, 0);
1761
1926
  // Model routing breakdown (uses routedModel for actual model counts)
1762
1927
  const opusCount = d.turns.filter(t => t.routedModel.includes('opus')).length;
@@ -1781,8 +1946,8 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1781
1946
  ` R[A:${d.replayAppliedCount} SZ:${d.replaySkippedSizeCount} ST:${d.replaySkipStoreCount}]` +
1782
1947
  savingsStr +
1783
1948
  (inTmux
1784
- ? ` {gray-fg}Ctrl+Q quit F7 redraw{/gray-fg}`
1785
- : ` {gray-fg}? help q quit r refresh F7 redraw{/gray-fg}`));
1949
+ ? ` {gray-fg}Ctrl+Q quit Ctrl+F7/F7 redraw{/gray-fg}`
1950
+ : ` {gray-fg}? help q quit r refresh Ctrl+F7/F7 redraw{/gray-fg}`));
1786
1951
  }
1787
1952
  catch (err) {
1788
1953
  dlog(`Footer: ${err.message}`);
@@ -2361,7 +2526,18 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
2361
2526
  // Full dashboard redraw used by F7 and manual refresh recovery paths.
2362
2527
  let hardRedrawInFlight = false;
2363
2528
  let lastHardRedrawAt = 0;
2529
+ let resizeRecoveryTimer = null;
2364
2530
  const HARD_REDRAW_DEBOUNCE_MS = 200;
2531
+ function scheduleResizeRecovery(source) {
2532
+ if (!needsTmuxResizeRecovery)
2533
+ return;
2534
+ if (resizeRecoveryTimer)
2535
+ clearTimeout(resizeRecoveryTimer);
2536
+ resizeRecoveryTimer = setTimeout(() => {
2537
+ resizeRecoveryTimer = null;
2538
+ void hardRedraw(`${source}-resize-recovery`);
2539
+ }, 120);
2540
+ }
2365
2541
  async function hardRedraw(source) {
2366
2542
  const now = Date.now();
2367
2543
  if (hardRedrawInFlight)
@@ -2408,13 +2584,18 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
2408
2584
  // F7 escape-sequence fallback for terminals/tmux states where blessed key names
2409
2585
  // are not emitted reliably. Supports plain and modifier variants.
2410
2586
  const F7_ESCAPE_RE = /\x1b\[18(?:;\d+)?~/;
2587
+ const CTRL_F7_ESCAPE_RE = /\x1b\[18;5~/;
2411
2588
  let detachRawF7Listener = null;
2589
+ let rawKeyBuffer = '';
2412
2590
  if (ttyInput && typeof ttyInput.on === 'function') {
2413
2591
  const onRawInput = (chunk) => {
2414
2592
  const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
2415
- if (!F7_ESCAPE_RE.test(text))
2593
+ rawKeyBuffer = `${rawKeyBuffer}${text}`.slice(-64);
2594
+ const match = rawKeyBuffer.match(F7_ESCAPE_RE);
2595
+ if (!match)
2416
2596
  return;
2417
- void hardRedraw('f7-escape');
2597
+ rawKeyBuffer = '';
2598
+ void hardRedraw(CTRL_F7_ESCAPE_RE.test(match[0]) ? 'ctrl-f7-escape' : 'f7-escape');
2418
2599
  };
2419
2600
  ttyInput.on('data', onRawInput);
2420
2601
  detachRawF7Listener = () => {
@@ -2427,6 +2608,43 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
2427
2608
  catch { }
2428
2609
  };
2429
2610
  }
2611
+ let detachSigwinchListener = null;
2612
+ if (process.platform !== 'win32') {
2613
+ const onSigwinch = () => {
2614
+ try {
2615
+ if (!syncProgramSizeFromTTY('sigwinch'))
2616
+ return;
2617
+ lastLayoutW = 0;
2618
+ lastLayoutH = 0;
2619
+ ensureLayoutSynced('sigwinch');
2620
+ if (lastData) {
2621
+ updateDashboard();
2622
+ }
2623
+ else {
2624
+ if (lastChartSeries) {
2625
+ try {
2626
+ tokenChart.setData(lastChartSeries);
2627
+ }
2628
+ catch { }
2629
+ }
2630
+ try {
2631
+ screen.render();
2632
+ }
2633
+ catch { }
2634
+ }
2635
+ }
2636
+ catch (err) {
2637
+ dlog(`SIGWINCH: ${err.message}`);
2638
+ }
2639
+ };
2640
+ process.on('SIGWINCH', onSigwinch);
2641
+ detachSigwinchListener = () => {
2642
+ try {
2643
+ process.off('SIGWINCH', onSigwinch);
2644
+ }
2645
+ catch { }
2646
+ };
2647
+ }
2430
2648
  // ── Handle terminal resize ──
2431
2649
  // Recalculate all widget positions from new screen.height
2432
2650
  screen.on('resize', () => {
@@ -2438,7 +2656,7 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
2438
2656
  catch { }
2439
2657
  lastLayoutW = 0;
2440
2658
  lastLayoutH = 0;
2441
- ensureLayoutSynced();
2659
+ ensureLayoutSynced('screen-resize');
2442
2660
  if (lastData) {
2443
2661
  updateDashboard();
2444
2662
  }
@@ -2463,6 +2681,9 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
2463
2681
  // ══════════════════════════════════════════════════════════════════════════
2464
2682
  function shutdownDashboard() {
2465
2683
  detachRawF7Listener?.();
2684
+ detachSigwinchListener?.();
2685
+ if (resizeRecoveryTimer)
2686
+ clearTimeout(resizeRecoveryTimer);
2466
2687
  clearInterval(pollInterval);
2467
2688
  clearInterval(windowPollInterval);
2468
2689
  clearInterval(tickInterval);
@@ -2477,8 +2698,8 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
2477
2698
  // F7 — Hard refresh / redraw (works in both tmux and standalone mode)
2478
2699
  // Forces a full screen realloc + layout recalculation to fix corruption
2479
2700
  // from rapid terminal resizing. Session data is preserved.
2480
- screen.key(['f7', 'F7'], () => {
2481
- void hardRedraw('f7-key');
2701
+ screen.key(['f7', 'F7', 'C-f7'], (_ch, key) => {
2702
+ void hardRedraw(key?.ctrl ? 'ctrl-f7-key' : 'f7-key');
2482
2703
  });
2483
2704
  if (!inTmux) {
2484
2705
  screen.key(['q'], () => {
@@ -2539,6 +2760,7 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
2539
2760
  '\n' +
2540
2761
  '{bold}Controls{/bold}\n' +
2541
2762
  ' r Refresh now\n' +
2763
+ ' Ctrl+F7 Hard redraw (mac fallback)\n' +
2542
2764
  ' F7 Hard redraw (fixes corruption)\n' +
2543
2765
  ' q/Ctrl+Q Quit\n' +
2544
2766
  '\n' +