Przeglądaj źródła

feat(web): rank skill candidates with the shared fuzzy name ranker

Yichen Jiang 6 dni temu
rodzic
commit
46d20f8bee

+ 2 - 2
.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md
-2026-08-04-web-slash-command-fuzzy-discovery.md: 8d7fe88f8d19a6edc7b51e63578c468df085c238
-2026-08-04-web-slash-command-fuzzy-discovery.zh.md: a96f9c984e32dd777950b9f9d8594b3a9c8b7c17
+2026-08-04-web-slash-command-fuzzy-discovery.md: 17ba1a1cba4876140bada74e65ba1b10ed5fdd76
+2026-08-04-web-slash-command-fuzzy-discovery.zh.md: fe23dcb5d3c14a492a5882b5bad4c84e256ef064

+ 6 - 4
.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md

@@ -6,13 +6,13 @@ English | [中文](2026-08-04-web-slash-command-fuzzy-discovery.zh.md)
 
 ## Problem
 
-The web command menu required a command-name prefix, so discovery failed when a user remembered the significant letters but not their exact positions. Broadening menu matching could make discovery easier, but command execution must remain exact and deterministic: an approximate line must never execute a nearby command.
+The web command menu required a command-name prefix, so discovery failed when a user remembered the significant letters but not their exact positions. The skill source of the same `/` menu later kept a case-sensitive prefix filter, so the two groups of one menu answered the same keystrokes differently. Broadening menu matching could make discovery easier, but command execution must remain exact and deterministic: an approximate line must never execute a nearby command.
 
 ## Decision
 
-The `/` command source fuzzy-matches the typed query against command names as a case-insensitive ordered subsequence. Exact prefixes form the highest ranking class. Within each class, the strongest alignment score rewards separator boundaries and adjacent characters while penalizing leading characters and gaps; equal scores retain the host-directory and client-contribution order. Position filtering still removes argument-taking commands from inline menus before ranking.
+The `/` menu's command and skill sources fuzzy-match the typed query against candidate names as a case-insensitive ordered subsequence through one ranker, `rankByName` in ui-primitives, the narrow static owner for shared browser code ([client rules](../../../../packages/client/AGENTS.md)). Exact prefixes form the highest ranking class. Within each class, the strongest alignment score rewards separator boundaries and adjacent characters while penalizing leading characters and gaps; equal scores retain the host catalog and client-contribution order. Position filtering still removes argument-taking commands from inline menus before ranking.
 
-The scorer uses dynamic programming in `O(query length × name length)` time and `O(name length)` memory per candidate. Candidate scoring stays client-side and examines names only; descriptions do not affect matching. Menu selection still dispatches the selected exact name, while space and Enter adjudication continue to require an exact command token.
+The scorer uses dynamic programming in `O(query length × name length)` time and `O(name length)` memory per candidate. Candidate scoring stays client-side and examines names only; descriptions do not affect matching. Menu selection still dispatches the selected exact name, the skill source still lands the literal `/name ` text the host resolves exactly, and space and Enter adjudication continue to require an exact command token.
 
 ## Alternatives considered
 
@@ -22,6 +22,8 @@ The scorer uses dynamic programming in `O(query length × name length)` time and
 
 **Use a general fuzzy-search dependency.** Rejected because this surface needs one constrained subsequence rule over a small command catalog; a configurable search index would add bundle weight and ranking behavior not used by the product.
 
+**Export the ranker from the command plugin or the trigger pipeline.** Rejected because a feature plugin exports no values beyond what cordis loading needs and never runtime-imports another feature plugin; peer agent products that share one matcher between commands and skills (Claude Code, Pi, Kimi Code) keep it in a shared library for the same reason.
+
 ## Consequences
 
