Merge remote-tracking branch 'origin/master' into worktree-fetest
This commit is contained in:
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-30-hook-bridges.md
|
||||
2026-06-30-hook-bridges.md: 99c6b1941a10e198ec3028f5fe505dabfff9abbe
|
||||
2026-06-30-hook-bridges.zh.md: 11ed3a5d177661271b30f1a58d034caa577b5348
|
||||
2026-06-30-hook-bridges.zh.md: 66855c3c4f36877aa627173de8e73250546e9621
|
||||
@@ -15,7 +15,7 @@ harness 的扩展面是其类型化的拦截 seam(见[拦截 seam Agent Note](
|
||||
`packages/hooks/` 组下两个独立插件,各为 function/namespace 插件(`name`/`inject`/`Config`/`apply`,无 default export——见[事后复盘 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)),仅注入 `bash`:
|
||||
|
||||
- **`dsh-hooks-claude`**——CC 方言。Claude Code 当前七个钩子点中的七个:`SessionStart`、`UserPromptSubmit`、`PreToolUse`、`PostToolUse`、`Stop`、`SubagentStart` 和 `SubagentStop`。拥有 CC 形态的每事件 stdin payload(基础字段 `session_id`/`transcript_path`/`cwd`/`hook_event_name` 加每事件字段)、`CLAUDE_PROJECT_DIR` 环境变量加 `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` 替换,以及字面量或正则的匹配模式。`transcript_path` 是持久化定位器结果或 `''`;stdin 带有**尾部换行**。
|
||||
- **`dsh-hooks-codex`**——Codex 当前五个钩子点中的五个:`PreToolUse`、`PostToolUse`、`SessionStart`、`UserPromptSubmit` 和 `Stop`。使用始终为正则的匹配模式、Codex 形态的 snake_case payload(含 `turn_id`/`model`/`permission_mode` 额外字段),写入时不带尾部换行,不注入 Codex 插件环境变量,不做配置时占位符替换,也没有 pre-tool 审批或重写路径。`transcript_path` 是同一定位器结果或 `null`;工具 payload 在精简后的 `tool_input: { command }` 形态中携带真实的 `tool_name`。
|
||||
- **`dsh-hooks-codex`**——Codex 当前五个钩子点中的五个:`PreToolUse`、`PostToolUse`、`SessionStart`、`UserPromptSubmit` 和 `Stop`。它使用始终按正则解释的 matcher,输出 Codex 形态的 snake_case payload(含 `turn_id`/`model`/`permission_mode` 额外字段)且写入时不带尾部换行,不注入 Codex 插件环境变量,不做配置时占位符替换,也没有 pre-tool 审批或重写路径。`transcript_path` 是同一定位器结果或 `null`;工具 payload 在精简后的 `tool_input: { command }` 形态中携带真实的 `tool_name`。
|
||||
|
||||
### Outcome → Decision 映射
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md
|
||||
2026-06-30-hook-protocol-lib.md: 33ec23dd4fa6aa8b4966bbe6c0ca5697ec83056c
|
||||
2026-06-30-hook-protocol-lib.zh.md: 8e8c89a4ecca3bea98fb26bc55974765f27f6a11
|
||||
2026-06-30-hook-protocol-lib.md: ce25f40e96ffd5c319d9845e36eab5130cec5857
|
||||
2026-06-30-hook-protocol-lib.zh.md: 062160931f52576e65557b6e0d385ccaac54aceb
|
||||
@@ -15,7 +15,7 @@ This Agent Note introduces `@deepseek-ai/dsh-hook-protocol`, a **library** (not
|
||||
A new `packages/hooks/` group with `hook-protocol` as a pure library. It owns four primitive families and the `hook/*` session events; each bridge plugin (`dsh-hooks-claude`, `dsh-hooks-codex`) owns what genuinely differs.
|
||||
|
||||
**Shared (here):**
|
||||
- **Matcher** — `matchesMatcher(pattern, query, mode)`. The ONE axis the dialects differ on is collapsed to the `mode` parameter: `claude` treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` is always an unanchored regex. Match-all on absent/`''`/`'*'`; an invalid regex matches nothing (never throws into the loop).
|
||||
- **Matcher** — `matcherDiagnostic(pattern, mode)` and `matchesMatcher(pattern, query, mode)`. The ONE axis the dialects differ on is collapsed to the `mode` parameter: `claude` treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` is always an unanchored regex. Match-all on absent/`''`/`'*'`. Each bridge ignores unsupported events before group parsing, discards matcher fields on supported events without matcher subjects, validates the remaining runnable groups, and treats an invalid regex there as a whole-config load failure, with a stable dialect/pattern/event diagnostic; no hook listeners are registered. Runtime matching still contains an invalid regex as a non-match, so a direct library caller never throws into the loop.
|
||||
- **Execution** — `runHook(bash, hook, options)`. Runs a command hook through the `ctx.bash` seam rather than a bespoke `spawn`: the executor already provides the scrubbed-but-overridable env, process-group kills, and timeout the protocol needs, and `dsh-bash`'s `stdin`/`env` fields (added for exactly this) are the trusted-plugin surface an in-process bridge is allowed to use. It serializes the bridge-built payload to stdin (trailing newline iff CC), honors the hook's `timeoutSec` (else `DEFAULT_HOOK_TIMEOUT_MS`, the 10-minute reference default both dialects share), and never throws (an executor rejection becomes a non-blocking-error `HookOutput`).
|
||||
- **Decode** — `parseHookOutput(exit, stdout, stderr)`, the exit-code + structured-stdout codec, producing a dialect-neutral `HookOutput`. Exit `0` → lenient JSON parse of stdout; exit `2` → blocking error with `stderr` as the reason (surfaced as `decision: 'block'` so no caller needs a separate exit-code branch); other → non-blocking error. Parses the CC structured-stdout fields that have a consumer on some path (`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`); the bridge honors only the subset meaningful for its dialect. Fields with no consumer on any path are not parsed at all (CC's `suppressOutput` — hook stdout never enters a transcript here, so there is nothing to suppress; see [the tighten-hook-protocol-contract Agent Note](../simplification/2026-07-04-tighten-hook-protocol-contract.md)).
|
||||
- **Merge** — `mergeHookOutputs(outputs)`, folding multiple matched hooks into one most-restrictive `MergedHookOutcome`: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined `\n\n`, context/system-messages accumulated in order.
|
||||
@@ -29,4 +29,4 @@ A new `packages/hooks/` group with `hook-protocol` as a pure library. It owns fo
|
||||
|
||||
## Consequences
|
||||
|
||||
Each bridge parses config, builds its dialect payload, invokes the shared runner and merge logic, maps the decision, and appends `hook/*`. Protocol tests cover every matcher mode, exit-code and codec field, runner plumbing, merge precedence, and audit helper at per-file 100%; bridge tests exercise the library's real load path. `updatedInput` is parsed but only logged and warned until the [input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md) lands.
|
||||
Each bridge parses config atomically, builds its dialect payload, invokes the shared runner and merge logic, maps the decision, and appends `hook/*`. Protocol tests cover every matcher mode and diagnostic, exit-code and codec field, runner plumbing, merge precedence, and audit helper at per-file 100%; bridge tests exercise the library's load path and pin the exact warning. Keyless ACP snapshots boot both bridges through the real Loader/app path with a valid blocking group before an invalid matcher, then prove the request reaches the replay model and persists no `hook/*` rows, so partial registration cannot hide behind a hand-mounted context. `updatedInput` is parsed but only logged and warned until the [input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md) lands.
|
||||
@@ -15,7 +15,7 @@ Status: implemented
|
||||
在 `packages/hooks/` 分组下新建 `hook-protocol` 作为纯库。它拥有四个原语族和 `hook/*` 会话事件;每个桥接插件(`dsh-hooks-claude`、`dsh-hooks-codex`)拥有真正不同的部分。
|
||||
|
||||
**共享(本库):**
|
||||
- **Matcher** — `matchesMatcher(pattern, query, mode)`。两种方言唯一不同的轴被收敛为 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` 模式视为字面量(管道符 = 精确匹配的多选),其余视为正则;`codex` 始终是无锚定正则。缺省/`''`/`'*'` 匹配一切;无效正则匹配空集(绝不向 agent loop(智能体循环)抛异常)。
|
||||
- **Matcher** — `matcherDiagnostic(pattern, mode)` 与 `matchesMatcher(pattern, query, mode)`。两种方言的唯一差异收敛到 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` pattern 视为字面量(管道符 = 精确匹配多选),其他 pattern 视为正则;`codex` 始终使用未锚定正则。缺省/`''`/`'*'` 匹配一切。每个桥接插件会在解析 group 前忽略不支持的事件,丢弃受支持但没有 matcher 匹配对象的事件所带字段,校验其余可运行 group;其中任何无效正则都会导致整份配置加载失败,并给出包含方言/pattern/事件的稳定诊断,不会注册任何钩子监听器。运行时匹配仍会将无效正则隔离为不匹配,因此直接调用本库绝不向 agent loop(智能体循环)抛异常。
|
||||
- **执行** — `runHook(bash, hook, options)`。通过 `ctx.bash` seam 而非自建 `spawn` 运行命令钩子:执行器已提供清洗但可覆盖的 env、进程组 kill 和超时,正是协议所需的能力;`dsh-bash` 的 `stdin`/`env` 字段(正是为此添加的)是进程内桥接插件被允许使用的受信插件接口。它将桥接插件构建的 payload 序列化到 stdin(CC 时追加尾部换行),遵守钩子的 `timeoutSec`(否则使用 `DEFAULT_HOOK_TIMEOUT_MS`,即两种方言共享的 10 分钟参考默认值),且从不抛异常(执行器拒绝变为 non-blocking-error 的 `HookOutput`)。
|
||||
- **解码** — `parseHookOutput(exit, stdout, stderr)`,exit-code + structured-stdout 编解码器,产出方言无关的 `HookOutput`。Exit `0` → 宽松 JSON 解析 stdout;exit `2` → blocking error,`stderr` 为原因(以 `decision: 'block'` 呈现,调用方无需单独处理 exit-code 分支);其他 → non-blocking error。解析 CC structured-stdout 中在某条路径上有消费方的字段(`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`);桥接插件只采纳对其方言有意义的子集。在任何路径上都没有消费方的字段不予解析(CC 的 `suppressOutput`——钩子 stdout 在此处从不进入 transcript(文本记录),因此无需抑制;见 [收紧钩子协议契约 Agent Note](../simplification/2026-07-04-tighten-hook-protocol-contract.md))。
|
||||
- **合并** — `mergeHookOutputs(outputs)`,将多个匹配钩子的输出折叠为一个最严格的 `MergedHookOutcome`:权限优先级 **deny > ask > allow**,halt 在首个 `continue:false` 时粘滞,阻止原因以 `\n\n` 拼接,context/system-messages 按序累积。
|
||||
@@ -29,4 +29,4 @@ Status: implemented
|
||||
|
||||
## 后果
|
||||
|
||||
每个桥接插件解析配置、构建方言 payload、调用共享的 runner 与合并逻辑、映射 decision、追加 `hook/*`。协议测试覆盖每种 matcher 模式、exit-code 与编解码器字段、runner 管道、合并优先级和审计辅助函数,逐文件 100% 覆盖率;桥接插件测试验证库的真实加载路径。`updatedInput` 已解析但仅记录日志并发出警告,直到 [input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)落地。
|
||||
每个桥接插件以原子方式解析配置、构建方言 payload、调用共享的 runner 与合并逻辑、映射 decision、追加 `hook/*`。协议测试覆盖每种 matcher 模式与诊断、exit-code 与编解码器字段、runner 管道、合并优先级和审计辅助函数,逐文件 100% 覆盖率;桥接插件测试验证库的加载路径并锁定精确警告。无密钥 ACP 快照通过真实 Loader/app 路径启动两个桥接插件,在非法 matcher 之前放置一个合法的拦截 group,然后证明请求仍到达 replay 模型且没有持久化任何 `hook/*` 行,从而避免手工挂载 Context 掩盖部分注册。`updatedInput` 已解析但仅记录日志并发出警告,直到 [input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)落地。
|
||||
+11
-8
@@ -268,7 +268,6 @@ flowchart TD
|
||||
pkg_client_modules --> pkg_invariants
|
||||
pkg_client_runtime --> pkg_invariants
|
||||
pkg_client_ui_primitives --> pkg_invariants
|
||||
pkg_client_ui_question --> pkg_invariants
|
||||
pkg_client_ui_slots --> pkg_invariants
|
||||
pkg_client_web --> pkg_invariants
|
||||
pkg_client_web_react --> pkg_invariants
|
||||
@@ -304,10 +303,6 @@ flowchart TD
|
||||
pkg_client_ui_settings --> pkg_client_ui_primitives
|
||||
pkg_client_ui_settings --> pkg_client_ui_slots
|
||||
pkg_client_ui_settings --> pkg_invariants
|
||||
pkg_client_ui_sidebar --> pkg_client_runtime
|
||||
pkg_client_ui_sidebar --> pkg_client_ui_primitives
|
||||
pkg_client_ui_sidebar --> pkg_client_ui_slots
|
||||
pkg_client_ui_sidebar --> pkg_invariants
|
||||
pkg_client_ui_trajectory --> pkg_client_ui_primitives
|
||||
pkg_client_ui_trajectory --> pkg_invariants
|
||||
pkg_client_ui_workspace --> pkg_client_runtime
|
||||
@@ -345,12 +340,19 @@ flowchart TD
|
||||
pkg_system_prompt --> pkg_scope
|
||||
pkg_web --> pkg_invariants
|
||||
pkg_web --> pkg_llm
|
||||
pkg_client_ui_question --> pkg_client_locale
|
||||
pkg_client_ui_question --> pkg_invariants
|
||||
pkg_client_ui_settings_general --> pkg_client_locale
|
||||
pkg_client_ui_settings_general --> pkg_client_runtime
|
||||
pkg_client_ui_settings_general --> pkg_client_ui_primitives
|
||||
pkg_client_ui_settings_general --> pkg_client_ui_settings
|
||||
pkg_client_ui_settings_general --> pkg_client_ui_slots
|
||||
pkg_client_ui_settings_general --> pkg_invariants
|
||||
pkg_client_ui_sidebar --> pkg_client_locale
|
||||
pkg_client_ui_sidebar --> pkg_client_runtime
|
||||
pkg_client_ui_sidebar --> pkg_client_ui_primitives
|
||||
pkg_client_ui_sidebar --> pkg_client_ui_slots
|
||||
pkg_client_ui_sidebar --> pkg_invariants
|
||||
pkg_client_ui_slash --> pkg_client_locale
|
||||
pkg_client_ui_slash --> pkg_client_runtime
|
||||
pkg_client_ui_slash --> pkg_client_ui_primitives
|
||||
@@ -618,6 +620,7 @@ flowchart TD
|
||||
pkg_client_ui_goal --> pkg_goal
|
||||
pkg_client_ui_goal --> pkg_invariants
|
||||
pkg_client_ui_model --> pkg_client_connection
|
||||
pkg_client_ui_model --> pkg_client_locale
|
||||
pkg_client_ui_model --> pkg_client_runtime
|
||||
pkg_client_ui_model --> pkg_client_ui_command
|
||||
pkg_client_ui_model --> pkg_client_ui_conversation
|
||||
@@ -1001,7 +1004,6 @@ flowchart TD
|
||||
| [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
| [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-question`](../packages/client/ui-question) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
| [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
| [`client-web-react`](../packages/client/web-react) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
@@ -1021,7 +1023,6 @@ flowchart TD
|
||||
| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) |
|
||||
@@ -1036,7 +1037,9 @@ flowchart TD
|
||||
| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
|
||||
@@ -1101,7 +1104,7 @@ flowchart TD
|
||||
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) |
|
||||
| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`user-approval`](../packages/ui/user-approval) |
|
||||
| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) |
|
||||
| [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) |
|
||||
| [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) |
|
||||
|
||||
@@ -212,10 +212,16 @@ const SCENARIOS: Scenario[] = [
|
||||
headerClass: 'advanced',
|
||||
configPath: ADVANCED_CONFIG,
|
||||
},
|
||||
// Prompt-submit blocks are authored keylessly. Admission rejects before a
|
||||
// turn opens, so only the ACP stop reason is observable and no log is harvested.
|
||||
// Prompt-submit blocks are authored keylessly with malformed matcher fields,
|
||||
// which these matcherless events must ignore. Admission rejects before a turn
|
||||
// opens, so only the ACP stop reason is observable and no log is harvested.
|
||||
{ name: 'hook-cc-promptsubmit-block', hasModelTurn: false, recorded: false },
|
||||
{ name: 'hook-codex-promptsubmit-block', hasModelTurn: false, recorded: false },
|
||||
// Each invalid matcher follows a runnable prompt blocker. Reaching the replay
|
||||
// model without any hook audit rows proves config loading is atomic through
|
||||
// the real Loader/app path, rather than retaining the earlier valid group.
|
||||
{ name: 'hook-cc-invalid-matcher', hasModelTurn: true, recorded: false },
|
||||
{ name: 'hook-codex-invalid-matcher', hasModelTurn: true, recorded: false },
|
||||
// The mid-turn seams fire during a real model turn, so each is recorded with its hook active
|
||||
// (the model's reaction to a deny/block/force-continue is part of the captured transcript).
|
||||
// SessionStart/SubagentStart are excluded because detached injection races log
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"steps": [
|
||||
{ "op": "initialize" },
|
||||
{ "op": "newSession" },
|
||||
{ "op": "prompt", "text": "Reply with exactly the word: PONG. Do not use any tools." }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0}
|
||||
{"type":"turn/start","seq":0,"time":1783600629541,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":1,"time":1783600629541,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"5a36df87-da8e-480d-8e0f-61cd2b93bbb8"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1783600629541,"data":{"title":"Reply with exactly the word:","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1783600629542,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":5,"time":1783600630819,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"reasoning-chunks","seq0":6,"time0":1783600630820,"data":{"turn":1,"step":1,"index":0,"dt":[2,30,0,0,0,33,1,40,0,0,0,0,0,18,0,36,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","P","ONG","\""," and"," not"," use"," any"," tools","."]}}
|
||||
{"type":"assistant/chunk","seq":26,"time":1783600630980,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":27,"time":1783600630980,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}}
|
||||
{"type":"assistant/chunk","seq":28,"time":1783600631006,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}}
|
||||
{"type":"assistant/chunk","seq":29,"time":1783600631008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}}
|
||||
{"type":"assistant/chunk","seq":30,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}}
|
||||
{"type":"assistant/chunk","seq":31,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}}
|
||||
{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":33,"time":1783600631011,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"7452a358-8038-4583-9ceb-66564f665bfb"},"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":34,"time":1783600631011,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":35,"time":1783600631011,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
|
||||
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PONG"}}}}
|
||||
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "echo 'must not run' >&2; exit 2" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "[",
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "exit 2" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"matcher": "[",
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "echo 'blocked by policy hook' >&2; exit 2" }
|
||||
]
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"steps": [
|
||||
{ "op": "initialize" },
|
||||
{ "op": "newSession" },
|
||||
{ "op": "prompt", "text": "Reply with exactly the word: PONG. Do not use any tools." }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0}
|
||||
{"type":"turn/start","seq":0,"time":1783600629541,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":1,"time":1783600629541,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"56715824-b0da-4a73-8d6c-0caa590995e6"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1783600629541,"data":{"title":"Reply with exactly the word:","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1783600629542,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":5,"time":1783600630819,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"reasoning-chunks","seq0":6,"time0":1783600630820,"data":{"turn":1,"step":1,"index":0,"dt":[2,30,0,0,0,33,1,40,0,0,0,0,0,18,0,36,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","P","ONG","\""," and"," not"," use"," any"," tools","."]}}
|
||||
{"type":"assistant/chunk","seq":26,"time":1783600630980,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":27,"time":1783600630980,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}}
|
||||
{"type":"assistant/chunk","seq":28,"time":1783600631006,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}}
|
||||
{"type":"assistant/chunk","seq":29,"time":1783600631008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}}
|
||||
{"type":"assistant/chunk","seq":30,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}}
|
||||
{"type":"assistant/chunk","seq":31,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}}
|
||||
{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":33,"time":1783600631011,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"2d9d88d1-b684-491f-9d5f-73721b7fd5ed"},"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":34,"time":1783600631011,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":35,"time":1783600631011,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
|
||||
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PONG"}}}}
|
||||
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "echo 'must not run' >&2; exit 2" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "[",
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "exit 2" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+1
@@ -2,6 +2,7 @@
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"matcher": "[",
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "echo 'blocked by codex policy hook' >&2; exit 2" }
|
||||
]
|
||||
|
||||
@@ -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/locale/README.md
|
||||
README.md: 9015af2b44a33771b06863ace139fe97695df616
|
||||
README.zh.md: 12205e21bb75a4433902b8e85c1cf7bdb0147bbf
|
||||
README.md: c2adbcabc77def740094288da4643032873aa5b8
|
||||
README.zh.md: c6ecb31e21d7513a4e7d579b17588107ccd7ea59
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`, getter/setter with `locale/change` snapshots) plus the ns×locale dictionary registry (`bind(ns)`→t with a stable function identity; lookup chain active → zh → key).
|
||||
Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`; `locale/change` fires on switches only) plus the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS<ns>`; lookup chain ns → common → zh → key). The service implements the slot system's `LocaleFace` and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience).
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -14,5 +14,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Only the Settings surface is translated** — other pages keep inline copy; repo-wide extraction into dictionaries is deferred.
|
||||
- **Locale switching re-renders subscribed consumers only** — sections not wired to `locale/change` keep their rendered text until remount.
|
||||
- **Most surfaces keep inline copy** — the standard seat is adopted by the Settings rows, sidebar, question composer, and model select; the remaining packages migrate in follow-up PRs.
|
||||
- **Registry-held text reads its translation once** — copy captured at registration time outside the slot render path (e.g. the `/model` command description in the command registry) keeps the language it was registered under until re-registration; slot-rendered copy follows switches live.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
locale 插件:LocaleService 包含浏览器 locale 偏好(`zh`/`en`,以 `dsh.locale` 为键持久化;提供 getter/setter,并生成 `locale/change` 快照),以及 ns×locale 字典注册表(`bind(ns)`→t 的函数标识稳定;查找链为 active → zh → key)。
|
||||
locale 插件:LocaleService——浏览器 locale 偏好(`zh`/`en`,以 `dsh.locale` 持久化;`locale/change` 仅在切换语言时触发),加上 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS<ns>`;查找链 ns → common → zh → key)。该服务实现 slot 系统的 `LocaleFace` 并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -14,5 +14,5 @@ locale 插件:LocaleService 包含浏览器 locale 偏好(`zh`/`en`,以
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **只有设置界面完成翻译**:其他页面仍保留内联文案;将全仓文案提取到字典的工作暂缓。
|
||||
- **切换 locale 只重新渲染已订阅的消费方**:未接入 `locale/change` 的界面区域会保留已渲染文本,直到重新挂载。
|
||||
- **多数界面仍保留内联文案**——标准席位已由设置行、侧边栏、问题作答器和模型选择接入;其余包在后续 PR 中迁移。
|
||||
- **注册表持有的文本只读取一次翻译**——在 slot 渲染路径之外于注册时捕获的文案(例如 command 注册表中的 `/model` 命令描述)在重新注册前保持注册时的语言;slot 渲染的文案随切换实时更新。
|
||||
@@ -5,23 +5,22 @@
|
||||
* settings surface.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {} from './settings-contract.ts'
|
||||
import type { createLanguageRowStore } from './settings-store.ts'
|
||||
import css from './LanguageRow.module.css'
|
||||
|
||||
/** Injected business face: namespace-bound translate + the preference write. */
|
||||
/** Injected business face: the preference write (t rides the standard locale seat). */
|
||||
export interface LanguageRowInjected {
|
||||
/** Translate a `settings.locale` dictionary key to the active-locale text. */
|
||||
t: (key: string) => string
|
||||
/** Switch the active locale (a registered locale id). */
|
||||
setLocale: (id: string) => void
|
||||
}
|
||||
|
||||
/** Full component props: runtime share + store share + injected face. */
|
||||
/** Full component props: runtime share + store share + locale seat + injected face. */
|
||||
export type LanguageRowComponentProps =
|
||||
PropsRuntime<'settings.general.item'> & PropsStore<ReturnType<typeof createLanguageRowStore>> & LanguageRowInjected
|
||||
PropsRuntime<'settings.general.item'> & PropsStore<ReturnType<typeof createLanguageRowStore>>
|
||||
& PropsLocale<'settings.locale'> & LanguageRowInjected
|
||||
|
||||
/**
|
||||
* Render the Language row.
|
||||
|
||||
@@ -4,11 +4,21 @@
|
||||
* preference row into the settings General section — the locale feature owns
|
||||
* its own settings surface.
|
||||
*/
|
||||
/* oxlint-disable typescript/no-redundant-type-constituents --
|
||||
* `keyof LocaleNamespaceMap & string` is the declare-merge key pattern (see
|
||||
* ui-slots): in THIS unit the map holds only this package's own merges, but
|
||||
* consumers merge more namespaces in and the intersection keeps them
|
||||
* string-typed. The rule fires on the narrow-map view, not real redundancy. */
|
||||
import type { Context } from 'cordis'
|
||||
import { deferRegistration, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
deferRegistration,
|
||||
type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { en } from '../locales/en.ts'
|
||||
import { zh } from '../locales/zh.ts'
|
||||
import { en, zh, type CommonKey } from '../locales/index.ts'
|
||||
import {
|
||||
en as settingsEn, zh as settingsZh, type SettingsLocaleKey,
|
||||
} from '../locales/settings.ts'
|
||||
import type { LanguageRowInjected } from './LanguageRow.tsx'
|
||||
import { LanguageRow } from './LanguageRow.tsx'
|
||||
import { createLanguageRowStore } from './settings-store.ts'
|
||||
@@ -16,9 +26,21 @@ import { createLanguageRowStore } from './settings-store.ts'
|
||||
export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageRow.tsx'
|
||||
export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts'
|
||||
export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts'
|
||||
export type { CommonKey } from '../locales/index.ts'
|
||||
|
||||
/** Translate a key with optional params. */
|
||||
export type Translate = (key: string, params?: Record<string, unknown>) => string
|
||||
// The translate currency lives in ui-slots (the render machinery synthesizes
|
||||
// the seat); re-exported here so dictionary owners import one package.
|
||||
// TranslateNS<'model'> is the namespace-addressed developer-facing form.
|
||||
export type { Translate, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** Shared cross-feature vocabulary, consulted by the lookup chain after the entry's own namespace misses. */
|
||||
common: CommonKey
|
||||
/** This feature's own settings-row copy (the Language row). */
|
||||
'settings.locale': SettingsLocaleKey
|
||||
}
|
||||
}
|
||||
|
||||
/** Locale dictionary: flat key to template string ({name} placeholders). */
|
||||
export type LocaleDict = Record<string, string>
|
||||
@@ -50,7 +72,10 @@ declare module 'cordis' {
|
||||
}
|
||||
interface Events {
|
||||
/**
|
||||
* Locale state changed (active locale switched or registry updated).
|
||||
* The active locale switched. Dictionary registrations do NOT emit this
|
||||
* event (listeners may re-register slots in response, and boot registers
|
||||
* one namespace per package); continuous render refresh rides the
|
||||
* LocaleFace revision instead.
|
||||
* @param snapshot - Current immutable locale snapshot.
|
||||
* @mode emit
|
||||
*/
|
||||
@@ -77,16 +102,20 @@ const LOCALES: readonly LocaleDefinition[] = Object.freeze([
|
||||
])
|
||||
|
||||
/**
|
||||
* Dictionary registry plus locale preference. Lookup chain per key: active
|
||||
* locale -> zh fallback -> the key itself (missing text stays visible, fail
|
||||
* loud in the UI rather than blank). Reads go through {@link getLocale};
|
||||
* writes only through {@link setLocale}; continuous sync only through the
|
||||
* `locale/change` event.
|
||||
* Dictionary registry plus locale preference. Lookup chain per key: the
|
||||
* entry's namespace in the active locale -> that namespace's zh fallback ->
|
||||
* the shared common namespace (active, then zh) -> the key itself (missing
|
||||
* text stays visible, fail loud in the UI rather than blank). Reads go
|
||||
* through {@link getLocale}; writes only through {@link setLocale};
|
||||
* continuous sync through the `locale/change` event, or through the
|
||||
* LocaleFace getSnapshot/subscribe pair the render machinery consumes
|
||||
* (installed via `ctx.slots.installLocale`).
|
||||
*/
|
||||
export class LocaleService {
|
||||
private dicts = new Map<string, Map<string, LocaleDict>>()
|
||||
private bound = new Map<string, Translate>()
|
||||
private snapshot: LocaleSnapshot
|
||||
private listeners = new Set<() => void>()
|
||||
private readonly ctx: Context
|
||||
|
||||
/**
|
||||
@@ -105,6 +134,27 @@ export class LocaleService {
|
||||
return this.snapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* LocaleFace getSnapshot: the current snapshot (carries `revision`; stable
|
||||
* reference between changes, uSES-safe).
|
||||
* @returns the current snapshot.
|
||||
*/
|
||||
getSnapshot(): LocaleSnapshot {
|
||||
return this.snapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* LocaleFace subscribe: notified on every snapshot change (locale switch
|
||||
* or dictionary registration — registrations bump the revision so already
|
||||
* rendered outlets pick up late-arriving dictionaries).
|
||||
* @param fn - change callback.
|
||||
* @returns unsubscribe.
|
||||
*/
|
||||
subscribe(fn: () => void): () => void {
|
||||
this.listeners.add(fn)
|
||||
return () => { this.listeners.delete(fn) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the active locale — the only preference write entry. Persists the
|
||||
* id and emits `locale/change`.
|
||||
@@ -114,44 +164,80 @@ export class LocaleService {
|
||||
const match = this.snapshot.locales.find(l => l.id === id)
|
||||
if (match === undefined) throw new Error(`locale "${id}" is not registered`)
|
||||
if (this.snapshot.active === match.id) return
|
||||
this.snapshot = Object.freeze({
|
||||
active: match.id,
|
||||
locales: this.snapshot.locales,
|
||||
revision: this.snapshot.revision + 1,
|
||||
})
|
||||
persistPreference(match.id)
|
||||
this.ctx.emit('locale/change', this.snapshot)
|
||||
this.publish(match.id, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a dictionary for a namespace and locale. Duplicate (ns, locale)
|
||||
* throws (single occupant; a namespace's texts have one owner).
|
||||
* Register a declared namespace's dictionaries, all locales in one call —
|
||||
* the typed form: each dictionary is checked against the namespace's
|
||||
* {@link LocaleNamespaceMap} key union (a missing or extra key is a
|
||||
* compile error), and every shipped locale is required (bilingual balance
|
||||
* enforced at the seam). Duplicate (ns, locale) throws (single occupant; a
|
||||
* namespace's texts have one owner). Registration bumps the revision so
|
||||
* mounted outlets pick up late-arriving dictionaries.
|
||||
* @param ns - a namespace merged into LocaleNamespaceMap.
|
||||
* @param dicts - complete dictionaries keyed by locale id.
|
||||
* @returns disposer removing every locale registered by this call (idempotent).
|
||||
*/
|
||||
register<N extends keyof LocaleNamespaceMap & string>(ns: N, dicts: Record<LocaleId, LocaleDictOf<N>>): () => void
|
||||
/**
|
||||
* Single-locale untyped form for namespaces outside the merge table
|
||||
* (dynamic composition, tests).
|
||||
* @param ns - namespace.
|
||||
* @param locale - locale tag (zh/en to start).
|
||||
* @param locale - locale tag.
|
||||
* @param dict - dictionary.
|
||||
* @returns disposer (idempotent).
|
||||
*/
|
||||
register(ns: string, locale: string, dict: LocaleDict): () => void {
|
||||
register(ns: string, locale: string, dict: LocaleDict): () => void
|
||||
register(ns: string, localeOrDicts: string | Record<string, LocaleDict>, dict?: LocaleDict): () => void {
|
||||
const pairs: [string, LocaleDict][] = typeof localeOrDicts === 'string'
|
||||
// Overload guarantees dict on the single-locale arm.
|
||||
? [[localeOrDicts, dict as LocaleDict]]
|
||||
: Object.entries(localeOrDicts)
|
||||
let locales = this.dicts.get(ns)
|
||||
if (!locales) {
|
||||
locales = new Map()
|
||||
this.dicts.set(ns, locales)
|
||||
}
|
||||
if (locales.has(locale)) throw new Error(`locale namespace "${ns}" already has locale "${locale}"`)
|
||||
locales.set(locale, dict)
|
||||
for (const [locale] of pairs) {
|
||||
if (locales.has(locale)) throw new Error(`locale namespace "${ns}" already has locale "${locale}"`)
|
||||
}
|
||||
for (const [locale, entries] of pairs) locales.set(locale, entries)
|
||||
this.publish(this.snapshot.active, false)
|
||||
return () => {
|
||||
const owner = this.dicts.get(ns)
|
||||
if (owner?.get(locale) === dict) owner.delete(locale)
|
||||
/* v8 ignore next -- defensive: a namespace's locales map is created on
|
||||
* first register and never removed, so the disposer always finds it. */
|
||||
if (!owner) return
|
||||
let removed = false
|
||||
for (const [locale, entries] of pairs) {
|
||||
if (owner.get(locale) === entries) {
|
||||
owner.delete(locale)
|
||||
removed = true
|
||||
}
|
||||
}
|
||||
if (removed) this.publish(this.snapshot.active, false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind a namespace to a translate function. The returned reference is
|
||||
* stable per namespace (repeat binds return the same function), so it can
|
||||
* ride inject surfaces without breaking memoization.
|
||||
* @param ns - namespace.
|
||||
* @returns the translate function (reads the active locale at call time).
|
||||
* Bind a declared namespace to a translate function typed to its
|
||||
* dictionary key union (plus the shared common vocabulary) — the same key
|
||||
* domain the framework-injected `t` seat carries. The returned reference
|
||||
* is stable per namespace (repeat binds return the same function), so it
|
||||
* can ride inject surfaces without breaking memoization.
|
||||
* @param ns - a namespace merged into LocaleNamespaceMap.
|
||||
* @returns the typed translate function (reads the active locale at call time).
|
||||
*/
|
||||
bind<N extends keyof LocaleNamespaceMap & string>(ns: N): TranslateNS<N>
|
||||
/**
|
||||
* Untyped form for namespaces outside the merge table (dynamic
|
||||
* composition, tests).
|
||||
* @param ns - namespace.
|
||||
* @returns the translate function.
|
||||
*/
|
||||
bind(ns: string): Translate
|
||||
bind(ns: string): Translate {
|
||||
let t = this.bound.get(ns)
|
||||
if (!t) {
|
||||
@@ -163,14 +249,43 @@ export class LocaleService {
|
||||
}
|
||||
|
||||
private translate(ns: string, key: string, params?: Record<string, unknown>): string {
|
||||
const locales = this.dicts.get(ns)
|
||||
const template = locales?.get(this.snapshot.active)?.[key]
|
||||
?? locales?.get(FALLBACK_LOCALE)?.[key]
|
||||
const template = this.lookup(ns, key)
|
||||
?? (ns !== COMMON_NS ? this.lookup(COMMON_NS, key) : undefined)
|
||||
?? key
|
||||
if (!params) return template
|
||||
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
|
||||
name in params ? String(params[name]) : match)
|
||||
}
|
||||
|
||||
private lookup(ns: string, key: string): string | undefined {
|
||||
const locales = this.dicts.get(ns)
|
||||
return locales?.get(this.snapshot.active)?.[key] ?? locales?.get(FALLBACK_LOCALE)?.[key]
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the snapshot revision and notify LocaleFace subscribers (render
|
||||
* refresh). Only an active-locale switch additionally emits
|
||||
* `locale/change` — dictionary registrations stay off the event so
|
||||
* registration-heavy boot cannot storm event listeners (which may
|
||||
* re-register slots in response).
|
||||
*/
|
||||
private publish(active: LocaleId, localeChanged: boolean): void {
|
||||
this.snapshot = Object.freeze({
|
||||
active,
|
||||
locales: this.snapshot.locales,
|
||||
revision: this.snapshot.revision + 1,
|
||||
})
|
||||
if (localeChanged) this.ctx.emit('locale/change', this.snapshot)
|
||||
for (const fn of [...this.listeners]) {
|
||||
try {
|
||||
fn()
|
||||
} catch (error) {
|
||||
// One throwing subscriber must not strand the rest on a stale
|
||||
// revision (outlets would keep the previous language).
|
||||
console.error('locale subscriber crashed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the persisted locale id; unknown or unreadable values fall back to zh. */
|
||||
@@ -208,11 +323,12 @@ export const inject = ['slots']
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const locale = new LocaleService(ctx)
|
||||
locale.register(COMMON_NS, 'zh', zh)
|
||||
locale.register(COMMON_NS, 'en', en)
|
||||
locale.register(SETTINGS_NS, 'zh', { 'language.title': '语言' })
|
||||
locale.register(SETTINGS_NS, 'en', { 'language.title': 'Language' })
|
||||
locale.register(COMMON_NS, { zh, en })
|
||||
locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn })
|
||||
ctx.provide('locale', locale)
|
||||
// The service IS the LocaleFace (bind + getSnapshot/subscribe): install it
|
||||
// so the render machinery can synthesize the `t` standard seat.
|
||||
ctx.slots.installLocale(locale)
|
||||
|
||||
const store = createLanguageRowStore()
|
||||
let bound: BoundActions<typeof store> | undefined
|
||||
@@ -230,7 +346,6 @@ export function apply(ctx: ClientContext): void {
|
||||
// first render (the store's revision guard drops stale duplicates).
|
||||
sync(locale.getLocale())
|
||||
return {
|
||||
t: locale.bind(SETTINGS_NS),
|
||||
setLocale: (id) => { locale.setLocale(id) },
|
||||
}
|
||||
}
|
||||
@@ -241,6 +356,7 @@ export function apply(ctx: ClientContext): void {
|
||||
id: 'language',
|
||||
order: 0,
|
||||
store,
|
||||
locale: SETTINGS_NS,
|
||||
inject: injected,
|
||||
}, LanguageRow))
|
||||
return () => { deferred.dispose() }
|
||||
|
||||
@@ -1,2 +1,29 @@
|
||||
/** en base dictionary for the common namespace (starter skeleton; texts land with their features). */
|
||||
export const en: Record<string, string> = {}
|
||||
import type { CommonKey } from './zh.ts'
|
||||
|
||||
/** en base dictionary for the common namespace, checked complete against the zh key set. */
|
||||
export const en = {
|
||||
'ok': 'OK',
|
||||
'cancel': 'Cancel',
|
||||
'close': 'Close',
|
||||
'copy': 'Copy',
|
||||
'copied': 'Copied',
|
||||
'retry': 'Retry',
|
||||
'loading': 'Loading…',
|
||||
'load.failed': 'Failed to load',
|
||||
'submit': 'Submit',
|
||||
'submitting': 'Submitting…',
|
||||
'next': 'Next',
|
||||
'previous': 'Previous',
|
||||
'skip': 'Skip',
|
||||
'delete': 'Delete',
|
||||
'edit': 'Edit',
|
||||
'save': 'Save',
|
||||
'search': 'Search',
|
||||
'more': 'More',
|
||||
'collapse': 'Collapse',
|
||||
'expand': 'Expand',
|
||||
'back': 'Back',
|
||||
'unknown': 'Unknown',
|
||||
'none': 'None',
|
||||
'truncated': 'Truncated',
|
||||
} satisfies Record<CommonKey, string>
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* The common-namespace dictionary pair. zh is the source of truth for the
|
||||
* key set (Chinese-first repo convention); en is checked complete against it
|
||||
* — a missing or extra en key is a compile error.
|
||||
*/
|
||||
export { zh } from './zh.ts'
|
||||
export { en } from './en.ts'
|
||||
export type { CommonKey } from './zh.ts'
|
||||
@@ -0,0 +1,14 @@
|
||||
/** `settings.locale` namespace dictionaries (the Language row's copy). */
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'language.title': '语言',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The settings.locale namespace key union. */
|
||||
export type SettingsLocaleKey = keyof typeof zh
|
||||
|
||||
/** English dictionary, checked complete against the zh key set. */
|
||||
export const en = {
|
||||
'language.title': 'Language',
|
||||
} satisfies Record<SettingsLocaleKey, string>
|
||||
@@ -1,2 +1,30 @@
|
||||
/** zh base dictionary for the common namespace (starter skeleton; texts land with their features). */
|
||||
export const zh: Record<string, string> = {}
|
||||
/** zh base dictionary for the common namespace: cross-feature standard words. */
|
||||
export const zh = {
|
||||
'ok': '确定',
|
||||
'cancel': '取消',
|
||||
'close': '关闭',
|
||||
'copy': '复制',
|
||||
'copied': '复制成功',
|
||||
'retry': '重试',
|
||||
'loading': '加载中…',
|
||||
'load.failed': '加载失败',
|
||||
'submit': '提交',
|
||||
'submitting': '正在提交…',
|
||||
'next': '下一步',
|
||||
'previous': '上一步',
|
||||
'skip': '跳过',
|
||||
'delete': '删除',
|
||||
'edit': '编辑',
|
||||
'save': '保存',
|
||||
'search': '搜索',
|
||||
'more': '更多',
|
||||
'collapse': '收起',
|
||||
'expand': '展开',
|
||||
'back': '返回',
|
||||
'unknown': '未知',
|
||||
'none': '无',
|
||||
'truncated': '已截断',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The common vocabulary key union (zh is the key-set source of truth). */
|
||||
export type CommonKey = keyof typeof zh
|
||||
@@ -69,16 +69,18 @@ describe('locale apply', () => {
|
||||
// An event ahead of any inject hits the unbound-actions arm.
|
||||
locale.setLocale('en')
|
||||
|
||||
const { instance, face } = faceOf(b.slots)
|
||||
const { entry, instance, face } = faceOf(b.slots)
|
||||
// The inject-time re-sync sealed the init window: the mirror is current.
|
||||
expect(instance.getSnapshot().active).toBe('en')
|
||||
expect(instance.getSnapshot().options.map(o => o.id)).toEqual(['zh', 'en'])
|
||||
expect(face.t('language.title')).toBe('Language')
|
||||
// Copy rides the standard locale seat: the entry declares the namespace.
|
||||
expect(entry.locale).toBe(SETTINGS_NS)
|
||||
expect(locale.bind(SETTINGS_NS)('language.title')).toBe('Language')
|
||||
|
||||
face.setLocale('zh')
|
||||
expect(locale.getLocale().active).toBe('zh')
|
||||
expect(instance.getSnapshot().active).toBe('zh')
|
||||
expect(face.t('language.title')).toBe('语言')
|
||||
expect(locale.bind(SETTINGS_NS)('language.title')).toBe('语言')
|
||||
})
|
||||
|
||||
it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => {
|
||||
|
||||
@@ -29,6 +29,24 @@ describe('LocaleService', () => {
|
||||
expect(t('missing.key')).toBe('missing.key')
|
||||
})
|
||||
|
||||
it('falls through to the common vocabulary after the namespace misses (production keys)', () => {
|
||||
const { svc } = make()
|
||||
// The shipped common pair is registered by apply; the bench registers it
|
||||
// directly to pin the production chain: ns -> common -> zh -> key.
|
||||
svc.register('common', 'zh', { retry: '重试' })
|
||||
svc.register('common', 'en', { retry: 'Retry' })
|
||||
svc.register('ns', 'zh', { own: '自有' })
|
||||
const t = svc.bind('ns')
|
||||
expect(t('retry')).toBe('重试')
|
||||
svc.setLocale('en')
|
||||
expect(t('retry')).toBe('Retry')
|
||||
expect(t('own')).toBe('自有')
|
||||
// common itself must not recurse: a miss inside common echoes the key.
|
||||
// (Wide-string ns hits the untyped bind overload — the typed one rejects
|
||||
// unknown keys at compile time, which is the point of the seam.)
|
||||
expect(svc.bind('common' as string)('nope')).toBe('nope')
|
||||
})
|
||||
|
||||
it('interpolates {name} params and leaves unknown placeholders intact', () => {
|
||||
const { svc } = make()
|
||||
svc.register('ns', 'zh', { greet: '你好,{name}!第 {n} 次', partial: '{known} 与 {unknown}' })
|
||||
@@ -56,6 +74,47 @@ describe('LocaleService', () => {
|
||||
expect(t('k')).toBe('v2')
|
||||
})
|
||||
|
||||
it('serves the LocaleFace: snapshot revision moves on switch and registration, subscribers fire, unsubscribe stops them', () => {
|
||||
const { svc } = make()
|
||||
const seen: number[] = []
|
||||
const off = svc.subscribe(() => { seen.push(svc.getSnapshot().revision) })
|
||||
expect(svc.getSnapshot()).toBe(svc.getLocale())
|
||||
const r0 = svc.getSnapshot().revision
|
||||
svc.register('ns', 'zh', { k: 'v' })
|
||||
expect(svc.getSnapshot().revision).toBe(r0 + 1)
|
||||
svc.setLocale('en')
|
||||
expect(seen).toEqual([r0 + 1, r0 + 2])
|
||||
off()
|
||||
svc.setLocale('zh')
|
||||
expect(seen).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('isolates a throwing subscriber: the rest still see the new revision', () => {
|
||||
const { svc } = make()
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
try {
|
||||
const seen: number[] = []
|
||||
svc.subscribe(() => { throw new Error('boom') })
|
||||
svc.subscribe(() => { seen.push(svc.getSnapshot().revision) })
|
||||
svc.setLocale('en')
|
||||
expect(seen).toEqual([1])
|
||||
expect(spy).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('register disposer republishes (mounted outlets drop the dead dictionary)', () => {
|
||||
const { svc } = make()
|
||||
const dispose = svc.register('ns', 'zh', { k: 'v' })
|
||||
const before = svc.getSnapshot().revision
|
||||
dispose()
|
||||
expect(svc.getSnapshot().revision).toBe(before + 1)
|
||||
// Second run hits the idempotent arm: nothing removed, no republish.
|
||||
dispose()
|
||||
expect(svc.getSnapshot().revision).toBe(before + 1)
|
||||
})
|
||||
|
||||
it('setLocale persists, republishes an immutable snapshot, and no-ops on same value', () => {
|
||||
const { svc, events } = make()
|
||||
svc.setLocale('en')
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Service } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {
|
||||
OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
|
||||
LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
|
||||
SlotScope, SlotSpec, StoreDecl, StoredEntry, StoreInstanceLike,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
@@ -70,6 +70,8 @@ interface ErasedRegisterOptions {
|
||||
select?: (owner: never) => unknown
|
||||
/** Chain-slot explicit ordering override (ascending; registration order otherwise). */
|
||||
priority?: number
|
||||
/** Declared dictionary namespace (the renderer synthesizes the `t` seat from it). */
|
||||
locale?: string
|
||||
registrant?: string
|
||||
}
|
||||
|
||||
@@ -82,6 +84,7 @@ export class SlotsService extends Service {
|
||||
/** Store-instance axis: handle -> mounted scope, refcount, resolved instances. */
|
||||
private readonly _stores = new Map<EngineStoreHandle, StoreAxisRecord>()
|
||||
private _renderer: SlotRenderer | undefined
|
||||
private _locale: LocaleFace | undefined
|
||||
private _host: SlotRendererHost | undefined
|
||||
|
||||
/**
|
||||
@@ -127,6 +130,23 @@ export class SlotsService extends Service {
|
||||
}, 'slots.install()')
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the locale face backing the `t` standard seat (the locale
|
||||
* plugin's product; same boot-once discipline as the renderer install).
|
||||
* Runs through the caller's ctx.effect, so the installing fiber's unload
|
||||
* uninstalls the face.
|
||||
* @param face - namespace binder + revision observable.
|
||||
*/
|
||||
installLocale(face: LocaleFace): void {
|
||||
if (this._locale !== undefined) throw new Error('locale face already installed (installLocale() is boot-once)')
|
||||
this.ctx.effect(() => {
|
||||
this._locale = face
|
||||
return () => {
|
||||
if (this._locale === face) this._locale = undefined
|
||||
}
|
||||
}, 'slots.installLocale()')
|
||||
}
|
||||
|
||||
/**
|
||||
* The single ctx-level render entry: the shell renders 'root'; every other
|
||||
* key renders inside components through the props renderSlot face. All
|
||||
@@ -246,6 +266,12 @@ export class SlotsService extends Service {
|
||||
if (workspaces === undefined) {
|
||||
throw new Error("renderSlot('root') before the workspaces service mounted — boot order puts runtime apply first")
|
||||
}
|
||||
// `locale` is a live getter: the face installs (and, under HMR, swaps)
|
||||
// on the locale plugin's own fiber lifetime, while this host object is
|
||||
// built once — a captured value would strand renders on a dead face. The
|
||||
// alias is required: `this` inside the getter is the host literal.
|
||||
// oxlint-disable-next-line typescript/no-this-alias
|
||||
const service = this
|
||||
this._host = {
|
||||
subscribe: (key, fn) => this._core.subscribe(key, fn),
|
||||
getVersion: key => this._core.getVersion(key),
|
||||
@@ -259,6 +285,7 @@ export class SlotsService extends Service {
|
||||
provideInfo: sessions.currentProvideInfo,
|
||||
},
|
||||
workspaces: { list: workspaces.list },
|
||||
get locale() { return service._locale },
|
||||
}
|
||||
return this._host
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-command"
|
||||
],
|
||||
@@ -36,6 +37,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-locale": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-command": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
|
||||
@@ -49,6 +51,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { ModelReasoningEffort, ModelTarget } from '@deepseek-ai/dsh-client-
|
||||
import {
|
||||
IconCheckOutline16, IconChevronDownOutline14, IconChevronRightOutline14,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ModelSelectInjected } from './slots.ts'
|
||||
import css from './ModelSelect.module.css'
|
||||
|
||||
@@ -34,10 +35,13 @@ interface EffortChoice {
|
||||
|
||||
/**
|
||||
* Render the composer model seat.
|
||||
* @param props - owner share (locked) + injected face (shared directory store/verbs).
|
||||
* @param props - owner share (locked) + injected face (shared directory
|
||||
* store/verbs) + the standard locale seat.
|
||||
* @returns the trigger and, while open, the two-level menu.
|
||||
*/
|
||||
export function ModelSelect({ locked, directory, load, select }: ModelSelectInjected & { locked: boolean }) {
|
||||
export function ModelSelect(
|
||||
{ locked, directory, load, select, t }: ModelSelectInjected & { locked: boolean } & PropsLocale<'model'>,
|
||||
) {
|
||||
const state = useSyncExternalStore(
|
||||
fn => directory.subscribe(fn),
|
||||
() => directory.getSnapshot(),
|
||||
@@ -70,13 +74,13 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
|
||||
const effortLabel = reasoning === undefined
|
||||
? undefined
|
||||
: effectiveEffort === undefined
|
||||
? 'Provider default'
|
||||
? t('effort.providerDefault')
|
||||
: reasoning.efforts.find(level => level.id === effectiveEffort)?.name ?? effectiveEffort
|
||||
const effortChoices = useMemo<readonly EffortChoice[]>(() => reasoning === undefined
|
||||
? []
|
||||
: [
|
||||
...reasoning.defaultEffort === undefined
|
||||
? [{ key: 'provider-default', effort: undefined, label: 'Provider default' }]
|
||||
? [{ key: 'provider-default', effort: undefined, label: t('effort.providerDefault') }]
|
||||
: [],
|
||||
...reasoning.efforts.map((effort: ModelReasoningEffort) => ({
|
||||
key: `effort:${effort.id}`,
|
||||
@@ -84,7 +88,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
|
||||
label: effort.name,
|
||||
...effort.description === undefined ? {} : { description: effort.description },
|
||||
})),
|
||||
], [reasoning])
|
||||
], [reasoning, t])
|
||||
const busy = state.status === 'selecting'
|
||||
|
||||
// Mount-time load resolves the trigger label; every open refreshes.
|
||||
@@ -165,7 +169,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
|
||||
})
|
||||
}
|
||||
|
||||
const modelLabel = choices[selectedIndex]?.model.name ?? state.current?.model ?? '选择模型'
|
||||
const modelLabel = choices[selectedIndex]?.model.name ?? state.current?.model ?? t('trigger.fallback')
|
||||
const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`
|
||||
itemRefs.current = []
|
||||
let itemIndex = 0
|
||||
@@ -180,7 +184,9 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={css.trigger}
|
||||
aria-label={`选择模型,当前 ${modelLabel}${effortLabel === undefined ? '' : `,推理等级 ${effortLabel}`}`}
|
||||
aria-label={effortLabel === undefined
|
||||
? t('trigger.aria', { model: modelLabel })
|
||||
: t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel })}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? `${id}-menu` : undefined}
|
||||
@@ -204,19 +210,19 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
|
||||
id={`${id}-menu`}
|
||||
className={css.menu}
|
||||
role="menu"
|
||||
aria-label="模型与推理等级"
|
||||
aria-label={t('menu.aria')}
|
||||
aria-busy={state.status === 'loading' || busy}
|
||||
>
|
||||
{pane === 'root' && (
|
||||
<>
|
||||
<button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => { setPane('model') }}>
|
||||
<span className={css.cellLabel}>Model</span>
|
||||
<span className={css.cellLabel}>{t('menu.model')}</span>
|
||||
<span className={css.cellValue}>{modelLabel}</span>
|
||||
<IconChevronRightOutline14 className={css.cellChevron} />
|
||||
</button>
|
||||
{reasoning !== undefined && (
|
||||
<button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => { setPane('effort') }}>
|
||||
<span className={css.cellLabel}>Effort</span>
|
||||
<span className={css.cellLabel}>{t('menu.effort')}</span>
|
||||
<span className={css.cellValue}>{effortLabel}</span>
|
||||
<IconChevronRightOutline14 className={css.cellChevron} />
|
||||
</button>
|
||||
@@ -227,18 +233,18 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
|
||||
{pane === 'model' && (
|
||||
<>
|
||||
{state.status === 'loading' && (
|
||||
<div className={css.status}>正在刷新模型列表…</div>
|
||||
<div className={css.status}>{t('status.loading')}</div>
|
||||
)}
|
||||
{state.error !== null && (
|
||||
<div className={css.error}>
|
||||
<span>模型操作失败:{state.error}</span>
|
||||
<button type="button" className={css.retry} onClick={() => { load() }}>重试</button>
|
||||
<span>{t('error.action', { message: state.error })}</span>
|
||||
<button type="button" className={css.retry} onClick={() => { load() }}>{t('retry')}</button>
|
||||
</div>
|
||||
)}
|
||||
{state.failures.map(failure => (
|
||||
<div className={css.warning} key={failure.id}>
|
||||
<span>{failure.name} 加载失败:{failure.message}</span>
|
||||
<button type="button" className={css.retry} onClick={() => { load() }}>重试</button>
|
||||
<span>{t('warning.groupLoad', { name: failure.name, message: failure.message })}</span>
|
||||
<button type="button" className={css.retry} onClick={() => { load() }}>{t('retry')}</button>
|
||||
</div>
|
||||
))}
|
||||
<div className={clsx(css.groups, 'scrollable')}>
|
||||
@@ -267,7 +273,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
|
||||
<span className={css.description}>{model.description}</span>
|
||||
)}
|
||||
{model.unlisted === true && (
|
||||
<span className={css.unlisted}>当前模型 · 未列入目录</span>
|
||||
<span className={css.unlisted}>{t('option.currentUnlisted')}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={css.check}>
|
||||
@@ -281,7 +287,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
|
||||
})}
|
||||
</div>
|
||||
{state.status === 'ready' && choices.length === 0 && (
|
||||
<div className={css.empty}>没有可用的模型。</div>
|
||||
<div className={css.empty}>{t('empty.models')}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -290,12 +296,12 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
|
||||
<>
|
||||
{state.error !== null && (
|
||||
<div className={css.error}>
|
||||
<span>模型操作失败:{state.error}</span>
|
||||
<button type="button" className={css.retry} onClick={() => { load() }}>重新加载</button>
|
||||
<span>{t('error.action', { message: state.error })}</span>
|
||||
<button type="button" className={css.retry} onClick={() => { load() }}>{t('action.reload')}</button>
|
||||
</div>
|
||||
)}
|
||||
{effortChoices.length === 0
|
||||
? <div className={css.empty}>当前模型未提供推理等级。</div>
|
||||
? <div className={css.empty}>{t('empty.efforts')}</div>
|
||||
: effortChoices.map(level => (
|
||||
<button
|
||||
ref={itemRef()}
|
||||
|
||||
@@ -14,15 +14,27 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
|
||||
// Type-only: pulls the ui-conversation SlotMap merge (the input.model seat).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ModelDirectoryState } from './directory.ts'
|
||||
import { ModelService } from './service.ts'
|
||||
import type { ModelSelectInjected } from './slots.ts'
|
||||
import { ModelSelect } from './ModelSelect.tsx'
|
||||
import { en, zh, type ModelKey } from './locales.ts'
|
||||
|
||||
export { ModelDirectory } from './directory.ts'
|
||||
export type { ModelDirectoryState } from './directory.ts'
|
||||
export { ModelService } from './service.ts'
|
||||
export type { ModelSelectInjected } from './slots.ts'
|
||||
export type { ModelKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** The model selection surfaces' copy (/model popup + composer seat). */
|
||||
model: ModelKey
|
||||
}
|
||||
}
|
||||
|
||||
/** One selectable row's id: an opaque row key (resolved by lookup, never parsed). */
|
||||
function rowId(providerId: string, modelId: string): string {
|
||||
@@ -30,7 +42,7 @@ function rowId(providerId: string, modelId: string): string {
|
||||
}
|
||||
|
||||
/** Flatten the directory into popup rows; failure rows are listed for visibility but never selectable. */
|
||||
function optionsOf(directory: SessionModels): SelectOption[] {
|
||||
function optionsOf(directory: SessionModels, t: TranslateNS<'model'>): SelectOption[] {
|
||||
const rows: SelectOption[] = []
|
||||
for (const group of directory.groups) {
|
||||
for (const model of group.models) {
|
||||
@@ -38,7 +50,7 @@ function optionsOf(directory: SessionModels): SelectOption[] {
|
||||
id: rowId(group.id, model.id),
|
||||
label: model.name,
|
||||
detail: model.unlisted === true
|
||||
? `${group.name} · 未列入目录`
|
||||
? t('option.unlisted', { group: group.name })
|
||||
: model.description !== undefined ? `${group.name} · ${model.description}` : group.name,
|
||||
...(directory.current.provider === group.id && directory.current.model === model.id
|
||||
? { active: true } : {}),
|
||||
@@ -46,7 +58,11 @@ function optionsOf(directory: SessionModels): SelectOption[] {
|
||||
}
|
||||
}
|
||||
for (const failure of directory.failures) {
|
||||
rows.push({ id: `failure/${failure.id}`, label: failure.name, detail: `目录加载失败:${failure.message}` })
|
||||
rows.push({
|
||||
id: `failure/${failure.id}`,
|
||||
label: failure.name,
|
||||
detail: t('option.loadError', { message: failure.message }),
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -76,28 +92,40 @@ function targetOf(state: ModelDirectoryState, id: string): ModelTarget | undefin
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Required services: the contribution registry, the seat's slot registry, and the service's own faces. */
|
||||
export const inject = ['command', 'connection', 'sessions', 'slots']
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
const NS = 'model'
|
||||
|
||||
/** Required services: the contribution registry, the seat's slot registry, locale, and the service's own faces. */
|
||||
export const inject = ['command', 'connection', 'locale', 'sessions', 'slots']
|
||||
|
||||
/**
|
||||
* Client plugin body: mount ModelService, then register the /model popup
|
||||
* contribution and the composer model seat over it.
|
||||
* Client plugin body: mount ModelService, register the `model` dictionaries,
|
||||
* then register the /model popup contribution and the composer model seat
|
||||
* over the service.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.plugin(ModelService)
|
||||
|
||||
// Entry 1: the /model popupSelect over the shared directory.
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-model: dictionaries')
|
||||
|
||||
// Non-slot faces (the command description, the popup option builder) read
|
||||
// through the bound translate; the seat component reads the standard seat.
|
||||
const t = ctx.locale.bind(NS)
|
||||
|
||||
// Entry 1: the /model popupSelect over the shared directory. The command
|
||||
// description is registry-held text: it reads t() once at registration and
|
||||
// refreshes only on re-registration, not on locale change.
|
||||
ctx.inject(['command', 'models'], (scope: ClientContext) => {
|
||||
const command = scope.get('command') as CommandServiceContract
|
||||
const models = scope.models
|
||||
scope.effect(() => command.register({
|
||||
name: 'model',
|
||||
description: 'Select the model for this conversation',
|
||||
description: t('command.description'),
|
||||
available: () => true,
|
||||
ui: {
|
||||
kind: 'popupSelect',
|
||||
options: async session => optionsOf(await models.directoryFor(session.sessionId).load()),
|
||||
options: async session => optionsOf(await models.directoryFor(session.sessionId).load(), t),
|
||||
onSelect: async (option, session) => {
|
||||
const directory = models.directoryFor(session.sessionId)
|
||||
const target = targetOf(directory.store.getSnapshot(), option.id)
|
||||
@@ -117,6 +145,7 @@ export function apply(ctx: ClientContext): void {
|
||||
const models = scope.models
|
||||
scope.effect(() => scope.slots.register({
|
||||
name: 'conversation.input.model',
|
||||
locale: NS,
|
||||
inject: (sessionId): ModelSelectInjected => {
|
||||
const directory = models.directoryFor(sessionId)
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/** `model` namespace dictionaries. */
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'command.description': '选择本会话使用的模型',
|
||||
'option.unlisted': '{group} · 未列入目录',
|
||||
'option.loadError': '目录加载失败:{message}',
|
||||
'trigger.fallback': '选择模型',
|
||||
'trigger.aria': '选择模型,当前 {model}',
|
||||
'trigger.ariaEffort': '选择模型,当前 {model},推理等级 {effort}',
|
||||
'menu.aria': '模型与推理等级',
|
||||
'menu.model': '模型',
|
||||
'menu.effort': '推理等级',
|
||||
'effort.providerDefault': '服务商默认',
|
||||
'status.loading': '正在刷新模型列表…',
|
||||
'error.action': '模型操作失败:{message}',
|
||||
'action.reload': '重新加载',
|
||||
'warning.groupLoad': '{name} 加载失败:{message}',
|
||||
'option.currentUnlisted': '当前模型 · 未列入目录',
|
||||
'empty.models': '没有可用的模型。',
|
||||
'empty.efforts': '当前模型未提供推理等级。',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The model namespace key union. */
|
||||
export type ModelKey = keyof typeof zh
|
||||
|
||||
/** English dictionary, checked complete against the zh key set. */
|
||||
export const en = {
|
||||
'command.description': 'Select the model for this conversation',
|
||||
'option.unlisted': '{group} · Not in catalog',
|
||||
'option.loadError': 'Catalog failed to load: {message}',
|
||||
'trigger.fallback': 'Select model',
|
||||
'trigger.aria': 'Select model, current {model}',
|
||||
'trigger.ariaEffort': 'Select model, current {model}, reasoning effort {effort}',
|
||||
'menu.aria': 'Model and reasoning effort',
|
||||
'menu.model': 'Model',
|
||||
'menu.effort': 'Effort',
|
||||
'effort.providerDefault': 'Provider default',
|
||||
'status.loading': 'Refreshing model list…',
|
||||
'error.action': 'Model operation failed: {message}',
|
||||
'action.reload': 'Reload',
|
||||
'warning.groupLoad': '{name} failed to load: {message}',
|
||||
'option.currentUnlisted': 'Current model · Not in catalog',
|
||||
'empty.models': 'No models available.',
|
||||
'empty.efforts': 'This model provides no reasoning effort levels.',
|
||||
} satisfies Record<ModelKey, string>
|
||||
@@ -12,6 +12,7 @@ import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createScope } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { CommandContribution, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
|
||||
import type { ModelSelectInjected } from '../src/client/slots.ts'
|
||||
@@ -79,14 +80,18 @@ async function bench() {
|
||||
return () => { contribution = undefined }
|
||||
},
|
||||
})
|
||||
const seats = new Map<string, { inject: ((sessionId: SessionId) => ModelSelectInjected) | undefined }>()
|
||||
const seats = new Map<string, {
|
||||
inject: ((sessionId: SessionId) => ModelSelectInjected) | undefined
|
||||
locale: string | undefined
|
||||
}>()
|
||||
ctx.provide('slots', {
|
||||
register(options: { name: string; inject?: (sessionId: SessionId) => ModelSelectInjected }) {
|
||||
seats.set(options.name, { inject: options.inject })
|
||||
register(options: { name: string; locale?: string; inject?: (sessionId: SessionId) => ModelSelectInjected }) {
|
||||
seats.set(options.name, { inject: options.inject, locale: options.locale })
|
||||
return () => { seats.delete(options.name) }
|
||||
},
|
||||
})
|
||||
ctx.provide('conversation', {})
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
const scopes = new Map<SessionId, Context>()
|
||||
ctx.provide('sessions', { scope: (id: SessionId) => scopes.get(id) })
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
@@ -114,6 +119,8 @@ describe('ui-model dual entry', () => {
|
||||
expect(b.contribution().name).toBe('model')
|
||||
expect(b.contribution().ui.kind).toBe('popupSelect')
|
||||
expect(b.seat().inject).toBeTypeOf('function')
|
||||
// Copy rides the standard locale seat.
|
||||
expect(b.seat().locale).toBe('model')
|
||||
})
|
||||
|
||||
it('popup options mark the host current active with the provider group in the detail', async () => {
|
||||
|
||||
@@ -3,8 +3,22 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { ModelDirectoryState } from '../src/client/directory.ts'
|
||||
import { ModelSelect } from '../src/client/ModelSelect.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
|
||||
// The seat's key domain is model ∪ common; the stub mirrors the real lookup
|
||||
// chain: package dictionary, then common vocabulary, then the key.
|
||||
const t: ComponentProps<typeof ModelSelect>['t'] = (key, params) => {
|
||||
const template = (zh as Record<string, string>)[key]
|
||||
?? (commonZh as Record<string, string>)[key]
|
||||
?? key
|
||||
return params === undefined
|
||||
? template
|
||||
: template.replace(/\{(\w+)\}/g, (match, name: string) => name in params ? String(params[name]) : match)
|
||||
}
|
||||
|
||||
const reasoning = {
|
||||
efforts: [
|
||||
@@ -44,13 +58,14 @@ describe('ModelSelect reasoning effort', () => {
|
||||
directory={directory}
|
||||
load={vi.fn()}
|
||||
select={select}
|
||||
t={t}
|
||||
/>)
|
||||
|
||||
const trigger = screen.getByRole('button', {
|
||||
name: '选择模型,当前 DeepSeek-V4-Flash,推理等级 High',
|
||||
})
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /Effort/ }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
|
||||
expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
|
||||
.toEqual(['Off', 'High', 'MaxLargest budget'])
|
||||
|
||||
@@ -83,13 +98,14 @@ describe('ModelSelect reasoning effort', () => {
|
||||
directory={directory}
|
||||
load={vi.fn()}
|
||||
select={vi.fn().mockResolvedValue(true)}
|
||||
t={t}
|
||||
/>)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {
|
||||
name: '选择模型,当前 Model,推理等级 Provider default',
|
||||
name: '选择模型,当前 Model,推理等级 服务商默认',
|
||||
}))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /Effort/ }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
|
||||
expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
|
||||
.toEqual(['Provider default', 'Standard'])
|
||||
.toEqual(['服务商默认', 'Standard'])
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,9 @@
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
@@ -44,11 +45,13 @@
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-locale": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
Button, IconCheckOutline16, IconChevronLeftOutline14, IconChevronRightOutline14,
|
||||
IconCloseOutline16, IconEditOutline16, MarkdownText,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { PendingQuestion, type QuestionAnswer, type QuestionComposerProps } from './contract/slots.ts'
|
||||
import {
|
||||
PendingQuestion,
|
||||
type QuestionAnswer, type QuestionComposerProps,
|
||||
} from './contract/slots.ts'
|
||||
import css from './QuestionComposer.module.css'
|
||||
|
||||
interface DraftAnswer {
|
||||
@@ -52,17 +55,20 @@ export function QuestionComposer(props: QuestionComposerProps) {
|
||||
// Domain-face mint rides the carrier's stable identity (never minted in a
|
||||
// select/render dispatch — per-dispatch minting would churn memo identity).
|
||||
const question = useMemo(() => new PendingQuestion(props.matched), [props.matched])
|
||||
return <QuestionFlow key={question.key} pending={question} />
|
||||
return <QuestionFlow key={question.key} pending={question} t={props.t} />
|
||||
}
|
||||
|
||||
function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
||||
function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<QuestionComposerProps, 't'>) {
|
||||
const questions = pending.questions
|
||||
const [index, setIndex] = useState(0)
|
||||
const [drafts, setDrafts] = useState<DraftAnswer[]>(() => questions.map(question => ({
|
||||
selected: [], custom: '', customOpen: (question.options?.length ?? 0) === 0, skipped: false,
|
||||
})))
|
||||
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// Validation feedback is stored as a dictionary KEY and translated at
|
||||
// render, so already-shown feedback follows a locale switch; runtime
|
||||
// failure messages (finished strings from the wire) pass through verbatim.
|
||||
const [error, setError] = useState<{ key: 'error.incomplete' | 'error.unanswered' } | { text: string } | null>(null)
|
||||
// index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1.
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
const question = questions[index]!
|
||||
@@ -75,7 +81,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
||||
setError(null)
|
||||
void pending.cancel().catch((cause: unknown) => {
|
||||
setBusy(null)
|
||||
setError(cause instanceof Error ? cause.message : String(cause))
|
||||
setError({ text: cause instanceof Error ? cause.message : String(cause) })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -111,7 +117,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
||||
const missing = values.findIndex(item => !completed(item))
|
||||
if (missing >= 0) {
|
||||
setIndex(missing)
|
||||
setError('请先完成这道问题。')
|
||||
setError({ key: 'error.incomplete' })
|
||||
return
|
||||
}
|
||||
const answer: QuestionAnswer = {
|
||||
@@ -130,13 +136,13 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
||||
setError(null)
|
||||
void pending.answer(answer).catch((cause: unknown) => {
|
||||
setBusy(null)
|
||||
setError(cause instanceof Error ? cause.message : String(cause))
|
||||
setError({ text: cause instanceof Error ? cause.message : String(cause) })
|
||||
})
|
||||
}
|
||||
|
||||
const continueFlow = (): void => {
|
||||
if (!answered(draft)) {
|
||||
setError('请选择一个选项或填写自定义答案。')
|
||||
setError({ key: 'error.unanswered' })
|
||||
return
|
||||
}
|
||||
if (index < questions.length - 1) {
|
||||
@@ -174,28 +180,30 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
||||
<span>{question.multiSelect === true
|
||||
? parseQuestionTitle(question.question)
|
||||
: question.question}</span>
|
||||
{question.multiSelect === true && <span className={css.multiSelectHint}>可多选</span>}
|
||||
{question.multiSelect === true && (
|
||||
<span className={css.multiSelectHint}>{t('title.multi')}</span>
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
<div className={css.headerActions}>
|
||||
<span className={css.progress}>{index + 1} / {questions.length}</span>
|
||||
<button
|
||||
type="button" className={css.iconButton} aria-label="上一题"
|
||||
type="button" className={css.iconButton} aria-label={t('nav.prev')}
|
||||
disabled={index === 0 || busy !== null}
|
||||
onClick={() => { setIndex(index - 1); setError(null) }}
|
||||
>
|
||||
<IconChevronLeftOutline14 />
|
||||
</button>
|
||||
<button
|
||||
type="button" className={css.iconButton} aria-label="下一题"
|
||||
type="button" className={css.iconButton} aria-label={t('nav.next')}
|
||||
disabled={index === questions.length - 1 || busy !== null}
|
||||
onClick={() => { setIndex(index + 1); setError(null) }}
|
||||
>
|
||||
<IconChevronRightOutline14 />
|
||||
</button>
|
||||
<button
|
||||
type="button" className={css.iconButton} aria-label="放弃整组问题"
|
||||
title="放弃整组问题"
|
||||
type="button" className={css.iconButton} aria-label={t('nav.cancel')}
|
||||
title={t('nav.cancel')}
|
||||
disabled={busy !== null} onClick={cancelFlow}
|
||||
>
|
||||
<IconCloseOutline16 />
|
||||
@@ -230,7 +238,9 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
||||
<span className={css.optionCopy}>
|
||||
<span className={css.optionLine}>
|
||||
<span className={css.optionLabel}>{display.label}</span>
|
||||
{display.recommended && <span className={css.badge}>推荐</span>}
|
||||
{display.recommended && (
|
||||
<span className={css.badge}>{t('option.recommended')}</span>
|
||||
)}
|
||||
{option.description !== undefined && (
|
||||
<span className={css.description}>{option.description}</span>
|
||||
)}
|
||||
@@ -255,7 +265,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
||||
aria-expanded={draft.customOpen}
|
||||
>
|
||||
<span className={css.number}><IconEditOutline16 /></span>
|
||||
<span>其他,请填写自定义答案</span>
|
||||
<span>{t('option.custom')}</span>
|
||||
</button>
|
||||
)}
|
||||
{draft.customOpen && (
|
||||
@@ -265,7 +275,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
||||
value={draft.custom}
|
||||
disabled={busy !== null}
|
||||
rows={2}
|
||||
placeholder="输入你的答案"
|
||||
placeholder={t('custom.placeholder')}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value
|
||||
updateDraft(current => ({
|
||||
@@ -285,18 +295,18 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
||||
</div>
|
||||
|
||||
<footer className={css.footer}>
|
||||
<div className={css.feedback} role="status">{error}</div>
|
||||
<div className={css.feedback} role="status">{error === null ? null : 'key' in error ? t(error.key) : error.text}</div>
|
||||
<div className={css.footerActions}>
|
||||
<Button variant="ghost" size="sm" disabled={busy !== null} onClick={skipQuestion}>
|
||||
跳过本题
|
||||
{t('action.skip')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary" size="sm"
|
||||
disabled={busy !== null || !answered(draft)} onClick={continueFlow}
|
||||
>
|
||||
{busy === 'answer'
|
||||
? '正在提交…'
|
||||
: index === questions.length - 1 ? '提交' : '下一题'}
|
||||
? t('submitting')
|
||||
: index === questions.length - 1 ? t('submit') : t('action.next')}
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* cancelled error encoding, receipt checks — lives HERE, with the package
|
||||
* that consumes it.
|
||||
*/
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Also pulls ui-conversation's SlotMap merge (the 'conversation.composer'
|
||||
// entry) into every program that sees this contract, so PropsRuntime resolves.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
@@ -70,8 +70,9 @@ export class PendingQuestion {
|
||||
/**
|
||||
* Full component props: the framework runtime share (chain currency +
|
||||
* session/global standard kit) plus the chain `matched` share — the entry's
|
||||
* selector result, already narrowed to the question carrier. No injected
|
||||
* share: the carrier plus the domain face above carry the whole behavior
|
||||
* surface.
|
||||
* selector result, already narrowed to the question carrier — plus the
|
||||
* standard locale seat; the carrier plus the domain face above carry the
|
||||
* whole behavior surface.
|
||||
*/
|
||||
export type QuestionComposerProps = PropsRuntime<'conversation.composer'> & { matched: QuestionWait }
|
||||
export type QuestionComposerProps =
|
||||
PropsRuntime<'conversation.composer'> & { matched: QuestionWait } & PropsLocale<'question'>
|
||||
@@ -1,18 +1,32 @@
|
||||
/**
|
||||
* Web question plugin, browser half: QuestionComposer registered as a
|
||||
* selector-routed entry of the conversation-declared composer chain. Pure
|
||||
* consumer — the selector narrows the owner's currency to the question
|
||||
* carrier (matched prop), and the whole behavior surface rides the carrier
|
||||
* (domain encoding in contract/slots.ts PendingQuestion); no inject face, no
|
||||
* service dependency beyond slots. Export discipline: packages/client/AGENTS.md.
|
||||
* selector-routed entry of the conversation-declared composer chain, plus the
|
||||
* `question` dictionaries. The selector narrows the owner's currency to the
|
||||
* question carrier (matched prop), and the whole behavior surface rides the
|
||||
* carrier (domain encoding in contract/slots.ts PendingQuestion); copy rides
|
||||
* the standard locale seat. Export discipline: packages/client/AGENTS.md.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { QuestionWait } from './contract/slots.ts'
|
||||
import { QuestionComposer } from './QuestionComposer.tsx'
|
||||
import { en, zh, type QuestionKey } from './locales.ts'
|
||||
|
||||
export { PendingQuestion } from './contract/slots.ts'
|
||||
export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './contract/slots.ts'
|
||||
export type { QuestionKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** The question composer's copy. */
|
||||
question: QuestionKey
|
||||
}
|
||||
}
|
||||
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
const NS = 'question'
|
||||
|
||||
/**
|
||||
* Required services (cordis fiber inject). 'conversation' is an ordering
|
||||
@@ -20,7 +34,7 @@ export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './cont
|
||||
* declared by ui-conversation's apply, and register() into an undeclared
|
||||
* slot throws — service waiting orders this apply after the declaring one.
|
||||
*/
|
||||
export const inject = ['slots', 'conversation']
|
||||
export const inject = ['slots', 'conversation', 'locale']
|
||||
|
||||
/** Chain routing: claim the composer while a question wait is pending (pure — owner props only). */
|
||||
function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | null {
|
||||
@@ -28,14 +42,19 @@ function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | nu
|
||||
}
|
||||
|
||||
/**
|
||||
* Client plugin body: register the question composer into the composer chain.
|
||||
* Zero business face — data and verbs both live on the matched carrier.
|
||||
* Client plugin body: register the `question` dictionaries and the question
|
||||
* composer into the composer chain. Zero business face — data and verbs live
|
||||
* on the matched carrier; t rides the standard locale seat.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const slots = ctx.slots
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-question: dictionaries')
|
||||
|
||||
ctx.effect(
|
||||
() => slots.register({ name: 'conversation.composer', select: selectQuestion }, QuestionComposer),
|
||||
() => ctx.slots.register(
|
||||
{ name: 'conversation.composer', select: selectQuestion, locale: NS },
|
||||
QuestionComposer,
|
||||
),
|
||||
'ui-question: composer chain registration',
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/** `question` namespace dictionaries. */
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'error.incomplete': '请先完成这道问题。',
|
||||
'error.unanswered': '请选择一个选项或填写自定义答案。',
|
||||
'title.multi': '可多选',
|
||||
'nav.prev': '上一题',
|
||||
'nav.next': '下一题',
|
||||
'nav.cancel': '放弃整组问题',
|
||||
'option.recommended': '推荐',
|
||||
'option.custom': '其他,请填写自定义答案',
|
||||
'custom.placeholder': '输入你的答案',
|
||||
'action.skip': '跳过本题',
|
||||
'action.next': '下一题',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The question namespace key union. */
|
||||
export type QuestionKey = keyof typeof zh
|
||||
|
||||
/** English dictionary, checked complete against the zh key set. */
|
||||
export const en = {
|
||||
'error.incomplete': 'Please complete this question first.',
|
||||
'error.unanswered': 'Please select an option or enter a custom answer.',
|
||||
'title.multi': 'Multi-select',
|
||||
'nav.prev': 'Previous question',
|
||||
'nav.next': 'Next question',
|
||||
'nav.cancel': 'Dismiss all questions',
|
||||
'option.recommended': 'Recommended',
|
||||
'option.custom': 'Other — enter a custom answer',
|
||||
'custom.placeholder': 'Type your answer',
|
||||
'action.skip': 'Skip this question',
|
||||
'action.next': 'Next',
|
||||
} satisfies Record<QuestionKey, string>
|
||||
@@ -9,6 +9,7 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
|
||||
@@ -24,12 +25,13 @@ async function bench() {
|
||||
// 'conversation' inject is an ordering edge (the declaring plugin provides
|
||||
// it after declaring the chain); the bench declares the chain itself.
|
||||
ctx.provide('conversation', {})
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
return { ctx, slots }
|
||||
}
|
||||
|
||||
describe('apply', () => {
|
||||
it('declares the services it binds', () => {
|
||||
expect(inject).toEqual(['slots', 'conversation'])
|
||||
expect(inject).toEqual(['slots', 'conversation', 'locale'])
|
||||
})
|
||||
|
||||
it('fails loud when no live entry has declared the composer slot', async () => {
|
||||
@@ -38,6 +40,7 @@ describe('apply', () => {
|
||||
// Satisfy the ordering inject without declaring the chain: apply must
|
||||
// then hit the undeclared-slot throw, not sit waiting on the service.
|
||||
ctx.provide('conversation', {})
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
await expect(ctx.plugin({ inject: [...inject], apply }))
|
||||
.rejects.toThrow(/slot "conversation.composer" is not declared/)
|
||||
})
|
||||
@@ -47,8 +50,10 @@ describe('apply', () => {
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const entry = slots.entries('conversation.composer')[0]!
|
||||
expect(entry.component).toBe(QuestionComposer)
|
||||
// The whole behavior surface rides the matched carrier: no business face.
|
||||
// The whole behavior surface rides the matched carrier: no business face;
|
||||
// copy rides the standard locale seat.
|
||||
expect(entry.inject).toBeUndefined()
|
||||
expect(entry.locale).toBe('question')
|
||||
// The selector narrows the chain currency: question wait in → that wait; none → null.
|
||||
const select = entry.select as (owner: { interactions: readonly { kind: string }[] }) => unknown
|
||||
const question = { kind: 'question' }
|
||||
|
||||
@@ -8,10 +8,12 @@ import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { RpcReceipt } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { PendingQuestion } from '../src/client/contract/slots.ts'
|
||||
import { PendingQuestion, type QuestionComposerProps } from '../src/client/contract/slots.ts'
|
||||
import {
|
||||
QuestionComposer, parseQuestionTitle, parseRecommendedLabel,
|
||||
} from '../src/client/QuestionComposer.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -28,6 +30,11 @@ const kit = {
|
||||
useProjection: (() => undefined) as never,
|
||||
useInput: (() => { throw new Error('unused') }) as never,
|
||||
inputActions: { setDraft: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') } } as never,
|
||||
// The seat's key domain is question ∪ common; the stub mirrors the real
|
||||
// lookup chain: package dictionary, then common vocabulary, then the key.
|
||||
t: (key => (zh as Record<string, string>)[key]
|
||||
?? (commonZh as Record<string, string>)[key]
|
||||
?? key) as QuestionComposerProps['t'],
|
||||
}
|
||||
|
||||
const QUESTIONS = [
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-layout"
|
||||
"@deepseek-ai/dsh-client-ui-layout",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
],
|
||||
"platform": "web"
|
||||
},
|
||||
@@ -38,6 +39,7 @@
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-locale": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
@@ -46,6 +48,7 @@
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
|
||||
@@ -32,6 +32,7 @@ export function SidebarRoot({
|
||||
width,
|
||||
startSession,
|
||||
toggleSidebar,
|
||||
t,
|
||||
renderSlot,
|
||||
}: SidebarRootComponentProps) {
|
||||
// Wide content stays mounted while the collapse animates (fading via
|
||||
@@ -67,7 +68,7 @@ export function SidebarRoot({
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.brand, css.wide)}
|
||||
aria-label="New session"
|
||||
aria-label={t('session.new.label')}
|
||||
onClick={() => { startSession() }}
|
||||
>
|
||||
<BrandWordmark />
|
||||
@@ -75,11 +76,11 @@ export function SidebarRoot({
|
||||
)}
|
||||
{/* Rail resting state is the whale mark; hovering swaps in the panel
|
||||
icon (the expand affordance, figma sidebar-hover flow). */}
|
||||
<Tooltip label="Open sidebar" disabled={wide}>
|
||||
<Tooltip label={t('toggle.open')} disabled={wide}>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.iconButton, css.toggle)}
|
||||
aria-label={collapsed ? 'Open sidebar' : 'Collapse sidebar'}
|
||||
aria-label={collapsed ? t('toggle.open') : t('toggle.collapse')}
|
||||
onClick={() => { toggleSidebar() }}
|
||||
>
|
||||
{!wide && <FishLogo className={css.railFish} size={24} />}
|
||||
@@ -89,15 +90,15 @@ export function SidebarRoot({
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<Tooltip label="New session" disabled={wide}>
|
||||
<Tooltip label={t('session.new.label')} disabled={wide}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.newSession}
|
||||
aria-label="New session"
|
||||
aria-label={t('session.new.label')}
|
||||
onClick={() => { startSession() }}
|
||||
>
|
||||
<IconNewChatOutline16 size={wide ? 14 : 18} />
|
||||
{wide && <span className={clsx(css.newSessionLabel, css.wide)}>New Session</span>}
|
||||
{wide && <span className={clsx(css.newSessionLabel, css.wide)}>{t('session.new')}</span>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* `sidebar.workspaces` registrant's (ui-workspace), and the foot is the
|
||||
* `sidebar.settings` registrant's (ui-settings).
|
||||
*/
|
||||
import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Type-only: pulls ui-layout's SlotMap merge (the 'sidebar' entry) into every
|
||||
// program that sees this contract, so PropsRuntime<'sidebar'> resolves.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
@@ -68,7 +68,9 @@ export type SidebarRootInjected = {
|
||||
|
||||
/**
|
||||
* Full component props: layout owner state/actions plus the declared holes'
|
||||
* render shares and this package's injected callbacks. No store is registered.
|
||||
* render shares, this package's injected callbacks, and the standard locale
|
||||
* seat. No store is registered.
|
||||
*/
|
||||
export type SidebarRootComponentProps =
|
||||
PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspaces' | 'sidebar.settings'> & SidebarRootInjected
|
||||
PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspaces' | 'sidebar.settings'>
|
||||
& SidebarRootInjected & PropsLocale<'sidebar'>
|
||||
@@ -1,17 +1,33 @@
|
||||
/** Registers the sidebar shell into the layout-owned slot. */
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { SidebarRootInjected } from './contract/slots.ts'
|
||||
import { SidebarRoot } from './SidebarRoot.tsx'
|
||||
import { en, zh, type SidebarKey } from './locales.ts'
|
||||
|
||||
export type { SidebarRootComponentProps, SidebarRootInjected, SidebarSectionOwnerProps, SidebarSettingsOwnerProps } from './contract/slots.ts'
|
||||
export type { SidebarKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** Sidebar shell controls copy. */
|
||||
sidebar: SidebarKey
|
||||
}
|
||||
}
|
||||
|
||||
/** Dictionary namespace owned by this plugin (shell controls copy). */
|
||||
const NS = 'sidebar'
|
||||
|
||||
/** Services required by the sidebar plugin. */
|
||||
export const inject = ['slots', 'layout', 'sessions', 'workspaces']
|
||||
export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale']
|
||||
|
||||
/** Registers the sidebar shell and its service callbacks.
|
||||
* @param ctx - Client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-sidebar: dictionaries')
|
||||
|
||||
const injectProps = (): SidebarRootInjected => ({
|
||||
// The shell's New Session button rides the runtime's shared action
|
||||
// (recent-Workspace targeting; explicit Workspace wins for scoped actions).
|
||||
@@ -21,6 +37,7 @@ export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(
|
||||
() => ctx.slots.register({
|
||||
name: 'sidebar',
|
||||
locale: NS,
|
||||
// The shell owns geometry; ui-workspace registers the whole browsing
|
||||
// region (header, search, session list, workspace dialogs), ui-settings
|
||||
// registers the foot trigger + settings panel.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/** `sidebar` namespace dictionaries: shell controls (brand row, New Session, fold toggle). */
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'session.new': '新会话',
|
||||
'session.new.label': '新建会话',
|
||||
'toggle.open': '打开侧边栏',
|
||||
'toggle.collapse': '收起侧边栏',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The sidebar namespace key union. */
|
||||
export type SidebarKey = keyof typeof zh
|
||||
|
||||
/** English dictionary, checked complete against the zh key set. */
|
||||
export const en = {
|
||||
'session.new': 'New Session',
|
||||
'session.new.label': 'New session',
|
||||
'toggle.open': 'Open sidebar',
|
||||
'toggle.collapse': 'Collapse sidebar',
|
||||
} satisfies Record<SidebarKey, string>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
import type { SidebarRootInjected } from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
|
||||
@@ -14,6 +15,7 @@ async function bench(declare = true) {
|
||||
ctx.provide('layout', layout)
|
||||
ctx.provide('sessions', sessions as never)
|
||||
ctx.provide('workspaces', workspaces as never)
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
if (declare) {
|
||||
slots.register(
|
||||
@@ -26,7 +28,7 @@ async function bench(declare = true) {
|
||||
|
||||
describe('ui-sidebar apply', () => {
|
||||
it('declares only the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces'])
|
||||
expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces', 'locale'])
|
||||
})
|
||||
|
||||
it('registers the shell and declares the browsing-region hole', async () => {
|
||||
@@ -34,6 +36,8 @@ describe('ui-sidebar apply', () => {
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
expect(b.slots.entries('sidebar')).toHaveLength(1)
|
||||
expect(b.slots.spec('sidebar.workspaces')).toEqual({ kind: 'single', scope: 'root' })
|
||||
// Copy rides the standard locale seat, not the inject face.
|
||||
expect(b.slots.entries('sidebar')[0]!.locale).toBe('sidebar')
|
||||
const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)()
|
||||
expect(Object.keys(injected)).toEqual(['startSession', 'toggleSidebar'])
|
||||
// Both arms delegate to the runtime's shared New Session action.
|
||||
|
||||
@@ -3,6 +3,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { SidebarRootComponentProps, SidebarSectionOwnerProps, SidebarSettingsOwnerProps } from '../src/client/contract/slots.ts'
|
||||
import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
// English-dictionary translate stub: the shell renders the same copy the
|
||||
// assertions below query by accessible name.
|
||||
const t: SidebarRootComponentProps['t'] = key => (en as Record<string, string>)[key] ?? key
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
@@ -23,7 +28,7 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w
|
||||
<SidebarRoot
|
||||
collapsed={current.collapsed} width={current.width}
|
||||
useSessions={neverHook} useWorkspaces={neverHook}
|
||||
startSession={startSession} toggleSidebar={toggleSidebar}
|
||||
startSession={startSession} toggleSidebar={toggleSidebar} t={t}
|
||||
renderSlot={((key: string, owner: SidebarSectionOwnerProps | SidebarSettingsOwnerProps) => {
|
||||
if (key === 'sidebar.settings') {
|
||||
settingsOwner = owner
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, waitFor } from '@testing-library/react'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -18,6 +19,12 @@ afterEach(cleanup)
|
||||
async function bench() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('layout', { toggleSidebar: vi.fn() })
|
||||
// English locale pins the snapshots to the copy they were recorded with;
|
||||
// the installed face backs the entry's standard `t` seat.
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
locale.setLocale('en')
|
||||
runtime.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
await runtime.declare({ 'sidebar': { kind: 'single', scope: 'root' } })
|
||||
await runtime.mount({ inject: [...inject], apply })
|
||||
return runtime
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
{
|
||||
"path": "../ui-layout"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
@@ -24,6 +24,67 @@ export * from './deferred.ts'
|
||||
/** Slot contract table. Owners extend via declaration merging; entries are {@link SlotEntryDef}. */
|
||||
export interface SlotMap {}
|
||||
|
||||
/**
|
||||
* Locale namespace table. Dictionary owners extend via declaration merging
|
||||
* (exactly like {@link SlotMap}, and declared in this entry module for the
|
||||
* same lexical-merge reason): the key is the namespace string, the value is
|
||||
* the union of its dictionary keys. Register sites declare one of these
|
||||
* namespaces (`locale:`), which puts the typed `t` standard seat on the
|
||||
* component props.
|
||||
*/
|
||||
export interface LocaleNamespaceMap {}
|
||||
|
||||
/**
|
||||
* Translate a dictionary key with optional `{name}` template params.
|
||||
* `K` narrows the accepted keys to the owning namespace's dictionary union
|
||||
* (plus the shared common vocabulary where composed).
|
||||
*/
|
||||
export type Translate<K extends string = string> =
|
||||
(key: K, params?: Record<string, unknown>) => string
|
||||
|
||||
/**
|
||||
* The shared `common` vocabulary keys as merged by the locale plugin;
|
||||
* resolves to `never` in programs without the merge (this package's tests),
|
||||
* keeping the union collapse harmless.
|
||||
*/
|
||||
export type CommonKeyOf = LocaleNamespaceMap extends { common: infer C } ? C & string : never
|
||||
|
||||
/**
|
||||
* Key domain of a namespace-bound translate: the namespace's own dictionary
|
||||
* union plus the shared common vocabulary (the lookup chain consults common
|
||||
* after the namespace misses).
|
||||
*/
|
||||
export type LocaleKeysOf<N extends keyof LocaleNamespaceMap & string> =
|
||||
(LocaleNamespaceMap[N] & string) | CommonKeyOf
|
||||
|
||||
/**
|
||||
* Namespace-addressed translate — the developer-facing alias over
|
||||
* {@link Translate}: `TranslateNS<'model'>` is the translate function of the
|
||||
* `model` namespace (key domain = its dictionary union plus the shared
|
||||
* common vocabulary), the exact type of the framework-injected `t` seat and
|
||||
* of the locale service's typed `bind`.
|
||||
*/
|
||||
export type TranslateNS<N extends keyof LocaleNamespaceMap & string> = Translate<LocaleKeysOf<N>>
|
||||
|
||||
/**
|
||||
* Dictionary shape for a declared namespace: exactly the keys the namespace
|
||||
* merged into {@link LocaleNamespaceMap} — a missing or extra key at a typed
|
||||
* registration site is a compile error.
|
||||
*/
|
||||
export type LocaleDictOf<N extends keyof LocaleNamespaceMap & string> =
|
||||
Record<LocaleNamespaceMap[N] & string, string>
|
||||
|
||||
/**
|
||||
* Locale share of the composed component props: the framework-injected `t`
|
||||
* seat, present exactly on entries whose registration declares `locale:`.
|
||||
*/
|
||||
export type PropsLocale<N> = N extends keyof LocaleNamespaceMap & string
|
||||
? {
|
||||
/** Translate a dictionary key of the declared namespace (or the shared common vocabulary). */
|
||||
t: TranslateNS<N>
|
||||
}
|
||||
: object
|
||||
|
||||
/** Slot cardinality: single occupant, ordered list, key-dispatched, or selector-routed chain. */
|
||||
export type SlotKind = 'single' | 'list' | 'keyed' | 'chain'
|
||||
|
||||
@@ -244,10 +305,11 @@ export type InjectFace<I extends object> =
|
||||
I extends { hooks: infer HS extends HooksSources } ? Omit<I, 'hooks'> & PropsHooks<HS> : I
|
||||
|
||||
/**
|
||||
* The four-share component props intersection: runtime share (SlotMap) +
|
||||
* The composed component props intersection: runtime share (SlotMap) +
|
||||
* child-render share (children declaration) + store share (declared handle) +
|
||||
* the registrant's injected business face (its hooks compartment bound, see
|
||||
* {@link InjectFace}). Each share derives from its single source of truth;
|
||||
* {@link InjectFace}) + the locale `t` seat (declared namespace, see
|
||||
* {@link PropsLocale}). Each share derives from its single source of truth;
|
||||
* components reference this composition, never re-type it.
|
||||
*/
|
||||
export type ComposedProps<
|
||||
@@ -256,7 +318,8 @@ export type ComposedProps<
|
||||
H,
|
||||
I extends object,
|
||||
M = never,
|
||||
> = PropsRuntime<K> & PropsRenderSlots<S> & PropsStore<H> & InjectFace<I> & MatchedShare<SlotMap[K], M>
|
||||
N = undefined,
|
||||
> = PropsRuntime<K> & PropsRenderSlots<S> & PropsStore<H> & InjectFace<I> & MatchedShare<SlotMap[K], M> & PropsLocale<N>
|
||||
|
||||
/**
|
||||
* Inject factory parameter list, derived from the registration's declaration:
|
||||
@@ -303,13 +366,20 @@ type RendersCheck<C, D> =
|
||||
: unknown
|
||||
|
||||
/** Common register options share (see {@link SlotCore.register} for semantics). */
|
||||
type BaseOptions<K extends keyof SlotMap & string, D extends ChildrenDecl, H, M = never> = {
|
||||
type BaseOptions<K extends keyof SlotMap & string, D extends ChildrenDecl, H, M = never, N = undefined> = {
|
||||
/** Target slot key (the entry contributes INTO this slot). */
|
||||
name: K
|
||||
/** Child-slot declaration + render authorization + runtime spec, in one table. */
|
||||
children?: D
|
||||
/** Store seat: a shared handle (apply-constructed) or an exclusive factory (framework-called per entry x scope). */
|
||||
store?: H
|
||||
/**
|
||||
* Dictionary namespace of this entry's copy. Declaring it puts the
|
||||
* framework-synthesized `t` seat (typed to the namespace's dictionary
|
||||
* union) on the component props; rendering requires an installed locale
|
||||
* face — fails loud otherwise.
|
||||
*/
|
||||
locale?: N
|
||||
/** Registrant identity label for diagnostics (the runtime Service wrapper stamps the caller's fiber name). */
|
||||
registrant?: string
|
||||
} & KindOptions<SlotMap[K], M>
|
||||
@@ -330,6 +400,8 @@ export interface StoredEntry {
|
||||
children?: Readonly<Record<string, SlotSpec<SlotEntryDef>>> | undefined
|
||||
/** Declared store seat (instance resolution and lifecycle live with the host machinery). */
|
||||
store?: StoreDecl | undefined
|
||||
/** Declared dictionary namespace (the render machinery synthesizes the `t` seat from it). */
|
||||
locale?: string | undefined
|
||||
/** Diagnostics label of who registered. */
|
||||
registrant?: string | undefined
|
||||
}
|
||||
@@ -350,6 +422,7 @@ interface ErasedOptions {
|
||||
priority?: number | undefined
|
||||
children?: Record<string, SlotSpec<SlotEntryDef>> | undefined
|
||||
store?: StoreDecl | undefined
|
||||
locale?: string | undefined
|
||||
/* oxlint-disable-next-line typescript/no-explicit-any --
|
||||
* implementation-signature position only (both public overloads type inject
|
||||
* exactly); `never[]` would fail overload-to-implementation compatibility
|
||||
@@ -427,16 +500,20 @@ export class SlotCore {
|
||||
* @returns disposer removing the registration and its declarations
|
||||
* (idempotent; stale disposers after a cascade are no-ops).
|
||||
*/
|
||||
/* jscpd:ignore-start -- the two register overloads are deliberately
|
||||
* parallel declarations differing only in the inject share; folding them
|
||||
* would lose the per-overload inference of I. */
|
||||
register<
|
||||
K extends keyof SlotMap & string,
|
||||
const D extends ChildrenDecl = Record<never, never>,
|
||||
H extends StoreDecl | undefined = undefined,
|
||||
M = never,
|
||||
N extends (keyof LocaleNamespaceMap & string) | undefined = undefined,
|
||||
C extends SlotComponent<never> = SlotComponent<never>,
|
||||
>(
|
||||
options: BaseOptions<K, D, H, M> & { inject?: undefined },
|
||||
options: BaseOptions<K, D, H, M, N> & { inject?: undefined },
|
||||
component: C
|
||||
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, object, NoInfer<M>>>
|
||||
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, object, NoInfer<M>, NoInfer<N>>>
|
||||
& RendersCheck<C, D>,
|
||||
): () => void
|
||||
/**
|
||||
@@ -455,13 +532,15 @@ export class SlotCore {
|
||||
const D extends ChildrenDecl = Record<never, never>,
|
||||
H extends StoreDecl | undefined = undefined,
|
||||
M = never,
|
||||
N extends (keyof LocaleNamespaceMap & string) | undefined = undefined,
|
||||
C extends SlotComponent<never> = SlotComponent<never>,
|
||||
>(
|
||||
options: BaseOptions<K, D, H, M> & { inject: (...args: InjectParams<K, H>) => I },
|
||||
options: BaseOptions<K, D, H, M, N> & { inject: (...args: InjectParams<K, H>) => I },
|
||||
component: C
|
||||
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, I, NoInfer<M>>>
|
||||
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, I, NoInfer<M>, NoInfer<N>>>
|
||||
& RendersCheck<C, D>,
|
||||
): () => void
|
||||
/* jscpd:ignore-end */
|
||||
register(options: ErasedOptions, component: unknown): () => void {
|
||||
const rec = this.records.get(options.name)
|
||||
if (!rec?.spec) {
|
||||
@@ -523,6 +602,7 @@ export class SlotCore {
|
||||
...(options.inject !== undefined ? { inject: options.inject } : {}),
|
||||
...(options.children !== undefined ? { children: options.children } : {}),
|
||||
...(options.store !== undefined ? { store: options.store } : {}),
|
||||
...(options.locale !== undefined ? { locale: options.locale } : {}),
|
||||
...(options.registrant !== undefined ? { registrant: options.registrant } : {}),
|
||||
}
|
||||
const next = [...rec.entries, entry]
|
||||
|
||||
@@ -1,6 +1,31 @@
|
||||
/** React-free contracts between the slot host and an installed renderer. */
|
||||
import type { ReactNode } from 'react'
|
||||
import type { SlotEntryDef, SlotSpec, StoredEntry } from './index.ts'
|
||||
import type { SlotEntryDef, SlotSpec, StoredEntry, Translate } from './index.ts'
|
||||
|
||||
/**
|
||||
* The locale face the render machinery consumes: namespace binding plus an
|
||||
* observable revision (getSnapshot/subscribe pair — the same HostObservable
|
||||
* currency as every other standard-kit source). The revision moves on every
|
||||
* active-locale or registry change; the renderer re-derives each entry's `t`
|
||||
* from (namespace, revision), so a locale switch hands out NEW function
|
||||
* references and memoized components re-render naturally. Implemented by the
|
||||
* locale plugin, installed through the runtime SlotsService (installLocale).
|
||||
* Install before the first render that needs the seat: outlets bind their
|
||||
* revision subscription at mount, and a face appearing later has no channel
|
||||
* to notify already-mounted outlets (the locale plugin is immediately-tier
|
||||
* infrastructure, so normal compositions install during boot).
|
||||
*/
|
||||
export interface LocaleFace extends HostObservable<{ revision: number }> {
|
||||
/**
|
||||
* Bind a namespace to a translate function reading the active locale at
|
||||
* call time. Identity may be stable per namespace — freshness of rendered
|
||||
* text is carried by the renderer's (ns, revision) seat derivation, not by
|
||||
* this binding.
|
||||
* @param ns - dictionary namespace.
|
||||
* @returns the namespace-bound translate function.
|
||||
*/
|
||||
bind(ns: string): Translate
|
||||
}
|
||||
|
||||
/** Minimal observable surface for host-provided standard-kit data sources. */
|
||||
export interface HostObservable<T> {
|
||||
@@ -128,6 +153,12 @@ export interface SlotRendererHost {
|
||||
/** Workspace list source backing the useWorkspaces standard hook. */
|
||||
list: HostObservable<unknown>
|
||||
}
|
||||
/**
|
||||
* Installed locale face backing the `t` standard seat (absent until the
|
||||
* locale plugin installs one; rendering an entry that declared `locale:`
|
||||
* without it is an assembly failure).
|
||||
*/
|
||||
locale?: LocaleFace | undefined
|
||||
}
|
||||
|
||||
/** The install seam: runtime owns install()/renderSlot(); web-react implements rendering. */
|
||||
|
||||
@@ -9,26 +9,26 @@ import clsx from 'clsx'
|
||||
import {
|
||||
IconDarkOutline16, IconFollowsystemOutline16, IconLightOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ThemePreference } from './index.ts'
|
||||
import type { ThemeKey } from './locales.ts'
|
||||
import type {} from './settings-contract.ts'
|
||||
import type { createAppearanceRowStore } from './settings-store.ts'
|
||||
import css from './AppearanceRow.module.css'
|
||||
|
||||
/** Injected business face: namespace-bound translate + the preference write. */
|
||||
/** Injected business face: the preference write (t rides the standard locale seat). */
|
||||
export interface AppearanceRowInjected {
|
||||
/** Translate a `settings.theme` dictionary key to the active-locale text. */
|
||||
t: (key: string) => string
|
||||
/** Switch the theme preference. */
|
||||
setTheme: (id: ThemePreference) => void
|
||||
}
|
||||
|
||||
/** Full component props: runtime share + store share + injected face. */
|
||||
/** Full component props: runtime share + store share + locale seat + injected face. */
|
||||
export type AppearanceRowComponentProps =
|
||||
PropsRuntime<'settings.general.item'> & PropsStore<ReturnType<typeof createAppearanceRowStore>> & AppearanceRowInjected
|
||||
PropsRuntime<'settings.general.item'> & PropsStore<ReturnType<typeof createAppearanceRowStore>>
|
||||
& PropsLocale<'settings.theme'> & AppearanceRowInjected
|
||||
|
||||
/** Cube order and icons (figma 501:30015-30017: Light, Dark, System). */
|
||||
const CUBES: readonly { id: ThemePreference; labelKey: string; Icon: typeof IconLightOutline16 }[] = [
|
||||
const CUBES: readonly { id: ThemePreference; labelKey: ThemeKey; Icon: typeof IconLightOutline16 }[] = [
|
||||
{ id: 'light', labelKey: 'appearance.light', Icon: IconLightOutline16 },
|
||||
{ id: 'dark', labelKey: 'appearance.dark', Icon: IconDarkOutline16 },
|
||||
{ id: 'system', labelKey: 'appearance.system', Icon: IconFollowsystemOutline16 },
|
||||
|
||||
@@ -14,13 +14,22 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { AppearanceRowInjected } from './AppearanceRow.tsx'
|
||||
import { AppearanceRow } from './AppearanceRow.tsx'
|
||||
import { createAppearanceRowStore } from './settings-store.ts'
|
||||
import { en, zh, type ThemeKey } from './locales.ts'
|
||||
|
||||
export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx'
|
||||
export type { AppearanceRowState } from './settings-store.ts'
|
||||
export type { ThemeKey } from './locales.ts'
|
||||
|
||||
/** Namespace owning this feature's settings-row copy. */
|
||||
export const SETTINGS_NS = 'settings.theme'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** The Appearance settings row's copy. */
|
||||
'settings.theme': ThemeKey
|
||||
}
|
||||
}
|
||||
|
||||
/** Theme token dictionary: --dsw-alias-* overrides keyed by variable name. */
|
||||
export type ThemeTokens = Record<string, string>
|
||||
|
||||
@@ -228,23 +237,7 @@ export function apply(ctx: ClientContext): void {
|
||||
const theme = new ThemeService(ctx)
|
||||
ctx.provide('theme', theme)
|
||||
|
||||
ctx.effect(() => {
|
||||
const disposers = [
|
||||
ctx.locale.register(SETTINGS_NS, 'zh', {
|
||||
'appearance.title': '外观',
|
||||
'appearance.light': '浅色',
|
||||
'appearance.dark': '深色',
|
||||
'appearance.system': '跟随系统',
|
||||
}),
|
||||
ctx.locale.register(SETTINGS_NS, 'en', {
|
||||
'appearance.title': 'Appearance',
|
||||
'appearance.light': 'Light',
|
||||
'appearance.dark': 'Dark',
|
||||
'appearance.system': 'System',
|
||||
}),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-theme: settings row dictionaries')
|
||||
ctx.effect(() => ctx.locale.register(SETTINGS_NS, { zh, en }), 'ui-theme: settings row dictionaries')
|
||||
|
||||
const store = createAppearanceRowStore()
|
||||
let bound: BoundActions<typeof store> | undefined
|
||||
@@ -258,7 +251,6 @@ export function apply(ctx: ClientContext): void {
|
||||
// first render (the store's revision guard drops stale duplicates).
|
||||
sync(theme.getTheme())
|
||||
return {
|
||||
t: ctx.locale.bind(SETTINGS_NS),
|
||||
setTheme: (id) => { theme.setTheme(id) },
|
||||
}
|
||||
}
|
||||
@@ -269,6 +261,7 @@ export function apply(ctx: ClientContext): void {
|
||||
id: 'appearance',
|
||||
order: 10,
|
||||
store,
|
||||
locale: SETTINGS_NS,
|
||||
inject: injected,
|
||||
}, AppearanceRow))
|
||||
return () => { deferred.dispose() }
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/** `settings.theme` namespace dictionaries (the Appearance row's copy). */
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'appearance.title': '外观',
|
||||
'appearance.light': '浅色',
|
||||
'appearance.dark': '深色',
|
||||
'appearance.system': '跟随系统',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The settings.theme namespace key union. */
|
||||
export type ThemeKey = keyof typeof zh
|
||||
|
||||
/** English dictionary, checked complete against the zh key set. */
|
||||
export const en = {
|
||||
'appearance.title': 'Appearance',
|
||||
'appearance.light': 'Light',
|
||||
'appearance.dark': 'Dark',
|
||||
'appearance.system': 'System',
|
||||
} satisfies Record<ThemeKey, string>
|
||||
@@ -73,7 +73,8 @@ describe('ui-theme apply', () => {
|
||||
const { instance, face } = faceOf(b.slots)
|
||||
// The inject-time re-sync sealed the init window: the mirror is current.
|
||||
expect(instance.getSnapshot().preference).toBe('dark')
|
||||
expect(face.t('appearance.dark')).toBe('深色')
|
||||
// Copy rides the standard locale seat: the entry declares the namespace.
|
||||
expect(b.slots.entries(SLOT).find(e => e.component === AppearanceRow)!.locale).toBe(SETTINGS_NS)
|
||||
|
||||
face.setTheme('system')
|
||||
expect(theme.getTheme().preference).toBe('system')
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
|
||||
import {
|
||||
SlotOwnershipError, StaleAuthorizationError,
|
||||
type ChainRenderOpts, type HostObservable, type RenderOpts, type SessionMaybeProvideInfo,
|
||||
type SessionProvideInfo, type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry,
|
||||
type ChainRenderOpts, type HostObservable, type LocaleFace, type RenderOpts,
|
||||
type SessionMaybeProvideInfo, type SessionProvideInfo, type SlotRenderer, type SlotRendererHost,
|
||||
type SlotScope, type StoredEntry, type Translate,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook,
|
||||
@@ -159,6 +160,74 @@ function cachedSessionMaybeInject(
|
||||
return props
|
||||
}
|
||||
|
||||
/**
|
||||
* Locale `t` seat bindings, cached per (face, namespace, revision). The
|
||||
* revision is part of the cache key ON PURPOSE: a locale switch mints a NEW
|
||||
* function reference per namespace, so `React.memo` components taking `t`
|
||||
* re-render through ordinary shallow comparison — freshness rides identity,
|
||||
* no extra invalidation channel. Within one revision the reference is stable
|
||||
* (memoized children do not churn on unrelated re-renders).
|
||||
*/
|
||||
const localeSeatCache = new WeakMap<LocaleFace, Map<string, { revision: number; t: Translate }>>()
|
||||
|
||||
function localeSeat(face: LocaleFace, ns: string): Translate {
|
||||
let perNs = localeSeatCache.get(face)
|
||||
if (!perNs) {
|
||||
perNs = new Map()
|
||||
localeSeatCache.set(face, perNs)
|
||||
}
|
||||
const revision = face.getSnapshot().revision
|
||||
const cached = perNs.get(ns)
|
||||
if (cached && cached.revision === revision) return cached.t
|
||||
const bound = face.bind(ns)
|
||||
// Fresh wrapper per revision: bind() itself may return a stable reference.
|
||||
const t: Translate = (key, params) => bound(key, params)
|
||||
perNs.set(ns, { revision, t })
|
||||
return t
|
||||
}
|
||||
|
||||
const noopSubscribe = (): (() => void) => () => {}
|
||||
const zeroRevision = (): number => 0
|
||||
|
||||
/**
|
||||
* Per-face subscribe/getSnapshot closure pair. Cached by face identity: the
|
||||
* face is one global source shared by every outlet, and uSES resubscribes
|
||||
* whenever the subscribe reference changes — fresh closures per render would
|
||||
* churn one unsubscribe/resubscribe pair per outlet per render.
|
||||
*/
|
||||
const localeSubscriptionCache = new WeakMap<LocaleFace, {
|
||||
subscribe: (fn: () => void) => () => void
|
||||
getRevision: () => number
|
||||
}>()
|
||||
|
||||
function localeSubscription(face: LocaleFace): { subscribe: (fn: () => void) => () => void; getRevision: () => number } {
|
||||
let cached = localeSubscriptionCache.get(face)
|
||||
if (!cached) {
|
||||
cached = {
|
||||
subscribe: fn => face.subscribe(fn),
|
||||
getRevision: () => face.getSnapshot().revision,
|
||||
}
|
||||
localeSubscriptionCache.set(face, cached)
|
||||
}
|
||||
return cached
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe an outlet to the installed locale face's revision (0 while none
|
||||
* is installed — exactly one uSES call either way, keeping hook order
|
||||
* stable). Every outlet re-renders on a locale switch; entry bodies then
|
||||
* re-derive their `t` seat at the new revision. The face must be installed
|
||||
* before the first render that needs it — a face appearing later has no
|
||||
* notification channel to already-mounted outlets.
|
||||
*/
|
||||
function useLocaleRevision(face: LocaleFace | undefined): number {
|
||||
const subscription = face !== undefined ? localeSubscription(face) : undefined
|
||||
return useSyncExternalStore(
|
||||
subscription?.subscribe ?? noopSubscribe,
|
||||
subscription?.getRevision ?? zeroRevision,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry-identity React keys for chain boundaries. A chain outlet renders ONE
|
||||
* elected entry through an error boundary; without a key, a boundary that
|
||||
@@ -242,6 +311,16 @@ function standardKit(
|
||||
// reader, bound per provide bundle (cached by info identity).
|
||||
kit['useProjection'] = projectionHook(info)
|
||||
}
|
||||
if (entry.locale !== undefined) {
|
||||
const face = host.locale
|
||||
// Loud assembly failure: locale is immediately-tier infrastructure; a
|
||||
// declared namespace with no installed face is a miswired composition.
|
||||
if (face === undefined) {
|
||||
throw new SlotAssemblyError(
|
||||
`entry declares locale namespace '${entry.locale}' but no locale face is installed (locale plugin missing from the composition?)`)
|
||||
}
|
||||
kit['t'] = localeSeat(face, entry.locale)
|
||||
}
|
||||
const store = scope === 'session-maybe' && info?.sessionId === undefined
|
||||
? undefined
|
||||
: host.storeOf(entry, info?.sessionId)
|
||||
@@ -329,6 +408,9 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
|
||||
fn => host.subscribe(slotKey, fn),
|
||||
() => host.getVersion(slotKey),
|
||||
)
|
||||
// Locale revision tick: a locale switch re-renders every outlet, and entry
|
||||
// bodies re-derive their `t` seat at the new revision (fresh identity).
|
||||
useLocaleRevision(host.locale)
|
||||
const sessionInfo = useSessionMaybeProvideInfo()
|
||||
const spec = host.specOf(slotKey)
|
||||
// Undeclared (or no-longer-declared) keys render empty: a declaring entry's
|
||||
@@ -435,6 +517,7 @@ function RootOutlet({ ownerProps }: { ownerProps: object }) {
|
||||
fn => host.subscribe('root', fn),
|
||||
() => host.getVersion('root'),
|
||||
)
|
||||
useLocaleRevision(host.locale)
|
||||
const entry = host.entriesOf('root')[0]
|
||||
if (!entry) throw new SlotAssemblyError("renderSlot('root') before any 'root' registration (boot order)")
|
||||
return (
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/hooks/README.md
|
||||
README.md: 23478fb5e9b813a3370ce465104b1f9db8b0a26a
|
||||
README.zh.md: 41024a8bd268550aa07401fd8b21c74db0914796
|
||||
README.zh.md: 741300a9a390a8f254c01733e5326be84541a78d
|
||||
@@ -10,4 +10,4 @@ hooks 子系统让用户可以像使用 Claude Code 和 Codex 一样,在 agent
|
||||
| `hooks-claude/` | Claude Code `hooks.json`/settings 的桥接 | 插件 |
|
||||
| `hooks-codex/` | Codex `hooks.json` 的桥接 | 插件 |
|
||||
|
||||
Codex 有意重新实现 Claude Code 协议的一个*子集*(`hooks.json` 结构相同、5 个事件而非 CC 的众多事件、仅命令、仅正则表达式 matcher、没有 env/替换),因此 `hook-protocol` 负责真正相同的原语,每个桥接只负责不同部分(逐事件 stdin 载荷、env,以及把 hook 的中性结果映射到 harness 类型化 Decision 的方式)。参见 [hook-protocol/README.md](hook-protocol/README.md)。
|
||||
Codex 有意重新实现 Claude Code 协议的一个*子集*(`hooks.json` 结构相同、5 个事件而非 CC 的众多事件、仅命令、仅使用正则的 matcher、没有 env/替换),因此 `hook-protocol` 负责真正相同的原语,每个桥接只负责不同部分(逐事件 stdin 载荷、env,以及把 hook 的中性结果映射到 harness 类型化 Decision 的方式)。参见 [hook-protocol/README.md](hook-protocol/README.md)。
|
||||
@@ -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/hooks/hook-protocol/README.md
|
||||
README.md: 10cfcdcbf819f318f2ccaf412ae04bba60812397
|
||||
README.zh.md: 9862d4f332e0e82fb6479fcadf9376403fb9bef8
|
||||
README.md: 8cf4b95c95d43b8fbc27bbdcaf727dabf7d96805
|
||||
README.zh.md: 15a537b67677a401ab434a3e73af1973030780c0
|
||||
@@ -10,7 +10,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud
|
||||
|
||||
| Concern | Here (`dsh-hook-protocol`) | The bridge (`dsh-hooks-claude` / `-codex`) |
|
||||
|---|---|---|
|
||||
| Matcher test | `matchesMatcher(pattern, query, mode)` — literal-or-regex by `mode` | picks its `mode` (`claude` = literal-or-regex, `codex` = always regex) |
|
||||
| Matcher validation + test | `matcherDiagnostic(pattern, mode)` for parse-time diagnostics; `matchesMatcher(pattern, query, mode)` for contained runtime matching | picks its `mode` (`claude` = literal-or-regex, `codex` = always regex) and rejects a config group carrying a diagnostic |
|
||||
| Run a hook | `runHook(bash, hook, opts, now)` — stdin payload + env via `ctx.bash`, decode | builds the per-event stdin **payload** + the dialect's **env** |
|
||||
| Decode output | `parseHookOutput(exit, stdout, stderr)` → neutral `HookOutput` | maps the neutral `HookOutput` onto a seam-specific typed Decision |
|
||||
| Merge N hooks | `mergeHookOutputs(outputs)` → most-restrictive `MergedHookOutcome` | — |
|
||||
@@ -19,7 +19,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud
|
||||
|
||||
## Primitives
|
||||
|
||||
- **`matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. An invalid regex matches nothing (never throws).
|
||||
- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. Bridge parsers discard matcher fields for events without matcher subjects, then use `matcherDiagnostic` to reject an invalid consumed regex with a stable diagnostic before registering any hooks. The runtime predicate still contains an invalid pattern as a non-match, so a direct library caller cannot throw into the agent loop.
|
||||
- **`runHook(bash, hook, options, now)`** — require and forward the caller-owned `options.signal`, serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `options.defaultTimeoutMs` — the bridge owns the default, its config defaulting to the lib's `DEFAULT_HOOK_TIMEOUT_MS` 10-minute reference), and decode the result (threading `options.expectedEventName` to the codec). Cancellation therefore reaches the executor's process-group kill and join boundary. Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations.
|
||||
- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** decodes exit status and structured stdout. Exit 2 blocks with stderr; other failures are non-blocking. A matching hook-specific permission decision overrides the legacy top-level decision; mismatched or missing event discriminators suppress only event-specific fields. Top-level fields remain event-agnostic, and successful non-JSON output is left to the bridge.
|
||||
- **`mergeHookOutputs(outputs)`** — fold the results of every hook that matched one point: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined with `\n\n`, `additionalContext`/`systemMessages` accumulated in order.
|
||||
@@ -42,4 +42,3 @@ No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`HookOutput.updatedInput` is parsed but not honored** — input rewrite is a deferred consistency-design problem ([the pre-tool-input-rewrite Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)); a bridge logs + warns when a hook sets it. See `src/types.ts` for the full contracts.
|
||||
- **An invalid matcher regex matches nothing, silently** — `matchesMatcher` never throws; surfacing the error needs a diagnostic-returning variant or parse-time validation (`TODO(matcher-diagnostics)`).
|
||||
@@ -10,7 +10,7 @@ Claude Code/Codex hook 协议格式(wire format)的**共享核心**。它
|
||||
|
||||
| 关注点 | 此处(`dsh-hook-protocol`) | 桥接(`dsh-hooks-claude` / `-codex`) |
|
||||
|---|---|---|
|
||||
| Matcher 测试 | `matchesMatcher(pattern, query, mode)`:根据 `mode` 使用字面匹配或正则匹配 | 选择自身 `mode`(`claude` = 字面或正则,`codex` = 始终使用正则) |
|
||||
| Matcher 校验 + 测试 | `matcherDiagnostic(pattern, mode)` 用于解析时诊断;`matchesMatcher(pattern, query, mode)` 用于隔离的运行时匹配 | 选择自身的 `mode`(`claude` = 字面量或正则,`codex` = 始终使用正则),并拒绝带有诊断的配置组 |
|
||||
| 运行 hook | `runHook(bash, hook, opts, now)`:通过 `ctx.bash` 提供 stdin payload + env,再解码 | 构造每个事件的 stdin **payload** + 该方言的 **env** |
|
||||
| 解码输出 | `parseHookOutput(exit, stdout, stderr)` → 中性 `HookOutput` | 将中性 `HookOutput` 映射到 seam 特定的类型化 Decision |
|
||||
| 合并 N 个 hook | `mergeHookOutputs(outputs)` → 最严格的 `MergedHookOutcome` | (无) |
|
||||
@@ -19,7 +19,7 @@ Claude Code/Codex hook 协议格式(wire format)的**共享核心**。它
|
||||
|
||||
## 原语
|
||||
|
||||
- **`matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;`claude` 模式将纯 `[A-Za-z0-9_|]+` pattern 视为字面值(pipe = 精确匹配交替),其他 pattern 视为正则;`codex` 模式始终使用未锚定正则。无效正则不匹配任何内容(绝不抛出异常)。
|
||||
- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;`claude` mode 将纯 `[A-Za-z0-9_|]+` pattern 视为字面量(管道符 = 精确匹配多选),其他 pattern 视为正则;`codex` mode 始终使用未锚定正则。桥接解析器会丢弃没有 matcher 匹配对象的事件所带字段,再用 `matcherDiagnostic` 拒绝事件实际使用的无效正则,并在注册任何钩子之前给出稳定诊断。运行时谓词仍会将无效 pattern 隔离为不匹配,因此直接调用本库不会向 agent loop(智能体循环)抛异常。
|
||||
- **`runHook(bash, hook, options, now)`**:要求并转发调用方拥有的 `options.signal`,将 `options.payload` 序列化到 hook stdin(当且仅当 `options.trailingNewline` 时添加尾随换行符),在执行器凭证清理后合并 `options.env`(`dsh-bash` 受信任插件接口),遵循 hook 的 `timeoutSec`(否则使用 `options.defaultTimeoutMs`;默认值属于桥接,其配置默认为 lib 的 `DEFAULT_HOOK_TIMEOUT_MS` 10 分钟参考值),再解码结果(将 `options.expectedEventName` 传递给 codec)。因此取消会到达执行器的进程组终止与 join 边界。它绝不抛出异常:执行器拒绝(基础设施故障)会变为 `HookOutput`,其 `exitCode: undefined`(非阻塞错误)。`now` 会被注入,以便测试持续时间。
|
||||
- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** 解码退出状态与结构化 stdout。退出码为 2 时,会以 stderr 内容阻止执行;其他失败不阻塞。匹配的 hook 特定权限决策会覆盖遗留顶层决策;事件判别字段不匹配或缺失只会抑制事件特定字段。顶层字段仍与事件无关,成功但非 JSON 的输出会留给桥接处理。
|
||||
- **`mergeHookOutputs(outputs)`**:折叠在一个点上匹配的每个 hook 结果:权限优先级为 **deny > ask > allow**,从首个 `continue:false` 起,halt 状态保持不变,阻塞原因用 `\n\n` 连接,`additionalContext`/`systemMessages` 按顺序累积。
|
||||
@@ -29,7 +29,7 @@ Claude Code/Codex hook 协议格式(wire format)的**共享核心**。它
|
||||
|
||||
通过 declaration merging 合并到 `SessionEventMap`(仅日志,与 `compact/*` 相同;不是 `SurfaceEventType`,没有 `surfaceOp`):`hook/invoked`(hook 命令已运行)与 `hook/result`(其结果,按 `handlerId` 配对,决策规则由 `appendHookResult` 负责)。Payload 与每事件 JSDoc 位于生成的 [持久化日志事件目录](../../../docs/persistence-catalog.md);`stderrSummary` 会截断到记录的 `stderrSummaryMaxChars`(桥接配置,参考默认值 `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500;为空时省略)。
|
||||
|
||||
Hook 溯源记录必须位于一个尚未结束的轮次内。轮次中的点(`PreToolUse`/`PostToolUse`/`Stop`)按构造满足这条由所有者定义的关系。`SessionStart` 与轮次前的 `UserPromptSubmit` 准入 seam 没有 `hook/*` 记录;获准的上下文改由其带来源的 `user/message` 作为证据,详见 hooks Agent Note(agent 决策记录)。
|
||||
Hook 溯源记录必须位于一个尚未结束的轮次内。轮次中的点(`PreToolUse`/`PostToolUse`/`Stop`)按构造满足这条由所有者定义的关系。`SessionStart` 与轮次前的 `UserPromptSubmit` 准入 seam 没有 `hook/*` 记录;获准的上下文改由其带来源的 `user/message` 作为证据,详见 hooks Agent Note。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -42,4 +42,3 @@ Hook 溯源记录必须位于一个尚未结束的轮次内。轮次中的点(
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **`HookOutput.updatedInput` 会被解析但不会应用**:输入改写是已暂缓的一致性设计问题(见 [pre-tool-input-rewrite Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md));当 hook 设置它时,桥接会记录 + 警告。完整契约见 `src/types.ts`。
|
||||
- **无效 matcher 正则会静默地不匹配任何内容**:`matchesMatcher` 绝不抛出异常;显示该错误需要返回诊断的变体或解析时验证(`TODO(matcher-diagnostics)`)。
|
||||
@@ -13,7 +13,7 @@ export type {
|
||||
MatcherGroup,
|
||||
MatcherMode,
|
||||
} from './types.ts'
|
||||
export { matchesMatcher } from './matcher.ts'
|
||||
export { matcherDiagnostic, matchesMatcher } from './matcher.ts'
|
||||
export { parseHookOutput } from './codec.ts'
|
||||
export { DEFAULT_HOOK_TIMEOUT_MS, runHook } from './runner.ts'
|
||||
export type { RunHookOptions, RunHookResult } from './runner.ts'
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* Matcher shared by both hook dialects. Claude treats alphanumeric/underscore/
|
||||
* pipe patterns as literal alternatives and other patterns as regex; Codex
|
||||
* treats every non-empty pattern as an unanchored regex. Missing, empty, and
|
||||
* `*` match all; invalid regexes silently match nothing.
|
||||
* `*` match all. Runtime matching contains invalid regexes as non-matches;
|
||||
* config parsers use {@link matcherDiagnostic} to reject them with a diagnostic.
|
||||
* @module @deepseek-ai/dsh-hook-protocol/matcher
|
||||
*/
|
||||
|
||||
@@ -16,10 +17,37 @@ function isMatchAll(matcher: string | undefined): boolean {
|
||||
/** A Claude-literal pattern is purely word chars + `|` (the regex-vs-literal discriminator). */
|
||||
const CLAUDE_LITERAL = /^[A-Za-z0-9_|]+$/
|
||||
|
||||
/** Compile an unanchored matcher regex; invalid patterns return `undefined`. */
|
||||
function compileRegex(pattern: string): RegExp | undefined {
|
||||
try {
|
||||
return new RegExp(pattern)
|
||||
} catch (_syntaxError) {
|
||||
// RegExp construction is the try's only operation, so malformed pattern
|
||||
// syntax is the only expected failure.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one matcher before a bridge accepts its config group.
|
||||
* @param matcher - configured pattern; match-all sentinels are valid.
|
||||
* @param mode - dialect deciding whether a word-and-pipe pattern is literal.
|
||||
* @returns `undefined` for a valid matcher, otherwise a stable diagnostic.
|
||||
*/
|
||||
export function matcherDiagnostic(matcher: string | undefined, mode: MatcherMode): string | undefined {
|
||||
if (isMatchAll(matcher)) return undefined
|
||||
const pattern = matcher as string
|
||||
if (mode === 'claude' && CLAUDE_LITERAL.test(pattern)) return undefined
|
||||
return compileRegex(pattern) === undefined
|
||||
? `invalid ${mode} regex matcher ${JSON.stringify(pattern)}`
|
||||
: undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `matcher` selects `query` under the given dialect. Claude literal
|
||||
* patterns exact-match pipe-separated alternatives; all other patterns are
|
||||
* unanchored regexes. Invalid regexes return `false` rather than throwing.
|
||||
* unanchored regexes. Invalid regexes return `false` rather than throwing;
|
||||
* bridge config parsers surface them through {@link matcherDiagnostic} before use.
|
||||
* @param matcher - the configured pattern; absent/empty/`'*'` are the match-all sentinels.
|
||||
* @param query - the candidate value (a tool name, a session source, …).
|
||||
* @param mode - the dialect deciding literal-vs-regex interpretation of the pattern.
|
||||
@@ -33,13 +61,5 @@ export function matchesMatcher(matcher: string | undefined, query: string, mode:
|
||||
if (mode === 'claude' && CLAUDE_LITERAL.test(pattern)) {
|
||||
return pattern.split('|').includes(query)
|
||||
}
|
||||
try {
|
||||
return new RegExp(pattern).test(query)
|
||||
} catch {
|
||||
// Invalid regex: a broken matcher selects nothing rather than throwing into
|
||||
// the agent loop. This is silent — callers get `false`, indistinguishable
|
||||
// from a genuine non-match, so a typo'd pattern quietly disables the matcher.
|
||||
// Surfacing it needs a diagnostic-returning variant (TODO(matcher-diagnostics)).
|
||||
return false
|
||||
}
|
||||
return compileRegex(pattern)?.test(query) ?? false
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { matchesMatcher } from '@deepseek-ai/dsh-hook-protocol'
|
||||
import { matcherDiagnostic, matchesMatcher } from '@deepseek-ai/dsh-hook-protocol'
|
||||
|
||||
describe('matchesMatcher — match-all sentinels (both dialects)', () => {
|
||||
for (const mode of ['claude', 'codex'] as const) {
|
||||
@@ -56,3 +56,19 @@ describe('matchesMatcher — invalid regex is a non-match (never throws)', () =>
|
||||
expect(matchesMatcher('[', 'x', 'codex')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('matcherDiagnostic — parse-time diagnostics', () => {
|
||||
it('accepts match-all sentinels, Claude literals, and valid regexes', () => {
|
||||
expect(matcherDiagnostic(undefined, 'claude')).toBeUndefined()
|
||||
expect(matcherDiagnostic('', 'codex')).toBeUndefined()
|
||||
expect(matcherDiagnostic('*', 'codex')).toBeUndefined()
|
||||
expect(matcherDiagnostic('Edit|Write', 'claude')).toBeUndefined()
|
||||
expect(matcherDiagnostic('^Bash$', 'claude')).toBeUndefined()
|
||||
expect(matcherDiagnostic('Edit|Write', 'codex')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns a stable diagnostic for invalid regexes in either dialect', () => {
|
||||
expect(matcherDiagnostic('(', 'claude')).toBe('invalid claude regex matcher "("')
|
||||
expect(matcherDiagnostic('[', 'codex')).toBe('invalid codex regex matcher "["')
|
||||
})
|
||||
})
|
||||
@@ -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/hooks/hooks-claude/README.md
|
||||
README.md: 24259c24ea35cd450f8ea27ca2cca423ed4406bd
|
||||
README.zh.md: 0a7afc20eba02d59d293124936cb81aeba6d3f0f
|
||||
README.md: 61c2d152dacdbec31bca015b94b9f2ac6d24c3aa
|
||||
README.zh.md: 38509ab6e6f72bb62a6bed064257603f728812cb
|
||||
@@ -28,7 +28,7 @@ In a `cordis.yml`:
|
||||
projectDir: .
|
||||
```
|
||||
|
||||
The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only shell-form `type: 'command'` hooks run; an `http`/`mcp_tool`/`prompt`/`agent` hook is parsed-and-skipped with a warning. A hook with no per-hook `timeout` runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes — the CC default).
|
||||
The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — including an invalid regex matcher on an event that consumes matchers, reported with its pattern and event — and the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only shell-form `type: 'command'` hooks run; an `http`/`mcp_tool`/`prompt`/`agent` hook is parsed-and-skipped with a warning. A hook with no per-hook `timeout` runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes — the CC default).
|
||||
|
||||
The hooks **themselves** run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` (the `session/new.cwd`) as the hook process's working directory, so a hook's `pwd`/relative-path/marker operates in the user's project tree, not the server launch dir.
|
||||
|
||||
@@ -86,7 +86,7 @@ A blocked prompt sends no request and invalidates nothing. Denial, feedback, and
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Unsupported hook events (23 of Claude Code's current 30):** `Setup`, `InstructionsLoaded`, `UserPromptExpansion`, `MessageDisplay`, `PermissionRequest`, `PostToolUseFailure`, `PostToolBatch`, `PermissionDenied`, `Notification`, `TaskCreated`, `TaskCompleted`, `StopFailure`, `TeammateIdle`, `ConfigChange`, `CwdChanged`, `FileChanged`, `WorktreeCreate`, `WorktreeRemove`, `PreCompact`, `PostCompact`, `SessionEnd`, `Elicitation`, and `ElicitationResult`. Config for these events is parsed but never dispatched. The comparison baseline is Claude Code's [official hook-event reference](https://code.claude.com/docs/en/hooks#hook-events).
|
||||
- **Unsupported hook events (23 of Claude Code's current 30):** `Setup`, `InstructionsLoaded`, `UserPromptExpansion`, `MessageDisplay`, `PermissionRequest`, `PostToolUseFailure`, `PostToolBatch`, `PermissionDenied`, `Notification`, `TaskCreated`, `TaskCompleted`, `StopFailure`, `TeammateIdle`, `ConfigChange`, `CwdChanged`, `FileChanged`, `WorktreeCreate`, `WorktreeRemove`, `PreCompact`, `PostCompact`, `SessionEnd`, `Elicitation`, and `ElicitationResult`. Config for these events is ignored before group parsing, so an unsupported event cannot invalidate or register hooks. The comparison baseline is Claude Code's [official hook-event reference](https://code.claude.com/docs/en/hooks#hook-events).
|
||||
- **`SessionStart` is partial:** JSON `additionalContext` is consumed, but plain stdout context, `initialUserMessage`, `sessionTitle`, `watchPaths`, `reloadSkills`, and `CLAUDE_ENV_FILE` are unsupported. The hook runs detached, so context can miss the first request (`TODO(session-start-gating)`), and the payload omits current optional fields such as `model`, `agent_type`, and `session_title`.
|
||||
- **`UserPromptSubmit` is partial:** blocking and JSON `additionalContext` work, but plain stdout context, `sessionTitle`, and `suppressOriginalPrompt` are unsupported. Unless overridden, the bridge also uses its 600-second default instead of Claude Code's event-specific 30-second command timeout.
|
||||
- **`PreToolUse` is partial:** `deny` and `ask` decisions work; `allow` does not pre-approve, `defer` is unsupported, `additionalContext` is ignored, and `updatedInput` is logged + warned but not honored ([the pre-tool-input-rewrite Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)).
|
||||
|
||||
@@ -28,7 +28,7 @@ const config: Config = {
|
||||
projectDir: .
|
||||
```
|
||||
|
||||
配置只在加载时解析**一次**。`configPath` 是**进程级**配置:相对路径在加载时根据进程启动 cwd 解析,因此一份配置应用于整个进程。尚未进行每会话(`session/new.cwd`)配置发现(`TODO(per-session-hook-config)`)。读取/解析失败会被隔离处理:桥接记录警告且不注册任何内容,而不是使启动崩溃(路径拼写错误不应使 agent(智能体)停止)。只运行 shell 形式 `type: 'command'` hook;`http`/`mcp_tool`/`prompt`/`agent` hook 会被解析并跳过,同时记录警告。没有每 hook `timeout` 的 hook 会使用协议参考默认值 `DEFAULT_HOOK_TIMEOUT_MS`(来自 `dsh-hook-protocol`,10 分钟,即 CC 默认值)。
|
||||
配置只在加载时解析**一次**。`configPath` 是**进程级**配置:相对路径在加载时根据进程启动 cwd 解析,因此一份配置应用于整个进程。尚未进行每会话(`session/new.cwd`)配置发现(`TODO(per-session-hook-config)`)。读取/解析失败会被隔离处理,其中包括实际消费 matcher 的事件所带的无效 matcher 正则(会报告其 pattern 与事件):桥接记录警告且不注册任何内容,而不是使启动崩溃(路径拼写错误不应使 agent(智能体)停止)。只运行 shell 形式 `type: 'command'` hook;`http`/`mcp_tool`/`prompt`/`agent` hook 会被解析并跳过,同时记录警告。没有每 hook `timeout` 的 hook 会使用协议参考默认值 `DEFAULT_HOOK_TIMEOUT_MS`(来自 `dsh-hook-protocol`,10 分钟,即 CC 默认值)。
|
||||
|
||||
hook **本身**会在 agent 的会话工作区中运行:对 agent scope 点,桥接会将会话 `cwd`(`session/new.cwd`)作为 hook 进程工作目录,因此 hook 的 `pwd`/相对路径/marker 作用于用户项目树,而非服务器启动目录。
|
||||
|
||||
@@ -86,7 +86,7 @@ hook 不返回上下文时没有成本。Hook 文本取决于数据,会被记
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **不支持的 hook 事件(Claude Code 当前 30 项中的 23 项):** `Setup`、`InstructionsLoaded`、`UserPromptExpansion`、`MessageDisplay`、`PermissionRequest`、`PostToolUseFailure`、`PostToolBatch`、`PermissionDenied`、`Notification`、`TaskCreated`、`TaskCompleted`、`StopFailure`、`TeammateIdle`、`ConfigChange`、`CwdChanged`、`FileChanged`、`WorktreeCreate`、`WorktreeRemove`、`PreCompact`、`PostCompact`、`SessionEnd`、`Elicitation` 和 `ElicitationResult`。这些事件的配置会被解析,但绝不分派。比较基线是 Claude Code [官方 hook 事件参考](https://code.claude.com/docs/en/hooks#hook-events)。
|
||||
- **不支持的 hook 事件(Claude Code 当前 30 项中的 23 项):** `Setup`、`InstructionsLoaded`、`UserPromptExpansion`、`MessageDisplay`、`PermissionRequest`、`PostToolUseFailure`、`PostToolBatch`、`PermissionDenied`、`Notification`、`TaskCreated`、`TaskCompleted`、`StopFailure`、`TeammateIdle`、`ConfigChange`、`CwdChanged`、`FileChanged`、`WorktreeCreate`、`WorktreeRemove`、`PreCompact`、`PostCompact`、`SessionEnd`、`Elicitation` 和 `ElicitationResult`。这些事件的配置会在配置组解析前被忽略,因此不支持的事件既不会使配置失效,也不会注册 hook。比较基线是 Claude Code [官方 hook 事件参考](https://code.claude.com/docs/en/hooks#hook-events)。
|
||||
- **`SessionStart` 只支持部分功能:** 会消费 JSON `additionalContext`,但不支持纯 stdout 上下文、`initialUserMessage`、`sessionTitle`、`watchPaths`、`reloadSkills` 与 `CLAUDE_ENV_FILE`。hook 脱离运行,因此上下文可能错过第一个请求(`TODO(session-start-gating)`),payload 会省略 `model`、`agent_type` 和 `session_title` 等当前可选字段。
|
||||
- **`UserPromptSubmit` 只支持部分功能:** 支持阻塞与 JSON `additionalContext`,但不支持纯 stdout 上下文、`sessionTitle` 和 `suppressOriginalPrompt`。除非被覆盖,否则桥接还会使用自身 600 秒默认值,而非 Claude Code 的事件特定 30 秒 command 超时。
|
||||
- **`PreToolUse` 只支持部分功能:** `deny` 与 `ask` 决策可用;`allow` 不会预审批,不支持 `defer`,`additionalContext` 会被忽略,`updatedInput` 会被记录 + 警告但不应用(见 [pre-tool-input-rewrite Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md))。
|
||||
|
||||
@@ -6,7 +6,17 @@
|
||||
* @module @deepseek-ai/dsh-hooks-claude/config
|
||||
*/
|
||||
|
||||
import type { MatcherGroup } from '@deepseek-ai/dsh-hook-protocol'
|
||||
import { matcherDiagnostic, type MatcherGroup } from '@deepseek-ai/dsh-hook-protocol'
|
||||
|
||||
const CLAUDE_EVENTS = [
|
||||
'SessionStart',
|
||||
'UserPromptSubmit',
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'Stop',
|
||||
'SubagentStart',
|
||||
'SubagentStop',
|
||||
] as const
|
||||
|
||||
/** A parsed CC config: event name → its matcher groups (command hooks only). */
|
||||
export type ClaudeHookConfig = Record<string, MatcherGroup[]>
|
||||
@@ -53,8 +63,11 @@ export function substituteCommand(command: string, vars: SubstitutionVars): stri
|
||||
|
||||
/**
|
||||
* Parse either a settings `hooks` value or a bare `hooks.json` event map. Malformed entries are
|
||||
* ignored rather than failing boot; non-command hooks are returned in `skipped`, and substitutions
|
||||
* are applied to every surviving command.
|
||||
* ignored rather than failing boot; unsupported events are ignored before their groups are parsed,
|
||||
* non-command hooks are returned in `skipped`, and substitutions are applied to every surviving
|
||||
* command. Matcher fields on UserPromptSubmit and Stop are discarded because those events have no
|
||||
* matcher subject. A matcher-bearing supported runnable group with an invalid regex throws a
|
||||
* `SyntaxError`, allowing the bridge to reject the complete config before listener registration.
|
||||
*
|
||||
* @param raw - the parsed JSON config: a settings object with a `hooks` key, or the bare
|
||||
* event map.
|
||||
@@ -70,7 +83,8 @@ export function parseClaudeConfig(raw: unknown, vars: SubstitutionVars = {}): Pa
|
||||
const hooksMap = root ? asObject(root.hooks) ?? root : undefined
|
||||
if (!hooksMap) return { config, skipped }
|
||||
|
||||
for (const [event, rawGroups] of Object.entries(hooksMap)) {
|
||||
for (const event of CLAUDE_EVENTS) {
|
||||
const rawGroups = hooksMap[event]
|
||||
if (!Array.isArray(rawGroups)) continue
|
||||
const groups: MatcherGroup[] = []
|
||||
for (const rawGroup of rawGroups) {
|
||||
@@ -92,8 +106,13 @@ export function parseClaudeConfig(raw: unknown, vars: SubstitutionVars = {}): Pa
|
||||
})
|
||||
}
|
||||
if (commands.length === 0) continue
|
||||
const matcher = event === 'UserPromptSubmit' || event === 'Stop'
|
||||
? undefined
|
||||
: typeof group.matcher === 'string' ? group.matcher : undefined
|
||||
const diagnostic = matcherDiagnostic(matcher, 'claude')
|
||||
if (diagnostic !== undefined) throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`)
|
||||
groups.push({
|
||||
...typeof group.matcher === 'string' ? { matcher: group.matcher } : {},
|
||||
...matcher !== undefined ? { matcher } : {},
|
||||
hooks: commands,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -45,17 +45,22 @@ function writeConfig(hooks: unknown, scripts: Record<string, string> = {}): stri
|
||||
return dir
|
||||
}
|
||||
|
||||
async function harness(configDir: string, adapter: MockAdapter): Promise<Context> {
|
||||
return (await harnessWithFiber(configDir, adapter)).ctx
|
||||
async function harness(configDir: string, adapter: MockAdapter, beforeHooks?: (ctx: Context) => void): Promise<Context> {
|
||||
return (await harnessWithFiber(configDir, adapter, beforeHooks)).ctx
|
||||
}
|
||||
|
||||
/** {@link harness}, also exposing the bridge's fiber for tests that dispose it. */
|
||||
async function harnessWithFiber(configDir: string, adapter: MockAdapter): Promise<{ ctx: Context; hooks: Fiber }> {
|
||||
async function harnessWithFiber(
|
||||
configDir: string,
|
||||
adapter: MockAdapter,
|
||||
beforeHooks?: (ctx: Context) => void,
|
||||
): Promise<{ ctx: Context; hooks: Fiber }> {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
beforeHooks?.(ctx)
|
||||
const hooks = await ctx.plugin(HooksClaude, { configPath: join(configDir, 'hooks.json') })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return { ctx, hooks }
|
||||
@@ -85,13 +90,14 @@ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10):
|
||||
|
||||
describe('hooks-claude bridge — UserPromptSubmit', () => {
|
||||
it('a UserPromptSubmit hook that exits 2 rejects admission without a turn', async () => {
|
||||
// The UserPromptSubmit hook exits 2 (blocking) with a reason on stderr.
|
||||
// UserPromptSubmit ignores its malformed matcher field, then exit 2 blocks
|
||||
// with the reason on stderr.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
|
||||
dirs.push(dir)
|
||||
const block = join(dir, 'block.sh')
|
||||
writeFileSync(block, '#!/usr/bin/env bash\necho "prompt denied by policy" >&2\nexit 2\n')
|
||||
chmodSync(block, 0o755)
|
||||
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: block }] }] } }))
|
||||
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { UserPromptSubmit: [{ matcher: '[', hooks: [{ type: 'command', command: block }] }] } }))
|
||||
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
@@ -361,6 +367,42 @@ describe('hooks-claude bridge — load resilience', () => {
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('an invalid regex matcher is reported and registers no hooks', async () => {
|
||||
const dir = writeConfig({
|
||||
UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }],
|
||||
PreToolUse: [{ matcher: '(', hooks: [{ type: 'command', command: 'exit 2' }] }],
|
||||
})
|
||||
const adapter = new MockAdapter([textResponse('fine')])
|
||||
const warn = vi.fn()
|
||||
const ctx = await harness(dir, adapter, (ctx) => { ctx.logger.warn = warn as never })
|
||||
const agent = ctx.agentLoop.create(SessionId('invalid-claude-matcher'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(events(agent).some(event => event.type === 'hook/invoked')).toBe(false)
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining(
|
||||
'invalid claude regex matcher "(" on event "PreToolUse"',
|
||||
))
|
||||
})
|
||||
|
||||
it('an invalid matcher on an unsupported event does not disable supported hooks', async () => {
|
||||
const dir = writeConfig({
|
||||
Setup: [{ matcher: '(', hooks: [{ type: 'command', command: 'exit 0' }] }],
|
||||
UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }],
|
||||
})
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const warn = vi.fn()
|
||||
const ctx = await harness(dir, adapter, (ctx) => { ctx.logger.warn = warn as never })
|
||||
const agent = ctx.agentLoop.create(SessionId('unsupported-claude-matcher'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(events(agent).some(event => event.type === 'turn/start')).toBe(false)
|
||||
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('invalid claude regex matcher'))
|
||||
})
|
||||
|
||||
it('disposing the bridge fiber removes its listeners (HMR safety)', async () => {
|
||||
// A BLOCKING UserPromptSubmit hook: if the listener leaked past dispose it
|
||||
// would veto the prompt (0 model requests) and log a hook/invoked. Build the
|
||||
|
||||
@@ -63,4 +63,33 @@ describe('parseClaudeConfig', () => {
|
||||
const { config } = parseClaudeConfig({ Stop: [{ hooks: [{ type: 'command', command: 's.sh' }] }] })
|
||||
expect('matcher' in config.Stop![0]!).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an invalid regex matcher with its event name', () => {
|
||||
expect(() => parseClaudeConfig({
|
||||
PreToolUse: [{ matcher: '(', hooks: [{ type: 'command', command: 'x.sh' }] }],
|
||||
})).toThrow('invalid claude regex matcher "(" on event "PreToolUse"')
|
||||
})
|
||||
|
||||
it('discards matcher fields on events without matcher subjects before validation', () => {
|
||||
const { config } = parseClaudeConfig({
|
||||
UserPromptSubmit: [{ matcher: '[', hooks: [{ type: 'command', command: 'prompt.sh' }] }],
|
||||
Stop: [{ matcher: '(', hooks: [{ type: 'command', command: 'stop.sh' }] }],
|
||||
})
|
||||
|
||||
expect(config).toEqual({
|
||||
UserPromptSubmit: [{ hooks: [{ command: 'prompt.sh' }] }],
|
||||
Stop: [{ hooks: [{ command: 'stop.sh' }] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores invalid matchers on unsupported events without dropping supported hooks', () => {
|
||||
const { config } = parseClaudeConfig({
|
||||
Setup: [{ matcher: '(', hooks: [{ type: 'command', command: 'ignored.sh' }] }],
|
||||
PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'kept.sh' }] }],
|
||||
})
|
||||
|
||||
expect(config).toEqual({
|
||||
PreToolUse: [{ matcher: 'Bash', hooks: [{ command: 'kept.sh' }] }],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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/hooks/hooks-codex/README.md
|
||||
README.md: fd57762c6fb91e0ea47ec57c30bf9850bc488a33
|
||||
README.zh.md: 58b387e22f0e56770e4ea779f184c6c82a38ff98
|
||||
README.md: e906810ed58c3d0204c618c32787af06c91cfb78
|
||||
README.zh.md: 4940fdb976dd963bbb2e41c0ec6ef274ee475334
|
||||
@@ -34,7 +34,7 @@ In a `cordis.yml`:
|
||||
model: deepseek-v4
|
||||
```
|
||||
|
||||
The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing). Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias; one that sets neither runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes). Events outside the five bridge-supported points are dropped at parse.
|
||||
The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing); an invalid regex matcher on an event that consumes matchers is one such failure and reports its pattern and event. Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias; one that sets neither runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes). Events outside the five bridge-supported points are dropped at parse.
|
||||
|
||||
The hooks themselves run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` as the hook process's working directory, so a hook operates in the user's project tree, not the server launch dir.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
该桥接实现 Codex 当前 hook 协议的一个明确子集:
|
||||
|
||||
- **10 个 hook 点中的 5 个:** `PreToolUse`、`PostToolUse`、`SessionStart`、`UserPromptSubmit` 和 `Stop`。
|
||||
- **只使用正则 matcher**(没有字面快速路径;matcher 始终是未锚定正则)。
|
||||
- **仅使用正则的 matcher**(没有字面量快速路径;matcher 始终是未锚定正则)。
|
||||
- **snake_case stdin payload**,携带 `turn_id`/`model` 额外字段,写入时**不带**尾随换行符。
|
||||
- **没有 Codex 插件 env 注入,也没有配置时 placeholder 替换**(命令仍会接收执行器环境,并通过其 shell 运行)。
|
||||
- **没有工具前审批或改写路径**:hook 可以阻塞,但桥接不会预审批或替换工具输入。
|
||||
@@ -34,7 +34,7 @@ const config: Config = {
|
||||
model: deepseek-v4
|
||||
```
|
||||
|
||||
配置只在加载时解析**一次**。`configPath` 是**进程级**配置:相对路径在加载时根据进程启动 cwd 解析,而非每会话解析(`TODO(per-session-hook-config)`)。读取/解析失败会被隔离处理(记录 + 不注册任何内容)。只运行同步 `type: 'command'` hook;非 command 或 `async: true` hook 会被解析并跳过,同时记录警告。hook 接受 `timeout` 或 `timeoutSec` alias;两者都未设置时,使用协议参考默认值 `DEFAULT_HOOK_TIMEOUT_MS`(来自 `dsh-hook-protocol`,10 分钟)。五个桥接支持点之外的事件会在解析时丢弃。
|
||||
配置只在加载时解析**一次**。`configPath` 是**进程级**配置:相对路径在加载时根据进程启动 cwd 解析,而非每会话解析(`TODO(per-session-hook-config)`)。读取/解析失败会被隔离处理(记录 + 不注册任何内容);实际消费 matcher 的事件所带的无效 matcher 正则属于此类失败,并报告其 pattern 与事件。只运行同步 `type: 'command'` hook;非 command 或 `async: true` hook 会被解析并跳过,同时记录警告。hook 接受 `timeout` 或 `timeoutSec` alias;两者都未设置时,使用协议参考默认值 `DEFAULT_HOOK_TIMEOUT_MS`(来自 `dsh-hook-protocol`,10 分钟)。五个桥接支持点之外的事件会在解析时丢弃。
|
||||
|
||||
hook 本身会在 agent(智能体)的会话工作区中运行:对 agent scope 点,桥接会将会话 `cwd` 作为 hook 进程工作目录,因此 hook 作用于用户项目树,而非服务器启动目录。
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* @module @deepseek-ai/dsh-hooks-codex/config
|
||||
*/
|
||||
|
||||
import type { MatcherGroup } from '@deepseek-ai/dsh-hook-protocol'
|
||||
import { matcherDiagnostic, type MatcherGroup } from '@deepseek-ai/dsh-hook-protocol'
|
||||
|
||||
/** The five Codex hook points this bridge supports. */
|
||||
export const CODEX_EVENTS = ['PreToolUse', 'PostToolUse', 'SessionStart', 'UserPromptSubmit', 'Stop'] as const
|
||||
@@ -33,7 +33,10 @@ function asObject(value: unknown): Record<string, unknown> | undefined {
|
||||
|
||||
/**
|
||||
* Parse a wrapped or bare Codex event map. Unknown events and malformed entries are ignored rather
|
||||
* than failing boot; unsupported or asynchronous hooks are returned in `skipped`.
|
||||
* than failing boot; unsupported or asynchronous hooks are returned in `skipped`. Matcher fields on
|
||||
* UserPromptSubmit and Stop are discarded because those events have no matcher subject. A
|
||||
* matcher-bearing runnable group with an invalid regex throws a `SyntaxError`, allowing the bridge
|
||||
* to reject the complete config before listener registration.
|
||||
* @param raw - the parsed JSON config: a `{ hooks: … }` wrapper or the bare event map.
|
||||
* @returns the runnable per-event groups plus the skipped hooks with their reasons.
|
||||
*/
|
||||
@@ -69,7 +72,12 @@ export function parseCodexConfig(raw: unknown): ParsedCodexConfig {
|
||||
commands.push({ command: hook.command, ...timeout !== undefined ? { timeoutSec: timeout } : {} })
|
||||
}
|
||||
if (commands.length === 0) continue
|
||||
groups.push({ ...typeof group.matcher === 'string' ? { matcher: group.matcher } : {}, hooks: commands })
|
||||
const matcher = event === 'UserPromptSubmit' || event === 'Stop'
|
||||
? undefined
|
||||
: typeof group.matcher === 'string' ? group.matcher : undefined
|
||||
const diagnostic = matcherDiagnostic(matcher, 'codex')
|
||||
if (diagnostic !== undefined) throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`)
|
||||
groups.push({ ...matcher !== undefined ? { matcher } : {}, hooks: commands })
|
||||
}
|
||||
if (groups.length > 0) config[event] = groups
|
||||
}
|
||||
|
||||
@@ -39,12 +39,13 @@ function writeHooks(dir: string, hooks: unknown): void {
|
||||
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks }))
|
||||
}
|
||||
|
||||
async function harness(dir: string, adapter: MockAdapter): Promise<Context> {
|
||||
async function harness(dir: string, adapter: MockAdapter, beforeHooks?: (ctx: Context) => void): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
beforeHooks?.(ctx)
|
||||
await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'test-model' })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
@@ -88,11 +89,11 @@ describe('hooks-codex bridge', () => {
|
||||
|
||||
it('a Stop hook (exit 2) forces the turn to continue with the reason as steering', async () => {
|
||||
const dir = configDir()
|
||||
// Block once with a marker; until the loop guard lands, an always-blocking
|
||||
// hook would never let this test finish.
|
||||
// Stop ignores its malformed matcher field. Block once with a marker;
|
||||
// until the loop guard lands, an always-blocking hook would never finish.
|
||||
const marker = join(dir, 'fired')
|
||||
const cont = script(dir, 'cont.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\necho "keep going: address the goal" >&2\nexit 2\n`)
|
||||
writeHooks(dir, { Stop: [{ hooks: [{ type: 'command', command: cont }] }] })
|
||||
writeHooks(dir, { Stop: [{ matcher: '[', hooks: [{ type: 'command', command: cont }] }] })
|
||||
|
||||
const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer after goal')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
@@ -151,6 +152,26 @@ describe('hooks-codex bridge', () => {
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('an invalid regex matcher is reported and registers no hooks', async () => {
|
||||
const dir = configDir()
|
||||
writeHooks(dir, {
|
||||
UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }],
|
||||
PreToolUse: [{ matcher: '[', hooks: [{ type: 'command', command: 'exit 2' }] }],
|
||||
})
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const warn = vi.fn()
|
||||
const ctx = await harness(dir, adapter, (ctx) => { ctx.logger.warn = warn as never })
|
||||
const agent = ctx.agentLoop.create(SessionId('invalid-codex-matcher'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(events(agent).some(event => event.type === 'hook/invoked')).toBe(false)
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining(
|
||||
'invalid codex regex matcher "[" on event "PreToolUse"',
|
||||
))
|
||||
})
|
||||
|
||||
it('disposing the bridge fiber removes its listeners (HMR safety)', async () => {
|
||||
const dir = configDir()
|
||||
// A leaked listener would let this blocking hook veto the prompt and log an invocation; a
|
||||
|
||||
@@ -65,4 +65,22 @@ describe('parseCodexConfig', () => {
|
||||
const { config } = parseCodexConfig({ PreToolUse: [{ matcher: '^Bash$', hooks: [{ type: 'command', command: 'b.sh' }] }] })
|
||||
expect(config.PreToolUse![0]!.matcher).toBe('^Bash$')
|
||||
})
|
||||
|
||||
it('rejects an invalid regex matcher with its event name', () => {
|
||||
expect(() => parseCodexConfig({
|
||||
PreToolUse: [{ matcher: '[', hooks: [{ type: 'command', command: 's.sh' }] }],
|
||||
})).toThrow('invalid codex regex matcher "[" on event "PreToolUse"')
|
||||
})
|
||||
|
||||
it('discards matcher fields on events without matcher subjects before validation', () => {
|
||||
const { config } = parseCodexConfig({
|
||||
UserPromptSubmit: [{ matcher: '[', hooks: [{ type: 'command', command: 'prompt.sh' }] }],
|
||||
Stop: [{ matcher: '(', hooks: [{ type: 'command', command: 'stop.sh' }] }],
|
||||
})
|
||||
|
||||
expect(config).toEqual({
|
||||
UserPromptSubmit: [{ hooks: [{ command: 'prompt.sh' }] }],
|
||||
Stop: [{ hooks: [{ command: 'stop.sh' }] }],
|
||||
})
|
||||
})
|
||||
})
|
||||
Generated
+9
@@ -1172,6 +1172,9 @@ importers:
|
||||
'@deepseek-ai/dsh-client-connection':
|
||||
specifier: workspace:^
|
||||
version: link:../connection
|
||||
'@deepseek-ai/dsh-client-locale':
|
||||
specifier: workspace:^
|
||||
version: link:../locale
|
||||
'@deepseek-ai/dsh-client-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../runtime
|
||||
@@ -1366,6 +1369,9 @@ importers:
|
||||
'@deepseek-ai/dsh-agent':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/agent
|
||||
'@deepseek-ai/dsh-client-locale':
|
||||
specifier: workspace:^
|
||||
version: link:../locale
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
@@ -1452,6 +1458,9 @@ importers:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.1
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-client-locale':
|
||||
specifier: workspace:^
|
||||
version: link:../locale
|
||||
'@deepseek-ai/dsh-client-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../runtime
|
||||
|
||||
Reference in New Issue
Block a user