refactor: apply repository naming contract

Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
Tianyi Cui
2026-08-13 00:36:22 +08:00
parent 101df7cf58
commit a2d0f7f411
3281 changed files with 21730 additions and 21592 deletions

View File

@@ -0,0 +1,47 @@
/** The agent loop's card: how many tool calls one step may run at once. */
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { ValueField } from './fields.tsx'
import { PluginCard } from './PluginCard.tsx'
import type { AgentLoopCardFace } from './agent-loop-card-controller.ts'
import type {} from './slot-contract.ts'
/** Props the renderer binds for the agent-loop card. */
export type AgentLoopCardProps =
PropsRuntime<'settings.plugin.item'>
& PropsLocale<'settings.plugins'>
& InjectFace<AgentLoopCardFace>
/**
* Render the agent-loop card.
* @param props - locale copy, the card snapshot, and its form actions.
* @returns the card.
*/
export function AgentLoopCard(props: AgentLoopCardProps) {
const { t } = props
const state = props.useAgentLoopCard(snapshot => snapshot)
return (
<PluginCard
t={t}
titleKey="agentLoopTitle"
descriptionKey="agentLoopDescription"
state={state}
onSave={props.save}
onDiscard={props.discard}
>
<ValueField
id="plugin-config-agent-loop-parallel"
label={t('agentLoopMaxParallel')}
hint={t('agentLoopMaxParallelHint')}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
invalidLabel={t('invalidNumber')}
numeric
disabled={!state.writable}
{...state.maxParallelToolCalls}
onEdit={(text) => { props.edit('maxParallelToolCalls', text) }}
onReset={() => { props.resetField('maxParallelToolCalls') }}
/>
</PluginCard>
)
}

View File

@@ -0,0 +1,61 @@
/** The shell plugin's card: the limits every command the agent runs is bound by. */
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { ValueField } from './fields.tsx'
import { PluginCard } from './PluginCard.tsx'
import type { BashCardFace } from './bash-card-controller.ts'
import type {} from './slot-contract.ts'
/** Props the renderer binds for the shell card. */
export type BashCardProps =
PropsRuntime<'settings.plugin.item'>
& PropsLocale<'settings.plugins'>
& InjectFace<BashCardFace>
/**
* Render the shell card.
* @param props - locale copy, the card snapshot, and its form actions.
* @returns the card.
*/
export function BashCard(props: BashCardProps) {
const { t } = props
const state = props.useBashCard(snapshot => snapshot)
const disabled = !state.writable
return (
<PluginCard
t={t}
titleKey="bashTitle"
descriptionKey="bashDescription"
state={state}
onSave={props.save}
onDiscard={props.discard}
>
<ValueField
id="plugin-config-bash-timeout"
label={t('bashTimeoutMs')}
hint={t('bashTimeoutMsHint')}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
invalidLabel={t('invalidNumber')}
numeric
disabled={disabled}
{...state.timeoutMs}
onEdit={(text) => { props.edit('timeoutMs', text) }}
onReset={() => { props.resetField('timeoutMs') }}
/>
<ValueField
id="plugin-config-bash-output"
label={t('bashMaxOutputBytes')}
hint={t('bashMaxOutputBytesHint')}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
invalidLabel={t('invalidNumber')}
numeric
disabled={disabled}
{...state.maxOutputBytes}
onEdit={(text) => { props.edit('maxOutputBytes', text) }}
onReset={() => { props.resetField('maxOutputBytes') }}
/>
</PluginCard>
)
}

View File

@@ -0,0 +1,25 @@
/** Configurable Host plugins contributed to the shared Plugins section. */
import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from './slot-contract.ts'
import css from './PluginsSettingsSection.module.css'
/** Registration-side business face for the configurable tab. */
export interface ConfigurablePluginsTabInjected {
/** How many cards the slot ledger held when the tab registration mounted. */
cardCount: number
}
/** Props the renderer binds for the configurable tab. */
export type ConfigurablePluginsTabProps =
PropsRuntime<'settings.plugins.tab'>
& PropsLocale<'settings.plugins'>
& PropsRenderSlots<'settings.plugin.item'>
& InjectFace<ConfigurablePluginsTabInjected>
/** Render cards registered by plugins that expose editable settings. */
export function ConfigurablePluginsTab({ t, renderSlot, cardCount }: ConfigurablePluginsTabProps) {
return cardCount === 0
? <p className={css.empty}>{t('empty')}</p>
: <ul className={css.cards}>{renderSlot('settings.plugin.item', {})}</ul>
}

View File

