diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 4fd637b4..dbc36e73 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -561,6 +561,7 @@ pub struct SettingsSnapshot { float_bar_style: String, taskbar_widget_open_on_hover: bool, float_bar_density: String, + float_bar_information_mode: String, float_bar_contrast: String, float_bar_click_through: bool, float_bar_provider_ids: Vec, @@ -661,6 +662,7 @@ impl From for SettingsSnapshot { float_bar_style: settings.float_bar_style, taskbar_widget_open_on_hover: settings.taskbar_widget_open_on_hover, float_bar_density: settings.float_bar_density, + float_bar_information_mode: settings.float_bar_information_mode, float_bar_contrast, float_bar_click_through: settings.float_bar_click_through, float_bar_provider_ids: settings.float_bar_provider_ids, diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index eea204fe..0dc68718 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -58,6 +58,7 @@ pub struct SettingsUpdate { pub float_bar_style: Option, pub taskbar_widget_open_on_hover: Option, pub float_bar_density: Option, + pub float_bar_information_mode: Option, pub float_bar_contrast: Option, pub float_bar_click_through: Option, pub float_bar_provider_ids: Option>, @@ -283,6 +284,7 @@ impl SettingsUpdate { style: self.float_bar_style.clone(), open_on_hover: self.taskbar_widget_open_on_hover, density: self.float_bar_density.clone(), + information_mode: self.float_bar_information_mode.clone(), contrast: self.float_bar_contrast.clone(), click_through: self.float_bar_click_through, provider_ids: self.float_bar_provider_ids.clone(), diff --git a/apps/desktop-tauri/src-tauri/src/floatbar/mod.rs b/apps/desktop-tauri/src-tauri/src/floatbar/mod.rs index c1ba6ab1..68bd957a 100644 --- a/apps/desktop-tauri/src-tauri/src/floatbar/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/floatbar/mod.rs @@ -107,6 +107,7 @@ pub struct SettingsPatch { pub style: Option, pub open_on_hover: Option, pub density: Option, + pub information_mode: Option, pub contrast: Option, pub click_through: Option, pub provider_ids: Option>, @@ -126,6 +127,7 @@ impl SettingsPatch { && self.style.is_none() && self.open_on_hover.is_none() && self.density.is_none() + && self.information_mode.is_none() && self.contrast.is_none() && self.click_through.is_none() && self.provider_ids.is_none() @@ -164,6 +166,10 @@ impl SettingsPatch { if let Some(v) = &self.density { settings.float_bar_density = codexbar::settings::normalize_float_bar_density(v); } + if let Some(v) = &self.information_mode { + settings.float_bar_information_mode = + codexbar::settings::normalize_float_bar_information_mode(v); + } if let Some(v) = &self.contrast { settings.float_bar_contrast = Some(codexbar::settings::normalize_float_bar_contrast(v)); } @@ -218,6 +224,22 @@ mod tests { assert!(SettingsPatch::default().is_empty()); } + #[test] + fn information_mode_only_change_is_a_non_empty_patch() { + // A mode-only change must count as a float-bar patch so the detached + // bar is told to re-fetch (after_settings_saved notifies when the patch + // is non-empty), and so it applies the new mode on disk. + let patch = SettingsPatch { + information_mode: Some("calm".into()), + ..SettingsPatch::default() + }; + assert!(!patch.is_empty()); + + let mut settings = Settings::default(); + patch.apply(&mut settings); + assert_eq!(settings.float_bar_information_mode, "calm"); + } + #[test] fn settings_patch_apply_only_writes_present_fields() { let mut s = Settings { diff --git a/apps/desktop-tauri/src/App.test.tsx b/apps/desktop-tauri/src/App.test.tsx index 0d433617..d191d671 100644 --- a/apps/desktop-tauri/src/App.test.tsx +++ b/apps/desktop-tauri/src/App.test.tsx @@ -112,6 +112,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { floatBarStyle: "floating", taskbarWidgetOpenOnHover: true, floatBarDensity: "standard", + floatBarInformationMode: "exact", floatBarContrast: "auto", floatBarClickThrough: false, floatBarProviderIds: [], diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.css b/apps/desktop-tauri/src/floatbar/FloatBar.css index 08371526..2b015d0b 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.css +++ b/apps/desktop-tauri/src/floatbar/FloatBar.css @@ -287,6 +287,51 @@ body.floatbar-window #root { .floatbar__reset--emphasis .floatbar__reset-time { font-weight: 650; } +/* ── Calm information mode ───────────────────────────────────────────── + The pill leads with a trustworthy pace state + next reset and becomes an + expandable button. No new motion, so reduced-motion is unaffected. */ +.floatbar__pill--calm { + cursor: pointer; +} +.floatbar__pill--calm:focus-visible { + outline: 2px solid color-mix(in srgb, var(--ceiling-accent, #26b5ce) 70%, white); + outline-offset: 1px; +} +.floatbar__pace { + font-size: 0.86em; + font-weight: 650; + line-height: 1; + white-space: nowrap; +} +.floatbar__pace--steady { + color: color-mix(in srgb, #34d399 62%, white); +} +.floatbar__pace--watch { + color: color-mix(in srgb, #f59e0b 68%, white); +} +.floatbar__calm-reset { + display: inline-flex; + align-items: center; + gap: calc(3px * var(--floatbar-scale, 1)); + opacity: 0.66; + line-height: 1; +} +.floatbar__calm-sep { + opacity: 0.5; +} +.floatbar__pct--calm { + padding-left: calc(6px * var(--floatbar-scale, 1)); + margin-left: calc(2px * var(--floatbar-scale, 1)); + border-left: 1px solid color-mix(in srgb, currentColor 18%, transparent); + font-size: 0.98em; +} +.floatbar--light-bg .floatbar__pace--steady { + color: color-mix(in srgb, #059669 78%, black); +} +.floatbar--light-bg .floatbar__pace--watch { + color: color-mix(in srgb, #b45309 82%, black); +} + .floatbar--vertical .floatbar__text { flex-direction: column; align-items: center; diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx index 97f93921..f6fe501f 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx @@ -137,6 +137,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { floatBarStyle: "floating", taskbarWidgetOpenOnHover: true, floatBarDensity: "standard", + floatBarInformationMode: "exact", floatBarContrast: "light-text", floatBarClickThrough: false, floatBarProviderIds: [], @@ -221,6 +222,73 @@ describe("FloatBar", () => { expect(titles[1]).toMatch(/Claude: 20% used/); }); + it("calm mode leads with a pace state and expands to the exact % on click", async () => { + const calmProvider: ProviderUsageSnapshot = { + ...snapshot("codex", "Codex", 60, { + resetsAt: new Date(Date.now() + 2 * 3600_000).toISOString(), + }), + updatedAt: new Date().toISOString(), + pace: { + windowLabel: "Weekly", + stage: "on_track", + deltaPercent: 0, + willLastToReset: true, + etaSeconds: null, + expectedUsedPercent: 60, + actualUsedPercent: 60, + }, + }; + tauriMocks.getCachedProviders.mockResolvedValue([calmProvider]); + tauriMocks.getSettingsSnapshot.mockResolvedValue( + settings({ floatBarInformationMode: "calm" }), + ); + + const { container } = renderFloatBar( + bootstrap({ floatBarInformationMode: "calm" }), + ); + await waitFor(() => { + expect(container.querySelector(".floatbar__pill--calm")).toBeInTheDocument(); + }); + + // Calm leads with the trustworthy pace state; the exact % is hidden until + // the user expands, and the pill is a keyboard-accessible button. + expect(container.querySelector(".floatbar__pace")).toHaveTextContent("On pace"); + expect(container.querySelector(".floatbar__pct--calm")).toBeNull(); + const pill = container.querySelector(".floatbar__pill--calm")!; + expect(pill.getAttribute("role")).toBe("button"); + expect(pill.getAttribute("tabindex")).toBe("0"); + + act(() => { + pill.click(); + }); + expect(container.querySelector(".floatbar__pct--calm")).toHaveTextContent("60%"); + }); + + it("calm + compact stays non-blank and drops the window·reset row", async () => { + // No fresh pace + compact density (which hides the window/reset): the pill + // must still show the exact % rather than collapse to just the icon, and + // the calm reset row (with its separator) must not render. + tauriMocks.getCachedProviders.mockResolvedValue([ + snapshot("codex", "Codex", 60, { + resetsAt: new Date(Date.now() + 3600_000).toISOString(), + }), + ]); + tauriMocks.getSettingsSnapshot.mockResolvedValue( + settings({ floatBarInformationMode: "calm", floatBarDensity: "compact" }), + ); + + const { container } = renderFloatBar( + bootstrap({ floatBarInformationMode: "calm", floatBarDensity: "compact" }), + ); + await waitFor(() => { + expect(container.querySelector(".floatbar__pill--calm")).toBeInTheDocument(); + }); + + expect(container.querySelector(".floatbar__calm-reset")).toBeNull(); + expect(container.querySelector(".floatbar__calm-sep")).toBeNull(); + expect(container.querySelector(".floatbar__pct--calm")).toHaveTextContent("60%"); + }); + it("does not render hypothetical local costs from the legacy setting", async () => { tauriMocks.getCachedProviders.mockResolvedValue([ snapshot("codex", "Codex", 75), diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.tsx index bcd04bbf..04071d9b 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.tsx @@ -5,6 +5,7 @@ import { useRef, useState, type CSSProperties, + type KeyboardEvent as ReactKeyboardEvent, type MouseEvent, } from "react"; import { listen } from "@tauri-apps/api/event"; @@ -36,9 +37,11 @@ import { capacityFreshness, constrainingWindow, activePromoBoosts, + calmPresentation, type CapacityFreshness, type ConstrainingWindow, } from "../lib/capacityPresentation"; +import type { FloatBarInformationMode } from "../types/bridge"; import "./FloatBar.css"; function ResetIcon({ size }: { size: number }) { @@ -117,6 +120,10 @@ function ProviderPill({ usedSuffix, remainingSuffix, capacityEventKind, + informationMode, + isCompact, + expanded, + onToggleExpand, }: { provider: ProviderUsageSnapshot; highRemaining: number; @@ -128,6 +135,10 @@ function ProviderPill({ usedSuffix: string; remainingSuffix: string; capacityEventKind?: CapacityEventPayload["kind"]; + informationMode: FloatBarInformationMode; + isCompact: boolean; + expanded: boolean; + onToggleExpand: () => void; }) { // Keep the number, label, and tone tied to the same displayed window. const hero = floatBarWindow(provider); @@ -171,17 +182,93 @@ function ProviderPill({ .filter(Boolean) .join("\n"); + const pillClass = [ + "floatbar__pill", + `floatbar__pill--${tone}`, + freshness !== "live" ? `floatbar__pill--${freshness}` : null, + capacityEventKind ? "floatbar__pill--capacity-event" : null, + capacityEventKind ? `floatbar__pill--${capacityEventKind}` : null, + ] + .filter(Boolean) + .join(" "); + + // Calm mode: lead with a trustworthy pace state and the next reset; exact % + // stays one keyboard/click away. The pill becomes an expandable button, so it + // opts out of the native drag region (drag the bar by its handle instead). + if (informationMode === "calm") { + const calm = calmPresentation(provider, hero); + // Compact hides the window·reset row, so calm degrades to pace-or-exact. + // Otherwise show exact only when there is no pace and no reset time to show. + // Either way the pill is never blank. + const showResetRow = !isCompact && calm.hasReset; + const showExact = + expanded || (isCompact ? !calm.pace : !calm.pace && !inlineReset); + const onKeyToggle = (event: ReactKeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onToggleExpand(); + } + }; + // Announce what the pill actually shows, not the exact % it hides — screen + // readers should hear the pace/reset state, and the % only once revealed. + const calmLabel = [ + `${provider.displayName}:`, + calm.pace?.label ?? null, + showResetRow + ? `${hero.label}${resetText ? ` ${resetText}` : ""}` + : null, + showExact ? `${label} ${displaySuffix}` : null, + stateChip ? `state ${stateChip}` : null, + ] + .filter(Boolean) + .join(" "); + return ( +
event.stopPropagation()} + style={{ "--brand": brand } as CSSProperties} + > + + + + + {calm.pace ? ( + + {calm.pace.label} + + ) : null} + {showResetRow ? ( + + {hero.label} + {inlineReset ? ( + <> + + · + + + {inlineReset} + + ) : null} + + ) : null} + {showExact ? ( + {label} + ) : null} + +
+ ); + } + return (
>(new Set()); + const toggleExpanded = useCallback((key: string) => { + setExpandedKeys((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }, []); const startDrag = useCallback((event: MouseEvent) => { if (event.button !== 0 || lockedRef.current || menuOpenRef.current) return; @@ -405,6 +502,8 @@ export default function FloatBar({ state }: { state: BootstrapState }) { ? settings.floatBarDensity : "standard"; const contrast = settings.floatBarContrast ?? "light-text"; + const informationMode: FloatBarInformationMode = + settings.floatBarInformationMode === "calm" ? "calm" : "exact"; const darkText = contrast === "dark-text" || (contrast === "auto" && systemPrefersLight); const filterIds = settings.floatBarProviderIds; @@ -459,6 +558,8 @@ export default function FloatBar({ state }: { state: BootstrapState }) { scale, showResetInline, settings.resetTimeRelative, + informationMode, + expandedKeys, menuOpen, ]); @@ -503,7 +604,7 @@ export default function FloatBar({ state }: { state: BootstrapState }) { return (
toggleExpanded(providerKey(p))} /> ))}
diff --git a/apps/desktop-tauri/src/floatbar/SettingsSection.test.tsx b/apps/desktop-tauri/src/floatbar/SettingsSection.test.tsx index 2aa23927..a67aa3c9 100644 --- a/apps/desktop-tauri/src/floatbar/SettingsSection.test.tsx +++ b/apps/desktop-tauri/src/floatbar/SettingsSection.test.tsx @@ -17,6 +17,7 @@ const settings = { floatBarStyle: "floating", taskbarWidgetOpenOnHover: true, floatBarDensity: "standard", + floatBarInformationMode: "exact", floatBarContrast: "auto", floatBarShowCost: false, floatBarShowResetInline: false, diff --git a/apps/desktop-tauri/src/floatbar/SettingsSection.tsx b/apps/desktop-tauri/src/floatbar/SettingsSection.tsx index fd5695aa..20a298f1 100644 --- a/apps/desktop-tauri/src/floatbar/SettingsSection.tsx +++ b/apps/desktop-tauri/src/floatbar/SettingsSection.tsx @@ -4,6 +4,7 @@ import type { FloatBarOrientation, FloatBarContrast, FloatBarDensity, + FloatBarInformationMode, SettingsSnapshot, SettingsUpdate, } from "../types/bridge"; @@ -150,6 +151,22 @@ export default function FloatBarSettingsSection({ settings, saving, set }: Props onChange={(v) => set({ floatBarDensity: v as FloatBarDensity })} /> + +