-Users can discover a command from remembered in-order letters, and ranking remains stable across identical catalogs. The score is deliberately heuristic: a separator-aligned match can outrank a match with a shorter raw span. Package tests pin each ranking factor and stable ties, while the assembled Web replay snapshot pins `/cpt` resolving to `/compact`. Exact execution semantics are unchanged.
+Users can discover a command or a skill from remembered in-order letters, and ranking remains stable across identical catalogs. The score is deliberately heuristic: a separator-aligned match can outrank a match with a shorter raw span. ui-primitives tests pin each ranking factor and stable ties, the command and skill sources pin that they rank through the shared ranker, and the assembled Web goldens pin `/cpt` resolving to `/compact` and a subsequence query resolving to one skill. Exact execution semantics are unchanged.

+ 6 - 4
.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md

@@ -6,13 +6,13 @@ Status: implemented
 
 ## 问题
 
-Web 命令菜单要求按命令名前缀匹配,因此用户只记得关键字母却不记得其准确位置时,就无法发现命令。扩大菜单的匹配范围可使命令更易发现,但命令执行仍必须保持精确匹配和确定性:近似输入行绝不能执行相近命令。
+Web 命令菜单要求按命令名前缀匹配,因此用户只记得关键字母却不记得其准确位置时,就无法发现命令。同一 `/` 菜单的 skill source 后来仍保留区分大小写的前缀过滤,同一菜单的两个分组对同样的按键给出不同答案。扩大菜单的匹配范围可使命令更易发现,但命令执行仍必须保持精确匹配和确定性:近似输入行绝不能执行相近命令。
 
 ## 决策
 
-`/` 命令 source 将键入的查询作为不区分大小写的有序子序列,与命令名进行模糊匹配。精确前缀构成排名最高的一类匹配。在每类匹配中,对齐分数越高越优先:分隔符边界和相邻字符会提高分数,前导字符和间隔会降低分数;分数相同则保持 host 目录和 client contribution 的顺序。位置过滤仍会在排名前从行内菜单中移除接收参数的命令。
+`/` 菜单的命令 source 与 skill source 将键入的查询作为不区分大小写的有序子序列,与候选名进行模糊匹配,二者共用一个排序器:ui-primitives 中的 `rankByName`,即共享浏览器代码的窄静态归属方([client 规则](../../../../packages/client/AGENTS.md))。精确前缀构成排名最高的一类匹配。在每类匹配中,对齐分数越高越优先:分隔符边界和相邻字符会提高分数,前导字符和间隔会降低分数;分数相同则保持 host 目录和 client contribution 的顺序。位置过滤仍会在排名前从行内菜单中移除接收参数的命令。
 
-评分器对每个候选项使用动态规划,时间复杂度为 `O(query length × name length)`,空间复杂度为 `O(name length)`。候选项评分只在客户端进行且只检查命令名;命令描述不影响匹配。菜单选择仍派发所选的精确名称,而空格键和 Enter 键的判定逻辑仍要求命令 token 精确匹配。
+评分器对每个候选项使用动态规划,时间复杂度为 `O(query length × name length)`,空间复杂度为 `O(name length)`。候选项评分只在客户端进行且只检查命令名;命令描述不影响匹配。菜单选择仍派发所选的精确名称,skill source 仍落下由宿主精确解析的字面 `/name ` 文本,而空格键和 Enter 键的判定逻辑仍要求命令 token 精确匹配。
 
 ## 考虑过的替代方案
 
@@ -22,6 +22,8 @@ Web 命令菜单要求按命令名前缀匹配,因此用户只记得关键字
 
 **使用通用模糊搜索依赖。** 否决,因为该界面只需对小型命令目录使用一种受限的子序列规则;可配置搜索索引会增加 bundle 体积,并引入产品未使用的排名行为。
 
+**从命令插件或触发管线导出排序器。** 否决,因为特性插件除 cordis 加载所需之外不导出任何值,也绝不运行时导入另一个特性插件;在命令与 skill 之间共用一个匹配器的同行产品(Claude Code、Pi、Kimi Code)出于同样的原因把它放在共享库中。
+
 ## 后果
 
