Ver código fonte

fix(web): decorate slash tokens in bubbles from logged skill and command facts

Yichen Jiang 2 semanas atrás
pai
commit
d0da8ccf40
30 arquivos alterados com 342 adições e 70 exclusões
  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. 5 2
      packages/client/ui-chat/src/client/chat/MessageItem.tsx
  5. 82 3
      packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts
  6. 11 0
      packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts
  7. 4 0
      packages/client/ui-chat/src/client/conversation-nodes/message.ts
  8. 24 3
      packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx
  9. 52 0
      packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts
  10. 10 1
      packages/client/ui-conversation/src/client/input/decorations.ts
  11. 8 0
      packages/client/ui-conversation/tests/submit-machine.client.spec.ts
  12. 2 2
      packages/client/ui-goal/README.i18n.yaml
  13. 1 1
      packages/client/ui-goal/README.md
  14. 1 1
      packages/client/ui-goal/README.zh.md
  15. 4 4
      packages/client/ui-goal/src/client/GoalCommandInputView.module.css
  16. 9 4
      packages/client/ui-goal/src/client/GoalCommandInputView.tsx
  17. 4 1
      packages/client/ui-goal/src/client/goal-command-input.ts
  18. 31 0
      packages/client/ui-goal/tests/goal-command-input-styles.client.spec.ts
  19. 3 0
      packages/client/ui-goal/tests/goal-command-input.client.spec.tsx
  20. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  21. 1 1
      packages/client/ui-primitives/README.md
  22. 1 1
      packages/client/ui-primitives/README.zh.md
  23. 0 1
      packages/client/ui-primitives/src/index.ts
  24. 0 9
      packages/client/ui-primitives/src/markdown/MessageText.module.css
  25. 0 7
      packages/client/ui-primitives/src/markdown/MessageText.tsx
  26. 8 0
      packages/client/ui-primitives/src/user-text.module.css
  27. 30 8
      packages/client/ui-primitives/src/user-text.tsx
  28. 0 9
      packages/client/ui-primitives/tests/markdown.client.spec.tsx
  29. 9 0
      packages/client/ui-primitives/tests/user-text-styles.client.spec.ts
  30. 34 4
      packages/client/ui-primitives/tests/user-text.client.spec.tsx

+ 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: 500f0acee97f225b0e1a05107f12a5c3ee814bfe
+2026-07-25-web-input-machine-and-slash-pipeline.zh.md: cfe0781b945e4570f8f8ca5a6b71a95a2eafadcf