@@ -0,0 +1,157 @@
/* Plugin card: a header that names the plugin, disclosing its controls in place. */
.card {
list-style: none;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
background: var(--dsw-alias-bg-layer-3);
transition: border-color .16s, background .16s;
}
.card:hover {
border-color: var(--dsw-alias-label-dimmed);
}
/* An open card reads as the one being worked on, not merely taller. */
.cardOpen {
background: var(--dsw-alias-bg-layer-2);
border-color: var(--dsw-alias-label-dimmed);
}
.header {
width: 100%;
appearance: none;
border: 0;
background: none;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 12px;
}
.header:focus-visible {
outline: 2px solid var(--dsw-alias-brand-primary);
outline-offset: -2px;
}
/* Name over description: the description is what tells two plugins apart, so
it gets its own line rather than trailing the name. */
.headText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.name {
font-size: 15px;
font-weight: 600;
line-height: 1.4;
color: var(--dsw-alias-label-primary);
}
.description {
font-size: 13px;
line-height: 1.5;
color: var(--dsw-alias-label-tertiary);
}
.chevron {
flex: none;
color: var(--dsw-alias-label-tertiary);
transition: transform .16s;
}
.chevronOpen {
transform: rotate(180deg);
}
.body {
border-top: 1px solid var(--dsw-alias-border-l2);
margin: 0 16px;
padding-bottom: 8px;
}
.readOnly {
margin: 12px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--dsw-alias-label-tertiary);
}
/* Carried on the header so a collapsed card still says it holds edits. */
.pending {
flex: none;
border-radius: 999px;
padding: 1px 8px;
font-size: 11px;
line-height: 17px;
font-weight: 500;
white-space: nowrap;
background: var(--dsw-alias-bg-module-platform);
color: var(--dsw-alias-label-secondary);
}
.footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 12px 0 4px;
border-top: 1px solid var(--dsw-alias-border-l2);
}
.failed {
flex: 1;
min-width: 0;
margin: 0;
font-size: 12px;
line-height: 1.5;
color: var(--dsw-alias-label-error);
}
.discard,
.save {
appearance: none;
border: 1px solid transparent;
border-radius: 8px;
padding: 5px 14px;
font: inherit;
font-size: 13px;
line-height: 1.5;
cursor: pointer;
}
.discard {
border-color: var(--dsw-alias-border-l2);
background: none;
color: var(--dsw-alias-label-secondary);
}
.discard:hover:not(:disabled) {
color: var(--dsw-alias-label-primary);
border-color: var(--dsw-alias-label-dimmed);
}
.save {
background: var(--dsw-alias-label-primary);
color: var(--dsw-alias-bg-layer-3);
}
.discard:disabled,
.save:disabled {
opacity: 0.4;
cursor: default;
}
.discard:focus-visible,
.save:focus-visible {
outline: 2px solid var(--dsw-alias-brand-primary);
outline-offset: 1px;
}

View File

@@ -0,0 +1,98 @@
/**
* One plugin's card: a header naming the plugin and what its settings govern,
* disclosing that plugin's controls in place, with the save that writes them.
*
* The header is its own button rather than a shared disclosure row because a
* card stacks its name over its description, while that row lays the two side
* by side — the layout, not the behavior, is what differs. Disclosure is
* card-local state: which card a user has open is a reading gesture, not
* something the Host or the section has any stake in. Staged edits outlive
* collapsing, so the header marks a card holding unsaved edits.
*
* 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.
*/
import { useState, type ReactNode } from 'react'
import clsx from 'clsx'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { CardShell } from './card-form.ts'
import type { PluginsSettingsLocaleKey } from './locales.ts'
import css from './PluginCard.module.css'
/** Card chrome shared by every plugin section. */
export interface PluginCardProps {
/** Locale reader for this section's copy. */
t: (key: PluginsSettingsLocaleKey) => string
/** Locale key of the plugin's name. */
titleKey: PluginsSettingsLocaleKey
/** Locale key of the line describing what this plugin's settings govern. */
descriptionKey: PluginsSettingsLocaleKey
/** The card's form state: availability, writability, and what a save would do. */
state: CardShell
/** Write every staged edit. */
onSave: () => void
/** Drop every staged edit. */
onDiscard: () => void
/** The plugin's controls. */
children: ReactNode
}
/**
* Render one plugin card.
* @param props - the plugin's copy keys, its form state, and its controls.
* @returns the card, or nothing when the namespace is unavailable.
*/
export function PluginCard(props: PluginCardProps) {
const [open, setOpen] = useState(false)
const { state } = props
if (!state.available) return null
const title = props.t(props.titleKey)
const blocked = !state.dirty || state.invalid || state.saving
return (
<li className={clsx(css.card, open && css.cardOpen)}>
<button
type="button"
className={css.header}
aria-expanded={open}
aria-label={`${props.t(open ? 'collapse' : 'expand')}: ${title}`}
onClick={() => { setOpen(!open) }}
>
<span className={css.headText}>
<span className={css.name}>{title}</span>
<span className={css.description}>{props.t(props.descriptionKey)}</span>
</span>
{state.dirty ? <span className={css.pending}>{props.t('unsaved')}</span> : null}
<IconChevronDownOutline14 className={clsx(css.chevron, open && css.chevronOpen)} />
</button>
{open
? (
<div className={css.body}>
{!state.writable ? <p className={css.readOnly} role="status">{props.t('readOnly')}</p> : null}
{props.children}
<div className={css.footer}>
{state.failed ? <p className={css.failed} role="status">{props.t('saveFailed')}</p> : null}
<button
type="button"
className={css.discard}
disabled={!state.dirty || state.saving}
onClick={props.onDiscard}
>
{props.t('discard')}
</button>
<button
type="button"
className={css.save}
disabled={blocked}
onClick={props.onSave}
>
{props.t(state.saving ? 'saving' : 'save')}
</button>
</div>
</div>
)
: null}
</li>
)
}

View File

