glad-web 1.0.39 → 1.0.41

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.
@@ -0,0 +1,226 @@
1
+ const { CcusageRunner } = require('./ccusage-runner');
2
+ const { SOURCES, getUsageSource } = require('./source-catalog');
3
+
4
+ const SCOPES = new Set(['weekly', 'monthly']);
5
+ const CACHE_TTL_MS = 5 * 60 * 1000;
6
+
7
+ function defaultTimezone() {
8
+ try {
9
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
10
+ } catch (_error) {
11
+ return 'UTC';
12
+ }
13
+ }
14
+
15
+ function ccusageVersion() {
16
+ try {
17
+ return require('ccusage/package.json').version;
18
+ } catch (_error) {
19
+ return 'unknown';
20
+ }
21
+ }
22
+
23
+ function positiveNumber(value) {
24
+ const number = Number(value);
25
+ return Number.isFinite(number) && number > 0 ? number : 0;
26
+ }
27
+
28
+ function isGptModel(modelName) {
29
+ return /^gpt(?:-|$)/i.test(String(modelName || ''));
30
+ }
31
+
32
+ function modelCost(sourceId, modelName, cost) {
33
+ if (sourceId !== 'codex' || !isGptModel(modelName)) return null;
34
+ const value = positiveNumber(cost);
35
+ return value > 0 ? value : null;
36
+ }
37
+
38
+ function normalizeModel(sourceId, breakdown) {
39
+ const uncachedInputTokens = positiveNumber(breakdown.inputTokens)
40
+ + positiveNumber(breakdown.cacheCreationTokens);
41
+ const cachedInputTokens = positiveNumber(breakdown.cacheReadTokens);
42
+ const outputTokens = positiveNumber(breakdown.outputTokens);
43
+ return {
44
+ modelName: String(breakdown.modelName || 'Unknown'),
45
+ uncachedInputTokens,
46
+ cachedInputTokens,
47
+ outputTokens,
48
+ totalTokens: uncachedInputTokens + cachedInputTokens + outputTokens,
49
+ estimatedCostUSD: modelCost(sourceId, breakdown.modelName, breakdown.cost)
50
+ };
51
+ }
52
+
53
+ function sumModels(models) {
54
+ return models.reduce((totals, model) => {
55
+ totals.uncachedInputTokens += model.uncachedInputTokens;
56
+ totals.cachedInputTokens += model.cachedInputTokens;
57
+ totals.outputTokens += model.outputTokens;
58
+ totals.totalTokens += model.totalTokens;
59
+ if (model.estimatedCostUSD !== null) {
60
+ totals.estimatedCostUSD = (totals.estimatedCostUSD || 0) + model.estimatedCostUSD;
61
+ }
62
+ return totals;
63
+ }, {
64
+ uncachedInputTokens: 0,
65
+ cachedInputTokens: 0,
66
+ outputTokens: 0,
67
+ totalTokens: 0,
68
+ estimatedCostUSD: null
69
+ });
70
+ }
71
+
72
+ function fallbackModel(sourceId, agentRow) {
73
+ const names = Array.isArray(agentRow.modelsUsed) ? agentRow.modelsUsed : [];
74
+ const modelName = names.length === 1 ? names[0] : names.length > 1 ? 'Multiple models' : 'Unknown';
75
+ return normalizeModel(sourceId, {
76
+ modelName,
77
+ inputTokens: agentRow.inputTokens,
78
+ cacheCreationTokens: agentRow.cacheCreationTokens,
79
+ cacheReadTokens: agentRow.cacheReadTokens,
80
+ outputTokens: agentRow.outputTokens,
81
+ cost: names.length === 1 ? agentRow.totalCost : null
82
+ });
83
+ }
84
+
85
+ function normalizeAgentRow(sourceId, period, agentRow) {
86
+ const breakdowns = Array.isArray(agentRow.modelBreakdowns) ? agentRow.modelBreakdowns : [];
87
+ const models = breakdowns.length
88
+ ? breakdowns.map(item => normalizeModel(sourceId, item))
89
+ : [fallbackModel(sourceId, agentRow)];
90
+ models.sort((a, b) => b.totalTokens - a.totalTokens || a.modelName.localeCompare(b.modelName));
91
+ return { period, models, totals: sumModels(models) };
92
+ }
93
+
94
+ function findAgentRow(row, sourceId) {
95
+ return Array.isArray(row && row.agents)
96
+ ? row.agents.find(agent => agent && agent.agent === sourceId) || null
97
+ : null;
98
+ }
99
+
100
+ function availablePeriods(raw, scope, sourceId) {
101
+ return (raw[scope] || [])
102
+ .filter(row => findAgentRow(row, sourceId))
103
+ .map(row => String(row.period || ''))
104
+ .filter(Boolean)
105
+ .sort()
106
+ .reverse();
107
+ }
108
+
109
+ function dateInsideScope(date, scope, selectedPeriod) {
110
+ if (scope === 'monthly') return date.startsWith(`${selectedPeriod}-`);
111
+ const start = new Date(`${selectedPeriod}T00:00:00Z`);
112
+ const candidate = new Date(`${date}T00:00:00Z`);
113
+ if (Number.isNaN(start.getTime()) || Number.isNaN(candidate.getTime())) return false;
114
+ const end = new Date(start);
115
+ end.setUTCDate(end.getUTCDate() + 7);
116
+ return candidate >= start && candidate < end;
117
+ }
118
+
119
+ function buildDashboard(raw, sourceId, scope, requestedPeriod) {
120
+ const periods = availablePeriods(raw, scope, sourceId);
121
+ const selectedPeriod = periods.includes(requestedPeriod) ? requestedPeriod : periods[0] || null;
122
+ if (!selectedPeriod) {
123
+ return { availablePeriods: [], selectedPeriod: null, summary: { models: [], totals: sumModels([]) }, days: [] };
124
+ }
125
+
126
+ const scopeRow = (raw[scope] || []).find(row => row.period === selectedPeriod);
127
+ const summaryAgent = findAgentRow(scopeRow, sourceId);
128
+ const summary = summaryAgent
129
+ ? normalizeAgentRow(sourceId, selectedPeriod, summaryAgent)
130
+ : { models: [], totals: sumModels([]) };
131
+ const days = (raw.daily || [])
132
+ .filter(row => dateInsideScope(String(row.period || ''), scope, selectedPeriod))
133
+ .flatMap(row => {
134
+ const agent = findAgentRow(row, sourceId);
135
+ return agent ? [normalizeAgentRow(sourceId, String(row.period), agent)] : [];
136
+ });
137
+ return { availablePeriods: periods, selectedPeriod, summary, days };
138
+ }
139
+
140
+ class UsageService {
141
+ constructor(options = {}) {
142
+ this.runner = options.runner || null;
143
+ this.timezone = options.timezone || defaultTimezone();
144
+ this.cacheTtlMs = options.cacheTtlMs ?? CACHE_TTL_MS;
145
+ this.logger = options.logger || { debug() {} };
146
+ this.cached = null;
147
+ this.loading = null;
148
+ }
149
+
150
+ async getSnapshot(refresh = false) {
151
+ const fresh = this.cached && Date.now() - this.cached.loadedAt < this.cacheTtlMs;
152
+ if (!refresh && fresh) return this.cached;
153
+ if (!refresh && this.cached) {
154
+ this.loadSnapshot().catch(error => this.logger.debug(`Background usage refresh failed: ${error.message}`));
155
+ return this.cached;
156
+ }
157
+ return this.loadSnapshot();
158
+ }
159
+
160
+ async loadSnapshot() {
161
+ if (this.loading) return this.loading;
162
+ if (!this.runner) this.runner = new CcusageRunner();
163
+ this.loading = this.runner.loadAllPeriods(this.timezone)
164
+ .then(raw => {
165
+ this.cached = { raw, loadedAt: Date.now(), generatedAt: new Date().toISOString() };
166
+ return this.cached;
167
+ })
168
+ .finally(() => { this.loading = null; });
169
+ return this.loading;
170
+ }
171
+
172
+ async listSources(refresh = false) {
173
+ const snapshot = await this.getSnapshot(refresh);
174
+ const present = new Set();
175
+ for (const scope of ['daily', 'weekly', 'monthly']) {
176
+ for (const row of snapshot.raw[scope] || []) {
177
+ for (const agent of row.agents || []) present.add(agent.agent);
178
+ }
179
+ }
180
+ return {
181
+ sources: SOURCES.filter(source => present.has(source.id)),
182
+ generatedAt: snapshot.generatedAt,
183
+ timezone: this.timezone,
184
+ engine: { name: 'ccusage', version: ccusageVersion(), pricingMode: 'embedded' }
185
+ };
186
+ }
187
+
188
+ async getDashboard(sourceId, scope, selectedPeriod, refresh = false) {
189
+ const source = getUsageSource(sourceId);
190
+ if (!source) {
191
+ const error = new Error('Unsupported usage source');
192
+ error.statusCode = 400;
193
+ throw error;
194
+ }
195
+ if (!SCOPES.has(scope)) {
196
+ const error = new Error('Scope must be weekly or monthly');
197
+ error.statusCode = 400;
198
+ throw error;
199
+ }
200
+ const snapshot = await this.getSnapshot(refresh);
201
+ return {
202
+ source,
203
+ scope,
204
+ ...buildDashboard(snapshot.raw, source.id, scope, selectedPeriod),
205
+ generatedAt: snapshot.generatedAt,
206
+ timezone: this.timezone,
207
+ engine: { name: 'ccusage', version: ccusageVersion(), pricingMode: 'embedded' },
208
+ cost: source.id === 'codex' ? {
209
+ basis: 'ccusage estimate for Codex GPT models',
210
+ note: 'Estimated from ccusage model pricing; it is not an actual provider bill.'
211
+ } : null
212
+ };
213
+ }
214
+ }
215
+
216
+ module.exports = {
217
+ UsageService,
218
+ availablePeriods,
219
+ buildDashboard,
220
+ dateInsideScope,
221
+ isGptModel,
222
+ modelCost,
223
+ normalizeAgentRow,
224
+ normalizeModel,
225
+ sumModels
226
+ };
package/lib/web/codex.js CHANGED
@@ -1,4 +1,7 @@
1
1
  function codexText(text) { return escapeHtml(text || '').replace(/\n/g, '<br>'); }
