diff --git a/desktop/app_test.go b/desktop/app_test.go index 67f02c3fa..a5a95bd7a 100644 --- a/desktop/app_test.go +++ b/desktop/app_test.go @@ -228,6 +228,8 @@ language = "zh" theme = "light" theme_style = "glacier" close_behavior = "quit" +status_bar_style = "icon" +status_bar_items = ["cost", "balance"] `), 0o644); err != nil { t.Fatalf("write project config: %v", err) } @@ -242,6 +244,12 @@ close_behavior = "quit" if err := userCfg.SetDesktopCloseBehavior("background"); err != nil { t.Fatalf("set desktop close behavior: %v", err) } + if err := userCfg.SetDesktopStatusBarStyle("text"); err != nil { + t.Fatalf("set desktop status bar style: %v", err) + } + if err := userCfg.SetDesktopStatusBarItems([]string{"model", "balance", "cache"}); err != nil { + t.Fatalf("set desktop status bar items: %v", err) + } if err := userCfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save user config: %v", err) } @@ -253,8 +261,11 @@ close_behavior = "quit" } got := NewApp().Settings() - if got.DesktopLanguage != "en" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.CloseBehavior != "background" { - t.Fatalf("desktop settings = lang:%q theme:%q style:%q close:%q, want user-level desktop prefs", got.DesktopLanguage, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior) + if got.DesktopLanguage != "en" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.CloseBehavior != "background" || got.StatusBarStyle != "text" { + t.Fatalf("desktop settings = lang:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs", got.DesktopLanguage, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle) + } + if want := []string{"model", "balance", "cache"}; !reflect.DeepEqual(got.StatusBarItems, want) { + t.Fatalf("desktop status bar items = %v, want user-level %v", got.StatusBarItems, want) } } @@ -270,6 +281,8 @@ language = "zh" theme = "light" theme_style = "glacier" close_behavior = "quit" +status_bar_style = "text" +status_bar_items = ["model", "cache", "balance"] `), 0o644); err != nil { t.Fatalf("write project config: %v", err) } @@ -285,9 +298,12 @@ close_behavior = "quit" if got.ConfigPath != config.UserConfigPath() { t.Fatalf("Settings configPath = %q, want user config %q", got.ConfigPath, config.UserConfigPath()) } - if got.DefaultModel != "legacy-provider/legacy-model" || got.DesktopLanguage != "zh" || got.DesktopTheme != "light" || got.DesktopThemeStyle != "glacier" || got.CloseBehavior != "quit" { + if got.DefaultModel != "legacy-provider/legacy-model" || got.DesktopLanguage != "zh" || got.DesktopTheme != "light" || got.DesktopThemeStyle != "glacier" || got.CloseBehavior != "quit" || got.StatusBarStyle != "text" { t.Fatalf("Settings did not seed from legacy project config: %+v", got) } + if want := []string{"model", "cache", "balance"}; !reflect.DeepEqual(got.StatusBarItems, want) { + t.Fatalf("Settings did not seed status bar items from legacy project config: got %v want %v", got.StatusBarItems, want) + } if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) { t.Fatalf("Settings() should not write user config before an edit, stat err = %v", err) } @@ -295,8 +311,11 @@ close_behavior = "quit" t.Fatalf("SetDesktopLanguage: %v", err) } userCfg := config.LoadForEdit(config.UserConfigPath()) - if userCfg.DesktopLanguage() != "en" || userCfg.DesktopTheme() != "light" || userCfg.DesktopThemeStyle() != "glacier" || userCfg.DesktopCloseBehavior() != "quit" { - t.Fatalf("saved user config did not preserve seeded desktop prefs: lang:%q theme:%q style:%q close:%q", userCfg.DesktopLanguage(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior()) + if userCfg.DesktopLanguage() != "en" || userCfg.DesktopTheme() != "light" || userCfg.DesktopThemeStyle() != "glacier" || userCfg.DesktopCloseBehavior() != "quit" || userCfg.DesktopStatusBarStyle() != "text" { + t.Fatalf("saved user config did not preserve seeded desktop prefs: lang:%q theme:%q style:%q close:%q status:%q", userCfg.DesktopLanguage(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle()) + } + if want := []string{"model", "cache", "balance"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) { + t.Fatalf("saved user config did not preserve seeded status bar items: got %v want %v", userCfg.DesktopStatusBarItems(), want) } } diff --git a/desktop/frontend/src/App.tsx b/desktop/frontend/src/App.tsx index f24fb8154..5897b2c44 100644 --- a/desktop/frontend/src/App.tsx +++ b/desktop/frontend/src/App.tsx @@ -79,6 +79,7 @@ import { } from "./lib/toolApprovalMode"; import { loadLayoutSize, saveLayoutSize } from "./lib/layoutPreferences"; import { hydrateDisplayMode } from "./lib/displayMode"; +import { DEFAULT_STATUS_BAR_ITEMS, normalizeStatusBarItems, type StatusBarItemId } from "./lib/statusBarItems"; import { blobToBase64, renderSessionImageBlob, renderSessionPdfBlob } from "./lib/sessionExport"; import { sessionActivityTime } from "./lib/session"; import { @@ -727,6 +728,8 @@ export default function App() { const [transientOverlayDismissSignal, setTransientOverlayDismissSignal] = useState(0); const [desktopPlatform, setDesktopPlatform] = useState(detectBrowserPlatform); const [expandThinking, setExpandThinking] = useState(false); + const [statusBarStyle, setStatusBarStyle] = useState<"icon" | "text">("text"); + const [statusBarItems, setStatusBarItems] = useState(() => [...DEFAULT_STATUS_BAR_ITEMS]); const [renamingTopicId, setRenamingTopicId] = useState(null); const [topicTitleDraft, setTopicTitleDraft] = useState(""); const [topicExportOpen, setTopicExportOpen] = useState(false); @@ -845,12 +848,14 @@ export default function App() { }, []); const applyDesktopPreferences = useCallback( - (settings: Pick) => { + (settings: Pick) => { const nextTheme = normalizeThemePreference(settings.desktopTheme); const nextStyle = normalizeThemeStyleForTheme(settings.desktopThemeStyle, nextTheme); applyTheme(nextTheme, nextStyle, { persist: false }); setLocalePref(normalizeLangPref(settings.desktopLanguage)); setStartupUpdateChecksEnabled(settings.checkUpdates !== false); + setStatusBarStyle(settings.statusBarStyle === "text" ? "text" : "icon"); + setStatusBarItems(normalizeStatusBarItems(settings.statusBarItems)); }, [setLocalePref], ); @@ -2638,9 +2643,12 @@ export default function App() { sessionTurns={sessionTurns} sessionTokens={state.sessionTokens} turnTokens={state.turnTotalTokens} + turnCost={state.turnCost} cost={state.sessionCost} currency={state.sessionCurrency} modelLabel={state.meta?.label} + labelStyle={statusBarStyle} + items={statusBarItems} /> )} diff --git a/desktop/frontend/src/components/SettingsPanel.tsx b/desktop/frontend/src/components/SettingsPanel.tsx index 432f5e119..519ff9b3b 100644 --- a/desktop/frontend/src/components/SettingsPanel.tsx +++ b/desktop/frontend/src/components/SettingsPanel.tsx @@ -1,6 +1,6 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useId, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type PointerEvent, type ReactNode } from "react"; import { QRCodeSVG } from "qrcode.react"; -import { Check, CheckCircle2, ChevronDown, Loader2, QrCode, RefreshCw } from "lucide-react"; +import { Check, CheckCircle2, ChevronDown, ChevronUp, GripVertical, Loader2, QrCode, RefreshCw } from "lucide-react"; import { asArray } from "../lib/array"; import { useDeferredClose } from "../lib/useMountTransition"; import { app } from "../lib/bridge"; @@ -20,6 +20,7 @@ import { import { TEXT_SIZES, applyTextSize, getTextSize, type TextSize } from "../lib/textSize"; import { FONT_FAMILIES, applyFontFamily, getFontFamily, type FontFamily } from "../lib/fontFamily"; import { getDisplayMode, onDisplayModeChange, setDisplayMode as setLocalDisplayMode } from "../lib/displayMode"; +import { DEFAULT_STATUS_BAR_ITEMS, normalizeStatusBarItems, type StatusBarItemId } from "../lib/statusBarItems"; import type { BotConnectionView, BotInstallStartResult, BotSettingsView, HookConfigView, HooksSettingsView, NetworkView, ProviderView, SettingsTab, SettingsView } from "../lib/types"; import { InlineConfirmButton } from "./InlineConfirmButton"; import { Tooltip } from "./Tooltip"; @@ -588,6 +589,8 @@ function normalizeSettingsView(view: SettingsView | null | undefined): SettingsV desktopThemeStyle: normalizeThemeStyleForTheme(view.desktopThemeStyle, normalizeThemePreference(view.desktopTheme)), closeBehavior: normalizeCloseBehavior(view.closeBehavior), displayMode: normalizeDisplayMode(view.displayMode), + statusBarStyle: normalizeStatusBarStyle(view.statusBarStyle), + statusBarItems: normalizeStatusBarItems(view.statusBarItems), checkUpdates: view.checkUpdates !== false, }; } @@ -604,6 +607,44 @@ function normalizeDisplayMode(mode: string | undefined): DisplayMode { return mode === "standard" || mode === "compact" || mode === "minimal" ? mode : "minimal"; } +type StatusBarStyle = "icon" | "text"; +type StatusBarDropPlacement = "before" | "after"; +type StatusBarDragTarget = { + id: StatusBarItemId; + placement: StatusBarDropPlacement; +}; + +function normalizeStatusBarStyle(style: string | undefined): StatusBarStyle { + return style === "icon" ? "icon" : "text"; +} + +function statusBarItemLabel(id: StatusBarItemId, t: ReturnType): string { + switch (id) { + case "model": + return t("settings.statusBarItem.model"); + case "cache": + return t("status.cacheLabel"); + case "cache_avg": + return t("status.cacheAvgLabel"); + case "session_tokens": + return t("status.sessionTokensLabel"); + case "turn_tokens": + return t("status.turnTokensLabel"); + case "turn_cost": + return t("status.turnCostLabel"); + case "session_turns": + return t("status.sessionTurnsLabel"); + case "context": + return t("status.ctxLabel"); + case "compact": + return t("status.compactLabel"); + case "cost": + return t("status.costLabel"); + case "balance": + return t("status.balanceLabel"); + } +} + function closeBehaviorLabel(mode: CloseBehavior, t: ReturnType): string { return mode === "quit" ? t("settings.closeBehavior.quit") : t("settings.closeBehavior.background"); } @@ -640,9 +681,162 @@ function GeneralSection({ s, busy, apply }: SectionProps) { const { t, setPref } = useI18n(); const closeBehavior = normalizeCloseBehavior(s.closeBehavior); const [displayMode, setDisplayMode] = useState(() => normalizeDisplayMode(getDisplayMode())); + const [statusBarItemsExpanded, setStatusBarItemsExpanded] = useState(false); + const [draggingStatusBarItem, setDraggingStatusBarItem] = useState(null); + const [statusBarDragTarget, setStatusBarDragTargetState] = useState(null); + const draggingStatusBarItemRef = useRef(null); + const statusBarDragTargetRef = useRef(null); + const mouseDragCleanupRef = useRef<(() => void) | null>(null); + const statusBarItemsPanelId = useId(); useEffect(() => onDisplayModeChange((mode) => setDisplayMode(mode)), []); + useEffect(() => () => mouseDragCleanupRef.current?.(), []); const autoPlan = normalizeAutoPlan(s.autoPlan); const languagePref = normalizeLangPref(s.desktopLanguage); + const statusBarStyle = normalizeStatusBarStyle(s.statusBarStyle); + const statusBarItems = normalizeStatusBarItems(s.statusBarItems); + const visibleStatusItems = new Set(statusBarItems); + const orderedStatusItems = [ + ...statusBarItems, + ...DEFAULT_STATUS_BAR_ITEMS.filter((id) => !visibleStatusItems.has(id)), + ]; + const applyStatusBarItems = (items: StatusBarItemId[]) => { + const contentScrollTop = document.querySelector(".settings-center__content")?.scrollTop ?? 0; + const navScrollTop = document.querySelector(".settings-center__nav")?.scrollTop ?? 0; + const active = document.activeElement; + if (active instanceof HTMLElement && active.closest(".status-bar-items-editor")) active.blur(); + void apply(() => app.SetStatusBarItems(items)).finally(() => { + window.scrollTo(0, 0); + requestAnimationFrame(() => { + window.scrollTo(0, 0); + const content = document.querySelector(".settings-center__content"); + const nav = document.querySelector(".settings-center__nav"); + if (content) content.scrollTop = Math.min(contentScrollTop, Math.max(0, content.scrollHeight - content.clientHeight)); + if (nav) nav.scrollTop = navScrollTop; + }); + }); + }; + const toggleStatusBarItem = (id: StatusBarItemId) => { + if (visibleStatusItems.has(id)) { + if (statusBarItems.length <= 1) return; + applyStatusBarItems(statusBarItems.filter((item) => item !== id)); + return; + } + applyStatusBarItems([...statusBarItems, id]); + }; + const moveStatusBarItem = (id: StatusBarItemId, direction: -1 | 1) => { + const idx = statusBarItems.indexOf(id); + const nextIdx = idx + direction; + if (idx < 0 || nextIdx < 0 || nextIdx >= statusBarItems.length) return; + const next = [...statusBarItems]; + [next[idx], next[nextIdx]] = [next[nextIdx], next[idx]]; + applyStatusBarItems(next); + }; + const reorderStatusBarItem = (fromId: StatusBarItemId, toId: StatusBarItemId, placement: StatusBarDropPlacement) => { + const fromIdx = statusBarItems.indexOf(fromId); + const toIdx = statusBarItems.indexOf(toId); + if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) return; + const next = statusBarItems.filter((item) => item !== fromId); + const insertAt = next.indexOf(toId); + if (insertAt < 0) return; + next.splice(placement === "after" ? insertAt + 1 : insertAt, 0, fromId); + if (next.every((item, index) => item === statusBarItems[index])) return; + applyStatusBarItems(next); + }; + const statusBarItemFromPoint = (x: number, y: number): StatusBarDragTarget | null => { + const row = document.elementFromPoint(x, y)?.closest("[data-statusbar-setting-item]"); + const id = row?.dataset.statusbarSettingItem as StatusBarItemId | undefined; + if (!row || !id || !statusBarItems.includes(id)) return null; + const rect = row.getBoundingClientRect(); + return { id, placement: y < rect.top + rect.height / 2 ? "before" : "after" }; + }; + const setStatusBarDragTarget = (target: StatusBarDragTarget | null) => { + const current = statusBarDragTargetRef.current; + if (current?.id === target?.id && current?.placement === target?.placement) return; + statusBarDragTargetRef.current = target; + setStatusBarDragTargetState(target); + }; + const beginStatusBarDrag = (id: StatusBarItemId, visible: boolean): boolean => { + if (busy || !visible) return false; + mouseDragCleanupRef.current?.(); + mouseDragCleanupRef.current = null; + draggingStatusBarItemRef.current = id; + statusBarDragTargetRef.current = null; + setDraggingStatusBarItem(id); + setStatusBarDragTargetState(null); + return true; + }; + const updateStatusBarDrag = (clientX: number, clientY: number) => { + const draggingId = draggingStatusBarItemRef.current; + if (!draggingId) return; + const target = statusBarItemFromPoint(clientX, clientY); + setStatusBarDragTarget(target && target.id !== draggingId ? target : null); + }; + const finishStatusBarDrag = (clientX?: number, clientY?: number) => { + const draggingId = draggingStatusBarItemRef.current; + let target = statusBarDragTargetRef.current; + if (draggingId && clientX !== undefined && clientY !== undefined) { + const pointerTarget = statusBarItemFromPoint(clientX, clientY); + if (pointerTarget && pointerTarget.id !== draggingId) target = pointerTarget; + } + if (draggingId && target) reorderStatusBarItem(draggingId, target.id, target.placement); + draggingStatusBarItemRef.current = null; + statusBarDragTargetRef.current = null; + setDraggingStatusBarItem(null); + setStatusBarDragTargetState(null); + }; + const cancelStatusBarDrag = () => { + mouseDragCleanupRef.current?.(); + mouseDragCleanupRef.current = null; + draggingStatusBarItemRef.current = null; + statusBarDragTargetRef.current = null; + setDraggingStatusBarItem(null); + setStatusBarDragTargetState(null); + }; + const startStatusBarPointerDrag = (event: PointerEvent, id: StatusBarItemId, visible: boolean) => { + if (event.button !== 0 || !beginStatusBarDrag(id, visible)) return; + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + }; + const moveStatusBarPointerDrag = (event: PointerEvent) => { + if (!draggingStatusBarItemRef.current) return; + event.preventDefault(); + updateStatusBarDrag(event.clientX, event.clientY); + }; + const endStatusBarPointerDrag = (event: PointerEvent) => { + if (!draggingStatusBarItemRef.current) return; + event.preventDefault(); + try { + event.currentTarget.releasePointerCapture(event.pointerId); + } catch { + // Pointer capture may already be released by the browser. + } + finishStatusBarDrag(event.clientX, event.clientY); + }; + const cancelStatusBarPointerDrag = (event: PointerEvent) => { + event.preventDefault(); + cancelStatusBarDrag(); + }; + const startStatusBarMouseDrag = (event: ReactMouseEvent, id: StatusBarItemId, visible: boolean) => { + if (event.button !== 0 || !beginStatusBarDrag(id, visible)) return; + event.preventDefault(); + const handleMove = (moveEvent: MouseEvent) => { + moveEvent.preventDefault(); + updateStatusBarDrag(moveEvent.clientX, moveEvent.clientY); + }; + const cleanup = () => { + window.removeEventListener("mousemove", handleMove); + window.removeEventListener("mouseup", handleUp); + }; + const handleUp = (upEvent: MouseEvent) => { + upEvent.preventDefault(); + cleanup(); + mouseDragCleanupRef.current = null; + finishStatusBarDrag(upEvent.clientX, upEvent.clientY); + }; + window.addEventListener("mousemove", handleMove); + window.addEventListener("mouseup", handleUp); + mouseDragCleanupRef.current = cleanup; + }; const setLanguage = (next: LangPref) => { setPref(next); void apply(() => app.SetDesktopLanguage(next)); @@ -722,6 +916,122 @@ function GeneralSection({ s, busy, apply }: SectionProps) { ))} + +
+ {(["icon", "text"] as const).map((style) => ( + + ))} +
+
+ +
+
+ + {t("settings.statusBarItemsSummary", { visible: statusBarItems.length, total: DEFAULT_STATUS_BAR_ITEMS.length })} + + + + +
+ {statusBarItemsExpanded && ( +
+ {orderedStatusItems.map((id) => { + const label = statusBarItemLabel(id, t); + const visible = visibleStatusItems.has(id); + const visibleIndex = statusBarItems.indexOf(id); + const disableHide = visible && statusBarItems.length <= 1; + const dragLabel = t("settings.statusBarItem.drag", { label }); + const moveUpLabel = t("settings.statusBarItem.moveUp", { label }); + const moveDownLabel = t("settings.statusBarItem.moveDown", { label }); + const dropPlacement = statusBarDragTarget?.id === id ? statusBarDragTarget.placement : null; + return ( +
+ + + + +
+ + + + + + +
+
+ ); + })} +
+ )} +
+
); } diff --git a/desktop/frontend/src/components/StatusBar.tsx b/desktop/frontend/src/components/StatusBar.tsx index 1bd0f464d..93aa129fb 100644 --- a/desktop/frontend/src/components/StatusBar.tsx +++ b/desktop/frontend/src/components/StatusBar.tsx @@ -1,10 +1,13 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { Activity, CircleDollarSign, CircleGauge, Database, Layers, Percent, RefreshCw, Wallet, Zap } from "lucide-react"; import { Tooltip } from "./Tooltip"; import { useI18n, type Translator } from "../lib/i18n"; import { formatMoney } from "../lib/money"; +import { normalizeStatusBarItems, type StatusBarItemId } from "../lib/statusBarItems"; import { type BalanceInfo, type CollaborationMode, type ContextInfo, type JobView, type ToolApprovalMode, type WireUsage } from "../lib/types"; +type StatusBarLabelStyle = "icon" | "text"; + // JobsChip is the status-bar background-jobs indicator: a count that opens an // upward popover listing the running jobs (id · label · status), mirroring the // ModelSwitcher's click-to-open pattern. With no jobs it stays hidden so the @@ -94,6 +97,14 @@ function formatTurnCount(turns: number | undefined, t: Translator): string { return t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns }); } +function MetricLabel({ style, icon, label }: { style: StatusBarLabelStyle; icon: ReactNode; label: string }) { + return ( + + {style === "icon" ? icon : label} + + ); +} + export function StatusBar({ context, usage, @@ -105,9 +116,12 @@ export function StatusBar({ sessionTurns, sessionTokens, turnTokens, + turnCost, cost, currency, modelLabel, + labelStyle = "text", + items, }: { context: ContextInfo; usage?: WireUsage; @@ -119,9 +133,12 @@ export function StatusBar({ sessionTurns?: number; sessionTokens?: number; turnTokens?: number; + turnCost?: number; cost?: number; currency?: string; modelLabel?: string; + labelStyle?: StatusBarLabelStyle; + items?: readonly string[]; }) { const { t } = useI18n(); const pct = context.window ? Math.min(100, Math.round((context.used / context.window) * 100)) : null; @@ -131,6 +148,7 @@ export function StatusBar({ const nowPct = nowRate(usage); const avgPct = avgRate(usage); const jobsList = jobs ?? []; + const turnCostLabel = formatMoney(turnCost, currency); const costLabel = formatMoney(cost, currency); const turnLabel = formatTurnCount(sessionTurns, t); const tokenLabel = formatTokenCount(sessionTokens); @@ -138,96 +156,130 @@ export function StatusBar({ const balanceLabel = balance?.available && balance.display ? balance.display : "-"; const planMode = collaborationMode === "plan"; const goalMode = collaborationMode === "goal"; + const metricLabelStyle = labelStyle === "text" ? "text" : "icon"; + const visibleItems = normalizeStatusBarItems(items); + const itemRenderers: Record = { + model: ( + + + + {modelLabel && {modelLabel}} + + + ), + cache: ( + + + } label={t("status.cacheLabel")} /> + {nowPct !== null ? `${nowPct}%` : "-"} + + + ), + cache_avg: ( + + + } label={t("status.cacheAvgLabel")} /> + {avgPct !== null ? `${avgPct}%` : "-"} + + + ), + session_tokens: ( + + + } label={t("status.sessionTokensLabel")} /> + {tokenLabel} + + + ), + turn_tokens: ( + + + } label={t("status.turnTokensLabel")} /> + {turnTokenLabel} + + + ), + turn_cost: ( + + + } label={t("status.turnCostLabel")} /> + {turnCostLabel} + + + ), + session_turns: ( + + + } label={t("status.sessionTurnsLabel")} /> + {turnLabel} + + + ), + context: ( + + + } label={t("status.ctxLabel")} /> + {pct !== null ? `${pct}%` : "-"} + + + ), + compact: ( + + + } label={t("status.compactLabel")} /> + + {compactPct !== null ? `${compactPct}%` : "-"} + + + + ), + cost: ( + + + } label={t("status.costLabel")} /> + {costLabel} + + + ), + balance: ( + + + } label={t("status.balanceLabel")} /> + {balanceLabel} + + + ), + }; + const modeIndicators = [ + planMode ? {t("status.plan")} : null, + goalMode ? {t("composer.goalMode")} : null, + toolApprovalMode === "auto" ? ( + + {t("composer.accessAuto")} + + ) : null, + toolApprovalMode === "yolo" ? ( + + {t("composer.accessYolo")} + + ) : null, + ].filter(Boolean); return ( -
-
- - - - {modelLabel && {modelLabel}} - - -
-
- - - - {nowPct !== null ? `${nowPct}%` : "-"} - - - - - - {avgPct !== null ? `${avgPct}%` : "-"} - - - - - - {tokenLabel} - - - - - - {turnTokenLabel} - - - - - - {turnLabel} - - -
-
- - - - {pct !== null ? `${pct}%` : "-"} - - - - - - - {compactPct !== null ? `${compactPct}%` : "-"} - - - -
-
- - - - {costLabel} - - - - - - {balanceLabel} +
+
+ {visibleItems.map((id) => ( + + {itemRenderers[id]} - - {planMode && {t("status.plan")}} - {goalMode && {t("composer.goalMode")}} - {toolApprovalMode === "auto" && ( - - {t("composer.accessAuto")} - - )} - {toolApprovalMode === "yolo" && ( - - {t("composer.accessYolo")} - - )} + ))}
+ {modeIndicators.length > 0 &&
{modeIndicators}
} {jobsList.length > 0 && (
diff --git a/desktop/frontend/src/lib/bridge.ts b/desktop/frontend/src/lib/bridge.ts index 91236a2e4..25193c535 100644 --- a/desktop/frontend/src/lib/bridge.ts +++ b/desktop/frontend/src/lib/bridge.ts @@ -8,6 +8,7 @@ import type * as GeneratedApp from "../../wailsjs/go/main/App"; import { t } from "./i18n"; +import { DEFAULT_STATUS_BAR_ITEMS, normalizeStatusBarItems } from "./statusBarItems"; import { modeWithAutoApproveTools, modeWithPlan, normalizeCollaborationMode, normalizeMode, normalizeToolApprovalMode } from "./types"; import type { @@ -224,6 +225,8 @@ export interface AppBindings { TestBotConnection(id: string, target?: string): Promise; SetCloseBehavior(mode: string): Promise; SetDisplayMode(mode: string): Promise; + SetStatusBarStyle(style: string): Promise; + SetStatusBarItems(items: string[]): Promise; SetDesktopLanguage(lang: string): Promise; SetDesktopAppearance(theme: string, style: string): Promise; SetDesktopCheckUpdates(enabled: boolean): Promise; @@ -781,6 +784,8 @@ function makeMockApp(): AppBindings { desktopThemeStyle: "graphite", closeBehavior: "background", displayMode: "minimal", + statusBarStyle: "text", + statusBarItems: [...DEFAULT_STATUS_BAR_ITEMS], checkUpdates: true, telemetry: true, metrics: false, @@ -2249,6 +2254,12 @@ function makeMockApp(): AppBindings { async SetDisplayMode(mode: string) { settings.displayMode = mode; }, + async SetStatusBarStyle(style: string) { + settings.statusBarStyle = style === "text" ? "text" : "icon"; + }, + async SetStatusBarItems(items: string[]) { + settings.statusBarItems = normalizeStatusBarItems(items); + }, async SetDesktopLanguage(lang: string) { settings.desktopLanguage = lang === "en" || lang === "zh" ? lang : ""; }, diff --git a/desktop/frontend/src/lib/statusBarItems.ts b/desktop/frontend/src/lib/statusBarItems.ts new file mode 100644 index 000000000..38592b426 --- /dev/null +++ b/desktop/frontend/src/lib/statusBarItems.ts @@ -0,0 +1,31 @@ +export const STATUS_BAR_ITEM_IDS = [ + "model", + "cache", + "cache_avg", + "session_tokens", + "turn_tokens", + "turn_cost", + "session_turns", + "context", + "compact", + "cost", + "balance", +] as const; + +export type StatusBarItemId = typeof STATUS_BAR_ITEM_IDS[number]; + +export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItemId[] = [...STATUS_BAR_ITEM_IDS]; + +const statusBarItemSet = new Set(STATUS_BAR_ITEM_IDS); + +export function normalizeStatusBarItems(items: readonly string[] | null | undefined): StatusBarItemId[] { + const out: StatusBarItemId[] = []; + const seen = new Set(); + for (const raw of items ?? []) { + const id = String(raw ?? "").trim(); + if (!statusBarItemSet.has(id) || seen.has(id)) continue; + out.push(id as StatusBarItemId); + seen.add(id); + } + return out.length > 0 ? out : [...DEFAULT_STATUS_BAR_ITEMS]; +} diff --git a/desktop/frontend/src/lib/types.ts b/desktop/frontend/src/lib/types.ts index ceb6c8f41..511e89b21 100644 --- a/desktop/frontend/src/lib/types.ts +++ b/desktop/frontend/src/lib/types.ts @@ -750,6 +750,8 @@ export interface SettingsView { desktopThemeStyle: string; closeBehavior: string; // "background" | "quit" displayMode: string; // "standard" | "compact" | "minimal" + statusBarStyle: string; // "icon" | "text" + statusBarItems: string[]; // ordered visible status bar item ids checkUpdates: boolean; // check for new versions on startup telemetry: boolean; // anonymous launch ping (install id + version + OS) metrics: boolean; // opt-in aggregate agent metrics (anonymous signal/bucket counts) diff --git a/desktop/frontend/src/lib/useController.ts b/desktop/frontend/src/lib/useController.ts index 36b08c40a..02eb7f92b 100644 --- a/desktop/frontend/src/lib/useController.ts +++ b/desktop/frontend/src/lib/useController.ts @@ -87,6 +87,7 @@ interface State { turnStartAt: number; turnTokens: number; turnTotalTokens: number; + turnCost: number; sessionTokens: number; sessionCost: number; sessionCurrency: string; @@ -104,6 +105,7 @@ export const initialState: State = { turnStartAt: 0, turnTokens: 0, turnTotalTokens: 0, + turnCost: 0, sessionTokens: 0, sessionCost: 0, sessionCurrency: "¥", @@ -325,7 +327,7 @@ function applyEvent(s: State, e: WireEvent): State { let cur: State = s; if (cur.pendingUser !== undefined) cur = flushPendingUser(cur); const { items, id, seq } = ensureAssistant(cur); - return { ...cur, items, currentAssistant: id, seq, live: { id, text: "", reasoning: "" }, running: true, turnActive: true, turnStartAt: Date.now(), turnTokens: 0, turnTotalTokens: 0 }; + return { ...cur, items, currentAssistant: id, seq, live: { id, text: "", reasoning: "" }, running: true, turnActive: true, turnStartAt: Date.now(), turnTokens: 0, turnTotalTokens: 0, turnCost: 0 }; } case "text": case "reasoning": { @@ -396,9 +398,10 @@ function applyEvent(s: State, e: WireEvent): State { const turnTotalTokens = s.turnTotalTokens + usageTokens; const sessionTokens = s.sessionTokens + usageTokens; const usageCost = e.usage?.cost ?? e.usage?.costUsd ?? 0; + const turnCost = s.turnCost + usageCost; const sessionCost = s.sessionCost + usageCost; const sessionCurrency = e.usage?.currency || s.sessionCurrency || "¥"; - return { ...s, usage: e.usage, context: { ...s.context, used, sessionTokens }, turnTokens, turnTotalTokens, sessionTokens, sessionCost, sessionCurrency }; + return { ...s, usage: e.usage, context: { ...s.context, used, sessionTokens }, turnTokens, turnTotalTokens, turnCost, sessionTokens, sessionCost, sessionCurrency }; } case "notice": return { ...s, running: s.turnActive ? s.running : false, seq: s.seq + 1, items: [...s.items, { kind: "notice", id: `n${s.seq}`, level: e.level ?? "info", text: e.text ?? "" }] }; @@ -449,6 +452,7 @@ export function reducer(s: State, a: Action): State { turnStartAt: Date.now(), turnTokens: 0, turnTotalTokens: 0, + turnCost: 0, pendingUser: a.text, discardTurn: false, }; diff --git a/desktop/frontend/src/locales/en.ts b/desktop/frontend/src/locales/en.ts index fcdb3a537..588bbbe84 100644 --- a/desktop/frontend/src/locales/en.ts +++ b/desktop/frontend/src/locales/en.ts @@ -364,13 +364,15 @@ export const en = { "status.sessionTokensTitle": "Model tokens spent in this session; not the current context-window usage.", "status.turnTokensLabel": "turn tokens", "status.turnTokensTitle": "Model tokens spent in the current or most recent turn.", + "status.turnCostLabel": "turn cost", + "status.turnCostTitle": "Model spend in the current or most recent turn.", "status.compactLabel": "compact at", "status.compactTitle": "Auto-compact is attempted when context reaches this share of the window", "status.cacheLabel": "turn hit", "status.cacheTitle": "Prompt cache hit rate for the latest request", "status.cacheAvgLabel": "avg hit", "status.cacheAvgTitle": "Session-average prompt cache hit rate", - "status.costLabel": "cost", + "status.costLabel": "session cost", "status.balanceLabel": "balance", "status.jobsLabel": "jobs", "status.plan": "PLAN", @@ -384,7 +386,7 @@ export const en = { "status.tokens": "tokens", "status.retrying": "retrying ({attempt}/{max})…", "status.balanceTitle": "Wallet balance", - "status.spendTitle": "Spent this session", + "status.spendTitle": "Total spend in this session", "status.jobs": "{n} running", "status.jobsTitle": "Background jobs", "status.modelTitle": "Current model", @@ -625,6 +627,18 @@ export const en = { "settings.displayMode.standard": "Standard", "settings.displayMode.compact": "Compact", "settings.displayMode.minimal": "Minimal", + "settings.statusBarStyle": "Bottom status bar style", + "settings.statusBarStyle.icon": "Icon version", + "settings.statusBarStyle.text": "Text version", + "settings.statusBarItems": "Status bar items", + "settings.statusBarItemsHint": "Choose which items to show, then drag the handle or use the arrows to reorder them.", + "settings.statusBarItemsSummary": "{visible}/{total} shown", + "settings.statusBarItemsExpand": "Expand status bar items", + "settings.statusBarItemsCollapse": "Collapse status bar items", + "settings.statusBarItem.model": "Model", + "settings.statusBarItem.drag": "Drag to move {label}", + "settings.statusBarItem.moveUp": "Move {label} up", + "settings.statusBarItem.moveDown": "Move {label} down", "settings.manageProviders": "Manage providers", "settings.activeProvider": "Active provider", "settings.plannerStatus": "Planning mode", diff --git a/desktop/frontend/src/locales/zh.ts b/desktop/frontend/src/locales/zh.ts index bd1e97e56..2e8515eb0 100644 --- a/desktop/frontend/src/locales/zh.ts +++ b/desktop/frontend/src/locales/zh.ts @@ -365,13 +365,15 @@ export const zh: Record = { "status.sessionTokensTitle": "本会话累计消耗的模型 tokens,不等于当前上下文窗口占用。", "status.turnTokensLabel": "本次 tokens", "status.turnTokensTitle": "当前或最近一轮交流累计消耗的模型 tokens。", + "status.turnCostLabel": "本次费用", + "status.turnCostTitle": "当前或最近一轮交流累计费用。", "status.compactLabel": "压缩阈值", "status.compactTitle": "上下文达到该比例时会尝试自动压缩", "status.cacheLabel": "本次命中", "status.cacheTitle": "最近一次请求的 prompt 缓存命中率", "status.cacheAvgLabel": "平均命中", "status.cacheAvgTitle": "本会话累计平均 prompt 缓存命中率", - "status.costLabel": "花费", + "status.costLabel": "会话费用", "status.balanceLabel": "余额", "status.jobsLabel": "任务", "status.plan": "计划", @@ -385,7 +387,7 @@ export const zh: Record = { "status.tokens": "tokens", "status.retrying": "正在重试 ({attempt}/{max})…", "status.balanceTitle": "钱包余额", - "status.spendTitle": "本会话花费", + "status.spendTitle": "当前会话累计费用", "status.jobs": "{n} 个运行中", "status.jobsTitle": "后台作业", "status.modelTitle": "当前模型", @@ -627,6 +629,18 @@ export const zh: Record = { "settings.displayMode.standard": "标准", "settings.displayMode.compact": "紧凑", "settings.displayMode.minimal": "极简", + "settings.statusBarStyle": "底部信息栏样式", + "settings.statusBarStyle.icon": "图标版", + "settings.statusBarStyle.text": "文字版", + "settings.statusBarItems": "信息栏显示项", + "settings.statusBarItemsHint": "勾选要显示的项目,拖拽手柄或用上下箭头调整顺序。", + "settings.statusBarItemsSummary": "已显示 {visible}/{total} 项", + "settings.statusBarItemsExpand": "展开信息栏显示项", + "settings.statusBarItemsCollapse": "收起信息栏显示项", + "settings.statusBarItem.model": "模型", + "settings.statusBarItem.drag": "拖拽移动 {label}", + "settings.statusBarItem.moveUp": "上移 {label}", + "settings.statusBarItem.moveDown": "下移 {label}", "settings.manageProviders": "管理模型服务", "settings.activeProvider": "当前模型服务", "settings.plannerStatus": "规划方式", @@ -1244,7 +1258,7 @@ export const zh: Record = { "context.total": "Total", "context.cacheHit": "缓存命中", "context.sessionTokens": "会话 tokens", - "context.sessionCost": "本次费用", + "context.sessionCost": "会话费用", "context.requests": "请求数", "context.time": "耗时", "context.compaction": "压缩状态", diff --git a/desktop/frontend/src/styles.css b/desktop/frontend/src/styles.css index e1b7abe67..d2c828bbc 100644 --- a/desktop/frontend/src/styles.css +++ b/desktop/frontend/src/styles.css @@ -4999,6 +4999,12 @@ a[href] { align-items: center; flex-shrink: 0; } +.statusbar__item, +.statusbar__item .tooltip-trigger { + display: inline-flex; + align-items: center; + flex-shrink: 0; +} .statusbar .modelsw { flex-shrink: 1; min-width: 0; @@ -5019,6 +5025,10 @@ a[href] { .stat__label { color: var(--fg-faint); } +.stat__label--text { + flex: 0 0 auto; + font-weight: 500; +} .stat__label--icon { display: inline-flex; width: 13px; @@ -9570,6 +9580,237 @@ a[href] { min-width: auto; } +.status-bar-items-editor { + display: grid; + gap: 8px; + width: 100%; + max-width: 520px; + margin-left: auto; +} + +.status-bar-items-editor__summary { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + min-height: 32px; +} + +.status-bar-items-editor__summary-text { + min-width: 0; + overflow: hidden; + color: var(--fg-dim); + font-size: var(--font-control-small); + font-weight: 600; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.status-bar-items-editor__toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 28px; + padding: 0; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--bg-elev); + color: var(--fg-dim); +} + +.status-bar-items-editor__toggle:hover:not(:disabled), +.status-bar-items-editor__toggle:focus-visible:not(:disabled) { + outline: none; + border-color: color-mix(in srgb, var(--fg-faint) 34%, var(--border)); + background: var(--bg-elev-2); + color: var(--fg); +} + +.status-bar-items-editor__list { + display: grid; + gap: 6px; +} + +.status-bar-item-row { + position: relative; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + min-height: 36px; + padding: 5px 6px 5px 9px; + border: 1px solid var(--border-soft); + border-radius: 8px; + background: var(--bg-elev); + transition: background var(--dur-fast), border-color var(--dur-fast), box-shadow var(--dur-fast), opacity var(--dur-fast), transform var(--dur-fast); +} + +.status-bar-item-row--hidden { + color: var(--fg-faint); + background: color-mix(in srgb, var(--bg-elev) 70%, transparent); +} + +.status-bar-item-row--dragging { + opacity: 0.56; + transform: scale(0.992); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent); +} + +.status-bar-item-row--drag-over { + border-color: color-mix(in srgb, var(--accent) 42%, var(--border)); + background: color-mix(in srgb, var(--accent) 8%, var(--bg-elev)); +} + +.status-bar-item-row--drop-before::before, +.status-bar-item-row--drop-after::after { + content: ""; + position: absolute; + left: 28px; + right: 14px; + z-index: var(--z-local-raised); + height: 2px; + border-radius: 999px; + background: var(--accent); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent), 0 3px 8px color-mix(in srgb, var(--accent) 24%, transparent); + pointer-events: none; +} + +.status-bar-item-row--drop-before::before { + top: -5px; +} + +.status-bar-item-row--drop-after::after { + bottom: -5px; +} + +.status-bar-item-row__drag { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 24px; + flex: 0 0 22px; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--fg-faint); + cursor: grab; + touch-action: none; + user-select: none; +} + +.status-bar-item-row__drag:hover, +.status-bar-item-row__drag:focus-visible { + outline: none; + background: var(--bg-elev-2); + color: var(--fg-dim); +} + +.status-bar-item-row__drag:active { + cursor: grabbing; +} + +.status-bar-item-row__drag:disabled { + cursor: not-allowed; + opacity: 0.42; +} + +.status-bar-item-row > .tooltip-trigger { + display: inline-flex; + align-items: center; +} + +.status-bar-item-row__toggle { + position: relative; + display: inline-flex; + align-items: center; + min-width: 0; + gap: 8px; + cursor: pointer; +} + +.status-bar-item-row__toggle input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + opacity: 0; + pointer-events: none; +} + +.status-bar-item-row__check { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + flex: 0 0 16px; + border: 1px solid var(--border); + border-radius: 5px; + background: var(--bg); + color: var(--accent); +} + +.status-bar-item-row__toggle:has(input:focus-visible) .status-bar-item-row__check { + box-shadow: 0 0 0 3px var(--accent-soft); +} + +.status-bar-item-row__toggle:has(input:disabled) { + cursor: not-allowed; +} + +.status-bar-item-row__label { + min-width: 0; + overflow: hidden; + color: var(--fg); + font-size: var(--font-control-small); + font-weight: 600; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.status-bar-item-row--hidden .status-bar-item-row__label { + color: var(--fg-faint); + font-weight: 550; +} + +.status-bar-item-row__actions { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.status-bar-item-row__order { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 24px; + padding: 0; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--fg-dim); +} + +.status-bar-item-row__order:hover:not(:disabled), +.status-bar-item-row__order:focus-visible:not(:disabled) { + outline: none; + border-color: var(--border); + background: var(--bg-elev-2); + color: var(--fg); +} + +.status-bar-item-row__order:disabled { + color: color-mix(in srgb, var(--fg-faint) 62%, transparent); + cursor: not-allowed; +} + .step-limit-control { display: flex; align-items: center; @@ -16872,8 +17113,11 @@ a[href] { @media (max-width: 820px) { .statusbar__group--account, - .statusbar__group > .statusbar__metric--avg, - .statusbar__group > .statusbar__metric--compact { + .statusbar__item[data-statusbar-item="cache_avg"], + .statusbar__item[data-statusbar-item="compact"], + .statusbar__item[data-statusbar-item="turn_cost"], + .statusbar__item[data-statusbar-item="cost"], + .statusbar__item[data-statusbar-item="balance"] { display: none; } @@ -16914,13 +17158,13 @@ a[href] { } @media (max-width: 1040px) { - .statusbar__group > .statusbar__metric--avg { + .statusbar__item[data-statusbar-item="cache_avg"] { display: none; } } @media (max-width: 940px) { - .statusbar__group > .statusbar__metric--compact { + .statusbar__item[data-statusbar-item="compact"] { display: none; } } @@ -16928,9 +17172,10 @@ a[href] { @media (max-width: 760px) { .statusbar__group--context, .statusbar__group--jobs, - .statusbar__group > .statusbar__metric--tokens, - .statusbar__group > .statusbar__metric--turn-tokens, - .statusbar__group > .statusbar__metric--turns, + .statusbar__item[data-statusbar-item="session_tokens"], + .statusbar__item[data-statusbar-item="turn_tokens"], + .statusbar__item[data-statusbar-item="session_turns"], + .statusbar__item[data-statusbar-item="context"], .statusbar__jobswrap { display: none; } diff --git a/desktop/settings_app.go b/desktop/settings_app.go index add71c68f..437ea8ef1 100644 --- a/desktop/settings_app.go +++ b/desktop/settings_app.go @@ -149,6 +149,8 @@ type SettingsView struct { DesktopThemeStyle string `json:"desktopThemeStyle"` CloseBehavior string `json:"closeBehavior"` DisplayMode string `json:"displayMode"` + StatusBarStyle string `json:"statusBarStyle"` + StatusBarItems []string `json:"statusBarItems"` CheckUpdates bool `json:"checkUpdates"` Telemetry bool `json:"telemetry"` Metrics bool `json:"metrics"` @@ -330,6 +332,8 @@ func (a *App) Settings() SettingsView { DesktopThemeStyle: "graphite", CloseBehavior: "background", DisplayMode: "minimal", + StatusBarStyle: "text", + StatusBarItems: config.DefaultDesktopStatusBarItems(), CheckUpdates: true, Telemetry: true, Metrics: false, @@ -383,6 +387,8 @@ func (a *App) Settings() SettingsView { DesktopThemeStyle: cfg.DesktopThemeStyle(), CloseBehavior: cfg.DesktopCloseBehavior(), DisplayMode: cfg.DesktopDisplayMode(), + StatusBarStyle: cfg.DesktopStatusBarStyle(), + StatusBarItems: cfg.DesktopStatusBarItems(), CheckUpdates: cfg.DesktopCheckUpdates(), Telemetry: cfg.DesktopTelemetry(), Metrics: cfg.DesktopMetrics(), @@ -1290,6 +1296,18 @@ func (a *App) SetDisplayMode(mode string) error { return a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopDisplayMode(mode) }) } +// SetStatusBarStyle updates the desktop status bar metric label style. UI-only, +// no rebuild needed. +func (a *App) SetStatusBarStyle(style string) error { + return a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopStatusBarStyle(style) }) +} + +// SetStatusBarItems updates the ordered visible desktop status bar items. +// UI-only, no rebuild needed. +func (a *App) SetStatusBarItems(items []string) error { + return a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopStatusBarItems(items) }) +} + // SetDesktopLanguage updates only the desktop UI language. It deliberately does // not touch config.language, which the CLI/model-facing runtime uses. func (a *App) SetDesktopLanguage(lang string) error { diff --git a/internal/config/config.go b/internal/config/config.go index 7055120f2..4404c422e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -74,16 +74,18 @@ type UIConfig struct { // separate from top-level language and [ui] so desktop choices do not affect CLI // language, terminal colours, or provider-visible prompt/request data. type DesktopConfig struct { - Language string `toml:"language"` // auto|en|zh; empty/auto = browser/OS auto-detect - Theme string `toml:"theme"` // auto|dark|light; empty resolves to dark - ThemeStyle string `toml:"theme_style"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases - CloseBehavior string `toml:"close_behavior"` // quit|background; desktop window close behavior - DisplayMode string `toml:"display_mode"` // standard|compact|minimal; transcript display mode - CheckUpdates *bool `toml:"check_updates"` // startup update checks; nil keeps the default enabled - Telemetry *bool `toml:"telemetry"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled - Metrics *bool `toml:"metrics"` // opt-in aggregate agent metrics (anonymous signal/bucket counts; no content); nil = disabled - ProviderAccess []string `toml:"provider_access"` // desktop-only list of provider entries shown in Settings > Model > Access - ExpandThinking bool `toml:"expand_thinking"` // true = show reasoning text expanded by default; false = collapsed + Language string `toml:"language"` // auto|en|zh; empty/auto = browser/OS auto-detect + Theme string `toml:"theme"` // auto|dark|light; empty resolves to dark + ThemeStyle string `toml:"theme_style"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases + CloseBehavior string `toml:"close_behavior"` // quit|background; desktop window close behavior + DisplayMode string `toml:"display_mode"` // standard|compact|minimal; transcript display mode + StatusBarStyle string `toml:"status_bar_style"` // icon|text; desktop status bar metric labels + StatusBarItems []string `toml:"status_bar_items"` // ordered visible desktop status bar items + CheckUpdates *bool `toml:"check_updates"` // startup update checks; nil keeps the default enabled + Telemetry *bool `toml:"telemetry"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled + Metrics *bool `toml:"metrics"` // opt-in aggregate agent metrics (anonymous signal/bucket counts; no content); nil = disabled + ProviderAccess []string `toml:"provider_access"` // desktop-only list of provider entries shown in Settings > Model > Access + ExpandThinking bool `toml:"expand_thinking"` // true = show reasoning text expanded by default; false = collapsed } // NotificationsConfig controls optional system notifications for CLI chat/run. @@ -207,6 +209,77 @@ func (c *Config) DesktopDisplayMode() string { } } +// DesktopStatusBarStyle normalizes the desktop status bar metric label style. +// Default is "text"; explicit "icon" preserves the user's compact choice. +func (c *Config) DesktopStatusBarStyle() string { + switch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) { + case "icon": + return "icon" + case "text": + return "text" + default: + return "text" + } +} + +var defaultDesktopStatusBarItems = []string{ + "model", + "cache", + "cache_avg", + "session_tokens", + "turn_tokens", + "turn_cost", + "session_turns", + "context", + "compact", + "cost", + "balance", +} + +var knownDesktopStatusBarItems = map[string]bool{ + "model": true, + "cache": true, + "cache_avg": true, + "session_tokens": true, + "turn_tokens": true, + "turn_cost": true, + "session_turns": true, + "context": true, + "compact": true, + "cost": true, + "balance": true, +} + +// DefaultDesktopStatusBarItems returns the default ordered visible desktop +// status bar items. +func DefaultDesktopStatusBarItems() []string { + return append([]string(nil), defaultDesktopStatusBarItems...) +} + +// DesktopStatusBarItems normalizes the ordered visible desktop status bar items. +// An unset or empty list uses the default full set; explicit non-empty lists +// preserve user order and omit hidden items. +func (c *Config) DesktopStatusBarItems() []string { + return normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems) +} + +func normalizeDesktopStatusBarItems(items []string) []string { + out := make([]string, 0, len(items)) + seen := map[string]bool{} + for _, raw := range items { + id := strings.TrimSpace(raw) + if !knownDesktopStatusBarItems[id] || seen[id] { + continue + } + out = append(out, id) + seen[id] = true + } + if len(out) == 0 { + return DefaultDesktopStatusBarItems() + } + return out +} + // DesktopCheckUpdates reports whether the desktop should check for updates on // startup. Missing configs default to true so existing users keep update notices. func (c *Config) DesktopCheckUpdates() bool { diff --git a/internal/config/edit.go b/internal/config/edit.go index c6b21d5ab..803dd36ab 100644 --- a/internal/config/edit.go +++ b/internal/config/edit.go @@ -207,6 +207,43 @@ func (c *Config) SetDesktopDisplayMode(mode string) error { return nil } +// SetDesktopStatusBarStyle sets the desktop status bar metric label style. +// UI-only; it must not affect CLI output or provider-visible request data. +func (c *Config) SetDesktopStatusBarStyle(style string) error { + switch strings.ToLower(strings.TrimSpace(style)) { + case "icon", "icons": + c.Desktop.StatusBarStyle = "icon" + case "", "text", "label", "labels": + c.Desktop.StatusBarStyle = "text" + default: + return fmt.Errorf("status bar style %q: must be icon|text", style) + } + return nil +} + +// SetDesktopStatusBarItems sets the ordered visible desktop status bar items. +// UI-only; it must not affect CLI output or provider-visible request data. +func (c *Config) SetDesktopStatusBarItems(items []string) error { + out := make([]string, 0, len(items)) + seen := map[string]bool{} + for _, raw := range items { + id := strings.TrimSpace(raw) + if id == "" || seen[id] { + continue + } + if !knownDesktopStatusBarItems[id] { + return fmt.Errorf("status bar item %q: unknown item", id) + } + out = append(out, id) + seen[id] = true + } + if len(out) == 0 { + out = DefaultDesktopStatusBarItems() + } + c.Desktop.StatusBarItems = out + return nil +} + // SetDesktopCheckUpdates sets whether the desktop app checks for updates on // startup. Manual checks remain available in Settings regardless of this value. func (c *Config) SetDesktopCheckUpdates(enabled bool) error { diff --git a/internal/config/edit_test.go b/internal/config/edit_test.go index 75852d378..33b96dc97 100644 --- a/internal/config/edit_test.go +++ b/internal/config/edit_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "reflect" "strings" "testing" @@ -107,6 +108,12 @@ func TestDesktopPreferencesAreSeparateFromCLI(t *testing.T) { if err := c.SetDesktopAppearance("dark", "graphite"); err != nil { t.Fatalf("SetDesktopAppearance: %v", err) } + if err := c.SetDesktopStatusBarStyle("text"); err != nil { + t.Fatalf("SetDesktopStatusBarStyle: %v", err) + } + if err := c.SetDesktopStatusBarItems([]string{"model", "balance", "cache"}); err != nil { + t.Fatalf("SetDesktopStatusBarItems: %v", err) + } if c.Language != "zh" { t.Fatalf("CLI language changed to %q", c.Language) @@ -126,6 +133,69 @@ func TestDesktopPreferencesAreSeparateFromCLI(t *testing.T) { if got := c.DesktopThemeStyle(); got != "graphite" { t.Fatalf("desktop theme style = %q, want graphite", got) } + if got := c.DesktopStatusBarStyle(); got != "text" { + t.Fatalf("desktop status bar style = %q, want text", got) + } + if got, want := c.DesktopStatusBarItems(), []string{"model", "balance", "cache"}; !reflect.DeepEqual(got, want) { + t.Fatalf("desktop status bar items = %v, want %v", got, want) + } +} + +func TestDesktopStatusBarStyleNormalizes(t *testing.T) { + if got := Default().DesktopStatusBarStyle(); got != "text" { + t.Fatalf("default desktop status bar style = %q, want text", got) + } + for _, tt := range []struct { + in string + want string + wantErr bool + }{ + {"", "text", false}, + {"icon", "icon", false}, + {"icons", "icon", false}, + {"text", "text", false}, + {"labels", "text", false}, + {"later", "text", true}, + } { + c := Default() + if err := c.SetDesktopStatusBarStyle(tt.in); (err != nil) != tt.wantErr { + t.Fatalf("SetDesktopStatusBarStyle(%q) err = %v, wantErr %v", tt.in, err, tt.wantErr) + } + if got := c.DesktopStatusBarStyle(); got != tt.want { + t.Fatalf("DesktopStatusBarStyle(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestDesktopStatusBarItemsNormalizeAndValidate(t *testing.T) { + if got, want := Default().DesktopStatusBarItems(), DefaultDesktopStatusBarItems(); !reflect.DeepEqual(got, want) { + t.Fatalf("default desktop status bar items = %v, want %v", got, want) + } + + c := Default() + c.Desktop.StatusBarItems = []string{" balance ", "cache", "cache", "unknown", "model"} + if got, want := c.DesktopStatusBarItems(), []string{"balance", "cache", "model"}; !reflect.DeepEqual(got, want) { + t.Fatalf("normalized desktop status bar items = %v, want %v", got, want) + } + + c = Default() + if err := c.SetDesktopStatusBarItems([]string{"balance", "cache", "balance", "model"}); err != nil { + t.Fatalf("SetDesktopStatusBarItems subset: %v", err) + } + if got, want := c.DesktopStatusBarItems(), []string{"balance", "cache", "model"}; !reflect.DeepEqual(got, want) { + t.Fatalf("saved desktop status bar items = %v, want %v", got, want) + } + + if err := c.SetDesktopStatusBarItems(nil); err != nil { + t.Fatalf("SetDesktopStatusBarItems nil: %v", err) + } + if got, want := c.DesktopStatusBarItems(), DefaultDesktopStatusBarItems(); !reflect.DeepEqual(got, want) { + t.Fatalf("nil desktop status bar items = %v, want default %v", got, want) + } + + if err := c.SetDesktopStatusBarItems([]string{"ghost"}); err == nil { + t.Fatal("expected error for unknown status bar item") + } } func TestDesktopCloseBehaviorFallsBackToLegacyUI(t *testing.T) { diff --git a/internal/config/render.go b/internal/config/render.go index c9eca896e..c739e8052 100644 --- a/internal/config/render.go +++ b/internal/config/render.go @@ -90,6 +90,8 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string { b.WriteString("# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n") } fmt.Fprintf(&b, "close_behavior = %q # desktop: quit|background when the window close button is clicked\n", c.DesktopCloseBehavior()) + fmt.Fprintf(&b, "status_bar_style = %q # desktop: icon|text metric labels in the bottom status bar\n", c.DesktopStatusBarStyle()) + fmt.Fprintf(&b, "status_bar_items = %s # desktop: ordered visible bottom status bar items\n", renderStringArray(c.DesktopStatusBarItems())) fmt.Fprintf(&b, "check_updates = %v # desktop: check for new versions on startup\n", c.DesktopCheckUpdates()) fmt.Fprintf(&b, "telemetry = %v # desktop: anonymous launch ping (install id + version + OS); never content\n", c.DesktopTelemetry()) fmt.Fprintf(&b, "metrics = %v # desktop: opt-in aggregate agent metrics (anonymous signal/bucket counts); never content\n", c.DesktopMetrics()) diff --git a/internal/config/render_test.go b/internal/config/render_test.go index a01a339f1..7fb924376 100644 --- a/internal/config/render_test.go +++ b/internal/config/render_test.go @@ -1,6 +1,7 @@ package config import ( + "reflect" "strconv" "strings" "testing" @@ -21,6 +22,8 @@ func TestRenderTOMLRoundTrips(t *testing.T) { orig.Desktop.Theme = "dark" orig.Desktop.ThemeStyle = "graphite" orig.Desktop.CloseBehavior = "background" + orig.Desktop.StatusBarStyle = "text" + orig.Desktop.StatusBarItems = []string{"model", "balance", "cache"} orig.Desktop.CheckUpdates = boolPtr(false) orig.Desktop.Telemetry = boolPtr(false) orig.Notifications.Enabled = true @@ -130,6 +133,12 @@ func TestRenderTOMLRoundTrips(t *testing.T) { if got.Desktop.CloseBehavior != "background" { t.Errorf("desktop.close_behavior = %q, want background", got.Desktop.CloseBehavior) } + if got.Desktop.StatusBarStyle != "text" { + t.Errorf("desktop.status_bar_style = %q, want text", got.Desktop.StatusBarStyle) + } + if want := []string{"model", "balance", "cache"}; !reflect.DeepEqual(got.Desktop.StatusBarItems, want) { + t.Errorf("desktop.status_bar_items = %v, want %v", got.Desktop.StatusBarItems, want) + } if got.Desktop.CheckUpdates == nil || *got.Desktop.CheckUpdates { t.Errorf("desktop.check_updates = %+v, want false", got.Desktop.CheckUpdates) } @@ -381,10 +390,11 @@ func TestScopedRenderSeparatesUserAndProjectConfig(t *testing.T) { c.Desktop.Theme = "dark" c.Desktop.ThemeStyle = "graphite" c.Desktop.CloseBehavior = "background" + c.Desktop.StatusBarStyle = "text" c.Desktop.CheckUpdates = boolPtr(false) user := RenderTOMLForScope(c, RenderScopeUser) - for _, want := range []string{"config_version = 2", "[desktop]", `theme = "dark"`, `close_behavior = "background"`, `check_updates = false`, "[notifications]"} { + for _, want := range []string{"config_version = 2", "[desktop]", `theme = "dark"`, `close_behavior = "background"`, `status_bar_style = "text"`, `check_updates = false`, "[notifications]"} { if !strings.Contains(user, want) { t.Fatalf("user render missing %q:\n%s", want, user) }