Ver código fonte

Merge pull request #1599 from deepseek-harness/codex/web-slash-fuzzy-search

feat(web): add fuzzy slash command discovery
Turtle 1 mês atrás
pai
commit
4d241d37e4

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

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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: d3efdc23351a1b50853ee76fb731aee046004750

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

@@ -0,0 +1,27 @@
+# Agent Note: Web slash-command fuzzy discovery
+
+Status: implemented
+
+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.
+
+## 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 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.
+
+## Alternatives considered
+
+**Keep prefix-only matching.** Rejected because it preserves the recall failure that motivates the feature; `/cpt` cannot discover `/compact`.
+
+**Match unordered characters or descriptions.** Rejected because unordered matches are difficult to predict, while description matches can surface commands whose visible names do not explain why they ranked.
+
+**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.
+
+## 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.

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

@@ -0,0 +1,27 @@
+# Agent Note: Web 斜杠命令模糊发现
+
+Status: implemented
+
+[English](2026-08-04-web-slash-command-fuzzy-discovery.md) | 中文
+
+## Problem
+
+Web 命令菜单要求按命令名前缀匹配,因此用户只记得关键字母却不记得其准确位置时,就无法发现命令。扩大菜单的匹配范围可使命令更易发现,但命令执行仍必须保持精确匹配和确定性:近似输入行绝不能执行相近命令。
+
+## Decision
+
+`/` 命令 source 将键入的查询作为不区分大小写的有序子序列,与命令名进行模糊匹配。精确前缀构成排名最高的一类匹配。在每类匹配中,对齐分数越高越优先:分隔符边界和相邻字符会提高分数,前导字符和间隔会降低分数;分数相同则保持 host 目录和 client contribution 的顺序。位置过滤仍会在排名前从行内菜单中移除接收参数的命令。
+
+评分器对每个候选项使用动态规划,时间复杂度为 `O(query length × name length)`,空间复杂度为 `O(name length)`。候选项评分只在客户端进行且只检查命令名;命令描述不影响匹配。菜单选择仍派发所选的精确名称,而 space 与 Enter 裁决继续要求命令 token 精确匹配。
+
+## Alternatives considered
+
+**保留仅前缀匹配。** 否决,因为本功能要解决的用户无法准确回忆前缀的问题依然存在:`/cpt` 无法发现 `/compact`。
+
+**匹配无序字符或描述。** 否决,因为无序匹配难以预测,而描述匹配可能展示命令,但命令的可见名称无法解释其排名。
+
+**使用通用模糊搜索依赖。** 否决,因为该界面只需对小型命令目录使用一种受限的子序列规则;可配置搜索索引会增加 bundle 体积,并引入产品未使用的排名行为。
+
+## Consequences
+
+用户可以凭按顺序记得的字母发现命令;只要目录相同,排名就保持稳定。评分刻意采用启发式规则:与分隔符对齐的匹配可能排在原始跨度更短的匹配之前。包(package)测试固定各项排名因素以及同分时的稳定顺序,组装后的 Web 回放快照固定 `/cpt` 解析为 `/compact` 的行为。精确执行语义保持不变。

+ 8 - 1
apps/web/tests/lifecycle-chrome.e2e.ts

@@ -26,6 +26,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', impor
 const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
 const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
 const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md')
+const FUZZY_COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu-fuzzy.expected.md')
 const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md')
 // Post-reload golden: the same settled conversation rebuilt purely from
 // persistence + history — byte-equal rendering is exactly the recovery claim.
@@ -83,6 +84,12 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
     expect(Math.abs(
       launchedBox!.y + launchedBox!.height - typedBox!.y - typedBox!.height,
     )).toBeLessThan(1)
+    await input.fill('/cpt')
+    await expect.poll(() => menu.getByRole('option').allTextContents()).toEqual([
+      'compactCompact older conversation history',
+    ])
+    const fuzzySnapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(FUZZY_COMMAND_MENU_EXPECTED, fuzzySnapshot, MODE)
     await input.fill('')
     await expect.poll(() => menu.count()).toBe(0)
   })
@@ -258,7 +265,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
     expect(tripwire.warnings).toEqual([])
     await assertFixtureInventory(SNAPSHOT_DIR, [
-      'session.jsonl', 'command-menu.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md',
+      'session.jsonl', 'command-menu.expected.md', 'command-menu-fuzzy.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md',
     ])
   })
 })

+ 3 - 0
apps/web/tests/snapshots/lifecycle-chrome/command-menu-fuzzy.expected.md

@@ -0,0 +1,3 @@
+- listbox "Trigger suggestions":
+  - text: Commands
+  - option "compact Compact older conversation history" [selected]

+ 2 - 2
packages/client/ui-command/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-command/README.md
-README.md: a19bfe7135acc5408448dc73d04813ed4104dd48
-README.zh.md: 79d903b916728c1200ab1311055f2be190008787
+README.md: bc7386c8fca3b5c623473328bee6322fa7295277
+README.zh.md: 478ee4ccee4558c70075baa45ab34ac6e3d71617

+ 2 - 0
packages/client/ui-command/README.md

