feat(desktop): make the ⌘K row note a shared primitive with a state variant

A toggle's on/off isn't a passive fact like a version string — the row is about
to change it. It keeps the label's color so the two read as one phrase, and
earns its separation from a faint underline instead of going muted. Both
variants live in floating-hud.ts beside the other shared HUD chrome.

That underline needs room to exist: flex items are blockified, so the note's
truncate/overflow-hidden bites, and the app's global 0.25rem offset sits at the
bottom edge of a text-xs line box. The state variant drops truncate (one word,
nothing to clip) and pulls the offset to 2px.

Flipping a setting isn't navigation either, so toggles keep the palette open the
way the theme and color-mode rows already do, and selecting a keepOpen row bumps
a counter that rebuilds the groups so the note can't report the state it left.

Also: yolo is lowercase, and logs gets its own icon instead of borrowing the
YOLO bolt.
This commit is contained in:
Brooklyn Nicholson 2026-07-30 03:13:21 -05:00
parent 09fa5c063c
commit 129b4e7fef
4 changed files with 74 additions and 21 deletions

View file

@ -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<PaletteContribution & { key: st
}
/**
* 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.
* A binary setting as one palette row: `Toggle status bar` trailed by the live
* state. The verb says what the row does, the note says where it stands
* neither alone is enough to act on.
*
* Rows keep the palette open: flipping a setting is the kind of thing you do
* two or three of in a row, and the note updating in place is the receipt.
*/
export function paletteToggle(
spec: Omit<PaletteContribution, 'detail' | 'run'> & {
spec: Omit<PaletteContribution, 'detail' | 'detailVariant' | 'keepOpen' | 'run'> & {
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())
}

View file

@ -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. */}
<HighlightMatches query={search.split(/\s+/)} text={item.label} />
</span>
{/* 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 && <span className="-ml-1 truncate text-muted-foreground/80">{item.detail}</span>}
{item.detail && (
<span className={cn(HUD_NOTE, HUD_NOTE_VARIANT[item.detailVariant ?? 'muted'])}>{item.detail}</span>
)}
{combo && <KbdCombo className="ml-auto opacity-55" combo={combo} size="sm" />}
{item.to && <ChevronRight className={cn('size-3.5 shrink-0 text-muted-foreground/70', !combo && 'ml-auto')} />}
{item.active && <Check className={cn('size-3.5 shrink-0 text-primary', !combo && !item.to && 'ml-auto')} />}
@ -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 (

View file

@ -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(<LogsPane />)
}
@ -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(),

View file

@ -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