@@ -0,0 +1,85 @@
/* Plugins section: compact tabs plus the configurable plugin card list. */
.section {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 760px;
color: var(--dsw-alias-label-primary);
}
.heading {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.intro {
margin: 0;
font-size: 13px;
color: var(--dsw-alias-label-tertiary);
}
.tabs {
display: flex;
align-items: flex-end;
gap: 22px;
border-bottom: 1px solid var(--dsw-alias-border-l2);
margin-top: 2px;
}
.tab {
position: relative;
border: 0;
padding: 7px 1px 9px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
font: inherit;
font-size: 13px;
line-height: 20px;
cursor: pointer;
}
.tab:hover,
.tab[data-active='true'] {
color: var(--dsw-alias-label-primary);
}
.tab[data-active='true']::after,
.tab:focus-visible::after {
position: absolute;
right: 0;
bottom: -1px;
left: 0;
height: 2px;
border-radius: 2px 2px 0 0;
background: var(--dsw-alias-label-primary);
content: '';
}
.tab:focus-visible {
outline: 2px solid var(--dsw-alias-state-business-primary);
outline-offset: 2px;
border-radius: 2px;
color: var(--dsw-alias-label-primary);
}
.panel {
min-width: 0;
padding-top: 2px;
}
.cards {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.empty {
margin: 0;
font-size: 13px;
color: var(--dsw-alias-label-tertiary);
}

View File

@@ -0,0 +1,123 @@
/** Plugins settings section: localized tabs around feature-owned pages. */
import { useEffect, useId, useRef, useState } from 'react'
import type {
HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { PluginsSettingsLocaleKey } from './locales.ts'
import css from './PluginsSettingsSection.module.css'
/** One tab projected from a `settings.plugins.tab` contribution. */
export interface PluginsSettingsTabEntry {
id: string
order: number
label: string
}
/** Registration-side business face for the section. */
export interface PluginsSettingsSectionInjected {
hooks: {
/** Ordered, locale-aware projection of the Plugins tab ledger. */
tabs: HostObservable<readonly PluginsSettingsTabEntry[]>
}
}
/** Props the renderer binds for the section. */
export type PluginsSettingsSectionProps =
PropsRuntime<'settings.section'>
& PropsLocale<'settings.plugins'>
& PropsRenderSlots<'settings.plugins.tab'>
& InjectFace<PluginsSettingsSectionInjected>
/** Render one Plugins page whose contents arrive from feature-owned tabs. */
export function PluginsSettingsSection({ t, renderSlot, useTabs }: PluginsSettingsSectionProps) {
const tabsId = useId()
const tabRefs = useRef<Array<HTMLButtonElement | null>>([])
const rows = useTabs(value => value)
const [activeId, setActiveId] = useState<string>()
const [visitedIds, setVisitedIds] = useState<ReadonlySet<string>>(() => new Set())
const active = rows.find(row => row.id === activeId)?.id ?? rows[0]?.id
// A tab mounts only when first selected, then stays mounted while hidden so
// local drafts, disclosure state, search, and the inventory snapshot survive
// switching between the two views.
useEffect(() => {
if (active === undefined) return
setVisitedIds((previous) => {
if (previous.has(active)) return previous
return new Set([...previous, active])
})
}, [active])
return (
<div className={css.section}>
<h2 className={css.heading}>{t('title')}</h2>
<p className={css.intro}>{t('intro')}</p>
{rows.length === 0 ? <p className={css.empty}>{t('empty')}</p> : (
<>
<div className={css.tabs} role="tablist" aria-label={t('tabs')}>
{rows.map((row, index) => {
const selected = row.id === active
return (
<button
key={row.id}
ref={(element) => { tabRefs.current[index] = element }}
id={`${tabsId}-tab-${row.id}`}
type="button"
role="tab"
className={css.tab}
aria-selected={selected}
aria-controls={`${tabsId}-panel-${row.id}`}
data-active={selected ? 'true' : undefined}
tabIndex={selected ? 0 : -1}
onClick={() => { setActiveId(row.id) }}
onKeyDown={(event) => {
let nextIndex: number
switch (event.key) {
case 'ArrowRight': nextIndex = (index + 1) % rows.length; break
case 'ArrowLeft': nextIndex = (index - 1 + rows.length) % rows.length; break
case 'Home': nextIndex = 0; break
case 'End': nextIndex = rows.length - 1; break
default: return
}
event.preventDefault()
const nextRow = rows[nextIndex] as PluginsSettingsTabEntry
const nextTab = tabRefs.current[nextIndex] as HTMLButtonElement
setActiveId(nextRow.id)
nextTab.focus()
}}
>
{row.label}
</button>
)
})}
</div>
{rows
.filter(row => row.id === active || visitedIds.has(row.id))
.map((row) => {
const selected = row.id === active
return (
<div
key={row.id}
id={`${tabsId}-panel-${row.id}`}
className={css.panel}
role="tabpanel"
aria-labelledby={`${tabsId}-tab-${row.id}`}
hidden={!selected}
>
{renderSlot('settings.plugins.tab', {}, { only: row.id })}
</div>
)
})}
</>
)}
</div>
)
}
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** Plugins section, configurable-tab, and card copy. */
'settings.plugins': PluginsSettingsLocaleKey
}
}

View File

@@ -0,0 +1,78 @@
/**
* 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 type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { SecretField, ValueField } from './fields.tsx'
import { PluginCard } from './PluginCard.tsx'
import type { WebSearchCardFace } from './web-search-card-controller.ts'
import type {} from './slot-contract.ts'
/** Props the renderer binds for the web-search card. */
export type WebSearchCardProps =
PropsRuntime<'settings.plugin.item'>
& PropsLocale<'settings.plugins'>
& InjectFace<WebSearchCardFace>
/**
* Render the web-search card.
* @param props - locale copy, the card snapshot, and its form actions.
* @returns the card.
*/
export function WebSearchCard(props: WebSearchCardProps) {
const { t } = props
const state = props.useWebSearchCard(snapshot => snapshot)
const disabled = !state.writable
return (
<PluginCard
t={t}
titleKey="webSearchTitle"
descriptionKey="webSearchDescription"
state={state}
onSave={props.save}
onDiscard={props.discard}
>
<SecretField
id="plugin-config-web-search-key"
label={t('webSearchApiKey')}
hint={t('webSearchApiKeyHint')}
// The credentials domain accepts a key even when the settings document
// itself is read-only; they are separate stores with separate refusals.
// Its own writability is what disables this control — a key sourced
// from the process environment cannot be written from here.
disabled={!state.apiKeyWritable}
text={state.apiKey.text}
configured={state.apiKeyConfigured}
stateLabel={state.apiKeyConfigured ? t('webSearchApiKeySet') : t('webSearchApiKeyUnset')}
onEdit={(text) => { props.edit('apiKey', text) }}
/>
<ValueField
id="plugin-config-web-search-endpoint"
label={t('webSearchBaseUrl')}
hint={t('webSearchBaseUrlHint')}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
invalidLabel={t('invalidNumber')}
disabled={disabled}
{...state.baseURL}
onEdit={(text) => { props.edit('baseURL', text) }}
onReset={() => { props.resetField('baseURL') }}
/>
<ValueField
id="plugin-config-web-search-max-uses"
label={t('webSearchMaxUses')}
hint={t('webSearchMaxUsesHint')}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
invalidLabel={t('invalidNumber')}
numeric
disabled={disabled}
{...state.maxUses}
onEdit={(text) => { props.edit('maxUses', text) }}
onReset={() => { props.resetField('maxUses') }}
/>
</PluginCard>
)
}

View File

@@ -0,0 +1,57 @@
/** The agent-loop card's staged form over the `agent-loop` settings namespace. */
import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { CardForm, numberField, type CardActions, type CardFieldState, type CardShell } from './card-form.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: CardFieldState
}
/** The registration-side face the agent-loop card's slot entry injects. */
export interface AgentLoopCardFace extends CardActions {
hooks: {
/** Card snapshot bound by the renderer as useAgentLoopCard. */
agentLoopCard: SnapshotStore<AgentLoopCardState>
}
}
/** Bridges the `agent-loop` scope onto the card's staged form. */
export class AgentLoopCardController {
private readonly form: CardForm<AgentLoopSettings>
private readonly store: SnapshotStore<AgentLoopCardState>
/** @param scope - the bound settings scope for the `agent-loop` namespace. */
constructor(scope: SettingsScope<AgentLoopSettings>) {
this.form = new CardForm(scope, [numberField('maxParallelToolCalls')])
this.store = this.form.bind(() => this.projection())
}
private projection(): AgentLoopCardState {
return { ...this.form.shell(), maxParallelToolCalls: this.form.field('maxParallelToolCalls') }
}
/**
* Build the face the card's slot registration injects.
* @returns the card's snapshot and its form actions.
*/
inject(): AgentLoopCardFace {
return { hooks: { agentLoopCard: this.store }, ...this.form.actions() }
}
}

