feat(client): configure host-plane plugins from a settings section

The section knows no namespace: it declares `settings.plugin.item` and
renders whatever cards were registered into it, so a plugin that ships a
browser half owns its card and its controls. The three cards here cover the
host-plane sections this deployment exposes.

A field shows its effective value and, when the raw user layer carries it, an
override badge and a reset that clears it back to the composition layer.
Controls commit on blur and Enter rather than per keystroke, which would burn
namespace revisions and race its own reads. The search key is the one value
that never rides a response: the card reports only whether one is configured
and writes it through the credentials domain, addressed by the reference the
section names.

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 a
disabled card the user cannot act on.
This commit is contained in:
Yichen Jiang
2026-08-10 19:14:59 +08:00
parent 8a3c5daad7
commit f8555b5561
45 changed files with 2445 additions and 8 deletions

View File

@@ -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<AgentLoopCardState>
}
/** 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<AgentLoopCardInjected>
/**
* 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 (
<PluginCard
title={t('agentLoopTitle')}
description={t('agentLoopDescription')}
available={state.available}
open={open}
onToggle={() => { setOpen(!open) }}
readOnly={disabled}
readOnlyLabel={t('readOnly')}
>
<NumberField
id="plugin-config-agent-loop-parallel"
label={t('agentLoopMaxParallel')}
hint={t('agentLoopMaxParallelHint')}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
overridden={state.maxParallelToolCalls.overridden}
disabled={disabled}
value={state.maxParallelToolCalls.value}
onCommit={props.setMaxParallelToolCalls}
onReset={props.resetMaxParallelToolCalls}
/>
</PluginCard>
)
}

View File

@@ -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<BashCardState>
}
/** 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<BashCardInjected>
/**
* 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 (
<PluginCard
title={t('bashTitle')}
description={t('bashDescription')}
available={state.available}
open={open}
onToggle={() => { setOpen(!open) }}
readOnly={disabled}
readOnlyLabel={t('readOnly')}
>
<NumberField
id="plugin-config-bash-timeout"
label={t('bashTimeoutMs')}
hint={t('bashTimeoutMsHint')}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
overridden={state.timeoutMs.overridden}
disabled={disabled}
value={state.timeoutMs.value}
onCommit={props.setTimeoutMs}
onReset={props.resetTimeoutMs}
/>
<NumberField
id="plugin-config-bash-output"
label={t('bashMaxOutputBytes')}
hint={t('bashMaxOutputBytesHint')}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
overridden={state.maxOutputBytes.overridden}
disabled={disabled}
value={state.maxOutputBytes.value}
onCommit={props.setMaxOutputBytes}
onReset={props.resetMaxOutputBytes}
/>
</PluginCard>
)
}

View File

@@ -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);
}

View File

@@ -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 (
<li className={css.card}>
<DisclosureRow
icon={null}
title={props.title}
open={props.open}
expandable
expandOnRowClick
onToggle={props.onToggle}
rowClassName={css.row}
titleClassName={css.title}
collapsedContent={<span className={css.description}>{props.description}</span>}
>
<div className={css.body}>
{props.readOnly && props.readOnlyLabel !== undefined
? <p className={css.readOnly} role="status">{props.readOnlyLabel}</p>
: null}
{props.children}
</div>
</DisclosureRow>
</li>
)
}

View File

@@ -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);
}

View File

@@ -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<PluginConfigSectionInjected>
/**
* 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 (
<div className={css.section}>
<h2 className={css.heading}>{t('title')}</h2>
<p className={css.intro}>{t('intro')}</p>
{cardCount === 0
? <p className={css.empty}>{t('empty')}</p>
: <ul className={css.cards}>{renderSlot('settings.plugin.item', {})}</ul>}
</div>
)
}
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** Plugin configuration section and card copy. */
'settings.pluginConfig': PluginConfigKey
}
}

View File

@@ -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<WebSearchCardState>
}
/** 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<WebSearchCardInjected>
/**
* 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 (
<PluginCard
title={t('webSearchTitle')}
description={t('webSearchDescription')}
available={state.available}
open={open}
onToggle={() => { setOpen(!open) }}
readOnly={disabled}
readOnlyLabel={t('readOnly')}
>
<SecretField
id="plugin-config-web-search-key"
label={t('webSearchApiKey')}
hint={t('webSearchApiKeyHint')}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
// The credentials domain accepts a key even when the settings document
// itself is read-only; they are separate stores with separate refusals.
disabled={false}
configured={state.apiKeyConfigured}
stateLabel={state.apiKeyConfigured ? t('webSearchApiKeySet') : t('webSearchApiKeyUnset')}
onCommit={props.setApiKey}
/>
<TextField
id="plugin-config-web-search-endpoint"
label={t('webSearchBaseUrl')}
hint={t('webSearchBaseUrlHint')}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
overridden={state.baseURL.overridden}
disabled={disabled}
value={state.baseURL.value}
onCommit={props.setBaseUrl}
onReset={props.resetBaseUrl}
/>
<NumberField
id="plugin-config-web-search-max-uses"
label={t('webSearchMaxUses')}
hint={t('webSearchMaxUsesHint')}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
overridden={state.maxUses.overridden}
disabled={disabled}
value={state.maxUses.value}
onCommit={props.setMaxUses}
onReset={props.resetMaxUses}
/>
</PluginCard>
)
}

View File

@@ -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<number>
}
/** 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<AgentLoopCardState>
}
/** 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<AgentLoopSettings, AgentLoopCardState> {
/** @param scope - the bound settings scope for the `agent-loop` namespace. */
constructor(scope: SettingsScope<AgentLoopSettings>) {
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') },
}
}
}

