From 4bb19af2ac94c7f5584f68935807f5031cf77133 Mon Sep 17 00:00:00 2001 From: dsh-slopo agent Date: Tue, 22 Sep 2026 07:22:03 +0100 Subject: [PATCH] =?UTF-8?q?Add=20the=20settings=20card=20=E2=80=94=20the?= =?UTF-8?q?=20browser=20half?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings namespace was registered but nothing drew a form for it, because the Plugin configuration tab renders the INTERSECTION of the namespaces the Host serves and the cards a browser half claims. A served namespace with no card renders nothing at all. This is the missing card. Plain ESM in the module-loader wrapper, no build step, requiring only `react` — which is in the platform seed table. Anything outside that table would have to be bundled into this file or declared in `dsh.client.external`, and the tests fail loudly on any other specifier. It stages edits and writes them on Save; a cleared field is an `unset` rather than a write of "", so the value re-inherits the composition layer instead of becoming empty, and the user layer's presence is what marks a field overridden. Reset clears every override. A namespace this client cannot reach renders nothing, so a deployment without the Host half shows no trace. package.json gains an `exports` map (the loader reads `exports["./client"]`; a bare `main` would not be enough) and the `dsh.client` declaration. --- client/client.js | 294 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 20 +++- 2 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 client/client.js diff --git a/client/client.js b/client/client.js new file mode 100644 index 0000000..707fdf7 --- /dev/null +++ b/client/client.js @@ -0,0 +1,294 @@ +/** + * dsh-slopo — browser half: the card in Settings → Plugins → Plugin configuration. + * + * The Host half registers a `dsh-slopo` settings namespace. This file registers + * a card under that exact namespace key. Both are required: the tab renders the + * INTERSECTION of the namespaces the Host serves and the cards the browser + * claims, so a served namespace with no card draws nothing at all, and a card + * whose namespace is not served is never dispatched. + * + * 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 only import is `react`, which is in the + * platform seed table; anything outside that table would have to be bundled + * into this file or declared in `dsh.client.external`. + * + * Styling is inline on purpose. The client bundle purity gate forbids + * cross-plugin value imports, so 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 NS = 'dsh-slopo' + const h = react.createElement + + /** + * The settings this card edits. + * + * This list mirrors the Host schema in index.js. It is duplicated rather + * than fetched because the card renders its own controls — there is no + * generic 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. + */ + const FIELDS = [ + { + key: 'embeddingModel', + label: 'Embedding model', + hint: 'LiteLLM string, provider prefix included — e.g. ollama/unclemusclez/jina-embeddings-v2-base-code:latest', + }, + { key: 'embeddingDimensions', label: 'Embedding dimensions', kind: 'number', hint: 'Must match what the model returns. Frozen into the index at first use.' }, + { key: 'embeddingApiBase', label: 'Embedding API base', hint: 'e.g. http://127.0.0.1:11434' }, + { + key: 'embeddingApiKey', + label: 'Embedding API key', + kind: 'password', + hint: 'Passed to slopo in its environment and never written into the slopo config file.', + }, + { key: 'embeddingParamsJson', label: 'Extra embedding parameters', hint: 'A JSON object, merged into embedding_params.' }, + { key: 'binary', label: 'slopo executable', hint: 'Empty discovers it: managed install, then PATH, then ~/.local/bin.' }, + { key: 'autoInstall', label: 'Install slopo with uv when missing', kind: 'boolean' }, + { key: 'sourceDir', label: 'Source directory', hint: 'Empty uses the session working directory.' }, + { key: 'sourceDirExclude', label: 'Exclude patterns', kind: 'list', hint: 'Comma separated, gitignore style.' }, + { key: 'stateDir', label: 'State directory', hint: 'Empty uses $DSH_HOME/dsh-slopo/workspaces/.' }, + { key: 'ignoreFile', label: 'Ignore file', hint: 'Point this at the repository to version the dismissed clusters.' }, + { key: 'embeddingRequestDelay', label: 'Delay between embedding requests (ms)', kind: 'number' }, + { key: 'timeoutMs', label: 'Foreground timeout (ms)', kind: 'number' }, + ] + + const S = { + card: { border: '1px solid var(--dsh-border, #3a3a3a)', borderRadius: '8px', padding: '16px', margin: '8px 0' }, + head: { display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: '12px', marginBottom: '4px' }, + title: { fontSize: '14px', fontWeight: 600 }, + badge: { fontSize: '11px', opacity: 0.7 }, + hint: { fontSize: '11px', opacity: 0.65, marginTop: '2px', lineHeight: 1.45 }, + row: { marginTop: '12px' }, + label: { display: 'block', fontSize: '12px', fontWeight: 500, marginBottom: '4px' }, + input: { + width: '100%', + boxSizing: 'border-box', + padding: '6px 8px', + fontSize: '12px', + borderRadius: '6px', + border: '1px solid var(--dsh-border, #3a3a3a)', + background: 'transparent', + color: 'inherit', + }, + actions: { display: 'flex', gap: '8px', alignItems: 'center', marginTop: '16px', flexWrap: 'wrap' }, + button: { + padding: '6px 12px', + fontSize: '12px', + borderRadius: '6px', + border: '1px solid var(--dsh-border, #3a3a3a)', + background: 'transparent', + color: 'inherit', + cursor: 'pointer', + }, + primary: { fontWeight: 600 }, + note: { fontSize: '11px', opacity: 0.75, marginLeft: 'auto' }, + dirty: { fontSize: '11px', opacity: 0.8 }, + } + + /** The editable string form of one field's value. */ + function toText(field, value) { + if (field.kind === 'list') return Array.isArray(value) ? value.join(', ') : '' + if (field.kind === 'boolean') return value === true ? 'true' : 'false' + if (value === undefined || value === null) return '' + return String(value) + } + + /** The value to write for one field, from its string form. */ + function fromText(field, text) { + const trimmed = typeof text === 'string' ? text.trim() : '' + if (field.kind === 'number') return Number(trimmed) + if (field.kind === 'boolean') return trimmed === 'true' + if (field.kind === 'list') { + return trimmed === '' + ? [] + : trimmed + .split(/[,\n]/) + .map((part) => part.trim()) + .filter((part) => part !== '') + } + return text + } + + /** Build a draft of every field from a resolved section. */ + function toDraft(value) { + const draft = {} + for (const field of FIELDS) draft[field.key] = toText(field, value?.[field.key]) + return draft + } + + function Field(props) { + const { field, value, overridden, disabled, onChange } = props + const common = { + style: S.input, + value, + disabled, + 'aria-label': field.label, + onChange: (event) => onChange(event.target.value), + } + return h( + 'div', + { style: S.row }, + h( + 'label', + { style: S.label }, + field.label, + overridden ? h('span', { style: { opacity: 0.6, fontWeight: 400 } }, ' · overridden') : null, + ), + field.kind === 'boolean' + ? h('input', { + type: 'checkbox', + checked: value === 'true', + disabled, + 'aria-label': field.label, + onChange: (event) => onChange(event.target.checked ? 'true' : 'false'), + }) + : h('input', { ...common, type: field.kind === 'password' ? 'password' : 'text' }), + field.hint === undefined ? null : h('div', { style: S.hint }, field.hint), + ) + } + + function Card(props) { + const scope = props.scope + const [snapshot, setSnapshot] = react.useState(() => scope.getSnapshot()) + const [draft, setDraft] = react.useState(null) + const [busy, setBusy] = react.useState(false) + const [note, setNote] = react.useState('') + + react.useEffect(() => scope.subscribe(() => setSnapshot(scope.getSnapshot())), [scope]) + + const status = snapshot.status + const value = snapshot.value + const baseline = react.useMemo(() => toDraft(value), [value]) + const current = draft ?? baseline + const user = snapshot.user + + if (status === 'unavailable') { + // A deployment without the Host half, or a remote browser: show no trace. + return null + } + + const dirtyKeys = FIELDS.filter((field) => current[field.key] !== baseline[field.key]).map((field) => field.key) + const overriddenKeys = user !== null && typeof user === 'object' ? Object.keys(user) : [] + + const save = async () => { + setBusy(true) + setNote('') + try { + for (const field of FIELDS) { + const next = current[field.key] + if (next === baseline[field.key]) continue + // Clearing a field is an unset, not a write of "", so the value + // re-inherits the composition layer instead of becoming empty. + if (String(next).trim() === '') await scope.unset(field.key) + else await scope.set(field.key, fromText(field, next)) + } + setDraft(null) + setNote('Saved.') + } catch (error) { + setNote(`Save failed: ${error?.message ?? error}`) + } finally { + setBusy(false) + } + } + + const reset = async () => { + setBusy(true) + setNote('') + try { + for (const key of overriddenKeys) await scope.unset(key) + setDraft(null) + setNote('Reset to the deployment defaults.') + } catch (error) { + setNote(`Reset failed: ${error?.message ?? error}`) + } finally { + setBusy(false) + } + } + + return h( + 'div', + { style: S.card }, + h( + 'div', + { style: S.head }, + h('div', { style: S.title }, 'Slopo'), + h( + 'div', + { style: S.badge }, + status === 'loading' ? 'loading…' : 'configuration', + snapshot.writable === true ? '' : ' · read-only', + ), + ), + h( + 'div', + { style: S.hint }, + 'Slopo runs as a separate process. Nothing here is defaulted: it stays unconfigured until you set a model and its dimensions.', + ), + ...FIELDS.map((field) => + h(Field, { + key: field.key, + field, + value: current[field.key], + overridden: overriddenKeys.includes(field.key), + disabled: busy || snapshot.writable !== true, + onChange: (next) => setDraft({ ...current, [field.key]: next }), + }), + ), + h( + 'div', + { style: S.actions }, + h( + 'button', + { type: 'button', style: { ...S.button, ...S.primary }, disabled: busy || dirtyKeys.length === 0, onClick: save }, + dirtyKeys.length === 0 ? 'Save' : `Save ${dirtyKeys.length} change${dirtyKeys.length === 1 ? '' : 's'}`, + ), + h( + 'button', + { type: 'button', style: S.button, disabled: busy || overriddenKeys.length === 0, onClick: reset }, + `Reset ${overriddenKeys.length} override${overriddenKeys.length === 1 ? '' : 's'}`, + ), + note === '' ? null : h('span', { style: S.note }, note), + ), + ) + } + + /** + * Register the card, but only once a settings transport exists. + * + * `ctx.inject(['settingsScope'], …)` rather than a top-level `inject`, + * because a required dependency that is unsatisfied means this plugin never + * applies at all — the card would vanish along with everything else instead + * of simply not appearing. + */ + function apply(ctx) { + ctx.inject(['settingsScope'], (scoped) => { + const binder = scoped.settingsScope + if (binder === undefined) return + const scope = binder.bind({ namespace: NS }) + scoped.slots.inject('settings.plugin.item', () => + scoped.slots.register( + { + name: 'settings.plugin.item', + key: NS, + inject: () => ({ scope }), + }, + (props) => h(Card, props), + ), + ) + }) + } + + module.exports = { name: NS, inject: ['slots'], apply } + return module.exports + }, +}) diff --git a/package.json b/package.json index 00d61dc..7d5ec5e 100644 --- a/package.json +++ b/package.json @@ -3,11 +3,22 @@ "version": "0.1.0", "private": true, "type": "module", - "description": "Slopo as a DeepSeek Harness bundle — embedding-based duplicate-code review, exposed as three user-triggered skills and one config-aware tool.", + "description": "Slopo as a DeepSeek Harness bundle — embedding-based duplicate-code review, exposed as three user-triggered skills, one config-aware tool, and a settings card.", "main": "index.js", + "exports": { + ".": { + "default": "./index.js" + }, + "./client": { + "default": "./client/client.js" + }, + "./cordis.patch.yml": "./cordis.patch.yml", + "./package.json": "./package.json" + }, "files": [ "index.js", "lib", + "client", "skills", "cordis.patch.yml", "README.md", @@ -23,6 +34,13 @@ "dsh": { "bundle": { "patch": "./cordis.patch.yml" + }, + "client": { + "platform": "web", + "inject": [ + "@deepseek-ai/dsh-client-ui-slots", + "@deepseek-ai/dsh-client-ui-settings" + ] } }, "keywords": [