View File

@@ -0,0 +1,63 @@
/** The shell card's staged form over the `bash` settings namespace. */
import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { CardForm, numberField, type CardActions, type CardFieldState, type CardShell } from './card-form.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 SHELL_NS = 'shell'
/** 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: CardFieldState
/** Per-stream output cap in bytes. */
maxOutputBytes: CardFieldState
}
/** The registration-side face the shell card's slot entry injects. */
export interface BashCardFace extends CardActions {
hooks: {
/** Card snapshot bound by the renderer as useBashCard. */
bashCard: SnapshotStore<BashCardState>
}
}
/** Bridges the `bash` scope onto the shell card's staged form. */
export class BashCardController {
private readonly form: CardForm<BashSettings>
private readonly store: SnapshotStore<BashCardState>
/** @param scope - the bound settings scope for the `bash` namespace. */
constructor(scope: SettingsScope<BashSettings>) {
this.form = new CardForm(scope, [numberField('timeoutMs'), numberField('maxOutputBytes')])
this.store = this.form.bind(() => this.projection())
}
private projection(): BashCardState {
return {
...this.form.shell(),
timeoutMs: this.form.field('timeoutMs'),
maxOutputBytes: this.form.field('maxOutputBytes'),
}
}
/**
* Build the face the card's slot registration injects.
* @returns the card's snapshot and its form actions.
*/
inject(): BashCardFace {
return { hooks: { bashCard: this.store }, ...this.form.actions() }
}
}

View File

