diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index bd321c33a0..632534c060 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 36053fd205923e207224a01f3f948c1977004c1c -config-catalog.zh.md: d092947d31cfe4b24cae5d0ee8570dda39d7a287 +config-catalog.md: a5269c1c5bbf45cdd6e13a9b4c9e6cb57dab1db9 +config-catalog.zh.md: 7cff05d3f741b40689db7ece92b0739e114a5206 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 36053fd205..a5269c1c5b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2717,6 +2717,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-models` ([`packages/client/ui-models/src/index.ts`](../packages/client/ui-models/src/index.ts)) - `@deepseek-ai/dsh-client-ui-permission` ([`packages/client/ui-permission/src/index.ts`](../packages/client/ui-permission/src/index.ts)) - `@deepseek-ai/dsh-client-ui-plan` ([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-plugin-config` ([`packages/client/ui-plugin-config/src/index.ts`](../packages/client/ui-plugin-config/src/index.ts)) - `@deepseek-ai/dsh-client-ui-question` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index d092947d31..7cff05d3f7 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2718,6 +2718,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-models`([`packages/client/ui-models/src/index.ts`](../packages/client/ui-models/src/index.ts)) - `@deepseek-ai/dsh-client-ui-permission`([`packages/client/ui-permission/src/index.ts`](../packages/client/ui-permission/src/index.ts)) - `@deepseek-ai/dsh-client-ui-plan`([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-plugin-config`([`packages/client/ui-plugin-config/src/index.ts`](../packages/client/ui-plugin-config/src/index.ts)) - `@deepseek-ai/dsh-client-ui-question`([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings`([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general`([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index c9b096618a..5cdbfcaed7 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: a9d4def424c875860781b4b7d46aea2e9af903dd -module-graph.zh.md: 759ded070a2991689ee18ca679cfa57213abc49d +module-graph.md: e6908727593755d52353e5119f6d57919f3d83f4 +module-graph.zh.md: bd7fe1e7212019168554367e505aca99eba97d67 diff --git a/docs/module-graph.md b/docs/module-graph.md index a9d4def424..e690872759 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -157,6 +157,7 @@ flowchart TD pkg_client_ui_models["client-ui-models"] pkg_client_ui_permission["client-ui-permission"] pkg_client_ui_plan["client-ui-plan"] + pkg_client_ui_plugin_config["client-ui-plugin-config"] pkg_client_ui_primitives["client-ui-primitives"] pkg_client_ui_question["client-ui-question"] pkg_client_ui_settings["client-ui-settings"] @@ -432,6 +433,14 @@ flowchart TD pkg_skill --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm + pkg_client_ui_plugin_config --> pkg_client_connection + pkg_client_ui_plugin_config --> pkg_client_locale + pkg_client_ui_plugin_config --> pkg_client_runtime + pkg_client_ui_plugin_config --> pkg_client_ui_primitives + pkg_client_ui_plugin_config --> pkg_client_ui_settings + pkg_client_ui_plugin_config --> pkg_client_ui_slots + pkg_client_ui_plugin_config --> pkg_client_web_react + pkg_client_ui_plugin_config --> pkg_invariants pkg_client_ui_question --> pkg_client_locale pkg_client_ui_question --> pkg_invariants pkg_client_ui_settings_general --> pkg_client_connection @@ -1303,6 +1312,7 @@ flowchart TD | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`skill`](../packages/skill/skill) | `skill` | [`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-plugin-config`](../packages/client/ui-plugin-config) | `client` | [`client-connection`](../packages/client/connection), [`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), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`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-connection`](../packages/client/connection), [`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), [`client-web-react`](../packages/client/web-react), [`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) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 759ded070a..bd7fe1e721 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -159,6 +159,7 @@ flowchart TD pkg_client_ui_models["client-ui-models"] pkg_client_ui_permission["client-ui-permission"] pkg_client_ui_plan["client-ui-plan"] + pkg_client_ui_plugin_config["client-ui-plugin-config"] pkg_client_ui_primitives["client-ui-primitives"] pkg_client_ui_question["client-ui-question"] pkg_client_ui_settings["client-ui-settings"] @@ -434,6 +435,14 @@ flowchart TD pkg_skill --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm + pkg_client_ui_plugin_config --> pkg_client_connection + pkg_client_ui_plugin_config --> pkg_client_locale + pkg_client_ui_plugin_config --> pkg_client_runtime + pkg_client_ui_plugin_config --> pkg_client_ui_primitives + pkg_client_ui_plugin_config --> pkg_client_ui_settings + pkg_client_ui_plugin_config --> pkg_client_ui_slots + pkg_client_ui_plugin_config --> pkg_client_web_react + pkg_client_ui_plugin_config --> pkg_invariants pkg_client_ui_question --> pkg_client_locale pkg_client_ui_question --> pkg_invariants pkg_client_ui_settings_general --> pkg_client_connection @@ -1305,6 +1314,7 @@ flowchart TD | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`skill`](../packages/skill/skill) | `skill` | [`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-plugin-config`](../packages/client/ui-plugin-config) | `client` | [`client-connection`](../packages/client/connection), [`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), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`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-connection`](../packages/client/connection), [`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), [`client-web-react`](../packages/client/web-react), [`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) | diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index e4c4935a2a..f00f757fe8 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -188,6 +188,11 @@ - id: ui-agent-preset name: '@deepseek-ai/dsh-client-ui-agent-preset' + # Plugin configuration: the host-plane sections a user owns, as expandable + # cards. A namespace this deployment does not expose renders nothing. + - id: ui-plugin-config + name: '@deepseek-ai/dsh-client-ui-plugin-config' + # Plan control: the composer plan seat over the plan projection + /plan channel. - id: ui-plan name: '@deepseek-ai/dsh-client-ui-plan' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 4f8b8d4318..ab42f7b747 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -33,12 +33,11 @@ }, "dependencies": { "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", "@deepseek-ai/dsh-client-ui-command": "workspace:^", @@ -50,6 +49,7 @@ "@deepseek-ai/dsh-client-ui-models": "workspace:^", "@deepseek-ai/dsh-client-ui-permission": "workspace:^", "@deepseek-ai/dsh-client-ui-plan": "workspace:^", + "@deepseek-ai/dsh-client-ui-plugin-config": "workspace:^", "@deepseek-ai/dsh-client-ui-question": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-general": "workspace:^", diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 816f8737e7..2b52f97ce2 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/README.md -README.md: 567e10f74ae9d017abef1d876401a958eb80fcfd -README.zh.md: ad6a9fb199c4118b864b80a466ddef40676b7169 +README.md: 2518285cea1dfd022a3d656bd4b7a7f2bd77a08e +README.zh.md: 962326f055866119370ff9ad845fed5103928541 diff --git a/packages/client/README.md b/packages/client/README.md index 567e10f74a..2518285cea 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -32,6 +32,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha | [`ui-model/`](ui-model/README.md) | Provides model selection in conversation surfaces. | | [`ui-permission/`](ui-permission/README.md) | Configures default permissions and switches the current session's access. | | [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control. | +| [`ui-plugin-config/`](ui-plugin-config/README.md) | The Plugins settings section: host-plane plugin configuration as expandable cards. | | [`ui-question/`](ui-question/README.md) | Presents interactive questions requested by the agent. | | [`ui-agent-preset/`](ui-agent-preset/README.md) | Selects a session's agent preset and authors preset compositions. | | [`ui-settings/`](ui-settings/README.md) | Hosts the settings interface and its extension areas. | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index ad6a9fb199..962326f055 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -32,6 +32,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U | [`ui-model/`](ui-model/README.md) | 在会话界面中提供模型选择。 | | [`ui-permission/`](ui-permission/README.md) | 配置默认权限并切换当前会话的访问模式。 | | [`ui-plan/`](ui-plan/README.md) | 展示生效中的 plan mode 状态及其退出控件。 | +| [`ui-plugin-config/`](ui-plugin-config/README.md) | 插件设置分区:把宿主平面的插件配置呈现为可展开卡片。 | | [`ui-question/`](ui-question/README.md) | 展示 agent 请求的交互式问题。 | | [`ui-agent-preset/`](ui-agent-preset/README.md) | 选择会话的 agent 预设,并创作预设组装。 | | [`ui-settings/`](ui-settings/README.md) | 承载设置界面及其扩展区域。 | diff --git a/packages/client/ui-plugin-config/README.i18n.yaml b/packages/client/ui-plugin-config/README.i18n.yaml new file mode 100644 index 0000000000..d9d198f4dc --- /dev/null +++ b/packages/client/ui-plugin-config/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-plugin-config/README.md +README.md: e830589b5e279fb0bdda23591502989bebc2a336 +README.zh.md: e614d7b6858f8cbf2a38cb7397b5d8f93445ab6c diff --git a/packages/client/ui-plugin-config/README.md b/packages/client/ui-plugin-config/README.md new file mode 100644 index 0000000000..e830589b5e --- /dev/null +++ b/packages/client/ui-plugin-config/README.md @@ -0,0 +1,33 @@ +# dsh-client-ui-plugin-config + +English | [中文](README.zh.md) + +The **Plugins** settings section: one expandable card per Host plugin whose configuration a user owns. A card shows the plugin's name and what it governs; expanding it in place reveals hand-written controls bound to that plugin's settings namespace, each field marking whether the user overrode it and offering a reset back to the value the deployment composed. + +## What appears here + +A card renders only when its namespace is both registered by a live Host plugin and served to the browser. A deployment that does not compose the owning plugin — or serves the namespace to no client — renders nothing for it rather than an empty or disabled card, so the section reflects what this deployment actually runs. + +The first batch covers the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), and the DeepSeek search provider (`web-search-deepseek`). + +## Extension point + +The section declares `settings.plugin.item`, a root list slot. A plugin that ships a browser half registers its own card into that slot and owns its controls; this package neither enumerates namespaces nor renders a form it was not given. Ordering follows the slot's `order`. + +## Writes + +Every control writes one field through the client settings scope, which fences each write with the namespace revision it read, so a form that has drifted from the document is refused rather than overwriting a concurrent change. A field's presence in the raw user layer — not its value — is what marks it overridden; a reset clears that field so it re-inherits the composition layer. Secret-role fields never ride a response, so a key control reports only whether one is configured and writes through the credentials domain rather than the settings section. + +## Model Experience + +None, as the section renders a browser configuration UI; the values it writes reach a model only through the plugins that own them, each documenting that effect itself. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Only host-plane plugins appear** — a plugin an agent preset mounts carries its configuration inline in that preset's `agent.cordis.yml` and cannot register a settings namespace at all (a second session mounting the same preset would fail on a duplicate registration), so this section lists nothing for it. Editing those values remains the preset editor's job. +- **Exposure is a Host allowlist, not a plugin declaration** — a namespace absent from the api-proxy's allowlist answers `settings-not-exposed` even when its owner registered it, so a plugin distributed outside this repository cannot surface its own configuration here without a change in `packages/host/apiproxy`. +- **The shell card follows the composed executor** — the POSIX and PowerShell executor families share the `bash` namespace because a host composes exactly one of them, so the card's fields differ by platform and a deployment composing neither shows no card. diff --git a/packages/client/ui-plugin-config/README.zh.md b/packages/client/ui-plugin-config/README.zh.md new file mode 100644 index 0000000000..e614d7b685 --- /dev/null +++ b/packages/client/ui-plugin-config/README.zh.md @@ -0,0 +1,33 @@ +# dsh-client-ui-plugin-config + +[English](README.md) | 中文 + +**插件**设置分区:每个配置由用户拥有的 Host 插件占一张可展开卡片。卡片展示插件名称及其管辖范围;就地展开后是绑定到该插件 settings 命名空间的手写控件,每个字段标注用户是否覆盖过它,并提供重置回部署组装值的入口。 + +## 这里会出现什么 + +只有当某个命名空间既被存活的 Host 插件注册、又被服务给浏览器时,它的卡片才会渲染。未组装该插件的部署——或未向任何客户端服务该命名空间的部署——不会渲染空卡片或禁用卡片,而是什么都不渲染,因此这一分区反映的是该部署实际运行的东西。 + +第一批覆盖 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。 + +## 扩展点 + +本分区声明了根级列表 slot `settings.plugin.item`。带浏览器半侧的插件把自己的卡片注册进该 slot 并拥有其控件;本包既不枚举命名空间,也不渲染未被交给它的表单。排序遵循 slot 的 `order`。 + +## 写入 + +每个控件都通过客户端 settings scope 写入单个字段,该 scope 用读取时的命名空间 revision 为每次写入设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应,因此密钥控件只报告是否已配置,并经由 credentials 领域而非 settings 分节写入。 + +## 模型体验 + +无。该分区渲染浏览器配置 UI;它写入的值只通过拥有这些值的插件到达模型,而这些效应各由其拥有方的包记录。 + +#### KV Cache 影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与暂缓事项 + +- **只有宿主平面的插件会出现**——由 agent preset 挂载的插件把配置内联在该 preset 的 `agent.cordis.yml` 中,且根本无法注册 settings 命名空间(同一 preset 挂载第二个会话时会因重复注册而失败),因此本分区不会列出它。编辑那些值仍是 preset 编辑器的职责。 +- **暴露是 Host 的白名单,而非插件的声明**——不在 api-proxy 白名单中的命名空间,即便其拥有方已注册,也只会得到 `settings-not-exposed`,因此在本仓库之外分发的插件无法在不改动 `packages/host/apiproxy` 的前提下让自己的配置出现在这里。 +- **shell 卡片跟随被组装的执行器**——POSIX 与 PowerShell 两个执行器家族共用 `bash` 命名空间,因为一个宿主只组装其中之一,所以该卡片的字段随平台不同,而两者都不组装的部署不会显示这张卡片。 diff --git a/packages/client/ui-plugin-config/package.json b/packages/client/ui-plugin-config/package.json new file mode 100644 index 0000000000..f3a9c04e62 --- /dev/null +++ b/packages/client/ui-plugin-config/package.json @@ -0,0 +1,71 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-plugin-config", + "description": "Plugin configuration section: host-plane plugin settings as expandable cards", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-settings" + ], + "platform": "web" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "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-primitives": "^0.0.1", + "@deepseek-ai/dsh-client-ui-settings": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-client-web-react": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-web-react": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-plugin-config/src/client/AgentLoopCard.tsx b/packages/client/ui-plugin-config/src/client/AgentLoopCard.tsx new file mode 100644 index 0000000000..99790467dc --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/AgentLoopCard.tsx @@ -0,0 +1,63 @@ +/** The agent-loop plugin's card: how many tool calls may run at once. */ + +import { useState } from 'react' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { NumberField } from './fields.tsx' +import { PluginCard } from './PluginCard.tsx' +import type { AgentLoopCardState } from './agent-loop-store.ts' +import type {} from './slot-contract.ts' + +/** Registration-side business face for the agent-loop card. */ +export interface AgentLoopCardInjected { + hooks: { + /** Card snapshot bound by the renderer as useAgentLoopCard. */ + agentLoopCard: SnapshotStore + } + /** Write the parallel tool-call cap. */ + setMaxParallelToolCalls: (next: number) => void + /** Clear the cap so it re-inherits the composition layer. */ + resetMaxParallelToolCalls: () => void +} + +/** Props the renderer binds for the agent-loop card. */ +export type AgentLoopCardProps = + PropsRuntime<'settings.plugin.item'> + & PropsLocale<'settings.pluginConfig'> + & InjectFace + +/** + * Render the agent-loop card. + * @param props - locale copy, the card snapshot, and its write actions. + * @returns the card. + */ +export function AgentLoopCard(props: AgentLoopCardProps) { + const { t } = props + const state = props.useAgentLoopCard(snapshot => snapshot) + const [open, setOpen] = useState(false) + const disabled = !state.writable + return ( + { setOpen(!open) }} + readOnly={disabled} + readOnlyLabel={t('readOnly')} + > + + + ) +} diff --git a/packages/client/ui-plugin-config/src/client/BashCard.tsx b/packages/client/ui-plugin-config/src/client/BashCard.tsx new file mode 100644 index 0000000000..7291ed18e8 --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/BashCard.tsx @@ -0,0 +1,79 @@ +/** The shell plugin's card: the limits every command the agent runs is bound by. */ + +import { useState } from 'react' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { NumberField } from './fields.tsx' +import { PluginCard } from './PluginCard.tsx' +import type { BashCardState } from './bash-store.ts' +import type {} from './slot-contract.ts' + +/** Registration-side business face for the shell card. */ +export interface BashCardInjected { + hooks: { + /** Card snapshot bound by the renderer as useBashCard. */ + bashCard: SnapshotStore + } + /** Write the foreground command timeout. */ + setTimeoutMs: (next: number) => void + /** Clear the timeout so it re-inherits the composition layer. */ + resetTimeoutMs: () => void + /** Write the per-stream output cap. */ + setMaxOutputBytes: (next: number) => void + /** Clear the output cap so it re-inherits the composition layer. */ + resetMaxOutputBytes: () => void +} + +/** Props the renderer binds for the shell card. */ +export type BashCardProps = + PropsRuntime<'settings.plugin.item'> + & PropsLocale<'settings.pluginConfig'> + & InjectFace + +/** + * Render the shell card. + * @param props - locale copy, the card snapshot, and its write actions. + * @returns the card. + */ +export function BashCard(props: BashCardProps) { + const { t } = props + const state = props.useBashCard(snapshot => snapshot) + const [open, setOpen] = useState(false) + const disabled = !state.writable + return ( + { setOpen(!open) }} + readOnly={disabled} + readOnlyLabel={t('readOnly')} + > + + + + ) +} diff --git a/packages/client/ui-plugin-config/src/client/PluginCard.module.css b/packages/client/ui-plugin-config/src/client/PluginCard.module.css new file mode 100644 index 0000000000..6a63962926 --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/PluginCard.module.css @@ -0,0 +1,35 @@ +/* Plugin card: one expandable row per plugin, its body holding the controls. */ + +.card { + list-style: none; + border-bottom: 1px solid var(--dsw-alias-border-l2); +} + +.row { + padding: 16px 0; +} + +.title { + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.description { + font-size: 12px; + font-weight: 400; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); +} + +.body { + padding: 0 0 8px 24px; +} + +.readOnly { + margin: 0 0 8px; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); +} diff --git a/packages/client/ui-plugin-config/src/client/PluginCard.tsx b/packages/client/ui-plugin-config/src/client/PluginCard.tsx new file mode 100644 index 0000000000..469444571b --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/PluginCard.tsx @@ -0,0 +1,61 @@ +/** + * One plugin's card: an expandable row whose body is that plugin's controls. + * A card renders nothing while its namespace is unavailable — a deployment + * that does not compose the owning plugin should show no trace of it, rather + * than an empty or disabled card the user cannot act on. + */ + +import type { ReactNode } from 'react' +import { DisclosureRow } from '@deepseek-ai/dsh-client-ui-primitives' +import css from './PluginCard.module.css' + +/** Card chrome shared by every plugin section. */ +export interface PluginCardProps { + /** Plugin name shown on the row. */ + title: string + /** One line describing what this plugin's settings govern. */ + description: string + /** False while the namespace is not served to this client. */ + available: boolean + /** Whether the card body is showing. */ + open: boolean + /** Toggle the card body. */ + onToggle: () => void + /** Copy shown when the settings document refuses writes. */ + readOnlyLabel?: string | undefined + /** True when the Host document is read-only. */ + readOnly: boolean + /** The plugin's controls. */ + children: ReactNode +} + +/** + * Render one plugin card. + * @param props - card chrome, disclosure state, and the plugin's controls. + * @returns the card, or nothing when the namespace is unavailable. + */ +export function PluginCard(props: PluginCardProps) { + if (!props.available) return null + return ( +
  • + {props.description}} + > +
    + {props.readOnly && props.readOnlyLabel !== undefined + ?

    {props.readOnlyLabel}

    + : null} + {props.children} +
    +
    +
  • + ) +} diff --git a/packages/client/ui-plugin-config/src/client/PluginConfigSection.module.css b/packages/client/ui-plugin-config/src/client/PluginConfigSection.module.css new file mode 100644 index 0000000000..6f2af513e9 --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/PluginConfigSection.module.css @@ -0,0 +1,36 @@ +/* Plugin configuration section: heading, intro, and the card list. */ + +.section { + display: flex; + flex-direction: column; +} + +.heading { + margin: 0; + font-size: 16px; + font-weight: 500; + line-height: 24px; + color: var(--dsw-alias-label-primary); +} + +.intro { + margin: 8px 0 16px; + font-size: 12px; + font-weight: 400; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); +} + +.cards { + margin: 0; + padding: 0; + list-style: none; +} + +.empty { + margin: 0; + padding: 16px 0; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-tertiary); +} diff --git a/packages/client/ui-plugin-config/src/client/PluginConfigSection.tsx b/packages/client/ui-plugin-config/src/client/PluginConfigSection.tsx new file mode 100644 index 0000000000..68de45eff3 --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/PluginConfigSection.tsx @@ -0,0 +1,49 @@ +/** + * Plugin configuration section: the shell around the per-plugin cards. It + * enumerates nothing itself — cards arrive through the `settings.plugin.item` + * slot it declares, so a plugin that ships a browser half owns its own card + * and this section never learns what a namespace means. + */ + +import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from './slot-contract.ts' +import type { PluginConfigKey } from './locales.ts' +import css from './PluginConfigSection.module.css' + +/** Registration-side business face for the section. */ +export interface PluginConfigSectionInjected { + /** How many cards the slot ledger currently holds; zero renders the empty line. */ + cardCount: number +} + +/** Props the renderer binds for the section. */ +export type PluginConfigSectionProps = + PropsRuntime<'settings.section'> + & PropsLocale<'settings.pluginConfig'> + & PropsRenderSlots<'settings.plugin.item'> + & InjectFace + +/** + * Render the plugin configuration section. + * @param props - runtime slot rendering, locale copy, and the card count. + * @returns the section. + */ +export function PluginConfigSection(props: PluginConfigSectionProps) { + const { t, renderSlot, cardCount } = props + return ( +
    +

    {t('title')}

    +

    {t('intro')}

    + {cardCount === 0 + ?

    {t('empty')}

    + :
      {renderSlot('settings.plugin.item', {})}
    } +
    + ) +} + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Plugin configuration section and card copy. */ + 'settings.pluginConfig': PluginConfigKey + } +} diff --git a/packages/client/ui-plugin-config/src/client/WebSearchCard.tsx b/packages/client/ui-plugin-config/src/client/WebSearchCard.tsx new file mode 100644 index 0000000000..72c11fa545 --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/WebSearchCard.tsx @@ -0,0 +1,98 @@ +/** + * The web-search provider's card: its endpoint, its per-request search budget, + * and the key — which is written through the credentials domain, never into + * the settings section, so the literal never rides a response. + */ + +import { useState } from 'react' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { NumberField, SecretField, TextField } from './fields.tsx' +import { PluginCard } from './PluginCard.tsx' +import type { WebSearchCardState } from './web-search-store.ts' +import type {} from './slot-contract.ts' + +/** Registration-side business face for the web-search card. */ +export interface WebSearchCardInjected { + hooks: { + /** Card snapshot bound by the renderer as useWebSearchCard. */ + webSearchCard: SnapshotStore + } + /** Write the provider endpoint; the empty string clears it. */ + setBaseUrl: (next: string) => void + /** Clear the endpoint so it re-inherits the composition layer. */ + resetBaseUrl: () => void + /** Write the per-request search budget. */ + setMaxUses: (next: number) => void + /** Clear the budget so it re-inherits the composition layer. */ + resetMaxUses: () => void + /** Write the credential the section references. */ + setApiKey: (next: string) => void +} + +/** Props the renderer binds for the web-search card. */ +export type WebSearchCardProps = + PropsRuntime<'settings.plugin.item'> + & PropsLocale<'settings.pluginConfig'> + & InjectFace + +/** + * Render the web-search card. + * @param props - locale copy, the card snapshot, and its write actions. + * @returns the card. + */ +export function WebSearchCard(props: WebSearchCardProps) { + const { t } = props + const state = props.useWebSearchCard(snapshot => snapshot) + const [open, setOpen] = useState(false) + const disabled = !state.writable + return ( + { setOpen(!open) }} + readOnly={disabled} + readOnlyLabel={t('readOnly')} + > + + + + + ) +} diff --git a/packages/client/ui-plugin-config/src/client/agent-loop-store.ts b/packages/client/ui-plugin-config/src/client/agent-loop-store.ts new file mode 100644 index 0000000000..1f4dae6c1a --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/agent-loop-store.ts @@ -0,0 +1,60 @@ +/** The agent-loop card's state and writes over the `agent-loop` settings namespace. */ + +import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { CardController, fieldOf, shellOf, type CardField, type CardShell } from './card-store.ts' + +/** + * Namespace of the agent loop's user-owned settings. Spelled here rather than + * imported: a client package must not depend on a Host package. + */ +export const AGENT_LOOP_NS = 'agent-loop' + +/** + * The agent-loop fields this card edits. The Host section carries only this + * field — the composed `agents` array is deliberately not part of it. + */ +export interface AgentLoopSettings { + /** Upper bound on parallel-safe tool calls in flight per step. */ + maxParallelToolCalls?: number +} + +/** What the agent-loop card renders. */ +export interface AgentLoopCardState extends CardShell { + /** Parallel tool-call cap. */ + maxParallelToolCalls: CardField +} + +/** The registration-side face the agent-loop card's slot entry injects. */ +export interface AgentLoopCardFace { + hooks: { + /** Card snapshot bound by the renderer as useAgentLoopCard. */ + agentLoopCard: SnapshotStore + } + /** Write the parallel tool-call cap. */ + setMaxParallelToolCalls: (next: number) => void + /** Clear the cap so it re-inherits the composition layer. */ + resetMaxParallelToolCalls: () => void +} + +/** Bridges the `agent-loop` scope onto the card's state and writes. */ +export class AgentLoopCardController extends CardController { + /** @param scope - the bound settings scope for the `agent-loop` namespace. */ + constructor(scope: SettingsScope) { + super(scope, snapshot => ({ + ...shellOf(snapshot), + maxParallelToolCalls: fieldOf(snapshot, 'maxParallelToolCalls', 0), + })) + } + + /** + * Build the face the card's slot registration injects. + * @returns the card's snapshot and its write actions. + */ + inject(): AgentLoopCardFace { + return { + hooks: { agentLoopCard: this.store }, + setMaxParallelToolCalls: (next: number) => { void this.scope.set('maxParallelToolCalls', next) }, + resetMaxParallelToolCalls: () => { void this.scope.unset('maxParallelToolCalls') }, + } + } +} diff --git a/packages/client/ui-plugin-config/src/client/bash-store.ts b/packages/client/ui-plugin-config/src/client/bash-store.ts new file mode 100644 index 0000000000..91114669c1 --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/bash-store.ts @@ -0,0 +1,71 @@ +/** The shell card's state and writes over the `bash` settings namespace. */ + +import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { CardController, fieldOf, shellOf, type CardField, type CardShell } from './card-store.ts' + +/** + * Namespace of the shell capability. Spelled here rather than imported: a + * client package must not depend on a Host package, and the executor families + * that own it spell the same value. + */ +export const BASH_NS = 'bash' + +/** The shell fields this card edits — a subset of the served schema by design. */ +export interface BashSettings { + /** Foreground command timeout in milliseconds. */ + timeoutMs?: number + /** Per-stream in-memory output cap in bytes. */ + maxOutputBytes?: number +} + +/** What the shell card renders. */ +export interface BashCardState extends CardShell { + /** Command timeout in milliseconds. */ + timeoutMs: CardField + /** Per-stream output cap in bytes. */ + maxOutputBytes: CardField +} + +/** The registration-side face the shell card's slot entry injects. */ +export interface BashCardFace { + hooks: { + /** Card snapshot bound by the renderer as useBashCard. */ + bashCard: SnapshotStore + } + /** Write the foreground command timeout. */ + setTimeoutMs: (next: number) => void + /** Clear the timeout so it re-inherits the composition layer. */ + resetTimeoutMs: () => void + /** Write the per-stream output cap. */ + setMaxOutputBytes: (next: number) => void + /** Clear the output cap so it re-inherits the composition layer. */ + resetMaxOutputBytes: () => void +} + +/** Bridges the `bash` scope onto the shell card's state and writes. */ +export class BashCardController extends CardController { + /** @param scope - the bound settings scope for the `bash` namespace. */ + constructor(scope: SettingsScope) { + super(scope, snapshot => ({ + ...shellOf(snapshot), + // The fallbacks only show before the Host serves a section; every served + // section is already schema-defaulted by the owning executor. + timeoutMs: fieldOf(snapshot, 'timeoutMs', 0), + maxOutputBytes: fieldOf(snapshot, 'maxOutputBytes', 0), + })) + } + + /** + * Build the face the card's slot registration injects. + * @returns the card's snapshot and its write actions. + */ + inject(): BashCardFace { + return { + hooks: { bashCard: this.store }, + setTimeoutMs: (next: number) => { void this.scope.set('timeoutMs', next) }, + resetTimeoutMs: () => { void this.scope.unset('timeoutMs') }, + setMaxOutputBytes: (next: number) => { void this.scope.set('maxOutputBytes', next) }, + resetMaxOutputBytes: () => { void this.scope.unset('maxOutputBytes') }, + } + } +} diff --git a/packages/client/ui-plugin-config/src/client/card-store.ts b/packages/client/ui-plugin-config/src/client/card-store.ts new file mode 100644 index 0000000000..ea18e79a9d --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/card-store.ts @@ -0,0 +1,84 @@ +/** + * Shared projection from one settings scope onto a card's fields. + * + * A card shows the effective value of each field and whether the user set it. + * Both come from the scope snapshot: `value` is what the plugin resolves, and + * the presence of a key in the raw `user` layer is what makes it overridden — + * an override equal to the composition default is still an override, and + * comparing values could not tell them apart. + */ + +import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' + +/** One field as a card renders it. */ +export interface CardField { + /** Effective value: the user layer over the composition layer over the schema default. */ + value: V + /** Whether the raw user layer carries this field. */ + overridden: boolean +} + +/** State every plugin card shares. */ +export interface CardShell { + /** False while the namespace is not served to this client; the card renders nothing. */ + available: boolean + /** Whether the Host document accepts writes. */ + writable: boolean +} + +/** + * Read one field out of a scope snapshot. + * @param snapshot - the scope snapshot to project. + * @param field - the section field to read. + * @param fallback - value shown before the Host serves a section. + * @returns the field as a card renders it. + */ +export function fieldOf( + snapshot: SettingsScopeSnapshot, + field: string, + fallback: V, +): CardField { + const section = snapshot.value as Record | undefined + const user = snapshot.user as Record | undefined + const value = section?.[field] + return { + value: value === undefined ? fallback : value as V, + overridden: user !== undefined && Object.hasOwn(user, field), + } +} + +/** + * Project the shell every card shares. + * @param snapshot - the scope snapshot to project. + * @returns availability and writability. + */ +export function shellOf(snapshot: SettingsScopeSnapshot): CardShell { + return { available: snapshot.status === 'ready', writable: snapshot.writable } +} + +/** + * Keep a snapshot store synchronized with one settings scope. + * + * The store exists because slot components read through a snapshot selector, + * while the scope publishes its own snapshot; this bridges the two and gives + * each card a state shaped for rendering rather than for the wire. + */ +export class CardController { + /** Snapshot the card's component reads through its bound selector. */ + readonly store: SnapshotStore + + /** + * @param scope - the bound settings scope for this card's namespace. + * @param project - build the card state from a scope snapshot. + */ + constructor( + protected readonly scope: SettingsScope, + private readonly project: (snapshot: SettingsScopeSnapshot) => S, + ) { + this.store = createSnapshotStore(project(scope.getSnapshot())) + scope.subscribe(() => { + this.store.set(this.project(this.scope.getSnapshot())) + }) + } +} diff --git a/packages/client/ui-plugin-config/src/client/fields.module.css b/packages/client/ui-plugin-config/src/client/fields.module.css new file mode 100644 index 0000000000..d48e1e37ee --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/fields.module.css @@ -0,0 +1,90 @@ +/* Plugin configuration fields: label, control, override badge, and hint. */ + +.field { + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px 0; +} + +.head { + display: flex; + align-items: center; + gap: 8px; +} + +.label { + flex: 1; + min-width: 0; + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.badges { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.badge { + padding: 0 8px; + border-radius: 10px; + background: var(--dsw-alias-bg-module-platform); + font-size: 12px; + line-height: 20px; + color: var(--dsw-alias-label-secondary); +} + +.badgeMuted { + padding: 0 8px; + border-radius: 10px; + font-size: 12px; + line-height: 20px; + color: var(--dsw-alias-label-tertiary); +} + +.reset { + border: none; + background: none; + padding: 0; + font: inherit; + font-size: 12px; + line-height: 20px; + color: var(--dsw-alias-label-secondary); + cursor: pointer; +} + +.reset:hover:not(:disabled) { + color: var(--dsw-alias-label-primary); +} + +.reset:disabled { + cursor: default; +} + +.input { + height: 36px; + padding: 0 12px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; + background: var(--dsw-alias-bg-module-platform); + font: inherit; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.input:disabled { + color: var(--dsw-alias-label-tertiary); + cursor: default; +} + +.hint { + margin: 0; + font-size: 12px; + font-weight: 400; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); +} diff --git a/packages/client/ui-plugin-config/src/client/fields.tsx b/packages/client/ui-plugin-config/src/client/fields.tsx new file mode 100644 index 0000000000..407fbb264a --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/fields.tsx @@ -0,0 +1,193 @@ +/** + * Hand-written controls for the plugin configuration forms. Each renders one + * field's label, its current effective value, whether the user overrode it, + * and — when overridden — the reset that clears it back to the composition + * layer. Commits happen on blur and on Enter rather than per keystroke: a + * write per keystroke would burn namespace revisions and race its own reads. + */ + +import { useState, type KeyboardEvent } from 'react' +import css from './fields.module.css' + +/** What every field control needs regardless of its value type. */ +export interface FieldProps { + /** Stable id associating the label with its control. */ + id: string + /** Visible label. */ + label: string + /** One-line explanation rendered under the control. */ + hint: string + /** True when the raw user layer carries this field. */ + overridden: boolean + /** Copy for the overridden badge. */ + overriddenLabel: string + /** Copy for the reset control. */ + resetLabel: string + /** Disables every control (read-only document, or an unavailable namespace). */ + disabled: boolean + /** Clear the field so it re-inherits the composition layer. */ + onReset: () => void +} + +/** Label, badge, and reset chrome shared by every control. */ +function FieldFrame(props: FieldProps & { children: React.ReactNode }) { + return ( +
    +
    + + {props.overridden + ? ( + + {props.overriddenLabel} + + + ) + : null} +
    + {props.children} +

    {props.hint}

    +
    + ) +} + +/** + * Keep a draft seeded from the authoritative value, re-seeding whenever that + * value changes underneath (a Host acceptance, or a reset). + * @param value - the current authoritative text. + * @returns the draft and its setter. + */ +function useDraft(value: string): [string, (next: string) => void] { + const [draft, setDraft] = useState(value) + const [seed, setSeed] = useState(value) + if (seed !== value) { + setSeed(value) + setDraft(value) + } + return [draft, setDraft] +} + +/** A whole-number field committed on blur or Enter. */ +export function NumberField(props: FieldProps & { + /** Current effective value. */ + value: number + /** Commit a parsed value; a draft that is not a finite number is discarded. */ + onCommit: (next: number) => void +}) { + const [draft, setDraft] = useDraft(String(props.value)) + const commit = () => { + const parsed = Number(draft) + if (draft.trim() === '' || !Number.isFinite(parsed)) { + setDraft(String(props.value)) + return + } + if (parsed === props.value) return + props.onCommit(parsed) + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') event.currentTarget.blur() + } + return ( + + { setDraft(event.target.value) }} + onBlur={commit} + onKeyDown={onKeyDown} + /> + + ) +} + +/** A free-text field committed on blur or Enter; an empty draft clears the field. */ +export function TextField(props: FieldProps & { + /** Current effective value; the empty string when the field is unset. */ + value: string + /** Placeholder shown while the draft is empty. */ + placeholder?: string + /** Commit the trimmed draft. */ + onCommit: (next: string) => void +}) { + const [draft, setDraft] = useDraft(props.value) + const commit = () => { + const next = draft.trim() + if (next === props.value) return + props.onCommit(next) + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') event.currentTarget.blur() + } + return ( + + { setDraft(event.target.value) }} + onBlur={commit} + onKeyDown={onKeyDown} + /> + + ) +} + +/** + * A write-only credential field. The value never rides a response, so the + * control reports only whether one is configured, and an empty draft commits + * nothing — leaving the field blank keeps the stored key rather than clearing it. + */ +export function SecretField(props: Omit & { + /** Whether the Host reports a configured credential for this reference. */ + configured: boolean + /** Copy describing the configured state. */ + stateLabel: string + /** Commit a non-empty draft. */ + onCommit: (next: string) => void +}) { + const [draft, setDraft] = useState('') + const commit = () => { + const next = draft.trim() + if (next === '') return + setDraft('') + props.onCommit(next) + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') event.currentTarget.blur() + } + return ( +
    +
    + + + {props.stateLabel} + +
    + { setDraft(event.target.value) }} + onBlur={commit} + onKeyDown={onKeyDown} + /> +

    {props.hint}

    +
    + ) +} diff --git a/packages/client/ui-plugin-config/src/client/index.ts b/packages/client/ui-plugin-config/src/client/index.ts new file mode 100644 index 0000000000..098485ebee --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/index.ts @@ -0,0 +1,91 @@ +/** + * Plugin configuration surface, browser half — one settings section holding + * an expandable card per Host plugin whose configuration a user owns. + * + * The section owns no knowledge of any namespace: it declares the + * `settings.plugin.item` slot and renders whatever cards were registered into + * it, so a plugin that ships a browser half contributes its own card and its + * own controls. The three cards this package registers are the host-plane + * sections the deployment already exposes; each binds its namespace through + * the client settings scope, which keeps them unaware of one another. + */ + +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' +// Type-only: pulls the settings shell's SlotMap merge (the 'settings.section' entry). +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import { bindSettingsScope, type ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { AgentLoopCard } from './AgentLoopCard.tsx' +import { BashCard } from './BashCard.tsx' +import { PluginConfigSection } from './PluginConfigSection.tsx' +import { WebSearchCard } from './WebSearchCard.tsx' +import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-store.ts' +import { BASH_NS, BashCardController } from './bash-store.ts' +import { WEB_SEARCH_NS, WebSearchCardController } from './web-search-store.ts' +import { en, zh } from './locales.ts' + +export type { PluginConfigSectionInjected, PluginConfigSectionProps } from './PluginConfigSection.tsx' +export type { PluginCardProps } from './PluginCard.tsx' +export type { SettingsPluginItemOwnerProps } from './slot-contract.ts' +export { NumberField, SecretField, TextField, type FieldProps } from './fields.tsx' +export { AGENT_LOOP_NS, AgentLoopCardController, type AgentLoopCardState } from './agent-loop-store.ts' +export { BASH_NS, BashCardController, type BashCardState } from './bash-store.ts' +export { WEB_SEARCH_NS, WebSearchCardController, type WebSearchCardState } from './web-search-store.ts' + +/** Dictionary namespace owned by this plugin. */ +const NS = 'settings.pluginConfig' + +/** Required services (cordis fiber inject). */ +export const inject = ['slots', 'locale', 'connection'] + +/** + * Mount the plugin configuration section and the cards this package ships. + * @param ctx - the browser plugin context. + */ +export function apply(ctx: ClientContext): void { + const { api } = ctx.get('connection') as ConnectionHandle + const t = ctx.locale.bind(NS) + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plugin-config: section dictionaries') + + const bash = new BashCardController(bindSettingsScope(ctx, { namespace: BASH_NS })) + const agentLoop = new AgentLoopCardController(bindSettingsScope(ctx, { namespace: AGENT_LOOP_NS })) + const webSearch = new WebSearchCardController(bindSettingsScope(ctx, { namespace: WEB_SEARCH_NS }), api) + + // The section renders the empty line rather than an empty list when no card + // is registered; the ledger is read at render time so a card arriving later + // (or leaving with its plugin) is reflected without the section subscribing. + ctx.slots.inject('settings.section', () => ctx.slots.register({ + name: 'settings.section', + id: 'plugins', + order: 30, + label: () => t('nav'), + locale: NS, + inject: () => ({ cardCount: ctx.slots.entries('settings.plugin.item').length }), + children: { 'settings.plugin.item': { kind: 'list', scope: 'root' } }, + }, PluginConfigSection)) + + ctx.slots.inject('settings.plugin.item', function* () { + yield ctx.slots.register({ + name: 'settings.plugin.item', + id: 'bash', + order: 0, + locale: NS, + inject: () => bash.inject(), + }, BashCard) + yield ctx.slots.register({ + name: 'settings.plugin.item', + id: 'agent-loop', + order: 10, + locale: NS, + inject: () => agentLoop.inject(), + }, AgentLoopCard) + yield ctx.slots.register({ + name: 'settings.plugin.item', + id: 'web-search', + order: 20, + locale: NS, + inject: () => webSearch.inject(), + }, WebSearchCard) + }) +} diff --git a/packages/client/ui-plugin-config/src/client/locales.ts b/packages/client/ui-plugin-config/src/client/locales.ts new file mode 100644 index 0000000000..6a3804236c --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/locales.ts @@ -0,0 +1,80 @@ +/** Locale bundles for the plugin configuration section and its plugin cards. */ + +/** Locale keys these surfaces render. */ +export type PluginConfigKey = + | 'nav' | 'title' | 'intro' | 'empty' + | 'overridden' | 'reset' | 'readOnly' | 'expand' | 'collapse' + | 'bashTitle' | 'bashDescription' | 'bashTimeoutMs' | 'bashTimeoutMsHint' + | 'bashMaxOutputBytes' | 'bashMaxOutputBytesHint' + | 'agentLoopTitle' | 'agentLoopDescription' | 'agentLoopMaxParallel' | 'agentLoopMaxParallelHint' + | 'webSearchTitle' | 'webSearchDescription' + | 'webSearchApiKey' | 'webSearchApiKeyHint' | 'webSearchApiKeySet' | 'webSearchApiKeyUnset' + | 'webSearchBaseUrl' | 'webSearchBaseUrlHint' | 'webSearchMaxUses' | 'webSearchMaxUsesHint' + +/** English copy. */ +export const en: Record = { + nav: 'Plugins', + title: 'Plugin configuration', + intro: + 'Settings owned by the plugins this deployment composes. A value you set here layers over the ' + + 'composition default and applies to the next use.', + empty: 'This deployment exposes no plugin settings.', + overridden: 'Overridden', + reset: 'Reset to default', + readOnly: 'This deployment stores settings read-only.', + expand: 'Show settings', + collapse: 'Hide settings', + bashTitle: 'Shell', + bashDescription: 'Limits every command the agent runs.', + bashTimeoutMs: 'Command timeout (ms)', + bashTimeoutMsHint: 'How long one command may run before it is terminated.', + bashMaxOutputBytes: 'Output cap per stream (bytes)', + bashMaxOutputBytesHint: 'Output beyond this spills to a temporary file rather than being lost.', + agentLoopTitle: 'Agent loop', + agentLoopDescription: 'How the agent dispatches tool calls.', + agentLoopMaxParallel: 'Parallel tool calls', + agentLoopMaxParallelHint: 'Upper bound on parallel-safe calls running at once within one step.', + webSearchTitle: 'Web search', + webSearchDescription: 'The DeepSeek search provider.', + webSearchApiKey: 'API key', + webSearchApiKeyHint: 'Stored outside the settings file. Leave blank to keep the current key.', + webSearchApiKeySet: 'A key is configured.', + webSearchApiKeyUnset: 'No key is configured; search is unavailable until one is.', + webSearchBaseUrl: 'Endpoint', + webSearchBaseUrlHint: 'Leave blank to use the provider default.', + webSearchMaxUses: 'Max searches per request', + webSearchMaxUsesHint: 'How many times one request may search before it must answer.', +} + +/** Simplified Chinese copy. */ +export const zh: Record = { + nav: '插件', + title: '插件配置', + intro: '本部署所组装插件自己拥有的设置。你在这里设的值会覆盖组装默认值,并在下一次使用时生效。', + empty: '本部署没有开放任何插件设置。', + overridden: '已覆盖', + reset: '恢复默认', + readOnly: '本部署的设置为只读。', + expand: '展开设置', + collapse: '收起设置', + bashTitle: '终端', + bashDescription: '限制 agent 运行的每一条命令。', + bashTimeoutMs: '命令超时(毫秒)', + bashTimeoutMsHint: '单条命令允许运行多久,超时即终止。', + bashMaxOutputBytes: '单流输出上限(字节)', + bashMaxOutputBytesHint: '超出部分会转存到临时文件,而不是被丢弃。', + agentLoopTitle: 'Agent 循环', + agentLoopDescription: 'Agent 如何派发工具调用。', + agentLoopMaxParallel: '并行工具调用数', + agentLoopMaxParallelHint: '同一步内最多同时运行多少个可并行的调用。', + webSearchTitle: '网页搜索', + webSearchDescription: 'DeepSeek 搜索提供方。', + webSearchApiKey: 'API Key', + webSearchApiKeyHint: '不写入设置文件。留空表示保持当前密钥。', + webSearchApiKeySet: '已配置密钥。', + webSearchApiKeyUnset: '未配置密钥;配置之前搜索不可用。', + webSearchBaseUrl: '接口地址', + webSearchBaseUrlHint: '留空则使用提供方默认地址。', + webSearchMaxUses: '单次请求最多搜索次数', + webSearchMaxUsesHint: '一次请求在必须作答前最多可以搜索多少次。', +} diff --git a/packages/client/ui-plugin-config/src/client/slot-contract.ts b/packages/client/ui-plugin-config/src/client/slot-contract.ts new file mode 100644 index 0000000000..02b00ea35b --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/slot-contract.ts @@ -0,0 +1,24 @@ +/** + * The `settings.plugin.item` slot type — one plugin's card inside the plugin + * configuration section. Options: `id` (card key), `order` (card position). + * A card draws its own internals; the section only stacks them and reports + * how many there are. + * + * TYPE HOME RATIONALE: unlike `settings.general.item`, whose registrants span + * packages that cannot reference its declarer, every current registrant of + * this slot ships in this package, and a plugin registering its own card + * already depends on this package for the card chrome. The type therefore + * lives with the section that declares it at runtime. + */ +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + /** One plugin's card inside the plugin configuration section (see module JSDoc). */ + 'settings.plugin.item': { kind: 'list'; scope: 'root'; owner: SettingsPluginItemOwnerProps } + } +} + +/** Owner share of a plugin card (the section supplies nothing). */ +export interface SettingsPluginItemOwnerProps { + /** Marker field: card owner props are intentionally empty. */ + children?: never +} diff --git a/packages/client/ui-plugin-config/src/client/web-search-store.ts b/packages/client/ui-plugin-config/src/client/web-search-store.ts new file mode 100644 index 0000000000..c0242eed8b --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/web-search-store.ts @@ -0,0 +1,144 @@ +/** + * The web-search card's state and writes over the `web-search-deepseek` + * settings namespace. + * + * The key is the one field that does not live in the section: its literal + * never rides a response, so the card learns only whether one is configured + * and writes it through the credentials domain, addressed by the reference + * the section names. + */ + +import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import type { SettingsScope, SettingsScopeSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { CardController, fieldOf, shellOf, type CardField, type CardShell } from './card-store.ts' + +/** + * Namespace of the DeepSeek search provider. Spelled here rather than + * imported: a client package must not depend on a Host package. + */ +export const WEB_SEARCH_NS = 'web-search-deepseek' + +/** Credential reference the provider resolves when the section names none. */ +const DEFAULT_API_KEY_REF = 'DEEPSEEK_API_KEY' + +/** The search-provider fields this card edits. */ +export interface WebSearchSettings { + /** Credential reference naming the environment key. */ + apiKeyEnv?: string + /** Provider endpoint; blank inherits the provider default. */ + baseURL?: string + /** Maximum searches served within one request. */ + maxUses?: number +} + +/** What the web-search card renders. */ +export interface WebSearchCardState extends CardShell { + /** Provider endpoint. */ + baseURL: CardField + /** Searches allowed per request. */ + maxUses: CardField + /** Credential reference the key is written under. */ + apiKeyRef: string + /** Whether the Host reports a credential configured for that reference. */ + apiKeyConfigured: boolean +} + +/** The registration-side face the web-search card's slot entry injects. */ +export interface WebSearchCardFace { + hooks: { + /** Card snapshot bound by the renderer as useWebSearchCard. */ + webSearchCard: SnapshotStore + } + /** Write the provider endpoint; the empty string clears it. */ + setBaseUrl: (next: string) => void + /** Clear the endpoint so it re-inherits the composition layer. */ + resetBaseUrl: () => void + /** Write the per-request search budget. */ + setMaxUses: (next: number) => void + /** Clear the budget so it re-inherits the composition layer. */ + resetMaxUses: () => void + /** Write the credential the section references. */ + setApiKey: (next: string) => void +} + +/** Bridges the `web-search-deepseek` scope and the credentials domain onto the card. */ +export class WebSearchCardController extends CardController { + private readonly credential: { configured: boolean } + + /** + * @param scope - the bound settings scope for the `web-search-deepseek` namespace. + * @param api - wire face used for the credential the section references. + */ + constructor(scope: SettingsScope, private readonly api: Pick) { + // Held in its own object because the projection runs during `super()`, + // before `this` exists, and must still see the latest credential state: + // that state comes from its own domain, so a settings change must not + // silently reset it to unknown. + const credential = { configured: false } + super(scope, snapshot => ({ + ...shellOf(snapshot), + baseURL: fieldOf(snapshot, 'baseURL', ''), + maxUses: fieldOf(snapshot, 'maxUses', 0), + apiKeyRef: refOf(snapshot), + apiKeyConfigured: credential.configured, + })) + this.credential = credential + scope.subscribe(() => { void this.readCredential() }) + void this.readCredential() + } + + /** Ask the credentials domain whether the referenced key exists. */ + private async readCredential(): Promise { + const ref = refOf(this.scope.getSnapshot()) + let response: Awaited> + try { + response = await this.api.credentials.describe({ refs: [ref] }) + } catch (_credentialReadFailure) { + // The card stays usable without this: the key control simply reports the + // last state it knew, and a write still reaches the Host. + return + } + if (!response.result.ok) return + const next = response.result.value.credentials[ref]?.configured ?? false + if (next === this.credential.configured) return + this.credential.configured = next + this.store.set({ ...this.store.getSnapshot(), apiKeyConfigured: next }) + } + + /** + * Build the face the card's slot registration injects. + * @returns the card's snapshot and its write actions. + */ + inject(): WebSearchCardFace { + return { + hooks: { webSearchCard: this.store }, + setBaseUrl: (next: string) => { void this.scope.set('baseURL', next) }, + resetBaseUrl: () => { void this.scope.unset('baseURL') }, + setMaxUses: (next: number) => { void this.scope.set('maxUses', next) }, + resetMaxUses: () => { void this.scope.unset('maxUses') }, + setApiKey: (next: string) => { void this.writeKey(next) }, + } + } + + private async writeKey(value: string): Promise { + const ref = refOf(this.scope.getSnapshot()) + try { + await this.api.credentials.set({ ref, value }) + } catch (_credentialWriteFailure) { + // Refusals surface through the re-read below: the Host is the only + // authority on whether the key now exists. + } + await this.readCredential() + } +} + +/** + * The credential reference the section names, or the provider's default. + * @param snapshot - the current scope snapshot. + * @returns the reference to address. + */ +function refOf(snapshot: SettingsScopeSnapshot): string { + const section = snapshot.value + const declared = section?.apiKeyEnv + return declared !== undefined && declared.length > 0 ? declared : DEFAULT_API_KEY_REF +} diff --git a/packages/client/ui-plugin-config/src/css-modules.d.ts b/packages/client/ui-plugin-config/src/css-modules.d.ts new file mode 100644 index 0000000000..8811db1264 --- /dev/null +++ b/packages/client/ui-plugin-config/src/css-modules.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} diff --git a/packages/client/ui-plugin-config/src/index.ts b/packages/client/ui-plugin-config/src/index.ts new file mode 100644 index 0000000000..96ac4efa2e --- /dev/null +++ b/packages/client/ui-plugin-config/src/index.ts @@ -0,0 +1,11 @@ +/** + * Plugin configuration surface, node half. The empty apply exists so the + * plugin appears in the host cordis.yml / Loader; the browser half ships the + * settings section through exports["./client"], discovered from the + * package.json dshClient declaration. Every section this page edits is owned + * by the Host plugin that registered it, so this package registers no + * namespace of its own. + */ + +/** Host plugin body — no host-side behavior for this surface plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-plugin-config/src/invariant.ts b/packages/client/ui-plugin-config/src/invariant.ts new file mode 100644 index 0000000000..33989fa16a --- /dev/null +++ b/packages/client/ui-plugin-config/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-plugin-config`. + * @module @deepseek-ai/dsh-client-ui-plugin-config/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-plugin-config' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-plugin-config-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this is a browser-side settings surface whose node half owns no event + * stream or mutable runtime data; the layering, write refusals, and exposure boundary are Host + * contracts covered by the owning plugins and the api-proxy. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-plugin-config/tests/apply.spec.ts b/packages/client/ui-plugin-config/tests/apply.spec.ts new file mode 100644 index 0000000000..86bb21606a --- /dev/null +++ b/packages/client/ui-plugin-config/tests/apply.spec.ts @@ -0,0 +1,100 @@ +/** What the browser half registers, and that it all leaves with the fiber. */ + +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { apply, inject } from '@deepseek-ai/dsh-client-ui-plugin-config/client' + +// The service reads its initial locale from the browser; these specs assert +// the shipped Chinese copy, so they state the browser they assume. +usePinnedBrowserLanguages('zh-CN') + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + const locale = new LocaleService(ctx) + ctx.provide('locale', locale) + ctx.provide('connection', { + isLoopback: true, + api: { + settings: { describe: vi.fn(() => Promise.resolve({ rpcId: 's', result: { ok: false, error: {} } })) }, + credentials: { describe: vi.fn(() => Promise.resolve({ rpcId: 'c', result: { ok: false, error: {} } })) }, + }, + } as never) + return { ctx, slots: ctx.get('slots') as SlotsService } +} + +function declareRoot(slots: SlotsService): () => void { + return slots.register({ + name: 'root', + children: { 'settings.section': { kind: 'list', scope: 'root' } }, + } as never, () => null) +} + +describe('ui-plugin-config apply', () => { + it('declares the services it uses', () => { + expect(inject).toEqual(['slots', 'locale', 'connection']) + }) + + it('registers the section and declares the per-plugin card slot', async () => { + const { ctx, slots } = await bench() + declareRoot(slots) + + await ctx.plugin({ inject: [...inject], apply }).await() + + const section = slots.entries('settings.section')[0]! + expect(section.options).toMatchObject({ id: 'plugins', order: 30 }) + // The nav label is a locale-following thunk; owners resolve it at read time. + expect(resolveSlotLabel(section.options.label)).toBe('插件') + expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'list', scope: 'root' }) + }) + + it('registers one card per host-plane section it ships, in a stable order', async () => { + const { ctx, slots } = await bench() + declareRoot(slots) + + await ctx.plugin({ inject: [...inject], apply }).await() + + expect(slots.entries('settings.plugin.item').map(entry => entry.options.id)) + .toEqual(['bash', 'agent-loop', 'web-search']) + }) + + it('injects a live card count and one business face per card', async () => { + const { ctx, slots } = await bench() + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + + const section = slots.entries('settings.section')[0]! + expect((section as { inject?: () => unknown }).inject?.()).toEqual({ cardCount: 3 }) + for (const entry of slots.entries('settings.plugin.item')) { + const face = (entry as { inject?: () => unknown }).inject?.() as { hooks: Record } + // Each card injects exactly one snapshot store plus its own actions. + expect(Object.keys(face.hooks)).toHaveLength(1) + } + }) + + it('registers into a declaration that arrives after apply', async () => { + const { ctx, slots } = await bench() + await ctx.plugin({ inject: [...inject], apply }).await() + + declareRoot(slots) + + await vi.waitFor(() => { expect(slots.entries('settings.section')).toHaveLength(1) }) + }) + + it('collapses every contribution on teardown', async () => { + const { ctx, slots } = await bench() + declareRoot(slots) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(slots.entries('settings.plugin.item')).toHaveLength(3) + + await fiber.dispose() + + expect(slots.entries('settings.section')).toHaveLength(0) + expect(slots.spec('settings.plugin.item')).toBeUndefined() + }) +}) diff --git a/packages/client/ui-plugin-config/tests/fields.spec.tsx b/packages/client/ui-plugin-config/tests/fields.spec.tsx new file mode 100644 index 0000000000..d444ae14a8 --- /dev/null +++ b/packages/client/ui-plugin-config/tests/fields.spec.tsx @@ -0,0 +1,330 @@ +// @vitest-environment jsdom +/** + * Field-control behavior: when a draft becomes a write, what a bad draft does + * instead, and how an overridden field offers its reset. + */ + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { NumberField, SecretField, TextField } from '../src/client/fields.tsx' + +afterEach(cleanup) + +const frame = { + id: 'field', + label: 'Command timeout', + hint: 'How long one command may run.', + overriddenLabel: 'Overridden', + resetLabel: 'Reset to default', + disabled: false, +} + +describe('NumberField', () => { + it('commits a changed draft on blur', () => { + const onCommit = vi.fn() + render( + , + ) + const input = screen.getByLabelText('Command timeout') + + fireEvent.change(input, { target: { value: '9000' } }) + fireEvent.blur(input) + + expect(onCommit).toHaveBeenCalledWith(9_000) + }) + + it('commits on Enter through the blur the key triggers', () => { + const onCommit = vi.fn() + render( + , + ) + const input = screen.getByLabelText('Command timeout') + + fireEvent.change(input, { target: { value: '1234' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + fireEvent.blur(input) + + expect(onCommit).toHaveBeenCalledWith(1_234) + }) + + it('restores the last good value instead of committing a draft that is not a number', () => { + const onCommit = vi.fn() + render( + , + ) + const input = screen.getByLabelText('Command timeout') + + fireEvent.change(input, { target: { value: 'soon' } }) + fireEvent.blur(input) + + expect(onCommit).not.toHaveBeenCalled() + expect(input).toHaveProperty('value', '60000') + }) + + it('writes nothing when the draft settles on the value already shown', () => { + const onCommit = vi.fn() + render( + , + ) + const input = screen.getByLabelText('Command timeout') + + fireEvent.change(input, { target: { value: '60000' } }) + fireEvent.blur(input) + + expect(onCommit).not.toHaveBeenCalled() + }) + + it('offers the reset only while the field is overridden', () => { + const onReset = vi.fn() + const { rerender } = render( + , + ) + expect(screen.queryByRole('button', { name: 'Reset to default' })).toBeNull() + + rerender( + , + ) + fireEvent.click(screen.getByRole('button', { name: 'Reset to default' })) + + expect(screen.getByText('Overridden')).toBeTruthy() + expect(onReset).toHaveBeenCalledOnce() + }) + + it('re-seeds the draft when the authoritative value changes underneath', () => { + const { rerender } = render( + , + ) + expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '9000') + + rerender( + , + ) + + expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '60000') + }) + + it('ignores a keystroke that is not Enter', () => { + const onCommit = vi.fn() + render( + , + ) + const input = screen.getByLabelText('Command timeout') + + fireEvent.change(input, { target: { value: '9000' } }) + fireEvent.keyDown(input, { key: 'Escape' }) + + expect(onCommit).not.toHaveBeenCalled() + }) + + it('suppresses every interaction while disabled', () => { + const onCommit = vi.fn() + const onReset = vi.fn() + render( + , + ) + const input = screen.getByLabelText('Command timeout') + + expect(input).toHaveProperty('disabled', true) + expect(screen.getByRole('button', { name: 'Reset to default' })).toHaveProperty('disabled', true) + expect(onCommit).not.toHaveBeenCalled() + expect(onReset).not.toHaveBeenCalled() + }) +}) + +describe('TextField', () => { + it('commits the trimmed draft', () => { + const onCommit = vi.fn() + render( + , + ) + const input = screen.getByLabelText('Endpoint') + + fireEvent.change(input, { target: { value: ' https://search.test/v1 ' } }) + fireEvent.blur(input) + + expect(onCommit).toHaveBeenCalledWith('https://search.test/v1') + }) + + it('commits an emptied draft, which clears the field', () => { + const onCommit = vi.fn() + render( + , + ) + const input = screen.getByLabelText('Endpoint') + + fireEvent.change(input, { target: { value: '' } }) + fireEvent.blur(input) + + expect(onCommit).toHaveBeenCalledWith('') + }) + + it('renders its placeholder and commits on Enter', () => { + const onCommit = vi.fn() + render( + , + ) + const input = screen.getByLabelText('Endpoint') + expect(input).toHaveProperty('placeholder', 'https://api.deepseek.com') + + fireEvent.change(input, { target: { value: 'https://other.test' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + fireEvent.blur(input) + + expect(onCommit).toHaveBeenCalledWith('https://other.test') + }) + + it('ignores a keystroke that is not Enter and writes nothing unchanged', () => { + const onCommit = vi.fn() + render( + , + ) + const input = screen.getByLabelText('Endpoint') + + fireEvent.keyDown(input, { key: 'a' }) + fireEvent.blur(input) + + expect(onCommit).not.toHaveBeenCalled() + }) +}) + +describe('SecretField', () => { + it('commits a non-empty draft and clears the control after writing', () => { + const onCommit = vi.fn() + render( + , + ) + const input = screen.getByLabelText('API key') + + fireEvent.change(input, { target: { value: ' ds-secret ' } }) + fireEvent.blur(input) + + expect(onCommit).toHaveBeenCalledWith('ds-secret') + expect(input).toHaveProperty('value', '') + }) + + it('keeps the stored key when the draft is left blank', () => { + const onCommit = vi.fn() + render( + , + ) + const input = screen.getByLabelText('API key') + + fireEvent.change(input, { target: { value: ' ' } }) + fireEvent.blur(input) + + expect(onCommit).not.toHaveBeenCalled() + expect(screen.getByText('A key is configured.')).toBeTruthy() + }) + + it('ignores a keystroke that is not Enter', () => { + const onCommit = vi.fn() + render( + , + ) + const input = screen.getByLabelText('API key') + + fireEvent.change(input, { target: { value: 'ds-secret' } }) + fireEvent.keyDown(input, { key: 'Tab' }) + + expect(onCommit).not.toHaveBeenCalled() + }) + + it('never renders the value it writes', () => { + render( + , + ) + + expect(screen.getByLabelText('API key')).toHaveProperty('type', 'password') + }) + + it('commits on Enter and stays disabled when the document is read-only', () => { + const onCommit = vi.fn() + const { rerender } = render( + , + ) + const input = screen.getByLabelText('API key') + fireEvent.change(input, { target: { value: 'ds-secret' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + fireEvent.blur(input) + expect(onCommit).toHaveBeenCalledWith('ds-secret') + + rerender( + , + ) + + expect(screen.getByLabelText('API key')).toHaveProperty('disabled', true) + }) +}) diff --git a/packages/client/ui-plugin-config/tests/invariant.spec.ts b/packages/client/ui-plugin-config/tests/invariant.spec.ts new file mode 100644 index 0000000000..04ad79c04c --- /dev/null +++ b/packages/client/ui-plugin-config/tests/invariant.spec.ts @@ -0,0 +1,25 @@ +/** The package's node half: an empty host body and an explained empty invariant companion. */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as PluginConfigInvariant from '@deepseek-ai/dsh-client-ui-plugin-config/invariant' + +describe('invariant companion', () => { + it('reserves package ownership with an empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + + await expect(ctx.plugin(PluginConfigInvariant).await()).resolves.toBeDefined() + }) + + it('has an empty node half', async () => { + const { apply } = await import('@deepseek-ai/dsh-client-ui-plugin-config') + + // The host body exists only so the plugin appears in the host cordis.yml; + // every surface this package ships lives in the browser half. + apply() + + expect(typeof apply).toBe('function') + }) +}) diff --git a/packages/client/ui-plugin-config/tests/section.spec.tsx b/packages/client/ui-plugin-config/tests/section.spec.tsx new file mode 100644 index 0000000000..891fa071f8 --- /dev/null +++ b/packages/client/ui-plugin-config/tests/section.spec.tsx @@ -0,0 +1,223 @@ +// @vitest-environment jsdom +/** + * What the section and its cards show: the empty line when no plugin + * contributed one, a card that renders nothing while its namespace is + * unavailable, and the read-only notice a locked document produces. + */ + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { AgentLoopCard } from '../src/client/AgentLoopCard.tsx' +import type { AgentLoopCardProps } from '../src/client/AgentLoopCard.tsx' +import { BashCard } from '../src/client/BashCard.tsx' +import type { BashCardProps } from '../src/client/BashCard.tsx' +import { PluginConfigSection } from '../src/client/PluginConfigSection.tsx' +import type { PluginConfigSectionProps } from '../src/client/PluginConfigSection.tsx' +import { WebSearchCard } from '../src/client/WebSearchCard.tsx' +import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx' +import type { AgentLoopCardState } from '../src/client/agent-loop-store.ts' +import type { BashCardState } from '../src/client/bash-store.ts' +import type { WebSearchCardState } from '../src/client/web-search-store.ts' +import { en } from '../src/client/locales.ts' + +afterEach(cleanup) + +const t = (key: keyof typeof en) => en[key] + +function renderSection(cardCount: number, cards = 'cards') { + const props = { + t, + cardCount, + renderSlot: () =>
  • {cards}
  • , + } as unknown as PluginConfigSectionProps + render() +} + +function renderBash(state: Partial = {}) { + const store = createSnapshotStore({ + available: true, + writable: true, + timeoutMs: { value: 60_000, overridden: false }, + maxOutputBytes: { value: 64_000, overridden: false }, + ...state, + }) + const actions = { + setTimeoutMs: vi.fn(), + resetTimeoutMs: vi.fn(), + setMaxOutputBytes: vi.fn(), + resetMaxOutputBytes: vi.fn(), + } + const props = { + ...actions, + t, + useBashCard: bindSnapshotSelector(store), + } as unknown as BashCardProps + render() + return actions +} + +describe('PluginConfigSection', () => { + it('says so when no plugin contributed a card', () => { + renderSection(0) + + expect(screen.getByText(en.empty)).toBeTruthy() + expect(screen.queryByText('cards')).toBeNull() + }) + + it('renders the card list once a plugin contributed one', () => { + renderSection(1) + + expect(screen.getByText('cards')).toBeTruthy() + expect(screen.queryByText(en.empty)).toBeNull() + }) + + it('leads with its own heading and intro', () => { + renderSection(1) + + expect(screen.getByRole('heading', { name: en.title })).toBeTruthy() + expect(screen.getByText(en.intro)).toBeTruthy() + }) +}) + +describe('BashCard', () => { + it('renders nothing while its namespace is unavailable', () => { + const { container } = render(
    ) + renderBash({ available: false }) + + expect(container.textContent).toBe('') + expect(screen.queryByText(en.bashTitle)).toBeNull() + }) + + it('shows the plugin and reveals its fields only once expanded', () => { + renderBash() + expect(screen.getByText(en.bashTitle)).toBeTruthy() + expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull() + + fireEvent.click(screen.getByText(en.bashTitle)) + + expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy() + expect(screen.getByLabelText(en.bashMaxOutputBytes)).toBeTruthy() + }) + + it('commits an edited field through its action', () => { + const actions = renderBash() + fireEvent.click(screen.getByText(en.bashTitle)) + + const input = screen.getByLabelText(en.bashTimeoutMs) + fireEvent.change(input, { target: { value: '9000' } }) + fireEvent.blur(input) + + expect(actions.setTimeoutMs).toHaveBeenCalledWith(9_000) + }) + + it('offers the reset for an overridden field only', () => { + const actions = renderBash({ timeoutMs: { value: 9_000, overridden: true } }) + fireEvent.click(screen.getByText(en.bashTitle)) + + // One badge and one reset: the output cap is still inherited. + expect(screen.getAllByText(en.overridden)).toHaveLength(1) + fireEvent.click(screen.getByRole('button', { name: en.reset })) + + expect(actions.resetTimeoutMs).toHaveBeenCalledOnce() + }) + + it('says the document is read-only and disables its controls', () => { + renderBash({ writable: false }) + fireEvent.click(screen.getByText(en.bashTitle)) + + expect(screen.getByRole('status')).toHaveProperty('textContent', en.readOnly) + expect(screen.getByLabelText(en.bashTimeoutMs)).toHaveProperty('disabled', true) + }) +}) + +describe('AgentLoopCard', () => { + it('edits the only field it owns', () => { + const store = createSnapshotStore({ + available: true, + writable: true, + maxParallelToolCalls: { value: 10, overridden: false }, + }) + const setMaxParallelToolCalls = vi.fn() + const props = { + t, + useAgentLoopCard: bindSnapshotSelector(store), + setMaxParallelToolCalls, + resetMaxParallelToolCalls: vi.fn(), + } as unknown as AgentLoopCardProps + render() + + fireEvent.click(screen.getByText(en.agentLoopTitle)) + const input = screen.getByLabelText(en.agentLoopMaxParallel) + fireEvent.change(input, { target: { value: '2' } }) + fireEvent.blur(input) + + expect(setMaxParallelToolCalls).toHaveBeenCalledWith(2) + }) +}) + +describe('WebSearchCard', () => { + function renderWebSearch(state: Partial = {}) { + const store = createSnapshotStore({ + available: true, + writable: true, + baseURL: { value: '', overridden: false }, + maxUses: { value: 5, overridden: false }, + apiKeyRef: 'DEEPSEEK_API_KEY', + apiKeyConfigured: false, + ...state, + }) + const actions = { + setBaseUrl: vi.fn(), + resetBaseUrl: vi.fn(), + setMaxUses: vi.fn(), + resetMaxUses: vi.fn(), + setApiKey: vi.fn(), + } + const props = { + ...actions, + t, + useWebSearchCard: bindSnapshotSelector(store), + } as unknown as WebSearchCardProps + render() + return actions + } + + it('reports whether a key is configured without ever showing one', () => { + renderWebSearch({ apiKeyConfigured: true }) + fireEvent.click(screen.getByText(en.webSearchTitle)) + + expect(screen.getByText(en.webSearchApiKeySet)).toBeTruthy() + expect(screen.getByLabelText(en.webSearchApiKey)).toHaveProperty('type', 'password') + }) + + it('keeps the key control usable while the settings document is read-only', () => { + const actions = renderWebSearch({ writable: false }) + fireEvent.click(screen.getByText(en.webSearchTitle)) + + const key = screen.getByLabelText(en.webSearchApiKey) + expect(key).toHaveProperty('disabled', false) + expect(screen.getByLabelText(en.webSearchBaseUrl)).toHaveProperty('disabled', true) + + fireEvent.change(key, { target: { value: 'ds-secret' } }) + fireEvent.blur(key) + + expect(actions.setApiKey).toHaveBeenCalledWith('ds-secret') + }) + + it('commits the endpoint and the search budget', () => { + const actions = renderWebSearch() + fireEvent.click(screen.getByText(en.webSearchTitle)) + + const endpoint = screen.getByLabelText(en.webSearchBaseUrl) + fireEvent.change(endpoint, { target: { value: 'https://search.test/v1' } }) + fireEvent.blur(endpoint) + const budget = screen.getByLabelText(en.webSearchMaxUses) + fireEvent.change(budget, { target: { value: '3' } }) + fireEvent.blur(budget) + + expect(actions.setBaseUrl).toHaveBeenCalledWith('https://search.test/v1') + expect(actions.setMaxUses).toHaveBeenCalledWith(3) + }) +}) diff --git a/packages/client/ui-plugin-config/tests/stores.spec.ts b/packages/client/ui-plugin-config/tests/stores.spec.ts new file mode 100644 index 0000000000..4797d3c262 --- /dev/null +++ b/packages/client/ui-plugin-config/tests/stores.spec.ts @@ -0,0 +1,193 @@ +/** + * Card controllers: how a scope snapshot becomes card state, and which wire + * call each action reaches. + */ + +import { describe, expect, it, vi } from 'vitest' +import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-store.ts' +import { BashCardController, type BashSettings } from '../src/client/bash-store.ts' +import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-store.ts' + +function credentialsApi(configured: boolean) { + const describe = vi.fn(() => Promise.resolve({ + rpcId: 'c-1' as never, + result: { ok: true as const, value: { credentials: { DEEPSEEK_API_KEY: { configured, writable: true } } } }, + })) + const set = vi.fn(() => Promise.resolve({ rpcId: 'c-2' as never, result: { ok: true as const, value: {} } })) + return { api: { credentials: { describe, set } } as never, describe, set } +} + +describe('BashCardController', () => { + it('publishes the effective value and marks only user-layer fields overridden', () => { + const host = stubSettingsScope() + const controller = new BashCardController(host.scope) + + host.publish({ + status: 'ready', + writable: true, + revision: 3, + value: { timeoutMs: 5_000, maxOutputBytes: 64_000 }, + base: { timeoutMs: 60_000, maxOutputBytes: 64_000 }, + user: { timeoutMs: 5_000 }, + }) + + expect(controller.store.getSnapshot()).toMatchObject({ + available: true, + writable: true, + timeoutMs: { value: 5_000, overridden: true }, + maxOutputBytes: { value: 64_000, overridden: false }, + }) + }) + + it('treats an override equal to the composition default as an override', () => { + const host = stubSettingsScope() + const controller = new BashCardController(host.scope) + + host.publish({ + status: 'ready', + writable: true, + value: { timeoutMs: 60_000 }, + base: { timeoutMs: 60_000 }, + user: { timeoutMs: 60_000 }, + }) + + expect(controller.store.getSnapshot().timeoutMs).toEqual({ value: 60_000, overridden: true }) + }) + + it('routes each action to its field write', async () => { + const host = stubSettingsScope() + const controller = new BashCardController(host.scope) + host.publish({ status: 'ready', writable: true, value: { timeoutMs: 5_000 } }) + const actions = controller.inject() + + actions.setTimeoutMs(9_000) + actions.resetTimeoutMs() + actions.setMaxOutputBytes(1_024) + actions.resetMaxOutputBytes() + await Promise.resolve() + + expect(host.set.mock.calls).toEqual([['timeoutMs', 9_000], ['maxOutputBytes', 1_024]]) + expect(host.unset.mock.calls).toEqual([['timeoutMs'], ['maxOutputBytes']]) + }) + + it('stays unavailable while the namespace is not served', () => { + const host = stubSettingsScope() + const controller = new BashCardController(host.scope) + + host.publish({ status: 'unavailable' }) + + expect(controller.store.getSnapshot().available).toBe(false) + }) +}) + +describe('AgentLoopCardController', () => { + it('publishes the cap and routes its two actions', async () => { + const host = stubSettingsScope() + const controller = new AgentLoopCardController(host.scope) + host.publish({ + status: 'ready', + writable: true, + value: { maxParallelToolCalls: 2 }, + base: { maxParallelToolCalls: 10 }, + user: { maxParallelToolCalls: 2 }, + }) + expect(controller.store.getSnapshot().maxParallelToolCalls).toEqual({ value: 2, overridden: true }) + + const actions = controller.inject() + actions.setMaxParallelToolCalls(4) + actions.resetMaxParallelToolCalls() + await Promise.resolve() + + expect(host.set).toHaveBeenCalledWith('maxParallelToolCalls', 4) + expect(host.unset).toHaveBeenCalledWith('maxParallelToolCalls') + }) + + it('reports a read-only document so the card can disable its controls', () => { + const host = stubSettingsScope() + const controller = new AgentLoopCardController(host.scope) + + host.publish({ status: 'ready', writable: false, value: { maxParallelToolCalls: 10 } }) + + expect(controller.store.getSnapshot().writable).toBe(false) + }) +}) + +describe('WebSearchCardController', () => { + it('reads the credential state for the reference the section names', async () => { + const host = stubSettingsScope() + const credentials = credentialsApi(true) + const controller = new WebSearchCardController(host.scope, credentials.api) + await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() }) + + host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' } }) + await vi.waitFor(() => { + expect(controller.store.getSnapshot().apiKeyConfigured).toBe(true) + }) + + expect(controller.store.getSnapshot()).toMatchObject({ + baseURL: { value: 'https://search.test/v1', overridden: false }, + apiKeyRef: 'DEEPSEEK_API_KEY', + }) + }) + + it('writes the key through the credentials domain, never the settings section', async () => { + const host = stubSettingsScope() + const credentials = credentialsApi(false) + const controller = new WebSearchCardController(host.scope, credentials.api) + host.publish({ status: 'ready', writable: true, value: {} }) + + controller.inject().setApiKey('ds-secret') + await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() }) + + expect(credentials.set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'ds-secret' }) + expect(host.set).not.toHaveBeenCalledWith('apiKey', expect.anything()) + }) + + it('addresses the reference the section declares rather than the default', async () => { + const host = stubSettingsScope() + const credentials = credentialsApi(false) + const controller = new WebSearchCardController(host.scope, credentials.api) + host.publish({ status: 'ready', writable: true, value: { apiKeyEnv: 'SEARCH_KEY' } }) + + controller.inject().setApiKey('ds-secret') + await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() }) + + expect(credentials.set).toHaveBeenCalledWith({ ref: 'SEARCH_KEY', value: 'ds-secret' }) + }) + + it('keeps the card usable when the credential read fails', async () => { + const host = stubSettingsScope() + const describe = vi.fn(() => Promise.reject(new Error('offline'))) + const controller = new WebSearchCardController( + host.scope, + { credentials: { describe, set: vi.fn() } } as never, + ) + await vi.waitFor(() => { expect(describe).toHaveBeenCalled() }) + + host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' } }) + + expect(controller.store.getSnapshot()).toMatchObject({ + available: true, + apiKeyConfigured: false, + baseURL: { value: 'https://search.test/v1' }, + }) + }) + + it('routes the endpoint and budget actions to their field writes', async () => { + const host = stubSettingsScope() + const credentials = credentialsApi(true) + const controller = new WebSearchCardController(host.scope, credentials.api) + host.publish({ status: 'ready', writable: true, value: {} }) + const actions = controller.inject() + + actions.setBaseUrl('https://other.test') + actions.resetBaseUrl() + actions.setMaxUses(3) + actions.resetMaxUses() + await Promise.resolve() + + expect(host.set.mock.calls).toEqual([['baseURL', 'https://other.test'], ['maxUses', 3]]) + expect(host.unset.mock.calls).toEqual([['baseURL'], ['maxUses']]) + }) +}) diff --git a/packages/client/ui-plugin-config/tsconfig.json b/packages/client/ui-plugin-config/tsconfig.json new file mode 100644 index 0000000000..584069fb86 --- /dev/null +++ b/packages/client/ui-plugin-config/tsconfig.json @@ -0,0 +1,42 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../connection" + }, + { + "path": "../locale" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../runtime" + }, + { + "path": "../test-runtime" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-settings" + }, + { + "path": "../ui-slots" + }, + { + "path": "../web-react" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-plugin-config/tsdown.config.ts b/packages/client/ui-plugin-config/tsdown.config.ts new file mode 100644 index 0000000000..5cda1844ab --- /dev/null +++ b/packages/client/ui-plugin-config/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-plugin-config', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 142fceb77d..4eb7296d5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1508,6 +1508,9 @@ importers: '@deepseek-ai/dsh-client-ui-plan': specifier: workspace:^ version: link:../../client/ui-plan + '@deepseek-ai/dsh-client-ui-plugin-config': + specifier: workspace:^ + version: link:../../client/ui-plugin-config '@deepseek-ai/dsh-client-ui-question': specifier: workspace:^ version: link:../../client/ui-question @@ -2287,6 +2290,45 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-plugin-config: + devDependencies: + '@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 + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-client-web-react': + specifier: workspace:^ + version: link:../web-react + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/client/ui-primitives: dependencies: '@shikijs/langs': diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 1b39447215..b7acca27fa 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -77,6 +77,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-model': { kind: 'indirect', reason: 'Selection routes session.selectModel; the Host snapshots the selection at the next prompt-assembly boundary and owns the model-visible effect.' }, 'packages/client/ui-goal': { kind: 'indirect', reason: 'The strip verbs route goal.* mutations; the host GoalService owns the model-visible goal/change context message.' }, 'packages/client/ui-permission': { kind: 'indirect', reason: 'The picker submits the host /permission command; the knob events it appends own the model-visible effect through the sandbox/approval consumers.' }, + 'packages/client/ui-plugin-config': { kind: 'none', reason: 'Browser-side settings surface; registers no model surface.' }, 'packages/client/ui-plan': { kind: 'indirect', reason: 'The chip dispatches /plan off; dsh-plan-mode owns the model-visible policy, exit tool, and logged state.' }, 'packages/client/ui-question': { kind: 'indirect', reason: 'The package mounts dsh-tool-ask-user; that tool owns the model-visible schema and answer rendering.' }, 'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 0523b378d9..ee1a7e4e9d 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -177,6 +177,7 @@ "@deepseek-ai/dsh-client-ui-permission": ["./packages/client/ui-permission/src"], "@deepseek-ai/dsh-client-ui-skill": ["./packages/client/ui-skill/src"], "@deepseek-ai/dsh-client-ui-subagent": ["./packages/client/ui-subagent/src"], + "@deepseek-ai/dsh-client-ui-plugin-config": ["./packages/client/ui-plugin-config/src"], "@deepseek-ai/dsh-client-ui-plan": ["./packages/client/ui-plan/src"], "@deepseek-ai/dsh-client-ui-question": ["./packages/client/ui-question/src"], "@deepseek-ai/dsh-client-ui-trajectory": ["./packages/client/ui-trajectory/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 632f6a84a7..2deaabd877 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -71,6 +71,7 @@ { "path": "./packages/client/ui-agent-preset" }, { "path": "./packages/client/ui-permission" }, { "path": "./packages/client/ui-plan" }, + { "path": "./packages/client/ui-plugin-config" }, { "path": "./packages/client/ui-question" }, { "path": "./packages/client/ui-trajectory" }, { "path": "./packages/client/ui-theme" },