-用户可以凭按顺序记得的字母发现命令;只要目录相同,排名就保持稳定。评分刻意采用启发式规则:与分隔符对齐的匹配可能排在原始跨度更短的匹配之前。包测试固定各项排名因素以及同分时的稳定顺序,组装后的 Web 回放快照固定 `/cpt` 解析为 `/compact` 的行为。精确执行语义保持不变。
+用户可以凭按顺序记得的字母发现命令或 skill;只要目录相同,排名就保持稳定。评分刻意采用启发式规则:与分隔符对齐的匹配可能排在原始跨度更短的匹配之前。ui-primitives 的测试固定各项排名因素以及同分时的稳定顺序,命令 source 与 skill source 的测试固定二者经共享排序器排名,组装后的 Web golden 固定 `/cpt` 解析为 `/compact`、以及一个子序列查询解析为唯一 skill 的行为。精确执行语义保持不变。

+ 3 - 0
apps/web/tests/expected/skill-invocation-policy/menu-fuzzy.expected.md

@@ -0,0 +1,3 @@
+- listbox "Trigger suggestions":
+  - text: Skills
+  - option "policy-user-only user-only · Available only to user invocation" [selected]

+ 11 - 2
apps/web/tests/skill-invocation-policy.e2e.ts

@@ -18,10 +18,11 @@ import {
   webSnapshotMode,
   type WebScaffold,
 } from './scaffold.ts'
-import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/skill-invocation-policy', import.meta.url))
 const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md')
+const FUZZY_MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu-fuzzy.expected.md')
 const MODE = webSnapshotMode()
 
 interface SeedSkill {
@@ -111,8 +112,16 @@ describe('web e2e: skill invocation policy through the real host', () => {
 
     const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE)
+
+    // Discovery needs no prefix: an in-order subsequence of one skill name
+    // ranks that skill alone, through the ranker the command group uses.
+    await writeComposerDraft(page, input, '/plcyusr')
+    await expect.poll(() => menu.getByRole('option').count(), { timeout: 10_000 }).toBe(1)
+    expect(await menu.getByRole('option', { name: /policy-user-only/ }).count()).toBe(1)
+    const fuzzySnapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(FUZZY_MENU_EXPECTED, fuzzySnapshot, MODE)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
-    await assertFixtureInventory(SNAPSHOT_DIR, ['menu.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['menu-fuzzy.expected.md', 'menu.expected.md'])
   })
 })

+ 6 - 67
packages/client/ui-commands/src/client/service.ts

@@ -2,8 +2,9 @@
  * CommandUiRuntime (`ctx.commandUi`): the '/' command source over the
  * session-keyed directory, the client-contribution registry, and the
  * per-session popupSelect controllers. Candidate synthesis merges the host
- * catalog with contributions by availability, then fuzzy query/position
- * filtering; a host/contribution name collision fails loud. Every execute
+ * catalog with contributions by availability, then position filtering and
+ * the `/` menu's shared name ranking (ui-primitives `rankByName`); a
+ * host/contribution name collision fails loud. Every execute
  * addresses the session's agent by sessionId — sessions are always
  * agent-backed.
  */
