Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
365 changes: 365 additions & 0 deletions docs/superpowers/plans/2026-07-28-command-palette-sort-modes.md

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions src/renderer/src/app-state/settings/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
} from '@renderer/app-state/settings/savedThemes'
import type { SavedTheme } from '@renderer/app-state/settings/savedThemes'
import { parseCustomAppearanceJson } from '@renderer/app-state/settings/customAppearance'
import { isCommandSortMode } from '@renderer/features/command-palette/lib/sortCommands'
import type { CommandSortMode } from '@renderer/features/command-palette/lib/sortCommands'
import type {
AccentId,
FontFamilyId,
Expand Down Expand Up @@ -138,6 +140,7 @@ export function coerceSettings(value: unknown): Settings {
// shape. A garbage value collapses to `{}` (nothing overridden),
// matching the "absent ≡ declared default" semantic.
commandStarred: coerceCommandStarred(parsed.commandStarred),
commandSortMode: coerceCommandSortMode(parsed.commandSortMode),
mouseModeEnabled: parsed.mouseModeEnabled === true,
commandVisibilityOverrides: coerceCommandVisibilityOverrides(
parsed.commandVisibilityOverrides,
Expand Down Expand Up @@ -289,6 +292,22 @@ function coerceCommandStarred(value: unknown): Record<string, boolean> {
return result
}

/**
* Fall back to the shipped default on anything unrecognized.
*
* The union is closed, but the blob it is read from is not: a settings file
* written by a future build (a fifth mode), hand-edited in devtools, or
* truncated mid-write can all put a string here that no longer means anything.
* An unknown value must degrade to 'catalog' — the behavior the palette had
* before this setting existed. Letting one through would NOT be harmless: it
* falls past the 'catalog' and 'alpha' branches in `orderFlat` and lands on the
* history sort, so an unrecognized mode would silently render as "recently
* used" while the control displayed whatever string it read.
*/
function coerceCommandSortMode(value: unknown): CommandSortMode {
return isCommandSortMode(value) ? value : DEFAULT_SETTINGS.commandSortMode
}

function coerceCommandVisibilityOverrides(value: unknown): Record<string, boolean> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
const result: Record<string, boolean> = {}
Expand Down
22 changes: 22 additions & 0 deletions src/renderer/src/app-state/settings/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { ColorFlagId } from '@renderer/app-state/settings/dispatchColorFlag
import type { DictationProvider } from '@shared/types/dictation'
import type { MouseButtonBinding, MouseChordBinding } from '@renderer/lib/mouseBinding'
import type { ConfigurableBuiltInMcpDomain } from '@mcp/shared/types'
import type { CommandSortMode } from '@renderer/features/command-palette/lib/sortCommands'

// Built-in theme ids only. 'custom' used to live here as a sentinel that
// rendered as a picker cell but acted as a button (it opened the JSON editor
Expand Down Expand Up @@ -411,6 +412,24 @@ export type Settings = {
* two lifecycles beats one map that forces the cheap one to pay the
* expensive one's cost. */
commandStarred: Record<string, boolean>
/** How the command palette orders its list while the search box is EMPTY.
*
* Scope is the whole point: this never touches search results. Typing is
* answered by relevance alone (`rankEntries`), and a sort applied on top of
* that would let 'A – Z' push a tier-5 prefix match below a tier-1
* subsequence match — the inversion class the ranking rewrite exists to
* prevent. `rankCommands` enforces the split with an early return; the
* header control shows "Relevance" and disables itself while a query is
* present so the user is told, not left guessing.
*
* WHY a persisted setting rather than per-open state: it is a scanning
* preference, not a transient one. Someone who browses by group wants to
* browse by group tomorrow too, and re-picking it on every palette open
* would make the feature not worth using.
*
* Defaults to 'catalog' — today's exact behavior — which keeps the whole
* feature additive: nothing about the palette changes until the user asks. */
commandSortMode: CommandSortMode
/** Show pointer-only affordances that a keyboard user does not need — today
* the composer's Send and Stop buttons.
*
Expand Down Expand Up @@ -530,6 +549,9 @@ export const DEFAULT_SETTINGS: Settings = {
// Nothing starred until the user stars something. The palette's resting
// order is otherwise exactly the catalog order it has always been.
commandStarred: {},
// Catalog order is what the palette has always shown, so it stays the
// default and the sort control is a pure opt-in.
commandSortMode: 'catalog',
// Off by default. The composer buttons cost ~28px of pane height per pane
// and a keyboard user gets nothing from them, so this is opt-in.
mouseModeEnabled: false,
Expand Down
90 changes: 90 additions & 0 deletions src/renderer/src/features/command-palette/lib/rankCommands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest'

import { rankCommands } from '@renderer/features/command-palette/lib/rankCommands'
import { COMMAND_SORT_MODES } from '@renderer/features/command-palette/lib/sortCommands'
import type { ResolvedCommand } from '@renderer/features/command-palette/types'

function cmd(id: string, title: string, keywords: string[] = []): ResolvedCommand {
return {
id,
title,
description: `${title} description`,
surface: 'app',
keywords,
keepPaletteOpen: false,
state: null,
run: () => {},
}
}

const titles = (commands: readonly ResolvedCommand[]): string[] =>
commands.map(command => command.title)

const NO_HISTORY = new Map<string, number>()
const NOTHING_STARRED: Record<string, boolean> = {}

describe('rankCommands — search always beats the sort mode', () => {
// THE invariant this feature had to not break. A sort mode governs the browse
// state only; the moment a query exists, relevance owns the ordering. If this
// ever fails, 'A – Z' is capable of pushing a tier-5 prefix match below a
// tier-1 subsequence match — the inversion class `rankEntries` was extracted
// to eliminate.
const commands = [
// Deliberately arranged so that alphabetical, catalog and recent orders all
// disagree with relevance: the exact-prefix match sorts LAST alphabetically
// and sits LAST in catalog order.
cmd('apple', 'Apple Something'),
cmd('banana', 'Banana Thing'),
cmd('zebra', 'Zebra Mode'),
]
const history = new Map([
['apple', 0.95],
['banana', 0.9],
])
const starred = { apple: true, banana: true }

it('puts the prefix match first under every sort mode', () => {
for (const mode of COMMAND_SORT_MODES) {
const result = rankCommands(commands, 'zebra', history, starred, mode)
expect(result.commands[0]?.title, `mode: ${mode}`).toBe('Zebra Mode')
}
})

it('is byte-identical across sort modes for any given query', () => {
// Stronger than the check above: the sort mode must have NO observable
// effect at all while searching, not merely leave the winner in place.
const baseline = rankCommands(commands, 'an', history, starred, 'catalog')
for (const mode of COMMAND_SORT_MODES) {
const result = rankCommands(commands, 'an', history, starred, mode)
expect(titles(result.commands), `mode: ${mode}`).toEqual(titles(baseline.commands))
}
})

it('never emits section headers for a search result', () => {
// Headers describe a browse structure; a relevance-ordered list has none.
const result = rankCommands(commands, 'a', NO_HISTORY, NOTHING_STARRED, 'grouped')
expect(result.commands.length).toBeGreaterThan(0)
expect(result.headers.size).toBe(0)
})
})

describe('rankCommands — browse state applies the sort mode', () => {
const commands = [cmd('c', 'Charlie'), cmd('a', 'Alpha'), cmd('b', 'Bravo')]

it('defaults to catalog order when no mode is supplied', () => {
// The parameter is optional so existing call sites (and the native-menu
// resolution path) keep today's behavior without being touched.
const result = rankCommands(commands, '', NO_HISTORY, NOTHING_STARRED)
expect(titles(result.commands)).toEqual(['Charlie', 'Alpha', 'Bravo'])
})

it('sorts alphabetically on an empty query when asked', () => {
const result = rankCommands(commands, '', NO_HISTORY, NOTHING_STARRED, 'alpha')
expect(titles(result.commands)).toEqual(['Alpha', 'Bravo', 'Charlie'])
})

it('still hoists starred commands ahead of the sort', () => {
const result = rankCommands(commands, '', NO_HISTORY, { c: true }, 'alpha')
expect(titles(result.commands)).toEqual(['Charlie', 'Alpha', 'Bravo'])
})
})
47 changes: 36 additions & 11 deletions src/renderer/src/features/command-palette/lib/rankCommands.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { ResolvedCommand } from '@renderer/features/command-palette/types'
import { primary, rankEntries, secondary } from '@renderer/features/command-palette/lib/rankEntries'
// EMPTY_HEADERS is shared rather than re-declared: search results are never
// sectioned either, and headers describe a browse structure a relevance-ordered
// list does not have.
import { browseOrder, EMPTY_HEADERS } from '@renderer/features/command-palette/lib/sortCommands'
import type { BrowseOrder, CommandSortMode } from '@renderer/features/command-palette/lib/sortCommands'

// rankCommands — the ordering function for the command-palette's command
// list specifically. The generic relevance machinery now lives in
Expand Down Expand Up @@ -28,8 +33,15 @@ import { primary, rankEntries, secondary } from '@renderer/features/command-pale
//
// Empty-query behavior (registry order, no history reordering) also lives in
// `rankEntries` now, for the same reason it did here: the palette's resting
// state must not shuffle. Starred commands are the ONE documented exception —
// see the WHY on the partition below before assuming that is a bug.
// state must not shuffle by itself. Two documented exceptions, both of which
// are the user asking for it rather than the app deciding on its own:
//
// 1. STARRED commands hoist to the top — see the WHY on the partition below.
// 2. The SORT MODE (`sortCommands.ts`) reorders the browse list on request.
//
// Neither can touch a SEARCH result. Both are applied only on the empty-query
// path, below the `query.length > 0` early return, which is what keeps "a text
// match always beats every other signal" true.

// Re-exported because the palette's other call sites import `fuzzyMatch`
// from here. Its real definition — and the warning about never running it
Expand All @@ -53,21 +65,33 @@ export function rankCommands(
query: string,
historyScore: Map<string, number>,
starred: Record<string, boolean>,
): ResolvedCommand[] {
sortMode: CommandSortMode = 'catalog',
): BrowseOrder {
const ranked = rankEntries(
commands,
query,
command => [primary(command.title), ...command.keywords.map(keyword => secondary(keyword))],
command => (starred[command.id] ? STAR_WEIGHT : 0) + (historyScore.get(command.id) ?? 0),
)

if (query.length > 0) return ranked
// THE INVARIANT, and the reason `sortMode` is not consulted anywhere above:
// a non-empty query is answered by relevance alone. The sort mode governs the
// BROWSE state only.
//
// This is not a limitation waiting to be lifted. Applying a sort to search
// results would let 'A – Z' place a tier-1 subsequence match above the tier-5
// prefix match the user typed in full — the exact inversion class that
// `rankEntries` was extracted to eliminate (see the plan doc referenced in
// its header). The control in the header shows "Relevance" and disables
// itself while a query is present, so this is legible to the user rather than
// looking like the setting stopped working.
if (query.length > 0) return { commands: ranked, headers: EMPTY_HEADERS }

// Empty query needs its own handling, because `rankEntries` short-circuits
// and returns the input verbatim WITHOUT sorting — so the extraTiebreak
// above is dead here.
//
// WHY the partition lives in this adapter and not in `rankEntries`: five
// WHY the star partition lives in this adapter and not in `rankEntries`: five
// lists share that short-circuit (commands, prompt templates, sessions,
// buried tabs, AI workspaces). Editing it would silently reshuffle four
// resting orders nobody asked to change.
Expand All @@ -80,10 +104,11 @@ export function rankCommands(
// the list, so the top row moving is precisely what they asked for. DO NOT
// "fix" this back to match the sibling rule without reading this paragraph.
//
// Two filter passes rather than a sort: this is a STABLE partition, so
// catalog registration order is preserved within each half — and that order
// is a declared user-visible invariant (catalog.ts).
const starredRows = ranked.filter(command => starred[command.id])
if (starredRows.length === 0) return ranked
return [...starredRows, ...ranked.filter(command => !starred[command.id])]
// The partition itself now lives in `browseOrder`, which owns every
// browse-state ordering decision (star split, the four sort modes, and the
// section headers that must stay in lockstep with the flat order). It still
// preserves catalog registration order within each half when the mode is
// 'catalog' — that order is a declared user-visible invariant (catalog.ts)
// and remains the default.
return browseOrder(ranked, sortMode, historyScore, starred)
}
Loading