@ekkos/cli 1.4.2 → 1.5.0

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,18 +585,18 @@ 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
602
  totalTokens: totalInput + totalCacheRead + totalCacheCreate + totalOutput,
@@ -554,22 +608,62 @@ function parseGeminiSessionFile(sessionPath, sessionName, launchMetadata) {
554
608
  maxContextPct,
555
609
  currentContextPct,
556
610
  currentContextTokens: lastTurn ? lastTurn.input + lastTurn.cacheRead + lastTurn.cacheCreate : 0,
557
- modelContextSize: lastTurn
558
- ? lastTurn.modelContextSize
559
- : resolveDashboardContextSize(model, fallbackWindow, launchMetadata, launchMetadata?.claudeContextSize),
611
+ modelContextSize: lastTurn ? lastTurn.modelContextSize : params.fallbackModelContextSize,
560
612
  cacheHitRate,
561
- replayAppliedCount: 0,
562
- replaySkippedSizeCount: 0,
563
- replaySkipStoreCount: 0,
564
- startedAt,
613
+ replayAppliedCount: params.replayAppliedCount || 0,
614
+ replaySkippedSizeCount: params.replaySkippedSizeCount || 0,
615
+ replaySkipStoreCount: params.replaySkipStoreCount || 0,
616
+ startedAt: params.startedAt,
565
617
  duration,
566
618
  turns,
567
619
  };
568
620
  }
