diff --git a/apps/desktop/src/app/command-palette/contrib.ts b/apps/desktop/src/app/command-palette/contrib.ts index 62b6a49cef6..00b7b1ff619 100644 --- a/apps/desktop/src/app/command-palette/contrib.ts +++ b/apps/desktop/src/app/command-palette/contrib.ts @@ -19,11 +19,15 @@ export interface PaletteContribution { keywords?: string[] run: () => void /** - * Muted text after the label — the live state the row acts on. A function + * Short note after the label — the live state the row acts on. A function * because contributions register once at boot while that state keeps moving; * the palette re-reads it on open. */ detail?: () => string + /** `state` when running the row CHANGES what `detail` says. */ + detailVariant?: 'muted' | 'state' + /** Leave the palette open after running — for rows you may run repeatedly. */ + keepOpen?: boolean } /** Contributed palette rows, with stable render keys. */ @@ -34,12 +38,15 @@ export function usePaletteContributions(): Array & { + spec: Omit & { get: () => boolean set: (enabled: boolean) => void } @@ -49,6 +56,8 @@ export function paletteToggle( const data: PaletteContribution = { ...rest, detail: () => (get() ? 'on' : 'off'), + detailVariant: 'state', + keepOpen: true, keywords: [...keywords, 'on', 'off', 'enable', 'disable'], run: () => set(!get()) } diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index cf7abbd7f05..970f4fb4be2 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -4,7 +4,15 @@ import { Dialog as DialogPrimitive } from 'radix-ui' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' -import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/app/floating-hud' +import { + HUD_HEADING, + HUD_ITEM, + HUD_NOTE, + HUD_NOTE_VARIANT, + HUD_POSITION, + HUD_SURFACE, + HUD_TEXT +} from '@/app/floating-hud' import { setTerminalTakeover } from '@/app/right-sidebar/store' import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' import { HighlightMatches } from '@/components/ui/highlight-matches' @@ -61,7 +69,7 @@ import { import { $bindings } from '@/store/keybinds' import { openPetGenerate } from '@/store/pet-generate' import { requestStartWorkSession } from '@/store/projects' -import { $connection, $yoloActive } from '@/store/session' +import { $connection } from '@/store/session' import { runGatewayRestart } from '@/store/system-actions' import { $backendUpdateApply, @@ -103,8 +111,10 @@ interface PaletteItem { action?: string /** Renders a trailing check: this row IS the current setting (theme, mode). */ active?: boolean - /** Muted text beside the label — state the row acts on (a version, a count). */ + /** Short note beside the label — state the row acts on (a version, a count). */ detail?: string + /** `state` when the row will change what `detail` says (a toggle's on/off). */ + detailVariant?: keyof typeof HUD_NOTE_VARIANT icon: IconComponent id: string /** Keep the palette open after running (live-preview pickers like theme/mode). */ @@ -260,9 +270,9 @@ const PaletteRow = memo(function PaletteRow({ shows exactly which words earned the row its rank. */} - {/* Reads as a suffix of the label, so it sits closer than the row's - icon-to-label gap-2 — negative margin trims that back to ~4px. */} - {item.detail && {item.detail}} + {item.detail && ( + {item.detail} + )} {combo && } {item.to && } {item.active && } @@ -376,9 +386,10 @@ export function CommandPalette() { const clientApply = useStore($updateApply) const backendStatus = useStore($backendUpdateStatus) const backendApply = useStore($backendUpdateApply) - // Contributed on/off rows check the live half (YOLO on / YOLO off), so the - // groups have to rebuild when that state moves under an open palette. - const yoloActive = useStore($yoloActive) + // Running a keepOpen row (a toggle) changes state the rows themselves report, + // so the groups have to rebuild without the palette closing. A counter keeps + // that generic — the palette never learns which stores its contributions read. + const [selectTick, setSelectTick] = useState(0) const updateVersionLabel = useMemo(() => { const backend = connection?.mode === 'remote' @@ -536,11 +547,13 @@ export function CommandPalette() { heading: cc.commands, items: contributedItems.map(item => ({ action: item.action, - // Resolved per palette open (see the `open` dep below) so a - // state-describing row can't show a state it left. + // Read on open and after every select (the deps below), so a + // row that reports state can't show the state it just left. detail: item.detail?.(), + detailVariant: item.detailVariant, icon: item.icon ?? Zap, id: item.key, + keepOpen: item.keepOpen, keywords: item.keywords, label: item.label, run: item.run @@ -731,7 +744,11 @@ export function CommandPalette() { ] } ] - }, [contributedItems, go, open, settingsSectionLabel, t, updateVersionLabel, yoloActive]) + // `open` and `selectTick` are deliberate re-read triggers, not values: rows + // report live state through `detail()`, so the groups must rebuild when the + // palette opens and after each select — eslint only sees unused deps. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [contributedItems, go, open, selectTick, settingsSectionLabel, t, updateVersionLabel]) // The long, granular lists (settings fields, API keys, MCP servers, archived // chats) only surface once the user types — otherwise they'd bury the @@ -1021,7 +1038,13 @@ export function CommandPalette() { if (!item.keepOpen) { closeCommandPalette() + + return } + + // Staying open means the rows are still on screen — re-read anything they + // report (a toggle's on/off) so the note isn't showing the previous state. + setSelectTick(tick => tick + 1) } return ( diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index b5a7631a032..b94df63799c 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -38,7 +38,7 @@ import { useContributions } from '@/contrib/react/use-contributions' import { registry } from '@/contrib/registry' import { discoverRuntimePlugins } from '@/contrib/runtime-loader' import { sessionTitle as storedSessionTitle } from '@/lib/chat-runtime' -import { LayoutDashboard, PanelBottom, Zap } from '@/lib/icons' +import { FileText, LayoutDashboard, PanelBottom, Zap } from '@/lib/icons' import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions' import { Codecs, persistentAtom } from '@/lib/persisted' import { setYoloEnabled } from '@/lib/yolo-session' @@ -232,12 +232,12 @@ registry.registerMany([ }, { // Optional chrome — in NO default layout. Adoption stacks it with the - // terminal; $logsOpen (default off, ⌘K "Logs on") reveals it. + // terminal; $logsOpen (default off, ⌘K "Toggle logs") reveals it. id: 'logs', area: 'panes', title: 'logs', // revealOnPreset: the Quad layout places logs, so applying it turns the - // logs pane on (like a ⌘K "Logs on") instead of leaving it collapsed. + // logs pane on (like a ⌘K "Toggle logs") instead of leaving it collapsed. data: { placement: 'bottom', height: '20vh', minHeight: '7.5rem', maxHeight: '80vh', revealOnPreset: true }, render: () => idle() } @@ -598,6 +598,7 @@ registry.register( paletteToggle({ id: 'logs.toggle', label: 'Toggle logs', + icon: FileText, keywords: ['logs', 'agent log', 'tail', 'debug'], get: () => $logsOpen.get(), set: enabled => $logsOpen.set(enabled) @@ -610,7 +611,7 @@ registry.register( registry.register( paletteToggle({ id: 'session.yolo', - label: 'Toggle YOLO', + label: 'Toggle yolo', icon: Zap, keywords: ['yolo', 'approvals', 'auto-approve', 'bypass', 'dangerous', 'commands'], get: () => $yoloActive.get(), diff --git a/apps/desktop/src/app/floating-hud.ts b/apps/desktop/src/app/floating-hud.ts index 1655fc99f44..83b97a9c8e4 100644 --- a/apps/desktop/src/app/floating-hud.ts +++ b/apps/desktop/src/app/floating-hud.ts @@ -31,3 +31,23 @@ export const HUD_ITEM = 'gap-2 px-2 py-1' // heading via the universal-descendant variant. export const HUD_HEADING = '**:[[cmdk-group-heading]]:static **:[[cmdk-group-heading]]:bg-transparent **:[[cmdk-group-heading]]:px-2.5 **:[[cmdk-group-heading]]:pb-1 **:[[cmdk-group-heading]]:pt-2.5 **:[[cmdk-group-heading]]:text-[0.64rem] **:[[cmdk-group-heading]]:font-semibold **:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-[0.16em] **:[[cmdk-group-heading]]:text-(--theme-primary)' + +// A short note trailing a row's label — a version, a count, a live state. Sits +// closer than the row's icon-to-label `gap-2` because it reads as a suffix of +// the label, not as another item in the row. +export const HUD_NOTE = '-ml-1' + +// `state` marks a note the row will CHANGE (a toggle's on/off) rather than a +// passive fact. It keeps the label's own color so the pair reads as one phrase, +// and earns its separation from a faint underline instead of going muted. +// +// That underline needs room, so `state` skips `truncate`: flex items are +// blockified, which makes its `overflow: hidden` bite, and the app's global +// 0.25rem underline offset (styles.css) already sits at the bottom edge of a +// `text-xs` line box — together they clip the rule away entirely. Pulling the +// offset to 2px keeps it inside the box; dropping truncate costs nothing +// because these notes are one word. +export const HUD_NOTE_VARIANT = { + muted: 'truncate text-muted-foreground/80', + state: 'shrink-0 underline decoration-current/50 underline-offset-2' +} as const