Browse Source

Merge remote-tracking branch 'origin/master' into worktree/2984-generic-file-upload

# Conflicts:
#	packages/client/ui-chat/src/client/chat/MessageItem.tsx
creatixchu 1 tháng trước cách đây
mục cha
commit
e28b58f8c0
49 tập tin đã thay đổi với 790 bổ sung và 184 xóa
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml
  2. 2 2
      .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
  3. 2 2
      .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md
  4. 2 2
      .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml
  5. 2 2
      .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md
  6. 2 2
      .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md
  7. 2 2
      .agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.i18n.yaml
  8. 6 4
      .agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md
  9. 6 4
      .agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md
  10. 3 0
      apps/web/tests/expected/skill-invocation-policy/menu-fuzzy.expected.md
  11. 11 2
      apps/web/tests/goal-command-presentation.e2e.ts
  12. 11 2
      apps/web/tests/skill-invocation-policy.e2e.ts
  13. 6 2
      packages/client/ui-chat/src/client/chat/MessageItem.tsx
  14. 207 3
      packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts
  15. 11 0
      packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts
  16. 4 0
      packages/client/ui-chat/src/client/conversation-nodes/message.ts
  17. 24 3
      packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx
  18. 52 0
      packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts
  19. 88 0
      packages/client/ui-chat/tests/skill-name-projector.client.spec.ts
  20. 6 67
      packages/client/ui-commands/src/client/service.ts
  21. 4 13
      packages/client/ui-commands/tests/service.client.spec.ts
  22. 9 1
      packages/client/ui-conversation/src/client/input/decorations.ts
  23. 8 0
      packages/client/ui-conversation/tests/submit-machine.client.spec.ts
  24. 2 2
      packages/client/ui-goal/README.i18n.yaml
  25. 1 1
      packages/client/ui-goal/README.md
  26. 1 1
      packages/client/ui-goal/README.zh.md
  27. 4 4
      packages/client/ui-goal/src/client/GoalCommandInputView.module.css
  28. 15 4
      packages/client/ui-goal/src/client/GoalCommandInputView.tsx
  29. 4 1
      packages/client/ui-goal/src/client/goal-command-input.ts
  30. 31 0
      packages/client/ui-goal/tests/goal-command-input-styles.client.spec.ts
  31. 34 0
      packages/client/ui-goal/tests/goal-command-input.client.spec.tsx
  32. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  33. 2 2
      packages/client/ui-primitives/README.md
  34. 2 2
      packages/client/ui-primitives/README.zh.md
  35. 1 1
      packages/client/ui-primitives/src/index.ts
  36. 0 9
      packages/client/ui-primitives/src/markdown/MessageText.module.css
  37. 0 7
      packages/client/ui-primitives/src/markdown/MessageText.tsx
  38. 79 0
      packages/client/ui-primitives/src/rank-by-name.ts
  39. 8 0
      packages/client/ui-primitives/src/user-text.module.css
  40. 31 9
      packages/client/ui-primitives/src/user-text.tsx
  41. 0 9
      packages/client/ui-primitives/tests/markdown.client.spec.tsx
  42. 41 0
      packages/client/ui-primitives/tests/rank-by-name.client.spec.ts
  43. 9 0
      packages/client/ui-primitives/tests/user-text-styles.client.spec.ts
  44. 38 7
      packages/client/ui-primitives/tests/user-text.client.spec.tsx
  45. 2 2
      packages/client/ui-skill/README.i18n.yaml
  46. 1 1
      packages/client/ui-skill/README.md
  47. 1 1
      packages/client/ui-skill/README.zh.md
  48. 4 2
      packages/client/ui-skill/src/client/index.ts
  49. 7 2
      packages/client/ui-skill/tests/browser-plugin.client.spec.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.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/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
-2026-07-25-web-input-machine-and-slash-pipeline.md: 200761cc9e648eea80bdae9d7b363246c816e5d1
-2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 673d0ee4bd0916b20ee74226f50240e3904fe06c
+2026-07-25-web-input-machine-and-slash-pipeline.md: 1b9e9d95b5a30efbf297be5fc5f788f9a1ac77c4
+2026-07-25-web-input-machine-and-slash-pipeline.zh.md: c9bee217da1dbffaeff69dfe5a2dcf8f0e8e3cb0

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md

@@ -67,8 +67,8 @@ skill/@subagent references skip the placeholder + occurrence identity chain —
 
 - PickOutcome gains a `{text}` arm; the new scoped bail event `slash/input-insert-text` `{text, span}` (the same contract as the other three: draftRev CAS, returning true ⟺ an actual rewrite); facade.insertText goes through setDraft concatenation — zero machine changes.
 - Sources get an optional `lexicon?(session)` hook: a synchronous hot-snapshot name roster, with `undefined` = data not warm — zero decoration, never triggering a fetch (the render path stays synchronous and side-effect-free); the paired optional `subscribeLexicon?(session, listener)` hook is the invalidation channel for rolls that change after warm (catalog settles, children spawn/exit). The controller aggregates the rolls into its `lexicon` snapshot store (re-polling on each source notification); sources registered after scope birth are warmed and folded in via the service's live-controller broadcast.
-- `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits) against the roster; a hit becomes a `TextRefNode` entity in the Lexical tree (the claim decoration has precedence on the leading-token seat — [the Lexical composer note](2026-08-20-web-composer-lexical-editor.md)); an edit breaking the match shape reverts the entity to plain text.
-- Sending is the literal text (no more `<skill>` serialization); on the bubble side MessageItem decorates both shapes (the legacy `<skill>` tag + plain-text tokens).
+- `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits; a `/name` token also ends at whitespace or the draft end — the whitespace-bounded shape of the host skill gesture, so `/nfs-hg/xxx` is a path and `/plan。` is prose; the sent-text projection `projectUserText` in ui-primitives applies the same shape) against the roster; a hit becomes a `TextRefNode` entity in the Lexical tree (the claim decoration has precedence on the leading-token seat — [the Lexical composer note](2026-08-20-web-composer-lexical-editor.md)); an edit breaking the match shape reverts the entity to plain text.
+- Sending is the literal text (no more `<skill>` serialization); on the bubble side `projectUserText` decorates a plain-text `/name` token only when the same step logged a `skill-invocation` injection for that name — ui-chat's `SkillNameProjector` attaches the step's injected names to the direct message Node, the way the recall projector attaches session labels — so `/123` or a stray `/word` stays plain; a command-input bubble (ui-goal) names its executed command the same way and renders the token as a `command` chip; `@name` tokens still decorate by shape.
 - Decoration reactivity: the shell subscribes to the controller's lexicon store and re-scans the document on each roll change, so a roll that settles after the scope-birth prewarm lights existing draft tokens up without any menu interaction or unrelated re-render.
 
 ### Per-session provide contributions and the private keyboard surface

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md

@@ -67,8 +67,8 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——纯文本引
 
 - PickOutcome 增 `{text}` arm;新 scoped bail 事件 `slash/input-insert-text` `{text, span}`(与另三个同约定:draftRev CAS、返回 true ⟺ 实际改写);facade.insertText 走 setDraft 拼接,机器零改动。
 - source 可选 `lexicon?(session)` 钩子:同步热快照名录,`undefined` = 数据未热——零装饰、永不触发 fetch(渲染路径保持同步无副作用);配对的可选 `subscribeLexicon?(session, listener)` 钩子是名录在 warm 之后仍会变化(目录 settle、子代生灭)时的失效通道。controller 把各名录聚合进自己的 `lexicon` 快照 store(每次 source 通知重拉);scope 出生后才注册的 source 由服务广播给活 controller,补 warm 并并入名录。
