taskchef 7.22.5 → 7.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "7.22.5",
3
+ "version": "7.23.0",
4
4
  "description": "Dispatch work from a data-only workspace to visible Codex project tasks.",
5
5
  "author": {
6
6
  "name": "Favo Yang",
package/README.md CHANGED
@@ -327,10 +327,10 @@ app-callable archive interface or guarantees CLI compatibility. The dormant
327
327
  server path also rejects requests before discovering or invoking the CLI.
328
328
 
329
329
  Task details also show whole-task and per-turn token usage when `ccusage` can
330
- map the linked Codex thread. A completed turn briefly shows “Tokens:
331
- calculating…” while TaskChef performs bounded deferred reconciliation, because
330
+ map the linked Codex thread. A completed turn briefly shows “Calculating token
331
+ usage…” while TaskChef performs bounded deferred reconciliation, because
332
332
  the terminal lifecycle callback precedes Codex's final output write. Historical
333
- tasks may show a trustworthy task total while older turns remain “Tokens
333
+ tasks may show a trustworthy task total while older turns remain “Token usage
334
334
  unavailable” when no cumulative turn boundaries were recorded. Input, cached
335
335
  input, output, reasoning, and total counts retain ccusage's categories. Dollar
336
336
  figures are labeled API-equivalent estimates; provenance identifies the online
@@ -25,6 +25,15 @@ workspace resolution, and startup isolation, but none of those requires the
25
25
  HTTP listener to share one MCP transport's lifetime. The session process keeps
26
26
  those benefits without retaining the obsolete per-transport ownership.
27
27
 
28
+ ## Token usage presentation
29
+
30
+ The detail view distinguishes an active turn whose final usage is pending from
31
+ a terminal turn whose usage is being calculated. Available usage keeps both
32
+ the token total and API-equivalent USD estimate; an available cumulative value
33
+ shown during a newer active turn is labeled as known so far.
34
+
35
+ ![Pending token usage in the task detail view](images/dashboard-token-pending.jpg)
36
+
28
37
  ## Start and reuse
29
38
 
30
39
  Unless `dashboard.autostart` is explicitly `false`, MCP activation runs the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "7.22.5",
3
+ "version": "7.23.0",
4
4
  "description": "A non-blocking interactive dispatcher for visible Codex tasks.",
5
5
  "license": "MIT",
6
6
  "author": "Favo Yang",
@@ -32,6 +32,7 @@
32
32
  "docs/images/notification-event-snapshots.jpg",
33
33
  "docs/images/result-history-dashboard.jpg",
34
34
  "docs/images/interrupted-turn-recovery.jpg",
35
+ "docs/images/dashboard-token-pending.jpg",
35
36
  "index.js",
36
37
  "mcp",
37
38
  "scripts/benchmark-dispatch-prepare.js",
@@ -49,6 +49,7 @@ const state = {
49
49
  notifications: [],
50
50
  seenNotificationIds: new Set(),
51
51
  initialized: false,
52
+ detailNavigation: null,
52
53
  manualTransition: null,
53
54
  selectedTask: null,
54
55
  };
@@ -80,6 +81,7 @@ const elements = {
80
81
  dismissDashboardMessage: document.querySelector("#dismiss-dashboard-message"),
81
82
  emptyState: document.querySelector("#empty-state"),
82
83
  notifications: document.querySelector("#notifications"),
84
+ notificationHost: document.querySelector("#notification-host"),
83
85
  notificationAnnouncer: document.querySelector("#notification-announcer"),
84
86
  manualTransitionPanel: document.querySelector("#manual-transition-panel"),
85
87
  manualTransitionError: document.querySelector("#manual-transition-error"),
@@ -103,6 +105,7 @@ function referenceLink(link, { compact = false, displayLabel } = {}) {
103
105
  anchor.href = link.url;
104
106
  anchor.target = "_blank";
105
107
  anchor.rel = "noopener noreferrer";
108
+ anchor.dataset.focusKey = `link:${link.url}`;
106
109
  const label = link.label ?? link.text;
107
110
  anchor.textContent = displayLabel ?? label;
108
111
  const accessibleLabel = githubReferenceAccessibleLabel(link);
@@ -211,6 +214,7 @@ function timestampControl(value, { accessibleName, key, prefix = "" }) {
211
214
  const control = document.createElement("button");
212
215
  control.type = "button";
213
216
  control.className = "timestamp-toggle";
217
+ control.dataset.focusKey = `timestamp:${key}`;
214
218
  const time = document.createElement("time");
215
219
  control.append(time);
216
220
  relativeTimes.register(key, value, ({ exact, iso, label }) => {
@@ -323,10 +327,16 @@ function notificationToast(notification) {
323
327
  text.append(metadata);
324
328
  describedBy.push(metadata.id);
325
329
  text.setAttribute("aria-describedby", describedBy.join(" "));
326
- text.addEventListener("click", () => {
330
+ text.addEventListener("click", async () => {
327
331
  const current = findCurrentTask(state.tasks, notification.taskId);
328
- if (current) openDialog(current);
329
- else showMessage("This task is no longer present in the current snapshot.");
332
+ state.notifications = dismissNotification(state.notifications, notification.id);
333
+ renderNotifications();
334
+ if (current) {
335
+ await openDialog(current, {
336
+ focus: true,
337
+ highlightTurnRef: notification.turnRef ?? notification.turnId,
338
+ });
339
+ } else showMessage("This task is no longer present in the current snapshot.");
330
340
  });
331
341
  const dismiss = document.createElement("button");
332
342
  dismiss.type = "button";
@@ -356,7 +366,31 @@ function renderNotifications(additions = []) {
356
366
  elements.toastList.replaceChildren(
357
367
  ...state.notifications.map(notificationToast),
358
368
  );
359
- elements.notifications.hidden = state.notifications.length === 0;
369
+ const notificationHost = elements.dialog.open ? elements.dialog : elements.notificationHost;
370
+ const movingLayer = elements.notifications.parentNode !== notificationHost;
371
+ if ((movingLayer || additions.length > 0) && state.notifications.length > 0) {
372
+ try {
373
+ elements.notifications.hidePopover?.();
374
+ } catch {
375
+ // The notification layer is not open yet.
376
+ }
377
+ }
378
+ if (movingLayer) notificationHost.append(elements.notifications);
379
+ if (state.notifications.length === 0) {
380
+ try {
381
+ elements.notifications.hidePopover?.();
382
+ } catch {
383
+ // The notification layer is already closed.
384
+ }
385
+ elements.notifications.hidden = true;
386
+ } else {
387
+ elements.notifications.hidden = false;
388
+ try {
389
+ elements.notifications.showPopover?.();
390
+ } catch {
391
+ // Re-rendering an already-open manual popover needs no further action.
392
+ }
393
+ }
360
394
  if (additions.length > 0) {
361
395
  elements.notificationAnnouncer.textContent = additions
362
396
  .map(notificationAnnouncement)
@@ -406,24 +440,31 @@ function usageBreakdownText(usage) {
406
440
  ].join(" · ");
407
441
  }
408
442
 
409
- function usagePresentation(usage, { wholeTask = false } = {}) {
443
+ export function usagePresentation(usage, { wholeTask = false } = {}) {
410
444
  const container = document.createElement("div");
411
- container.className = `usage-summary usage-${usage?.status ?? "calculating"}`;
412
- if (!usage || usage.status === "calculating") {
413
- const indicator = document.createElement("span");
414
- indicator.className = "usage-spinner";
415
- indicator.setAttribute("aria-hidden", "true");
445
+ container.className = `usage-summary usage-${usage?.status ?? "unavailable"}`;
446
+ if (usage?.status === "pending" || usage?.status === "calculating") {
416
447
  const text = document.createElement("span");
417
- text.textContent = "Tokens: calculating…";
418
- container.replaceChildren(indicator, text);
448
+ text.className = "usage-shimmer";
449
+ text.textContent = usage.status === "pending"
450
+ ? "Tokens pending · available when turn finishes"
451
+ : "Calculating token usage…";
452
+ container.replaceChildren(text);
419
453
  return container;
420
454
  }
421
- if (usage.status !== "available") {
422
- container.textContent = `Tokens unavailable${usage.reason ? ` — ${usage.reason}` : "."}`;
455
+ if (usage?.status !== "available") {
456
+ const headline = document.createElement("strong");
457
+ headline.textContent = "Token usage unavailable";
458
+ container.append(headline);
459
+ if (usage?.reason) {
460
+ const reason = document.createElement("span");
461
+ reason.textContent = usage.reason;
462
+ container.append(reason);
463
+ }
423
464
  return container;
424
465
  }
425
466
  const headline = document.createElement("strong");
426
- headline.textContent = `${tokenFormatter.format(usage.totalTokens)} tokens · ${formatEstimatedCost(usage.estimatedCostUsd)}`;
467
+ headline.textContent = `${tokenFormatter.format(usage.totalTokens)} tokens · ${formatEstimatedCost(usage.estimatedCostUsd)}${usage.knownSoFar ? " · known so far" : ""}`;
427
468
  const breakdown = document.createElement("span");
428
469
  breakdown.textContent = usageBreakdownText(usage);
429
470
  container.append(headline, breakdown);
@@ -449,7 +490,7 @@ function usageStillCalculating(task) {
449
490
  .some((turn) => turn.status === "calculating"));
450
491
  }
451
492
 
452
- function turnTimeline(task) {
493
+ function turnTimeline(task, { highlightTurnRef = null } = {}) {
453
494
  if (task.turns.length === 0) {
454
495
  const empty = document.createElement("p");
455
496
  empty.className = "result-history-empty";
@@ -467,6 +508,11 @@ function turnTimeline(task) {
467
508
  status.className = `status status-${turnStatus}`;
468
509
  status.textContent = turnStatus.replaceAll("_", " ");
469
510
  const turnKey = turn.turnRef ?? turn.turnId ?? `no-turn:${index}`;
511
+ item.dataset.turnRef = turnKey;
512
+ if (highlightTurnRef !== null && turnKey === highlightTurnRef) {
513
+ item.className += " result-history-notified";
514
+ item.tabIndex = -1;
515
+ }
470
516
  const timestamp = timestampControl(presentation.updatedAt, {
471
517
  accessibleName: `Turn updated time for ${turnStatus.replaceAll("_", " ")}`,
472
518
  key: `detail:${task.id}:turn:${turnKey}`,
@@ -495,11 +541,13 @@ function turnTimeline(task) {
495
541
  const turnMetadata = document.createElement("p");
496
542
  turnMetadata.className = "result-history-turn";
497
543
  turnMetadata.textContent = `Turn ref ${turn.turnRef ?? "not recorded"}; Codex turn ${turn.turnId ?? "unavailable"}`;
498
- const turnUsage = task.usage?.turns?.[turn.turnRef ?? turn.turnId] ?? (
499
- turn.result === null
500
- ? { status: "calculating" }
501
- : { status: "unavailable", reason: "No reliable turn boundary is available." }
502
- );
544
+ const recordedUsage = task.usage?.turns?.[turn.turnRef ?? turn.turnId];
545
+ const turnUsage = turn.result === null
546
+ ? { status: "pending" }
547
+ : recordedUsage ?? {
548
+ status: "unavailable",
549
+ reason: "No reliable turn boundary is available.",
550
+ };
503
551
  item.append(
504
552
  header,
505
553
  requestLabel,
@@ -513,6 +561,39 @@ function turnTimeline(task) {
513
561
  });
514
562
  }
515
563
 
564
+ function descendantPath(root, descendant) {
565
+ const path = [];
566
+ let current = descendant;
567
+ while (current && current !== root) {
568
+ const parent = current.parentNode;
569
+ if (!parent) return null;
570
+ const index = Array.prototype.indexOf.call(parent.children, current);
571
+ if (index < 0) return null;
572
+ path.unshift(index);
573
+ current = parent;
574
+ }
575
+ return current === root ? path : null;
576
+ }
577
+
578
+ function descendantAtPath(root, path) {
579
+ let current = root;
580
+ for (const index of path ?? []) {
581
+ current = current?.children?.[index];
582
+ if (!current) return null;
583
+ }
584
+ return current;
585
+ }
586
+
587
+ function descendantWithFocusKey(root, focusKey) {
588
+ if (!root || !focusKey) return null;
589
+ if (root.dataset?.focusKey === focusKey) return root;
590
+ for (const child of root.children ?? []) {
591
+ const match = descendantWithFocusKey(child, focusKey);
592
+ if (match) return match;
593
+ }
594
+ return null;
595
+ }
596
+
516
597
  function manualTransitionPending() {
517
598
  return state.manualTransition?.stage === "pending";
518
599
  }
@@ -648,7 +729,7 @@ async function submitManualTransition(targetStatus, event) {
648
729
  elements.manualTransitionError.focus();
649
730
  }
650
731
 
651
- function renderDialog(task) {
732
+ function renderDialog(task, { highlightTurnRef = null } = {}) {
652
733
  if (state.selectedTask?.id !== task.id) {
653
734
  copyTaskIdGeneration += 1;
654
735
  clearTimeout(copyTaskIdTimer);
@@ -657,11 +738,27 @@ function renderDialog(task) {
657
738
  const preservedResults = state.selectedTask?.id === task.id
658
739
  ? state.selectedTask.results
659
740
  : null;
741
+ const preservedUsage = state.selectedTask?.id === task.id
742
+ ? state.selectedTask.usage
743
+ : null;
660
744
  const detailedTask = {
661
745
  ...task,
662
746
  turns: mergeProjectedTurns(task, state.selectedTask?.turns ?? []),
663
747
  results: task.results ?? preservedResults ?? [],
748
+ usage: task.usage ?? preservedUsage ?? null,
664
749
  };
750
+ const focusedElement = elements.dialogResults.contains?.(document.activeElement)
751
+ ? document.activeElement
752
+ : null;
753
+ let focusedTurn = focusedElement;
754
+ while (focusedTurn && focusedTurn !== elements.dialogResults && !focusedTurn.dataset?.turnRef) {
755
+ focusedTurn = focusedTurn.parentNode;
756
+ }
757
+ const focusedTurnRef = focusedTurn?.dataset?.turnRef ?? null;
758
+ const focusedDescendantKey = focusedElement?.dataset?.focusKey ?? null;
759
+ const focusedDescendantPath = focusedTurn && focusedElement
760
+ ? descendantPath(focusedTurn, focusedElement)
761
+ : null;
665
762
  state.selectedTask = detailedTask;
666
763
  renderProject(elements.dialogProject, task);
667
764
  elements.dialogTitle.textContent = task.title;
@@ -673,11 +770,48 @@ function renderDialog(task) {
673
770
  elements.dialogRelatedLinks.hidden = (
674
771
  (task.relatedGitHubLinks?.length ?? 0) === 0 && !task.relatedGitHubLinksTruncated
675
772
  );
676
- elements.dialogResults.replaceChildren(...turnTimeline(detailedTask));
773
+ const timeline = turnTimeline(detailedTask, { highlightTurnRef });
774
+ elements.dialogResults.replaceChildren(...timeline);
775
+ const highlightedTurn = timeline.find((item) => item.dataset.turnRef === highlightTurnRef) ?? null;
776
+ const replacedFocusedTurn = timeline.find((item) => item.dataset.turnRef === focusedTurnRef) ?? null;
777
+ if (replacedFocusedTurn) {
778
+ const pathMatchedElement = descendantAtPath(replacedFocusedTurn, focusedDescendantPath);
779
+ const replacedFocusedElement = (
780
+ pathMatchedElement?.dataset?.focusKey === focusedDescendantKey
781
+ ? pathMatchedElement
782
+ : null
783
+ ) ?? descendantWithFocusKey(replacedFocusedTurn, focusedDescendantKey);
784
+ if (replacedFocusedElement && replacedFocusedElement !== replacedFocusedTurn) {
785
+ replacedFocusedElement.focus?.();
786
+ } else {
787
+ replacedFocusedTurn.tabIndex = -1;
788
+ replacedFocusedTurn.focus?.();
789
+ }
790
+ }
791
+ const currentTurnRef = task.turnRef ?? task.latestTurn?.turnRef ?? null;
792
+ const preservedUsageIsPriorGeneration = task.usage == null
793
+ && preservedUsage != null
794
+ && currentTurnRef != null
795
+ && preservedUsage.generationTurnRef !== currentTurnRef;
796
+ const taskUsage = detailedTask.usage?.task
797
+ && (detailedTask.usage.status === "available" || task.status === "working")
798
+ && (!preservedUsageIsPriorGeneration || task.status === "working")
799
+ ? {
800
+ status: "available",
801
+ ...detailedTask.usage.task,
802
+ knownSoFar: task.status === "working",
803
+ }
804
+ : detailedTask.usage?.status === "calculating" || preservedUsageIsPriorGeneration
805
+ ? { ...detailedTask.usage, status: task.status === "working" ? "pending" : "calculating" }
806
+ : detailedTask.usage ?? {
807
+ status: task.status === "working"
808
+ ? "pending"
809
+ : task.threadId
810
+ ? "calculating"
811
+ : "unavailable",
812
+ };
677
813
  elements.dialogUsage.replaceChildren(usagePresentation(
678
- task.usage?.status === "available" && task.usage.task
679
- ? { status: "available", ...task.usage.task }
680
- : task.usage ?? { status: task.threadId ? "calculating" : "unavailable" },
814
+ taskUsage,
681
815
  { wholeTask: true },
682
816
  ));
683
817
  elements.dialogInstruction.textContent = task.instruction;
@@ -722,12 +856,31 @@ function renderDialog(task) {
722
856
  archived ? `${task.title} is archived in Codex` : `Archive ${task.title} in Codex`,
723
857
  );
724
858
  renderManualTransition(detailedTask);
859
+ return highlightedTurn;
725
860
  }
726
861
 
727
- async function openDialog(task) {
862
+ async function openDialog(task, {
863
+ focus = false,
864
+ highlightTurnRef = null,
865
+ preserveNavigation = false,
866
+ } = {}) {
867
+ if (focus) {
868
+ state.detailNavigation = {
869
+ focusPending: true,
870
+ highlightTurnRef,
871
+ taskId: task.id,
872
+ };
873
+ } else if (!preserveNavigation) state.detailNavigation = null;
874
+ const navigation = state.detailNavigation?.taskId === task.id
875
+ ? state.detailNavigation
876
+ : null;
877
+ const activeHighlightTurnRef = navigation?.highlightTurnRef ?? highlightTurnRef;
728
878
  const requestGeneration = ++detailRequestGeneration;
729
- renderDialog(task);
730
- if (!elements.dialog.open) elements.dialog.showModal();
879
+ renderDialog(task, { highlightTurnRef: activeHighlightTurnRef });
880
+ if (!elements.dialog.open) {
881
+ elements.dialog.showModal();
882
+ renderNotifications();
883
+ }
731
884
  const load = async (attempt = 0) => {
732
885
  try {
733
886
  const response = await fetch(`/api/tasks/${encodeURIComponent(task.id)}`);
@@ -738,14 +891,34 @@ async function openDialog(task) {
738
891
  && state.selectedTask?.id === task.id
739
892
  && elements.dialog.open
740
893
  ) {
741
- renderDialog(detail.task);
894
+ const activeNavigation = state.detailNavigation?.taskId === task.id
895
+ ? state.detailNavigation
896
+ : null;
897
+ const highlightedTurn = renderDialog(detail.task, {
898
+ highlightTurnRef: activeNavigation?.highlightTurnRef ?? activeHighlightTurnRef,
899
+ });
900
+ if (activeNavigation?.focusPending) {
901
+ (highlightedTurn ?? elements.dialogTitle).focus?.();
902
+ activeNavigation.focusPending = false;
903
+ }
742
904
  if (usageStillCalculating(detail.task) && attempt < MAX_USAGE_POLL_ATTEMPTS) {
743
905
  setTimeout(() => load(attempt + 1), USAGE_POLL_INTERVAL_MS);
744
906
  }
745
907
  }
746
908
  } catch {
747
- if (state.selectedTask?.id === task.id) {
909
+ if (
910
+ requestGeneration === detailRequestGeneration
911
+ && state.selectedTask?.id === task.id
912
+ && elements.dialog.open
913
+ ) {
748
914
  showMessage("Task activity timeline is temporarily unavailable.");
915
+ const activeNavigation = state.detailNavigation?.taskId === task.id
916
+ ? state.detailNavigation
917
+ : null;
918
+ if (activeNavigation?.focusPending) {
919
+ elements.dialogTitle.focus?.();
920
+ activeNavigation.focusPending = false;
921
+ }
749
922
  }
750
923
  }
751
924
  };
@@ -869,7 +1042,7 @@ function applySnapshot(snapshot) {
869
1042
  );
870
1043
  if (state.selectedTask) {
871
1044
  const updated = state.tasks.find((task) => task.id === state.selectedTask.id);
872
- if (updated && elements.dialog.open) openDialog(updated);
1045
+ if (updated && elements.dialog.open) openDialog(updated, { preserveNavigation: true });
873
1046
  }
874
1047
  renderNotifications(reconciled.additions);
875
1048
  render();
@@ -924,6 +1097,8 @@ elements.dialog.addEventListener("cancel", (event) => {
924
1097
  });
925
1098
  elements.dialog.addEventListener("close", () => {
926
1099
  if (!manualTransitionPending()) state.manualTransition = null;
1100
+ state.detailNavigation = null;
1101
+ renderNotifications();
927
1102
  });
928
1103
  elements.moreTaskActions.addEventListener("click", () => {
929
1104
  const task = state.selectedTask;
@@ -87,14 +87,16 @@
87
87
  </section>
88
88
  </main>
89
89
 
90
- <aside id="notifications" class="notifications" aria-label="Task notifications" hidden>
91
- <div class="notifications-heading">
92
- <strong>Updates</strong>
93
- <button id="clear-notifications" class="text-button" type="button">Clear all</button>
94
- </div>
95
- <p id="notification-announcer" class="visually-hidden" role="status" aria-live="polite"></p>
96
- <div id="toast-list" class="toast-list"></div>
97
- </aside>
90
+ <div id="notification-host">
91
+ <aside id="notifications" class="notifications" aria-label="Task notifications" popover="manual">
92
+ <div class="notifications-heading">
93
+ <strong>Updates</strong>
94
+ <button id="clear-notifications" class="text-button" type="button">Clear all</button>
95
+ </div>
96
+ <p id="notification-announcer" class="visually-hidden" role="status" aria-live="polite"></p>
97
+ <div id="toast-list" class="toast-list"></div>
98
+ </aside>
99
+ </div>
98
100
 
99
101
  <dialog id="task-dialog" aria-labelledby="dialog-title">
100
102
  <div class="dialog-header">
@@ -134,7 +134,7 @@ time, .timestamp-missing { font-size: 0.75rem; }
134
134
  .dashboard-message span { flex: 1; }
135
135
  .dashboard-message .icon-button { flex: none; padding: 4px 7px; color: inherit; }
136
136
 
137
- .notifications { position: fixed; right: 20px; bottom: 20px; z-index: 3; width: min(380px, calc(100vw - 40px)); padding: 14px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); box-shadow: var(--shadow); }
137
+ .notifications { position: fixed; inset: auto 20px 20px auto; width: min(380px, calc(100vw - 40px)); margin: 0; padding: 14px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); color: var(--text); box-shadow: var(--shadow); }
138
138
  .notifications-heading { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
139
139
  .toast-list { display: grid; gap: 7px; max-height: 50vh; overflow: auto; }
140
140
  .toast { display: flex; align-items: flex-start; gap: 8px; padding: 10px; border-radius: 7px; background: var(--surface-muted); }
@@ -170,6 +170,7 @@ dialog section + section { margin-top: 24px; }
170
170
  .result-history { display: grid; gap: 10px; }
171
171
  .result-history-item { padding: 13px 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-muted); }
172
172
  .result-history-latest { border-color: var(--accent); background: var(--accent-soft); box-shadow: inset 3px 0 var(--accent); }
173
+ .result-history-notified { outline: 3px solid color-mix(in srgb, var(--accent) 35%, transparent); outline-offset: 2px; }
173
174
  .result-history-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 9px; }
174
175
  .result-history-header .timestamp-toggle { margin-left: auto; }
175
176
  .result-history-source { color: var(--muted); font-size: 0.72rem; font-weight: 700; }
@@ -181,10 +182,24 @@ dialog section + section { margin-top: 24px; }
181
182
  .usage-panel { margin-top: 8px; }
182
183
  .usage-summary { display: grid; gap: 3px; margin: 10px 0 4px; color: var(--muted); font-size: 0.8rem; }
183
184
  .usage-summary strong { color: var(--text); font-size: 0.9rem; }
184
- .usage-calculating { display: flex; align-items: center; gap: 7px; }
185
- .usage-spinner { width: 12px; height: 12px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: usage-spin 0.8s linear infinite; }
186
- @keyframes usage-spin { to { transform: rotate(360deg); } }
187
- @media (prefers-reduced-motion: reduce) { .usage-spinner { animation: none; border-top-color: var(--border); background: var(--accent); } }
185
+ .usage-pending, .usage-calculating { display: flex; align-items: center; }
186
+ .usage-shimmer {
187
+ color: var(--muted);
188
+ background: linear-gradient(100deg, var(--muted) 35%, var(--text) 50%, var(--muted) 65%);
189
+ background-size: 250% 100%;
190
+ background-position: 100% 0;
191
+ background-clip: text;
192
+ -webkit-background-clip: text;
193
+ -webkit-text-fill-color: transparent;
194
+ animation: usage-shimmer 2.8s ease-in-out infinite;
195
+ }
196
+ @keyframes usage-shimmer {
197
+ 0% { background-position: 100% 0; }
198
+ 72%, 100% { background-position: -100% 0; }
199
+ }
200
+ @media (prefers-reduced-motion: reduce) {
201
+ .usage-shimmer { background: none; animation: none; -webkit-text-fill-color: currentcolor; }
202
+ }
188
203
  pre { max-height: 280px; margin: 0; padding: 14px; overflow: auto; border-radius: 7px; background: var(--surface-muted); white-space: pre-wrap; overflow-wrap: anywhere; font: 0.86rem/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
189
204
  .metadata { display: grid; grid-template-columns: minmax(100px, 150px) 1fr; margin: 0; font-size: 0.88rem; }
190
205
  .metadata dt, .metadata dd { padding: 7px 0; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }