@genesislcap/ai-assistant 15.7.3-alpha-e7d4aa5.0 → 15.8.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.
Files changed (43) hide show
  1. package/dist/ai-assistant.api.json +191 -1
  2. package/dist/ai-assistant.d.ts +155 -1
  3. package/dist/custom-elements.json +114 -2
  4. package/dist/dts/components/settings-modal/settings-modal.styles.d.ts.map +1 -1
  5. package/dist/dts/components/settings-modal/settings-modal.styles.test.d.ts +2 -0
  6. package/dist/dts/components/settings-modal/settings-modal.styles.test.d.ts.map +1 -0
  7. package/dist/dts/components/settings-modal/settings-modal.template.d.ts +22 -0
  8. package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
  9. package/dist/dts/components/settings-modal/settings-modal.template.test.d.ts +2 -0
  10. package/dist/dts/components/settings-modal/settings-modal.template.test.d.ts.map +1 -0
  11. package/dist/dts/main/budget-meter.test.d.ts +2 -0
  12. package/dist/dts/main/budget-meter.test.d.ts.map +1 -0
  13. package/dist/dts/main/main.d.ts +113 -2
  14. package/dist/dts/main/main.d.ts.map +1 -1
  15. package/dist/dts/main/main.types.d.ts +25 -0
  16. package/dist/dts/main/main.types.d.ts.map +1 -1
  17. package/dist/dts/state/ai-assistant-slice.d.ts +47 -0
  18. package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
  19. package/dist/dts/state/session-store.d.ts +4 -0
  20. package/dist/dts/state/session-store.d.ts.map +1 -1
  21. package/dist/dts/utils/format-usd.d.ts +24 -0
  22. package/dist/dts/utils/format-usd.d.ts.map +1 -0
  23. package/dist/esm/components/settings-modal/settings-modal.styles.js +33 -3
  24. package/dist/esm/components/settings-modal/settings-modal.styles.test.js +80 -0
  25. package/dist/esm/components/settings-modal/settings-modal.template.js +64 -14
  26. package/dist/esm/components/settings-modal/settings-modal.template.test.js +91 -0
  27. package/dist/esm/main/budget-meter.test.js +317 -0
  28. package/dist/esm/main/main.js +189 -3
  29. package/dist/esm/state/ai-assistant-slice.js +38 -0
  30. package/dist/esm/utils/format-usd.js +23 -0
  31. package/dist/tsconfig.tsbuildinfo +1 -1
  32. package/docs/migration-GENC-1464.md +63 -0
  33. package/docs/styling.md +23 -0
  34. package/package.json +17 -17
  35. package/src/components/settings-modal/settings-modal.styles.test.ts +94 -0
  36. package/src/components/settings-modal/settings-modal.styles.ts +33 -3
  37. package/src/components/settings-modal/settings-modal.template.test.ts +124 -0
  38. package/src/components/settings-modal/settings-modal.template.ts +82 -13
  39. package/src/main/budget-meter.test.ts +427 -0
  40. package/src/main/main.ts +198 -3
  41. package/src/main/main.types.ts +26 -0
  42. package/src/state/ai-assistant-slice.ts +74 -0
  43. package/src/utils/format-usd.ts +26 -0
@@ -1 +1 @@
1
- {"root":["../src/chat-driver-node.ts","../src/index.ts","../src/channel/ai-activity-bus.ts","../src/channel/ai-activity-channel.ts","../src/components/flowing-waves-indicator.ts","../src/components/halo-overlay.ts","../src/components/plasma-orb-indicator.ts","../src/components/waves-indicator.ts","../src/components/activity-halo/activity-halo.ts","../src/components/agent-picker/agent-picker.constants.ts","../src/components/agent-picker/agent-picker.styles.ts","../src/components/agent-picker/agent-picker.template.ts","../src/components/agent-picker/agent-picker.ts","../src/components/agent-picker/index.ts","../src/components/ai-driver/ai-driver.ts","../src/components/ai-driver/index.ts","../src/components/chat-bubble/chat-bubble.styles.ts","../src/components/chat-bubble/chat-bubble.template.ts","../src/components/chat-bubble/chat-bubble.ts","../src/components/chat-bubble/index.ts","../src/components/chat-driver/align-event-globals.ts","../src/components/chat-driver/chat-driver.compact.test.ts","../src/components/chat-driver/chat-driver.test.ts","../src/components/chat-driver/chat-driver.ts","../src/components/chat-driver/index.ts","../src/components/chat-interaction-wrapper/chat-interaction-wrapper.styles.ts","../src/components/chat-interaction-wrapper/chat-interaction-wrapper.template.ts","../src/components/chat-interaction-wrapper/chat-interaction-wrapper.test.ts","../src/components/chat-interaction-wrapper/chat-interaction-wrapper.ts","../src/components/chat-interaction-wrapper/index.ts","../src/components/chat-markdown/chat-markdown.ts","../src/components/chat-markdown/index.ts","../src/components/orchestrating-driver/index.ts","../src/components/orchestrating-driver/orchestrating-driver.budget.test.ts","../src/components/orchestrating-driver/orchestrating-driver.pin.test.ts","../src/components/orchestrating-driver/orchestrating-driver.ts","../src/components/popout-manager/index.ts","../src/components/popout-manager/popout-manager.ts","../src/components/settings-modal/index.ts","../src/components/settings-modal/settings-modal.styles.ts","../src/components/settings-modal/settings-modal.template.ts","../src/config/config.ts","../src/config/define-stateful-agent.test.ts","../src/config/define-stateful-agent.ts","../src/config/fallback-agents.ts","../src/config/index.ts","../src/config/validate-providers.test.ts","../src/config/validate-providers.ts","../src/main/blocked-state.test.ts","../src/main/cost-session-banking.test.ts","../src/main/index.ts","../src/main/main.styles.test.ts","../src/main/main.styles.ts","../src/main/main.template.ts","../src/main/main.ts","../src/main/main.types.ts","../src/main/popout-interaction-gate.test.ts","../src/provider/ai-provider-switcher.ts","../src/provider/assistant-app-settings.ts","../src/state/ai-assistant-slice.test.ts","../src/state/ai-assistant-slice.ts","../src/state/debug-event-log.test.ts","../src/state/debug-event-log.ts","../src/state/driver-registry.test.ts","../src/state/driver-registry.ts","../src/state/interaction-context.test.ts","../src/state/interaction-context.ts","../src/state/session-store.ts","../src/state/persistence/build-timeline-entries.ts","../src/state/persistence/diagnostics-cursors.test.ts","../src/state/persistence/diagnostics-cursors.ts","../src/state/persistence/diagnostics.test.ts","../src/state/persistence/diagnostics.ts","../src/state/persistence/index.ts","../src/state/persistence/persister-registry.ts","../src/state/persistence/session-persistence-provider.test.ts","../src/state/persistence/session-persistence-provider.ts","../src/state/persistence/session-persistence.integration.test.ts","../src/state/persistence/session-persister.test.ts","../src/state/persistence/session-persister.ts","../src/state/persistence/session-snapshot.test.ts","../src/state/persistence/session-snapshot.ts","../src/state/persistence/stateful-restore.e2e.test.ts","../src/styles/ai-colours.ts","../src/styles/settings-section.ts","../src/suggestions/chat-suggestions.ts","../src/tags/index.ts","../src/types/ai-chat-widget.ts","../src/types/interaction-context.ts","../src/utils/animated-panel-toggle.ts","../src/utils/animation-exclusivity.test.ts","../src/utils/animation-exclusivity.ts","../src/utils/banked-usage-baselines.ts","../src/utils/collect-session-models.test.ts","../src/utils/collect-session-models.ts","../src/utils/condense-history.test.ts","../src/utils/condense-history.ts","../src/utils/cost-session-history.test.ts","../src/utils/cost-session-history.ts","../src/utils/derive-cost-session-title.test.ts","../src/utils/derive-cost-session-title.ts","../src/utils/flatten-sub-agent-messages.test.ts","../src/utils/flatten-sub-agent-messages.ts","../src/utils/history-transform.test.ts","../src/utils/history-transform.ts","../src/utils/index.ts","../src/utils/logger.ts","../src/utils/message-partition.test.ts","../src/utils/message-partition.ts","../src/utils/resolve-cost-history-config.test.ts","../src/utils/resolve-cost-history-config.ts","../src/utils/resolve-preference-baseline.test.ts","../src/utils/resolve-preference-baseline.ts","../src/utils/strip-agent-handlers.test.ts","../src/utils/strip-agent-handlers.ts","../src/utils/sum-costs.test.ts","../src/utils/sum-costs.ts","../src/utils/sum-tokens.test.ts","../src/utils/sum-tokens.ts","../src/utils/sum-usage.test.ts","../src/utils/sum-usage.ts","../src/utils/tool-fold.ts","../src/utils/with-timeout.ts"],"version":"5.9.2"}
1
+ {"root":["../src/chat-driver-node.ts","../src/index.ts","../src/channel/ai-activity-bus.ts","../src/channel/ai-activity-channel.ts","../src/components/flowing-waves-indicator.ts","../src/components/halo-overlay.ts","../src/components/plasma-orb-indicator.ts","../src/components/waves-indicator.ts","../src/components/activity-halo/activity-halo.ts","../src/components/agent-picker/agent-picker.constants.ts","../src/components/agent-picker/agent-picker.styles.ts","../src/components/agent-picker/agent-picker.template.ts","../src/components/agent-picker/agent-picker.ts","../src/components/agent-picker/index.ts","../src/components/ai-driver/ai-driver.ts","../src/components/ai-driver/index.ts","../src/components/chat-bubble/chat-bubble.styles.ts","../src/components/chat-bubble/chat-bubble.template.ts","../src/components/chat-bubble/chat-bubble.ts","../src/components/chat-bubble/index.ts","../src/components/chat-driver/align-event-globals.ts","../src/components/chat-driver/chat-driver.compact.test.ts","../src/components/chat-driver/chat-driver.test.ts","../src/components/chat-driver/chat-driver.ts","../src/components/chat-driver/index.ts","../src/components/chat-interaction-wrapper/chat-interaction-wrapper.styles.ts","../src/components/chat-interaction-wrapper/chat-interaction-wrapper.template.ts","../src/components/chat-interaction-wrapper/chat-interaction-wrapper.test.ts","../src/components/chat-interaction-wrapper/chat-interaction-wrapper.ts","../src/components/chat-interaction-wrapper/index.ts","../src/components/chat-markdown/chat-markdown.ts","../src/components/chat-markdown/index.ts","../src/components/orchestrating-driver/index.ts","../src/components/orchestrating-driver/orchestrating-driver.budget.test.ts","../src/components/orchestrating-driver/orchestrating-driver.pin.test.ts","../src/components/orchestrating-driver/orchestrating-driver.ts","../src/components/popout-manager/index.ts","../src/components/popout-manager/popout-manager.ts","../src/components/settings-modal/index.ts","../src/components/settings-modal/settings-modal.styles.test.ts","../src/components/settings-modal/settings-modal.styles.ts","../src/components/settings-modal/settings-modal.template.test.ts","../src/components/settings-modal/settings-modal.template.ts","../src/config/config.ts","../src/config/define-stateful-agent.test.ts","../src/config/define-stateful-agent.ts","../src/config/fallback-agents.ts","../src/config/index.ts","../src/config/validate-providers.test.ts","../src/config/validate-providers.ts","../src/main/blocked-state.test.ts","../src/main/budget-meter.test.ts","../src/main/cost-session-banking.test.ts","../src/main/index.ts","../src/main/main.styles.test.ts","../src/main/main.styles.ts","../src/main/main.template.ts","../src/main/main.ts","../src/main/main.types.ts","../src/main/popout-interaction-gate.test.ts","../src/provider/ai-provider-switcher.ts","../src/provider/assistant-app-settings.ts","../src/state/ai-assistant-slice.test.ts","../src/state/ai-assistant-slice.ts","../src/state/debug-event-log.test.ts","../src/state/debug-event-log.ts","../src/state/driver-registry.test.ts","../src/state/driver-registry.ts","../src/state/interaction-context.test.ts","../src/state/interaction-context.ts","../src/state/session-store.ts","../src/state/persistence/build-timeline-entries.ts","../src/state/persistence/diagnostics-cursors.test.ts","../src/state/persistence/diagnostics-cursors.ts","../src/state/persistence/diagnostics.test.ts","../src/state/persistence/diagnostics.ts","../src/state/persistence/index.ts","../src/state/persistence/persister-registry.ts","../src/state/persistence/session-persistence-provider.test.ts","../src/state/persistence/session-persistence-provider.ts","../src/state/persistence/session-persistence.integration.test.ts","../src/state/persistence/session-persister.test.ts","../src/state/persistence/session-persister.ts","../src/state/persistence/session-snapshot.test.ts","../src/state/persistence/session-snapshot.ts","../src/state/persistence/stateful-restore.e2e.test.ts","../src/styles/ai-colours.ts","../src/styles/settings-section.ts","../src/suggestions/chat-suggestions.ts","../src/tags/index.ts","../src/types/ai-chat-widget.ts","../src/types/interaction-context.ts","../src/utils/animated-panel-toggle.ts","../src/utils/animation-exclusivity.test.ts","../src/utils/animation-exclusivity.ts","../src/utils/banked-usage-baselines.ts","../src/utils/collect-session-models.test.ts","../src/utils/collect-session-models.ts","../src/utils/condense-history.test.ts","../src/utils/condense-history.ts","../src/utils/cost-session-history.test.ts","../src/utils/cost-session-history.ts","../src/utils/derive-cost-session-title.test.ts","../src/utils/derive-cost-session-title.ts","../src/utils/flatten-sub-agent-messages.test.ts","../src/utils/flatten-sub-agent-messages.ts","../src/utils/format-usd.ts","../src/utils/history-transform.test.ts","../src/utils/history-transform.ts","../src/utils/index.ts","../src/utils/logger.ts","../src/utils/message-partition.test.ts","../src/utils/message-partition.ts","../src/utils/resolve-cost-history-config.test.ts","../src/utils/resolve-cost-history-config.ts","../src/utils/resolve-preference-baseline.test.ts","../src/utils/resolve-preference-baseline.ts","../src/utils/strip-agent-handlers.test.ts","../src/utils/strip-agent-handlers.ts","../src/utils/sum-costs.test.ts","../src/utils/sum-costs.ts","../src/utils/sum-tokens.test.ts","../src/utils/sum-tokens.ts","../src/utils/sum-usage.test.ts","../src/utils/sum-usage.ts","../src/utils/tool-fold.ts","../src/utils/with-timeout.ts"],"version":"5.9.2"}
@@ -512,6 +512,69 @@ you `switch` exhaustively over either with a `never` guard, TypeScript will now
512
512
  member — that is the intended prompt to decide how your app should treat it, not a breaking
513
513
  change to runtime behaviour.
514
514
 
515
+ ### Feeding the budget meter
516
+
517
+ Orthogonal to Options A–C (combine it with any of them): the settings modal can show a
518
+ **per-vendor budget meter** — one row per vendor with `$spent / $limit (P%)` text and a
519
+ progress bar — but only if the host feeds it. The element meters nothing itself; the
520
+ meter renders exactly the vendors that currently have figures, fed through the public
521
+ `setVendorBudget(vendor, figures)` API from the same budget endpoint the pre-flight
522
+ (Option B) already consumes:
523
+
524
+ ```ts
525
+ async function feedBudgetMeter(assistantEl: FoundationAiAssistant) {
526
+ // `vendors` is an OBJECT keyed by vendor id, not an array.
527
+ const { vendors } = await (await fetch('/api/budget')).json();
528
+ for (const v of Object.values(vendors)) {
529
+ // An UNLIMITED vendor (`unlimited: true` / `limitUsd: null`) gets no row —
530
+ // simply do not feed it. There is no "unlimited" figure shape: a bar with
531
+ // no cap has nothing truthful to fill to.
532
+ if (v.unlimited || v.limitUsd == null) {
533
+ assistantEl.setVendorBudget(v.vendor, null);
534
+ continue;
535
+ }
536
+ assistantEl.setVendorBudget(v.vendor, { budgetUsd: v.limitUsd, spentUsd: v.spentUsd });
537
+ }
538
+ }
539
+
540
+ // on mount (alongside the Option B pre-flight, if you run one)…
541
+ await feedBudgetMeter(assistantEl);
542
+ // …and re-run it after every completed turn, so the meter tracks the spend
543
+ // that turn just caused.
544
+ ```
545
+
546
+ What to expect from that loop:
547
+
548
+ - **Re-feed after each turn, unconditionally.** The call is idempotent — identical
549
+ figures are a stored no-op — so there is no cheaper "only if changed" protocol worth
550
+ building. `setVendorBudget(vendor, null)` clears a vendor's row.
551
+ - **The figures lag by one turn, and that is expected.** The server meters a turn's
552
+ spend as it happens, but the host only re-fetches after the turn completes — so the
553
+ meter the user watches *during* a turn shows the total up to the previous one. The
554
+ meter is a gauge, not a live ticker; the authoritative gate is the server's (the 402
555
+ and the pre-flight), which reads the live account.
556
+ - **Spend can exceed the limit.** The last allowed call may overshoot the cap, and the
557
+ row's text shows the real figures (`$62.34 / $50.00 (125%)`); only the bar clamps
558
+ at 100%.
559
+ - **Validation is forgiving at run time.** An unbudgeted vendor (`chrome`, `openai`,
560
+ `'none'`, anything unknown) or non-finite/negative figures make the call return
561
+ `false` — with a single warning per element, not one per turn — and change nothing.
562
+ `undefined` figures behave exactly like `null` and clear the row, so an untyped
563
+ per-vendor lookup that misses is a clear, not a crash.
564
+ - **`false` also means "too early".** A feed that lands before the element has its
565
+ session store — ahead of the first append, or during a pop-out remount — stores
566
+ nothing and returns `false`, without a warning (a lifecycle window is not bad
567
+ input). Nothing to handle: the per-turn re-feed above lands the figures as soon as
568
+ the element is ready.
569
+ - **Lifetime follows the blocked latch, minus persistence.** The figures survive
570
+ pop-in/out and "Clear" / "New chat" (a new chat refills no budget), but are *not*
571
+ written to the session snapshot: the server is authoritative, and your mount-time
572
+ feed is what restores the meter after a reload — a persisted copy could only ever
573
+ show stale dollars.
574
+ - **Config:** `chatConfig.ui.showBudgetUsage: false` hides the meter (default is on,
575
+ like `showContextUsage`). Styling hooks: `::part(budget-usage)` and
576
+ `::part(budget-progress)` — see `docs/styling.md`.
577
+
515
578
  ---
