Fix cmd+j search ranking to require coverage of meaningful query words

Extract tokenization logic into a shared module to ensure consistent
ranking across settings and project search bands. Enhance ranking with:
- Coverage requirement: candidates must match most meaningful words, not
  just one (fixes "linear triage" matching all projects on "linear" alone)
- Filler words: ignore navigation words like "open", "go", "the" when
  measuring coverage
- Unicode support: split on Unicode word boundaries, not ASCII only
This commit is contained in:
Jinjing
2026-08-06 15:27:55 -07:00
parent f71373953b
commit d0ba0bd829
4 changed files with 209 additions and 144 deletions
@@ -1,4 +1,9 @@
import { isCmdJPaletteQueryTooLarge } from './palette-results'
import {
cmdJPaletteTokenScore,
normalizeCmdJPaletteQuery,
uniqueNormalizedCmdJPaletteKeywords
} from './palette-query-tokens'
import type { Project, ProjectGroup, ProjectHostSetup, Repo } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
import {
@@ -39,73 +44,6 @@ type RankedProjectResult = {
const PROJECT_GROUP_ALIASES = ['group', 'repo group']
const PROJECT_ALIASES = ['project', 'repo']
function normalizeQuery(value: string): string {
let normalized = ''
let pendingWhitespace = false
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (isCmdJPaletteWhitespace(code)) {
pendingWhitespace = normalized.length > 0
continue
}
if (pendingWhitespace) {
normalized += ' '
pendingWhitespace = false
}
normalized += value.charAt(index).toLowerCase()
}
return normalized
}
function isCmdJPaletteWhitespace(code: number): boolean {
return (
code === 32 ||
(code >= 9 && code <= 13) ||
code === 160 ||
code === 5760 ||
(code >= 8192 && code <= 8202) ||
code === 8232 ||
code === 8233 ||
code === 8239 ||
code === 8287 ||
code === 12288 ||
code === 65279
)
}
function uniqueNormalized(values: readonly string[]): string[] {
return [...new Set(values.map(normalizeQuery).filter(Boolean))]
}
function tokenize(value: string): string[] {
return normalizeQuery(value)
.split(/[^a-z0-9]+/)
.filter(Boolean)
}
function tokenScore(query: string, values: readonly string[]): number {
const candidateTokens = values.flatMap(tokenize)
if (candidateTokens.length === 0) {
return 0
}
let score = 0
for (const queryToken of tokenize(query)) {
let best = 0
for (const candidateToken of candidateTokens) {
if (candidateToken === queryToken) {
best = Math.max(best, 3)
} else if (candidateToken.startsWith(queryToken)) {
best = Math.max(best, 2)
} else if (candidateToken.includes(queryToken)) {
best = Math.max(best, 1)
}
}
score += best
}
return score
}
function buildCmdJProjectSearchCandidates({
projectGroups,
repos,
@@ -134,7 +72,7 @@ function buildCmdJProjectSearchCandidates({
),
rowKey: getProjectGroupHeaderKey(group.id),
order,
keywords: uniqueNormalized([group.name, ...PROJECT_GROUP_ALIASES])
keywords: uniqueNormalizedCmdJPaletteKeywords([group.name, ...PROJECT_GROUP_ALIASES])
})
})
@@ -156,7 +94,11 @@ function buildCmdJProjectSearchCandidates({
rowKey: target.key,
repo: target.repo,
order: projectGroups.length + repoIndex,
keywords: uniqueNormalized([target.label, repo.displayName, ...PROJECT_ALIASES])
keywords: uniqueNormalizedCmdJPaletteKeywords([
target.label,
repo.displayName,
...PROJECT_ALIASES
])
})
})
@@ -191,7 +133,7 @@ function projectRankingForCandidate(
query: string,
candidate: CmdJProjectSearchResult
): RankedProjectResult | null {
const title = normalizeQuery(candidate.title)
const title = normalizeCmdJPaletteQuery(candidate.title)
if (query === title) {
return { result: candidate, rule: 1, score: 0 }
}
@@ -199,13 +141,13 @@ function projectRankingForCandidate(
return { result: candidate, rule: 2, score: 0 }
}
const aliasKeywords = candidate.kind === 'project-group' ? PROJECT_GROUP_ALIASES : PROJECT_ALIASES
if (aliasKeywords.map(normalizeQuery).includes(query)) {
if (aliasKeywords.map(normalizeCmdJPaletteQuery).includes(query)) {
return { result: candidate, rule: 3, score: 0 }
}
if (candidate.keywords.some((keyword) => keyword.startsWith(query))) {
return { result: candidate, rule: 4, score: 0 }
}
const score = tokenScore(query, [candidate.title, ...candidate.keywords])
const score = cmdJPaletteTokenScore(query, [candidate.title, ...candidate.keywords])
return score > 0 ? { result: candidate, rule: 5, score } : null
}
@@ -242,7 +184,7 @@ export function searchCmdJProjectResults({
if (isCmdJPaletteQueryTooLarge(query)) {
return []
}
const normalizedQuery = normalizeQuery(query)
const normalizedQuery = normalizeCmdJPaletteQuery(query)
// Why: project/group rows sit after worktree matches, so one-character
// searches would add broad noisy navigation targets before intent is clear.
if (normalizedQuery.length < 2) {
@@ -0,0 +1,115 @@
// Why: the middle band and the project band scored queries with byte-identical copies of
// this logic, so ranking fixes only ever landed in one of the two.
function isCmdJPaletteWhitespace(code: number): boolean {
return (
code === 32 ||
(code >= 9 && code <= 13) ||
code === 160 ||
code === 5760 ||
(code >= 8192 && code <= 8202) ||
code === 8232 ||
code === 8233 ||
code === 8239 ||
code === 8287 ||
code === 12288 ||
code === 65279
)
}
export function normalizeCmdJPaletteQuery(value: string): string {
let normalized = ''
let pendingWhitespace = false
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (isCmdJPaletteWhitespace(code)) {
pendingWhitespace = normalized.length > 0
continue
}
if (pendingWhitespace) {
normalized += ' '
pendingWhitespace = false
}
normalized += value.charAt(index).toLowerCase()
}
return normalized
}
export function uniqueNormalizedCmdJPaletteKeywords(values: readonly string[]): string[] {
return [...new Set(values.map(normalizeCmdJPaletteQuery).filter(Boolean))]
}
// Why: splitting on non-ASCII would drop CJK and accented words entirely, so a localized
// query would silently skip the coverage rule below instead of failing it.
export function tokenizeCmdJPaletteQuery(value: string): string[] {
return normalizeCmdJPaletteQuery(value)
.split(/[^\p{L}\p{N}]+/u)
.filter(Boolean)
}
// Why: navigation filler carries no intent ("open ssh settings" means "ssh settings"), so an
// unmatched filler word must not count against a candidate's coverage.
const CMD_J_QUERY_FILLER_TOKENS = new Set([
'a',
'an',
'and',
'change',
'edit',
'for',
'from',
'go',
'goto',
'in',
'into',
'jump',
'me',
'my',
'of',
'on',
'open',
'please',
'set',
'show',
'the',
'to',
'view',
'with'
])
export function cmdJPaletteTokenScore(query: string, values: readonly string[]): number {
const candidateTokens = values.flatMap(tokenizeCmdJPaletteQuery)
if (candidateTokens.length === 0) {
return 0
}
let score = 0
let meaningful = 0
let covered = 0
for (const queryToken of tokenizeCmdJPaletteQuery(query)) {
let best = 0
for (const candidateToken of candidateTokens) {
if (candidateToken === queryToken) {
best = Math.max(best, 3)
} else if (candidateToken.startsWith(queryToken)) {
best = Math.max(best, 2)
} else if (candidateToken.includes(queryToken)) {
best = Math.max(best, 1)
}
}
score += best
if (CMD_J_QUERY_FILLER_TOKENS.has(queryToken)) {
continue
}
meaningful += 1
if (best > 0) {
covered += 1
}
}
// Why: "linear triage" matched the Linear pane as strongly as "linear" did, so a candidate
// now has to cover most of what was typed, not just one word of it.
if (meaningful > 0 && covered * 2 <= meaningful) {
return 0
}
return score
}
@@ -208,6 +208,59 @@ describe('Cmd+J palette middle-band ranking', () => {
})
})
it('drops candidates that cover a minority of the words typed', () => {
// Why: "linear triage" used to surface the Linear and Integrations panes on the
// "linear" token alone, with the unmatched word costing nothing. See screenshot report.
const integrationSections: SettingsNavSection[] = [
{
id: 'linear',
title: 'Linear',
description: 'How Linear works in Orca.',
icon: Settings,
searchEntries: [],
group: 'capabilities'
},
{
id: 'integrations',
title: 'Integrations',
description: 'Connect GitHub, GitLab, and Linear.',
icon: Settings,
searchEntries: [],
group: 'setup'
}
]
const rank = (query: string): string[] =>
rankCmdJMiddleResults({
query,
settingsResults: buildCmdJSettingsResults(integrationSections),
actionResults: []
}).map((result) => result.id)
expect(rank('linear triage')).toEqual([])
expect(rank('linear')).toEqual(['settings:linear', 'settings:integrations'])
// Why: a majority still counts, so one stray word cannot blank an otherwise good match.
expect(rank('linear integrations triage')).toEqual(['settings:integrations'])
})
it('keeps out-of-order partial-word matches when every query word lands somewhere', () => {
expect(top('font term')).toBe('settings:terminal')
expect(top('font sparkles')).toBeUndefined()
})
it('ignores navigation filler words when measuring coverage', () => {
// Why: "open"/"go to" carry no intent, so they must not read as unmatched words
// and blank the band mid-query.
expect(top('open terminal settings')).toBe('settings:terminal')
expect(top('go to ssh settings')).toBe('settings:ssh')
expect(top('change the terminal font')).toBe('settings:terminal')
})
it('counts non-latin query words instead of discarding them', () => {
// Why: tokenizing on ASCII only let a localized query skip the coverage rule entirely.
expect(top('ssh 主机 设置')).toBeUndefined()
expect(top('ssh 设置')).toBeUndefined()
})
it('does not match settings on one-character or description-only queries', () => {
expect(top('t')).toBeUndefined()
expect(top('cookie import')).toBeUndefined()
@@ -316,6 +369,23 @@ function projectGroup(id: string, name: string, parentGroupId: string | null = n
}
describe('Cmd+J project and repo-group search', () => {
it('drops projects that only match the query through a generic alias word', () => {
// Why: every project carries the 'repo'/'project' aliases, so "repo triage" used to
// match all of them on that word alone — the same bug the middle band had.
const search = (query: string): string[] =>
searchCmdJProjectResults({
query,
projectGroups: [],
repos: [repo('repo-1', 'linear-sync'), repo('repo-2', 'billing')],
projects: [],
projectHostSetups: []
}).map((result) => result.title)
expect(search('repo triage')).toEqual([])
expect(search('linear triage')).toEqual([])
expect(search('linear repo')).toEqual(['linear-sync'])
})
it('finds a Project Group by name', () => {
const [result] = searchCmdJProjectResults({
query: 'infra',
@@ -1,6 +1,11 @@
import type { SettingsNavIcon, SettingsNavSection } from '@/lib/settings-navigation-types'
import type { CmdJQuickAction } from './quick-actions'
import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text'
import {
cmdJPaletteTokenScore,
normalizeCmdJPaletteQuery,
uniqueNormalizedCmdJPaletteKeywords
} from './palette-query-tokens'
export type CmdJSettingsResult = {
id: string
@@ -53,40 +58,6 @@ export function isCmdJPaletteQueryTooLarge(
return isClipboardTextByteLengthOverLimit(query, maxBytes)
}
function normalizeQuery(value: string): string {
let normalized = ''
let pendingWhitespace = false
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (isCmdJPaletteWhitespace(code)) {
pendingWhitespace = normalized.length > 0
continue
}
if (pendingWhitespace) {
normalized += ' '
pendingWhitespace = false
}
normalized += value.charAt(index).toLowerCase()
}
return normalized
}
function isCmdJPaletteWhitespace(code: number): boolean {
return (
code === 32 ||
(code >= 9 && code <= 13) ||
code === 160 ||
code === 5760 ||
(code >= 8192 && code <= 8202) ||
code === 8232 ||
code === 8233 ||
code === 8239 ||
code === 8287 ||
code === 12288 ||
code === 65279
)
}
function keywordParts(section: SettingsNavSection): string[] {
const baseId = section.id.startsWith('repo-') ? 'repo' : section.id
const idWords = baseId.replace(/-/g, ' ')
@@ -107,10 +78,6 @@ function targetEntryKeywordParts(entryTitle: string): string[] {
return [entryTitle, `${entryTitle} settings`]
}
function uniqueNormalized(values: readonly string[]): string[] {
return [...new Set(values.map(normalizeQuery).filter(Boolean))]
}
export function buildCmdJSettingsResults(
sections: readonly SettingsNavSection[]
): CmdJSettingsResult[] {
@@ -123,7 +90,7 @@ export function buildCmdJSettingsResults(
icon: section.icon,
sectionId: section.id,
order,
configKeywords: uniqueNormalized(keywordParts(section))
configKeywords: uniqueNormalizedCmdJPaletteKeywords(keywordParts(section))
}
const targetedResults = section.searchEntries
.filter((entry) => entry.targetSectionId)
@@ -136,7 +103,7 @@ export function buildCmdJSettingsResults(
sectionId: section.id,
targetSectionId: entry.targetSectionId,
order: order + (entryIndex + 1) / 100,
configKeywords: uniqueNormalized([
configKeywords: uniqueNormalizedCmdJPaletteKeywords([
...targetEntryKeywordParts(entry.title),
...(entry.cmdJKeywords ?? entry.keywords ?? [])
])
@@ -154,35 +121,6 @@ function startsOrIsStartedBy(query: string, keyword: string): boolean {
return keyword.startsWith(query) || query.startsWith(keyword)
}
function tokenize(value: string): string[] {
return normalizeQuery(value)
.split(/[^a-z0-9]+/)
.filter(Boolean)
}
function tokenScore(query: string, values: readonly string[]): number {
const candidateTokens = values.flatMap(tokenize)
if (candidateTokens.length === 0) {
return 0
}
let score = 0
for (const queryToken of tokenize(query)) {
let best = 0
for (const candidateToken of candidateTokens) {
if (candidateToken === queryToken) {
best = Math.max(best, 3)
} else if (candidateToken.startsWith(queryToken)) {
best = Math.max(best, 2)
} else if (candidateToken.includes(queryToken)) {
best = Math.max(best, 1)
}
}
score += best
}
return score
}
function rankingForCandidate(
query: string,
candidate: CmdJMiddleResult,
@@ -231,7 +169,7 @@ function rankingForCandidate(
candidate.kind === 'settings'
? [candidate.title, ...candidate.configKeywords]
: [candidate.title, ...candidate.verbKeywords]
const score = tokenScore(query, values)
const score = cmdJPaletteTokenScore(query, values)
return score > 0 ? { result: candidate, rule: 6, score } : null
}
@@ -263,7 +201,7 @@ export function rankCmdJMiddleResults({
if (isCmdJPaletteQueryTooLarge(query)) {
return []
}
const normalizedQuery = normalizeQuery(query)
const normalizedQuery = normalizeCmdJPaletteQuery(query)
if (normalizedQuery.length < 2) {
return []
}