2
+ function codexReadyForInput() {
3
+ return codexState.presentation === 'structured' && codexState.status === 'idle' && !codexState.aborting;
4
+ }
2
5
  function codexJson(value) {
3
6
  if (typeof value === 'string') return value;
4
7
  try { return JSON.stringify(value || {}, null, 2); } catch (_) { return String(value || ''); }
@@ -324,7 +327,7 @@
324
327
  i += 1;
325
328
  }
326
329
  for (const request of codexPendingPermissions) if (!usedPermissions.has(request.id)) parts.push(renderCodexPermission(request));
327
- const skillBubble = selectedCodexSkill && codexState.status === 'idle'
330
+ const skillBubble = selectedCodexSkill && codexReadyForInput()
328
331
  ? `<div class="codex-skill-bubble" role="status" aria-label="Selected skill: ${escapeHtml(selectedCodexSkill.name)}"><span class="codex-skill-bubble-name">Skill · ${escapeHtml(selectedCodexSkill.name)}</span><button type="button" class="codex-skill-bubble-close" onclick="clearCodexSkillSelection()" title="Remove selected skill" aria-label="Remove selected skill">×</button></div>`
329
332
  : '';
330
333
  const working = `<div class="codex-working-indicator" role="status" aria-label="Codex is working" title="Codex is working"${codexState.status === 'running' ? '' : ' style="display:none"'}></div>`;