@@ -8,6 +8,8 @@ Client command surface (`ctx.command`): the session-keyed command-directory cach
 
 `CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt.
 
+Menu queries fuzzy-match ordered, case-insensitive subsequences of command names. Prefixes rank first; separator boundaries, adjacent characters, and shorter gaps rank the remaining matches, with directory and contribution order breaking ties. This affects discovery only: space and Enter still require an exact command name. Rationale: [Web slash-command fuzzy discovery](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md).
+
 `PopupSelectController` (`src/client/popup.ts`) is the headless shell state: `PopupSelectView` self-registers into `conversation.input.overlay` (the SlotMap key is ui-conversation's; this package pulls the declaration in with a type-only import — no runtime edge). The shell is a transient layer holding focus while open; token-segment consumption after onSelect runs both branches through `consumeTokenSegment` (menu-path span CAS, enter-path bare-token equality) against the draft face the wiring layer binds via `bindDraft`.
 
 The `/client` export surface is the plugin body (`apply`/`inject`), `CommandService`, the directory and popup classes with their state types, and the frozen contract types; the shell component itself is internal to the overlay registration.

+ 2 - 0
packages/client/ui-command/README.zh.md

@@ -8,6 +8,8 @@
 
 `CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent,若预热它,就会仅因查看持久化历史而激活子代理。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。
 
+菜单查询会按顺序且不区分大小写地模糊匹配命令名的子序列。前缀排名最高;其余匹配项按分隔符边界优先、相邻字符优先、间隔越短越优先的规则排序,若仍同分,则以目录顺序和 contribution 顺序打破平局。此行为只影响命令发现:space 和 Enter 仍要求命令名精确匹配。原理:[Web 斜杠命令模糊发现](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)。
+
 `PopupSelectController`(`src/client/popup.ts`)是无头的壳状态:`PopupSelectView` 自行注册进 `conversation.input.overlay`(SlotMap key 归 ui-conversation 所有;本包只以 type-only 导入引入该声明——没有运行时依赖边)。壳是打开期间持有焦点的瞬态层;onSelect 之后的 token 片段消费在两条分支上都经 `consumeTokenSegment` 执行(菜单路径做 span CAS,回车路径做裸 token 相等比较),作用于接线层经 `bindDraft` 绑定的草稿表层。
 
 `/client` 导出表层是插件主体(`apply`/`inject`)、`CommandService`、目录类和 popup 类及其状态类型,以及冻结的契约类型;壳组件本身是 overlay 注册的内部实现。

+ 72 - 7
packages/client/ui-command/src/client/service.ts

@@ -2,9 +2,10 @@
  * CommandService (`ctx.command`): 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 query/position filtering;
- * a host/contribution name collision fails loud. Every execute addresses the
- * session's agent by sessionId — sessions are always agent-backed.
+ * catalog with contributions by availability, then fuzzy query/position
+ * filtering; a host/contribution name collision fails loud. Every execute
+ * addresses the session's agent by sessionId — sessions are always
+ * agent-backed.
  */
 import { Service } from 'cordis'
 import type { Context } from 'cordis'
@@ -27,6 +28,69 @@ interface LiveState {
   readonly popups: Map<SessionId, PopupSelectController<ClientSessionContext>>
 }
 
+/** One fuzzy match with its stable source position. */
+interface RankedCandidate {
+  readonly candidate: SlashCandidate
+  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 SlashCandidate[], rawQuery: string): readonly SlashCandidate[] {
+  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 CommandService extends Service implements CommandServiceContract {
   static inject = ['slash', 'sessions', 'connection']
@@ -147,7 +211,7 @@ export class CommandService extends Service implements CommandServiceContract {
     }
   }
 
-  /** Menu candidates: host catalog + contribution availability, then query/position filtering. */
+  /** Menu candidates: host catalog + contribution availability, then position filtering and fuzzy name ranking. */
   private async candidates(session: ClientSessionContext, req: CandidateRequest): Promise<readonly SlashCandidate[]> {
     const list = await this.directory.ensureReady(session.sessionId, req.signal)
     const rows: SlashCandidate[] = []
@@ -163,9 +227,10 @@ export class CommandService extends Service implements CommandServiceContract {
       }
       rows.push({ name: contribution.name, description: contribution.description })
     }
-    return rows
-      .filter(c => c.name.startsWith(req.query))
-      .filter(c => req.position === 'leading' || c.hint === undefined)
+    return fuzzyCandidates(
+      rows.filter(c => req.position === 'leading' || c.hint === undefined),
+      req.query,
+    )
   }
 
   /** Decision table, menu column: contribution/decorated-host → popup; host input → claim; host bare → detached execute. */

+ 23 - 3
packages/client/ui-command/tests/service.spec.ts

@@ -164,13 +164,33 @@ describe('candidates', () => {
     expect(b.listCalls).toEqual([])
   })
 
-  it('pulls the session catalog; prefix filter and hint mapping apply', async () => {
+  it('pulls the session catalog; fuzzy filter and hint mapping apply', async () => {
     const { source, listCalls } = await bench()
     const list = await source.candidates(proj('s1'), req('g'))
     expect(listCalls).toEqual([{ sessionId: sid('s1') }])
     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 () => {
+    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: '' },
+    ]
+    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('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 () => {
     const { source, listCalls } = await bench()
     const names = (await source.candidates(proj('s2'), req(''))).map(c => c.name)
@@ -195,10 +215,10 @@ describe('candidates', () => {
     expect(s2Names).not.toContain('theme')
   })
 
-  it('contribution rows ride the same query prefix filter', async () => {
+  it('contribution rows ride the same fuzzy query filter', async () => {
     const { command, source } = await bench()
     command.register(themeContribution())
-    const names = (await source.candidates(proj('s1'), req('th'))).map(c => c.name)
+    const names = (await source.candidates(proj('s1'), req('tm'))).map(c => c.name)
     expect(names).toEqual(['theme'])
   })