@ekkos/cli 0.3.3 → 1.0.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.
- package/README.md +57 -0
- package/dist/agent/daemon.d.ts +27 -0
- package/dist/agent/daemon.js +254 -29
- package/dist/agent/health-check.d.ts +35 -0
- package/dist/agent/health-check.js +243 -0
- package/dist/agent/pty-runner.d.ts +1 -0
- package/dist/agent/pty-runner.js +6 -1
- package/dist/capture/transcript-repair.d.ts +1 -0
- package/dist/capture/transcript-repair.js +12 -1
- package/dist/commands/agent.d.ts +6 -0
- package/dist/commands/agent.js +244 -0
- package/dist/commands/dashboard.d.ts +25 -0
- package/dist/commands/dashboard.js +1175 -0
- package/dist/commands/run.d.ts +3 -0
- package/dist/commands/run.js +503 -350
- package/dist/commands/setup-remote.js +146 -37
- package/dist/commands/swarm-dashboard.d.ts +20 -0
- package/dist/commands/swarm-dashboard.js +735 -0
- package/dist/commands/swarm-setup.d.ts +10 -0
- package/dist/commands/swarm-setup.js +956 -0
- package/dist/commands/swarm.d.ts +46 -0
- package/dist/commands/swarm.js +441 -0
- package/dist/commands/test-claude.d.ts +16 -0
- package/dist/commands/test-claude.js +156 -0
- package/dist/commands/usage/blocks.d.ts +8 -0
- package/dist/commands/usage/blocks.js +60 -0
- package/dist/commands/usage/daily.d.ts +9 -0
- package/dist/commands/usage/daily.js +96 -0
- package/dist/commands/usage/dashboard.d.ts +8 -0
- package/dist/commands/usage/dashboard.js +104 -0
- package/dist/commands/usage/formatters.d.ts +41 -0
- package/dist/commands/usage/formatters.js +147 -0
- package/dist/commands/usage/index.d.ts +13 -0
- package/dist/commands/usage/index.js +87 -0
- package/dist/commands/usage/monthly.d.ts +8 -0
- package/dist/commands/usage/monthly.js +66 -0
- package/dist/commands/usage/session.d.ts +11 -0
- package/dist/commands/usage/session.js +193 -0
- package/dist/commands/usage/weekly.d.ts +9 -0
- package/dist/commands/usage/weekly.js +61 -0
- package/dist/deploy/instructions.d.ts +5 -2
- package/dist/deploy/instructions.js +11 -8
- package/dist/index.js +256 -20
- package/dist/lib/tmux-scrollbar.d.ts +14 -0
- package/dist/lib/tmux-scrollbar.js +296 -0
- package/dist/lib/usage-parser.d.ts +95 -5
- package/dist/lib/usage-parser.js +416 -71
- package/dist/utils/log-rotate.d.ts +18 -0
- package/dist/utils/log-rotate.js +74 -0
- package/dist/utils/platform.d.ts +2 -0
- package/dist/utils/platform.js +3 -1
- package/dist/utils/session-binding.d.ts +5 -0
- package/dist/utils/session-binding.js +46 -0
- package/dist/utils/state.js +4 -0
- package/dist/utils/verify-remote-terminal.d.ts +10 -0
- package/dist/utils/verify-remote-terminal.js +415 -0
- package/package.json +16 -11
- package/templates/CLAUDE.md +135 -23
- package/templates/cursor-hooks/after-agent-response.sh +0 -0
- package/templates/cursor-hooks/before-submit-prompt.sh +0 -0
- package/templates/cursor-hooks/stop.sh +0 -0
- package/templates/ekkos-manifest.json +5 -5
- package/templates/hooks/assistant-response.sh +0 -0
- package/templates/hooks/lib/contract.sh +43 -31
- package/templates/hooks/lib/count-tokens.cjs +86 -0
- package/templates/hooks/lib/ekkos-reminders.sh +98 -0
- package/templates/hooks/lib/state.sh +53 -1
- package/templates/hooks/session-start.sh +0 -0
- package/templates/hooks/stop.sh +150 -388
- package/templates/hooks/user-prompt-submit.sh +353 -443
- package/templates/plan-template.md +0 -0
- package/templates/spec-template.md +0 -0
- package/templates/windsurf-hooks/README.md +212 -0
- package/templates/windsurf-hooks/hooks.json +9 -2
- package/templates/windsurf-hooks/install.sh +148 -0
- package/templates/windsurf-hooks/lib/contract.sh +2 -0
- package/templates/windsurf-hooks/post-cascade-response.sh +251 -0
- package/templates/windsurf-hooks/pre-user-prompt.sh +435 -0
- package/templates/windsurf-skills/ekkos-memory/SKILL.md +219 -0
- package/LICENSE +0 -21
- package/templates/windsurf-hooks/before-submit-prompt.sh +0 -238
|
@@ -0,0 +1,1175 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* ekkos dashboard [session-name]
|
|
4
|
+
*
|
|
5
|
+
* Live TUI dashboard for monitoring Claude Code session usage in real-time.
|
|
6
|
+
* Uses blessed-contrib for rich terminal widgets (gauges, charts, tables).
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* ekkos dashboard <session-name> Watch specific session
|
|
10
|
+
* ekkos dashboard --latest Auto-detect latest active session
|
|
11
|
+
* ekkos dashboard --wait-for-new Wait for a brand-new session to appear
|
|
12
|
+
* ekkos dashboard Interactive session picker
|
|
13
|
+
*
|
|
14
|
+
* Text Selection:
|
|
15
|
+
* To select text separately from Claude Code, run the dashboard in a different
|
|
16
|
+
* terminal window/pane. This prevents the blessed screen from interfering with
|
|
17
|
+
* Claude Code's text selection. Use iTerm2 split panes or tmux windows.
|
|
18
|
+
*
|
|
19
|
+
* Scrolling:
|
|
20
|
+
* Up/Down arrows or j/k Scroll one line
|
|
21
|
+
* PageUp/PageDown or u/d Scroll one page
|
|
22
|
+
* Home/End or g/G Jump to top/bottom
|
|
23
|
+
* Mouse wheel Scroll with mouse
|
|
24
|
+
*/
|
|
25
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
26
|
+
if (k2 === undefined) k2 = k;
|
|
27
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
28
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
29
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
30
|
+
}
|
|
31
|
+
Object.defineProperty(o, k2, desc);
|
|
32
|
+
}) : (function(o, m, k, k2) {
|
|
33
|
+
if (k2 === undefined) k2 = k;
|
|
34
|
+
o[k2] = m[k];
|
|
35
|
+
}));
|
|
36
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
37
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
38
|
+
}) : function(o, v) {
|
|
39
|
+
o["default"] = v;
|
|
40
|
+
});
|
|
41
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
42
|
+
var ownKeys = function(o) {
|
|
43
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
44
|
+
var ar = [];
|
|
45
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
46
|
+
return ar;
|
|
47
|
+
};
|
|
48
|
+
return ownKeys(o);
|
|
49
|
+
};
|
|
50
|
+
return function (mod) {
|
|
51
|
+
if (mod && mod.__esModule) return mod;
|
|
52
|
+
var result = {};
|
|
53
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
54
|
+
__setModuleDefault(result, mod);
|
|
55
|
+
return result;
|
|
56
|
+
};
|
|
57
|
+
})();
|
|
58
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
59
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
60
|
+
};
|
|
61
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
62
|
+
exports.dashboardCommand = void 0;
|
|
63
|
+
const fs = __importStar(require("fs"));
|
|
64
|
+
const os = __importStar(require("os"));
|
|
65
|
+
const path = __importStar(require("path"));
|
|
66
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
67
|
+
const commander_1 = require("commander");
|
|
68
|
+
const usage_parser_js_1 = require("../lib/usage-parser.js");
|
|
69
|
+
const state_js_1 = require("../utils/state.js");
|
|
70
|
+
// ── Pricing ──
|
|
71
|
+
// Pricing per MTok from https://platform.claude.com/docs/en/about-claude/pricing
|
|
72
|
+
const MODEL_PRICING = {
|
|
73
|
+
'claude-opus-4-6': { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.50 },
|
|
74
|
+
'claude-opus-4-5-20250620': { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.50 },
|
|
75
|
+
'claude-sonnet-4-5-20250929': { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.30 },
|
|
76
|
+
'claude-sonnet-4-5-20250514': { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.30 },
|
|
77
|
+
'claude-haiku-4-5-20251001': { input: 0.80, output: 4, cacheWrite: 1.00, cacheRead: 0.08 },
|
|
78
|
+
};
|
|
79
|
+
function getModelPricing(modelId) {
|
|
80
|
+
if (MODEL_PRICING[modelId])
|
|
81
|
+
return MODEL_PRICING[modelId];
|
|
82
|
+
if (modelId.includes('opus'))
|
|
83
|
+
return MODEL_PRICING['claude-opus-4-6'];
|
|
84
|
+
if (modelId.includes('sonnet'))
|
|
85
|
+
return MODEL_PRICING['claude-sonnet-4-5-20250929'];
|
|
86
|
+
if (modelId.includes('haiku'))
|
|
87
|
+
return MODEL_PRICING['claude-haiku-4-5-20251001'];
|
|
88
|
+
return MODEL_PRICING['claude-sonnet-4-5-20250929'];
|
|
89
|
+
}
|
|
90
|
+
function calculateTurnCost(model, usage) {
|
|
91
|
+
const p = getModelPricing(model);
|
|
92
|
+
return ((usage.input_tokens / 1000000) * p.input +
|
|
93
|
+
(usage.output_tokens / 1000000) * p.output +
|
|
94
|
+
(usage.cache_creation_tokens / 1000000) * p.cacheWrite +
|
|
95
|
+
(usage.cache_read_tokens / 1000000) * p.cacheRead);
|
|
96
|
+
}
|
|
97
|
+
function getModelCtxSize(model) {
|
|
98
|
+
if (model.includes('opus'))
|
|
99
|
+
return 200000;
|
|
100
|
+
if (model.includes('haiku'))
|
|
101
|
+
return 200000;
|
|
102
|
+
if (model.includes('sonnet'))
|
|
103
|
+
return 200000;
|
|
104
|
+
return 200000; // Default Anthropic context
|
|
105
|
+
}
|
|
106
|
+
/** Model tag for dashboard display */
|
|
107
|
+
function modelTag(model) {
|
|
108
|
+
if (model.includes('opus'))
|
|
109
|
+
return 'Opus';
|
|
110
|
+
if (model.includes('sonnet'))
|
|
111
|
+
return 'Sonnet';
|
|
112
|
+
if (model.includes('haiku'))
|
|
113
|
+
return 'Haiku';
|
|
114
|
+
return '?';
|
|
115
|
+
}
|
|
116
|
+
function parseCacheHintValue(cacheHint, key) {
|
|
117
|
+
if (!cacheHint || typeof cacheHint !== 'string')
|
|
118
|
+
return null;
|
|
119
|
+
const parts = cacheHint.split(';');
|
|
120
|
+
for (const part of parts) {
|
|
121
|
+
const [k, ...rest] = part.split('=');
|
|
122
|
+
if (k?.trim() === key) {
|
|
123
|
+
const value = rest.join('=').trim();
|
|
124
|
+
return value || null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
function parseJsonlFile(jsonlPath, sessionName) {
|
|
130
|
+
const content = fs.readFileSync(jsonlPath, 'utf-8');
|
|
131
|
+
const lines = content.trim().split('\n');
|
|
132
|
+
const turnsByMsgId = new Map();
|
|
133
|
+
const msgIdOrder = [];
|
|
134
|
+
let startedAt = '';
|
|
135
|
+
let model = 'unknown';
|
|
136
|
+
const toolsByMessage = new Map();
|
|
137
|
+
for (const line of lines) {
|
|
138
|
+
try {
|
|
139
|
+
const entry = JSON.parse(line);
|
|
140
|
+
if (entry.type === 'assistant' && entry.message?.content) {
|
|
141
|
+
const msgId = entry.message.id;
|
|
142
|
+
for (const block of entry.message.content) {
|
|
143
|
+
if (block.type === 'tool_use' && block.name) {
|
|
144
|
+
if (!toolsByMessage.has(msgId))
|
|
145
|
+
toolsByMessage.set(msgId, new Set());
|
|
146
|
+
toolsByMessage.get(msgId).add(block.name);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (entry.type === 'assistant' && entry.message?.usage) {
|
|
151
|
+
const msgId = entry.message.id;
|
|
152
|
+
const isNew = msgId && !turnsByMsgId.has(msgId);
|
|
153
|
+
const usage = entry.message.usage;
|
|
154
|
+
model = entry.message.model || model;
|
|
155
|
+
// Smart routing: _ekkos_routed_model contains the actual model used
|
|
156
|
+
const routedModel = entry.message._ekkos_routed_model || entry.message.model || model;
|
|
157
|
+
const cacheHint = typeof entry.message._ekkos_cache_hint === 'string'
|
|
158
|
+
? entry.message._ekkos_cache_hint
|
|
159
|
+
: undefined;
|
|
160
|
+
const replayState = typeof entry.message._ekkos_replay_state === 'string'
|
|
161
|
+
? entry.message._ekkos_replay_state
|
|
162
|
+
: (parseCacheHintValue(cacheHint, 'replay') || 'unknown');
|
|
163
|
+
const replayStore = typeof entry.message._ekkos_replay_store === 'string'
|
|
164
|
+
? entry.message._ekkos_replay_store
|
|
165
|
+
: 'none';
|
|
166
|
+
const evictionState = typeof entry.message._ekkos_eviction_state === 'string'
|
|
167
|
+
? entry.message._ekkos_eviction_state
|
|
168
|
+
: (parseCacheHintValue(cacheHint, 'eviction') || 'unknown');
|
|
169
|
+
const inputTokens = usage.input_tokens || 0;
|
|
170
|
+
const outputTokens = usage.output_tokens || 0;
|
|
171
|
+
const cacheReadTokens = usage.cache_read_input_tokens || 0;
|
|
172
|
+
const cacheCreationTokens = usage.cache_creation_input_tokens || 0;
|
|
173
|
+
const contextTokens = inputTokens + cacheReadTokens + cacheCreationTokens;
|
|
174
|
+
const modelCtxSize = getModelCtxSize(model);
|
|
175
|
+
const contextPct = (contextTokens / modelCtxSize) * 100;
|
|
176
|
+
const ts = entry.timestamp || new Date().toISOString();
|
|
177
|
+
if (!startedAt)
|
|
178
|
+
startedAt = ts;
|
|
179
|
+
const usageData = {
|
|
180
|
+
input_tokens: inputTokens,
|
|
181
|
+
output_tokens: outputTokens,
|
|
182
|
+
cache_read_tokens: cacheReadTokens,
|
|
183
|
+
cache_creation_tokens: cacheCreationTokens,
|
|
184
|
+
};
|
|
185
|
+
// Cost at actual model pricing (Haiku if routed)
|
|
186
|
+
const turnCost = calculateTurnCost(routedModel, usageData);
|
|
187
|
+
// Cost if it had been Opus (for savings calculation)
|
|
188
|
+
const opusCost = routedModel !== model
|
|
189
|
+
? calculateTurnCost(model, usageData)
|
|
190
|
+
: turnCost;
|
|
191
|
+
const savings = opusCost - turnCost;
|
|
192
|
+
const msgTools = toolsByMessage.get(msgId);
|
|
193
|
+
const toolStr = msgTools && msgTools.size > 0
|
|
194
|
+
? Array.from(msgTools).map(t => t.replace(/^mcp__ekkos-memory__/, '').replace(/^ekkOS_/, '')).join(',')
|
|
195
|
+
: '-';
|
|
196
|
+
// Last entry wins — final JSONL entry for a message has the real output_tokens
|
|
197
|
+
const turnNum = isNew ? msgIdOrder.length + 1 : (turnsByMsgId.get(msgId).turn);
|
|
198
|
+
const turnData = {
|
|
199
|
+
turn: turnNum,
|
|
200
|
+
contextPct,
|
|
201
|
+
cacheRead: cacheReadTokens,
|
|
202
|
+
cacheCreate: cacheCreationTokens,
|
|
203
|
+
output: outputTokens,
|
|
204
|
+
cost: turnCost,
|
|
205
|
+
opusCost,
|
|
206
|
+
savings,
|
|
207
|
+
tools: toolStr,
|
|
208
|
+
model,
|
|
209
|
+
routedModel,
|
|
210
|
+
replayState,
|
|
211
|
+
replayStore,
|
|
212
|
+
evictionState,
|
|
213
|
+
timestamp: ts,
|
|
214
|
+
};
|
|
215
|
+
if (msgId) {
|
|
216
|
+
if (isNew)
|
|
217
|
+
msgIdOrder.push(msgId);
|
|
218
|
+
turnsByMsgId.set(msgId, turnData);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
catch { /* skip bad lines */ }
|
|
223
|
+
}
|
|
224
|
+
// Build ordered turns array from the Map (last-entry-wins dedup)
|
|
225
|
+
const turns = msgIdOrder.map(id => turnsByMsgId.get(id));
|
|
226
|
+
const totalCost = turns.reduce((s, t) => s + t.cost, 0);
|
|
227
|
+
const totalCacheRead = turns.reduce((s, t) => s + t.cacheRead, 0);
|
|
228
|
+
const totalCacheCreate = turns.reduce((s, t) => s + t.cacheCreate, 0);
|
|
229
|
+
const totalOutput = turns.reduce((s, t) => s + t.output, 0);
|
|
230
|
+
const maxContextPct = turns.length > 0 ? Math.max(...turns.map(t => t.contextPct)) : 0;
|
|
231
|
+
const currentContextPct = turns.length > 0 ? turns[turns.length - 1].contextPct : 0;
|
|
232
|
+
const avgCostPerTurn = turns.length > 0 ? totalCost / turns.length : 0;
|
|
233
|
+
const cacheHitRate = (totalCacheRead + totalCacheCreate) > 0
|
|
234
|
+
? (totalCacheRead / (totalCacheRead + totalCacheCreate)) * 100
|
|
235
|
+
: 0;
|
|
236
|
+
const replayAppliedCount = turns.reduce((sum, t) => sum + (t.replayState === 'applied' ? 1 : 0), 0);
|
|
237
|
+
const replaySkippedSizeCount = turns.reduce((sum, t) => sum + (t.replayState === 'skipped-size' ? 1 : 0), 0);
|
|
238
|
+
const replaySkipStoreCount = turns.reduce((sum, t) => sum + (t.replayStore === 'skip-size' ? 1 : 0), 0);
|
|
239
|
+
let duration = '0m';
|
|
240
|
+
if (startedAt && turns.length > 0) {
|
|
241
|
+
const start = new Date(startedAt).getTime();
|
|
242
|
+
const end = new Date(turns[turns.length - 1].timestamp).getTime();
|
|
243
|
+
const mins = Math.round((end - start) / 60000);
|
|
244
|
+
duration = mins >= 60 ? `${Math.floor(mins / 60)}h${mins % 60}m` : `${mins}m`;
|
|
245
|
+
}
|
|
246
|
+
// Get current context tokens from the last turn's raw data
|
|
247
|
+
const lastTurn = turns.length > 0 ? turns[turns.length - 1] : null;
|
|
248
|
+
const currentContextTokens = lastTurn
|
|
249
|
+
? lastTurn.cacheRead + lastTurn.cacheCreate + (lastTurn.output || 0)
|
|
250
|
+
: 0;
|
|
251
|
+
const modelContextSize = getModelCtxSize(model);
|
|
252
|
+
return {
|
|
253
|
+
sessionName,
|
|
254
|
+
model,
|
|
255
|
+
turnCount: turns.length,
|
|
256
|
+
totalCost,
|
|
257
|
+
totalTokens: totalCacheRead + totalCacheCreate + totalOutput,
|
|
258
|
+
totalCacheRead,
|
|
259
|
+
totalCacheCreate,
|
|
260
|
+
totalOutput,
|
|
261
|
+
avgCostPerTurn,
|
|
262
|
+
maxContextPct,
|
|
263
|
+
currentContextPct,
|
|
264
|
+
currentContextTokens,
|
|
265
|
+
modelContextSize,
|
|
266
|
+
cacheHitRate,
|
|
267
|
+
replayAppliedCount,
|
|
268
|
+
replaySkippedSizeCount,
|
|
269
|
+
replaySkipStoreCount,
|
|
270
|
+
startedAt,
|
|
271
|
+
duration,
|
|
272
|
+
turns,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
// ── Resolve session to JSONL path ──
|
|
276
|
+
function resolveJsonlPath(sessionName, createdAfterMs) {
|
|
277
|
+
// 1) Try standard resolution (works when sessionId is a real UUID)
|
|
278
|
+
const resolved = (0, usage_parser_js_1.resolveSessionName)(sessionName);
|
|
279
|
+
if (resolved) {
|
|
280
|
+
const jsonlPath = path.join(os.homedir(), '.claude', 'projects', resolved.encodedProjectPath, `${resolved.uuid}.jsonl`);
|
|
281
|
+
if (fs.existsSync(jsonlPath))
|
|
282
|
+
return jsonlPath;
|
|
283
|
+
}
|
|
284
|
+
// 2) Fallback: active session has "pending" UUID
|
|
285
|
+
const activeSessionsPath = path.join(os.homedir(), '.ekkos', 'active-sessions.json');
|
|
286
|
+
if (fs.existsSync(activeSessionsPath)) {
|
|
287
|
+
try {
|
|
288
|
+
const sessions = JSON.parse(fs.readFileSync(activeSessionsPath, 'utf-8'));
|
|
289
|
+
const match = sessions.find((s) => s.sessionName === sessionName);
|
|
290
|
+
if (match?.projectPath) {
|
|
291
|
+
return findLatestJsonl(match.projectPath, createdAfterMs);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
catch { /* ignore */ }
|
|
295
|
+
}
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Find a JSONL file in a project directory.
|
|
300
|
+
* @param createdAfterMs - If set, only return files CREATED (birthtime) after this timestamp.
|
|
301
|
+
* This prevents picking up old sessions that are still being modified.
|
|
302
|
+
*/
|
|
303
|
+
function findLatestJsonl(projectPath, createdAfterMs) {
|
|
304
|
+
const encoded = projectPath.replace(/\//g, '-');
|
|
305
|
+
const projectDir = path.join(os.homedir(), '.claude', 'projects', encoded);
|
|
306
|
+
if (!fs.existsSync(projectDir))
|
|
307
|
+
return null;
|
|
308
|
+
const jsonlFiles = fs.readdirSync(projectDir)
|
|
309
|
+
.filter(f => f.endsWith('.jsonl'))
|
|
310
|
+
.map(f => {
|
|
311
|
+
const stat = fs.statSync(path.join(projectDir, f));
|
|
312
|
+
return {
|
|
313
|
+
path: path.join(projectDir, f),
|
|
314
|
+
mtime: stat.mtimeMs,
|
|
315
|
+
birthtime: stat.birthtimeMs,
|
|
316
|
+
};
|
|
317
|
+
})
|
|
318
|
+
.filter(f => !createdAfterMs || f.birthtime > createdAfterMs)
|
|
319
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
320
|
+
return jsonlFiles.length > 0 ? jsonlFiles[0].path : null;
|
|
321
|
+
}
|
|
322
|
+
function getLatestSession() {
|
|
323
|
+
const sessions = (0, state_js_1.getActiveSessions)();
|
|
324
|
+
if (sessions.length === 0)
|
|
325
|
+
return null;
|
|
326
|
+
sessions.sort((a, b) => (b.lastHeartbeat || '').localeCompare(a.lastHeartbeat || ''));
|
|
327
|
+
return sessions[0].sessionName;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Wait for a new session to appear (used with --wait-for-new from ekkos run --dashboard).
|
|
331
|
+
* Polls active-sessions.json until a session started AFTER the launch marker timestamp.
|
|
332
|
+
* Returns the JSONL path for the new session.
|
|
333
|
+
*/
|
|
334
|
+
async function waitForNewSession() {
|
|
335
|
+
// Read launch timestamp + CWD marker
|
|
336
|
+
const markerPath = path.join(state_js_1.EKKOS_DIR, '.dashboard-launch-ts');
|
|
337
|
+
let launchTs = Date.now() - 5000; // default: 5s ago
|
|
338
|
+
let launchCwd = null;
|
|
339
|
+
try {
|
|
340
|
+
const lines = fs.readFileSync(markerPath, 'utf-8').trim().split('\n');
|
|
341
|
+
launchTs = parseInt(lines[0]) || launchTs;
|
|
342
|
+
if (lines[1])
|
|
343
|
+
launchCwd = lines[1];
|
|
344
|
+
}
|
|
345
|
+
catch { }
|
|
346
|
+
console.log(chalk_1.default.gray(' Waiting for new session to start...'));
|
|
347
|
+
const maxWaitMs = 120000; // 2 minutes max
|
|
348
|
+
const pollMs = 3000;
|
|
349
|
+
const startWait = Date.now();
|
|
350
|
+
let candidateName = null;
|
|
351
|
+
while (Date.now() - startWait < maxWaitMs) {
|
|
352
|
+
const sessions = (0, state_js_1.getActiveSessions)();
|
|
353
|
+
// Find sessions that started after our launch
|
|
354
|
+
for (const s of sessions) {
|
|
355
|
+
const startedMs = new Date(s.startedAt).getTime();
|
|
356
|
+
if (startedMs >= launchTs - 2000) {
|
|
357
|
+
candidateName = s.sessionName;
|
|
358
|
+
// Try standard resolution (works when session bind has happened with real UUID)
|
|
359
|
+
const jsonlPath = resolveJsonlPath(s.sessionName, launchTs);
|
|
360
|
+
if (jsonlPath) {
|
|
361
|
+
console.log(chalk_1.default.green(` Found session: ${s.sessionName}`));
|
|
362
|
+
return { sessionName: s.sessionName, jsonlPath };
|
|
363
|
+
}
|
|
364
|
+
// Try all unique projectPaths for this session name (bind may create second entry)
|
|
365
|
+
const allPaths = new Set(sessions.filter(x => x.sessionName === s.sessionName && x.projectPath)
|
|
366
|
+
.map(x => x.projectPath));
|
|
367
|
+
for (const pp of allPaths) {
|
|
368
|
+
const latestJsonl = findLatestJsonl(pp, launchTs);
|
|
369
|
+
if (latestJsonl) {
|
|
370
|
+
console.log(chalk_1.default.green(` Found session: ${s.sessionName}`));
|
|
371
|
+
return { sessionName: s.sessionName, jsonlPath: latestJsonl };
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
// Fallback: use launch CWD to find any new JSONL
|
|
377
|
+
if (launchCwd) {
|
|
378
|
+
const latestJsonl = findLatestJsonl(launchCwd, launchTs);
|
|
379
|
+
if (latestJsonl) {
|
|
380
|
+
const name = candidateName || 'session';
|
|
381
|
+
console.log(chalk_1.default.green(` Found session via CWD: ${name}`));
|
|
382
|
+
return { sessionName: name, jsonlPath: latestJsonl };
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
await sleep(pollMs);
|
|
386
|
+
process.stdout.write(chalk_1.default.gray('.'));
|
|
387
|
+
}
|
|
388
|
+
// Timeout — try one more time with the candidate name (still use birthtime filter)
|
|
389
|
+
if (candidateName) {
|
|
390
|
+
console.log(chalk_1.default.yellow(`\n Timeout. Trying ${candidateName} anyway...`));
|
|
391
|
+
const jsonlPath = resolveJsonlPath(candidateName, launchTs);
|
|
392
|
+
if (jsonlPath)
|
|
393
|
+
return { sessionName: candidateName, jsonlPath };
|
|
394
|
+
}
|
|
395
|
+
// Last resort: latest session overall
|
|
396
|
+
const latestName = getLatestSession();
|
|
397
|
+
if (latestName) {
|
|
398
|
+
console.log(chalk_1.default.yellow(`\n Falling back to latest: ${latestName}`));
|
|
399
|
+
const jsonlPath = resolveJsonlPath(latestName);
|
|
400
|
+
if (jsonlPath)
|
|
401
|
+
return { sessionName: latestName, jsonlPath };
|
|
402
|
+
}
|
|
403
|
+
console.log(chalk_1.default.red(' No session found.'));
|
|
404
|
+
process.exit(1);
|
|
405
|
+
}
|
|
406
|
+
function sleep(ms) {
|
|
407
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
408
|
+
}
|
|
409
|
+
// ── TUI Dashboard ──
|
|
410
|
+
async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
411
|
+
let sessionName = initialSessionName;
|
|
412
|
+
const blessed = require('blessed');
|
|
413
|
+
const contrib = require('blessed-contrib');
|
|
414
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
415
|
+
// TMUX SPLIT PANE ISOLATION
|
|
416
|
+
// When dashboard runs in a separate tmux pane from `ekkos run`, blessed must
|
|
417
|
+
// NOT consume stdin/stdout control, which would break both panes.
|
|
418
|
+
//
|
|
419
|
+
// KEY FIX: We use /dev/tty to give blessed DIRECT terminal access instead of
|
|
420
|
+
// inheriting stdin/stdout. This ensures:
|
|
421
|
+
// 1. Dashboard draws only to its own tmux pane (not to stdout pipe)
|
|
422
|
+
// 2. Terminal control (raw mode, alternate buffer) is local to that pane
|
|
423
|
+
// 3. Claude Code pane remains fully independent
|
|
424
|
+
// 4. Text selection works across panes without interference
|
|
425
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
426
|
+
// Open /dev/tty for blessed to use (gives it direct terminal access)
|
|
427
|
+
const fs = require('fs');
|
|
428
|
+
let ttyInput = process.stdin;
|
|
429
|
+
let ttyOutput = process.stdout;
|
|
430
|
+
try {
|
|
431
|
+
// Create readable/writable streams from /dev/tty file descriptors
|
|
432
|
+
const ttyFd = fs.openSync('/dev/tty', 'r+');
|
|
433
|
+
ttyInput = new (require('tty').ReadStream)(ttyFd);
|
|
434
|
+
ttyOutput = new (require('tty').WriteStream)(ttyFd);
|
|
435
|
+
}
|
|
436
|
+
catch (e) {
|
|
437
|
+
// Fallback: if /dev/tty unavailable (some CI/remote environments),
|
|
438
|
+
// use stdin/stdout but disable all event handling
|
|
439
|
+
ttyInput = process.stdin;
|
|
440
|
+
ttyOutput = process.stdout;
|
|
441
|
+
}
|
|
442
|
+
const screen = blessed.screen({
|
|
443
|
+
smartCSR: true,
|
|
444
|
+
title: `ekkOS - ${sessionName}`,
|
|
445
|
+
fullUnicode: true,
|
|
446
|
+
mouse: false, // Disable ALL mouse capture (allows terminal text selection)
|
|
447
|
+
grabKeys: false, // Don't grab keyboard input from other panes
|
|
448
|
+
sendFocus: false, // Don't send focus events (breaks paste)
|
|
449
|
+
ignoreLocked: ['C-c'], // Only capture Ctrl+C for quit
|
|
450
|
+
input: ttyInput, // Use /dev/tty for input (isolated from stdout pipe)
|
|
451
|
+
output: ttyOutput, // Use /dev/tty for output (isolated from stdout pipe)
|
|
452
|
+
forceUnicode: true, // Better text rendering
|
|
453
|
+
terminal: 'xterm-256color',
|
|
454
|
+
resizeTimeout: 300, // Debounce resize events
|
|
455
|
+
});
|
|
456
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
457
|
+
// DISABLE TERMINAL CONTROL SEQUENCE HIJACKING
|
|
458
|
+
// Prevent blessed from sending control sequences that might interfere with
|
|
459
|
+
// other panes in tmux. The /dev/tty approach already isolates this, but we
|
|
460
|
+
// also disable the raw mode entirely to be extra safe.
|
|
461
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
462
|
+
if (screen.program) {
|
|
463
|
+
// Override alternateBuffer to do nothing
|
|
464
|
+
screen.program.alternateBuffer = () => { };
|
|
465
|
+
screen.program.normalBuffer = () => { };
|
|
466
|
+
// Don't enter raw mode — let the OS handle it
|
|
467
|
+
if (screen.program.setRawMode) {
|
|
468
|
+
screen.program.setRawMode = (enabled) => {
|
|
469
|
+
// Silently ignore raw mode requests
|
|
470
|
+
return enabled;
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
// ── Zero-gap calculated layout ──
|
|
475
|
+
//
|
|
476
|
+
// Every widget has exact top + height computed from screen.height.
|
|
477
|
+
// Fixed heights for header/context/usage/footer, remaining split
|
|
478
|
+
// between chart (40%) and table (60%). No percentages = no gaps.
|
|
479
|
+
//
|
|
480
|
+
// header: 3 rows (session stats + animated logo)
|
|
481
|
+
// context: 5 rows (progress bar + cost breakdown + cache stats)
|
|
482
|
+
// chart: 40% of remaining (token usage graph)
|
|
483
|
+
// table: 60% of remaining (turn-by-turn breakdown)
|
|
484
|
+
// usage: 3 rows (Anthropic rate limit window)
|
|
485
|
+
// footer: 3 rows (totals + routing + keybindings)
|
|
486
|
+
const LOGO_CHARS = ['e', 'k', 'k', 'O', 'S', '_'];
|
|
487
|
+
const WAVE_COLORS = ['cyan', 'blue', 'magenta', 'yellow', 'green', 'white'];
|
|
488
|
+
const W = '100%';
|
|
489
|
+
const HEADER_H = 3;
|
|
490
|
+
const CONTEXT_H = 5;
|
|
491
|
+
const USAGE_H = 4;
|
|
492
|
+
const FOOTER_H = 3;
|
|
493
|
+
const CLAWD_W = 16; // Width reserved for Clawd mascot in context box
|
|
494
|
+
const FIXED_H = HEADER_H + CONTEXT_H + USAGE_H + FOOTER_H; // 15
|
|
495
|
+
function calcLayout() {
|
|
496
|
+
const H = screen.height;
|
|
497
|
+
const remaining = Math.max(6, H - FIXED_H);
|
|
498
|
+
const chartH = Math.max(4, Math.floor(remaining * 0.30));
|
|
499
|
+
const tableH = Math.max(4, remaining - chartH);
|
|
500
|
+
return {
|
|
501
|
+
header: { top: 0, height: HEADER_H },
|
|
502
|
+
context: { top: HEADER_H, height: CONTEXT_H },
|
|
503
|
+
chart: { top: HEADER_H + CONTEXT_H, height: chartH },
|
|
504
|
+
table: { top: HEADER_H + CONTEXT_H + chartH, height: tableH },
|
|
505
|
+
usage: { top: H - USAGE_H - FOOTER_H, height: USAGE_H },
|
|
506
|
+
footer: { top: H - FOOTER_H, height: FOOTER_H },
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
let layout = calcLayout();
|
|
510
|
+
// Header: session stats (3 lines)
|
|
511
|
+
const headerBox = blessed.box({
|
|
512
|
+
top: layout.header.top, left: 0, width: W, height: layout.header.height,
|
|
513
|
+
content: ' {gray-fg}Loading...{/gray-fg}',
|
|
514
|
+
tags: true,
|
|
515
|
+
style: { fg: 'white', border: { fg: 'cyan' } },
|
|
516
|
+
border: { type: 'line' },
|
|
517
|
+
label: ' ekkOS_ ',
|
|
518
|
+
});
|
|
519
|
+
// Context: progress bar + costs + cache (5 lines)
|
|
520
|
+
const contextBox = blessed.box({
|
|
521
|
+
top: layout.context.top, left: 0, width: W, height: layout.context.height,
|
|
522
|
+
content: '',
|
|
523
|
+
tags: true,
|
|
524
|
+
style: { fg: 'white', border: { fg: 'cyan' } },
|
|
525
|
+
border: { type: 'line' },
|
|
526
|
+
label: ' Context ',
|
|
527
|
+
});
|
|
528
|
+
// Clawd mascot — official Claude Code mascot (redBright / rgb(215,119,87))
|
|
529
|
+
const clawdBox = blessed.box({
|
|
530
|
+
parent: contextBox,
|
|
531
|
+
top: 0,
|
|
532
|
+
right: 2, // Keep a small visual gap from the context border
|
|
533
|
+
width: 10,
|
|
534
|
+
height: 3,
|
|
535
|
+
content: ' ▐▛███▜▌\n ▝▜█████▛▘\n ▘▘ ▝▝',
|
|
536
|
+
tags: false,
|
|
537
|
+
style: { fg: 'red', bold: true }, // ansi redBright = official Clawd orange
|
|
538
|
+
});
|
|
539
|
+
// Token chart (fills 30% of remaining)
|
|
540
|
+
const tokenChart = contrib.line({
|
|
541
|
+
top: layout.chart.top, left: 0, width: W, height: layout.chart.height,
|
|
542
|
+
label: ' Tokens/Turn (K) ',
|
|
543
|
+
showLegend: true,
|
|
544
|
+
legend: { width: 8 },
|
|
545
|
+
style: {
|
|
546
|
+
line: 'green',
|
|
547
|
+
text: 'white',
|
|
548
|
+
baseline: 'white',
|
|
549
|
+
border: { fg: 'cyan' },
|
|
550
|
+
},
|
|
551
|
+
border: { type: 'line', fg: 'cyan' },
|
|
552
|
+
xLabelPadding: 0,
|
|
553
|
+
xPadding: 1,
|
|
554
|
+
wholeNumbersOnly: false,
|
|
555
|
+
});
|
|
556
|
+
// Turn table — manual rendering for full-width columns + dim dividers
|
|
557
|
+
const turnBox = blessed.box({
|
|
558
|
+
top: layout.table.top, left: 0, width: W, height: layout.table.height,
|
|
559
|
+
content: '',
|
|
560
|
+
tags: true,
|
|
561
|
+
scrollable: true,
|
|
562
|
+
alwaysScroll: true,
|
|
563
|
+
scrollbar: { ch: '│', style: { fg: 'cyan' } },
|
|
564
|
+
keys: true, // Enable keyboard scrolling
|
|
565
|
+
vi: true, // Enable vi-style keys (j/k for scroll)
|
|
566
|
+
mouse: false, // Mouse disabled (use keyboard for scrolling, allows text selection)
|
|
567
|
+
input: true,
|
|
568
|
+
interactive: true, // Make box interactive for scrolling
|
|
569
|
+
label: ' Turns (scroll: ↑↓/k/j, page: PgUp/u, home/end: g/G) ',
|
|
570
|
+
border: { type: 'line', fg: 'cyan' },
|
|
571
|
+
style: { fg: 'white', border: { fg: 'cyan' } },
|
|
572
|
+
});
|
|
573
|
+
// Usage window (rate limits)
|
|
574
|
+
const windowBox = blessed.box({
|
|
575
|
+
top: layout.usage.top, left: 0, width: W, height: layout.usage.height,
|
|
576
|
+
content: ' {gray-fg}Loading usage...{/gray-fg}',
|
|
577
|
+
tags: true,
|
|
578
|
+
style: { fg: 'white', border: { fg: 'magenta' } },
|
|
579
|
+
border: { type: 'line' },
|
|
580
|
+
label: ' Rate Limit ',
|
|
581
|
+
});
|
|
582
|
+
// Footer (totals + routing + keys)
|
|
583
|
+
const footerBox = blessed.box({
|
|
584
|
+
top: layout.footer.top, left: 0, width: W, height: layout.footer.height,
|
|
585
|
+
content: ' {gray-fg}Loading...{/gray-fg}',
|
|
586
|
+
tags: true,
|
|
587
|
+
style: { fg: 'white', border: { fg: 'cyan' } },
|
|
588
|
+
border: { type: 'line' },
|
|
589
|
+
label: ' Session ',
|
|
590
|
+
});
|
|
591
|
+
// Add all widgets to screen
|
|
592
|
+
screen.append(headerBox);
|
|
593
|
+
screen.append(contextBox);
|
|
594
|
+
screen.append(tokenChart);
|
|
595
|
+
screen.append(turnBox);
|
|
596
|
+
screen.append(windowBox);
|
|
597
|
+
screen.append(footerBox);
|
|
598
|
+
// Apply layout positions to all widgets
|
|
599
|
+
function applyLayout() {
|
|
600
|
+
layout = calcLayout();
|
|
601
|
+
const fullWidth = Math.max(20, screen.width || 80);
|
|
602
|
+
const H_PAD = 1; // Keep dashboard widgets off the pane edges
|
|
603
|
+
const contentWidth = Math.max(18, fullWidth - (H_PAD * 2));
|
|
604
|
+
headerBox.top = layout.header.top;
|
|
605
|
+
headerBox.left = H_PAD;
|
|
606
|
+
headerBox.width = contentWidth;
|
|
607
|
+
headerBox.height = layout.header.height;
|
|
608
|
+
contextBox.top = layout.context.top;
|
|
609
|
+
contextBox.left = H_PAD;
|
|
610
|
+
contextBox.width = contentWidth;
|
|
611
|
+
contextBox.height = layout.context.height;
|
|
612
|
+
tokenChart.top = layout.chart.top;
|
|
613
|
+
tokenChart.left = H_PAD;
|
|
614
|
+
tokenChart.width = contentWidth;
|
|
615
|
+
tokenChart.height = layout.chart.height;
|
|
616
|
+
turnBox.top = layout.table.top;
|
|
617
|
+
turnBox.left = H_PAD;
|
|
618
|
+
turnBox.width = contentWidth;
|
|
619
|
+
turnBox.height = layout.table.height;
|
|
620
|
+
windowBox.top = layout.usage.top;
|
|
621
|
+
windowBox.left = H_PAD;
|
|
622
|
+
windowBox.width = contentWidth;
|
|
623
|
+
windowBox.height = layout.usage.height;
|
|
624
|
+
footerBox.top = layout.footer.top;
|
|
625
|
+
footerBox.left = H_PAD;
|
|
626
|
+
footerBox.width = contentWidth;
|
|
627
|
+
footerBox.height = layout.footer.height;
|
|
628
|
+
}
|
|
629
|
+
// Track geometry so we can re-anchor widgets even if tmux resize events are flaky
|
|
630
|
+
let lastLayoutW = screen.width || 0;
|
|
631
|
+
let lastLayoutH = screen.height || 0;
|
|
632
|
+
function ensureLayoutSynced() {
|
|
633
|
+
const w = screen.width || 0;
|
|
634
|
+
const h = screen.height || 0;
|
|
635
|
+
if (w === lastLayoutW && h === lastLayoutH)
|
|
636
|
+
return;
|
|
637
|
+
lastLayoutW = w;
|
|
638
|
+
lastLayoutH = h;
|
|
639
|
+
try {
|
|
640
|
+
screen.realloc?.();
|
|
641
|
+
}
|
|
642
|
+
catch { }
|
|
643
|
+
applyLayout();
|
|
644
|
+
}
|
|
645
|
+
// ── Logo color wave animation ──
|
|
646
|
+
let waveOffset = 0;
|
|
647
|
+
let sparkleTimer;
|
|
648
|
+
function renderLogoWave() {
|
|
649
|
+
try {
|
|
650
|
+
ensureLayoutSynced();
|
|
651
|
+
// Color wave in the border label
|
|
652
|
+
const coloredChars = LOGO_CHARS.map((ch, i) => {
|
|
653
|
+
const colorIdx = (i + waveOffset) % WAVE_COLORS.length;
|
|
654
|
+
return `{${WAVE_COLORS[colorIdx]}-fg}${ch}{/${WAVE_COLORS[colorIdx]}-fg}`;
|
|
655
|
+
});
|
|
656
|
+
// Logo left + session name right in border label
|
|
657
|
+
const logoStr = ` ${coloredChars.join('')} `;
|
|
658
|
+
// Session name with traveling shimmer across ALL characters
|
|
659
|
+
const SESSION_GLOW = ['white', 'cyan', 'magenta'];
|
|
660
|
+
const glowPos = (waveOffset * 2) % sessionName.length; // 2x speed for snappier travel
|
|
661
|
+
const nameChars = sessionName.split('').map((ch, i) => {
|
|
662
|
+
const dist = Math.min(Math.abs(i - glowPos), sessionName.length - Math.abs(i - glowPos)); // wrapping distance
|
|
663
|
+
if (dist === 0)
|
|
664
|
+
return `{${SESSION_GLOW[0]}-fg}${ch}{/${SESSION_GLOW[0]}-fg}`;
|
|
665
|
+
if (dist <= 2)
|
|
666
|
+
return `{${SESSION_GLOW[1]}-fg}${ch}{/${SESSION_GLOW[1]}-fg}`;
|
|
667
|
+
return `{${SESSION_GLOW[2]}-fg}${ch}{/${SESSION_GLOW[2]}-fg}`;
|
|
668
|
+
});
|
|
669
|
+
const sessionStr = ` ${nameChars.join('')} `;
|
|
670
|
+
const rawLogoLen = LOGO_CHARS.length + 2; // " ekkOS_ " = 8
|
|
671
|
+
const rawSessionLen = sessionName.length + 3; // " name " + 1 extra space before ┐
|
|
672
|
+
const boxW = Math.max(10, headerBox.width - 2); // minus border chars
|
|
673
|
+
const pad = Math.max(1, boxW - rawLogoLen - rawSessionLen);
|
|
674
|
+
headerBox.setLabel(logoStr + '─'.repeat(pad) + sessionStr);
|
|
675
|
+
waveOffset = (waveOffset + 1) % WAVE_COLORS.length;
|
|
676
|
+
// Stats go inside the box
|
|
677
|
+
const data = lastData;
|
|
678
|
+
if (data) {
|
|
679
|
+
const m = data.model.replace('claude-', '').replace(/-\d{8}$/, '');
|
|
680
|
+
headerBox.setContent(` {green-fg}$${data.totalCost.toFixed(2)}{/green-fg} T${data.turnCount} ${data.duration} $${data.avgCostPerTurn.toFixed(2)}/t {cyan-fg}${m}{/cyan-fg}`);
|
|
681
|
+
}
|
|
682
|
+
screen.render();
|
|
683
|
+
}
|
|
684
|
+
catch { }
|
|
685
|
+
}
|
|
686
|
+
// Wave cycles every 200ms for smooth color sweep
|
|
687
|
+
function scheduleWave() {
|
|
688
|
+
sparkleTimer = setTimeout(() => {
|
|
689
|
+
renderLogoWave();
|
|
690
|
+
scheduleWave();
|
|
691
|
+
}, 200);
|
|
692
|
+
}
|
|
693
|
+
scheduleWave();
|
|
694
|
+
// ── Update function ──
|
|
695
|
+
let lastFileSize = 0;
|
|
696
|
+
let lastData = null;
|
|
697
|
+
let lastScrollPerc = 0; // Preserve scroll position across updates
|
|
698
|
+
const debugLog = path.join(os.homedir(), '.ekkos', 'dashboard.log');
|
|
699
|
+
function dlog(msg) {
|
|
700
|
+
try {
|
|
701
|
+
fs.appendFileSync(debugLog, `[${new Date().toISOString()}] ${msg}\n`);
|
|
702
|
+
}
|
|
703
|
+
catch { }
|
|
704
|
+
}
|
|
705
|
+
function updateDashboard() {
|
|
706
|
+
ensureLayoutSynced();
|
|
707
|
+
// Resolve "initializing" → real session name from JSONL UUID filename
|
|
708
|
+
if (sessionName === 'initializing' || sessionName === 'session') {
|
|
709
|
+
try {
|
|
710
|
+
const basename = path.basename(jsonlPath, '.jsonl');
|
|
711
|
+
// JSONL filename is the session UUID (e.g., 607bd8e4-0a04-4db2-acf5-3f794be0f956.jsonl)
|
|
712
|
+
if (/^[0-9a-f]{8}-/.test(basename)) {
|
|
713
|
+
sessionName = (0, state_js_1.uuidToWords)(basename);
|
|
714
|
+
screen.title = `ekkOS - ${sessionName}`;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
catch { }
|
|
718
|
+
}
|
|
719
|
+
let data;
|
|
720
|
+
try {
|
|
721
|
+
const stat = fs.statSync(jsonlPath);
|
|
722
|
+
if (stat.size === lastFileSize && lastData)
|
|
723
|
+
return;
|
|
724
|
+
lastFileSize = stat.size;
|
|
725
|
+
data = parseJsonlFile(jsonlPath, sessionName);
|
|
726
|
+
lastData = data;
|
|
727
|
+
}
|
|
728
|
+
catch (err) {
|
|
729
|
+
dlog(`Parse error: ${err.message}`);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
// ── Header — wave animation handles rendering, just trigger a frame ──
|
|
733
|
+
try {
|
|
734
|
+
renderLogoWave();
|
|
735
|
+
}
|
|
736
|
+
catch (err) {
|
|
737
|
+
dlog(`Header: ${err.message}`);
|
|
738
|
+
}
|
|
739
|
+
// ── Context + Cost (progress bar + stats, 3 content lines) ──
|
|
740
|
+
try {
|
|
741
|
+
const ctxPct = Math.min(data.currentContextPct, 100);
|
|
742
|
+
const ctxColor = ctxPct < 50 ? 'green' : ctxPct < 80 ? 'yellow' : 'red';
|
|
743
|
+
const tokensK = Math.round(data.currentContextTokens / 1000);
|
|
744
|
+
const maxK = Math.round(data.modelContextSize / 1000);
|
|
745
|
+
// Visual progress bar (fills available width)
|
|
746
|
+
const contextInnerWidth = Math.max(10, contextBox.width - 2);
|
|
747
|
+
// Extend bar slightly closer to mascot while keeping a small visual gap.
|
|
748
|
+
const barWidth = Math.max(10, contextInnerWidth - 4 - CLAWD_W);
|
|
749
|
+
const filled = Math.round((ctxPct / 100) * barWidth);
|
|
750
|
+
const bar = `{${ctxColor}-fg}${'█'.repeat(filled)}{/${ctxColor}-fg}${'░'.repeat(barWidth - filled)}`;
|
|
751
|
+
// Cost breakdown (use actual model pricing, not hardcoded)
|
|
752
|
+
const p = getModelPricing(data.model);
|
|
753
|
+
const rd = (data.totalCacheRead / 1000000) * p.cacheRead;
|
|
754
|
+
const wr = (data.totalCacheCreate / 1000000) * p.cacheWrite;
|
|
755
|
+
const ou = (data.totalOutput / 1000000) * p.output;
|
|
756
|
+
// Cache stats
|
|
757
|
+
const hitColor = data.cacheHitRate >= 80 ? 'green' : data.cacheHitRate >= 50 ? 'yellow' : 'red';
|
|
758
|
+
const cappedMax = Math.min(data.maxContextPct, 100);
|
|
759
|
+
contextBox.setContent(` ${bar}\n` +
|
|
760
|
+
` {${ctxColor}-fg}${ctxPct.toFixed(0)}%{/${ctxColor}-fg} ${tokensK}K/${maxK}K` +
|
|
761
|
+
` {green-fg}Read{/green-fg} $${rd.toFixed(2)}` +
|
|
762
|
+
` {yellow-fg}Write{/yellow-fg} $${wr.toFixed(2)}` +
|
|
763
|
+
` {cyan-fg}Output{/cyan-fg} $${ou.toFixed(2)}\n` +
|
|
764
|
+
` {${hitColor}-fg}${data.cacheHitRate.toFixed(0)}% cache{/${hitColor}-fg}` +
|
|
765
|
+
` peak:${cappedMax.toFixed(0)}%` +
|
|
766
|
+
` avg:$${data.avgCostPerTurn.toFixed(2)}/t` +
|
|
767
|
+
` replay A:${data.replayAppliedCount}` +
|
|
768
|
+
` SZ:${data.replaySkippedSizeCount}` +
|
|
769
|
+
` ST:${data.replaySkipStoreCount}`);
|
|
770
|
+
}
|
|
771
|
+
catch (err) {
|
|
772
|
+
dlog(`Context: ${err.message}`);
|
|
773
|
+
}
|
|
774
|
+
// ── Token Line Chart (all 3 series: Rd, Wr, Out) ──
|
|
775
|
+
try {
|
|
776
|
+
const recent = data.turns.slice(-30);
|
|
777
|
+
if (recent.length >= 2) {
|
|
778
|
+
const x = recent.map(t => String(t.turn));
|
|
779
|
+
tokenChart.setData([
|
|
780
|
+
{ title: 'Rd', x, y: recent.map(t => Math.round(t.cacheRead / 1000)), style: { line: 'green' } },
|
|
781
|
+
{ title: 'Wr', x, y: recent.map(t => Math.round(t.cacheCreate / 1000)), style: { line: 'yellow' } },
|
|
782
|
+
{ title: 'Out', x, y: recent.map(t => Math.round(t.output / 1000)), style: { line: 'cyan' } },
|
|
783
|
+
]);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
catch (err) {
|
|
787
|
+
dlog(`Chart: ${err.message}`);
|
|
788
|
+
}
|
|
789
|
+
// ── Turn Table (manual rendering — dynamic widths + dim dividers) ──
|
|
790
|
+
try {
|
|
791
|
+
// Preserve scroll position BEFORE updating content
|
|
792
|
+
lastScrollPerc = turnBox.getScrollPerc();
|
|
793
|
+
// Account for borders + scrollbar gutter + padding so last column never wraps
|
|
794
|
+
const w = Math.max(18, turnBox.width - 5); // usable content width
|
|
795
|
+
const div = '{gray-fg}│{/gray-fg}';
|
|
796
|
+
function pad(s, width) {
|
|
797
|
+
if (s.length >= width)
|
|
798
|
+
return s.slice(0, width);
|
|
799
|
+
return s + ' '.repeat(width - s.length);
|
|
800
|
+
}
|
|
801
|
+
function rpad(s, width) {
|
|
802
|
+
if (s.length >= width)
|
|
803
|
+
return s.slice(0, width);
|
|
804
|
+
return ' '.repeat(width - s.length) + s;
|
|
805
|
+
}
|
|
806
|
+
// Data rows — RENDER ALL TURNS for full scrollback history
|
|
807
|
+
// Don't slice to visibleRows only — let user scroll through entire session
|
|
808
|
+
const turns = data.turns.slice().reverse();
|
|
809
|
+
let header = '';
|
|
810
|
+
let separator = '';
|
|
811
|
+
let rows = [];
|
|
812
|
+
// Responsive table layouts keep headers visible even in very narrow panes.
|
|
813
|
+
if (w >= 44) {
|
|
814
|
+
// Full mode: Turn, Model, Context, Cache Rd, Cache Wr, Output, Cost
|
|
815
|
+
const colNum = 4;
|
|
816
|
+
const colM = 6;
|
|
817
|
+
const colCtx = 6;
|
|
818
|
+
const colCost = 6;
|
|
819
|
+
const nDividers = 6;
|
|
820
|
+
const fixedW = colNum + colM + colCtx + colCost;
|
|
821
|
+
const flexTotal = w - fixedW - nDividers;
|
|
822
|
+
const rdW = Math.max(4, Math.floor(flexTotal * 0.35));
|
|
823
|
+
const wrW = Math.max(4, Math.floor(flexTotal * 0.30));
|
|
824
|
+
const outW = Math.max(4, flexTotal - rdW - wrW);
|
|
825
|
+
header = `{bold}${pad('Turn', colNum)}${div}${pad('Model', colM)}${div}${pad('Context', colCtx)}${div}${rpad('Cache Rd', rdW)}${div}${rpad('Cache Wr', wrW)}${div}${rpad('Output', outW)}${div}${rpad('Cost', colCost)}{/bold}`;
|
|
826
|
+
separator = `{gray-fg}${'─'.repeat(colNum)}┼${'─'.repeat(colM)}┼${'─'.repeat(colCtx)}┼${'─'.repeat(rdW)}┼${'─'.repeat(wrW)}┼${'─'.repeat(outW)}┼${'─'.repeat(colCost)}{/gray-fg}`;
|
|
827
|
+
rows = turns.map(t => {
|
|
828
|
+
const mTag = modelTag(t.routedModel);
|
|
829
|
+
const mColor = t.routedModel.includes('haiku') ? 'green' : t.routedModel.includes('sonnet') ? 'blue' : 'magenta';
|
|
830
|
+
const costFlag = t.cost > 1.0 ? '{red-fg}' : t.cost > 0.5 ? '{yellow-fg}' : '{white-fg}';
|
|
831
|
+
const costEnd = t.cost > 1.0 ? '{/red-fg}' : t.cost > 0.5 ? '{/yellow-fg}' : '{/white-fg}';
|
|
832
|
+
return (pad(String(t.turn), colNum) + div +
|
|
833
|
+
`{${mColor}-fg}${pad(mTag, colM)}{/${mColor}-fg}` + div +
|
|
834
|
+
pad(`${t.contextPct.toFixed(0)}%`, colCtx) + div +
|
|
835
|
+
`{green-fg}${rpad(fmtK(t.cacheRead), rdW)}{/green-fg}` + div +
|
|
836
|
+
`{yellow-fg}${rpad(fmtK(t.cacheCreate), wrW)}{/yellow-fg}` + div +
|
|
837
|
+
`{cyan-fg}${rpad(fmtK(t.output), outW)}{/cyan-fg}` + div +
|
|
838
|
+
costFlag + rpad(`$${t.cost.toFixed(2)}`, colCost) + costEnd);
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
else if (w >= 30) {
|
|
842
|
+
// Compact mode: drop cache split columns to preserve readable headers.
|
|
843
|
+
const colNum = 4;
|
|
844
|
+
const colM = 6;
|
|
845
|
+
const colCtx = 6;
|
|
846
|
+
const colCost = 6;
|
|
847
|
+
const nDividers = 4;
|
|
848
|
+
const outW = Math.max(4, w - (colNum + colM + colCtx + colCost + nDividers));
|
|
849
|
+
header = `{bold}${pad('Turn', colNum)}${div}${pad('Model', colM)}${div}${pad('Context', colCtx)}${div}${rpad('Output', outW)}${div}${rpad('Cost', colCost)}{/bold}`;
|
|
850
|
+
separator = `{gray-fg}${'─'.repeat(colNum)}┼${'─'.repeat(colM)}┼${'─'.repeat(colCtx)}┼${'─'.repeat(outW)}┼${'─'.repeat(colCost)}{/gray-fg}`;
|
|
851
|
+
rows = turns.map(t => {
|
|
852
|
+
const mTag = modelTag(t.routedModel);
|
|
853
|
+
const mColor = t.routedModel.includes('haiku') ? 'green' : t.routedModel.includes('sonnet') ? 'blue' : 'magenta';
|
|
854
|
+
const costFlag = t.cost > 1.0 ? '{red-fg}' : t.cost > 0.5 ? '{yellow-fg}' : '{white-fg}';
|
|
855
|
+
const costEnd = t.cost > 1.0 ? '{/red-fg}' : t.cost > 0.5 ? '{/yellow-fg}' : '{/white-fg}';
|
|
856
|
+
return (pad(String(t.turn), colNum) + div +
|
|
857
|
+
`{${mColor}-fg}${pad(mTag, colM)}{/${mColor}-fg}` + div +
|
|
858
|
+
pad(`${t.contextPct.toFixed(0)}%`, colCtx) + div +
|
|
859
|
+
`{cyan-fg}${rpad(fmtK(t.output), outW)}{/cyan-fg}` + div +
|
|
860
|
+
costFlag + rpad(`$${t.cost.toFixed(2)}`, colCost) + costEnd);
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
else {
|
|
864
|
+
// Minimal mode: guaranteed no-wrap fallback.
|
|
865
|
+
const colNum = 4;
|
|
866
|
+
const colCtx = 6;
|
|
867
|
+
const colCost = 6;
|
|
868
|
+
header = `{bold}${pad('Turn', colNum)}${div}${pad('Context', colCtx)}${div}${rpad('Cost', colCost)}{/bold}`;
|
|
869
|
+
separator = `{gray-fg}${'─'.repeat(colNum)}┼${'─'.repeat(colCtx)}┼${'─'.repeat(colCost)}{/gray-fg}`;
|
|
870
|
+
rows = turns.map(t => {
|
|
871
|
+
const costFlag = t.cost > 1.0 ? '{red-fg}' : t.cost > 0.5 ? '{yellow-fg}' : '{white-fg}';
|
|
872
|
+
const costEnd = t.cost > 1.0 ? '{/red-fg}' : t.cost > 0.5 ? '{/yellow-fg}' : '{/white-fg}';
|
|
873
|
+
return (pad(String(t.turn), colNum) + div +
|
|
874
|
+
pad(`${t.contextPct.toFixed(0)}%`, colCtx) + div +
|
|
875
|
+
costFlag + rpad(`$${t.cost.toFixed(2)}`, colCost) + costEnd);
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
const lines = [header, separator, ...rows];
|
|
879
|
+
turnBox.setContent(lines.join('\n'));
|
|
880
|
+
// Restore scroll position AFTER content update
|
|
881
|
+
// Only restore if user has scrolled away from top (0%)
|
|
882
|
+
if (lastScrollPerc > 0) {
|
|
883
|
+
turnBox.setScrollPerc(lastScrollPerc);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
catch (err) {
|
|
887
|
+
dlog(`Table: ${err.message}`);
|
|
888
|
+
}
|
|
889
|
+
// ── Session Totals (footer) ──
|
|
890
|
+
try {
|
|
891
|
+
const totalTokensM = ((data.totalCacheRead + data.totalCacheCreate + data.totalOutput) / 1000000).toFixed(2);
|
|
892
|
+
const totalSavings = data.turns.reduce((s, t) => s + t.savings, 0);
|
|
893
|
+
// Model routing breakdown (uses routedModel for actual model counts)
|
|
894
|
+
const opusCount = data.turns.filter(t => t.routedModel.includes('opus')).length;
|
|
895
|
+
const sonnetCount = data.turns.filter(t => t.routedModel.includes('sonnet')).length;
|
|
896
|
+
const haikuCount = data.turns.filter(t => t.routedModel.includes('haiku')).length;
|
|
897
|
+
const routingParts = [`{magenta-fg}O{/magenta-fg}:${opusCount}`];
|
|
898
|
+
if (sonnetCount > 0)
|
|
899
|
+
routingParts.push(`{blue-fg}S{/blue-fg}:${sonnetCount}`);
|
|
900
|
+
routingParts.push(`{green-fg}H{/green-fg}:${haikuCount}`);
|
|
901
|
+
const routingStr = routingParts.join(' ');
|
|
902
|
+
const savingsStr = totalSavings > 0
|
|
903
|
+
? ` {green-fg}saved $${totalSavings.toFixed(2)}{/green-fg}`
|
|
904
|
+
: '';
|
|
905
|
+
footerBox.setContent(` {green-fg}$${data.totalCost.toFixed(2)}{/green-fg}` +
|
|
906
|
+
` ${totalTokensM}M` +
|
|
907
|
+
` ${routingStr}` +
|
|
908
|
+
` R[A:${data.replayAppliedCount} SZ:${data.replaySkippedSizeCount} ST:${data.replaySkipStoreCount}]` +
|
|
909
|
+
savingsStr +
|
|
910
|
+
` {gray-fg}? help q quit r refresh{/gray-fg}`);
|
|
911
|
+
}
|
|
912
|
+
catch (err) {
|
|
913
|
+
dlog(`Footer: ${err.message}`);
|
|
914
|
+
}
|
|
915
|
+
try {
|
|
916
|
+
screen.render();
|
|
917
|
+
}
|
|
918
|
+
catch (err) {
|
|
919
|
+
dlog(`Render: ${err.message}`);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
// ── Usage window update — calls Anthropic's OAuth usage API ──
|
|
923
|
+
/**
|
|
924
|
+
* Fetch real usage limits from Anthropic's OAuth usage endpoint.
|
|
925
|
+
* Returns { five_hour: { utilization, resets_at }, seven_day: { utilization, resets_at } }
|
|
926
|
+
* Requires Claude Code OAuth token from macOS Keychain.
|
|
927
|
+
*/
|
|
928
|
+
async function fetchAnthropicUsage() {
|
|
929
|
+
try {
|
|
930
|
+
const { execSync } = require('child_process');
|
|
931
|
+
const credsJson = execSync('security find-generic-password -s "Claude Code-credentials" -w', { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
932
|
+
const creds = JSON.parse(credsJson);
|
|
933
|
+
const token = creds?.claudeAiOauth?.accessToken;
|
|
934
|
+
if (!token)
|
|
935
|
+
return null;
|
|
936
|
+
const resp = await fetch('https://api.anthropic.com/api/oauth/usage', {
|
|
937
|
+
headers: {
|
|
938
|
+
'Authorization': `Bearer ${token}`,
|
|
939
|
+
'anthropic-beta': 'oauth-2025-04-20',
|
|
940
|
+
'Content-Type': 'application/json',
|
|
941
|
+
'User-Agent': 'ekkos-cli/dashboard',
|
|
942
|
+
},
|
|
943
|
+
});
|
|
944
|
+
if (!resp.ok)
|
|
945
|
+
return null;
|
|
946
|
+
return await resp.json();
|
|
947
|
+
}
|
|
948
|
+
catch {
|
|
949
|
+
return null;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
async function updateWindowBox() {
|
|
953
|
+
try {
|
|
954
|
+
const usage = await fetchAnthropicUsage();
|
|
955
|
+
let line1 = ' {gray-fg}No usage data{/gray-fg}';
|
|
956
|
+
let line2 = '';
|
|
957
|
+
// ── 5h Window (from Anthropic OAuth API) ──
|
|
958
|
+
if (usage?.five_hour) {
|
|
959
|
+
const pct = usage.five_hour.utilization;
|
|
960
|
+
const resetAt = new Date(usage.five_hour.resets_at).getTime();
|
|
961
|
+
const remainMs = Math.max(0, resetAt - Date.now());
|
|
962
|
+
const remainMin = Math.round(remainMs / 60000);
|
|
963
|
+
const rH = Math.floor(remainMin / 60);
|
|
964
|
+
const rM = remainMin % 60;
|
|
965
|
+
const pctColor = pct < 50 ? 'green' : pct < 80 ? 'yellow' : 'red';
|
|
966
|
+
const timeColor = remainMin > 120 ? 'green' : remainMin > 60 ? 'yellow' : 'red';
|
|
967
|
+
line1 = ` {bold}5h:{/bold}` +
|
|
968
|
+
` {${pctColor}-fg}${pct.toFixed(0)}% used{/${pctColor}-fg}` +
|
|
969
|
+
` {${timeColor}-fg}${rH}h${rM}m left{/${timeColor}-fg}`;
|
|
970
|
+
}
|
|
971
|
+
// ── Weekly (from Anthropic OAuth API) ──
|
|
972
|
+
if (usage?.seven_day) {
|
|
973
|
+
const pct = usage.seven_day.utilization;
|
|
974
|
+
const resetAt = new Date(usage.seven_day.resets_at);
|
|
975
|
+
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
976
|
+
const resetDay = dayNames[resetAt.getDay()];
|
|
977
|
+
const resetHour = resetAt.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
|
978
|
+
const pctColor = pct < 50 ? 'green' : pct < 80 ? 'yellow' : 'red';
|
|
979
|
+
line2 = ` {bold}Week:{/bold}` +
|
|
980
|
+
` {${pctColor}-fg}${pct.toFixed(0)}% used{/${pctColor}-fg}` +
|
|
981
|
+
` resets ${resetDay} ${resetHour}`;
|
|
982
|
+
}
|
|
983
|
+
windowBox.setContent(line1 + (line2 ? '\n' + line2 : ''));
|
|
984
|
+
}
|
|
985
|
+
catch (err) {
|
|
986
|
+
dlog(`Window: ${err.message}`);
|
|
987
|
+
windowBox.setContent(` {gray-fg}Usage data unavailable{/gray-fg}`);
|
|
988
|
+
}
|
|
989
|
+
try {
|
|
990
|
+
screen.render();
|
|
991
|
+
}
|
|
992
|
+
catch { }
|
|
993
|
+
}
|
|
994
|
+
// ── Handle terminal resize ──
|
|
995
|
+
// Recalculate all widget positions from new screen.height
|
|
996
|
+
screen.on('resize', () => {
|
|
997
|
+
try {
|
|
998
|
+
ensureLayoutSynced();
|
|
999
|
+
if (lastData)
|
|
1000
|
+
updateDashboard();
|
|
1001
|
+
else
|
|
1002
|
+
screen.render();
|
|
1003
|
+
}
|
|
1004
|
+
catch (err) {
|
|
1005
|
+
dlog(`Resize: ${err.message}`);
|
|
1006
|
+
}
|
|
1007
|
+
});
|
|
1008
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1009
|
+
// KEYBOARD SHORTCUTS - Only capture when dashboard pane has focus
|
|
1010
|
+
// In tmux split mode, this prevents capturing keys from Claude Code pane
|
|
1011
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1012
|
+
screen.key(['q', 'C-c'], () => {
|
|
1013
|
+
clearInterval(pollInterval);
|
|
1014
|
+
clearInterval(windowPollInterval);
|
|
1015
|
+
clearTimeout(sparkleTimer);
|
|
1016
|
+
screen.destroy();
|
|
1017
|
+
process.exit(0);
|
|
1018
|
+
});
|
|
1019
|
+
screen.key(['r'], () => {
|
|
1020
|
+
lastFileSize = 0;
|
|
1021
|
+
updateDashboard();
|
|
1022
|
+
updateWindowBox();
|
|
1023
|
+
});
|
|
1024
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1025
|
+
// FOCUS MANAGEMENT: In tmux split mode, DON'T auto-focus the turnBox
|
|
1026
|
+
// This prevents the dashboard from stealing focus from Claude Code on startup
|
|
1027
|
+
// User can manually focus by clicking into the dashboard pane
|
|
1028
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1029
|
+
// Check if we're in a tmux session
|
|
1030
|
+
const inTmux = process.env.TMUX !== undefined;
|
|
1031
|
+
if (!inTmux) {
|
|
1032
|
+
// Only auto-focus when running standalone (not in tmux split)
|
|
1033
|
+
turnBox.focus();
|
|
1034
|
+
}
|
|
1035
|
+
// Scroll controls for turn table
|
|
1036
|
+
screen.key(['up', 'k'], () => {
|
|
1037
|
+
turnBox.scroll(-1);
|
|
1038
|
+
screen.render();
|
|
1039
|
+
});
|
|
1040
|
+
screen.key(['down', 'j'], () => {
|
|
1041
|
+
turnBox.scroll(1);
|
|
1042
|
+
screen.render();
|
|
1043
|
+
});
|
|
1044
|
+
screen.key(['pageup', 'u'], () => {
|
|
1045
|
+
turnBox.scroll(-(turnBox.height - 2));
|
|
1046
|
+
screen.render();
|
|
1047
|
+
});
|
|
1048
|
+
screen.key(['pagedown', 'd'], () => {
|
|
1049
|
+
turnBox.scroll((turnBox.height - 2));
|
|
1050
|
+
screen.render();
|
|
1051
|
+
});
|
|
1052
|
+
screen.key(['home', 'g'], () => {
|
|
1053
|
+
turnBox.setScrollPerc(0);
|
|
1054
|
+
screen.render();
|
|
1055
|
+
});
|
|
1056
|
+
screen.key(['end', 'G'], () => {
|
|
1057
|
+
turnBox.setScrollPerc(100);
|
|
1058
|
+
screen.render();
|
|
1059
|
+
});
|
|
1060
|
+
screen.key(['?', 'h'], () => {
|
|
1061
|
+
// Quick help overlay
|
|
1062
|
+
const help = blessed.box({
|
|
1063
|
+
top: 'center',
|
|
1064
|
+
left: 'center',
|
|
1065
|
+
width: 50,
|
|
1066
|
+
height: 16,
|
|
1067
|
+
content: ('{bold}Navigation{/bold}\n' +
|
|
1068
|
+
' ↑/k/j/↓ Scroll line\n' +
|
|
1069
|
+
' PgUp/u Scroll page up\n' +
|
|
1070
|
+
' PgDn/d Scroll page down\n' +
|
|
1071
|
+
' g/Home Scroll to top\n' +
|
|
1072
|
+
' G/End Scroll to bottom\n' +
|
|
1073
|
+
'\n' +
|
|
1074
|
+
'{bold}Controls{/bold}\n' +
|
|
1075
|
+
' r Refresh now\n' +
|
|
1076
|
+
' q/Ctrl+C Quit\n' +
|
|
1077
|
+
'\n' +
|
|
1078
|
+
'{gray-fg}Press any key to close{/gray-fg}'),
|
|
1079
|
+
tags: true,
|
|
1080
|
+
border: 'line',
|
|
1081
|
+
style: { border: { fg: 'cyan' } },
|
|
1082
|
+
padding: 1,
|
|
1083
|
+
});
|
|
1084
|
+
screen.append(help);
|
|
1085
|
+
screen.render();
|
|
1086
|
+
// Close on any key press
|
|
1087
|
+
const closeHelp = () => {
|
|
1088
|
+
help.destroy();
|
|
1089
|
+
screen.render();
|
|
1090
|
+
screen.removeListener('key', closeHelp);
|
|
1091
|
+
};
|
|
1092
|
+
screen.on('key', closeHelp);
|
|
1093
|
+
});
|
|
1094
|
+
// Clear terminal buffer — prevents garbage text from previous commands
|
|
1095
|
+
screen.program.clear();
|
|
1096
|
+
// Dashboard is fully passive — no widget captures keyboard input
|
|
1097
|
+
updateDashboard();
|
|
1098
|
+
screen.render();
|
|
1099
|
+
// Delay first ccusage call — let blessed render first, then load heavy data
|
|
1100
|
+
setTimeout(() => updateWindowBox(), 2000);
|
|
1101
|
+
const pollInterval = setInterval(updateDashboard, refreshMs);
|
|
1102
|
+
const windowPollInterval = setInterval(updateWindowBox, 15000); // every 15s
|
|
1103
|
+
}
|
|
1104
|
+
// ── Helpers ──
|
|
1105
|
+
function fmtK(n) {
|
|
1106
|
+
if (n >= 1000000)
|
|
1107
|
+
return `${(n / 1000000).toFixed(1)}M`;
|
|
1108
|
+
if (n >= 1000)
|
|
1109
|
+
return `${(n / 1000).toFixed(1)}K`;
|
|
1110
|
+
return String(n);
|
|
1111
|
+
}
|
|
1112
|
+
function formatK(n) { return fmtK(n); }
|
|
1113
|
+
// ── Session picker ──
|
|
1114
|
+
async function pickSession() {
|
|
1115
|
+
const sessions = await (0, usage_parser_js_1.listEkkosSessions)(20);
|
|
1116
|
+
if (sessions.length === 0) {
|
|
1117
|
+
console.log(chalk_1.default.yellow('No sessions found with usage data.'));
|
|
1118
|
+
console.log(chalk_1.default.gray('Start a session with "ekkos run" first.'));
|
|
1119
|
+
return null;
|
|
1120
|
+
}
|
|
1121
|
+
const { default: inquirer } = await Promise.resolve().then(() => __importStar(require('inquirer')));
|
|
1122
|
+
const answer = await inquirer.prompt([
|
|
1123
|
+
{
|
|
1124
|
+
type: 'list',
|
|
1125
|
+
name: 'session',
|
|
1126
|
+
message: 'Choose session:',
|
|
1127
|
+
choices: sessions.map(s => ({
|
|
1128
|
+
name: `${s.name} ($${s.cost.toFixed(2)}, ${s.turnCount}t)`,
|
|
1129
|
+
value: s.name,
|
|
1130
|
+
})),
|
|
1131
|
+
},
|
|
1132
|
+
]);
|
|
1133
|
+
return answer.session;
|
|
1134
|
+
}
|
|
1135
|
+
// ── Commander command ──
|
|
1136
|
+
exports.dashboardCommand = new commander_1.Command('dashboard')
|
|
1137
|
+
.description('Live TUI dashboard for monitoring Claude Code session usage')
|
|
1138
|
+
.argument('[session-name]', 'ekkOS session name (e.g., dew-pod-hum)')
|
|
1139
|
+
.option('--latest', 'Auto-detect latest active session')
|
|
1140
|
+
.option('--wait-for-new', 'Wait for a new session to start (used by ekkos run --dashboard)')
|
|
1141
|
+
.option('--refresh <ms>', 'Polling interval in ms', '2000')
|
|
1142
|
+
.option('--compact', 'Minimal layout for small terminals')
|
|
1143
|
+
.action(async (sessionNameArg, options) => {
|
|
1144
|
+
const refreshMs = parseInt(options.refresh) || 2000;
|
|
1145
|
+
// --wait-for-new: poll until a brand new session appears
|
|
1146
|
+
if (options.waitForNew) {
|
|
1147
|
+
const { sessionName, jsonlPath } = await waitForNewSession();
|
|
1148
|
+
await launchDashboard(sessionName, jsonlPath, refreshMs);
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
let sessionName = null;
|
|
1152
|
+
if (sessionNameArg) {
|
|
1153
|
+
sessionName = sessionNameArg;
|
|
1154
|
+
}
|
|
1155
|
+
else if (options.latest) {
|
|
1156
|
+
sessionName = getLatestSession();
|
|
1157
|
+
if (!sessionName) {
|
|
1158
|
+
console.log(chalk_1.default.yellow('No active sessions found.'));
|
|
1159
|
+
process.exit(1);
|
|
1160
|
+
}
|
|
1161
|
+
console.log(chalk_1.default.gray(`Auto-detected: ${sessionName}`));
|
|
1162
|
+
}
|
|
1163
|
+
else {
|
|
1164
|
+
sessionName = await pickSession();
|
|
1165
|
+
if (!sessionName)
|
|
1166
|
+
process.exit(0);
|
|
1167
|
+
}
|
|
1168
|
+
const jsonlPath = resolveJsonlPath(sessionName);
|
|
1169
|
+
if (!jsonlPath) {
|
|
1170
|
+
console.log(chalk_1.default.red(`No JSONL found for "${sessionName}"`));
|
|
1171
|
+
console.log(chalk_1.default.gray('Run "ekkos sessions" to see active sessions'));
|
|
1172
|
+
process.exit(1);
|
|
1173
|
+
}
|
|
1174
|
+
await launchDashboard(sessionName, jsonlPath, refreshMs);
|
|
1175
|
+
});
|