569
- function tokenSafePercent(numerator, denominator) {
570
- if (!Number.isFinite(denominator) || denominator <= 0)
571
- return 0;
572
- return (numerator / denominator) * 100;
621
+ function reconcileDashboardData(previous, next) {
622
+ if (!previous || previous.sessionName !== next.sessionName || previous.turns.length === 0) {
623
+ return next;
624
+ }
625
+ const mergedTurns = next.turns.map(turn => ({ ...turn }));
626
+ const previousTurns = previous.turns;
627
+ if (mergedTurns.length === 0) {
628
+ return previous;
629
+ }
630
+ const previousLast = previousTurns[previousTurns.length - 1];
631
+ const nextLast = mergedTurns[mergedTurns.length - 1];
632
+ const keepLastMutable = turnsMatch(previousLast, nextLast);
633
+ const freezeCount = Math.min(previousTurns.length - (keepLastMutable ? 1 : 0), mergedTurns.length);
634
+ for (let index = 0; index < freezeCount; index++) {
635
+ if (!turnsMatch(previousTurns[index], mergedTurns[index]))
636
+ continue;
637
+ mergedTurns[index] = pickMoreCompleteTurn(previousTurns[index], mergedTurns[index]);
638
+ }
639
+ if (keepLastMutable) {
640
+ const tailIndex = previousTurns.length - 1;
641
+ if (tailIndex < mergedTurns.length && turnsMatch(previousTurns[tailIndex], mergedTurns[tailIndex])) {
642
+ mergedTurns[tailIndex] = pickMoreCompleteTurn(previousTurns[tailIndex], mergedTurns[tailIndex]);
643
+ }
644
+ }
645
+ if (previousTurns.length > mergedTurns.length) {
646
+ const prefixMatches = mergedTurns.every((turn, index) => turnsMatch(previousTurns[index], turn));
647
+ if (prefixMatches) {
648
+ for (let index = mergedTurns.length; index < previousTurns.length; index++) {
649
+ mergedTurns.push({ ...previousTurns[index] });
650
+ }
651
+ }
652
+ }
653
+ const cacheHitMode = mergedTurns.some(turn => Boolean(turn.msgId))
654
+ ? 'read-vs-write'
655
+ : 'read-vs-total-input';
656
+ return buildDashboardData({
657
+ sessionName: next.sessionName,
658
+ model: next.model,
659
+ startedAt: next.startedAt || previous.startedAt,
660
+ turns: mergedTurns,
661
+ replayAppliedCount: Math.max(previous.replayAppliedCount, next.replayAppliedCount),
662
+ replaySkippedSizeCount: Math.max(previous.replaySkippedSizeCount, next.replaySkippedSizeCount),
663
+ replaySkipStoreCount: Math.max(previous.replaySkipStoreCount, next.replaySkipStoreCount),
664
+ fallbackModelContextSize: next.modelContextSize || previous.modelContextSize,
665
+ cacheHitMode,
666
+ });
573
667
  }
574
668
  function readJsonFile(filePath) {
575
669
  try {
@@ -661,9 +755,9 @@ function formatLaunchWindowLabel(metadata) {
661
755
  ? metadata.claudeContextSize
662
756
  : undefined;
663
757
  if (window === '200k')
664
- return `Window ${fmtK(exactSize || 200000)} · out ${fmtK(metadata?.claudeMaxOutputTokens || 32768)}`;
758
+ return `Window ${fmtK(exactSize || 200000)} · out ${fmtK(metadata?.claudeMaxOutputTokens || MAX_OUTPUT_200K_OPUS_SONNET)}`;
665
759
  if (window === '1m')
666
- return `Window ${fmtK(exactSize || 1000000)} · out ${fmtK(metadata?.claudeMaxOutputTokens || 128000)}`;
760
+ return `Window ${fmtK(exactSize || 1000000)} · out ${fmtK(metadata?.claudeMaxOutputTokens || MAX_OUTPUT_1M_MODELS)}`;
667
761
  if (typeof metadata?.claudeContextSize === 'number' && metadata.claudeContextSize > 0) {
668
762
  return `Window ${fmtK(metadata.claudeContextSize)}`;
669
763
  }
@@ -1034,6 +1128,7 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1034
1128
  const blessed = require('blessed');
1035
1129
  const contrib = require('blessed-contrib');
1036
1130
  const inTmux = process.env.TMUX !== undefined;
1131
+ const needsTmuxResizeRecovery = inTmux && process.platform === 'darwin';
1037
1132
  // ══════════════════════════════════════════════════════════════════════════
1038
1133
  // TMUX SPLIT PANE ISOLATION
1039
1134
  // When dashboard runs in a separate tmux pane from `ekkos run`, blessed must
@@ -1072,13 +1167,52 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1072
1167
  mouse: false, // Disable ALL mouse capture (allows terminal text selection)
1073
1168
  grabKeys: false, // Don't grab keyboard input from other panes
1074
1169
  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
1170
+ ignoreLocked: ['C-c', 'C-q', 'f7', 'C-f7'], // Capture quit + redraw keys even when widgets are passive
1076
1171
  input: ttyInput, // Use /dev/tty for input (isolated from stdout pipe)
1077
1172
  output: ttyOutput, // Use /dev/tty for output (isolated from stdout pipe)
1078
1173
  forceUnicode: true, // Better text rendering
1079
1174
  terminal: 'xterm-256color',
1080
1175
  resizeTimeout: 300, // Debounce resize events
1081
1176
  });
1177
+ function readTerminalSize() {
1178
+ const candidates = [ttyOutput, screen.program?.output, process.stdout];
1179
+ for (const candidate of candidates) {
1180
+ if (!candidate)
1181
+ continue;
1182
+ try {
1183
+ if (typeof candidate.getWindowSize === 'function') {
1184
+ const size = candidate.getWindowSize();
1185
+ if (Array.isArray(size) && size.length >= 2) {
1186
+ const [cols, rows] = size;
1187
+ if (cols > 0 && rows > 0)
1188
+ return { cols, rows };
1189
+ }
1190
+ }
1191
+ }
1192
+ catch { }
1193
+ const cols = Number(candidate.columns);
1194
+ const rows = Number(candidate.rows);
1195
+ if (cols > 0 && rows > 0)
1196
+ return { cols, rows };
1197
+ }
1198
+ return null;
1199
+ }
1200
+ function syncProgramSizeFromTTY(source) {
1201
+ const program = screen.program;
1202
+ if (!program)
1203
+ return false;
1204
+ const size = readTerminalSize();
1205
+ if (!size)
1206
+ return false;
1207
+ const cols = Math.max(20, size.cols);
1208
+ const rows = Math.max(10, size.rows);
1209
+ if (program.cols === cols && program.rows === rows)
1210
+ return false;
1211
+ dlog(`TTY geometry sync (${source}): ${program.cols}x${program.rows} -> ${cols}x${rows}`);
1212
+ program.cols = cols;
1213
+ program.rows = rows;
1214
+ return true;
1215
+ }
1082
1216
  // ══════════════════════════════════════════════════════════════════════════
1083
1217
  // DISABLE TERMINAL CONTROL SEQUENCE HIJACKING (TMUX ONLY)
1084
1218
  // When running in a tmux split pane alongside Claude Code, prevent blessed
@@ -1115,7 +1249,7 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1115
1249
  // No percentages = no gaps.
1116
1250
  //
1117
1251
  // header: 3 rows (session stats + animated logo)
1118
- // context: 5 rows (progress bar + cost breakdown + cache stats)
1252
+ // context: 5-6 rows (progress bar + runtime stats + cache/cost summary)
1119
1253
  // chart: ~25-34% of remaining (token usage graph)
1120
1254
  // table: ~66-75% of remaining (turn-by-turn breakdown)
1121
1255
  // usage: 3 rows (Anthropic rate limit window)
@@ -1158,11 +1292,19 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1158
1292
  }
1159
1293
  const W = '100%';
1160
1294
  const HEADER_H = 3;
1161
- const CONTEXT_H = 5;
1162
1295
  const USAGE_H = 4;
1163
1296
  const FOOTER_H = 3;
1164
1297
  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
1298
+ function resolveContextHeight(height) {
1299
+ return height >= 36 ? 6 : 5;
1300
+ }
1301
+ function resolveChartMinHeight(height) {
1302
+ if (height >= 28)
1303
+ return 6;
1304
+ if (height >= 26)
1305
+ return 5;
1306
+ return 4;
1307
+ }
1166
1308
  function resolveChartRatio(height) {
1167
1309
  if (height >= 62)
1168
1310
  return 0.25;
@@ -1174,9 +1316,14 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1174
1316
  }
1175
1317
  function calcLayout() {
1176
1318
  const H = Math.max(24, screen.height || 24);
1319
+ const CONTEXT_H = resolveContextHeight(H);
1320
+ const FIXED_H = HEADER_H + CONTEXT_H + USAGE_H + FOOTER_H;
1177
1321
  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);
1322
+ const tableMin = 5;
1323
+ const chartMin = resolveChartMinHeight(H);
1324
+ const chartTarget = Math.floor(remaining * resolveChartRatio(H));
1325
+ const chartH = Math.max(chartMin, Math.min(chartTarget, remaining - tableMin));
1326
+ const tableH = Math.max(tableMin, remaining - chartH);
1180
1327
  return {
1181
1328
  header: { top: 0, height: HEADER_H },
1182
1329
  context: { top: HEADER_H, height: CONTEXT_H },
@@ -1378,13 +1525,17 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1378
1525
  // Track geometry so we can re-anchor widgets even if tmux resize events are flaky
1379
1526
  let lastLayoutW = screen.width || 0;
1380
1527
  let lastLayoutH = screen.height || 0;
1381
- function ensureLayoutSynced() {
1528
+ function ensureLayoutSynced(source = 'layout') {
1529
+ const geometryChanged = syncProgramSizeFromTTY(source);
1382
1530
  const w = screen.width || 0;
1383
1531
  const h = screen.height || 0;
1384
1532
  if (w === lastLayoutW && h === lastLayoutH)
1385
1533
  return;
1386
1534
  lastLayoutW = w;
1387
1535
  lastLayoutH = h;
1536
+ if (geometryChanged && needsTmuxResizeRecovery) {
1537
+ scheduleResizeRecovery(source);
1538
+ }
1388
1539
  try {
1389
1540
  screen.realloc?.();
1390
1541
  }
@@ -1478,11 +1629,14 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1478
1629
  try {
1479
1630
  const launchModelLabel = formatLaunchModelLabel(launchMetadata);
1480
1631
  const launchWindowLabel = formatLaunchWindowLabel(launchMetadata);
1481
- const compactSelected = formatCompactLaunchModel(launchMetadata) || 'Awaiting profile';
1482
1632
  const compactLane = formatContextLaneCompact(launchMetadata?.claudeContextSize, launchMetadata) || 'Awaiting lane';
1633
+ const extendedContext = (layout.context.height || 0) >= 6;
1483
1634
  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.'}`);
1635
+ ` ${buildPreTurnRuntimeLeadSignal(launchMetadata)} {gray-fg}│{/gray-fg} ${buildRuntimeSignal('ACT', 'magenta', 'standby')} {gray-fg}│{/gray-fg} ${buildRuntimeSignal('LANE', 'yellow', compactLane)}\n` +
1636
+ ` ${[launchModelLabel, launchWindowLabel].filter(Boolean).join(' ') || 'Token and cost metrics appear after the first assistant response.'}` +
1637
+ (extendedContext
1638
+ ? `\n {gray-fg}Cache, peak context, and routed cost appear after the first assistant response.{/gray-fg}`
1639
+ : ''));
1486
1640
  turnBox.setContent(`{bold}Turns{/bold}\n` +
1487
1641
  `{gray-fg}—{/gray-fg}`);
1488
1642
  const timerStr = sessionStartMs
@@ -1491,8 +1645,8 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1491
1645
  footerBox.setLabel(` ${sessionName} `);
1492
1646
  footerBox.setContent(` ${timerStr}{green-fg}Ready{/green-fg}` +
1493
1647
  (inTmux
1494
- ? ` {gray-fg}Ctrl+Q quit F7 redraw{/gray-fg}`
1495
- : ` {gray-fg}? help q quit r refresh F7 redraw{/gray-fg}`));
1648
+ ? ` {gray-fg}Ctrl+Q quit Ctrl+F7/F7 redraw{/gray-fg}`
1649
+ : ` {gray-fg}? help q quit r refresh Ctrl+F7/F7 redraw{/gray-fg}`));
1496
1650
  }
1497
1651
  catch (err) {
1498
1652
  dlog(`Pre-turn render: ${err.message}`);
@@ -1562,7 +1716,7 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1562
1716
  launchMetadata = readSessionLaunchMetadata(sessionName) || launchMetadata;
1563
1717
  provider = inferDashboardProvider(initialProvider, launchMetadata, jsonlPath);
1564
1718
  renderRuntimeMascot();
1565
- data = parseTranscriptFile(jsonlPath, provider, sessionName, launchMetadata);
1719
+ data = reconcileDashboardData(lastData && lastData.sessionName === sessionName ? lastData : null, parseTranscriptFile(jsonlPath, provider, sessionName, launchMetadata));
1566
1720
  lastData = data;
1567
1721
  if (!sessionStartMs && data.startedAt) {
1568
1722
  sessionStartMs = new Date(data.startedAt).getTime();
@@ -1610,19 +1764,24 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1610
1764
  // Cache stats
1611
1765
  const hitColor = data.cacheHitRate >= 80 ? 'green' : data.cacheHitRate >= 50 ? 'yellow' : 'red';
1612
1766
  const cappedMax = Math.min(data.maxContextPct, 100);
1767
+ const extendedContext = (layout.context.height || 0) >= 6;
1613
1768
  contextBox.setContent(` ${bar}\n` +
1614
1769
  ` ${buildRuntimeSignal('SEL', 'cyan', selectedProfile)} {gray-fg}│{/gray-fg} ${buildRuntimeSignal('ACT', 'magenta', activeModel)} {gray-fg}│{/gray-fg} ${buildRuntimeSignal('LANE', 'yellow', laneLabel)}\n` +
1615
1770
  ` {${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
1771
  ` {${hitColor}-fg}${data.cacheHitRate.toFixed(0)}% cache{/${hitColor}-fg}` +
1621
1772
  ` peak:${cappedMax.toFixed(0)}%` +
1622
1773
  ` avg:$${data.avgCostPerTurn.toFixed(2)}/t` +
1623
- ` replay A:${data.replayAppliedCount}` +
1624
- ` SZ:${data.replaySkippedSizeCount}` +
1625
- ` ST:${data.replaySkipStoreCount}`);
1774
+ (extendedContext
1775
+ ? `\n {white-fg}In{/white-fg} $${breakdown.input.toFixed(2)}` +
1776
+ ` {green-fg}Read{/green-fg} $${breakdown.read.toFixed(2)}` +
1777
+ ` {yellow-fg}Write{/yellow-fg} $${breakdown.write.toFixed(2)}` +
1778
+ ` {cyan-fg}Out{/cyan-fg} $${breakdown.output.toFixed(2)}` +
1779
+ ` replay A:${data.replayAppliedCount}` +
1780
+ ` SZ:${data.replaySkippedSizeCount}` +
1781
+ ` ST:${data.replaySkipStoreCount}`
1782
+ : ` {white-fg}In{/white-fg} $${breakdown.input.toFixed(2)}` +
1783
+ ` {cyan-fg}Out{/cyan-fg} $${breakdown.output.toFixed(2)}` +
1784
+ ` R:${data.replayAppliedCount}/${data.replaySkippedSizeCount}/${data.replaySkipStoreCount}`));
1626
1785
  }
1627
1786
  catch (err) {
1628
1787
  dlog(`Context: ${err.message}`);
@@ -1781,8 +1940,8 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1781
1940
  ` R[A:${d.replayAppliedCount} SZ:${d.replaySkippedSizeCount} ST:${d.replaySkipStoreCount}]` +
1782
1941
  savingsStr +
1783
1942
  (inTmux
1784
- ? ` {gray-fg}Ctrl+Q quit F7 redraw{/gray-fg}`
1785
- : ` {gray-fg}? help q quit r refresh F7 redraw{/gray-fg}`));
1943
+ ? ` {gray-fg}Ctrl+Q quit Ctrl+F7/F7 redraw{/gray-fg}`
1944
+ : ` {gray-fg}? help q quit r refresh Ctrl+F7/F7 redraw{/gray-fg}`));
1786
1945
  }
1787
1946
  catch (err) {
1788
1947
  dlog(`Footer: ${err.message}`);
@@ -2361,7 +2520,18 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
2361
2520
  // Full dashboard redraw used by F7 and manual refresh recovery paths.
2362
2521
  let hardRedrawInFlight = false;
2363
2522
  let lastHardRedrawAt = 0;
2523
+ let resizeRecoveryTimer = null;
2364
2524
  const HARD_REDRAW_DEBOUNCE_MS = 200;
2525
+ function scheduleResizeRecovery(source) {
2526
+ if (!needsTmuxResizeRecovery)
2527
+ return;
2528
+ if (resizeRecoveryTimer)
2529
+ clearTimeout(resizeRecoveryTimer);
2530
+ resizeRecoveryTimer = setTimeout(() => {
2531
+ resizeRecoveryTimer = null;
2532
+ void hardRedraw(`${source}-resize-recovery`);
2533
+ }, 120);
2534
+ }
2365
2535
  async function hardRedraw(source) {
2366
2536
  const now = Date.now();
2367
2537
  if (hardRedrawInFlight)
@@ -2408,13 +2578,18 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
2408
2578
  // F7 escape-sequence fallback for terminals/tmux states where blessed key names
2409
2579
  // are not emitted reliably. Supports plain and modifier variants.
2410
2580
  const F7_ESCAPE_RE = /\x1b\[18(?:;\d+)?~/;
2581
+ const CTRL_F7_ESCAPE_RE = /\x1b\[18;5~/;
2411
2582
  let detachRawF7Listener = null;
2583
+ let rawKeyBuffer = '';
2412
2584
  if (ttyInput && typeof ttyInput.on === 'function') {
2413
2585
  const onRawInput = (chunk) => {
2414
2586
  const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
2415
- if (!F7_ESCAPE_RE.test(text))
2587
+ rawKeyBuffer = `${rawKeyBuffer}${text}`.slice(-64);
2588
+ const match = rawKeyBuffer.match(F7_ESCAPE_RE);
2589
+ if (!match)
2416
2590
  return;
2417
- void hardRedraw('f7-escape');
2591
+ rawKeyBuffer = '';
2592
+ void hardRedraw(CTRL_F7_ESCAPE_RE.test(match[0]) ? 'ctrl-f7-escape' : 'f7-escape');
2418
2593
  };
2419
2594
  ttyInput.on('data', onRawInput);
2420
2595
  detachRawF7Listener = () => {
@@ -2427,6 +2602,43 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
2427
2602
  catch { }
2428
2603
  };
2429
2604
  }
2605
+ let detachSigwinchListener = null;
2606
+ if (process.platform !== 'win32') {
2607
+ const onSigwinch = () => {
2608
+ try {
2609
+ if (!syncProgramSizeFromTTY('sigwinch'))
2610
+ return;
2611
+ lastLayoutW = 0;
2612
+ lastLayoutH = 0;
2613
+ ensureLayoutSynced('sigwinch');
2614
+ if (lastData) {
2615
+ updateDashboard();
2616
+ }
2617
+ else {
2618
+ if (lastChartSeries) {
2619
+ try {
2620
+ tokenChart.setData(lastChartSeries);
2621
+ }
2622
+ catch { }
2623
+ }
2624
+ try {
2625
+ screen.render();
2626
+ }
2627
+ catch { }
2628
+ }
2629
+ }
2630
+ catch (err) {
2631
+ dlog(`SIGWINCH: ${err.message}`);
2632
+ }
2633
+ };
2634
+ process.on('SIGWINCH', onSigwinch);
2635
+ detachSigwinchListener = () => {
2636
+ try {
2637
+ process.off('SIGWINCH', onSigwinch);
2638
+ }
2639
+ catch { }
2640
+ };
2641
+ }
2430
2642
  // ── Handle terminal resize ──
2431
2643
  // Recalculate all widget positions from new screen.height
2432
2644
  screen.on('resize', () => {
@@ -2438,7 +2650,7 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
2438
2650
  catch { }
2439
2651
  lastLayoutW = 0;
2440
2652
  lastLayoutH = 0;
2441
- ensureLayoutSynced();
2653
+ ensureLayoutSynced('screen-resize');
2442
2654
  if (lastData) {
2443
2655
  updateDashboard();
2444
2656
  }
@@ -2463,6 +2675,9 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
2463
2675
  // ══════════════════════════════════════════════════════════════════════════
2464
2676
  function shutdownDashboard() {
2465
2677
  detachRawF7Listener?.();
2678
+ detachSigwinchListener?.();
2679
+ if (resizeRecoveryTimer)
2680
+ clearTimeout(resizeRecoveryTimer);
2466
2681
  clearInterval(pollInterval);
2467
2682
  clearInterval(windowPollInterval);
2468
2683
  clearInterval(tickInterval);
@@ -2477,8 +2692,8 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
2477
2692
  // F7 — Hard refresh / redraw (works in both tmux and standalone mode)
2478
2693
  // Forces a full screen realloc + layout recalculation to fix corruption
2479
2694
  // from rapid terminal resizing. Session data is preserved.
2480
- screen.key(['f7', 'F7'], () => {
2481
- void hardRedraw('f7-key');
2695
+ screen.key(['f7', 'F7', 'C-f7'], (_ch, key) => {
2696
+ void hardRedraw(key?.ctrl ? 'ctrl-f7-key' : 'f7-key');
2482
2697
  });
2483
2698
  if (!inTmux) {
2484
2699
  screen.key(['q'], () => {
@@ -2539,6 +2754,7 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
2539
2754
  '\n' +
2540
2755
  '{bold}Controls{/bold}\n' +
2541
2756
  ' r Refresh now\n' +
2757
+ ' Ctrl+F7 Hard redraw (mac fallback)\n' +
2542
2758
  ' F7 Hard redraw (fixes corruption)\n' +
2543
2759
  ' q/Ctrl+Q Quit\n' +
2544
2760
  '\n' +