+ 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, the draft end, or trailing sentence punctuation — the whitespace-bounded shape of the host skill gesture, so `/nfs-hg/xxx` is a path; 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` 是路径;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,无需菜单交互或无关重渲染。
 
 ### 每会话供数贡献与键盘私面

+ 5 - 2
packages/client/ui-chat/src/client/chat/MessageItem.tsx

@@ -148,7 +148,7 @@ function TurnMaxTokensItem({ t }: {
 
 /** Right-aligned bubble shared by user and steering rows. */
 function UserStyleBubble({
-  content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewImages, t,
+  content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], skillNames = [], previewImages, t,
 }: {
   content: readonly unknown[]
   renderMessageImages: ChatNodeOwnerProps['renderMessageImages']
@@ -160,6 +160,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 previews replacing the content-derived image group. */
   previewImages?: readonly MessageImageSource[]
   t: ChatViewSlotProps['t']
@@ -177,7 +179,7 @@ function UserStyleBubble({
       <div className={css.userStack}>
         {renderMessageImages({ images, align: 'end' })}
         {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 && (
@@ -279,6 +281,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

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

@@ -12,7 +12,7 @@ import type {
   PartialAssistant, RunningToolCall, 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'
 
@@ -506,6 +506,84 @@ 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 }
+}
+
+/**
+ * Skill names each direct message's step loaded, keyed by message Node key.
+ *
+ * A step's `skill-invocation` injections follow the direct messages the host
+ * scanned for `/name` gestures and precede the step's first non-message Node
+ * (the assistant step, a Turn error, a command); every Node of another kind
+ * therefore closes the batch. Names attach to every direct message of the
+ * batch: the bubble decorates only the tokens its own text carries.
+ * @param nodes - every materialized Chat Node, in any order.
+ * @returns the loaded skill names per direct message key; absent for none.
+ */
+function skillNamesByMessage(nodes: readonly ChatConversationViewNode[]): Map<string, readonly string[]> {
+  const ordered = [...nodes].sort((left, right) => left.anchorSeq - right.anchorSeq)
+  const result = new Map<string, readonly string[]>()
+  let batchMessages: string[] = []
+  let batchNames: string[] = []
+  const flush = (): void => {
+    if (batchNames.length > 0) for (const key of batchMessages) result.set(key, batchNames)
+    batchMessages = []
+    batchNames = []
+  }
+  for (const node of ordered) {
+    const candidate = node as ChatNode
+    if (candidate.kind === 'user' || candidate.kind === 'steering') {
+      batchMessages.push(node.key)
+      continue
+    }
+    if (candidate.kind !== 'context') {
+      flush()
+      continue
+    }
+    const name = skillInvocationName(candidate.data.source)
+    if (name !== null && !batchNames.includes(name)) batchNames.push(name)
+  }
+  flush()
+  return result
+}
+
+/** Attaches each direct message's step-loaded skill names to its Node. */
+class SkillNameProjector {
+  replace(nodes: readonly ChatConversationViewNode[]): readonly ChatConversationViewNode[] {
+    const names = skillNamesByMessage(nodes)
+    return nodes.map(node => withSkillNames(node, names.get(node.key) ?? EMPTY_KEYS))
+  }
+
+  apply(
+    upserts: readonly ChatConversationViewNode[],
+    store: ChatNodeStore,
+  ): readonly ChatConversationViewNode[] {
+    const byKey = new Map(upserts.map(node => [node.key, node]))
+    const all = new Map(store.values().map(node => [node.key, node]))
+    for (const [key, node] of byKey) all.set(key, node)
+    const names = skillNamesByMessage([...all.values()])
+    for (const [key, node] of all) {
+      const candidate = node as ChatNode
+      if (candidate.kind !== 'user' && candidate.kind !== 'steering') continue
+      const next = withSkillNames(node, names.get(key) ?? EMPTY_KEYS)
+      if (next !== node || byKey.has(key)) byKey.set(key, next)
+    }
+    return [...byKey.values()]
+  }
+}
+
 interface LegacyContribution {
   readonly anchorSeq: number
   readonly nodes: readonly ConversationNode[]
@@ -756,6 +834,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
@@ -769,7 +848,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)
@@ -785,7 +864,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

@@ -9,11 +9,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 }),

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

@@ -23,12 +23,20 @@ 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, optionally
+ * after trailing sentence punctuation. The host skill gesture
+ * (`dsh-tool-skill`) is whitespace-bounded, so `/nfs-hg/xxx` or `/plan.md`
+ * is a path, 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, the draft
+ * end, or trailing sentence punctuation ('/name/x' is a path).
  * @param draft - draft text.
  * @param lexicon - per-trigger name lists (a missing trigger scans nothing).
  * @returns matched ranges in draft order.
@@ -44,6 +52,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 may end at trailing punctuation before whitespace', () => {
+    expect(scanTextRefs('/goal。 then', lexicon)).toEqual([{ start: 0, end: 5, trigger: '/' }])
+  })
+
   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;

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

@@ -1,14 +1,19 @@
 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 the token was a command — and keeps
+ * the objective as plain text.
+ */
 export const GoalCommandInputView = memo(function GoalCommandInputView({
   node, t,
 }: GoalCommandInputViewProps) {
@@ -22,7 +27,7 @@ export const GoalCommandInputView = memo(function GoalCommandInputView({
     >
       <div className={css.stack}>
         <div className={css.bubble}>
-          <MessageText text={data.text} />
+          {projectUserText(data.text, [], [GOAL_COMMAND], 'command')}
         </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))',
+    ]))
+  })
+})

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

@@ -133,5 +133,8 @@ 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']])
   })
 })

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

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

@@ -33,7 +33,7 @@ Compose feature UI from these atoms whenever the web client needs a standard con
 
 ### 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
 

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

@@ -33,7 +33,7 @@ kind: "package-library"
 
 ### 渲染 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,供消息气泡和排队行使用
 
 ### 本地化文案
 

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

@@ -58,7 +58,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>
-}

+ 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

+ 30 - 8
packages/client/ui-primitives/src/user-text.tsx

@@ -4,17 +4,27 @@
  * only, and every part renders inline so a single-line message never breaks
  * across lines. Three 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`), optionally before trailing sentence punctuation, so
+ * slash paths (`/nfs-hg/xxx`, `/plan.md`) 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
+  // The `/` alternative's lookahead admits the same trailing punctuation set
+  // TRAILING_PUNCTUATION_RE strips.
+  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 = [

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

+ 34 - 4
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]')
@@ -56,12 +61,37 @@ describe('projectUserText', () => {
   })
 
   it('strips trailing punctuation and skips degenerate tokens', () => {
-    const host = project('用 /plan。 试试 @。')
+    const host = project('用 /plan。 试试 @。', [], ['plan'])
     const chips = [...host.querySelectorAll('[data-ref-chip]')]
     expect(chips.map(c => c.textContent)).toEqual(['/plan'])
     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 or trailing punctuation', () => {
+    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"]')]