diff --git a/apps/desktop/src/app/command-palette/contrib.ts b/apps/desktop/src/app/command-palette/contrib.ts index bd3e669cbe3..62b6a49cef6 100644 --- a/apps/desktop/src/app/command-palette/contrib.ts +++ b/apps/desktop/src/app/command-palette/contrib.ts @@ -18,6 +18,12 @@ export interface PaletteContribution { icon?: IconComponent keywords?: string[] run: () => void + /** + * Muted text 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 } /** Contributed palette rows, with stable render keys. */ @@ -26,3 +32,26 @@ export function usePaletteContributions(): Array ({ key: `${c.source ?? 'core'}:${c.id}`, ...(c.data as PaletteContribution) })) .filter(item => Boolean(item.label && item.run)) } + +/** + * A binary setting as one palette row: `Toggle status bar` with the live state + * trailing in muted text. The verb says what the row does, the detail says + * which way it will go — neither alone is enough. + */ +export function paletteToggle( + spec: Omit & { + get: () => boolean + set: (enabled: boolean) => void + } +) { + const { get, keywords = [], set, ...rest } = spec + + const data: PaletteContribution = { + ...rest, + detail: () => (get() ? 'on' : 'off'), + keywords: [...keywords, 'on', 'off', 'enable', 'disable'], + run: () => set(!get()) + } + + return { id: data.id, area: PALETTE_AREA, data } +} diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index a0279c495ee..026f8f2f8aa 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -61,7 +61,7 @@ import { import { $bindings } from '@/store/keybinds' import { openPetGenerate } from '@/store/pet-generate' import { requestStartWorkSession } from '@/store/projects' -import { $connection } from '@/store/session' +import { $connection, $yoloActive } from '@/store/session' import { runGatewayRestart } from '@/store/system-actions' import { $backendUpdateApply, @@ -374,6 +374,9 @@ 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) const updateVersionLabel = useMemo(() => { const backend = connection?.mode === 'remote' @@ -704,6 +707,9 @@ 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. + detail: item.detail?.(), icon: item.icon ?? Zap, id: item.key, keywords: item.keywords, @@ -714,7 +720,7 @@ export function CommandPalette() { ] : []) ] - }, [contributedItems, go, settingsSectionLabel, t, updateVersionLabel, worktrees]) + }, [contributedItems, go, open, settingsSectionLabel, t, updateVersionLabel, worktrees, yoloActive]) // The long, granular lists (settings fields, API keys, MCP servers, archived // chats) only surface once the user types — otherwise they'd bury the diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index b808b236583..b5a7631a032 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -4,10 +4,10 @@ import type { CSSProperties, ReactElement, PointerEvent as ReactPointerEvent } f import { PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH } from '@/app/chat/right-rail' import { SessionStatusDot } from '@/app/chat/session-status-dot' -import { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib' +import { PALETTE_AREA, type PaletteContribution, paletteToggle } from '@/app/command-palette/contrib' import { type StatusbarItem } from '@/app/shell/statusbar-controls' import { IdleMount } from '@/components/idle-mount' -import { toggleLayoutEditMode } from '@/components/pane-shell/edit-mode' +import { $layoutEditMode, toggleLayoutEditMode } from '@/components/pane-shell/edit-mode' import { allPaneIds, group, groupLeafIds, split } from '@/components/pane-shell/tree/model' import { LayoutTreeRoot } from '@/components/pane-shell/tree/renderer' import type { DoubleTapContext } from '@/components/pane-shell/tree/renderer/drag-session' @@ -38,9 +38,10 @@ 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 } from '@/lib/icons' +import { 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' import { pruneComposerPopoutZones } from '@/store/composer-popout' import { $fileBrowserOpen, @@ -56,9 +57,9 @@ import { } from '@/store/layout' import { $previewOpenRequest, $previewTabs, closeRightRail } from '@/store/preview' import { $reviewOpen, closeReview, REVIEW_PANE_ID } from '@/store/review' -import { $currentCwd, $selectedStoredSessionId, $sessions, sessionMatchesStoredId } from '@/store/session' +import { $currentCwd, $selectedStoredSessionId, $sessions, $yoloActive, sessionMatchesStoredId } from '@/store/session' import { watchSessionPins } from '@/store/session-pin-sync' -import { $statusbarVisible, toggleStatusbarVisible } from '@/store/statusbar-prefs' +import { $statusbarVisible } from '@/store/statusbar-prefs' import type { SessionDragPayload } from '../chat/composer/inline-refs' import { watchRouteTiles } from '../chat/route-tile' @@ -231,12 +232,12 @@ registry.registerMany([ }, { // Optional chrome — in NO default layout. Adoption stacks it with the - // terminal; $logsOpen (default off, ⌘K "Toggle logs") reveals it. + // terminal; $logsOpen (default off, ⌘K "Logs on") 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 "Toggle logs") instead of leaving it collapsed. + // logs pane on (like a ⌘K "Logs on") instead of leaving it collapsed. data: { placement: 'bottom', height: '20vh', minHeight: '7.5rem', maxHeight: '80vh', revealOnPreset: true }, render: () => idle() } @@ -267,18 +268,15 @@ registry.registerMany([ run: toggleLayoutEditMode } satisfies KeybindContribution }, - { + paletteToggle({ id: 'layout.editMode', - area: PALETTE_AREA, - data: { - id: 'layout.editMode', - label: 'Toggle layout edit mode', - action: 'layout.editMode', - icon: LayoutDashboard, - keywords: ['layout', 'zones', 'panes', 'edit', 'rearrange'], - run: toggleLayoutEditMode - } satisfies PaletteContribution - }, + label: 'Toggle layout edit mode', + action: 'layout.editMode', + icon: LayoutDashboard, + keywords: ['layout', 'zones', 'panes', 'edit', 'rearrange'], + get: () => $layoutEditMode.get(), + set: enabled => $layoutEditMode.set(enabled) + }), // The agent's write -> see loop: rescan /desktop-plugins // without relaunching (same-id reloads dispose the previous incarnation). { @@ -304,18 +302,15 @@ registry.registerMany([ }, // Hiding the bar removes the surface that would otherwise offer it back, so // ⌘K is the guaranteed door in (alongside the rebindable ⌘⇧S). - { + paletteToggle({ id: 'view.toggleStatusbar', - area: PALETTE_AREA, - data: { - id: 'view.toggleStatusbar', - label: 'Toggle status bar', - action: 'view.toggleStatusbar', - icon: PanelBottom, - keywords: ['status bar', 'statusbar', 'bottom bar', 'hide', 'show', 'chrome'], - run: toggleStatusbarVisible - } satisfies PaletteContribution - }, + label: 'Toggle status bar', + action: 'view.toggleStatusbar', + icon: PanelBottom, + keywords: ['status bar', 'statusbar', 'bottom bar', 'hide', 'show', 'chrome'], + get: () => $statusbarVisible.get(), + set: enabled => $statusbarVisible.set(enabled) + }), // The keybind panel's non-titlebar door (the keyboard icon is gone). { id: 'keybinds.panel', @@ -599,16 +594,29 @@ bindPaneCollapse( () => $logsOpen.set(false), () => $logsOpen.set(true) ) -registry.register({ - id: 'logs.toggle', - area: PALETTE_AREA, - data: { +registry.register( + paletteToggle({ id: 'logs.toggle', label: 'Toggle logs', keywords: ['logs', 'agent log', 'tail', 'debug'], - run: () => $logsOpen.set(!$logsOpen.get()) - } satisfies PaletteContribution -}) + get: () => $logsOpen.get(), + set: enabled => $logsOpen.set(enabled) + }) +) + +// YOLO (dangerous-command approval bypass) is a status-bar zap and a /yolo +// command; ⌘K is the third door onto the SAME store function, so a user who +// lives in the palette never has to hunt for the pill. +registry.register( + paletteToggle({ + id: 'session.yolo', + label: 'Toggle YOLO', + icon: Zap, + keywords: ['yolo', 'approvals', 'auto-approve', 'bypass', 'dangerous', 'commands'], + get: () => $yoloActive.get(), + set: enabled => void setYoloEnabled(enabled).catch(() => undefined) + }) +) // Sessions/files Close = collapse their SIDE (⌘B/⌘J truthful, titlebar button // flips back) — but only while the pane actually lives in that root side diff --git a/apps/desktop/src/lib/yolo-session.ts b/apps/desktop/src/lib/yolo-session.ts index 72f01ec2be3..841cafdada7 100644 --- a/apps/desktop/src/lib/yolo-session.ts +++ b/apps/desktop/src/lib/yolo-session.ts @@ -1,4 +1,5 @@ -import { setYoloActive } from '@/store/session' +import { $gateway } from '@/store/gateway' +import { $activeSessionId, setYoloActive } from '@/store/session' export type GatewayRequester = (method: string, params?: Record) => Promise @@ -45,3 +46,31 @@ export async function setGlobalYolo(requestGateway: GatewayRequester, enabled: b return active } + +/** + * Set YOLO to an explicit state from a surface that has no React context — the + * ⌘K rows. `useSlashCommand` keeps its own `requestGateway` (it already holds + * one, with the reconnect handling), so this reaches the active gateway + * directly rather than growing a second requester abstraction. + * + * With no session yet the flag is armed locally; the session-create path + * (use-session-actions) applies it on the first message, exactly as a bare + * `/yolo` in a fresh draft does. + */ +export async function setYoloEnabled(enabled: boolean): Promise { + const sessionId = $activeSessionId.get() + + if (!sessionId) { + setYoloActive(enabled) + + return enabled + } + + const gateway = $gateway.get() + + if (!gateway) { + throw new Error('Hermes gateway unavailable') + } + + return setSessionYolo((method, params) => gateway.request(method, params), sessionId, enabled) +}