@@ -17,6 +18,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
+import { rankByName } from '@deepseek-ai/dsh-client-ui-primitives'
 import type {
   CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, InputTriggerCandidate, InputTriggerPick,
   SubmitEnvelope, SubmitImageAttachment, SubmitOutcome,
@@ -56,69 +58,6 @@ interface LiveState {
   readonly popups: Map<SessionId, PopupSelectController<ClientSessionContext>>
 }
 
-/** One fuzzy match with its stable source position. */
-interface RankedCandidate {
-  readonly candidate: InputTriggerCandidate
-  readonly index: number
-  readonly prefix: boolean
-  readonly score: number
-}
-
-/** Extra weight for command-name starts and separator boundaries. */
-function boundaryBonus(name: string, index: number): number {
-  return index === 0 || name.charAt(index - 1) === '-' || name.charAt(index - 1) === '_' ? 8 : 0
-}
-
-/**
- * Score the strongest ordered-subsequence alignment in O(name × query).
- * Boundary and adjacent matches earn weight; skipped and leading characters
- * cost weight.
- */
-function fuzzyScore(name: string, query: string): number | undefined {
-  if (query === '') return 0
-  if (query.length > name.length) return undefined
-  const noMatch = Number.NEGATIVE_INFINITY
-  let previous = Array<number>(name.length).fill(noMatch)
-  for (let index = 0; index < name.length; index++) {
-    if (name.charAt(index) === query.charAt(0)) previous[index] = 1 + boundaryBonus(name, index) - index
-  }
-  for (let queryIndex = 1; queryIndex < query.length; queryIndex++) {
-    const current = Array<number>(name.length).fill(noMatch)
-    let bestGapped = noMatch
-    for (let index = 0; index < name.length; index++) {
-      const gappedIndex = index - 2
-      if (gappedIndex >= 0) {
-        const prior = previous[gappedIndex] ?? noMatch
-        if (prior !== noMatch) bestGapped = Math.max(bestGapped, prior + gappedIndex)
-      }
-      if (name.charAt(index) !== query.charAt(queryIndex)) continue
-      const bonus = 1 + boundaryBonus(name, index)
-      const adjacent = index > 0 ? previous[index - 1] ?? noMatch : noMatch
-      if (adjacent !== noMatch) current[index] = adjacent + bonus + 4
-      if (bestGapped !== noMatch) current[index] = Math.max(current[index] ?? noMatch, bestGapped + bonus + 1 - index)
-    }
-    previous = current
-  }
-  let best = noMatch
-  for (const score of previous) best = Math.max(best, score)
-  return best === noMatch ? undefined : best
-}
-
-/** Case-insensitive fuzzy filtering with stable ordering for equal matches. */
-function fuzzyCandidates(candidates: readonly InputTriggerCandidate[], rawQuery: string): readonly InputTriggerCandidate[] {
-  const query = rawQuery.toLowerCase()
-  if (query === '') return candidates
-  const ranked: RankedCandidate[] = []
-  candidates.forEach((candidate, index) => {
-    const name = candidate.name.toLowerCase()
-    const score = fuzzyScore(name, query)
-    if (score !== undefined) ranked.push({ candidate, index, prefix: name.startsWith(query), score })
-  })
-  ranked.sort((left, right) =>
-    Number(right.prefix) - Number(left.prefix) || right.score - left.score || left.index - right.index)
-  return ranked.map(match => match.candidate)
-}
-
 /** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */
 export class CommandUiRuntime extends Service implements CommandUiContract {
   static inject = ['inputTriggers', 'sessions', 'remote', 'remote.commands']
@@ -246,7 +185,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
     }
   }
 
-  /** Menu candidates: host catalog + contribution availability, then position filtering and fuzzy name ranking. */
+  /** Menu candidates: host catalog + contribution availability, then position filtering and the shared name ranking. */
   private async candidates(session: ClientSessionContext, req: CandidateRequest): Promise<readonly InputTriggerCandidate[]> {
     const list = await this.directory.ensureReady(session.sessionId, req.signal)
     const rows: InputTriggerCandidate[] = []
@@ -262,7 +201,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
       }
       rows.push({ name: contribution.name, description: contribution.description })
     }