-- `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中)对照名录,命中即成为 Lexical 树中的 `TextRefNode` 实体(claim 装饰对行首 token 席位有优先权——见 [Lexical composer note](2026-08-20-web-composer-lexical-editor.zh.md));编辑破坏匹配形状时实体还原为普通文本。
-- 发送即原文(不再 `<skill>` 序列化);气泡侧 MessageItem 双形状装饰(legacy `<skill>` 标签 + 纯文本 token)。
+- `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中;`/name` token 还必须止于空白或 draft 末尾——与宿主 skill gesture 同样以空白为界,因此 `/nfs-hg/xxx` 是路径、`/plan。` 是普通文本;ui-primitives 中已发送文本的投影 `projectUserText` 采用同一形状)对照名录,命中即成为 Lexical 树中的 `TextRefNode` 实体(claim 装饰对行首 token 席位有优先权——见 [Lexical composer note](2026-08-20-web-composer-lexical-editor.zh.md));编辑破坏匹配形状时实体还原为普通文本。
+- 发送即原文(不再 `<skill>` 序列化);气泡侧 `projectUserText` 只在同一步骤记录了该名字的 `skill-invocation` 注入时才装饰纯文本 `/name` token——ui-chat 的 `SkillNameProjector` 把该步骤注入的 skill 名挂到直接消息节点上,与 recall 投影挂会话标签的方式相同——因此 `/123` 或随手敲的 `/词` 保持普通文本;指令输入气泡(ui-goal)以同样方式指明其已执行的指令,把 token 渲染为 `command` chip;`@name` token 仍按形状装饰。
 - 装饰响应性:shell 订阅 controller 的 lexicon store,每次名录变化重扫全文档,scope 出生预热后才 settle 的名录会直接点亮已有 draft token,无需菜单交互或无关重渲染。
 
 ### 每会话供数贡献与键盘私面

+ 2 - 2
.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.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-07-23-web-assistant-markdown.md
-2026-07-23-web-assistant-markdown.md: d2b8e30d779656636f70b05524c96796a254b57b
-2026-07-23-web-assistant-markdown.zh.md: c1542d75faf1b484160f98b4217164b5df4e4b99
+2026-07-23-web-assistant-markdown.md: 4063ad647c485be295a55087247681a494dbeabf
+2026-07-23-web-assistant-markdown.zh.md: 6b062e155c88bc8c3f3cf048ec463f3db0b4f7bc

+ 2 - 2
.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md

@@ -10,7 +10,7 @@ The Web conversation preserves assistant Markdown source through session events,
 
 ## Decision
 
-`@deepseek-ai/dsh-client-ui-primitives` exports `MarkdownText` as the untrusted assistant-text renderer, and `ui-conversation` selects it only for assistant `text` blocks. Finalized history, the streaming tail, and interrupted partials already share `AssistantMarkdown`, so they receive the same renderer without changing events or snapshots. User and steering messages keep `MessageText` and remain literal.
+`@deepseek-ai/dsh-client-ui-primitives` exports `MarkdownText` as the untrusted assistant-text renderer, and `ui-conversation` selects it only for assistant `text` blocks. Finalized history, the streaming tail, and interrupted partials already share `AssistantMarkdown`, so they receive the same renderer without changing events or snapshots. User and steering messages render through `projectUserText` (inline plain runs plus reference chips) and remain literal.
 
 `MarkdownText` parses with `mdast-util-from-markdown` plus the GFM micromark extensions and renders the mdast tree through the package's own renderer, parsing incrementally while a turn streams (the [incremental AST renderer note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) owns that mechanism and its DOM-parity contract). It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without raw-HTML parsing. A micromark attention extension reuses the CommonMark resolver while letting runs of at least two asterisks close after Unicode punctuation when followed immediately by CJK text. This exception covers punctuation-terminated strong emphasis in whitespace-free CJK prose during streaming and after settlement; single-asterisk emphasis, non-CJK adjacency, escaped source, code, and math retain upstream parsing. Fenced code routes through the shared `CodeBlock`, which highlights registered grammars with the client's shiki singleton (`--shiki-*` tokens) and falls back to plain monospace otherwise. While a turn streams, fences highlight incrementally: each chunk tokenizes newly completed text from a saved grammar state plus the still-growing last line, excluding the completed prefix from repeated work (the [streaming fence-highlight note](2026-08-20-web-streaming-fence-highlight.md) owns that mechanism).
 
@@ -28,7 +28,7 @@ Fenced code and GFM tables own horizontal overflow so long content cannot widen
 
 **Promote the existing mdast and micromark development dependencies and maintain a custom React walker.** This avoids a new parser family but makes the product own every node mapping, GFM extension, and security-sensitive rendering branch. The dedicated React renderer keeps that traversal upstream while preserving an AST-to-React path. *Later reversed on new evidence — incremental streaming parsing needs AST-level input the string-only wrapper cannot provide; the [incremental AST renderer note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) owns that decision.*
 
-**Replace `MessageText` with Markdown rendering.** This formats user prompts and steering as a side effect. Those authored inputs remain literal until the product chooses that behavior explicitly.
+**Render user prompts and steering as Markdown too.** This formats authored input as a side effect. Those authored inputs remain literal until the product chooses that behavior explicitly.
 
 **Parse Markdown into session snapshots.** This would make React nodes or presentation ASTs durable runtime state and reintroduce a final-versus-streaming mode boundary. Parsing stays at the presentation leaf instead.
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md

@@ -10,7 +10,7 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd
 
 ## 决策
 
-`@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息继续使用 `MessageText`,并保持按字面渲染。
+`@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息经 `projectUserText` 渲染(行内普通片段加引用 chip),并保持按字面渲染。
 
 `MarkdownText` 以 `mdast-util-from-markdown` 加 GFM micromark 扩展解析,并经包内自有渲染器渲染 mdast 树,轮次流式输出期间增量解析([增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md) 拥有该机制及其 DOM 一致性约定)。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。一个 micromark attention 扩展复用 CommonMark resolver,同时允许至少两个星号组成的连续序列在 Unicode 标点后闭合,前提是其后紧邻 CJK 文本。这一例外涵盖流式输出期间与完成后无空格 CJK 文本中以标点结尾的粗体;单星号强调、紧邻非 CJK 文本的情况、已转义源文本、代码与数学公式仍沿用上游解析行为。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token)高亮已注册语法,否则回退为纯等宽文本。轮次流式输出期间,围栏增量高亮:每个分片从保存的 grammar state 出发 tokenize 新完成的文本以及仍在增长的最后一行,不重复处理已完成的前缀([流式围栏高亮 Note](2026-08-20-web-streaming-fence-highlight.zh.md) 拥有该机制)。
 
@@ -28,7 +28,7 @@ assistant 生成的链接目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。
 
 **将现有的 mdast 与 micromark 开发依赖提升为正式依赖,并维护自定义 React walker。**此方案避免引入新的解析器体系,但产品需要自行负责每种节点映射、GFM 扩展和安全敏感的渲染分支。专用 React 渲染器将这套遍历交由上游维护,同时保留 AST 到 React 的处理路径。*后因新证据被推翻——增量流式解析需要纯字符串封装无法提供的 AST 级输入;该决策由[增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md) 拥有。*
 
-**将 `MessageText` 替换为 Markdown 渲染。**这会产生格式化用户提示词与 steering 的副作用。在产品明确选择此行为之前,这些输入仍按字面渲染。
+**把用户提示词与 steering 也按 Markdown 渲染。**这会产生格式化用户输入的副作用。在产品明确选择此行为之前,这些输入仍按字面渲染。
 
 **将 Markdown 解析为会话快照。**这会让 React 节点或呈现层 AST 成为持久的运行时状态,并重新引入最终输出与流式输出之间的模式边界。解析仍留在呈现层的叶节点中。
 

+ 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/goal-command-presentation.e2e.ts

