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

This commit is contained in:
Yichen Jiang committed 2026-09-04 14:38:30 +08:00
1 parent 46d20f8bee
commit d0da8ccf40
30 files changed
+342 -70

No files matched your search

@@ -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
@@ -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
@@ -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,无需菜单交互或无关重渲染。
### 每会话供数贡献与键盘私面
@@ -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
@@ -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[] = []
@@ -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.
@@ -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
@@ -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', {
@@ -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 }),
@@ -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 })
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -29,7 +29,7 @@ kind: "package-reference"
### 指令输入气泡
每条持久的 `/goal` 运行都投影为一个右对齐的等宽用户样式气泡,标签为 `Command input`(或 `指令输入`),渲染在通用命令结果行之前。它不含时间戳、复制或分支操作,重新加载时会依据运行记录重建。
每条持久的 `/goal` 运行都投影为一个右对齐的用户样式气泡,标签为 `Command input`(或 `指令输入`),渲染在通用命令结果行之前;开头的 `/goal` token 经 ui-primitives 的 `projectUserText` 以等宽代码字体渲染为指令引用 chip,目标文本保持正文字体。它不含时间戳、复制或分支操作,重新加载时会依据运行记录重建。
### 失败
@@ -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;
@@ -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>
@@ -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) => {
@@ -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))',
]))
})
})
@@ -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,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
View File
@@ -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
View File
@@ -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,供消息气泡和排队行使用。
### 本地化文案
@@ -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'
@@ -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;
}
@@ -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>
}
@@ -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
@@ -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 && (
@@ -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 = [
@@ -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)
})
})
@@ -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"]')]