View File

@@ -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<number>
/** Per-stream output cap in bytes. */
maxOutputBytes: CardField<number>
}
/** 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<BashCardState>
}
/** 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<BashSettings, BashCardState> {
/** @param scope - the bound settings scope for the `bash` namespace. */
constructor(scope: SettingsScope<BashSettings>) {
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') },
}
}
}

View File

@@ -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<V> {
/** 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<T, V>(
snapshot: SettingsScopeSnapshot<T>,
field: string,
fallback: V,
): CardField<V> {
const section = snapshot.value as Record<string, unknown> | undefined
const user = snapshot.user as Record<string, unknown> | 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<T>(snapshot: SettingsScopeSnapshot<T>): 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<T, S> {
/** Snapshot the card's component reads through its bound selector. */
readonly store: SnapshotStore<S>
/**
* @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<T>,
private readonly project: (snapshot: SettingsScopeSnapshot<T>) => S,
) {
this.store = createSnapshotStore(project(scope.getSnapshot()))
scope.subscribe(() => {
this.store.set(this.project(this.scope.getSnapshot()))
})
}
}

View File

@@ -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);
}

View File

@@ -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 (
<div className={css.field}>
<div className={css.head}>
<label className={css.label} htmlFor={props.id}>{props.label}</label>
{props.overridden
? (
<span className={css.badges}>
<span className={css.badge}>{props.overriddenLabel}</span>
<button
type="button"
className={css.reset}
disabled={props.disabled}
onClick={props.onReset}
>
{props.resetLabel}
</button>
</span>
)
: null}
</div>
{props.children}
<p className={css.hint}>{props.hint}</p>
</div>
)
}
/**
* 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<HTMLInputElement>) => {
if (event.key === 'Enter') event.currentTarget.blur()
}
return (
<FieldFrame {...props}>
<input
id={props.id}
className={css.input}
type="text"
inputMode="numeric"
value={draft}
disabled={props.disabled}
onChange={(event) => { setDraft(event.target.value) }}
onBlur={commit}
onKeyDown={onKeyDown}
/>
</FieldFrame>
)
}
/** 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<HTMLInputElement>) => {
if (event.key === 'Enter') event.currentTarget.blur()
}
return (
<FieldFrame {...props}>
<input
id={props.id}
className={css.input}
type="text"
value={draft}
placeholder={props.placeholder ?? ''}
disabled={props.disabled}
onChange={(event) => { setDraft(event.target.value) }}
onBlur={commit}
onKeyDown={onKeyDown}
/>
</FieldFrame>
)
}
/**
* 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<FieldProps, 'overridden' | 'onReset'> & {
/** 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<HTMLInputElement>) => {
if (event.key === 'Enter') event.currentTarget.blur()
}
return (
<div className={css.field}>
<div className={css.head}>
<label className={css.label} htmlFor={props.id}>{props.label}</label>
<span className={css.badges}>
<span className={props.configured ? css.badge : css.badgeMuted}>{props.stateLabel}</span>
</span>
</div>
<input
id={props.id}
className={css.input}
type="password"
autoComplete="off"
value={draft}
disabled={props.disabled}
onChange={(event) => { setDraft(event.target.value) }}
onBlur={commit}
onKeyDown={onKeyDown}
/>
<p className={css.hint}>{props.hint}</p>
</div>
)
}

View File

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

View File

@@ -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<PluginConfigKey, string> = {
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<PluginConfigKey, string> = {
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: '一次请求在必须作答前最多可以搜索多少次。',
}

View File

@@ -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
}

View File

@@ -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<string>
/** Searches allowed per request. */
maxUses: CardField<number>
/** 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<WebSearchCardState>
}
/** 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<WebSearchSettings, WebSearchCardState> {
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<WebSearchSettings>, private readonly api: Pick<IApiClient, 'credentials'>) {
// 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<void> {
const ref = refOf(this.scope.getSnapshot())
let response: Awaited<ReturnType<IApiClient['credentials']['describe']>>
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<void> {
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<WebSearchSettings>): string {
const section = snapshot.value
const declared = section?.apiKeyEnv
return declared !== undefined && declared.length > 0 ? declared : DEFAULT_API_KEY_REF
}

View File

@@ -0,0 +1,4 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}

View File

@@ -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 {}

View File

@@ -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 */