codex-copilot-dx 0.9.5 → 0.9.6

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 CHANGED
@@ -236,6 +236,10 @@ The dashboard loads local status, a live GitHub Copilot model directory, and a s
236
236
 
237
237
  The transparent, single-color double-peak favicon follows the dashboard's saved light/dark choice without a separate network request.
238
238
 
239
+ Usage Analytics is collapsed by default. Opening it adds daily input/output token bars (7/30/90 days) and a 365-day calendar of recorded usage calls or tokens, grouped in the browser's time zone. Cache-read tokens are already part of input and are not added again. Model and range filters use the current snapshot without additional requests; selecting a day shows its filtered model totals in the Usage table, and All history restores the original table. Analytics shares the existing current/rotated-log scan and exposes only bounded metadata (365 days, up to 100 model filters). Days before retained history are hatched, not presented as zero; the oldest retained day may be partial. Counts describe retained usage records, not messages, session time, or all HTTP requests. Refresh explicitly to update the snapshot; there is no background collection or polling.
240
+
241
+ Terminal Animation is collapsed at the bottom of the page, with an Animation shortcut in the header. Preview frames load only when opened; closing it, hiding the page, or requesting reduced motion stops the preview timer. Unsaved choices survive folding and reopening. Recent upstream failures expand to existing diagnostic fields (time, model, code, message, response/request IDs, and retry policy) with a Copy diagnostic control. These are the last 10 retained `response.failed` events, not complete session histories; a retry attempt does not prove recovery. Diagnostic prose is redacted by the existing recorder but can still contain sensitive context: review before copying or sharing.
242
+
239
243
  Inspect only the local Codex configuration:
240
244
 
241
245
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "description": "Use Codex App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
@@ -14,7 +14,7 @@
14
14
  <div class="shell">
15
15
  <header class="topbar">
16
16
  <div class="brand">CCDX <span class="brand-detail">/ LOCAL STATUS</span></div>
17
- <div class="topbar-actions"><span id="connection" class="badge neutral" role="status">READING</span><button id="theme-toggle" class="icon-button" type="button" aria-label="Switch to light mode" title="Switch to light mode"><svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M4.93 4.93l1.42 1.42m11.3 11.3 1.42 1.42M2 12h2m16 0h2M4.93 19.07l1.42-1.42m11.3-11.3 1.42-1.42"/></svg><svg class="icon-moon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><path d="M12 3A9 9 0 1 0 21 12 7 7 0 0 1 12 3Z"/></svg></button><button id="refresh" type="button">Refresh</button></div>
17
+ <div class="topbar-actions"><span id="connection" class="badge neutral" role="status">READING</span><button id="theme-toggle" class="icon-button" type="button" aria-label="Switch to light mode" title="Switch to light mode"><svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M4.93 4.93l1.42 1.42m11.3 11.3 1.42 1.42M2 12h2m16 0h2M4.93 19.07l1.42-1.42m11.3-11.3 1.42-1.42"/></svg><svg class="icon-moon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><path d="M12 3A9 9 0 1 0 21 12 7 7 0 0 1 12 3Z"/></svg></button><button id="open-animation" type="button" aria-controls="animation-settings" aria-expanded="false">Animation</button><button id="refresh" type="button">Refresh</button></div>
18
18
  </header>
19
19
 
20
20
  <main>
@@ -47,12 +47,6 @@
47
47
  </article>
48
48
  </section>
49
49
 
50
- <section class="panel dashboard-section" aria-label="Terminal animation">
51
- <div class="panel-heading"><h2>TERMINAL ANIMATION</h2><span id="animation-state">READING</span></div>
52
- <div id="animation-options" class="animation-options" role="group" aria-label="Animation theme"></div>
53
- <div class="section-actions"><button id="save-animation" type="button" disabled>Save selection</button><span id="animation-note" role="status">Applies on next ccdx start.</span></div>
54
- </section>
55
-
56
50
  <section class="panel dashboard-section" aria-label="Live upstream models">
57
51
  <div class="panel-heading"><h2>UPSTREAM GPT MODELS</h2><div class="heading-actions"><span id="models-state">CHECKING LIVE</span><button id="refresh-models" type="button">Refresh</button></div></div>
58
52
  <p id="models-note" class="table-note">Querying GitHub Copilot…</p>
@@ -61,17 +55,39 @@
61
55
  </section>
62
56
 
63
57
  <section class="panel dashboard-section" aria-label="Usage by model">
64
- <div class="panel-heading"><h2>USAGE</h2><div class="heading-actions"><span id="usage-state">READING LOG</span><button id="refresh-usage" type="button">Refresh</button></div></div>
58
+ <div class="panel-heading"><h2>USAGE</h2><div class="heading-actions"><span id="usage-state">READING LOG</span><button id="clear-usage-day" type="button" hidden>All history</button><button id="refresh-usage" type="button">Refresh</button></div></div>
65
59
  <p id="usage-note" class="table-note">Aggregating local usage metadata…</p>
66
60
  <div class="table-wrap"><table class="usage-table"><thead><tr><th scope="col">MODEL</th><th scope="col">CALLS</th><th scope="col">INPUT</th><th scope="col">CACHE READ</th><th scope="col">OUTPUT</th><th scope="col">TOTAL</th><th scope="col">CACHE HIT</th></tr></thead><tbody id="usage-body"><tr><td colspan="7">Loading…</td></tr></tbody></table></div>
67
61
  <p class="hint">Cache hit = recorded cached / input tokens. ~ = incomplete history. — = unavailable or invalid counts.</p>