@@ -0,0 +1,351 @@
/**
* Shared form model behind every plugin card.
*
* A card stages what the user types and writes it only when they save. Each
* settings write is a durable, revision-fenced document mutation, so a control
* that committed as it settled turned one edit into a write the user never
* asked for and could not preview; staged text makes what is on screen exactly
* what a save would store.
*
* A field shows its effective value — the user layer over the composition
* layer over the schema default — and whether the user layer carries it. That
* presence, not a value comparison, is what marks a field overridden: an
* override equal to the composition default is still an override.
*/
import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
/** The write one field's staged text performs when the card is saved. */
export type FieldWrite =
| { kind: 'set'; value: unknown }
| { kind: 'clear' }
/** How one section field converts between its stored value and its draft text. */
export interface CardFieldSpec {
/** Field name inside the namespace section. */
field: string
/** Render a stored value as draft text; the empty string when the section carries none. */
format: (value: unknown) => string
/**
* The write this draft text stages, or undefined when the text is not a
* value this field accepts — which blocks the save rather than discarding it.
*/
parse: (text: string) => FieldWrite | undefined
}
/**
* A control whose value is written outside the settings section. A credential
* literal never rides a response, so its draft has nothing to seed from: it is
* blank until typed, and a blank draft writes nothing.
*/
export interface CardSecretSpec {
/** Field name addressing this control inside the card's form. */
field: string
/** Write the staged text; resolves to whether the Host accepted it. */
write: (text: string) => Promise<boolean>
}
/** One field as a card's control renders it. */
export interface CardFieldState {
/** Draft text the control renders. */
text: string
/**
* Whether saving would leave a user-layer entry for this field. A staged
* edit answers for itself, so the badge previews the save rather than
* reporting a state the pending edit already contradicts.
*/
overridden: boolean
/** Whether the draft is not a value this field accepts, which blocks saving. */
invalid: boolean
}
/** Form 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
/** Whether the form holds edits that a save would write. */
dirty: boolean
/** Whether any staged draft is invalid, which blocks the save. */
invalid: boolean
/** Whether a save is crossing the wire. */
saving: boolean
/** Whether the last save did not land as staged; cleared by the next edit or save. */
failed: boolean
}
/** The write actions every plugin card's slot entry injects. */
export interface CardActions {
/** Stage draft text for one field. */
edit: (field: string, text: string) => void
/** Stage a clear, so saving lets the field re-inherit the composition layer. */
resetField: (field: string) => void
/** Write every staged edit, then re-seed from what the Host accepted. */
save: () => void
/** Drop every staged edit. */
discard: () => void
}
/** One field's staged edit. */
interface StagedEdit {
/** Draft text the control renders. */
text: string
/** True when this edit clears the field whatever text it shows. */
clear: boolean
}
/** One staged edit resolved into the write a save performs. */
interface PlannedWrite {
/** Field this entry writes. */
field: string
/**
* Perform the write and report whether the Host holds the staged value
* afterwards; undefined when the draft is not a value the field accepts.
*/
run: (() => Promise<boolean>) | undefined
}
/**
* A whole-number field. An empty draft clears the field; any other draft that
* is not a finite number blocks the save.
* @param field - field name inside the namespace section.
* @returns the field's conversion spec.
*/
export function numberField(field: string): CardFieldSpec {
return {
field,
// A section that carries no number for this field renders empty rather
// than as a value nobody chose.
format: value => typeof value === 'number' ? String(value) : '',
parse: (text) => {
const trimmed = text.trim()
if (trimmed === '') return { kind: 'clear' }
const parsed = Number(trimmed)
return Number.isFinite(parsed) ? { kind: 'set', value: parsed } : undefined
},
}
}
/**
* A free-text field. An empty draft clears the field, so emptying the control
* and saving is the same gesture as resetting it.
* @param field - field name inside the namespace section.
* @returns the field's conversion spec.
*/
export function textField(field: string): CardFieldSpec {
return {
field,
format: value => typeof value === 'string' ? value : '',
parse: (text) => {
const trimmed = text.trim()
return trimmed === '' ? { kind: 'clear' } : { kind: 'set', value: trimmed }
},
}
}
/**
* Stages one card's edits over one settings namespace and writes them on save.
*
* The form publishes through a snapshot store because slot components read
* through a snapshot selector, while both the scope and the local drafts
* change underneath; every projection is rebuilt from the two together.
*/
export class CardForm<T> {
private readonly specs: Map<string, CardFieldSpec>
private readonly secretSpecs: Map<string, CardSecretSpec>
private readonly staged = new Map<string, StagedEdit>()
private readonly listeners = new Set<() => void>()
private saving = false
private failed = false
/**
* @param scope - the bound settings scope for this card's namespace.
* @param specs - the section fields this card edits.
* @param secrets - the card's write-only controls, written outside the section.
*/
constructor(
private readonly scope: SettingsScope<T>,
specs: CardFieldSpec[],
secrets: CardSecretSpec[] = [],
) {
this.specs = new Map(specs.map(spec => [spec.field, spec]))
this.secretSpecs = new Map(secrets.map(spec => [spec.field, spec]))
scope.subscribe(() => { this.publish() })
}
/**
* Publish a projection of this form, rebuilt whenever the scope or a draft changes.
* @param project - build the card's state from the form's current reads.
* @returns the store the card's component reads through its bound selector.
*/
bind<S>(project: () => S): SnapshotStore<S> {
const store = createSnapshotStore(project())
this.listeners.add(() => { store.set(project()) })
return store
}
/**
* Read the card-level state: what the Host serves, and what a save would do.
* @returns the form state every card shares.
*/
shell(): CardShell {
const snapshot = this.scope.getSnapshot()
const plan = this.plan()
return {
available: snapshot.status === 'ready',
writable: snapshot.writable,
dirty: plan.length > 0,
invalid: plan.some(item => item.run === undefined),
saving: this.saving,
failed: this.failed,
}
}
/**
* Read one control's state.
* @param field - field name of a section field or of a write-only control.
* @returns the draft text, whether a save would leave an override, and whether it is invalid.
*/
field(field: string): CardFieldState {
const staged = this.staged.get(field)
if (this.secretSpecs.has(field)) {
return { text: staged?.text ?? '', overridden: false, invalid: false }
}
const spec = this.spec(field)
if (staged === undefined) {
return { text: spec.format(this.sectionValue(field)), overridden: this.stored(field), invalid: false }
}
const write = staged.clear ? { kind: 'clear' as const } : spec.parse(staged.text)
return {
text: staged.text,
overridden: write?.kind === 'set',
invalid: write === undefined,
}
}
/**
* Build the edit, reset, save, and discard actions bound to this form.
* @returns the actions a card's slot entry injects.
*/
actions(): CardActions {
return {
edit: (field, text) => { this.stage(field, { text, clear: false }) },
resetField: (field) => {
this.stage(field, { text: this.spec(field).format(this.baseValue(field)), clear: true })
},
save: () => { void this.save() },
discard: () => {
if (this.staged.size === 0 && !this.failed) return
this.staged.clear()
this.failed = false
this.publish()
},
}
}
/**
* Write every staged edit, then re-seed from what the Host accepted.
*
* The Host is the only authority on whether a value was accepted — its
* validators own the constraints no schema can express — so the outcome is
* read back from the section rather than predicted here. A save that did not
* land keeps its drafts, so the user can correct them instead of retyping.
* @returns settlement after every write and the read-back.
*/
async save(): Promise<void> {
const plan = this.plan()
const writes = plan.flatMap(item => item.run === undefined ? [] : [item.run])
if (plan.length === 0 || this.saving || writes.length !== plan.length) return
this.saving = true
this.failed = false
this.publish()
let landed = true
for (const write of writes) {
landed = await write() && landed
}
if (landed) this.staged.clear()
this.saving = false
this.failed = !landed
this.publish()
}
/**
* Every staged edit a save would write. An entry whose draft is not a value
* its field accepts carries no write: the form is still dirty, and the save
* refuses rather than dropping the edit.
* @returns the planned writes, in the order the fields were staged.
*/
private plan(): PlannedWrite[] {
const plan: PlannedWrite[] = []
for (const [field, staged] of this.staged) {
const secret = this.secretSpecs.get(field)
if (secret !== undefined) {
const value = staged.text.trim()
if (value !== '') plan.push({ field, run: () => secret.write(value) })
continue
}
const spec = this.spec(field)
if (staged.clear) {
if (this.stored(field)) plan.push({ field, run: () => this.clear(field) })
continue
}
if (staged.text === spec.format(this.sectionValue(field))) continue
const write = spec.parse(staged.text)
if (write === undefined) plan.push({ field, run: undefined })
else if (write.kind === 'clear') plan.push({ field, run: () => this.clear(field) })
else plan.push({ field, run: () => this.store(field, write.value) })
}
return plan
}
private async clear(field: string): Promise<boolean> {
await this.scope.unset(field)
return !this.stored(field)
}
private async store(field: string, value: unknown): Promise<boolean> {
await this.scope.set(field, value)
return this.userLayer()?.[field] === value
}
private stage(field: string, edit: StagedEdit): void {
this.staged.set(field, edit)
this.failed = false
this.publish()
}
private spec(field: string): CardFieldSpec {
const spec = this.specs.get(field)
// Every call site names a field this card declared; a missing one is a
// wiring mistake that must not degrade into a silently inert control.
if (spec === undefined) throw new Error(`plugin card has no field ${field}`)
return spec
}
private snapshotOf(): SettingsScopeSnapshot<T> {
return this.scope.getSnapshot()
}
private sectionValue(field: string): unknown {
return (this.snapshotOf().value as Record<string, unknown> | undefined)?.[field]
}
private baseValue(field: string): unknown {
return (this.snapshotOf().base as Record<string, unknown> | undefined)?.[field]
}
private userLayer(): Record<string, unknown> | undefined {
return this.snapshotOf().user as Record<string, unknown> | undefined
}
private stored(field: string): boolean {
const user = this.userLayer()
return user !== undefined && Object.hasOwn(user, field)
}
private publish(): void {
for (const listener of this.listeners) listener()
}
}