@@ -67,17 +67,26 @@ describe('web e2e: /goal human transcript presentation', () => {
     const typography = await commandInput.evaluate((element) => {
       const bubble = element.firstElementChild?.firstElementChild
       if (!(bubble instanceof HTMLElement)) throw new Error('command input bubble is missing')
+      const chip = bubble.querySelector('[data-ref-chip="command"]')
+      if (!(chip instanceof HTMLElement)) throw new Error('command chip is missing')
       const rootStyle = getComputedStyle(element)
       const bubbleStyle = getComputedStyle(bubble)
+      const chipStyle = getComputedStyle(chip)
       return {
         fontFamily: bubbleStyle.fontFamily,
         parentFontFamily: rootStyle.fontFamily,
         fontSize: bubbleStyle.fontSize,
         lineHeight: bubbleStyle.lineHeight,
+        chipText: chip.textContent,
+        chipFontFamily: chipStyle.fontFamily,
+        chipFontSize: chipStyle.fontSize,
       }
     })
-    expect(typography).toMatchObject({ fontSize: '14px', lineHeight: '22px' })
-    expect(typography.fontFamily).not.toBe(typography.parentFontFamily)
+    expect(typography).toMatchObject({ fontSize: '14px', lineHeight: '22px', chipText: '/goal', chipFontSize: '14px' })
+    // The bubble reads in the body face like a user bubble; only the command
+    // chip carries the code face that marks the echoed token as a command.
+    expect(typography.fontFamily).toBe(typography.parentFontFamily)
+    expect(typography.chipFontFamily).not.toBe(typography.fontFamily)
     const resultRow = page.locator('[data-variant="others"]').filter({ hasText: 'No goal is currently set.' })
     await expect.poll(() => resultRow.count(), { timeout: 10_000 }).toBe(1)
     expect(await resultRow.getByText('goal', { exact: true }).count()).toBe(1)

+ 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 - 2
packages/client/ui-chat/src/client/chat/MessageItem.tsx

@@ -161,7 +161,8 @@ function TurnMaxTokensItem({ t }: {
 
 /** Right-aligned bubble shared by user and steering rows. */
 function UserStyleBubble({
-  content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewAttachments, t,
+  content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], skillNames = [],
+  previewAttachments, t,
 }: {
   content: readonly unknown[]
   renderMessageImages: ChatNodeOwnerProps['renderMessageImages']
@@ -173,6 +174,8 @@ function UserStyleBubble({
   echo?: boolean
   /** Exact session mention labels associated by the adjacent recall node. */
   referenceLabels?: readonly string[]
+  /** Skill names the step's `skill-invocation` injections loaded for this message. */
+  skillNames?: readonly string[]
   /** Local submission-echo attachments replacing the content-derived attachment sequence. */
   previewAttachments?: readonly PresentedAttachment[]
   t: ChatViewSlotProps['t']
@@ -216,7 +219,7 @@ function UserStyleBubble({
           </div>
         )}
         {showBubble && <div className={css.bubble}>
-          {projectUserText(text, referenceLabels)}
+          {projectUserText(text, referenceLabels, skillNames)}
           {rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
         </div>}
         {referenceLabels.length > 0 && (
@@ -323,6 +326,7 @@ export const UserMessageNodeView = memo(function UserMessageNodeView({
       content={data.content}
       renderMessageImages={renderMessageImages}
       {...data.referenceLabels === undefined ? {} : { referenceLabels: data.referenceLabels }}
+      {...data.skillNames === undefined ? {} : { skillNames: data.skillNames }}
       t={t}
       actions={text => (
         <MessageIconActions

+ 207 - 3
packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts

@@ -11,7 +11,7 @@ import type {
   ChatTurnNavigationIndex, ChatTurnProcessPresentation, LegacyConversationSlice, TurnNavigationItem,
 } from '../contract/snapshot.ts'
 import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts'
-import { sessionRecallLabels } from './event-projection.ts'
+import { sessionRecallLabels, skillInvocationName } from './event-projection.ts'
 import { sameTurnNavigationItem, turnNavigationItem } from './turn-navigation.ts'
 import { ChatTurnProcessProjector } from './turn-process-presentation.ts'
 
@@ -505,6 +505,209 @@ class ReferenceLabelProjector {
   }
 }
 
+function withSkillNames(
+  node: ChatConversationViewNode,
+  names: readonly string[],
+): ChatConversationViewNode {
+  const candidate = node as ChatNode
+  if (candidate.kind !== 'user' && candidate.kind !== 'steering') return node
+  const current = candidate.data.skillNames ?? EMPTY_KEYS
+  const hasNames = Object.hasOwn(candidate.data, 'skillNames')
+  if (sameReferences(current, names) && hasNames === (names.length > 0)) return node
+  const data: Record<string, unknown> = { ...candidate.data }
+  if (names.length === 0) delete data.skillNames
+  else data.skillNames = names
+  return { ...candidate, data }
+}
+
+/** What skill-name batches are made of; every other Node is transparent. */
+type SlashEntryKind = 'message' | 'skill' | 'boundary'
+
+interface SlashEntry {
+  readonly key: string
+  readonly seq: number
+  readonly kind: SlashEntryKind
+  /** The injected skill name of a `skill` entry. */
+  readonly name: string | null
+}
+
+/**
+ * Classify one Node for batching: a direct message, a `skill-invocation`
+ * context, or a boundary of any other kind. A context that injects no skill
+ * (workspace rules, the catalog, a recall) is transparent and yields null.
+ */
+function slashEntryOf(node: ChatConversationViewNode): SlashEntry | null {
+  const candidate = node as ChatNode
+  if (candidate.kind === 'user' || candidate.kind === 'steering') {
+    return { key: node.key, seq: node.anchorSeq, kind: 'message', name: null }
+  }
+  if (candidate.kind === 'context') {
+    const name = skillInvocationName(candidate.data.source)
+    return name === null ? null : { key: node.key, seq: node.anchorSeq, kind: 'skill', name }
+  }
+  return { key: node.key, seq: node.anchorSeq, kind: 'boundary', name: null }
+}
+
+function sameSlashEntry(left: SlashEntry, right: SlashEntry): boolean {
+  return left.seq === right.seq && left.kind === right.kind && left.name === right.name
+}
+
+/**
+ * Attaches each direct message's step-loaded skill names to its Node.
+ *
+ * A step's `skill-invocation` injections follow the direct messages the host
+ * scanned for `/name` gestures and precede the step's first Node of any other
+ * kind, so every non-message, non-context Node closes a batch. Every ended
+ * Turn publishes its `turn-tail` Node on `turn/end` whatever the reason, so a
+ * batch never spans Turns, and `step/start` precedes the direct message in
+ * the log, so no boundary separates a message from its injections. Names
+ * attach to every direct message of the batch: the bubble decorates only the
+ * tokens its own text carries.
+ *
+ * The index holds only messages, skill injections, and boundaries, ordered by
+ * `anchorSeq`. An apply re-reads just the batches around the Nodes whose
+ * classification changed and never scans the store, so an assistant
+ * streaming frame costs nothing here (the append hot path never scans the
+ * Chat Nodes).
+ */
+export class SkillNameProjector {
+  private readonly entries = new Map<string, SlashEntry>()
+  /** Every indexed entry in `anchorSeq` order. */
+  private sorted: SlashEntry[] = []
+
+  /**
+   * Rebuild the index from a whole Node set and attach names to its messages.
+   * @param nodes - every materialized Chat Node, in any order.
+   * @returns the same Nodes, direct messages carrying their batch's names.
+   */
+  replace(nodes: readonly ChatConversationViewNode[]): readonly ChatConversationViewNode[] {
+    this.entries.clear()
+    this.sorted = []
+    for (const node of nodes) {
+      const entry = slashEntryOf(node)
+      if (entry === null) continue
+      this.entries.set(entry.key, entry)
+      this.sorted.push(entry)
+    }
+    this.sorted.sort((left, right) => left.seq - right.seq)
+    const names = new Map<string, readonly string[]>()
+    for (let index = 0; index < this.sorted.length; index++) {
+      if (this.sorted[index]?.kind === 'boundary') continue
+      const end = this.runEnd(index)
+      this.assignRun(index, end, names)
+      index = end
+    }
+    return nodes.map(node => withSkillNames(node, names.get(node.key) ?? EMPTY_KEYS))
+  }
+
+  /**
+   * Fold one incremental upsert set: re-read only the batches around the
+   * Nodes whose classification changed.
+   * @param upserts - the changed Nodes.
+   * @param store - the resident Nodes, read by key for the messages of an affected batch.
+   * @returns the upserts plus any resident message whose names changed.
+   */
+  apply(
+    upserts: readonly ChatConversationViewNode[],
+    store: ChatNodeStore,
+  ): readonly ChatConversationViewNode[] {
+    const dirty: number[] = []
+    for (const node of upserts) {
+      const next = slashEntryOf(node)
+      const previous = this.entries.get(node.key)
+      if (previous !== undefined) {
+        if (next !== null && sameSlashEntry(previous, next)) continue
+        this.remove(previous)
+        dirty.push(previous.seq)
+      }
+      if (next === null) continue
+      this.insert(next)
+      dirty.push(next.seq)
+    }
+    if (dirty.length === 0) return upserts
+    const names = new Map<string, readonly string[]>()
+    for (const seq of dirty) this.collectAround(seq, names)
+    const byKey = new Map(upserts.map(node => [node.key, node]))
+    for (const [key, list] of names) {
+      const node = byKey.get(key) ?? store.get(key)
+      if (node === undefined) continue
+      const next = withSkillNames(node, list)
+      if (next !== node || byKey.has(key)) byKey.set(key, next)
+    }
+    return [...byKey.values()]
+  }
+
+  private insert(entry: SlashEntry): void {
+    this.sorted.splice(this.lowerBound(entry.seq), 0, entry)
+    this.entries.set(entry.key, entry)
+  }
+
+  private remove(entry: SlashEntry): void {
+    this.sorted.splice(this.sorted.indexOf(entry), 1)
+    this.entries.delete(entry.key)
+  }
+
+  /** First index whose seq is at least `seq`. */
+  private lowerBound(seq: number): number {
+    let low = 0
+    let high = this.sorted.length
+    while (low < high) {
+      const middle = (low + high) >>> 1
+      if ((this.sorted[middle]?.seq ?? Number.POSITIVE_INFINITY) < seq) low = middle + 1
+      else high = middle
+    }
+    return low
+  }
+
+  /** Last index of the boundary-free run containing `index`. */
+  private runEnd(index: number): number {
+    let end = index
+    while (end + 1 < this.sorted.length && this.sorted[end + 1]?.kind !== 'boundary') end++
+    return end
+  }
+
+  /** First index of the boundary-free run containing `index`. */
+  private runStart(index: number): number {
+    let start = index
+    while (start - 1 >= 0 && this.sorted[start - 1]?.kind !== 'boundary') start--
+    return start
+  }
+
+  /** Record the names every message of the run `[start, end]` carries. */
+  private assignRun(start: number, end: number, names: Map<string, readonly string[]>): void {
+    const list: string[] = []
+    for (let index = start; index <= end; index++) {
+      const entry = this.sorted[index]
+      if (entry?.kind === 'skill' && entry.name !== null && !list.includes(entry.name)) list.push(entry.name)
+    }
+    for (let index = start; index <= end; index++) {
+      const entry = this.sorted[index]
+      if (entry?.kind === 'message') names.set(entry.key, list)
+    }
+  }
+
+  /**
+   * Re-read the run(s) around one changed seq: the run holding a message or
+   * skill entry, or — for a boundary, or a seq that left the index — the runs
+   * on both sides of that position.
+   */
+  private collectAround(seq: number, names: Map<string, readonly string[]>): void {
+    const at = this.lowerBound(seq)
+    const here = this.sorted[at]
+    if (here !== undefined && here.seq === seq && here.kind !== 'boundary') {
+      this.assignRun(this.runStart(at), this.runEnd(at), names)
+      return
+    }
+    if (at - 1 >= 0 && this.sorted[at - 1]?.kind !== 'boundary') {
+      this.assignRun(this.runStart(at - 1), at - 1, names)
+    }
+    const right = here !== undefined && here.seq === seq ? at + 1 : at
+    if (right < this.sorted.length && this.sorted[right]?.kind !== 'boundary') {
+      this.assignRun(right, this.runEnd(right), names)
+    }
+  }
+}
+
 interface LegacyContribution {
   readonly anchorSeq: number
   readonly nodes: readonly ConversationNode[]
@@ -755,6 +958,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
   private readonly navigation = new MutableTurnNavigationIndex()
   private readonly legacy = new LegacySliceBuilder()
   private readonly referenceLabels = new ReferenceLabelProjector()
+  private readonly skillNames = new SkillNameProjector()
   private order: readonly string[] = EMPTY_KEYS
   /** Last published timeline: a Turn boundary can land without a new node. */
   private timeline: ConversationTimelineSnapshot | null = null
@@ -768,7 +972,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
     readonly nodes: readonly ChatConversationViewNode[]
     readonly timeline: ConversationTimelineSnapshot
   }): ChatSnapshot {
-    const nodes = this.referenceLabels.replace(input.nodes)
+    const nodes = this.skillNames.replace(this.referenceLabels.replace(input.nodes))
     this.store.replace(nodes)
     this.order = orderedVisibleChatNodes(nodes).map(node => node.key)
     this.locations.rebuild(this.order, this.store)
@@ -784,7 +988,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
     readonly upserts: readonly ChatConversationViewNode[]
     readonly timeline: ConversationTimelineSnapshot
   }): ChatSnapshot {
-    const upserts = this.referenceLabels.apply(input.upserts, this.store)
+    const upserts = this.skillNames.apply(this.referenceLabels.apply(input.upserts, this.store), this.store)
     const processTurns = new Set<number>()
     let structural = false
     const contentOnly: ChatConversationViewNode[] = []

+ 11 - 0
packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts

@@ -88,6 +88,17 @@ export function sessionRecallLabels(source: unknown): string[] {
   return collect(record, 'references', 'label')
 }
 
+/**
+ * Read the skill name a durable skill-invocation injection loaded.
+ * @param source - Logged `user/message` source.
+ * @returns The skill name, or null for every other source.
+ */
+export function skillInvocationName(source: unknown): string | null {
+  const record = asRecord(source)
+  if (record === null || readString(record, 'kind') !== 'skill-invocation') return null
+  return readString(record, 'name')
+}
+
 /**
  * Classify finalized Assistant content for Chat rendering.
  * @param content - Core content blocks.

+ 4 - 0
packages/client/ui-chat/src/client/conversation-nodes/message.ts

@@ -10,11 +10,15 @@ import { contextForm, contextProvenance } from './event-projection.ts'
 interface ReferencedUserMessageNode extends UserMessageNode {
   /** Labels cited by the immediately following session-reference context. */
   readonly referenceLabels?: readonly string[]
+  /** Skill names the same step's `skill-invocation` injections loaded. */
+  readonly skillNames?: readonly string[]
 }
 
 interface ReferencedSteeringMessageNode extends SteeringMessageNode {
   /** Labels cited by the immediately following session-reference context. */
   readonly referenceLabels?: readonly string[]
+  /** Skill names the same step's `skill-invocation` injections loaded. */
+  readonly skillNames?: readonly string[]
 }
 
 type MessageNode = ReferencedUserMessageNode | ReferencedSteeringMessageNode | ContextMessageNode

+ 24 - 3
packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx

@@ -48,10 +48,11 @@ interface MessageItemProps {
   readonly node: ConversationNode
   readonly t: ChatNodeViewProps['t']
   readonly referenceLabels?: readonly string[]
+  readonly skillNames?: readonly string[]
 }
 
 /** Legacy-node fixture adapter for the independently registered renderers. */
-function MessageItem({ node, t: translate, referenceLabels }: MessageItemProps) {
+function MessageItem({ node, t: translate, referenceLabels, skillNames }: MessageItemProps) {
   const kind = node.kind === 'assistant' ? 'assistant-step' : node.kind
   const viewNode: ChatConversationViewNode = {
     key: `fixture:${node.kind}:${node.seq}`,
@@ -63,8 +64,12 @@ function MessageItem({ node, t: translate, referenceLabels }: MessageItemProps)
     visibility: 'visible',
     data: node.kind === 'model-retry'
       ? { attempts: [node], current: node }
-      : (node.kind === 'user' || node.kind === 'steering') && referenceLabels !== undefined
-        ? { ...node, referenceLabels }
+      : (node.kind === 'user' || node.kind === 'steering') && (referenceLabels !== undefined || skillNames !== undefined)
+        ? {
+          ...node,
+          ...(referenceLabels === undefined ? {} : { referenceLabels }),
+          ...(skillNames === undefined ? {} : { skillNames }),
+        }
         : node,
   }
   const props = { node: viewNode, t: translate, renderMessageImages, useChat: useDetachedChat } as ChatNodeViewProps
@@ -141,6 +146,22 @@ describe('MessageItem arms', () => {
     expect(view.container.textContent).toContain('README.md, please.')
   })
 
+  it('decorates a slash token as a skill chip only when the step resolved that skill', () => {
+    const message = {
+      kind: 'user' as const,
+      seq: 1,
+      time: 1_000,
+      content: [{ type: 'text', text: '/123 then /demo-skill go' }] as never,
+      source: null,
+    }
+    const plain = render(<MessageItem t={t} node={message} />)
+    expect(plain.container.querySelectorAll('[data-ref-chip]').length).toBe(0)
+    const resolved = render(<MessageItem t={t} node={message} skillNames={['demo-skill']} />)
+    const chips = [...resolved.container.querySelectorAll('[data-ref-chip="skill"]')]
+    expect(chips.map(chip => chip.textContent)).toEqual(['/demo-skill'])
+    expect(resolved.container.textContent).toContain('/123 then ')
+  })
+
   it('user bubbles expose clock / copy and neither branch nor edit; copy writes the text', () => {
     const writeText = vi.fn().mockResolvedValue(undefined)
     Object.defineProperty(navigator, 'clipboard', {

+ 52 - 0
packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts

@@ -1766,6 +1766,58 @@ describe('built-in conversation node Definitions', () => {
     })
   })
 
+  it('associates a direct message with the skill invocations injected for its step', () => {
+    const skillInvocation = (id: string) => ({
+      ...textMessage(id, 'instructions'),
+      source: { kind: 'skill-invocation', name: 'demo-skill', form: 'instructions' },
+    })
+    const instructions = (id: string) => ({
+      ...textMessage(id, 'workspace rules'),
+      source: { kind: 'agent-instructions', changes: [{ path: 'AGENTS.md' }] },
+    })
+    const value = assembler([
+      at(1, 'turn/start', { turn: 1 }),
+      at(2, 'user/message', textMessage('gesture', '/demo-skill go'), { surfaceOp: 'append' }),
+      at(3, 'step/start', { turn: 1, step: 1 }),
+      at(4, 'user/message', instructions('rules-1'), { surfaceOp: 'append' }),
+      at(5, 'user/message', skillInvocation('skill-body'), { surfaceOp: 'append' }),
+      at(6, 'assistant/message', {
+        turn: 1,
+        step: 1,
+        message: assistantMessage('answer-1', 'done'),
+      }, { surfaceOp: 'append' }),
+      at(7, 'step/end', { turn: 1, step: 1 }),
+      at(8, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
+      at(9, 'turn/start', { turn: 2 }),
+      at(10, 'user/message', textMessage('later', '/demo-skill again?'), { surfaceOp: 'append' }),
+      at(11, 'step/start', { turn: 2, step: 1 }),
+      at(12, 'user/message', instructions('rules-2'), { surfaceOp: 'append' }),
+    ])
+
+    const users = [...snapshot(value).nodes.values()].filter(candidate => candidate.kind === 'user')
+    expect(users).toHaveLength(2)
+    expect(users[0]?.data).toMatchObject({ skillNames: ['demo-skill'] })
+    expect(users[1]?.data).not.toHaveProperty('skillNames')
+  })
+
+  it('updates an already published direct node when its skill injection arrives', () => {
+    const value = assembler([
+      at(1, 'user/message', textMessage('gesture', '/demo-skill go'), { surfaceOp: 'append' }),
+    ])
+    const before = node(snapshot(value), 'user')
+    expect(before?.data).not.toHaveProperty('skillNames')
+
+    value.append(at(2, 'user/message', {
+      ...textMessage('skill-body', 'instructions'),
+      source: { kind: 'skill-invocation', name: 'demo-skill', form: 'instructions' },
+    }, { surfaceOp: 'append' }))
+    value.flush()
+
+    const after = node(snapshot(value), 'user')
+    expect(after?.key).toBe(before?.key)
+    expect(after?.data).toMatchObject({ skillNames: ['demo-skill'] })
+  })
+
   it('keeps replacement copies out of Chat business nodes', () => {
     const value = assembler([
       at(1, 'turn/start', { turn: 1 }),

+ 88 - 0
packages/client/ui-chat/tests/skill-name-projector.client.spec.ts

@@ -0,0 +1,88 @@
+/**
+ * SkillNameProjector: the step's `skill-invocation` injections attach to the
+ * direct messages of the same batch, incrementally — an assistant-only apply
+ * must neither scan the store nor re-emit message Nodes.
+ */
+import { describe, expect, it } from 'vitest'
+import type { ChatConversationViewNode } from '../src/client/contract/chat-nodes.ts'
+import type { ChatNodeStore } from '../src/client/contract/snapshot.ts'
+import { SkillNameProjector } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
+
+function viewNode(kind: string, seq: number, data: Record<string, unknown>): ChatConversationViewNode {
+  return {
+    key: `${kind}:${seq}`,
+    kind,
+    id: String(seq),
+    target: 'chat',
+    anchorSeq: seq,
+    location: { kind: 'session' },
+    visibility: 'visible',
+    data: { kind, seq, time: seq, ...data },
+  } as unknown as ChatConversationViewNode
+}
+const user = (seq: number, text: string) =>
+  viewNode('user', seq, { content: [{ type: 'text', text }], source: { kind: 'user' } })
+const skill = (seq: number, name: string) =>
+  viewNode('context', seq, { content: [], source: { kind: 'skill-invocation', name, form: 'instructions' } })
+const instructions = (seq: number) =>
+  viewNode('context', seq, { content: [], source: { kind: 'agent-instructions', changes: [] } })
+const assistant = (seq: number, text = 'answer') => viewNode('assistant-step', seq, { text })
+
+function storeOf(nodes: readonly ChatConversationViewNode[]): ChatNodeStore & { valuesCalls: number } {
+  const byKey = new Map(nodes.map(node => [node.key, node]))
+  const store = {
+    valuesCalls: 0,
+    get: (key: string) => byKey.get(key),
+    values: () => {
+      store.valuesCalls += 1
+      return [...byKey.values()]
+    },
+    source: () => { throw new Error('unused') },
+    processSource: () => { throw new Error('unused') },
+  }
+  return store
+}
+
+const names = (node: ChatConversationViewNode | undefined) =>
+  (node?.data as { skillNames?: readonly string[] } | undefined)?.skillNames
+
+describe('SkillNameProjector', () => {
+  it('attaches a batch\'s injected skill names on replace and leaves other Nodes untouched', () => {
+    const projector = new SkillNameProjector()
+    const answer = assistant(6)
+    const out = projector.replace([user(2, '/demo go'), instructions(4), skill(5, 'demo'), answer, user(10, '/demo later?')])
+    expect(names(out[0])).toEqual(['demo'])
+    expect(names(out[4])).toBeUndefined()
+    expect(out[3]).toBe(answer)
+  })
+
+  it('an assistant-only apply neither scans the store nor re-emits message Nodes', () => {
+    const projector = new SkillNameProjector()
+    const replaced = projector.replace([user(2, '/demo go'), skill(5, 'demo'), assistant(6)])
+    const store = storeOf(replaced)
+    const frame = assistant(6, 'answer grows')
+    const out = projector.apply([frame], store)
+    expect(out).toEqual([frame])
+    expect(store.valuesCalls).toBe(0)
+  })
+
+  it('a late skill injection updates only the direct messages of its batch', () => {
+    const projector = new SkillNameProjector()
+    const replaced = projector.replace([user(2, '/demo go'), assistant(6), user(10, 'unrelated')])
+    const store = storeOf(replaced)
+    const injection = skill(3, 'demo')
+    const out = projector.apply([injection], store)
+    expect(out.map(node => node.key).sort()).toEqual(['context:3', 'user:2'])
+    expect(names(out.find(node => node.key === 'user:2'))).toEqual(['demo'])
+  })
+
+  it('a boundary arriving inside a batch splits it and drops the names past it', () => {
+    const projector = new SkillNameProjector()
+    const replaced = projector.replace([user(2, '/demo go'), skill(4, 'demo'), user(8, '/demo again')])
+    expect(names(replaced[2])).toEqual(['demo'])
+    const store = storeOf(replaced)
+    const out = projector.apply([assistant(6)], store)
+    expect(out.map(node => node.key).sort()).toEqual(['assistant-step:6', 'user:8'])
+    expect(names(out.find(node => node.key === 'user:8'))).toBeUndefined()
+  })
+})

+ 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,
   SubmitAttachment, SubmitEnvelope, 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 () => {

+ 9 - 1
packages/client/ui-conversation/src/client/input/decorations.ts

@@ -23,12 +23,19 @@ export interface TextRefRange {
 /** Token matcher: a trigger char at line start or after whitespace, then a word-ish name (never crosses \n). */
 const TEXT_REF_RE = /(^|\s)([/@])([\w-]+)/g
 const FOLDER_REF_RE = /(^|\s)(@(?:"[^"\n]*\/|[^\s"]+\/))/g
+/**
+ * What may follow a `/name` token: whitespace or the draft end, the boundary
+ * the host skill gesture (`dsh-tool-skill`) requires, so `/nfs-hg/xxx`,
+ * `/plan.md`, and `/plan。` are prose, never a reference.
+ */
+const SLASH_TOKEN_END_RE = /^(?:\s|$)/
 
 /**
  * Scan the draft for plain-text reference tokens against the hot lexicons.
  * Word-boundary discipline: the trigger must sit at the draft
  * start or after whitespace ('x/name' never matches); the name must be an
- * exact lexicon member.
+ * exact lexicon member; a `/name` token must end at whitespace or the draft
+ * end ('/name/x' is a path, '/name。' is prose).
  * @param draft - draft text.
  * @param lexicon - per-trigger name lists (a missing trigger scans nothing).
  * @returns matched ranges in draft order.
@@ -44,6 +51,7 @@ export function scanTextRefs(
     while ((m = TEXT_REF_RE.exec(draft)) !== null) {
       const trigger = m[2] as '/' | '@'
       const name = m[3] ?? ''
+      if (trigger === '/' && !SLASH_TOKEN_END_RE.test(draft.slice(m.index + m[0].length))) continue
       if (lexicon.get(trigger)?.includes(name)) {
         const start = m.index + (m[1]?.length ?? 0)
         out.push({ start, end: start + 1 + name.length, trigger })

+ 8 - 0
packages/client/ui-conversation/tests/submit-machine.client.spec.ts

@@ -347,6 +347,14 @@ describe('decorations: scanTextRefs', () => {
     expect(scanTextRefs('/research @goal', lexicon)).toEqual([])
   })
 
+  it('a "/" token continued by a path never matches, even when the name is on the lexicon', () => {
+    expect(scanTextRefs('/goal/x /goal/ /goal.md', lexicon)).toEqual([])
+  })
+
+  it('a "/" token glued to punctuation is not a reference: the host gesture is whitespace-bounded', () => {
+    expect(scanTextRefs('/goal。 then /goal, now', lexicon)).toEqual([])
+  })
+
   it('word boundary: a trigger glued to text never matches', () => {
     expect(scanTextRefs('x/goal y@research', lexicon)).toEqual([])
   })

+ 2 - 2
packages/client/ui-goal/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-goal/README.md
-README.md: 1a2c65759f5983fbece83f538ee9ee09902dd020
-README.zh.md: feebeaff0b5e7e9258f15637d0a78a897e0f24c1
+README.md: 8556575b00e1d3073f251d83f3dee0bafb4c8969
+README.zh.md: 22d0417bc2d37719150ec46aad849ad2bce8b460

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

@@ -29,7 +29,7 @@ Mount this plugin alongside `ui-conversation` and the goal domain package; the s
 
 ### The command-input bubble
 
-Each durable `/goal` run projects as a right-aligned monospace user-style bubble labeled `Command input` (or `指令输入`), rendered before the generic command result row. It carries no timestamp, copy, or branch actions, and reloading reconstructs it from the run.
+Each durable `/goal` run projects as a right-aligned user-style bubble labeled `Command input` (or `指令输入`), rendered before the generic command result row; the leading `/goal` token renders as a command reference chip in the code face through ui-primitives `projectUserText`, and the objective stays plain body text. It carries no timestamp, copy, or branch actions, and reloading reconstructs it from the run.
 
 ### Failures
 

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

@@ -29,7 +29,7 @@ kind: "package-reference"
 
 ### 指令输入气泡
 
-每条持久的 `/goal` 运行都投影为一个右对齐的等宽用户样式气泡,标签为 `Command input`(或 `指令输入`),渲染在通用命令结果行之前。它不含时间戳、复制或分支操作,重新加载时会依据运行记录重建。
+每条持久的 `/goal` 运行都投影为一个右对齐的用户样式气泡,标签为 `Command input`(或 `指令输入`),渲染在通用命令结果行之前;开头的 `/goal` token 经 ui-primitives 的 `projectUserText` 以等宽代码字体渲染为指令引用 chip,目标文本保持正文字体。它不含时间戳、复制或分支操作,重新加载时会依据运行记录重建。
 
 ### 失败
 

+ 4 - 4
packages/client/ui-goal/src/client/GoalCommandInputView.module.css

@@ -23,10 +23,10 @@
   border-radius: 22px;
   background: var(--dsw-specific-bubble);
   color: var(--dsw-alias-label-primary);
-  font: var(--dsw-font-markdown-code);
-  /* The command echo is a user message, not dense code-in-prose: keep the
-     code family from the token but read at the body size, riding the
-     Settings font-size axis like the user bubble (14/22 at the default). */
+  /* The command echo is a user message: it inherits the body face like the
+     user bubble and rides the same Settings font-size axis (14/22 at the
+     default); the `/goal` command chip, set in the code face by the shared
+     projection, is what marks the line as a command. */
   font-size: var(--dsh-content-font-size, 14px);
   line-height: calc(22px + var(--dsh-content-font-delta, 0px));
   white-space: pre-wrap;

+ 15 - 4
packages/client/ui-goal/src/client/GoalCommandInputView.tsx

@@ -1,18 +1,28 @@
 import { memo } from 'react'
-import { MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
+import { projectUserText } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
-import type { GoalCommandInputData } from './goal-command-input.ts'
+import { GOAL_COMMAND, type GoalCommandInputData } from './goal-command-input.ts'
 import css from './GoalCommandInputView.module.css'
 
 type GoalCommandInputViewProps =
   PropsRuntime<'conversation.chat.node', 'command-input'>
   & PropsLocale<'goal'>
 
-/** Right-aligned `/goal` input bubble without ordinary message actions. */
+/**
+ * Right-aligned `/goal` input bubble without ordinary message actions. The
+ * echoed line decorates its leading `/goal` token as a command chip — the run
+ * this Node projects is the fact that that token was a command — and keeps
+ * the objective, `/goal` mentions included, as plain text.
+ */
 export const GoalCommandInputView = memo(function GoalCommandInputView({
   node, t,
 }: GoalCommandInputViewProps) {
   const data: GoalCommandInputData = node.data
+  // Only the leading token is the executed command; the rest of the line is
+  // the objective, where a further `/goal` is prose.
+  const split = data.text.search(/\s/u)
+  const head = split === -1 ? data.text : data.text.slice(0, split)
+  const rest = split === -1 ? '' : data.text.slice(split)
   return (
     <div
       className={css.row}
@@ -22,7 +32,8 @@ export const GoalCommandInputView = memo(function GoalCommandInputView({
     >
       <div className={css.stack}>
         <div className={css.bubble}>
-          <MessageText text={data.text} />
+          {projectUserText(head, [], [GOAL_COMMAND], 'command')}
+          {rest !== '' && projectUserText(rest, [])}
         </div>
       </div>
     </div>

+ 4 - 1
packages/client/ui-goal/src/client/goal-command-input.ts

@@ -5,6 +5,9 @@ import type {
   ConversationNodeDefinition,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 
+/** The command name whose runs this projection owns. */
+export const GOAL_COMMAND = 'goal'
+
 /** Goal-owned human command input projected independently of model messages. */
 export interface GoalCommandInputData {
   readonly commandId: CommandId
@@ -36,7 +39,7 @@ export function goalCommandText(event: SessionEvent<'command/run'>): string {
 export const goalCommandInputDefinition: ConversationNodeDefinition<GoalCommandInputState> = {
   kind: 'goal-command-input',
   target: 'chat',
-  match: event => event.type === 'command/run' && event.data.name === 'goal'
+  match: event => event.type === 'command/run' && event.data.name === GOAL_COMMAND
     ? { id: String(event.data.commandId), role: 'start' }
     : null,
   start: (_context, match) => {

+ 31 - 0
packages/client/ui-goal/tests/goal-command-input-styles.client.spec.ts

@@ -0,0 +1,31 @@
+/**
+ * The command-input bubble's typography as CSS text. jsdom has no layout, so
+ * this reads the declarations that make the bubble share the user bubble's
+ * face and size axis: the shared projection sets the `/goal` chip in the code
+ * face, so the bubble itself must not pin a different family.
+ */
+import { readFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+
+const css = readFileSync(
+  fileURLToPath(new URL('../src/client/GoalCommandInputView.module.css', import.meta.url)),
+  'utf8',
+).replace(/\/\*[\s\S]*?\*\//g, ' ')
+
+function declarations(selector: string): string[] {
+  const rule = new RegExp(`(?:^|\\})\\s*${selector.replace(/[.[\]():*+^$\\]/g, '\\$&')}\\s*\\{([^{}]*)\\}`).exec(css)
+  if (rule === null) throw new Error(`GoalCommandInputView.module.css has no \`${selector}\` rule`)
+  return (rule[1] ?? '').split(';').map(part => part.trim()).filter(Boolean)
+}
+
+describe('GoalCommandInputView.module.css typography', () => {
+  it('types the command bubble like the user bubble: no family override, same size axis', () => {
+    const bubble = declarations('.bubble')
+    expect(bubble.some(declaration => /^font(-family)?:/.test(declaration))).toBe(false)
+    expect(bubble).toEqual(expect.arrayContaining([
+      'font-size: var(--dsh-content-font-size, 14px)',
+      'line-height: calc(22px + var(--dsh-content-font-delta, 0px))',
+    ]))
+  })
+})

+ 34 - 0
packages/client/ui-goal/tests/goal-command-input.client.spec.tsx

@@ -133,5 +133,39 @@ describe('goal command input projection', () => {
 
     expect(bubble.textContent).toBe('/goal ship it')
     expect(within(bubble).queryByRole('button')).toBeNull()
+    // The executed command token reads as a reference chip; the objective stays plain text.
+    const chips = [...bubble.querySelectorAll('[data-ref-chip]')]
+    expect(chips.map(chip => [chip.getAttribute('data-ref-chip'), chip.textContent])).toEqual([['command', '/goal']])
+  })
+
+  it('renders a bare /goal as one command chip and nothing else', () => {
+    const t = makeTranslate(zh, commonZh)
+    const props = {
+      node: {
+        key: 'goal-command-input:bare',
+        data: { commandId: 'command-goal', text: '/goal', time: 1_700_000_000_000 },
+      },
+      t,
+    } as unknown as Parameters<typeof GoalCommandInputView>[0]
+    const view = render(<GoalCommandInputView {...props} />)
+    const bubble = view.getByRole('group', { name: '指令输入' })
+    expect(bubble.textContent).toBe('/goal')
+    expect([...bubble.querySelectorAll('[data-ref-chip]')].map(chip => chip.textContent)).toEqual(['/goal'])
+  })
+
+  it('decorates only the leading command token: a /goal inside the objective stays plain', () => {
+    const t = makeTranslate(zh, commonZh)
+    const props = {
+      node: {
+        key: 'goal-command-input:two',
+        data: { commandId: 'command-goal', text: '/goal 检查 /goal 的语法', time: 1_700_000_000_000 },
+      },
+      t,
+    } as unknown as Parameters<typeof GoalCommandInputView>[0]
+    const view = render(<GoalCommandInputView {...props} />)
+    const bubble = view.getByRole('group', { name: '指令输入' })
+    expect(bubble.textContent).toBe('/goal 检查 /goal 的语法')
+    const chips = [...bubble.querySelectorAll('[data-ref-chip]')]
+    expect(chips.map(chip => chip.textContent)).toEqual(['/goal'])
   })
 })

+ 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: 758132bc9c193594a9407a105820633c75e58880
+README.zh.md: 8b3fffa9e73464d526c3a2f11b212797c2707c69

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

@@ -29,11 +29,11 @@ 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
 
-`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax ([incremental renderer](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md), [streaming fence highlighting](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md)). `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `MessageText` remains the literal-text primitive for user-authored content.
+`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax ([incremental renderer](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md), [streaming fence highlighting](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md)). `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows.
 
 ### Localizing copy
 

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

@@ -29,11 +29,11 @@ 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 输出
 
-`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM([增量渲染器](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md)、[流式 fence 高亮](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md))。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`MessageText` 仍是用户创作内容的字面文本原语。
+`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM([增量渲染器](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md)、[流式 fence 高亮](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md))。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。
 
 ### 本地化文案
 

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

@@ -36,6 +36,7 @@ export { fileSizeText } from './file-size.ts'
 export { DocumentFileIcon } from './DocumentFileIcon.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'
@@ -59,7 +60,6 @@ export type { CodeBlockProps } from './markdown/CodeBlock.tsx'
 export { JsonBlock } from './markdown/JsonBlock.tsx'
 export { MarkdownText } from './markdown/MarkdownText.tsx'
 export type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownLabels } from './markdown/MarkdownText.tsx'
-export { MessageText } from './markdown/MessageText.tsx'
 export { extractMarkdownPlainText } from './markdown/plain-text.ts'
 export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts'
 export * from './icons/index.tsx'

+ 0 - 9
packages/client/ui-primitives/src/markdown/MessageText.module.css

@@ -1,9 +0,0 @@
-.text {
-  white-space: pre-wrap;
-  word-break: break-word;
-  /* Font metrics inherit from the consumer's container (bubble 16/24,
-     assistant flow 16/28) — a generic text primitive must not pin its own
-     size, or every consumer's line grid breaks (44px bubble spec regression). */
-  font-size: inherit;
-  line-height: inherit;
-}

+ 0 - 7
packages/client/ui-primitives/src/markdown/MessageText.tsx

@@ -1,7 +0,0 @@
-// MessageText is the literal-text primitive for user and steering content; assistant output uses MarkdownText.
-
-import css from './MessageText.module.css'
-
-export function MessageText({ text }: { text: string }) {
-  return <div className={css.text}>{text}</div>
-}

+ 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)
+}

+ 8 - 0
packages/client/ui-primitives/src/user-text.module.css

@@ -20,6 +20,14 @@
   white-space: nowrap;
 }
 
+/* Skill and command tokens read as code: the theme's code family at the
+   consumer's own size and line height, so the chip sits on the bubble line
+   like the plain runs around it. `@` chips keep the body face — a file or
+   session name is a label, not code. */
+.slashChip {
+  font-family: var(--dsw-font-markdown-code-font-family);
+}
+
 /* Inline reference glyphs (always ReferenceIcon svgs) ride the consumer's own
    font: 1em keeps the glyph at the text's size in the bubble (14px + user
    setting), the queue preview's fixed 13px line, and any future consumer — a

+ 31 - 9
packages/client/ui-primitives/src/user-text.tsx

@@ -2,19 +2,29 @@
  * Display projection of reference forms in sent user text (bubble and queue
  * rows). The logged model text remains the single truth; this is presentation
  * only, and every part renders inline so a single-line message never breaks
- * across lines. Three decoration sources, by precedence: the wire session form
+ * across lines. Four decoration sources, by precedence: the wire session form
  * `@[label](dsh-session:...)` folds to its label; exact session labels
- * supplied by an adjacent recall decorate their bare `@label` mention; and
- * plain `/name` / `@name` word-boundary tokens decorate by shape alone (sent
- * tokens were validated at compose time).
+ * supplied by an adjacent recall decorate their bare `@label` mention; plain
+ * `@name` word-boundary tokens decorate by shape alone; and a plain `/name`
+ * token decorates only when the caller names it — a skill the host actually
+ * loaded for that message (ui-chat reads the step's `skill-invocation`
+ * injections) or the command a command-input bubble echoes — so `/123` or a
+ * stray `/word` stays plain text. A `/name` token is whitespace-bounded like
+ * the host skill gesture (`dsh-tool-skill`): it ends at whitespace or the
+ * text end, so slash paths (`/nfs-hg/xxx`, `/plan.md`) and punctuation-glued
+ * tokens (`/plan。`) stay plain even for a loaded name.
  */
 import type { ReactNode } from 'react'
+import clsx from 'clsx'
 import { ReferenceIcon } from './ReferenceIcon.tsx'
 import css from './user-text.module.css'
 
 /** The wire form a session chip serializes to; label is the display text. */
 const SESSION_WIRE_RE = /@\[([^\]\n]+)\]\(dsh-session:[^)\s]+\)/gu
 
+/** Sentence punctuation a bare `@name` token may carry without being part of the reference. */
+const TRAILING_PUNCTUATION_RE = /[.,;:!?,。;:!?]+$/u
+
 interface DecorationRange {
   readonly start: number
   readonly end: number
@@ -29,9 +39,18 @@ interface DecorationRange {
  * Split one sent text into inline plain runs and reference chips.
  * @param text - the logged model text of the message or queue row.
  * @param sessionLabels - exact session mention labels associated by an adjacent recall.
+ * @param slashNames - names a `/name` token may decorate as: the skills the
+ * host loaded for this message, or the command a command bubble echoes
+ * (unsent queue rows pass none).
+ * @param slashKind - the chip kind those tokens render as.
  * @returns inline nodes covering the whole text.
  */
-export function projectUserText(text: string, sessionLabels: readonly string[]): ReactNode {
+export function projectUserText(
+  text: string,
+  sessionLabels: readonly string[],
+  slashNames: readonly string[] = [],
+  slashKind: 'skill' | 'command' = 'skill',
+): ReactNode {
   const ranges: DecorationRange[] = []
   SESSION_WIRE_RE.lastIndex = 0
   let wire: RegExpExecArray | null
@@ -52,15 +71,18 @@ export function projectUserText(text: string, sessionLabels: readonly string[]):
       start = text.indexOf(label, start + label.length)
     }
   }
-  const re = /(^|\s)(\/[\w-]+|@"[^"\n]+"|@[^\s]+)/gu
+  // A `/` token ends at whitespace or the text end like the host skill
+  // gesture; only `@` tokens shed sentence punctuation below.
+  const re = /(^|\s)(\/[\w-]+(?=\s|$)|@"[^"\n]+"|@[^\s]+)/gu
   let m: RegExpExecArray | null
   while ((m = re.exec(text)) !== null) {
     const tokenStart = m.index + (m[1] as string).length // (^|\s) captures '' at line start
     const rawLabel = m[2] as string // non-optional alternation capture
     const label = rawLabel.startsWith('@"')
       ? rawLabel
-      : rawLabel.replace(/[.,;:!?,。;:!?]+$/gu, '')
+      : rawLabel.replace(TRAILING_PUNCTUATION_RE, '')
     if (label.length <= 1) continue
+    if (label.startsWith('/') && !slashNames.includes(label.slice(1))) continue
     ranges.push({ start: tokenStart, end: tokenStart + label.length, label, kind: 'plain' })
   }
   const rankOf = (range: DecorationRange): number => range.kind === 'session' ? 0 : 1
@@ -88,8 +110,8 @@ export function projectUserText(text: string, sessionLabels: readonly string[]):
     parts.push(
       <span
         key={tokenStart}
-        className={css.refChip}
-        data-ref-chip={referenceKind ?? 'skill'}
+        className={clsx(css.refChip, referenceKind === undefined && css.slashChip)}
+        data-ref-chip={referenceKind ?? slashKind}
         title={label}
       >
         {referenceKind !== undefined && (

+ 0 - 9
packages/client/ui-primitives/tests/markdown.client.spec.tsx

@@ -1,21 +1,12 @@
 // @vitest-environment jsdom
 import { cleanup, fireEvent, render, screen } from '@testing-library/react'
 import { afterEach, describe, expect, it } from 'vitest'
-import { MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
 import { JsonBlock, MarkdownText } from './markdown-test-components.tsx'
 import { cjkFriendlyStrong } from '../src/markdown/cjkFriendlyStrong.ts'
 import { mathCompatibility } from '../src/markdown/mathCompatibility.ts'
 
 afterEach(cleanup)
 
-describe('MessageText', () => {
-  it('renders the text verbatim', () => {
-    const { container } = render(<MessageText text={'# line1\n`line2`'} />)
-    expect(container.textContent).toBe('# line1\n`line2`')
-    expect(container.querySelector('h1')).toBeNull()
-  })
-})
-
 describe('MarkdownText', () => {
   it('renders CommonMark and GFM elements as semantic DOM', () => {
     const markdown = [

+ 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' }])
+  })
+})

+ 9 - 0
packages/client/ui-primitives/tests/user-text-styles.client.spec.ts

@@ -24,4 +24,13 @@ describe('user-text.module.css font-size axis', () => {
       'height: 1em',
     ]))
   })
+
+  it('sets slash chips in the code face at the consumer size', () => {
+    // Skill and command tokens read as code — the family the theme publishes
+    // for code — while the size and line height stay the consumer's, so the
+    // chip rides the bubble line like the plain runs around it.
+    const slashChip = declarations('.slashChip')
+    expect(slashChip.some(declaration => /^font-family: var\(--dsw-font-/.test(declaration))).toBe(true)
+    expect(slashChip.some(declaration => /^(font|font-size|line-height):/.test(declaration))).toBe(false)
+  })
 })

+ 38 - 7
packages/client/ui-primitives/tests/user-text.client.spec.tsx

@@ -8,12 +8,17 @@ import { describe, expect, it } from 'vitest'
 import { render } from '@testing-library/react'
 import { projectUserText } from '../src/user-text.tsx'
 
-const project = (text: string, labels: readonly string[] = []) =>
-  render(<div data-host>{projectUserText(text, labels)}</div>).container.querySelector('[data-host]')!
+const project = (
+  text: string,
+  labels: readonly string[] = [],
+  slashNames: readonly string[] = [],
+  slashKind: 'skill' | 'command' = 'skill',
+) =>
+  render(<div data-host>{projectUserText(text, labels, slashNames, slashKind)}</div>).container.querySelector('[data-host]')!
 
 describe('projectUserText', () => {
   it('keeps a decorated single-line message on one line: every part is inline', () => {
-    const host = project('反反复复 /dsh-acp-test @执行几个命令测试', ['执行几个命令测试'])
+    const host = project('反反复复 /dsh-acp-test @执行几个命令测试', ['执行几个命令测试'], ['dsh-acp-test'])
     expect(host.querySelectorAll('div').length).toBe(0)
     expect(host.textContent).toBe('反反复复 /dsh-acp-test 执行几个命令测试')
     const chips = host.querySelectorAll('[data-ref-chip]')
@@ -55,13 +60,39 @@ describe('projectUserText', () => {
     expect(host.querySelectorAll('[data-ref-chip="session"]').length).toBe(2)
   })
 
-  it('strips trailing punctuation and skips degenerate tokens', () => {
-    const host = project('用 /plan。 试试 @。')
-    const chips = [...host.querySelectorAll('[data-ref-chip]')]
-    expect(chips.map(c => c.textContent)).toEqual(['/plan'])
+  it('keeps a punctuation-glued slash token plain and skips degenerate tokens', () => {
+    // The host skill gesture ends at whitespace or the text end, so `/plan。`
+    // never loads a skill; the bubble must not suggest otherwise.
+    const host = project('用 /plan。 试试 @。', [], ['plan'])
+    expect(host.querySelectorAll('[data-ref-chip]').length).toBe(0)
     expect(host.textContent).toBe('用 /plan。 试试 @。')
   })
 
+  it('decorates a slash token only when the host resolved it as a skill in that step', () => {
+    const bare = project('/123')
+    expect(bare.querySelectorAll('[data-ref-chip]').length).toBe(0)
+    expect(bare.textContent).toBe('/123')
+    const unresolved = project('用 /plan 看看')
+    expect(unresolved.querySelectorAll('[data-ref-chip]').length).toBe(0)
+    const resolved = project('用 /plan 看看', [], ['plan'])
+    expect([...resolved.querySelectorAll('[data-ref-chip]')].map(c => [c.getAttribute('data-ref-chip'), c.textContent]))
+      .toEqual([['skill', '/plan']])
+  })
+
+  it('marks a resolved slash token as a command chip when the caller says so', () => {
+    const host = project('/goal ship it\nsecond line', [], ['goal'], 'command')
+    const chips = [...host.querySelectorAll('[data-ref-chip]')]
+    expect(chips.map(c => [c.getAttribute('data-ref-chip'), c.textContent])).toEqual([['command', '/goal']])
+    expect(host.textContent).toBe('/goal ship it\nsecond line')
+  })
+
+  it('leaves slash paths undecorated even for a resolved name: a /name token ends at whitespace', () => {
+    const text = '测试一下ui,不用管我:\n/nfs-hg/xxx/yyy 与 /root-dir/ 和 /plan.md'
+    const host = project(text, [], ['nfs-hg', 'root-dir', 'plan'])
+    expect(host.querySelectorAll('[data-ref-chip]').length).toBe(0)
+    expect(host.textContent).toBe(text)
+  })
+
   it('prefers the longer recall label when one nests inside another', () => {
     const host = project('@会话一 收尾', ['会话', '会话一'])
     const chips = [...host.querySelectorAll('[data-ref-chip="session"]')]

+ 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 () => {