62
+ <details id="usage-analytics" class="analytics-section">
63
+ <summary>USAGE ANALYTICS</summary>
64
+ <div class="analytics-controls">
65
+ <label>Model <select id="analytics-model"><option value="">All models</option></select></label>
66
+ <label>Daily range <select id="analytics-range"><option value="7">7 days</option><option value="30" selected>30 days</option><option value="90">90 days</option></select></label>
67
+ <label>Activity <select id="analytics-metric"><option value="requests">Calls</option><option value="tokens">Tokens</option></select></label>
68
+ </div>
69
+ <p id="analytics-note" class="table-note" role="status">Open to read retained usage history.</p>
70
+ <div class="chart-heading"><h3>DAILY TOKENS</h3><span>INPUT + OUTPUT / CACHE INCLUDED IN INPUT</span></div>
71
+ <div id="daily-bars" class="daily-bars" aria-label="Daily token usage"></div>
72
+ <div class="daily-axis"><span id="daily-from"></span><span id="daily-to"></span></div>
73
+ <div class="chart-legend"><span class="legend-input">Input</span><span class="legend-output">Output</span></div>
74
+ <div class="chart-heading"><h3>REQUEST ACTIVITY · 365 DAYS</h3><span id="activity-scale">—</span></div>
75
+ <div class="calendar-scroll"><div class="calendar-grid"><div id="calendar-months" class="calendar-months" aria-hidden="true"></div><div id="activity-calendar" class="activity-calendar" aria-label="Daily recorded request activity"></div></div></div>
76
+ <p id="analytics-selection" class="hint" role="status">Select a day to inspect its model totals. No history is distinct from zero recorded calls.</p>
77
+ </details>
68
78
  </section>
69
79
 
70
80
  <section class="lower" aria-label="Recent failures">
71
- <div class="panel-heading"><h2>RECENT UPSTREAM FAILURES</h2><span>response.failed</span></div>
81
+ <div class="panel-heading"><h2>RECENT UPSTREAM FAILURES</h2><span id="failure-count">response.failed</span></div>
72
82
  <ol id="failures"><li class="empty">Reading…</li></ol>
73
83
  <p class="hint">Upstream messages may contain sensitive text.</p>
74
84
  </section>
85
+
86
+ <details id="animation-settings" class="panel dashboard-section settings-panel">
87
+ <summary class="panel-heading"><h2>TERMINAL ANIMATION</h2><span id="animation-state">READING</span></summary>
88
+ <div id="animation-options" class="animation-options" role="group" aria-label="Animation theme"></div>
89
+ <div class="section-actions"><button id="save-animation" type="button" disabled>Save selection</button><span id="animation-note" role="status">Applies on next ccdx start.</span></div>
90
+ </details>
75
91
  </main>
76
92
 
77
93
  <footer>LOCAL ONLY / NO BACKGROUND POLLING</footer>