-    return fuzzyCandidates(
+    return rankByName(
       rows.filter(c => req.position === 'leading' || c.hint === undefined),
       req.query,
     )

+ 4 - 13
packages/client/ui-commands/tests/service.client.spec.ts

@@ -209,24 +209,15 @@ describe('candidates', () => {
     expect(list).toEqual([{ name: 'goal', description: 'leadingInput kind', hint: 'goal text' }])
   })
 
-  it('matches case-insensitive subsequences and ranks prefixes, boundaries, adjacency, gaps, then source order', async () => {
+  it('ranks rows through the shared name ranker: prefixes first, then alignment, then source order', async () => {
     const commands: CommandDescriptor[] = [
-      { name: 'q-xylophone', description: '' },
-      { name: 'qx-long', description: '' },
-      { name: 'fabulous', description: '' },
-      { name: 'foo-bar', description: '' },
-      { name: 'zuv', description: '' },
-      { name: 'zu1v', description: '' },
-      { name: 'yu1v', description: '' },
-      { name: 'zu12v', description: '' },
+      { name: 'z_a_b', description: '' },
+      { name: 'abc', description: '' },
     ]
     const { source } = await bench({ commands: () => Promise.resolve({ commands }) })
     const names = async (query: string) => (await source.candidates(proj('s1'), req(query))).map(c => c.name)
-    await expect(names('QX')).resolves.toEqual(['qx-long', 'q-xylophone'])
-    await expect(names('fb')).resolves.toEqual(['foo-bar', 'fabulous'])
-    await expect(names('uv')).resolves.toEqual(['zuv', 'zu1v', 'yu1v', 'zu12v'])
+    await expect(names('AB')).resolves.toEqual(['abc', 'z_a_b'])
     await expect(names('zzz')).resolves.toEqual([])
-    await expect(names('query-longer-than-every-name')).resolves.toEqual([])
   })
 
   it('catalogs are per session: another session pulls its own key', async () => {

+ 2 - 2
packages/client/ui-primitives/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: 78cb6caec665687feb1c18c65e1afe47c6e01ea1
-README.zh.md: 1450de83f88e4e33be4616ebd1f70588dd19e8d2
+README.md: c280f0b179b115f417514b40b21179eaac77ad79
+README.zh.md: e9cea4b31bdbefaa9cc076d5b6866d4e870a70d9

+ 1 - 1
packages/client/ui-primitives/README.md

@@ -29,7 +29,7 @@ Compose feature UI from these atoms whenever the web client needs a standard con
 
 ### Controls and icons
 
-`Button`, `Pill`, `Input`, `Menu`, `Modal`, `Tooltip`, `DisclosureRow`, `StateDot`, `HoverCard`, `Toast`, `ConnectionIndicator`, `RiskConfirmation`, and the `OnboardingSurface` first-run takeover cover the common interaction shapes. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree.
+`Button`, `Pill`, `Input`, `Menu`, `Modal`, `Tooltip`, `DisclosureRow`, `StateDot`, `HoverCard`, `Toast`, `ConnectionIndicator`, `RiskConfirmation`, and the `OnboardingSurface` first-run takeover cover the common interaction shapes. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order ([ranking decision](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)).
 
 ### Rendering agent output
 

+ 1 - 1
packages/client/ui-primitives/README.zh.md

@@ -29,7 +29,7 @@ kind: "package-library"
 
 ### 控件与图标
 
-`Button`、`Pill`、`Input`、`Menu`、`Modal`、`Tooltip`、`DisclosureRow`、`StateDot`、`HoverCard`、`Toast`、`ConnectionIndicator`、`RiskConfirmation` 与首次运行接管层 `OnboardingSurface` 覆盖常见的交互形态。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。
+`Button`、`Pill`、`Input`、`Menu`、`Modal`、`Tooltip`、`DisclosureRow`、`StateDot`、`HoverCard`、`Toast`、`ConnectionIndicator`、`RiskConfirmation` 与首次运行接管层 `OnboardingSurface` 覆盖常见的交互形态。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序([排名决策](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md))。
 
 ### 渲染 agent 输出
 

+ 1 - 0
packages/client/ui-primitives/src/index.ts

@@ -34,6 +34,7 @@ export type { TooltipSide } from './Tooltip.tsx'
 export { Toast } from './Toast.tsx'
 export { writeClipboard } from './clipboard.ts'
 export { relativeTime } from './relative-time.ts'
+export { rankByName } from './rank-by-name.ts'
 export type { RelativeTime, RelativeTimeUnit } from './relative-time.ts'
 export { JsonTree } from './JsonTree.tsx'
 export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx'

+ 79 - 0
packages/client/ui-primitives/src/rank-by-name.ts

@@ -0,0 +1,79 @@
+/**
+ * Shared ranking for `/` menu candidates: the query must be a
+ * case-insensitive ordered subsequence of the candidate name. Prefix hits
+ * rank first, then the strongest alignment score, then the source order of
+ * the input. Decision record:
+ * .agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md
+ */
+
+/** One match with its stable source position. */
+interface Ranked<T> {
+  readonly item: T
+  readonly index: number
+  readonly prefix: boolean
+  readonly score: number
+}
+
+/** Extra weight for name starts and separator boundaries. */
+function boundaryBonus(name: string, index: number): number {
+  return index === 0 || name.charAt(index - 1) === '-' || name.charAt(index - 1) === '_' ? 8 : 0
+}
+
+/**
+ * Score the strongest ordered-subsequence alignment in O(name × query).
+ * Boundary and adjacent matches earn weight; skipped and leading characters
+ * cost weight. Undefined when the query is not a subsequence of the name.
+ */
+function alignmentScore(name: string, query: string): number | undefined {
+  if (query.length > name.length) return undefined
+  const noMatch = Number.NEGATIVE_INFINITY
+  let previous = Array<number>(name.length).fill(noMatch)
+  for (let index = 0; index < name.length; index++) {
+    if (name.charAt(index) === query.charAt(0)) previous[index] = 1 + boundaryBonus(name, index) - index
+  }
+  for (let queryIndex = 1; queryIndex < query.length; queryIndex++) {
+    const current = Array<number>(name.length).fill(noMatch)
+    // Sweep the previous row once: `left` is its score one character back
+    // (the adjacent continuation), `leftLeft` two back (the earliest gapped one).
+    let left = noMatch
+    let leftLeft = noMatch
+    let bestGapped = noMatch
+    for (const [index, prior] of previous.entries()) {
+      if (leftLeft !== noMatch) bestGapped = Math.max(bestGapped, leftLeft + index - 2)
+      if (name.charAt(index) === query.charAt(queryIndex)) {
+        const bonus = 1 + boundaryBonus(name, index)
+        let score = noMatch
+        if (left !== noMatch) score = left + bonus + 4
+        if (bestGapped !== noMatch) score = Math.max(score, bestGapped + bonus + 1 - index)
+        current[index] = score
+      }
+      leftLeft = left
+      left = prior
+    }
+    previous = current
+  }
+  let best = noMatch
+  for (const score of previous) best = Math.max(best, score)
+  return best === noMatch ? undefined : best
+}
+
+/**
+ * Rank named items by a menu query.
+ * @param items - candidates in source order (a host catalog, then client contributions).
+ * @param rawQuery - the text typed after the trigger, matched case-insensitively.
+ * @returns the matching items: prefix hits first, then by alignment score,
+ * then in source order. The input list itself for an empty query.
+ */
+export function rankByName<T extends { readonly name: string }>(items: readonly T[], rawQuery: string): readonly T[] {
+  const query = rawQuery.toLowerCase()
+  if (query === '') return items
+  const ranked: Ranked<T>[] = []
+  items.forEach((item, index) => {
+    const name = item.name.toLowerCase()
+    const score = alignmentScore(name, query)
+    if (score !== undefined) ranked.push({ item, index, prefix: name.startsWith(query), score })
+  })
+  ranked.sort((left, right) =>
+    Number(right.prefix) - Number(left.prefix) || right.score - left.score || left.index - right.index)
+  return ranked.map(match => match.item)
+}

+ 41 - 0
packages/client/ui-primitives/tests/rank-by-name.client.spec.ts

@@ -0,0 +1,41 @@
+/**
+ * Shared `/` menu ranker: case-insensitive ordered-subsequence matching,
+ * prefix hits first, alignment score next, source order for ties.
+ */
+import { describe, expect, it } from 'vitest'
+import { rankByName } from '@deepseek-ai/dsh-client-ui-primitives'
+
+const named = (...names: string[]) => names.map(name => ({ name }))
+const names = (items: readonly { name: string }[]) => items.map(item => item.name)
+
+describe('rankByName', () => {
+  it('returns the input list itself for an empty query', () => {
+    const items = named('b', 'a')
+    expect(rankByName(items, '')).toBe(items)
+  })
+
+  it('matches case-insensitive subsequences and ranks prefixes, boundaries, adjacency, gaps, then source order', () => {
+    const items = named('q-xylophone', 'qx-long', 'fabulous', 'foo-bar', 'zuv', 'zu1v', 'yu1v', 'zu12v')
+    expect(names(rankByName(items, 'QX'))).toEqual(['qx-long', 'q-xylophone'])
+    expect(names(rankByName(items, 'fb'))).toEqual(['foo-bar', 'fabulous'])
+    expect(names(rankByName(items, 'uv'))).toEqual(['zuv', 'zu1v', 'yu1v', 'zu12v'])
+    expect(names(rankByName(items, 'zzz'))).toEqual([])
+    expect(names(rankByName(items, 'query-longer-than-every-name'))).toEqual([])
+  })
+
+  it('a prefix hit outranks a stronger non-prefix alignment', () => {
+    // 'z_a_b' aligns both characters on separator boundaries and outscores
+    // every non-prefix rival; a name that starts with the query still wins.
+    expect(names(rankByName(named('z_a_b', 'xabc'), 'ab'))).toEqual(['z_a_b', 'xabc'])
+    expect(names(rankByName(named('z_a_b', 'abc'), 'ab'))).toEqual(['abc', 'z_a_b'])
+  })
+
+  it('takes the stronger of an adjacent and a gapped alignment for the same character', () => {
+    expect(names(rankByName(named('aab', 'ab'), 'ab'))).toEqual(['ab', 'aab'])
+  })
+
+  it('returns the ranked items with their payload intact', () => {
+    const items = [{ name: 'goal', description: 'g' }, { name: 'plan', description: 'p' }]
+    expect(rankByName(items, 'pl')).toEqual([{ name: 'plan', description: 'p' }])
+  })
+})

+ 2 - 2
packages/client/ui-skill/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-skill/README.md
-README.md: 6f5bd109a66b061db3ab5069b15bb4f101039e73
-README.zh.md: 61941ef1b5966d9155d724873b25e03d38d214e1
+README.md: a98194f7e590eb8a68bd329495896e9f2df1b506
+README.zh.md: c4f444f1c749f0697e8883166a0f4ea8ca2009ba

+ 1 - 1
packages/client/ui-skill/README.md

@@ -29,7 +29,7 @@ Type `/` in the composer and pick a skill from the suggestions, or type `/name`
 
 ### What the source offers
 
-Ordinary-session candidates come from the `skills/list` Remote; the host serves every user-invocable skill, and a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Results filter by `startsWith(query)`. A failed `skills/list` call is logged and folded into a silent menu-group drop — the menu shows only pending/ready states.
+Ordinary-session candidates come from the `skills/list` Remote; the host serves every user-invocable skill, and a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Results rank through the `/` menu's shared name ranker, `rankByName` from ui-primitives: the query matches a case-insensitive ordered subsequence of the skill name, prefix hits rank first, and ties keep the host order ([ranking decision](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)). A failed `skills/list` call is logged and folded into a silent menu-group drop — the menu shows only pending/ready states.
 
 ### The skill tool row
 

+ 1 - 1
packages/client/ui-skill/README.zh.md

@@ -29,7 +29,7 @@ kind: "package-reference"
 
 ### source 提供什么
 
-普通会话的候选来自 `skills/list` Remote;宿主提供每一个用户可调用的 skill,`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。结果按 `startsWith(query)` 过滤。`skills/list` 调用失败时会被记录并静默丢弃该菜单组——菜单只显示 pending/ready 状态。
+普通会话的候选来自 `skills/list` Remote;宿主提供每一个用户可调用的 skill,`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。结果经 `/` 菜单共享的名字排序器(ui-primitives 的 `rankByName`)排名:查询作为不区分大小写的有序子序列匹配 skill 名,前缀命中排最前,同分保持宿主顺序([排名决策](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md))。`skills/list` 调用失败时会被记录并静默丢弃该菜单组——菜单只显示 pending/ready 状态。
 
 ### skill 工具行
 

+ 4 - 2
packages/client/ui-skill/src/client/index.ts

@@ -35,6 +35,7 @@ import type { SkillEntry } from '@deepseek-ai/dsh-api-remotes/client'
 import type {} from '@deepseek-ai/dsh-api-session-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { InputTriggerServiceContract, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
+import { rankByName } from '@deepseek-ai/dsh-client-ui-primitives'
 // Type-only: pulls the locale plugin's Context merge (ctx.locale).
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 // Type-only: pulls the SlotRegistry service merge (ctx.slots).
@@ -142,8 +143,9 @@ export function apply(ctx: ClientContext): void {
       const skills = await fetchCatalog(session.sessionId)
       // Superseded keystroke: the shared fetch stays warm, this caller yields.
       if (signal.aborted) return []
-      return skills
-        .filter(skill => skill.name.startsWith(query))
+      // The same ranking as the command group of this menu: case-insensitive
+      // ordered subsequence, prefix hits first.
+      return rankByName(skills, query)
         .map(skill => ({
           name: skill.name,
           // The user-only marker rides the description (the menu's only

+ 7 - 2
packages/client/ui-skill/tests/browser-plugin.client.spec.ts

@@ -5,7 +5,7 @@
  * the source behavior contract driven directly on the captured source with
  * real ClientSessionContext projections — sessionId addressing, the
  * session-keyed catalog cache (single-flight per key, scope-birth warm
- * prewarm, connection/reset clear), startsWith filtering, RPC-failure
+ * prewarm, connection/reset clear), shared fuzzy name ranking, RPC-failure
  * rejection, pick → plain-text outcome (the plain-text-reference decision:
  * .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md),
  * the synchronous
@@ -167,7 +167,7 @@ describe('apply', () => {
 })
 
 describe('candidates: sessionId addressing', () => {
-  it('lists via {sessionId} and filters by startsWith(query)', async () => {
+  it('lists via {sessionId} and ranks case-insensitive subsequence matches with prefixes first', async () => {
     const { list, payloads } = countingList()
     const { source } = await bench(list)
     const items = await source.candidates(proj('s1'), req('co'))
@@ -177,6 +177,11 @@ describe('candidates: sessionId addressing', () => {
       { name: 'commit-helper', description: 'commit flow' },
       { name: 'code-review', description: 'review flow' },
     ])
+    const names = async (query: string) => (await source.candidates(proj('s1'), req(query))).map(c => c.name)
+    // 'de' prefixes deploy and is a subsequence of code-review: the prefix ranks first.
+    await expect(names('de')).resolves.toEqual(['deploy', 'code-review'])
+    await expect(names('REV')).resolves.toEqual(['code-review'])
+    await expect(names('zzz')).resolves.toEqual([])
   })
 
   it('rejects on a failed result (the slash shell owns the menu-side fold)', async () => {