View File

@@ -0,0 +1,113 @@
/* Plugin configuration fields: label, control, override badge, and hint. */
.field {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 0;
}
.field + .field {
border-top: 1px solid var(--dsw-alias-border-l2);
}
.head {
display: flex;
align-items: center;
gap: 8px;
}
.label {
flex: 1;
min-width: 0;
font-size: 13px;
font-weight: 500;
line-height: 1.5;
color: var(--dsw-alias-label-primary);
}
.badges {
display: inline-flex;
align-items: center;
gap: 8px;
}
.badge {
border-radius: 999px;
padding: 1px 8px;
font-size: 11px;
line-height: 17px;
white-space: nowrap;
font-weight: 500;
background: var(--dsw-alias-bg-module-platform);
color: var(--dsw-alias-label-secondary);
}
.badgeMuted {
border-radius: 999px;
padding: 1px 8px;
font-size: 11px;
line-height: 17px;
white-space: nowrap;
color: var(--dsw-alias-label-tertiary);
}
.reset {
border: none;
background: none;
padding: 0;
font: inherit;
font-size: 12px;
line-height: 1.5;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
.reset:hover:not(:disabled) {
color: var(--dsw-alias-label-primary);
}
.reset:disabled {
cursor: default;
}
.input {
height: 34px;
padding: 0 12px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
background: var(--dsw-alias-bg-layer-3);
font: inherit;
font-size: 13px;
line-height: 1.5;
color: var(--dsw-alias-label-primary);
}
.input:focus-visible {
outline: none;
border-color: var(--dsw-alias-brand-primary);
}
.input:disabled {
color: var(--dsw-alias-label-tertiary);
cursor: default;
}
.inputInvalid {
composes: input;
border-color: var(--dsw-alias-label-error);
}
.invalid {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: var(--dsw-alias-label-error);
}
.hint {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: var(--dsw-alias-label-tertiary);
}

View File

@@ -0,0 +1,123 @@
/**
* Hand-written controls for the plugin configuration forms. Each renders one
* field's label, its staged text, whether saving would leave an override, and
* — when one stands — the reset that stages a clear back to the composition
* layer. Nothing here writes: a control reports what the user typed, and the
* card's save is the single point where a draft becomes a document mutation.
*/
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
/** Draft text this control renders. */
text: string
/** True when saving would leave a user-layer entry for this field. */
overridden: boolean
/** True when the draft is not a value this field accepts. */
invalid: boolean
/** Copy for the overridden badge. */
overriddenLabel: string
/** Copy for the reset control. */
resetLabel: string
/** Copy shown in place of the hint while the draft is invalid. */
invalidLabel: string
/** Disables every control (read-only document, or an unavailable namespace). */
disabled: boolean
/** Stage draft text. */
onEdit: (text: string) => void
/** Stage a clear so the field re-inherits the composition layer. */
onReset: () => void
}
/**
* A staged value field. `numeric` only hints the keypad: which drafts a field
* accepts is decided by its spec, so the control never silently rewrites what
* the user typed.
* @param props - the field's copy, its staged text, and the edit actions.
* @returns the labelled control.
*/
export function ValueField(props: FieldProps & {
/** Hints a numeric keypad without narrowing what the control accepts. */
numeric?: boolean
/** Placeholder shown while the draft is empty. */
placeholder?: string
}) {
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>
<input
id={props.id}
className={props.invalid ? css.inputInvalid : css.input}
type="text"
{...props.numeric === true ? { inputMode: 'numeric' as const } : {}}
{...props.invalid ? { 'aria-invalid': true } : {}}
value={props.text}
placeholder={props.placeholder ?? ''}
disabled={props.disabled}
onChange={(event) => { props.onEdit(event.target.value) }}
/>
<p className={props.invalid ? css.invalid : css.hint}>
{props.invalid ? props.invalidLabel : props.hint}
</p>
</div>
)
}
/**
* A write-only credential control. The value never rides a response, so the
* control reports only whether one is configured and starts blank; a blank
* draft writes nothing, which keeps the stored key rather than clearing it.
* @param props - the field's copy, its staged text, and the configured state.
* @returns the labelled control.
*/
export function SecretField(props: Pick<FieldProps, 'id' | 'label' | 'hint' | 'text' | 'disabled' | 'onEdit'> & {
/** Whether the Host reports a configured credential for this reference. */
configured: boolean
/** Copy describing the configured state. */
stateLabel: string
}) {
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={props.text}
disabled={props.disabled}
onChange={(event) => { props.onEdit(event.target.value) }}
/>
<p className={css.hint}>{props.hint}</p>
</div>
)
}

