Storing the Selection as an Index Is Why Your Command Palette Sometimes Runs the Wrong Thing

2026-11-03来源 dev.to UXEnglish

Every Cmd-K palette has the same two halves. The half you see is a list that filters as you type. The half that decides whether it feels right is the ranking. And the half that decides whether it is safe is what happens to the selection when the list changes underneath it.

👉 Live, type into it: https://dev48.in/design/day82-raycast-palette.html

The bug

Type c, press Down twice, keep typing. Here is what happens if the selection is stored as an index:

state position selected command
before, query c row 4 Search Files
after, query co — follow the id row 0 Copy Deep Link
if it had kept the index row 4 Rename Symbol

Row 4 of the old list is a different command from row 4 of the new one. The user looked at Search Files, typed one more character, pressed Enter, and ran Rename Symbol.

The fix is one line — store the id, and fall back to the top only when that id is gone:

case "query": { const next = rank(items, action.value, state.recents); const keep = next.findIndex(i => i.id === state.selectedId); return { ...state, query: action.value, selectedId: next.length ? (keep >= 0 ? state.selectedId : next[0].id) : null }; } 

Matching is a DP, not a scan

Palettes match subsequences — gp should find Git: Push. The obvious implementation walks left to right taking the first character that matches. That finds a match, not the best-scoring one, and the difference shows up exactly on the queries people type: short initialisms over multi-word commands.

So the match is chosen by score, over all embeddings:

function charScore(t, i, last) { let s = 1; if (i === last + 1) s += 5; // adjacent to the previous match if (i === 0) s += 8; // very start of the string else { const p = t[i - 1]; if (p === " " || p === "-" || p === "_" || p === "/") s += 7; // word start } s -= Math.min(i, 10) * 0.08; // deeper is worth less return s; } 

Those weights are the design. Seven points for following a space is what makes initialisms rank above incidental letter hits.

Ties must break on something stable

scored.sort((a, b) => (b.score - a.score) || (a.item.name.length - b.item.name.length) || (a.item.name < b.item.name ? -1 : 1)); 

Not array order. If two commands tie and the tiebreak is "whichever the data happened to arrive in first", the list reorders itself for reasons the user cannot see — and the row under their finger moves. Reverse the input array and the output must be identical.

A test whose premise was wrong

I wrote this and it failed:

t("arrow keys wrap at both ends", () => { st = reduce(st, { type: "query", value: "git" }, C); const list = rank(C, "git", st.recents).map(s => s.item); const up = reduce(st, { type: "up" }, C); return up.selectedId === list[list.length - 1].id; // assumed selection is at row 0 }); 

The wrap was fine. The assumption was wrong: Git: Push was already selected and it survives the query git, so the id rule correctly left it at row 1 rather than yanking it to the top. Up from row 1 goes to row 0, not to the end.

That failure was the id rule working. I rewrote the test to measure from wherever the selection actually is, and added a separate one pinned at the boundary.

Checked by brute force

The scoring DP is verified against an exhaustive search over every possible embedding of the query — exponential, usable only on short queries, which is exactly the case that matters. If the DP ever disagrees with the exhaustive maximum, the DP is wrong.

The selection rule is checked over 720 randomised keystrokes (typing and deleting), asserting at every step that the selection is a command currently visible, or null when nothing matches.

998 verifier asserts, 0 failures. 24 in-page checks.

原文:https://www.a8q9.cn/en/article/storing-the-selection-as-an-index-is-why-your-command-palette-sometimes-runs-the-343066.html

相关文章