@@ -443,13 +446,18 @@
443
446
  const modelButton = document.getElementById('codex-model-btn');
444
447
  if (modelButton) modelButton.textContent = 'Model';
445
448
  const abort = document.getElementById('codex-abort-btn');
446
- if (abort) abort.disabled = !codexState.canAbort;
449
+ if (abort) {
450
+ abort.disabled = !codexState.canAbort;
451
+ abort.textContent = codexState.aborting ? 'Aborting…' : 'Abort';
452
+ }
447
453
  const compact = document.getElementById('codex-compact-btn');
448
454
  if (compact) { compact.disabled = !codexState.canCompact; compact.textContent = codexState.compacting ? 'Compacting' : 'Compact'; }
449
455
  const skills = document.getElementById('codex-skills-btn');
450
- if (skills) { skills.disabled = !(codexState.presentation === 'structured' && codexState.status === 'idle'); skills.classList.toggle('primary', Boolean(selectedCodexSkill)); }
456
+ if (skills) { skills.disabled = !codexReadyForInput(); skills.classList.toggle('primary', Boolean(selectedCodexSkill)); }
457
+ const resume = document.getElementById('codex-resume-btn');
458
+ if (resume) resume.disabled = !codexReadyForInput();
451
459
  const fork = document.getElementById('codex-fork-btn');
452
- if (fork) fork.disabled = !(codexState.presentation === 'structured' && codexState.status === 'idle');
460
+ if (fork) fork.disabled = !codexReadyForInput();
453
461
  const terminal = document.getElementById('codex-terminal-switch');
454
462
  if (terminal) { terminal.textContent = codexState.presentation === 'terminal' ? 'CHAT' : 'TERM'; terminal.disabled = codexState.presentation === 'structured' && !codexState.canSwitchToTerminal; terminal.title = codexState.presentation === 'terminal' ? 'Return to Codex chat' : 'Switch to Codex terminal'; }
455
463
  renderCodexStateBar();
@@ -464,6 +472,7 @@
464
472
  const pending = Number(codexState.pendingPermissionCount || codexPendingPermissions.filter(item => item.status === 'pending').length) || 0;
465
473
  const subagents = Number(codexState.activeSubagentCount || 0) || 0;
466
474
  const parts = [];
475
+ if (codexState.aborting) parts.push('<span class="claude-state-pill warn">Stopping Codex…</span>');
467
476
  if (pending || codexState.status === 'waiting_approval') parts.push(`<button type="button" class="claude-state-pill warn codex-approval-jump" onclick="jumpToCodexApproval()" title="Jump to pending approval" aria-label="Jump to pending approval">${pending || 1} approval${pending === 1 ? '' : 's'}<span aria-hidden="true">↓</span></button>`);
468
477
  if (subagents) parts.push(`<span class="claude-state-pill">${subagents} subagent${subagents === 1 ? '' : 's'} running</span>`);
469
478
  el.innerHTML = parts.join('');
@@ -589,7 +598,12 @@
589
598
  applyCodexState({ permissionMode, sandboxMode });
590
599
  sendCodexSettings({ permissionMode, sandboxMode });
591
600
  }
592
- function abortCodexSession() { if (currentSocket?.readyState === 1) currentSocket.send(JSON.stringify({ type: 'codex-abort' })); }
601
+ function abortCodexSession() {
602
+ if (!codexState.canAbort || codexState.aborting || currentSocket?.readyState !== 1) return false;
603
+ currentSocket.send(JSON.stringify({ type: 'codex-abort' }));
604
+ applyCodexState({ aborting: true, canAbort: false });
605
+ return true;
606
+ }
593
607
  function requestCodexStatus() { if (currentSocket?.readyState === 1) currentSocket.send(JSON.stringify({ type: 'codex-status' })); }
594
608
  function compactCodexContext() {
595
609
  if (!codexState.canCompact || currentSocket?.readyState !== 1) return false;
@@ -599,6 +613,7 @@
599
613
  }
600
614
  function respondCodexPermission(id, decision) { if (currentSocket?.readyState === 1) currentSocket.send(JSON.stringify({ type: 'codex-permission', id, decision })); }
