@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.
- package/dist/commands/dashboard.js +309 -93
- package/dist/commands/init.js +59 -4
- package/dist/commands/living-docs.d.ts +1 -0
- package/dist/commands/living-docs.js +5 -2
- package/dist/commands/logout.d.ts +9 -0
- package/dist/commands/logout.js +104 -0
- package/dist/commands/run.js +56 -23
- package/dist/commands/workspaces.d.ts +4 -0
- package/dist/commands/workspaces.js +153 -0
- package/dist/index.js +82 -83
- package/dist/local/diff-engine.d.ts +19 -0
- package/dist/local/diff-engine.js +81 -0
- package/dist/local/entity-extractor.d.ts +18 -0
- package/dist/local/entity-extractor.js +67 -0
- package/dist/local/git-utils.d.ts +37 -0
- package/dist/local/git-utils.js +169 -0
- package/dist/local/living-docs-manager.d.ts +6 -0
- package/dist/local/living-docs-manager.js +180 -139
- package/dist/utils/notifier.d.ts +15 -0
- package/dist/utils/notifier.js +40 -0
- package/dist/utils/paths.d.ts +4 -0
- package/dist/utils/paths.js +7 -0
- package/dist/utils/state.d.ts +3 -0
- package/dist/utils/stdin-relay.d.ts +37 -0
- package/dist/utils/stdin-relay.js +155 -0
- package/package.json +4 -1
- package/templates/CLAUDE.md +3 -1
- package/dist/commands/setup.d.ts +0 -6
- package/dist/commands/setup.js +0 -389
|
@@ -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 ?
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
454
|
-
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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 =
|
|
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
|
|
570
|
-
if (!
|
|
571
|
-
return
|
|
572
|
-
|
|
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 ||
|
|
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 ||
|
|
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
|
|
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 +
|
|
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
|
-
|
|
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
|
|
1179
|
-
const
|
|
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
|
-
` ${
|
|
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
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
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
|
-
|
|
2587
|
+
rawKeyBuffer = `${rawKeyBuffer}${text}`.slice(-64);
|
|
2588
|
+
const match = rawKeyBuffer.match(F7_ESCAPE_RE);
|
|
2589
|
+
if (!match)
|
|
2416
2590
|
return;
|
|
2417
|
-
|
|
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' +
|