/** * dsh-slopo — browser half: the bundle's configuration page on the Plugins page. * * Where the card lives, and why it moved. Up to dsh 0.1.6 the settings surfaces * were: a settings namespace a plugin registered for itself, plus a * `settings.plugin.item` card the plugin's client half registered under that * namespace key, rendered inside Settings → Plugins → Plugin configuration. * 0.1.7-rc.2 removed both halves of that contract: * * * `ctx.settings.installSection(...)` no longer exists, and a namespace is no * longer chosen by the plugin — it IS the Loader profile entry id, which the * bundle's own patch declares (`dsh-slopo`). * * the `settings.plugin.item` slot and the `settingsScope` service no longer * exist either. Configuration now lives on the Plugins page, and a bundle's * own settings register into `plugins.bundle.config` keyed by the bundle's * PACKAGE NAME, which the page only dispatches for a package it lists. * * The service that replaced `settingsScope` is `configForms`, and the object it * hands out for one entry (`configForms.get(id)`) is API-compatible with what * this card used before: `getSnapshot()`, `subscribe()`, `mutate(ops, revision)`. * What actually changed is the chrome. The framework now ships the form * vocabulary this card hand-rolled — `SettingsFormModel` stages the edits and * fences the save with the revision the drafts were read at, `SettingsForm` * draws the frame and the single Save, and the field controls carry the * overridden badge, the reset and the invalid-edit block. Reproducing that by * hand is what the file used to do; using it is the point. * * Hand-written plain JavaScript inside the module-loader wrapper, with no build * step and no bundler. `dsh plugin add git+…` fetches sources rather than build * output, so a `prepare` script would make every user allowlist a build before * the first install succeeds. The imports are `react` and * `@deepseek-ai/dsh-client-ui-primitives`, both platform seed words; anything * outside that table would have to be bundled into this file or declared in * `dsh.client.external`. * * Styling is inline on purpose, for the same reason: there is no stylesheet this * file may reach. */ window.__ModuleLoader__.load({ id: 'dsh-slopo', factory: (require) => { var module = { exports: {} } var exports = module.exports const react = require('react') const primitives = require('@deepseek-ai/dsh-client-ui-primitives') const h = react.createElement const { Checkbox, SettingsForm, SettingsFormModel, SettingsSecretField, SettingsValueField } = primitives const { settingsNumberField, settingsTextField } = primitives /** * The bundle's package name. * * One string plays three roles and they must agree: it is the npm package * the Plugins page lists, the Loader entry id inside that bundle's patch * (and therefore the settings namespace `configForms` addresses), and the * `plugins.bundle.config` key this card registers under. */ const BUNDLE = 'dsh-slopo' /** Dictionary namespace owned by this plugin. */ const NS = 'dsh-slopo' /** The one write-only field: a credential the wire never carries back. */ const API_KEY_FIELD = 'embeddingApiKey' const DESCRIPTION = 'Embedding backend, index scope and the budget that stops a large embed.' /** The card's own copy. */ const en = { description: DESCRIPTION, overridden: 'Overridden', reset: 'Reset to default', readOnly: 'This deployment stores settings read-only.', unavailable: 'This plugin is not loaded, so it cannot be configured right now.', save: 'Save', saving: 'Saving…', 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.', apiKeySet: 'A key is stored.', apiKeyUnset: 'No key is stored.', listHint: 'Comma separated.', 'binary.label': 'slopo executable', 'binary.hint': 'Empty discovers it: managed install, then PATH, then ~/.local/bin.', 'autoInstall.label': 'Install slopo with uv when missing', 'autoInstall.hint': 'Installs into $DSH_HOME with uv, so the executable survives a container recreation.', 'embeddingModel.label': 'Embedding model', 'embeddingModel.hint': 'LiteLLM string, provider prefix included — e.g. ollama/unclemusclez/jina-embeddings-v2-base-code:latest', 'embeddingDimensions.label': 'Embedding dimensions', 'embeddingDimensions.hint': 'Must match what the model returns. Frozen into the index at first use.', 'embeddingApiBase.label': 'Embedding API base', 'embeddingApiBase.hint': 'e.g. http://127.0.0.1:11434', 'embeddingApiKey.label': 'Embedding API key', 'embeddingApiKey.hint': 'Passed to slopo in its environment and never written into the slopo config file. It is write-only here: the deployment reports whether one is stored, never the key.', 'embeddingParamsJson.label': 'Extra embedding parameters', 'embeddingParamsJson.hint': 'A JSON object, merged into embedding_params.', 'sourceDir.label': 'Source directory', 'sourceDir.hint': "Empty uses the session's working directory.", 'sourceDirExclude.label': 'Extra exclude patterns', 'sourceDirExclude.hint': 'Gitignore style, comma separated. Added to the defaults rather than replacing them.', 'useDefaultExcludes.label': 'Exclude node_modules and build output', 'useDefaultExcludes.hint': 'Off means slopo parses your dependencies too — on a repo with node_modules, the difference between ~1,500 and ~114,000 code units.', 'maxIndexUnits.label': 'Refuse to embed more than this many units', 'maxIndexUnits.hint': '0 removes the limit. Makes a large scope a question rather than an hours-long embed.', 'ignoreFile.label': 'Ignore file', 'ignoreFile.hint': 'Point this at the repository to version the dismissed clusters.', 'stateDir.label': 'State directory', 'stateDir.hint': 'Empty uses $DSH_HOME/dsh-slopo/workspaces/.', 'embeddingRequestDelay.label': 'Delay between embedding requests (ms)', 'embeddingRequestDelay.hint': 'Rate limit for a provider that rejects bursts. 0 sends as fast as it can.', 'timeoutMs.label': 'Foreground timeout (ms)', 'timeoutMs.hint': 'How long one foreground slopo run may take before it is killed.', } /** * The fields this card edits, in display order. * * This list mirrors the Host schema in index.js, including `kind`. It is * duplicated rather than fetched because the card renders its own controls — * there is no schema-to-form renderer in the framework — and a field the * Host does not know is rejected there anyway, so a drift shows up as a * clear error rather than silent data loss. Every key must be a `volatile()` * field of that schema or the write is refused. */ const FIELDS = [ { key: 'embeddingModel', kind: 'text' }, { key: 'embeddingDimensions', kind: 'number' }, { key: 'embeddingApiBase', kind: 'text' }, { key: 'embeddingApiKey', kind: 'secret' }, { key: 'embeddingParamsJson', kind: 'text' }, { key: 'binary', kind: 'text' }, { key: 'autoInstall', kind: 'boolean' }, { key: 'sourceDir', kind: 'text' }, { key: 'sourceDirExclude', kind: 'list' }, { key: 'useDefaultExcludes', kind: 'boolean' }, { key: 'maxIndexUnits', kind: 'number' }, { key: 'ignoreFile', kind: 'text' }, { key: 'stateDir', kind: 'text' }, { key: 'embeddingRequestDelay', kind: 'number' }, { key: 'timeoutMs', kind: 'number' }, ] /** A label or hint from this card's dictionary. */ const copy = (t, key, part) => t(`${key}.${part}`) /** Every field's label and hint, resolving against the active locale. */ const describeField = (t, field) => ({ key: field.key, kind: field.kind, label: copy(t, field.key, 'label'), hint: copy(t, field.key, 'hint'), id: `plugin-config-${BUNDLE}-${field.key}`, }) /** * A boolean field's draft conversion. * * The shared kit exports number and text specs only; a switch is a set of * `true`/`false`, so the control has nothing to parse but the two literals * it renders. Anything else is an invalid draft, which blocks the save * rather than silently writing `false`. */ const settingsBooleanField = (field) => ({ field, format: (value) => (value === true ? 'true' : 'false'), parse: (text) => { const trimmed = text.trim() if (trimmed === 'true') return { kind: 'set', value: true } if (trimmed === 'false') return { kind: 'set', value: false } return undefined }, }) /** * A string-array field's draft conversion: a comma-separated list. * * An empty draft is an empty list rather than a clear: the schema declares * this field's default as `[]`, so "no extra patterns" and "fall back to * the composition layer" are the same answer. */ const settingsListField = (field) => ({ field, format: (value) => (Array.isArray(value) ? value.join(', ') : ''), parse: (text) => ({ kind: 'set', value: text .split(/[,\n]/) .map((part) => part.trim()) .filter((part) => part !== ''), }), }) /** The spec one field's drafts are converted with. */ function specFor(field) { if (field.kind === 'number') return settingsNumberField(field.key) if (field.kind === 'boolean') return settingsBooleanField(field.key) if (field.kind === 'list') return settingsListField(field.key) return settingsTextField(field.key) } /** The form frame's copy, read from this card's dictionary. */ const formLabels = (t) => ({ unavailable: t('unavailable'), readOnly: t('readOnly'), saveFailed: t('saveFailed'), save: t('save'), saving: t('saving'), }) /** The field-control copy shared by every row. */ const fieldLabels = (t) => ({ overriddenLabel: t('overridden'), resetLabel: t('reset'), invalidLabel: t('invalidNumber'), }) /** One staged text row. */ function Row(props) { const { t, state, disabled, edit, resetField, meta } = props return h(SettingsValueField, { id: meta.id, label: meta.label, hint: meta.hint, disabled, numeric: meta.kind === 'number', ...fieldLabels(t), ...state, onEdit: (text) => edit(meta.key, text), onReset: () => resetField(meta.key), }) } /** * One staged switch row. * * `Checkbox` owns native keyboard and form semantics but no override * affordance, so the badge and the reset sit beside it — the same pair a * `SettingsValueField` draws for a text row. */ function SwitchRow(props) { const { t, state, disabled, edit, resetField, meta } = props const checked = state.text === 'true' return h( 'div', { style: { display: 'flex', flexDirection: 'column', gap: '4px' } }, h( 'div', { style: { display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap' } }, h(Checkbox, { label: meta.label, checked, disabled, onChange: (next) => edit(meta.key, next ? 'true' : 'false'), }), state.overridden ? h( 'button', { type: 'button', disabled, onClick: () => resetField(meta.key), style: { font: 'inherit', fontSize: '11px', cursor: 'pointer', background: 'transparent', color: 'inherit', opacity: 0.75, border: 'none', padding: 0, }, }, `${t('overridden')} · ${t('reset')}`, ) : null, ), h('div', { style: { fontSize: '11px', opacity: 0.65, lineHeight: 1.45 } }, meta.hint), ) } /** * Render the bundle's page: the form, or nothing while the deployment does * not serve the entry. * * @param props - the card's state snapshot, the form actions, and locale copy. * @returns the settings form. */ function SlopoCard(props) { const { t } = props const state = props.useSlopoCard((snapshot) => snapshot) // `view` is `page` for a bundle's configuration slot; the bundle's // one-liner is the package's own description, so there is no summary view // to answer here. const disabled = !state.writable const children = [] for (const field of FIELDS) { const meta = describeField(t, field) const shared = { key: field.key, t, state: state[field.key], disabled, edit: props.edit, resetField: props.resetField, meta } children.push(field.kind === 'boolean' ? h(SwitchRow, shared) : h(Row, shared)) } return h( SettingsForm, { labels: formLabels(t), state, onSave: props.save, onDiscard: props.discard }, h( 'div', { style: { display: 'flex', flexDirection: 'column', gap: '14px' } }, // The key is write-only, so it is not one of the rows above. h(SettingsSecretField, { id: `plugin-config-${BUNDLE}-${API_KEY_FIELD}`, label: copy(t, API_KEY_FIELD, 'label'), hint: copy(t, API_KEY_FIELD, 'hint'), disabled, text: state.apiKey.text, configured: state.apiKeyConfigured, stateLabel: state.apiKeyConfigured ? t('apiKeySet') : t('apiKeyUnset'), onEdit: (text) => props.edit(API_KEY_FIELD, text), }), ...children, ), ) } /** * The card's staged form over the `dsh-slopo` profile entry. * * `SettingsFormModel` is what makes Save the only write: a draft is staged * against the revision it was read at, an invalid draft blocks the save * instead of being dropped, and a refused save keeps its drafts so the user * can correct them. The model subscribes to the scope itself and republishes * through {@link SettingsFormModel.bind}, so this class only has to describe * what one snapshot contains. */ class SlopoCardController { /** * @param ctx - the browser plugin context, whose `configForms` reports the entry's secret slots. * @param scope - the shared configuration form for the `dsh-slopo` entry. */ constructor(ctx, scope) { const specs = FIELDS.filter((field) => field.kind !== 'secret').map(specFor) const secrets = [ { field: API_KEY_FIELD, // The credential is a settings field, not a separate credential // reference, so its one-way write is the same fenced mutation the // ordinary rows use — with the value never coming back. write: (text) => scope.mutate([{ op: 'set', path: [API_KEY_FIELD], value: text }]), }, ] this.ctx = ctx this.form = new SettingsFormModel(scope, specs, secrets) this.store = this.form.bind(() => this.projection()) } /** Whether the deployment reports a stored key; the value itself never rides. */ readApiKeyConfigured() { const view = this.ctx.configForms.describe().getSnapshot().view const entry = view?.namespaces?.find((row) => row.ns === BUNDLE) const slot = entry?.secrets?.find( (secret) => Array.isArray(secret.path) && secret.path.length === 1 && secret.path[0] === API_KEY_FIELD, ) return slot?.set === true } /** Build the snapshot the card renders from the form's current reads. */ projection() { const state = { ...this.form.shell(), apiKey: this.form.field(API_KEY_FIELD), apiKeyConfigured: this.readApiKeyConfigured(), } for (const field of FIELDS) { if (field.kind !== 'secret') state[field.key] = this.form.field(field.key) } return state } /** Build the face the slot registration injects: the card's state and its actions. */ inject() { return { hooks: { slopoCard: this.store }, ...this.form.actions() } } /** Release the form's accepted-value subscription. */ dispose() { this.form.dispose() } } /** * Register the card, but only once the settings transport exists. * * `ctx.inject(['configForms'], …)` rather than a top-level `inject`: a * required dependency that is unsatisfied means this plugin never applies at * all, and the card would simply not exist on a deployment that composes no * settings surface — which is the correct outcome, not a load failure. * * `whileServed` is the second gate. The registration only stands while the * deployment actually serves the `dsh-slopo` namespace, so an uninstalled or * disabled bundle leaves no card behind, and the Plugins page's own * `plugins.bundle.config` ledger stays in step with what the Host serves. */ function apply(ctx) { ctx.effect( () => ctx.locale.register(NS, { en, }), 'dsh-slopo: card dictionary', ) ctx.inject(['configForms'], (child) => { const forms = child.configForms if (forms === undefined) return const controller = new SlopoCardController(child, forms.get(BUNDLE)) child.effect( () => () => controller.dispose(), 'dsh-slopo: form subscription', ) child.effect( () => forms.whileServed([BUNDLE], () => child.slots.inject('plugins.bundle.config', () => child.slots.register( { name: 'plugins.bundle.config', key: BUNDLE, locale: NS, inject: () => controller.inject(), }, SlopoCard, ), ), ), 'dsh-slopo: Plugins page card', ) }) } module.exports = { name: BUNDLE, inject: ['slots', 'locale'], apply } return module.exports }, })