@@ -1,6 +1,7 @@
1
1
  :root { color-scheme: dark; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; --page: #0b110d; --surface: #111a13; --text: #e0e9df; --strong: #f0f6ec; --muted: #9aada0; --badge: #a9b9aa; --subtle: #78917d; --quiet: #6d8371; --footer: #617966; --border: #2b3b2f; --top-border: #2a3a2e; --accent: #a6f5a8; --accent-soft: #84e99e; --heading: #83b98b; --bad: #ff947e; --failed: #ff9f91; --incomplete: #e3c77c; --button-border: #45634b; --button-bg: #152219; --button-fg: #b2f1bd; --button-hover: #203526; --selected-bg: #192a1d; --option-hover: #1a2a1e; --table-text: #d8e2d8; --preview: #657d69; --ansi-white: #f5fff5; --ansi-159: #9af5e9; --ansi-123: #75e4d9; --ansi-87: #5ad0d4; --ansi-51: #42b9d8; --ansi-45: #3ca2d0; --ansi-39: #4389c2; --ansi-33: #4775b2; }
2
2
  :root[data-theme="light"] { color-scheme: light; --page: #f5f8f3; --surface: #ffffff; --text: #1b2b20; --strong: #122219; --muted: #475d4e; --badge: #475d4e; --subtle: #4b6652; --quiet: #55715c; --footer: #59735f; --border: #c4d2c5; --top-border: #b8c9ba; --accent: #176b3a; --accent-soft: #176b3a; --heading: #235f37; --bad: #a33625; --failed: #a33625; --incomplete: #835600; --button-border: #79a085; --button-bg: #ebf4ec; --button-fg: #185b32; --button-hover: #dbeedd; --selected-bg: #e4f3e7; --option-hover: #edf6ee; --table-text: #233b2b; --preview: #596f60; --ansi-white: #234231; --ansi-159: #116b64; --ansi-123: #0d7771; --ansi-87: #116b75; --ansi-51: #14697e; --ansi-45: #1a6183; --ansi-39: #265f8d; --ansi-33: #365a8d; }
3
3
  * { box-sizing: border-box; }
4
+ [hidden] { display: none !important; }
4
5
  body { margin: 0; color: var(--text); background: var(--page); font-size: 13px; }
5
6
  .shell { max-width: 1220px; margin: 0 auto; padding: 0 24px; }
6
7
  .topbar { min-height: 68px; display: flex; justify-content: space-between; align-items: center; gap: 20px; border-bottom: 1px solid var(--top-border); }
@@ -78,12 +79,52 @@ td { color: var(--table-text); }
78
79
  .usage-table tr.total-row td { color: var(--accent); font-weight: 700; }
79
80
  .lower { margin-top: 11px; padding: 18px; }
80
81
  .lower ol { list-style: none; margin: 16px 0 0; padding: 0; }
81
- .lower li { display: grid; grid-template-columns: minmax(150px, 1fr) minmax(0, 2fr); gap: 14px; padding: 9px 0; border-top: 1px solid var(--border); font-size: 11px; }
82
+ .lower li { padding: 9px 0; border-top: 1px solid var(--border); font-size: 11px; }
82
83
  .lower li strong, .lower li span { overflow-wrap: anywhere; }
83
84
  .lower li strong { color: var(--text); font-weight: 650; }
84
85
  .lower li span, .lower li.empty { color: var(--muted); line-height: 1.5; }
86
+ summary { cursor: pointer; line-height: 1.6; overflow-wrap: anywhere; }
87
+ summary:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
88
+ .settings-panel > summary { list-style: none; align-items: center; }
89
+ .settings-panel > summary::-webkit-details-marker { display: none; }
90
+ .settings-panel > summary h2::before { content: "+ "; }
91
+ .settings-panel[open] > summary h2::before { content: "− "; }
92
+ .failure-detail { display: grid; grid-template-columns: minmax(110px, auto) minmax(0, 1fr); gap: 7px 18px; margin: 14px 0; line-height: 1.6; }
93
+ .failure-detail dt { color: var(--muted); }
94
+ .failure-detail dd { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
95
+ .analytics-section { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); }
96
+ .analytics-section > summary { color: var(--heading); font-size: 11px; font-weight: 700; letter-spacing: .08em; }
97
+ .analytics-controls { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 18px; }
98
+ .analytics-controls label { color: var(--muted); font-size: 11px; }
99
+ select { max-width: 260px; padding: 5px; font: inherit; color: var(--text); background: var(--page); border: 1px solid var(--border); }
100
+ .chart-heading { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; margin: 22px 0 10px; }
101
+ .chart-heading h3 { margin: 0; color: var(--heading); font-size: 11px; }
102
+ .chart-heading span, .chart-legend { color: var(--muted); font-size: 10px; }
103
+ .daily-bars { display: flex; align-items: end; gap: 3px; height: 130px; border-bottom: 1px solid var(--border); }
104
+ .daily-bar { height: 100%; flex: 1; min-width: 0; padding: 0; border: 0; display: flex; flex-direction: column; justify-content: end; background: transparent; border-radius: 0; }
105
+ .daily-bar span { display: block; flex-shrink: 0; width: 100%; }
106
+ .bar-input { background: var(--accent-soft); }
107
+ .bar-output { background: var(--ansi-39); }
108
+ .daily-bar:hover { background: var(--option-hover); }
109
+ .daily-axis { display: flex; justify-content: space-between; color: var(--muted); font-size: 10px; margin-top: 7px; }
110
+ .daily-bar[aria-pressed="true"], .calendar-day[aria-pressed="true"] { outline: 2px solid var(--strong); outline-offset: 1px; }
111
+ .chart-legend { display: flex; gap: 18px; margin-top: 9px; }
112
+ .chart-legend span::before { content: "■"; margin-right: 6px; }
113
+ .legend-input::before { color: var(--accent-soft); }
114
+ .legend-output::before { color: var(--ansi-39); }
115
+ .calendar-scroll { overflow-x: auto; padding: 4px; }
116
+ .calendar-grid { width: max-content; }
117
+ .calendar-months { display: grid; grid-auto-columns: 12px; gap: 4px; margin-bottom: 7px; color: var(--muted); font-size: 9px; height: 14px; }
118
+ .activity-calendar { display: grid; grid-template-rows: repeat(7, 12px); grid-auto-flow: column; grid-auto-columns: 12px; gap: 4px; width: max-content; }
119
+ .calendar-day { padding: 0; border: 1px solid var(--border); width: 12px; height: 12px; background: var(--button-bg); }
120
+ .calendar-day[data-level="1"] { background: color-mix(in srgb, var(--accent-soft) 25%, var(--surface)); }
121
+ .calendar-day[data-level="2"] { background: color-mix(in srgb, var(--accent-soft) 45%, var(--surface)); }
122
+ .calendar-day[data-level="3"] { background: color-mix(in srgb, var(--accent-soft) 70%, var(--surface)); }
123
+ .calendar-day[data-level="4"] { background: var(--accent-soft); }
124
+ .calendar-day:disabled, .daily-bar:disabled { background: repeating-linear-gradient(135deg, transparent, transparent 3px, var(--border) 3px, var(--border) 4px); opacity: .5; cursor: default; }
85
125
  footer { color: var(--footer); font-size: 10px; letter-spacing: .05em; padding: 22px 0 30px; }
86
126
  @media (max-width: 1000px) { .animation-options { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
87
127
  @media (max-width: 900px) { .overview { grid-template-columns: repeat(2, minmax(0, 1fr)); } .details { grid-template-columns: repeat(2, minmax(0, 1fr)); } .details .panel:last-child { grid-column: 1 / -1; } }
88
128
  @media (max-width: 680px) { .animation-options { grid-template-columns: 1fr; } }
89
129
  @media (max-width: 620px) { .shell { padding: 0 16px; } .topbar { min-height: 60px; } .brand-detail { display: none; } .topbar-actions { gap: 11px; } .intro { display: block; padding: 29px 0 21px; } .intro p { margin-top: 9px; } .overview, .details { grid-template-columns: 1fr; } .details .panel:last-child { grid-column: auto; } .card { min-height: 122px; } .card strong { margin-top: 14px; } .lower li { display: block; } .lower li span { display: block; margin-top: 5px; } }
130
+ @media (max-width: 460px) { .topbar { flex-wrap: wrap; padding: 12px 0; gap: 10px; } .topbar-actions { gap: 9px; } .panel-heading { flex-wrap: wrap; } .heading-actions { gap: 10px; } .failure-detail { grid-template-columns: 1fr; gap: 3px; } .failure-detail dd { margin-bottom: 7px; } select { max-width: 210px; } }
@@ -15,8 +15,10 @@ function duration(value) {
15
15
 
16
16
  function renderFailures(recent) {
17
17
  const list = element("failures");
18
+ const expanded = new Set([...list.querySelectorAll("details[open]")].map((item) => item.dataset.key));
18
19
  list.replaceChildren();
19
- const failures = Array.isArray(recent) ? recent.slice(-5).reverse() : [];
20
+ const failures = Array.isArray(recent) ? recent.slice(-10).reverse() : [];
21
+ text("failure-count", `${failures.length} retained / response.failed`);
20
22
  if (!failures.length) {
21
23
  const item = document.createElement("li");
22
24
  item.className = "empty";
@@ -26,11 +28,38 @@ function renderFailures(recent) {
26
28
  }
27
29
  for (const failure of failures) {
28
30
  const item = document.createElement("li");
29
- const title = document.createElement("strong");
30
- const detail = document.createElement("span");
31
- title.textContent = `${String(failure.model || "unknown_model").slice(0, 80)} / ${String(failure.code || "unknown_error").slice(0, 80)}`;
32
- detail.textContent = `${String(failure.message || "No message").slice(0, 240)}${failure.retried ? " / retried" : ""}`;
33
- item.append(title, detail);
31
+ const details = document.createElement("details");
32
+ details.dataset.key = `${failure.at}/${failure.response_id}/${failure.code}`;
33
+ details.open = expanded.has(details.dataset.key);
34
+ const title = document.createElement("summary");
35
+ title.textContent = `${String(failure.at || "unknown time")} / ${String(failure.model || "unknown_model").slice(0, 80)} / ${String(failure.code || "unknown_error").slice(0, 80)} / ${failure.retried ? "retry attempted" : "no retry"}`;
36
+ const fields = { time: failure.at, model: failure.model, event: failure.event_type, code: failure.code,
37
+ message: failure.message, response_id: failure.response_id, upstream_request_id: failure.upstream_request_id,
38
+ retry: failure.retried ? "attempted (outcome not recorded here)" : "not attempted",
39
+ retry_policy: failure.retry_policy, retry_skipped: failure.retry_skipped };
40
+ const diagnostic = document.createElement("dl");
41
+ diagnostic.className = "failure-detail";
42
+ const lines = [];
43
+ for (const [key, value] of Object.entries(fields)) {
44
+ if (value === undefined || value === null || value === "") continue;
45
+ const label = document.createElement("dt");
46
+ const content = document.createElement("dd");
47
+ label.textContent = key;
48
+ content.textContent = String(value).slice(0, 500);
49
+ diagnostic.append(label, content);
50
+ lines.push(`${key}: ${content.textContent}`);
51
+ }
52
+ const copy = document.createElement("button");
53
+ copy.type = "button";
54
+ copy.textContent = "Copy diagnostic";
55
+ copy.addEventListener("click", async () => {
56
+ try {
57
+ await navigator.clipboard.writeText(lines.join("\n"));
58
+ copy.textContent = "Copied";
59
+ } catch { copy.textContent = "Copy unavailable"; }
60
+ });
61
+ details.append(title, diagnostic, copy);
62
+ item.append(details);
34
63
  list.append(item);
35
64
  }
36
65
  }
@@ -109,6 +138,8 @@ let previewDisabledByEnvironment = false;
109
138
  let previewVisible = false;
110
139
  let previewTimer = null;
111
140
  let previewStarted = 0;
141
+ let animationLoading = false;
142
+ let animationFramesLoaded = false;
112
143
  const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
113
144
 
114
145
  function renderAnsiFrame(target, frame) {
@@ -146,14 +177,14 @@ function stopAnimationPreview() {
146
177
 
147
178
  function tickAnimationPreview() {
148
179
  previewTimer = null;
149
- if (!previewVisible || document.visibilityState !== "visible" || reducedMotion.matches || previewDisabledByEnvironment) return;
180
+ if (!animationTimelines.length || !element("animation-settings").open || !previewVisible || document.visibilityState !== "visible" || reducedMotion.matches || previewDisabledByEnvironment) return;
150
181
  renderAnimationFrames(performance.now() - previewStarted);
151
182
  previewTimer = setTimeout(tickAnimationPreview, 32);
152
183
  }
153
184
 
154
185
  function syncAnimationPreview() {
155
186
  stopAnimationPreview();
156
- if (!previewVisible || document.visibilityState !== "visible" || reducedMotion.matches || previewDisabledByEnvironment) {
187
+ if (!animationTimelines.length || !element("animation-settings").open || !previewVisible || document.visibilityState !== "visible" || reducedMotion.matches || previewDisabledByEnvironment) {
157
188
  for (const timeline of animationTimelines) timeline.lastFrame = -2;
158
189
  renderAnimationFrames(0);
159
190
  return;
@@ -186,6 +217,7 @@ function buildAnimationOptions(data) {
186
217
  selectedAnimation = theme.id;
187
218
  for (const [id, row] of animationRows) row.setAttribute("aria-pressed", String(id === theme.id));
188
219
  element("save-animation").disabled = selectedAnimation === savedAnimation;
220
+ updateAnimationState();
189
221
  syncAnimationPreview();
190
222
  });
191
223
  const name = document.createElement("span");
@@ -220,20 +252,33 @@ function animationNote(data) {
220
252
  }
221
253
 
222
254
  async function loadAnimation() {
255
+ const includeFrames = element("animation-settings").open;
256
+ if (animationLoading || (includeFrames && animationFramesLoaded)) return;
257
+ animationLoading = true;
223
258
  text("animation-state", "READING");
224
259
  try {
225
- const response = await fetch("/_ccdx/ui/animation", { cache: "no-store", headers: { "X-CCDX-Dashboard": "1" }, signal: AbortSignal.timeout(8000) });
260
+ const response = await fetch(`/_ccdx/ui/animation${includeFrames ? "" : "?frames=0"}`, { cache: "no-store", headers: { "X-CCDX-Dashboard": "1" }, signal: AbortSignal.timeout(8000) });
226
261
  const data = await response.json();
227
262
  if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
228
- buildAnimationOptions(data);
229
- text("animation-state", `CURRENT ${data.theme.toUpperCase()}`);
263
+ if (includeFrames) {
264
+ buildAnimationOptions(data);
265
+ animationFramesLoaded = true;
266
+ } else { savedAnimation = data.theme; selectedAnimation = data.theme; }
267
+ updateAnimationState();
230
268
  text("animation-note", animationNote(data));
231
269
  } catch (error) {
232
270
  text("animation-state", "UNAVAILABLE");
233
271
  text("animation-note", String(error.message || error).slice(0, 180));
272
+ } finally {
273
+ animationLoading = false;
274
+ if (!includeFrames && element("animation-settings").open) loadAnimation();
234
275
  }
235
276
  }
236
277
 
278
+ function updateAnimationState() {
279
+ text("animation-state", `CURRENT ${savedAnimation.toUpperCase()}${selectedAnimation !== savedAnimation ? " / UNSAVED" : ""}`);
280
+ }
281
+
237
282
  async function saveAnimation() {
238
283
  const button = element("save-animation");
239
284
  if (!selectedAnimation || selectedAnimation === savedAnimation) return;
@@ -249,7 +294,7 @@ async function saveAnimation() {
249
294
  const data = await response.json();
250
295
  if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
251
296
  savedAnimation = data.theme;
252
- text("animation-state", `CURRENT ${data.theme.toUpperCase()}`);
297
+ updateAnimationState();
253
298
  text("animation-note", animationNote(data));
254
299
  } catch (error) {
255
300
  text("animation-note", `Not saved / ${String(error.message || error).slice(0, 150)}`);
@@ -313,36 +358,178 @@ function usageValues(row) {
313
358
  return [row.model, number(row.requests), number(row.input_tokens), number(row.cache_read_tokens), number(row.output_tokens), number(row.total_tokens), rate];
314
359
  }
315
360
 
361
+ let usageData = null;
362
+ let usageLoading = false;
363
+ let selectedUsageDate = "";
364
+
365
+ function renderUsageTable() {
366
+ const day = selectedUsageDate && usageData.analytics?.days.find((entry) => entry.date === selectedUsageDate);
367
+ const model = element("analytics-model").value;
368
+ const total = day ? { model: "TOTAL", ...analyticsPoint(day, model) } : usageData.total;
369
+ const rows = day ? day.models.filter((row) => !model || row.model === model).sort((a, b) => b.total_tokens - a.total_tokens || a.model.localeCompare(b.model)) : usageData.rows;
370
+ const body = element("usage-body");
371
+ body.replaceChildren();
372
+ if (total.requests > 0) {
373
+ body.append(tableRow(usageValues(total), { total: true }));
374
+ for (const row of rows) body.append(tableRow(usageValues(row)));
375
+ } else { emptyTable("usage-body", 7, "No usage records."); }
376
+ element("clear-usage-day").hidden = !day;
377
+ text("usage-state", day ? day.date : "LOCAL LOG");
378
+ text("usage-note", `${number(total.requests)} records / ${number(rows.length)} of ${number(day ? rows.length : usageData.model_count)} models shown${day ? ` / ${usageData.analytics.time_zone} / ${model || "All models"}` : ""}`);
379
+ }
380
+
381
+ function analyticsPoint(day, model) {
382
+ return model ? day.models.find((row) => row.model === model) || { requests: 0, input_tokens: 0, output_tokens: 0 } : day;
383
+ }
384
+
385
+ function selectUsageDay(date) {
386
+ selectedUsageDate = date;
387
+ renderUsageTable();
388
+ renderAnalytics();
389
+ }
390
+
391
+ function dayButton(day, point) {
392
+ const button = document.createElement("button");
393
+ button.type = "button";
394
+ button.disabled = !day.available;
395
+ button.setAttribute("aria-pressed", String(day.date === selectedUsageDate));
396
+ const description = !day.available ? "no retained history"
397
+ : `${number(point.requests)} recorded calls / input ${number(point.input_tokens)} / output ${number(point.output_tokens)} / cached ${number(point.cache_read_tokens)} (included in input)${point.tokens_partial ? " / partial token counts" : ""}`;
398
+ button.title = `${day.date} / ${description}`;
399
+ button.setAttribute("aria-label", button.title);
400
+ button.addEventListener("click", () => selectUsageDay(day.date));
401
+ return button;
402
+ }
403
+
404
+ function renderAnalytics() {
405
+ const analytics = usageData?.analytics;
406
+ if (!analytics) return;
407
+ const model = element("analytics-model").value;
408
+ const days = analytics.days;
409
+ const daily = days.slice(-Number(element("analytics-range").value));
410
+ const maximum = Math.max(1, ...daily.map((day) => {
411
+ const point = analyticsPoint(day, model);
412
+ return point.input_tokens + point.output_tokens;
413
+ }));
414
+ const bars = element("daily-bars");
415
+ bars.replaceChildren();
416
+ for (const day of daily) {
417
+ const point = analyticsPoint(day, model);
418
+ const button = dayButton(day, point);
419
+ button.className = "daily-bar";
420
+ for (const key of ["output_tokens", "input_tokens"]) {
421
+ const segment = document.createElement("span");
422
+ segment.className = key === "input_tokens" ? "bar-input" : "bar-output";
423
+ segment.style.height = `${point[key] / maximum * 100}%`;
424
+ button.append(segment);
425
+ }
426
+ bars.append(button);
427
+ }
428
+ text("daily-from", daily[0].date);
429
+ text("daily-to", analytics.to);
430
+ const metric = element("analytics-metric").value;
431
+ const value = (day) => {
432
+ const point = analyticsPoint(day, model);
433
+ return metric === "tokens" ? point.input_tokens + point.output_tokens : point.requests;
434
+ };
435
+ const peak = Math.max(1, ...days.map(value));
436
+ text("activity-scale", `${metric === "tokens" ? "INPUT + OUTPUT" : "RECORDED CALLS"} / PEAK ${number(peak === 1 && days.every((day) => value(day) === 0) ? 0 : peak)}`);
437
+ const calendar = element("activity-calendar");
438
+ calendar.replaceChildren();
439
+ const offset = (new Date(`${days[0].date}T00:00:00Z`).getUTCDay() + 6) % 7;
440
+ const months = element("calendar-months");
441
+ months.replaceChildren();
442
+ const columns = Math.ceil((offset + days.length) / 7);
443
+ let previousColumn = -4;
444
+ days.forEach((day, index) => {
445
+ if (index !== 0 && !day.date.endsWith("-01")) return;
446
+ const column = Math.floor((offset + index) / 7);
447
+ if (column - previousColumn < 4 || column + 4 > columns) return;
448
+ previousColumn = column;
449
+ const label = document.createElement("span");
450
+ label.textContent = day.date.slice(0, 7);
451
+ label.style.gridColumn = `${column + 1} / span 4`;
452
+ months.append(label);
453
+ });
454
+ for (let index = 0; index < offset; index += 1) {
455
+ const spacer = document.createElement("span");
456
+ spacer.setAttribute("aria-hidden", "true");
457
+ calendar.append(spacer);
458
+ }
459
+ for (const day of days) {
460
+ const button = dayButton(day, analyticsPoint(day, model));
461
+ button.className = "calendar-day";
462
+ const count = value(day);
463
+ button.dataset.level = String(count > 0 ? Math.min(4, Math.ceil(count / peak * 4)) : 0);
464
+ calendar.append(button);
465
+ }
466
+ text("analytics-note", `${analytics.time_zone} / ${daily[0].date} — ${analytics.to} / retained history starts ${analytics.first_date || "unknown"}; oldest day may be partial.${analytics.undated_records ? ` ${number(analytics.undated_records)} records with invalid/future timestamps excluded.` : ""}${analytics.models_truncated ? " Model filters limited to 100; all-model totals include the remainder." : ""}`);
467
+ text("analytics-selection", selectedUsageDate ? `Selected ${selectedUsageDate} / ${model || "All models"}: totals in the Usage table above / All history resets the table.` : "Select a day for model totals. Calls are usage records, not messages or time. Hatched cells: no retained history; empty cells: zero recorded calls.");
468
+ }
469
+
470
+ function updateAnalyticsModels() {
471
+ const select = element("analytics-model");
472
+ const previous = select.value;
473
+ select.replaceChildren();
474
+ for (const model of ["", ...usageData.analytics.models]) {
475
+ const option = document.createElement("option");
476
+ option.value = model;
477
+ option.textContent = model || "All models";
478
+ select.append(option);
479
+ }
480
+ select.value = usageData.analytics.models.includes(previous) ? previous : "";
481
+ }
482
+
316
483
  async function loadUsage() {
484
+ if (usageLoading) return;
485
+ usageLoading = true;
486
+ const includeAnalytics = element("usage-analytics").open || Boolean(selectedUsageDate);
317
487
  const button = element("refresh-usage");
318
488
  button.disabled = true;
319
489
  text("usage-state", "READING LOG");
320
490
  text("usage-note", "Aggregating local usage metadata…");
321
491
  emptyTable("usage-body", 7, "Loading…");
322
492
  try {
323
- const response = await fetch("/_ccdx/ui/usage", { cache: "no-store", headers: { "X-CCDX-Dashboard": "1" }, signal: AbortSignal.timeout(15000) });
493
+ const query = includeAnalytics ? `?analytics=1&time_zone=${encodeURIComponent(Intl.DateTimeFormat().resolvedOptions().timeZone)}` : "";
494
+ const response = await fetch(`/_ccdx/ui/usage${query}`, { cache: "no-store", headers: { "X-CCDX-Dashboard": "1" }, signal: AbortSignal.timeout(15000) });
324
495
  const data = await response.json();
325
496
  if (!response.ok || data.source !== "local_usage_log") throw new Error(data.error || `HTTP ${response.status}`);
326
- const body = element("usage-body");
327
- body.replaceChildren();
328
- if (data.total.requests > 0) {
329
- body.append(tableRow(usageValues(data.total), { total: true }));
330
- for (const row of data.rows) body.append(tableRow(usageValues(row)));
331
- } else {
332
- emptyTable("usage-body", 7, "No usage records.");
333
- }
334
- text("usage-state", "LOCAL LOG");
335
- text("usage-note", `${number(data.total.requests)} records / ${number(data.rows.length)} of ${number(data.model_count)} models shown`);
497
+ usageData = data;
498
+ if (data.analytics) updateAnalyticsModels();
499
+ renderUsageTable();
500
+ renderAnalytics();
336
501
  } catch (error) {
337
502
  text("usage-state", "LOG UNAVAILABLE");
338
503
  text("usage-note", String(error.message || error).slice(0, 180));
339
504
  emptyTable("usage-body", 7, "No usage summary available.");
505
+ if (includeAnalytics) text("analytics-note", "Analytics unavailable / refresh Usage to retry.");
340
506
  } finally {
507
+ usageLoading = false;
341
508
  button.disabled = false;
509
+ if (!includeAnalytics && element("usage-analytics").open) loadUsage();
342
510
  }
343
511
  }
344
512
 
345
513
  element("save-animation").addEventListener("click", saveAnimation);
514
+ element("animation-settings").addEventListener("toggle", () => {
515
+ element("open-animation").setAttribute("aria-expanded", String(element("animation-settings").open));
516
+ if (element("animation-settings").open) loadAnimation();
517
+ syncAnimationPreview();
518
+ });
519
+ element("open-animation").addEventListener("click", () => {
520
+ const settings = element("animation-settings");
521
+ settings.open = true;
522
+ settings.scrollIntoView({ block: "center" });
523
+ settings.querySelector("summary").focus();
524
+ });
525
+ element("usage-analytics").addEventListener("toggle", () => {
526
+ if (element("usage-analytics").open && !usageData?.analytics) loadUsage();
527
+ });
528
+ for (const id of ["analytics-model", "analytics-range", "analytics-metric"]) element(id).addEventListener("change", () => {
529
+ renderAnalytics();
530
+ if (usageData && selectedUsageDate) renderUsageTable();
531
+ });
532
+ element("clear-usage-day").addEventListener("click", () => selectUsageDay(""));
346
533
  element("refresh-models").addEventListener("click", loadModels);
347
534
  element("refresh-usage").addEventListener("click", loadUsage);
348
535
  loadAnimation();
@@ -10,6 +10,7 @@ import {
10
10
  TERMINAL_ANIMATION_THEMES,
11
11
  } from "./terminal-animation.mjs";
12
12
  import { cacheReadTokens, summarizeUsageLogs, usageCacheHitRate } from "./usage.mjs";
13
+ import { createUsageAnalytics } from "./usage-analytics.mjs";
13
14
  import { readUserSettings, terminalAnimationPreference, writeTerminalAnimationTheme } from "./user-settings.mjs";
14
15
 
15
16
  const ANIMATION_PATH = "/_ccdx/ui/animation";
@@ -107,7 +108,7 @@ export async function handleDashboardApi(req, res, pathname, {
107
108
  env = process.env,
108
109
  home = os.homedir(),
109
110
  liveModelsFn = () => fetchLiveCopilotModels({ home }),
110
- usageSummaryFn = () => summarizeUsageLogs(),
111
+ usageSummaryFn = (options) => summarizeUsageLogs(undefined, options),
111
112
  } = {}) {
112
113
  if (!isLoopbackAddress(req.socket?.remoteAddress)
113
114
  || req.headers?.host === undefined
@@ -122,7 +123,8 @@ export async function handleDashboardApi(req, res, pathname, {
122
123
 
123
124
  if (pathname === ANIMATION_PATH && req.method === "GET") {
124
125
  try {
125
- sendJson(res, 200, animationState(env, home, { includeFrames: true }));
126
+ const query = new URL(req.url, "http://localhost").searchParams;
127
+ sendJson(res, 200, animationState(env, home, { includeFrames: query.get("frames") !== "0" }));
126
128
  } catch {
127
129
  sendJson(res, 409, { error: "Animation settings are invalid; inspect them with ccdx animation" });
128
130
  }
@@ -189,8 +191,21 @@ export async function handleDashboardApi(req, res, pathname, {
189
191
  }
190
192
 
191
193
  if (pathname === USAGE_PATH && req.method === "GET") {
194
+ const query = new URL(req.url, "http://localhost").searchParams;
195
+ let analytics = null;
196
+ if (query.get("analytics") === "1") {
197
+ try {
198
+ const timeZone = query.get("time_zone") || "UTC";
199
+ if (timeZone.length > 100) throw new Error("Invalid time zone");
200
+ analytics = createUsageAnalytics({ timeZone });
201
+ } catch {
202
+ sendJson(res, 400, { error: "Select a valid usage time zone" });
203
+ return;
204
+ }
205
+ }
192
206
  try {
193
- sendJson(res, 200, { source: "local_usage_log", ...(usageTable(await usageSummaryFn())) });
207
+ const summary = await usageSummaryFn(analytics ? { onRecord: analytics.record } : undefined);
208
+ sendJson(res, 200, { source: "local_usage_log", ...usageTable(summary), ...(analytics ? { analytics: analytics.snapshot() } : {}) });
194
209
  } catch {
195
210
  sendJson(res, 500, { error: "Could not read local usage summary" });
196
211
  }
@@ -0,0 +1,77 @@
1
+ import { cacheReadTokens, usageCacheHitRate } from "./usage.mjs";
2
+
3
+ const DAY_MS = 86400000;
4
+ const DAYS = 365;
5
+ const MAX_MODELS = 100;
6
+ const TOKEN_FIELDS = ["input_tokens", "output_tokens", "total_tokens", "cached_input_tokens", "cache_read_input_tokens", "cache_hit_unknown_requests", "cache_hit_invalid_requests"];
7
+
8
+ export function createUsageAnalytics({ timeZone = "UTC", now = Date.now() } = {}) {
9
+ const formatter = new Intl.DateTimeFormat("sv-SE", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" });
10
+ const today = formatter.format(new Date(now));
11
+ const end = Date.parse(`${today}T00:00:00Z`);
12
+ const start = end - (DAYS - 1) * DAY_MS;
13
+ const from = new Date(start).toISOString().slice(0, 10);
14
+ const dates = new Map();
15
+ const models = new Set();
16
+ let first = Infinity;
17
+ let last = -Infinity;
18
+ let undated = 0;
19
+ let modelsTruncated = false;
20
+
21
+ function add(target, values) {
22
+ target.requests += 1;
23
+ for (const key of TOKEN_FIELDS) {
24
+ if (Number.isFinite(values[key]) && values[key] >= 0) target[key] = (target[key] || 0) + values[key];
25
+ }
26
+ if (!Number.isFinite(values.input_tokens) || values.input_tokens < 0 || !Number.isFinite(values.output_tokens) || values.output_tokens < 0) target.tokens_partial = true;
27
+ }
28
+
29
+ function row(values = {}) {
30
+ return {
31
+ requests: values.requests || 0,
32
+ input_tokens: values.input_tokens || 0,
33
+ output_tokens: values.output_tokens || 0,
34
+ total_tokens: values.total_tokens ?? (values.input_tokens || 0) + (values.output_tokens || 0),
35
+ cache_read_tokens: cacheReadTokens(values) ?? null,
36
+ cache_hit_rate: usageCacheHitRate(values),
37
+ cache_hit_rate_partial: values.cache_hit_unknown_requests > 0,
38
+ tokens_partial: values.tokens_partial === true || values.cache_hit_invalid_requests > 0,
39
+ };
40
+ }
41
+
42
+ return {
43
+ record(record, summary) {
44
+ const timestamp = typeof record.ts === "string" ? Date.parse(record.ts) : NaN;
45
+ if (!Number.isFinite(timestamp) || timestamp > now) { undated += 1; return; }
46
+ first = Math.min(first, timestamp);
47
+ last = Math.max(last, timestamp);
48
+ if (timestamp < start - 2 * DAY_MS) return;
49
+ const date = formatter.format(new Date(timestamp));
50
+ if (date < from || date > today) return;
51
+ let point = dates.get(date);
52
+ if (!point) { point = { requests: 0, byModel: new Map() }; dates.set(date, point); }
53
+ add(point, summary.totals);
54
+ const model = String(record.model || "unknown").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, 100);
55
+ if (!models.has(model) && models.size >= MAX_MODELS) { modelsTruncated = true; return; }
56
+ models.add(model);
57
+ if (!point.byModel.has(model)) point.byModel.set(model, { requests: 0 });
58
+ add(point.byModel.get(model), summary.totals);
59
+ },
60
+ snapshot() {
61
+ const firstDate = Number.isFinite(first) ? formatter.format(new Date(first)) : null;
62
+ const days = Array.from({ length: DAYS }, (_, index) => {
63
+ const date = new Date(start + index * DAY_MS).toISOString().slice(0, 10);
64
+ const point = dates.get(date);
65
+ return {
66
+ date,
67
+ available: firstDate !== null && date >= firstDate,
68
+ ...row(point),
69
+ models: point ? [...point.byModel].map(([model, values]) => ({ model, ...row(values) })) : [],
70
+ };
71
+ });
72
+ return { time_zone: formatter.resolvedOptions().timeZone, from, to: today, first_date: firstDate,
73
+ last_date: Number.isFinite(last) ? formatter.format(new Date(last)) : null,
74
+ undated_records: undated, models_truncated: modelsTruncated, models: [...models].sort(), days };
75
+ },
76
+ };
77
+ }
package/src/usage.mjs CHANGED
@@ -288,11 +288,12 @@ export function summarizeUsage(records) {
288
288
  return summary;
289
289
  }
290
290
 
291
- export async function summarizeUsageLogs(filePath = usageLogPath(), { warn = console.error } = {}) {
291
+ export async function summarizeUsageLogs(filePath = usageLogPath(), { warn = console.error, onRecord } = {}) {
292
292
  const summary = { requests: 0, totals: {}, byModel: {} };
293
293
  for (const candidate of [rotatedFilePath(filePath), filePath]) {
294
294
  for await (const record of iterateUsageRecords(candidate, { warn })) {
295
295
  const one = summarizeUsage([record]);
296
+ onRecord?.(record, one);
296
297
  summary.requests += one.requests;
297
298
  addUsageTotals(summary.totals, one.totals);
298
299
  for (const [model, values] of Object.entries(one.byModel)) {