View File

@@ -0,0 +1,158 @@
/**
* Plugins settings surface, browser half — one section whose feature-owned
* tabs include configurable Host plugin cards and read-only inventory.
*
* The section declares `settings.plugins.tab`; its own `configurable` tab then
* declares `settings.plugin.item` and renders whatever cards were registered
* into it. The three cards this package ships 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 and of other tabs.
*/
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: the settings shell's SlotMap merge (the 'settings.section' entry)
// and the ctx.settingsScope Context merge. Cross-plugin collaboration goes
// through the service, never a value import (client bundle purity gate).
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: the ctx.remote Context merge and the forwarded-event key face.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import { AgentLoopCard } from './AgentLoopCard.tsx'
import { BashCard } from './BashCard.tsx'
import { ConfigurablePluginsTab } from './ConfigurablePluginsTab.tsx'
import type { ConfigurablePluginsTabInjected } from './ConfigurablePluginsTab.tsx'
import { PluginsSettingsSection } from './PluginsSettingsSection.tsx'
import type { PluginsSettingsSectionInjected, PluginsSettingsTabEntry } from './PluginsSettingsSection.tsx'
import { WebSearchCard } from './WebSearchCard.tsx'
import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-card-controller.ts'
import { SHELL_NS, BashCardController } from './bash-card-controller.ts'
import { WEB_SEARCH_NS, WebSearchCardController } from './web-search-card-controller.ts'
import { en, zh } from './locales.ts'
export type { PluginsSettingsSectionInjected, PluginsSettingsSectionProps } from './PluginsSettingsSection.tsx'
export type { ConfigurablePluginsTabInjected, ConfigurablePluginsTabProps } from './ConfigurablePluginsTab.tsx'
export type { PluginCardProps } from './PluginCard.tsx'
export type { SettingsPluginItemOwnerProps } from './slot-contract.ts'
export type { FieldProps } from './fields.tsx'
export type {
CardActions, CardFieldSpec, CardFieldState, CardSecretSpec, CardShell,
} from './card-form.ts'
export type { AgentLoopCardFace, AgentLoopCardState } from './agent-loop-card-controller.ts'
export type { BashCardFace, BashCardState } from './bash-card-controller.ts'
export type { WebSearchCardFace, WebSearchCardState } from './web-search-card-controller.ts'
/** Dictionary namespace owned by this plugin. */
const NS = 'settings.plugins'
/** Required services (cordis fiber inject). */
export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope']
/**
* 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-settings-plugins: section dictionaries')
const bash = new BashCardController(ctx.settingsScope.bind({ namespace: SHELL_NS }))
const agentLoop = new AgentLoopCardController(ctx.settingsScope.bind({ namespace: AGENT_LOOP_NS }))
const webSearch = new WebSearchCardController(ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), api)
// The credential a card reports is not part of any settings section, so its
// scope publishes nothing when one is written. This is the only signal that
// a key written on another surface reached the Host.
ctx.effect(
() => ctx.remote.$on('credentials/updated', (ref) => { webSearch.refreshCredential(ref) }),
'ui-settings-plugins: credential invalidations',
)
let tabsVersion = -1
let tabsRevision = -1
let tabs: readonly PluginsSettingsTabEntry[] = []
const sectionInjected = (): PluginsSettingsSectionInjected => ({
hooks: {
tabs: {
getSnapshot: () => {
const version = ctx.slots.getVersion('settings.plugins.tab')
const revision = ctx.locale.getSnapshot().revision
if (version !== tabsVersion || revision !== tabsRevision) {
tabsVersion = version
tabsRevision = revision
tabs = ctx.slots.entries('settings.plugins.tab')
.map(entry => ({
/* v8 ignore next -- list-slot registration requires id */
id: entry.options.id ?? '',
order: entry.options.order ?? 0,
label: resolveSlotLabel(entry.options.label) ?? '',
}))
.sort((a, b) => a.order - b.order)
}
return tabs
},
subscribe: (listener) => {
const offLedger = ctx.slots.subscribe('settings.plugins.tab', listener)
const offLocale = ctx.locale.subscribe(listener)
return () => {
offLedger()
offLocale()
}
},
},
},
})
// This package owns the one Plugins navigation entry and the tab chrome;
// feature plugins contribute pages without competing for Settings nav rows.
ctx.slots.inject('settings.section', () => ctx.slots.register({
name: 'settings.section',
id: 'plugins',
order: 15,
label: () => t('nav'),
locale: NS,
inject: sectionInjected,
children: { 'settings.plugins.tab': { kind: 'list', scope: 'root' } },
}, PluginsSettingsSection))
// The existing configuration page is one ordinary tab. It keeps ownership
// of the card slot and the three shipped card contributions below.
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
name: 'settings.plugins.tab',
id: 'configurable',
order: 0,
label: () => t('configurableTab'),
locale: NS,
inject: (): ConfigurablePluginsTabInjected => ({
cardCount: ctx.slots.entries('settings.plugin.item').length,
}),
children: { 'settings.plugin.item': { kind: 'list', scope: 'root' } },
}, ConfigurablePluginsTab))
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,95 @@
/** Locale bundles for the plugin configuration section and its plugin cards. */
/** Locale keys these surfaces render. */
export type PluginsSettingsLocaleKey =
| 'nav' | 'title' | 'intro' | 'tabs' | 'configurableTab' | 'empty'
| 'overridden' | 'reset' | 'readOnly' | 'expand' | 'collapse'
| 'save' | 'saving' | 'discard' | 'unsaved' | 'saveFailed' | 'invalidNumber'
| '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<PluginsSettingsLocaleKey, string> = {
nav: 'Plugins',
title: 'Plugins',
intro: 'Configure and inspect the plugins installed in this deployment.',
tabs: 'Plugin views',
configurableTab: 'Plugin configuration',
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',
save: 'Save',
saving: 'Saving…',
discard: 'Discard',
unsaved: 'Unsaved',
saveFailed: 'The deployment did not accept these values; they were left for you to correct.',
invalidNumber: 'Enter a number, or leave blank to use the default.',
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<PluginsSettingsLocaleKey, string> = {
nav: '插件',
title: '插件',
intro: '配置和查看本部署已安装的插件。',
tabs: '插件视图',
configurableTab: '插件配置',
empty: '本部署没有开放任何插件设置。',
overridden: '已覆盖',
reset: '恢复默认',
readOnly: '本部署的设置为只读。',
expand: '展开设置',
collapse: '收起设置',
save: '保存',
saving: '保存中…',
discard: '放弃修改',
unsaved: '未保存',
saveFailed: '本部署没有接受这些值,已保留供你修改。',
invalidNumber: '请填数字;留空表示使用默认值。',
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,192 @@
/**
* The web-search card's staged form over the `web-search-deepseek` settings
* namespace.
*
* The key is the one control 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. It is still staged with the rest of the form, so one save
* covers everything the card shows.
*/
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import type { SettingsScope, SettingsScopeSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import {
CardForm, numberField, textField,
type CardActions, type CardFieldState, type CardShell,
} from './card-form.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'
/** Form field the credential control stages under. */
const API_KEY_FIELD = 'apiKey'
/** 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 credentials domain last reported, and for which reference. */
interface CredentialState {
/** Reference this answer describes; a stale response for another one is dropped. */
ref: string
/** Whether any layer supplies a value for it. */
configured: boolean
/** Whether `credentials.set` can affect it; false disables the control. */
writable: boolean
}
/** What the web-search card renders. */
export interface WebSearchCardState extends CardShell {
/** Provider endpoint. */
baseURL: CardFieldState
/** Searches allowed per request. */
maxUses: CardFieldState
/** The staged credential, which starts blank on every load. */
apiKey: CardFieldState
/** Whether the Host reports a credential configured for the referenced key. */
apiKeyConfigured: boolean
/** Whether the credentials domain accepts a write for it; false disables the control. */
apiKeyWritable: boolean
}
/** The registration-side face the web-search card's slot entry injects. */
export interface WebSearchCardFace extends CardActions {
hooks: {
/** Card snapshot bound by the renderer as useWebSearchCard. */
webSearchCard: SnapshotStore<WebSearchCardState>
}
}
/** Bridges the `web-search-deepseek` scope and the credentials domain onto the card. */
export class WebSearchCardController {
private readonly form: CardForm<WebSearchSettings>
private readonly store: SnapshotStore<WebSearchCardState>
private credential: CredentialState = { ref: '', configured: false, writable: true }
/**
* @param scope - the bound settings scope for the `web-search-deepseek` namespace.
* @param api - wire face used for the credential the section references.
*/
constructor(
private readonly scope: SettingsScope<WebSearchSettings>,
private readonly api: Pick<IApiClient, 'credentials'>,
) {
this.form = new CardForm(
scope,
[textField('baseURL'), numberField('maxUses')],
[{ field: API_KEY_FIELD, write: text => this.writeKey(text) }],
)
this.store = this.form.bind(() => this.projection())
scope.subscribe(() => { void this.readCredential() })
void this.readCredential()
}
private projection(): WebSearchCardState {
return {
...this.form.shell(),
baseURL: this.form.field('baseURL'),
maxUses: this.form.field('maxUses'),
apiKey: this.form.field(API_KEY_FIELD),
apiKeyConfigured: this.credential.configured,
apiKeyWritable: this.credential.writable,
}
}
/**
* Ask the credentials domain about the reference the section currently names.
*
* The answer is stored with the reference it describes: `apiKeyEnv` can
* change between the request and its response, and two reads can settle out
* of order, so a response is published only while it still answers for the
* reference in force.
*/
private async readCredential(): Promise<void> {
const ref = refOf(this.scope.getSnapshot())
if (ref !== this.credential.ref) {
// A new reference knows nothing yet; keeping the old answer would claim
// the key is configured under a name nobody has checked.
this.credential = { ref, configured: false, writable: true }
this.store.set(this.projection())
}
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 || ref !== refOf(this.scope.getSnapshot())) return
const view = response.result.value.credentials[ref]
const next: CredentialState = {
ref,
configured: view?.configured ?? false,
// An unknown reference is treated as writable: the control stays usable
// and the Host is what refuses, rather than the card guessing a refusal.
writable: view?.writable ?? true,
}
if (next.configured === this.credential.configured && next.writable === this.credential.writable) return
this.credential = next
this.store.set(this.projection())
}
/**
* Re-read after the Host reports a change to the reference this card watches.
*
* A key can be written from somewhere else — the Models page addresses the
* same reference — and the settings section does not change when it is, so
* without this the badge keeps reporting a state the Host already replaced.
* @param ref - the reference the Host reports as changed.
*/
refreshCredential(ref: string): void {
if (ref !== this.credential.ref) return
void this.readCredential()
}
/**
* Build the face the card's slot registration injects.
* @returns the card's snapshot and its form actions.
*/
inject(): WebSearchCardFace {
return { hooks: { webSearchCard: this.store }, ...this.form.actions() }
}
/**
* Write the staged key, then re-read whether the Host now holds one.
* @param value - the staged credential literal.
* @returns whether the Host reports a configured credential afterwards.
*/
private async writeKey(value: string): Promise<boolean> {
try {
await this.api.credentials.set({ ref: refOf(this.scope.getSnapshot()), 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()
return this.credential.configured
}
}
/**
* 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 declared = snapshot.value?.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 @@
/**
* Plugins settings surface, node half. The empty apply exists so the plugin
* appears in the host cordis.yml / Loader; the browser half owns the section
* and its configurable tab through exports["./client"], discovered from the
* package.json dsh.client 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-settings-plugins`.
* @module @deepseek-ai/dsh-client-ui-settings-plugins/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-settings-plugins'
/** Cordis companion plugin name. */
export const name = 'client-ui-settings-plugins-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 */