feat(session-export): add command and Header action

This commit is contained in:
NI0317
2026-08-12 14:23:06 +08:00
parent 185a1f7da3
commit 8940282aee
114 changed files with 1533 additions and 368 deletions

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-command/README.md
README.md: 60b70cfbc3784dd5b138f8857270c4bbdc0fd634
README.zh.md: 639b7f997e967527232bb88116558c5457b4d497
README.md: 1fa5d6cd38a18303857f4132ea0839822183f76b
README.zh.md: 1af0a17a0a14cf135f7b8b70089d610a1ab71d96

View File

@@ -8,6 +8,8 @@ Client command API (`ctx.command`): the session-keyed command-directory cache, t
`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the forwarded `commands/change` owner event (old snapshots serve while the repull flies) and by forwarded `agent-preset/selected` for that one session (recomposing an agent registers nothing, so the registry-wide signal never fires for it), hard-invalidated by `connection/reset`, and epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt.
After `command.execute` returns a matched command result, this browser emits local `command/executed(sessionId, name, result)`. Other clients receive the durable command nodes through the Host event stream but never this acknowledgment, so a browser-only side effect can select successful results from the client that submitted the command without treating Session replay as an action request.
Menu queries fuzzy-match ordered, case-insensitive subsequences of command names. Prefixes rank first; separator boundaries, adjacent characters, and shorter gaps rank the remaining matches, with directory and contribution order breaking ties. This affects discovery only: space and Enter still require an exact command name. Rationale: [Web slash-command fuzzy discovery](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md).
`PopupSelectController` (`src/client/popup.ts`) is the headless shell state: `PopupSelectView` self-registers into `conversation.input.overlay` (the SlotMap key is ui-conversation's; this package pulls the declaration in with a type-only import — no runtime edge). The shell is a transient layer holding focus while open; token-segment consumption after onSelect runs both branches through `consumeTokenSegment` (menu-path span CAS, enter-path bare-token equality) against the draft face the wiring layer binds via `bindDraft`.

View File

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

View File

@@ -12,6 +12,7 @@ import type { Context } from '@deepseek-ai/cordis'
// Type-only: pulls the ctx.remote merge and the forwarded-event key face
// (`commands/change` rides the allowlist) into this program.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type { CommandResult } from '@deepseek-ai/dsh-commands'
import type { ClientContext, ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type {
CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick,
@@ -23,6 +24,28 @@ import { CommandDirectory } from './directory.ts'
import { PopupSelectController } from './popup.ts'
import type { TokenSegment } from './popup.ts'
declare module '@deepseek-ai/cordis' {
interface Events {
/**
* This browser client completed one admitted Host command execution.
* Other clients receive the durable command nodes but never this local
* submission acknowledgment.
* @param sessionId - Session addressed by the local submission.
* @param name - Executed command name without the leading slash.
* @param result - Host command result returned to this browser.
* @mode emit
*/
'command/executed'(sessionId: SessionId, name: string, result: CommandResult): void
}
}
/** Recover the command name from a line the Host confirmed as executed. */
function submittedCommandName(line: string): string {
const trimmed = line.trim()
const separator = trimmed.search(/\s/u)
return (separator === -1 ? trimmed : trimmed.slice(0, separator)).slice(1)
}
/** Live mutable state in one holder (service methods run behind the caller-ctx tracker). */
interface LiveState {
readonly contributions: Map<string, CommandContribution>
@@ -351,6 +374,7 @@ export class CommandService extends Service implements CommandServiceContract {
const result = await this.ctx.remote.commands.execute(session.sessionId, line)
if (!result.ok) throw new Error(`command.execute failed: ${result.error.code}: ${result.error.message}`)
if (result.value === undefined) return { kind: 'error', text: `unknown or malformed command: ${line}` }
this.ctx.emit('command/executed', session.sessionId, submittedCommandName(line), result.value.result)
return { kind: 'success' }
}

View File

@@ -9,6 +9,7 @@
*/
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import type { CommandResult } from '@deepseek-ai/dsh-commands'
import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientSessionContext, ConsumeTokenRequest, SlashPick, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
@@ -120,6 +121,10 @@ async function bench(opts: BenchOptions = {}) {
},
})
ctx.provide('remote.commands', commandsRemote)
const executions: Array<{ sessionId: SessionId; name: string; result: CommandResult }> = []
ctx.on('command/executed', (sessionId, name, result) => {
executions.push({ sessionId, name, result })
})
/** Notices the fake conversation face collected (runDetached routing). */
const notices: Array<{ scope: SessionId | undefined; level: 'info' | 'error'; text: string }> = []
ctx.provide('conversation', {
@@ -145,7 +150,7 @@ async function bench(opts: BenchOptions = {}) {
const warm = async (session: ClientSessionContext) => {
await source.candidates(session, { query: '', position: 'leading', signal: new AbortController().signal })
}
return { ctx, fiber, command, source, mint, warm, listCalls, executeCalls, registered, notices }
return { ctx, fiber, command, source, mint, warm, listCalls, executeCalls, executions, registered, notices }
}
function menuPick(source: SlashSource, name: string, session: ClientSessionContext, end?: number) {
@@ -367,7 +372,7 @@ describe('dispatch (menu column)', () => {
})
it('host bare → consume-token span guard on the session scope + detached execute', async () => {
const { source, mint, warm, executeCalls } = await bench()
const { source, mint, warm, executeCalls, executions } = await bench()
const scope = mint('s1')
const consumes: ConsumeTokenRequest[] = []
scope.ctx.on('slash/input-consume-token', (r) => {
@@ -377,8 +382,14 @@ describe('dispatch (menu column)', () => {
await warm(proj('s1'))
expect(menuPick(source, 'plan', proj('s1'), 5)).toBe('handled')
expect(consumes).toEqual([{ guard: { kind: 'span', span: { start: 0, end: 5, draftRev: 3 } } }])
await Promise.resolve()
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }])
await vi.waitFor(() => {
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }])
expect(executions).toEqual([{
sessionId: sid('s1'),
name: 'plan',
result: { kind: 'success' },
}])
})
})
it('a name the directory no longer serves → undefined (snapshot swapped between menu and pick)', async () => {
@@ -496,7 +507,7 @@ describe('matchEnter (enter column)', () => {
describe('execute payload', () => {
it('claim.submit addresses the session; admitted outcomes stay off the composer (flow card owns them)', async () => {
const { source, warm, executeCalls } = await bench({
const { source, warm, executeCalls, executions } = await bench({
execute: () => Promise.resolve({ matched: true }),
})
await warm(proj('s1'))
@@ -507,6 +518,11 @@ describe('execute payload', () => {
// Pure admission: no outcome text ever rides the submit result — the
// durable command lifecycle events render the outcome in the flow.
expect(settled).toEqual({ kind: 'success' })
expect(executions).toEqual([{
sessionId: sid('s1'),
name: 'goal',
result: { kind: 'success' },
}])
})
it('maps matched:false to an error outcome and a matched bare result to success', async () => {