516
579
 
517
580
  ## Styling the banner
package/docs/styling.md CHANGED
@@ -81,6 +81,29 @@ foundation-ai-assistant::part(message) {
81
81
  }
82
82
  ```
83
83
 
84
+ ### The settings-modal budget meter
85
+
86
+ The per-vendor AI budget meter (GENC-1464) renders in the settings modal's
87
+ "AI Model Settings" section — one row per vendor the host has fed via
88
+ `setVendorBudget` — and follows the context-usage indicator's part convention:
89
+
90
+ | Part | Element |
91
+ | --- | --- |
92
+ | `budget-usage` | The meter's container (all vendor rows) |
93
+ | `budget-progress` | One vendor's budget bar (repeats per rendered row) |
94
+
95
+ ```css
96
+ /* Tighten the meter's row spacing */
97
+ foundation-ai-assistant::part(budget-usage) {
98
+ gap: 4px;
99
+ }
100
+
101
+ /* Restyle every vendor's bar at once */
102
+ foundation-ai-assistant::part(budget-progress) {
103
+ height: 6px;
104
+ }
105
+ ```
106
+
84
107
  ### The conversation column (`messages-content`)
85
108
 
86
109
  Messages sit in a column capped at `1000px` and centred inside the full-width
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@genesislcap/ai-assistant",
3
3
  "description": "Genesis AI Assistant micro-frontend",
4
- "version": "15.7.3-alpha-e7d4aa5.0",
4
+ "version": "15.8.0",
5
5
  "license": "SEE LICENSE IN license.txt",
6
6
  "main": "dist/esm/index.js",
7
7
  "types": "dist/ai-assistant.d.ts",
@@ -73,26 +73,26 @@
73
73
  }
74
74
  },
75
75
  "devDependencies": {
76
- "@genesislcap/foundation-testing": "15.7.3-alpha-e7d4aa5.0",
77
- "@genesislcap/genx": "15.7.3-alpha-e7d4aa5.0",
78
- "@genesislcap/rollup-builder": "15.7.3-alpha-e7d4aa5.0",
79
- "@genesislcap/ts-builder": "15.7.3-alpha-e7d4aa5.0",
80
- "@genesislcap/uvu-playwright-builder": "15.7.3-alpha-e7d4aa5.0",
81
- "@genesislcap/vite-builder": "15.7.3-alpha-e7d4aa5.0",
82
- "@genesislcap/webpack-builder": "15.7.3-alpha-e7d4aa5.0",
76
+ "@genesislcap/foundation-testing": "15.8.0",
77
+ "@genesislcap/genx": "15.8.0",
78
+ "@genesislcap/rollup-builder": "15.8.0",
79
+ "@genesislcap/ts-builder": "15.8.0",
80
+ "@genesislcap/uvu-playwright-builder": "15.8.0",
81
+ "@genesislcap/vite-builder": "15.8.0",
82
+ "@genesislcap/webpack-builder": "15.8.0",
83
83
  "@types/dompurify": "^3.0.5",
84
84
  "@types/marked": "^5.0.2",
85
85
  "esbuild": "0.25.12"
86
86
  },
87
87
  "dependencies": {
88
- "@genesislcap/foundation-ai": "15.7.3-alpha-e7d4aa5.0",
89
- "@genesislcap/foundation-logger": "15.7.3-alpha-e7d4aa5.0",
90
- "@genesislcap/foundation-notifications": "15.7.3-alpha-e7d4aa5.0",
91
- "@genesislcap/foundation-redux": "15.7.3-alpha-e7d4aa5.0",
92
- "@genesislcap/foundation-ui": "15.7.3-alpha-e7d4aa5.0",
93
- "@genesislcap/foundation-utils": "15.7.3-alpha-e7d4aa5.0",
94
- "@genesislcap/rapid-design-system": "15.7.3-alpha-e7d4aa5.0",
95
- "@genesislcap/web-core": "15.7.3-alpha-e7d4aa5.0",
88
+ "@genesislcap/foundation-ai": "15.8.0",
89
+ "@genesislcap/foundation-logger": "15.8.0",
90
+ "@genesislcap/foundation-notifications": "15.8.0",
91
+ "@genesislcap/foundation-redux": "15.8.0",
92
+ "@genesislcap/foundation-ui": "15.8.0",
93
+ "@genesislcap/foundation-utils": "15.8.0",
94
+ "@genesislcap/rapid-design-system": "15.8.0",
95
+ "@genesislcap/web-core": "15.8.0",
96
96
  "dompurify": "^3.3.1",
97
97
  "marked": "^17.0.3"
98
98
  },
@@ -105,5 +105,5 @@
105
105
  "access": "public"
106
106
  },
107
107
  "customElements": "dist/custom-elements.json",
108
- "gitHead": "47329b0160faf49d52b2964ea22ed9362f275130"
108
+ "gitHead": "10412a7d63fbdf104e0c8f8704a03a7f3b7ad347"
109
109
  }
@@ -0,0 +1,94 @@
1
+ import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
2
+ import { settingsModalStyles } from './settings-modal.styles';
3
+
4
+ // GENC-1464 budget meter — the styling invariants a reviewer would otherwise
5
+ // have to catch by eye, following main.styles.test.ts (see there for why CSS
6
+ // gets asserted from the generated text at all, and for the scoping lesson:
7
+ // these assertions are about the BUDGET rules only, not a global style policy).
8
+ //
9
+ // 1. Design-system tokens are consumed UNFALLBACKED — the #2411/#2436 review
10
+ // rule. Every token the meter reads ships a DS default, so a
11
+ // `var(--token, fallback)` is dead code at best and a silently-broken
12
+ // token name at worst.
13
+ // 2. The meter's row anatomy is SHARED with the context indicator via grouped
14
+ // selectors, not copied. The two indicators are siblings by design;
15
+ // copied declarations would let them drift apart one edit at a time, which
16
+ // is exactly what the grouping was chosen to make impossible.
17
+
18
+ const suite = createLogicSuite('settings-modal.styles budget-meter rules');
19
+
20
+ /**
21
+ * The generated stylesheet text, flattened recursively — `css` composes nested
22
+ * `ElementStyles`, and a shallow read can return almost nothing, making every
23
+ * negative assertion below pass vacuously. The first test guards that.
24
+ */
25
+ const flatten = (value: unknown): string => {
26
+ if (typeof value === 'string') return value;
27
+ if (Array.isArray(value)) return value.map(flatten).join('\n');
28
+ const nested = (value as { styles?: unknown } | null | undefined)?.styles;
29
+ return nested ? flatten(nested) : '';
30
+ };
31
+
32
+ /**
33
+ * Comments stripped before matching: the assertions below are about
34
+ * DECLARATIONS, and the sheet's own prose legitimately mentions the very
35
+ * pattern (`var(--x, y)`) the negative assertion hunts for.
36
+ */
37
+ const cssText = flatten(settingsModalStyles).replace(/\/\*[\s\S]*?\*\//g, '');
38
+
39
+ /** Every declaration block whose selector mentions `.settings-budget-`, joined. */
40
+ const budgetRules = (cssText.match(/[^{}]*\.settings-budget-[^{}]*\{[^}]*\}/g) ?? []).join('\n');
41
+
42
+ suite('the budget rules were extracted as text (guards the assertions below)', () => {
43
+ assert.ok(cssText.includes('.settings-budget-usage'), 'the meter container rule is in the sheet');
44
+ assert.ok(budgetRules.length > 0, 'the extraction found the budget rules');
45
+ });
46
+
47
+ suite('the meter consumes design-system tokens without dead fallbacks', () => {
48
+ // One structural probe rather than a per-token list: ANY `var(--x,` inside a
49
+ // budget rule is a fallback, and the meter reads only tokens that ship DS
50
+ // defaults (--design-unit, --neutral-foreground-*), so there is no legitimate
51
+ // fallback for this scope. Scoped to the budget rules on purpose — see
52
+ // main.styles.test.ts for why a whole-sheet grep is the wrong test.
53
+ assert.not.ok(/var\(--[a-z-]+\s*,/.test(budgetRules), 'no var(--token, fallback) in the meter');
54
+ assert.ok(budgetRules.includes('var(--design-unit)'), 'and it does consume tokens');
55
+ });
56
+
57
+ suite('every meter row sits on one shared rhythm', () => {
58
+ // The 4/2/2 fix. `.settings-modal-section-body` gaps at 4 units and the
59
+ // budget meter at 2, so an unwrapped context indicator sat 4 units above the
60
+ // FIRST vendor row while the vendor rows sat 2 apart — three visually
61
+ // identical meter rows, unevenly spaced. Both meters now live in
62
+ // `.settings-usage-meters`, which must carry the SAME gap as the meter it
63
+ // wraps: asserted as ONE grouped rule, so the two cannot drift to different
64
+ // rhythms. (settings-modal.template.test.ts pins the other half — that the
65
+ // wrapper is conditional, so a host with no meters pays no stray gap for it.)
66
+ const shared = /\.settings-usage-meters\s*,\s*\.settings-budget-usage\s*\{([^}]*)\}/.exec(
67
+ cssText,
68
+ );
69
+ assert.ok(shared, '.settings-usage-meters is grouped with .settings-budget-usage');
70
+ assert.ok(shared![1].includes('flex-direction: column'), 'the shared container is a column');
71
+ assert.ok(
72
+ shared![1].includes('gap: calc(var(--design-unit) * 2px)'),
73
+ 'gapped at the meter rhythm (2 units), not the section body 4',
74
+ );
75
+ });
76
+
77
+ suite("the meter shares the context indicator's row anatomy by grouped selector", () => {
78
+ // The invariant is the SHARING, not the shape: each budget class must sit in
79
+ // the same selector list as its context sibling, so restyling one restyles
80
+ // both and the two indicators cannot drift apart.
81
+ const sharedPairs: Array<[string, string]> = [
82
+ ['.settings-context-header', '.settings-budget-header'],
83
+ ['.settings-context-label', '.settings-budget-label'],
84
+ ['.settings-context-tokens', '.settings-budget-figures'],
85
+ ];
86
+ for (const [contextClass, budgetClass] of sharedPairs) {
87
+ const grouped = new RegExp(
88
+ `${contextClass.replace(/\./g, '\\.')}\\s*,\\s*${budgetClass.replace(/\./g, '\\.')}\\s*\\{`,
89
+ );
90
+ assert.ok(grouped.test(cssText), `${budgetClass} is grouped with ${contextClass}`);
91
+ }
92
+ });
93
+
94
+ suite.run();
@@ -275,7 +275,17 @@ export const settingsModalStyles = css`
275
275
  font-style: italic;
276
276
  }
277
277
 
278
- .settings-context-header {
278
+ /*
279
+ * The budget meter (GENC-1464) is the context indicator's money sibling and
280
+ * shares its row anatomy 1:1, so the budget classes JOIN these rules rather
281
+ * than restating them — one place to restyle the header/label/figures shape,
282
+ * and the two indicators cannot drift apart. Design-system tokens only, and
283
+ * unfallbacked: every token here ships a DS default, so a var(--x, y)
284
+ * fallback would be dead code that hides a broken token name (#2411/#2436
285
+ * review rule, pinned by settings-modal.styles.test.ts).
286
+ */
287
+ .settings-context-header,
288
+ .settings-budget-header {
279
289
  display: flex;
280
290
  justify-content: space-between;
281
291
  gap: calc(var(--design-unit) * 2px);
@@ -283,7 +293,8 @@ export const settingsModalStyles = css`
283
293
  font-size: 12px;
284
294
  }
285
295
 
286
- .settings-context-label {
296
+ .settings-context-label,
297
+ .settings-budget-label {
287
298
  color: var(--neutral-foreground-hint);
288
299
  font-size: 11px;
289
300
  font-weight: 600;
@@ -291,10 +302,29 @@ export const settingsModalStyles = css`
291
302
  letter-spacing: 0.06em;
292
303
  }
293
304
 
294
- .settings-context-tokens {
305
+ .settings-context-tokens,
306
+ .settings-budget-figures {
295
307
  color: var(--neutral-foreground-rest);
296
308
  }
297
309
 
310
+ /*
311
+ * ONE rhythm for every meter row. The outer wrapper holds the context
312
+ * indicator and the budget meter; the inner one holds the budget meter's
313
+ * vendor rows — and both gap by the same 2 units (half the section body's 4)
314
+ * so that visually identical meter rows are evenly spaced whatever mix of
315
+ * them is on screen. Without the wrapper the context row inherits the body's
316
+ * 4-unit gap while the vendor rows keep 2, and three identical-looking rows
317
+ * read as an uneven 4/2/2. The wrapper renders only when at least one meter
318
+ * does — see settingsUsageMetersTemplate for why an always-on one would cost
319
+ * a stray gap.
320
+ */
321
+ .settings-usage-meters,
322
+ .settings-budget-usage {
323
+ display: flex;
324
+ flex-direction: column;
325
+ gap: calc(var(--design-unit) * 2px);
326
+ }
327
+
298
328
  .settings-current-build,
299
329
  .settings-lifetime-usage {
300
330
  background: var(--neutral-layer-2);
@@ -0,0 +1,124 @@
1
+ import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
2
+ import { FoundationAiAssistant } from '../../main/main';
3
+ import { getSessionStore, clearAllSessionStores } from '../../state/session-store';
4
+ import { settingsUsageMetersTemplate } from './settings-modal.template';
5
+
6
+ // Hold a reference so the custom-element registration isn't tree-shaken.
7
+ FoundationAiAssistant;
8
+
9
+ // GENC-1464 — the shared meter column in the settings modal's "AI Model
10
+ // Settings" section.
11
+ //
12
+ // The section body is a 4-unit flex column and the budget meter is a 2-unit
13
+ // one, so an unwrapped context indicator sat 4 units above the first vendor row
14
+ // while the vendor rows sat 2 apart — three visually identical meter rows
15
+ // reading as an uneven 4/2/2. Both meters now share ONE 2-unit column
16
+ // (`.settings-usage-meters`, grouped with `.settings-budget-usage` in the
17
+ // styles — see settings-modal.styles.test.ts for the gap itself).
18
+ //
19
+ // The wrapper must also be ABSENT when neither meter shows: a permanent wrapper
20
+ // is a permanent flex child of the section body, so a host with no meters would
21
+ // pay a stray 4-unit gap after its model slot for a div that renders nothing.
22
+ // That is a structural claim about the template, so this suite renders the
23
+ // template rather than reading a getter — the getter cannot show whether the
24
+ // `when` was wired to it.
25
+ //
26
+ // The element is created but never CONNECTED (`document.createElement` upgrades
27
+ // only), for the reason budget-meter.test.ts gives: connecting subscribes to
28
+ // `agenticActivityBus` and leaves the runner's event loop open.
29
+
30
+ const Suite = createLogicSuite('settings-modal usage meters structure');
31
+
32
+ const template = settingsUsageMetersTemplate('test-progress');
33
+
34
+ let storeSeq = 0;
35
+
36
+ /** A fresh (unconnected) element wired to its own real session store. */
37
+ function element(): FoundationAiAssistant {
38
+ const el = document.createElement('foundation-ai-assistant') as FoundationAiAssistant;
39
+ storeSeq += 1;
40
+ (el as unknown as { _sessionRef: unknown })._sessionRef = getSessionStore(
41
+ `usage-meters-test-${storeSeq}`,
42
+ false,
43
+ );
44
+ return el;
45
+ }
46
+
47
+ /** Renders the meters for `el` into a detached host and returns it. */
48
+ function render(el: FoundationAiAssistant): HTMLElement {
49
+ const host = document.createElement('div');
50
+ template.render(el, host);
51
+ return host;
52
+ }
53
+
54
+ Suite.after(() => {
55
+ clearAllSessionStores();
56
+ });
57
+
58
+ Suite('both meters render inside one shared column', () => {
59
+ const el = element();
60
+ el.contextTokens = 1000;
61
+ el.contextLimit = 4000;
62
+ el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 45 });
63
+
64
+ const host = render(el);
65
+ const wrapper = host.querySelector('.settings-usage-meters');
66
+
67
+ assert.ok(wrapper, 'the shared column is rendered');
68
+ assert.ok(
69
+ wrapper!.querySelector('.settings-context-usage'),
70
+ 'the context indicator is inside it',
71
+ );
72
+ assert.ok(wrapper!.querySelector('.settings-budget-usage'), 'so is the budget meter');
73
+ // The rhythm the wrapper exists for: every meter row is a child of a 2-unit
74
+ // column, none of them of the section body's 4-unit one.
75
+ assert.is(
76
+ host.querySelectorAll('.settings-usage-meters').length,
77
+ 1,
78
+ 'one column, not one per meter',
79
+ );
80
+ });
81
+
82
+ Suite('neither meter visible → no wrapper at all, not an empty one', () => {
83
+ // The stray-gap case. Nothing fed, no context figures: the section body must
84
+ // see no flex child from the meters.
85
+ const el = element();
86
+
87
+ const host = render(el);
88
+
89
+ assert.is(el.settingsContextUsageVisible, false, 'precondition: no context data');
90
+ assert.is(el.settingsBudgetUsageVisible, false, 'precondition: no budget data');
91
+ assert.is(host.querySelector('.settings-usage-meters'), null, 'no wrapper element');
92
+ assert.is(
93
+ host.querySelectorAll('*').length,
94
+ 0,
95
+ 'and no element of any kind — comment markers only',
96
+ );
97
+ });
98
+
99
+ Suite('one meter alone still gets the shared column, with only its own child', () => {
100
+ // Both single-meter cases: the wrapper appears for either meter on its own,
101
+ // and the hidden sibling contributes NO element inside it (both gate with
102
+ // `when`, which emplaces comment markers, not empty divs — an empty div would
103
+ // be a second stray gap, inside the column this time).
104
+ const contextOnly = element();
105
+ contextOnly.contextTokens = 1000;
106
+ contextOnly.contextLimit = 4000;
107
+
108
+ const contextHost = render(contextOnly);
109
+ const contextWrapper = contextHost.querySelector('.settings-usage-meters');
110
+ assert.ok(contextWrapper, 'context alone still gets the column');
111
+ assert.is(contextWrapper!.children.length, 1, 'the hidden budget meter adds no flex child');
112
+ assert.ok(contextWrapper!.querySelector('.settings-context-usage'));
113
+
114
+ const budgetOnly = element();
115
+ budgetOnly.setVendorBudget('gemini', { budgetUsd: 50, spentUsd: 15 });
116
+
117
+ const budgetHost = render(budgetOnly);
118
+ const budgetWrapper = budgetHost.querySelector('.settings-usage-meters');
119
+ assert.ok(budgetWrapper, 'budget alone still gets the column');
120
+ assert.is(budgetWrapper!.children.length, 1, 'the hidden context indicator adds no flex child');
121
+ assert.ok(budgetWrapper!.querySelector('.settings-budget-usage'));
122
+ });
123
+
124
+ Suite.run();
@@ -1,17 +1,20 @@
1
1
  import type { AggregateUsage } from '@genesislcap/foundation-ai';
2
2
  import { html, ref, repeat, slotted, when, type ViewTemplate } from '@genesislcap/web-core';
3
3
  import type { FoundationAiAssistant } from '../../main/main';
4
- import { ANIMATION_DEFS, type AiAssistantAnimation } from '../../main/main.types';
4
+ import {
5
+ ANIMATION_DEFS,
6
+ type AiAssistantAnimation,
7
+ type SettingsBudgetRow,
8
+ } from '../../main/main.types';
5
9
  import type { AssistantAppSettingsToggle } from '../../provider/assistant-app-settings';
6
10
  import {
7
11
  formatCostSessionDate,
8
12
  type CostSessionModelEntry,
9
13
  type CostSessionRecord,
10
14
  } from '../../utils/cost-session-history';
15
+ import { formatUsd, formatUsdAmount } from '../../utils/format-usd';
11
16
  import { formatCompactTokenCountSafe } from '../../utils/sum-tokens';
12
17
 
13
- const SESSION_COST_DECIMALS = 2;
14
-
15
18
  type AnimationCategoryGroup = {
16
19
  category: string;
17
20
  items: Array<{ value: AiAssistantAnimation; label: string; description: string }>;
@@ -182,10 +185,10 @@ const platformContextUsageTemplate = (
182
185
  progressTag: string,
183
186
  ): ViewTemplate<FoundationAiAssistant> => html<FoundationAiAssistant>`
184
187
  ${when(
185
- (x) =>
186
- x.chatConfig.ui?.showContextUsage !== false &&
187
- x.contextTokens != null &&
188
- x.contextLimit != null,
188
+ // The getter, not a copy of its expression: `settingsUsageMetersTemplate`
189
+ // gates its shared wrapper on this same getter, and the two must agree —
190
+ // a drifted copy would leave the wrapper rendering around nothing.
191
+ (x) => x.settingsContextUsageVisible,
189
192
  html<FoundationAiAssistant>`
190
193
  <div class="settings-context-usage" part="context-usage">
191
194
  <div class="settings-context-header">
@@ -205,6 +208,74 @@ const platformContextUsageTemplate = (
205
208
  )}
206
209
  `;
207
210
 
211
+ /**
212
+ * Per-vendor AI budget meter (GENC-1464) — the money sibling of
213
+ * {@link platformContextUsageTemplate}, one row per vendor the host has fed
214
+ * figures for. The row text is unclamped (overshoot is real and shown); only
215
+ * the bar clamps — both computed in `settingsBudgetRows`, so the bindings here
216
+ * stay pure reads and the arithmetic is unit-testable.
217
+ */
218
+ const platformBudgetUsageTemplate = (
219
+ progressTag: string,
220
+ ): ViewTemplate<FoundationAiAssistant> => html<FoundationAiAssistant>`
221
+ ${when(
222
+ (x) => x.settingsBudgetUsageVisible,
223
+ html<FoundationAiAssistant>`
224
+ <div class="settings-budget-usage" part="budget-usage">
225
+ ${repeat(
226
+ (x) => x.settingsBudgetRows,
227
+ html<SettingsBudgetRow, FoundationAiAssistant>`
228
+ <div class="settings-budget-vendor">
229
+ <div class="settings-budget-header">
230
+ <span class="settings-budget-label">${(row) => row.label} budget</span>
231
+ <span class="settings-budget-figures">${(row) => row.figures}</span>
232
+ </div>
233
+ <${progressTag}
234
+ part="budget-progress"
235
+ value="${(row) => row.barValue}"
236
+ ></${progressTag}>
237
+ </div>
238
+ `,
239
+ )}
240
+ </div>
241
+ `,
242
+ )}
243
+ `;
244
+
245
+ /**
246
+ * The two usage meters in one shared, evenly-gapped column.
247
+ *
248
+ * Without it the context row and the FIRST budget row sit the section body's
249
+ * 4 units apart while the budget rows sit 2 apart — three visually identical
250
+ * meter rows reading as an uneven 4/2/2. The wrapper puts every meter row on
251
+ * the same 2-unit rhythm; see `.settings-usage-meters` in the styles.
252
+ *
253
+ * CONDITIONAL on purpose. An always-present wrapper is a permanent flex child
254
+ * of `.settings-modal-section-body`, so a host with neither meter would get a
255
+ * stray 4-unit gap after its model slot from a div that renders nothing. The
256
+ * gate is exactly the disjunction of the two children's own gates — both of
257
+ * which are `when`, which emplaces only comment markers when false, so a
258
+ * hidden meter contributes no flex child inside the wrapper either.
259
+ *
260
+ * Exported for `settings-modal.template.test.ts`, which renders it in isolation
261
+ * to pin both of those properties; `index.ts` does not re-export it, so it is
262
+ * not package API.
263
+ *
264
+ * @internal
265
+ */
266
+ export const settingsUsageMetersTemplate = (
267
+ progressTag: string,
268
+ ): ViewTemplate<FoundationAiAssistant> => html<FoundationAiAssistant>`
269
+ ${when(
270
+ (x) => x.settingsContextUsageVisible || x.settingsBudgetUsageVisible,
271
+ html<FoundationAiAssistant>`
272
+ <div class="settings-usage-meters">
273
+ ${platformContextUsageTemplate(progressTag)} ${platformBudgetUsageTemplate(progressTag)}
274
+ </div>
275
+ `,
276
+ )}
277
+ `;
278
+
208
279
  const chatBotSettingsTemplate = (
209
280
  switchTag: string,
210
281
  buttonTag: string,
@@ -484,9 +555,7 @@ const lifetimeUsageTemplate =
484
555
  <div class="settings-build-stat-label">Total build cost</div>
485
556
  <div class="settings-build-stat-value">
486
557
  <span class="prefix">$</span>
487
- <span class="value">
488
- ${(x) => x.cumulativeCostUsd.toFixed(SESSION_COST_DECIMALS)}
489
- </span>
558
+ <span class="value">${(x) => formatUsdAmount(x.cumulativeCostUsd)}</span>
490
559
  </div>
491
560
  </div>
492
561
  <div
@@ -536,7 +605,7 @@ const sessionCostSummaryTemplate =
536
605
  </div>
537
606
  <div class="settings-build-stat-value">
538
607
  <span class="prefix">$</span>
539
- <span class="value">${(x) => x.sessionCostUsd.toFixed(SESSION_COST_DECIMALS)}</span>
608
+ <span class="value">${(x) => formatUsdAmount(x.sessionCostUsd)}</span>
540
609
  </div>
541
610
  </div>
542
611
  <div
@@ -590,7 +659,7 @@ const costSessionHistoryTemplate = (
590
659
  <div class="settings-cost-history-row-right">
591
660
  <div class="settings-cost-history-row-metrics">
592
661
  <div class="settings-cost-history-row-cost">
593
- $${(record) => record.usage.costUsd.toFixed(SESSION_COST_DECIMALS)}
662
+ ${(record) => formatUsd(record.usage.costUsd)}
594
663
  </div>
595
664
  </div>
596
665
  <${buttonTag}
@@ -666,7 +735,7 @@ export const SettingsModalTemplate = (
666
735
  </h3>
667
736
  <div class="settings-modal-section-body">
668
737
  <slot name="settings-model" ${slotted('settingsModelSlotted')}></slot>
669
- ${platformContextUsageTemplate(progressTag)}
738
+ ${settingsUsageMetersTemplate(progressTag)}
670
739
  </div>
671
740
  </section>
672
741
  <!--