601
615
  async function toggleCodexResumePanel() {
616
+ if (!codexReadyForInput()) return;
602
617
  codexResumePanelOpen = !codexResumePanelOpen;
603
618
  codexModelPanelOpen = false;
604
619
  codexForkPanelOpen = false;
@@ -615,7 +630,7 @@
615
630
  await loadCodexThreadPanel(panel, 'resume');
616
631
  }
617
632
  async function toggleCodexForkPanel() {
618
- if (!(codexState.presentation === 'structured' && codexState.status === 'idle')) return;
633
+ if (!codexReadyForInput()) return;
619
634
  codexForkPanelOpen = !codexForkPanelOpen;
620
635
  codexModelPanelOpen = false;
621
636
  codexResumePanelOpen = false;
@@ -889,7 +904,7 @@
889
904
  }
890
905
 
891
906
  async function toggleCodexSkillPanel() {
892
- if (!(codexState.presentation === 'structured' && codexState.status === 'idle')) return;
907
+ if (!codexReadyForInput()) return;
893
908
  codexSkillPanelOpen = !codexSkillPanelOpen;
894
909
  codexModelPanelOpen = false;
895
910
  codexResumePanelOpen = false;
@@ -206,6 +206,7 @@
206
206
  return;
207
207
  }
208
208
  if ((val || readyImageAttachments.length) && isCodexSession() && codexState.presentation === 'structured') {
209
+ if (!codexReadyForInput()) return;
209
210
  if (currentSocket && currentSocket.readyState === 1) {
210
211
  currentSocket.send(JSON.stringify({
211
212
  type: 'codex-input',
package/lib/web/core.js CHANGED
@@ -30,9 +30,19 @@
30
30
  let claudeResumeItemsLoaded = false;
31
31
  let claudeRenderFrame = null;
32
32
  let claudeApprovalJumpIndex = 0;
33
+ function createDefaultCodexState() {
34
+ return {
35
+ permissionMode: 'default', sandboxMode: 'default',
36
+ effectivePermissionMode: null, effectiveSandboxMode: null,
37
+ model: null, effort: null, status: 'idle', threadId: null,
38
+ presentation: 'structured', models: [], aborting: false,
39
+ canAbort: false, canCompact: false, compacting: false,
40
+ canSwitchToTerminal: false, canSwitchToStructured: false
41
+ };
42
+ }
33
43
  let codexMessages = [];
34
44
  let codexPendingPermissions = [];
35
- let codexState = { permissionMode: 'default', sandboxMode: 'default', effectivePermissionMode: null, effectiveSandboxMode: null, model: null, effort: null, status: 'idle', threadId: null, presentation: 'structured', models: [], canAbort: false, canCompact: false, compacting: false, canSwitchToTerminal: false, canSwitchToStructured: false };
45
+ let codexState = createDefaultCodexState();
36
46
  let codexModelPanelOpen = false;
37
47
  let codexModelCandidate = null;
38
48
  let codexResumePanelOpen = false;
@@ -226,14 +236,14 @@
226
236
  : '';
227
237
  html += `<div class="session-card">
228
238
  <div class="session-info">
229
- <h3>${escapeHtml(s.name)}${s.hasUnreadCompletion ? '<span class="completion-dot" title="Completed"></span>' : ''}${timerBadge} <button class="icon-btn" onclick="renameSession('${s.id}', decodePathValue('${encodedName}'), event)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg></button></h3>
239
+ <h3>${escapeHtml(s.name)}${s.hasUnreadCompletion ? '<span class="completion-dot" title="Completed"></span>' : ''}${timerBadge} <button type="button" class="icon-btn session-edit-btn" title="Rename session" aria-label="Rename session" onclick="renameSession('${s.id}', decodePathValue('${encodedName}'), event)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg></button></h3>
230
240
  <p>${escapeHtml(s.tool)}</p>
231
241
  <p>${new Date(s.startTime).toLocaleTimeString()}</p>
232
242
  </div>
233
243
  <div class="session-actions">
234
244
  ${renderServerChanSessionAction(s)}
235
245
  <button class="btn-join" onclick="joinSession('${s.id}', decodePathValue('${encodedName}'), '${escapeHtml(s.toolKey || '')}')">Connect</button>
236
- <button class="icon-btn btn-delete" onclick="deleteSession('${s.id}', event)"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg></button>
246
+ <button type="button" class="icon-btn btn-delete session-delete-btn" title="Delete session" aria-label="Delete session" onclick="deleteSession('${s.id}', event)"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg></button>
237
247
  </div>
238
248
  <div class="session-dir-row">
239
249
  <button class="copy-dir-btn" title="Copy directory" onclick="copySessionDirectory(decodePathValue('${encodedDir}'), event)">
@@ -21,7 +21,18 @@
21
21
  <h1><img class="header-logo" src="logo.svg" alt="">Glad</h1>
22
22
  <div class="header-actions">
23
23
  <button class="header-action-btn" onclick="showToolModal()" title="New AI session"><span>+</span><span>Session</span></button>
24
- <button class="header-action-btn" onclick="showScheduleModal()" title="New scheduled task"><span>+</span><span>Task</span></button>
24
+ <button id="usage-dashboard-button" class="header-action-btn icon-only" type="button"
25
+ onclick="showUsageSourceModal()" title="Usage dashboard" aria-label="Usage dashboard">
26
+ <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
27
+ <path d="M4 19V10"></path><path d="M10 19V5"></path><path d="M16 19v-7"></path><path d="M22 19V2"></path>
28
+ </svg>
29
+ </button>
30
+ <button id="schedule-create-button" class="header-action-btn icon-only" type="button"
31
+ onclick="showScheduleModal()" title="New scheduled task" aria-label="New scheduled task">
32
+ <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
33
+ <circle cx="12" cy="13" r="8"></circle><path d="M12 9v4l2.5 1.5"></path><path d="M9 2h6"></path><path d="M12 2v3"></path>
34
+ </svg>
35
+ </button>
25
36
  <button id="app-settings-button" class="header-action-btn icon-only" type="button"
26
37
  onclick="openSettings()" title="Settings" aria-label="Settings">
27
38
  <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
@@ -44,6 +55,67 @@
44
55
  </div>
45
56
  </div>
46
57
 
58
+ <!-- Usage Dashboard View -->
59
+ <div id="usage-view" class="view">
60
+ <div class="usage-nav">
61
+ <button class="usage-nav-button" type="button" onclick="showLobby()" aria-label="Back to lobby">
62
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>
63
+ Lobby
64
+ </button>
65
+ <div class="usage-nav-title">
66
+ <strong id="usage-source-title">Usage</strong>
67
+ <span id="usage-updated-at"></span>
68
+ </div>
69
+ <div class="usage-nav-actions">
70
+ <button class="icon-btn usage-nav-icon" type="button" onclick="showUsageSourceModal()" title="Change CLI" aria-label="Change CLI">
71
+ <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16"></path><path d="M4 12h16"></path><path d="M4 17h16"></path></svg>
72
+ </button>
73
+ <button id="usage-refresh-button" class="icon-btn usage-nav-icon" type="button" onclick="refreshUsageDashboard()" title="Refresh usage" aria-label="Refresh usage">
74
+ <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7"></path><polyline points="21 3 21 9 15 9"></polyline></svg>
75
+ </button>
76
+ </div>
77
+ </div>
78
+ <main class="usage-content">
79
+ <div class="usage-filter-bar">
80
+ <div class="usage-period-toggle" role="tablist" aria-label="Usage scope">
81
+ <button id="usage-scope-weekly" class="active" type="button" onclick="setUsageScope('weekly')">Week</button>
82
+ <button id="usage-scope-monthly" type="button" onclick="setUsageScope('monthly')">Month</button>
83
+ </div>
84
+ <label class="usage-period-picker">
85
+ <span>Period</span>
86
+ <select id="usage-period-select" onchange="selectUsagePeriod(this.value)" aria-label="Select period"></select>
87
+ </label>
88
+ </div>
89
+ <div id="usage-loading" class="usage-state">Loading usage data...</div>
90
+ <div id="usage-dashboard" hidden>
91
+ <section id="usage-summary" class="usage-summary usage-summary-totals" aria-label="All-model totals"></section>
92
+ <section class="usage-panel">
93
+ <div class="usage-panel-heading"><div><h2>Model summary</h2><p>All models in the selected period.</p></div></div>
94
+ <div id="usage-model-summary" class="usage-table-wrap"></div>
95
+ </section>
96
+ <section class="usage-panel">
97
+ <div class="usage-panel-heading">
98
+ <div><h2>Tokens by day</h2><p>Daily totals stacked by model.</p></div>
99
+ <div id="usage-token-legend" class="usage-legend" aria-label="Token chart model legend"></div>
100
+ </div>
101
+ <div id="usage-token-chart" class="usage-chart"></div>
102
+ </section>
103
+ <section id="usage-cost-panel" class="usage-panel">
104
+ <div class="usage-panel-heading">
105
+ <div><h2>Cost by day</h2><p>ccusage estimates stacked by GPT model.</p></div>
106
+ <div id="usage-cost-legend" class="usage-legend" aria-label="Cost chart model legend"></div>
107
+ </div>
108
+ <div id="usage-cost-chart" class="usage-chart"></div>
109
+ </section>
110
+ <section class="usage-panel">
111
+ <div class="usage-panel-heading"><div><h2>Daily details</h2><p>Exact daily totals reported by ccusage.</p></div></div>
112
+ <div id="usage-daily-table" class="usage-table-wrap"></div>
113
+ </section>
114
+ <div id="usage-engine-note" class="usage-pricing-note"></div>
115
+ </div>
116
+ </main>
117
+ </div>
118
+
47
119
  <!-- Terminal View -->
48
120
  <div id="terminal-view" class="view">
49
121
  <div id="top-ui">
@@ -259,6 +331,20 @@
259
331
  </div>
260
332
  </div>
261
333
 
334
+ <!-- Usage Source Modal -->
335
+ <div id="usage-source-overlay" onclick="closeUsageSourceModal(event)">
336
+ <div id="usage-source-modal" role="dialog" aria-modal="true" aria-labelledby="usage-source-modal-title" onclick="event.stopPropagation()">
337
+ <div class="usage-source-header">
338
+ <div>
339
+ <h2 id="usage-source-modal-title">Usage Dashboard</h2>
340
+ <p>Select a CLI with local usage history.</p>
341
+ </div>
342
+ <button class="icon-btn" type="button" onclick="closeUsageSourceModal()" aria-label="Close">×</button>
343
+ </div>
344
+ <div id="usage-sources-list"><p class="usage-modal-state">Reading local usage data...</p></div>
345
+ </div>
346
+ </div>
347
+
262
348
  <!-- App Settings Modal -->
263
349
  <div id="settings-modal-overlay" onclick="closeSettings(event)">
264
350
  <div id="settings-modal" role="dialog" aria-modal="true" aria-labelledby="settings-modal-title" onclick="event.stopPropagation()">
@@ -367,5 +453,6 @@
367
453
  <script src="timed-inputs.js"></script>
368
454
  <script src="terminal-scroll.js"></script>
369
455
  <script src="git.js"></script>
456
+ <script src="usage.js"></script>
370
457
  </body>
371
458
  </html>
@@ -155,7 +155,7 @@
155
155
  document.getElementById('codex-prompt-panel').classList.remove('active');
156
156
  document.getElementById('codex-skill-panel').classList.remove('active');
157
157
  document.getElementById('codex-control-rail').scrollLeft = 0;
158
- codexState = { permissionMode: 'default', sandboxMode: 'default', effectivePermissionMode: null, effectiveSandboxMode: null, model: null, effort: null, status: 'idle', threadId: null, presentation: 'structured', models: [], canAbort: false, canCompact: false, compacting: false, canSwitchToTerminal: false, canSwitchToStructured: false };
158
+ codexState = createDefaultCodexState();
159
159
  setClaudeModeEnabled(false);
160
160
  applyCodexState(codexState);
161
161
  installCodexLazyDetailHandler();
@@ -11,8 +11,7 @@
11
11
  .header-action-btn.icon-only { width: 36px; padding: 0; }
12
12
  .header-action-btn:active { background: #0062cc; transform: scale(.97); }
13
13
  .btn-retry { background: #333; color: #fff; border: none; padding: 8px 16px; border-radius: 20px; margin-top: 10px; cursor: pointer; }
14
- .session-card { background: var(--card-bg); border-radius: 12px; padding: 12px 16px 8px; margin-bottom: 12px; display: grid; grid-template-columns: minmax(0, 1fr) auto; column-gap: 10px; row-gap: 0; align-items: center; transition: transform 0.1s; position: relative; }
15
- .session-card:active { transform: scale(0.98); }
14
+ .session-card { background: var(--card-bg); border-radius: 12px; padding: 12px 16px 8px; margin-bottom: 12px; display: grid; grid-template-columns: minmax(0, 1fr) auto; column-gap: 10px; row-gap: 0; align-items: center; position: relative; }
16
15
  .completion-dot { width: 9px; height: 9px; border-radius: 50%; background: #ff3b30; flex-shrink: 0; }
17
16
  .session-info { flex: 1; min-width: 0; }
18
17
  .session-info h3 { margin: 0 0 4px 0; font-size: 17px; display: flex; align-items: center; gap: 8px; }
@@ -29,6 +28,11 @@
29
28
  .btn-join { background: rgba(255,255,255,0.1); border: none; color: var(--primary); padding: 8px 14px; border-radius: 18px; font-weight: 600; font-size: 14px; cursor: pointer; }
30
29
  .icon-btn { color: var(--text-dim); background: none; border: none; padding: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
31
30
  .icon-btn:active { color: var(--text); }
31
+ .session-edit-btn, .session-delete-btn { position: relative; }
32
+ .session-edit-btn::before, .session-delete-btn::before { content: ""; position: absolute; }
33
+ .session-edit-btn::before { inset: -9px; }
34
+ .session-delete-btn::before { inset: -8px; }
35
+ .session-edit-btn svg, .session-delete-btn svg { pointer-events: none; }
32
36
  .btn-delete { color: #ff3b30; }
33
37
  #modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.8); z-index: 10000; display: none; align-items: center; justify-content: center; padding: 20px; }
34
38
  #tool-modal { background: var(--card-bg); width: 100%; max-width: 400px; border-radius: 16px; padding: 20px; box-shadow: 0 20px 40px rgba(0,0,0,0.4); }
@@ -59,6 +63,74 @@
59
63
  .tool-item { padding: 12px; border-bottom: 1px solid #333; cursor: pointer; display: flex; align-items: center; border-radius: 8px; margin-top: 4px; }
60
64
  .tool-item:hover { background: rgba(255,255,255,0.05); }
61
65
  .tool-icon { width: 32px; height: 32px; background: #333; border-radius: 8px; margin-right: 12px; display: flex; align-items: center; justify-content: center; font-size: 18px; }
66
+ #usage-source-overlay { position: fixed; inset: 0; z-index: 10030; display: none; align-items: center; justify-content: center; padding: 20px; box-sizing: border-box; background: rgba(0,0,0,0.82); }
67
+ #usage-source-modal { width: 100%; max-width: 420px; max-height: min(680px, 90dvh); overflow-y: auto; padding: 20px; box-sizing: border-box; border-radius: 16px; background: var(--card-bg); box-shadow: 0 20px 40px rgba(0,0,0,0.45); }
68
+ .usage-source-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
69
+ .usage-source-header h2 { margin: 0; font-size: 20px; }
70
+ .usage-source-header p { margin: 5px 0 0; color: var(--text-dim); font-size: 12px; }
71
+ .usage-source-header .icon-btn { font-size: 25px; line-height: 1; }
72
+ .usage-source-item { width: 100%; display: flex; align-items: center; gap: 12px; padding: 12px; margin-top: 5px; box-sizing: border-box; border: 0; border-radius: 10px; background: transparent; color: #fff; text-align: left; cursor: pointer; }
73
+ .usage-source-item:hover, .usage-source-item:active { background: rgba(255,255,255,0.07); }
74
+ .usage-source-badge { width: 38px; height: 38px; flex: 0 0 auto; display: flex; align-items: center; justify-content: center; border-radius: 10px; background: rgba(0,122,255,0.18); color: #78b7ff; font-size: 12px; font-weight: 850; letter-spacing: .03em; }
75
+ .usage-source-copy { min-width: 0; flex: 1; }
76
+ .usage-source-copy strong { display: block; font-size: 15px; }
77
+ .usage-source-copy span { display: block; margin-top: 3px; color: var(--text-dim); font-size: 11px; }
78
+ .usage-source-arrow { color: var(--text-dim); font-size: 20px; }
79
+ .usage-modal-state { padding: 26px 8px; color: var(--text-dim); text-align: center; font-size: 13px; line-height: 1.5; }
80
+
81
+ #usage-view { background: #09090a; }
82
+ .usage-nav { min-height: 54px; flex: 0 0 auto; display: grid; grid-template-columns: minmax(90px, 1fr) minmax(0, 2fr) minmax(90px, 1fr); align-items: center; gap: 8px; padding: env(safe-area-inset-top) 14px 0; border-bottom: 1px solid rgba(255,255,255,0.08); background: rgba(18,18,18,0.96); box-sizing: content-box; }
83
+ .usage-nav-button { display: inline-flex; align-items: center; justify-self: start; gap: 4px; padding: 8px 0; border: 0; background: none; color: var(--primary); font-size: 15px; font-weight: 600; cursor: pointer; }
84
+ .usage-nav-title { min-width: 0; text-align: center; }
85
+ .usage-nav-title strong { display: block; overflow: hidden; color: #fff; font-size: 16px; text-overflow: ellipsis; white-space: nowrap; }
86
+ .usage-nav-title span { display: block; min-height: 13px; margin-top: 2px; overflow: hidden; color: var(--text-dim); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
87
+ .usage-nav-actions { display: flex; justify-self: end; gap: 8px; }
88
+ .usage-nav-icon { width: 34px; height: 34px; padding: 0; border-radius: 50%; background: rgba(255,255,255,0.06); color: var(--primary); }
89
+ .usage-nav-icon.loading svg { animation: usage-spin .8s linear infinite; }
90
+ @keyframes usage-spin { to { transform: rotate(360deg); } }
91
+ .usage-content { flex: 1; overflow-y: auto; padding: 18px max(16px, calc((100vw - 1120px) / 2)) calc(28px + env(safe-area-inset-bottom)); box-sizing: border-box; }
92
+ .usage-filter-bar { width: min(100%, 680px); display: grid; grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr); align-items: end; gap: 12px; margin: 0 auto 18px; }
93
+ .usage-period-toggle { display: grid; grid-template-columns: repeat(2, 1fr); gap: 4px; padding: 4px; border-radius: 10px; background: var(--card-bg); }
94
+ .usage-period-toggle button { min-height: 34px; border: 0; border-radius: 7px; background: transparent; color: var(--text-dim); font-size: 13px; font-weight: 750; cursor: pointer; }
95
+ .usage-period-toggle button.active { background: var(--primary); color: #fff; }
96
+ .usage-period-picker span { display: block; margin: 0 0 5px 2px; color: var(--text-dim); font-size: 10px; font-weight: 750; text-transform: uppercase; }
97
+ .usage-period-picker select { width: 100%; min-height: 42px; padding: 8px 34px 8px 11px; box-sizing: border-box; border: 1px solid rgba(255,255,255,0.09); border-radius: 10px; background: var(--card-bg); color: #fff; font-size: 13px; font-weight: 650; outline: none; }
98
+ .usage-state { padding: 70px 16px; color: var(--text-dim); text-align: center; font-size: 14px; line-height: 1.6; }
99
+ .usage-state.error { color: #ff6b61; }
100
+ .usage-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 12px; }
101
+ .usage-summary.usage-summary-totals { grid-template-columns: repeat(2, minmax(0, 1fr)); }
102
+ .usage-summary.usage-summary-totals .usage-summary-card:only-child { grid-column: 1 / -1; }
103
+ .usage-summary-card { min-width: 0; padding: 16px; border: 1px solid rgba(255,255,255,0.07); border-radius: 13px; background: var(--card-bg); }
104
+ .usage-summary-card .label { display: flex; align-items: center; gap: 7px; color: var(--text-dim); font-size: 12px; font-weight: 650; }
105
+ .usage-summary-card .dot { width: 8px; height: 8px; flex: 0 0 auto; border-radius: 50%; }
106
+ .usage-summary-card .value { display: block; margin-top: 9px; overflow: hidden; color: #fff; font-size: clamp(20px, 3vw, 29px); font-weight: 760; letter-spacing: -.03em; text-overflow: ellipsis; white-space: nowrap; }
107
+ .usage-summary-card .exact { display: block; margin-top: 5px; overflow: hidden; color: #66666b; font: 10px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; }
108
+ .usage-panel { margin-top: 12px; padding: 17px; border: 1px solid rgba(255,255,255,0.07); border-radius: 13px; background: var(--card-bg); }
109
+ .usage-panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
110
+ .usage-panel-heading h2 { margin: 0; font-size: 16px; }
111
+ .usage-panel-heading p { margin: 5px 0 0; color: var(--text-dim); font-size: 11px; }
112
+ .usage-legend { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; color: var(--text-dim); font-size: 10px; }
113
+ .usage-legend span { display: inline-flex; align-items: center; gap: 4px; }
114
+ .usage-legend i { width: 7px; height: 7px; flex: 0 0 auto; border-radius: 50%; }
115
+ .usage-summary-card .dot.tokens { background: #0a84ff; }
116
+ .usage-summary-card .dot.cost { background: #ff9f0a; }
117
+ .usage-chart { display: flex; flex-direction: column; gap: 9px; }
118
+ .usage-chart-row { display: grid; grid-template-columns: 82px minmax(80px, 1fr) 82px; align-items: center; gap: 10px; min-height: 18px; }
119
+ .usage-chart-label { overflow: hidden; color: #b5b5ba; font: 10px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; }
120
+ .usage-chart-track { height: 10px; display: flex; overflow: hidden; border-radius: 999px; background: rgba(255,255,255,0.06); }
121
+ .usage-chart-track span { display: block; height: 100%; min-width: 0; }
122
+ .usage-chart-total { color: #77777c; font-size: 10px; text-align: right; white-space: nowrap; }
123
+ .usage-table-wrap { overflow-x: auto; }
124
+ .usage-table { width: 100%; border-collapse: collapse; font-size: 12px; }
125
+ .usage-table th { padding: 8px 10px; border-bottom: 1px solid rgba(255,255,255,0.1); color: var(--text-dim); font-size: 10px; font-weight: 750; text-align: right; white-space: nowrap; }
126
+ .usage-table th:first-child, .usage-table td:first-child { padding-left: 0; text-align: left; }
127
+ .usage-table th:last-child, .usage-table td:last-child { padding-right: 0; }
128
+ .usage-table td { padding: 11px 10px; border-bottom: 1px solid rgba(255,255,255,0.055); color: #d8d8dc; text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
129
+ .usage-table tr:last-child td { border-bottom: 0; }
130
+ .usage-table tfoot td { border-top: 1px solid rgba(255,255,255,0.12); border-bottom: 0; color: #fff; font-weight: 750; }
131
+ .usage-models { max-width: 220px; overflow: hidden; color: var(--text-dim); text-overflow: ellipsis; }
132
+ .usage-pricing-note { margin: 14px 3px 0; color: #6f6f74; font-size: 10px; line-height: 1.5; text-align: center; }
133
+ .usage-empty { padding: 30px 10px; color: var(--text-dim); text-align: center; font-size: 13px; }
62
134
  #terminal-view { background: #000; overflow-anchor: none; position: relative; }
63
135
  #top-ui { flex-shrink: 0; background: #121212; border-bottom: 1px solid #222; z-index: 2000; padding-top: env(safe-area-inset-top); position: relative; }
64
136
  #nav-bar { display: flex; align-items: center; padding: 8px 14px; border-bottom: 1px solid #222; }
@@ -414,6 +486,15 @@
414
486
  .step-card { border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; padding: 10px; margin-bottom: 8px; background: rgba(255,255,255,0.04); }
415
487
  .step-grid { display: grid; grid-template-columns: minmax(110px, 150px) 1fr auto; gap: 8px; align-items: center; }
416
488
  @media (max-width: 640px) {
489
+ .usage-content { padding: 14px 10px calc(24px + env(safe-area-inset-bottom)); }
490
+ .usage-filter-bar { grid-template-columns: 1fr; gap: 9px; }
491
+ .usage-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
492
+ .usage-summary-card { padding: 13px; }
493
+ .usage-panel { padding: 13px; }
494
+ .usage-panel-heading { flex-direction: column; gap: 10px; }
495
+ .usage-legend { justify-content: flex-start; }
496
+ .usage-chart-row { grid-template-columns: 70px minmax(70px, 1fr); gap: 8px; }
497
+ .usage-chart-total { display: none; }
417
498
  .schedule-row { flex-direction: column; }
418
499
  .schedule-actions { justify-content: flex-start; }
419
500
  .step-grid { grid-template-columns: 1fr; }
@@ -439,6 +520,10 @@
439
520
  #codex-chat-container { padding: 10px 10px calc(22px + env(safe-area-inset-bottom)); }
440
521
  .codex-message-block.user { max-width: 94%; }
441
522
  .claude-permission-actions { justify-content: flex-start; }
523
+ .usage-nav { grid-template-columns: 78px minmax(0, 1fr) 78px; padding-left: 10px; padding-right: 10px; }
524
+ .usage-nav-actions { gap: 5px; }
525
+ .usage-nav-icon { width: 32px; height: 32px; }
526
+ .usage-summary-card .value { font-size: 21px; }
442
527
  }
443
528
  @media (max-width: 430px) {
444
529
  #nav-bar > div:last-child .icon-btn:not(#codex-terminal-switch) { width: 34px; font-size: 0 !important; gap: 0 !important; }