@huanlin/dsh-plugin-interpreters 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/gateway.js ADDED
@@ -0,0 +1,163 @@
1
+ /**
2
+ * gateway.ts — host-side HTTP gateway exposing the `interpreters` config to
3
+ * the browser through a self-hosted `/interpreters/api` route.
4
+ *
5
+ * The DSH typertGateway `/api` RPC dispatch was the original channel
6
+ * (TypertRemoteService + @Remote), but the host's SRC discovery
7
+ * (ctx.reflect.props enumeration) is not claiming plugin-owned service
8
+ * endpoints on the current dsh snapshot. The self-hosted HTTP route
9
+ * mirrors the better-sidebar pattern: `ctx.webServer.register` claims a
10
+ * prefix route, the handler reads/writes the settings seam in-process
11
+ * (no wire-layer allowlist gate), and the browser reaches it through
12
+ * `fetch('/interpreters/api/<method>')`.
13
+ *
14
+ * Route shape:
15
+ * POST /interpreters/api/get → { ok: true, value: { config: ResolvedConfig } }
16
+ * POST /interpreters/api/set body: { patch: Partial<Config> }
17
+ * → { ok: true, value: { config: ResolvedConfig } }
18
+ * Errors carry { ok: false, error: { code, message } }.
19
+ *
20
+ * @module dsh-interpreters/gateway
21
+ */
22
+ import { resolveConfig, } from './config.js';
23
+ import { SETTINGS_NAMESPACE, } from './settings.js';
24
+ /** HTTP route prefix owning every interpreters API request. */
25
+ const API_PREFIX = '/interpreters/api';
26
+ /** Config keys the `set` endpoint accepts (allow-list; unknown keys are dropped). */
27
+ const ALLOWED_KEYS = new Set(['pythonPath', 'nodePath', 'timeoutMs']);
28
+ /**
29
+ * Register the `/interpreters/api` HTTP route on the host's web server.
30
+ *
31
+ * The route reads/writes the `interpreters` settings namespace in-process
32
+ * through the bridge + `ctx.settings`. The settings service is optional:
33
+ * when absent, `get` degrades to the entry source and `set` returns a
34
+ * clear error.
35
+ * @param ctx - host context carrying `webServer`.
36
+ * @param bridge - the settings bridge the route reads through.
37
+ */
38
+ export function registerHttpGateway(ctx, bridge) {
39
+ let settings;
40
+ ctx.inject(['settings'], (sctx) => {
41
+ settings = sctx.settings;
42
+ return () => { settings = undefined; };
43
+ });
44
+ ctx.effect(() => ctx.webServer.register({
45
+ kind: 'prefix',
46
+ path: API_PREFIX,
47
+ handler: async (req, res) => {
48
+ if (req.method !== 'POST') {
49
+ writeJson(res, 405, envelopeError('method-not-allowed', 'POST only'));
50
+ return;
51
+ }
52
+ const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname;
53
+ const method = pathname.startsWith(`${API_PREFIX}/`)
54
+ ? pathname.slice(`${API_PREFIX}/`.length)
55
+ : undefined;
56
+ if (method === undefined || method.includes('/')) {
57
+ writeJson(res, 404, envelopeError('not-found', 'unknown interpreters API method'));
58
+ return;
59
+ }
60
+ try {
61
+ const body = await readJsonBody(req);
62
+ if (method === 'get') {
63
+ const config = resolveConfig(bridge.source());
64
+ writeJson(res, 200, envelopeOk({ config }));
65
+ }
66
+ else if (method === 'set') {
67
+ const result = await handleSet(body, settings, bridge);
68
+ writeJson(res, 200, envelopeOk(result));
69
+ }
70
+ else {
71
+ writeJson(res, 404, envelopeError('not-found', `unknown interpreters API method "${method}"`));
72
+ }
73
+ }
74
+ catch (error) {
75
+ const message = error instanceof Error ? error.message : String(error);
76
+ writeJson(res, 500, envelopeError('internal', message));
77
+ }
78
+ },
79
+ }), 'dsh-interpreters: /interpreters/api routes');
80
+ }
81
+ /**
82
+ * Handle the `set` method: validate the patch, write the user layer, return
83
+ * the new resolved config.
84
+ * @param body - the parsed JSON body from the request.
85
+ * @param settings - the live settings service (undefined when unavailable).
86
+ * @param bridge - the settings bridge for reading the source.
87
+ * @returns the new resolved config view.
88
+ * @throws when the settings service is unavailable.
89
+ */
90
+ export async function handleSet(body, settings, bridge) {
91
+ const patch = extractPatch(body);
92
+ if (Object.keys(patch).length === 0) {
93
+ return { config: resolveConfig(bridge.source()) };
94
+ }
95
+ if (settings === undefined) {
96
+ throw new Error('interpreters: settings service is unavailable — configuration cannot be written');
97
+ }
98
+ await settings.update(SETTINGS_NAMESPACE, patch);
99
+ return { config: resolveConfig(bridge.source()) };
100
+ }
101
+ /**
102
+ * Extract and validate the patch from the request body.
103
+ *
104
+ * JSON wire boundary: null = "delete" (filtered), undefined never crosses
105
+ * JSON. Unknown keys are dropped (the settings service is non-strict and
106
+ * would otherwise store them). Light type guards constrain paths to
107
+ * strings and timeout to a finite number.
108
+ * @param body - the parsed JSON body.
109
+ * @returns the normalized patch (only known, well-typed keys).
110
+ */
111
+ export function extractPatch(body) {
112
+ if (!isObject(body))
113
+ return {};
114
+ const raw = Reflect.get(body, 'patch');
115
+ if (!isObject(raw))
116
+ return {};
117
+ const normalized = {};
118
+ for (const [key, value] of Object.entries(raw)) {
119
+ if (!ALLOWED_KEYS.has(key))
120
+ continue;
121
+ if (value === null || value === undefined)
122
+ continue;
123
+ if (key === 'timeoutMs') {
124
+ if (typeof value !== 'number' || !Number.isFinite(value))
125
+ continue;
126
+ }
127
+ else {
128
+ if (typeof value !== 'string')
129
+ continue;
130
+ }
131
+ normalized[key] = value;
132
+ }
133
+ return normalized;
134
+ }
135
+ /** Read and parse a JSON body from a node:http request. */
136
+ async function readJsonBody(req) {
137
+ const chunks = [];
138
+ for await (const chunk of req) {
139
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
140
+ }
141
+ const text = Buffer.concat(chunks).toString('utf8');
142
+ if (text === '')
143
+ return {};
144
+ return JSON.parse(text);
145
+ }
146
+ /** Write a JSON response envelope. */
147
+ function writeJson(res, status, body) {
148
+ const json = JSON.stringify(body);
149
+ res.writeHead(status, { 'content-type': 'application/json' });
150
+ res.end(json);
151
+ }
152
+ /** Build a success envelope. */
153
+ function envelopeOk(value) {
154
+ return { ok: true, value };
155
+ }
156
+ /** Build an error envelope. */
157
+ function envelopeError(code, message) {
158
+ return { ok: false, error: { code, message } };
159
+ }
160
+ /** Narrow unknown to a non-null object. */
161
+ function isObject(value) {
162
+ return typeof value === 'object' && value !== null;
163
+ }
package/lib/index.js ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * index.ts — dsh-interpreters host plugin entry.
3
+ *
4
+ * Registers two model-facing tools (`run_python`, `run_node`) whose
5
+ * descriptions embed the configured interpreter paths. The paths persist
6
+ * through the settings seam under the `interpreters` namespace in
7
+ * `$DSH_HOME/settings.yaml`; runtime edits dispose and re-register the tools
8
+ * so the model immediately sees the updated path. The browser reaches the
9
+ * same namespace through a self-hosted `/interpreters/api` HTTP route
10
+ * (the DSH settings RPC domain only serves allowlisted namespaces to
11
+ * configuration clients, so this plugin exposes its own route through
12
+ * `ctx.webServer.register`, bypassing the wire-layer allowlist by calling
13
+ * the settings seam in-process).
14
+ *
15
+ * Architecture:
16
+ * - `installInterpretersSettings` registers the namespace and exposes a
17
+ * `source()` thunk + `onChange()` subscription.
18
+ * - `registerHttpGateway` claims `/interpreters/api/get|set` and
19
+ * reads/writes through the bridge + `ctx.settings` in-process.
20
+ * - The tool registration is re-run on every `bridge.onChange` notification
21
+ * so the model-visible description tracks the live interpreter path.
22
+ * - Headless assemblies without a settings provider fall back to the
23
+ * composition config (no persistence, no live reload, the `set`
24
+ * endpoint returns a clear "settings service unavailable" error).
25
+ *
26
+ * @module @huanlin/dsh-plugin-interpreters
27
+ */
28
+ import { resolveConfig } from './config.js';
29
+ import { registerHttpGateway } from './gateway.js';
30
+ import { installInterpretersSettings } from './settings.js';
31
+ import { registerTools } from './tools.js';
32
+ export { Config, resolveConfig } from './config.js';
33
+ export { registerHttpGateway } from './gateway.js';
34
+ export { SETTINGS_NAMESPACE } from './settings.js';
35
+ export const name = 'dsh-interpreters';
36
+ export const inject = ['tools', 'webServer'];
37
+ /**
38
+ * Plugin body: register tools with the composition config, then swap to
39
+ * settings-resolved config when the settings service mounts, and expose the
40
+ * config through a `/interpreters/api/get|set` HTTP route.
41
+ * @param ctx - host context carrying `tools` and `webServer`.
42
+ * @param config - resolved composition config (seed).
43
+ */
44
+ export function apply(ctx, config = {}) {
45
+ ctx.logger('dsh-interpreters').info('apply() called, config=', JSON.stringify(config));
46
+ const bridge = installInterpretersSettings(ctx, config);
47
+ let disposeTools = registerTools(ctx, resolveConfig(bridge.source()));
48
+ // Live re-register on every committed settings change so the model-visible
49
+ // tool description tracks the live interpreter path.
50
+ bridge.onChange(() => {
51
+ disposeTools?.();
52
+ disposeTools = registerTools(ctx, resolveConfig(bridge.source()));
53
+ });
54
+ // Register the HTTP gateway; the /interpreters/api route claims get/set.
55
+ registerHttpGateway(ctx, bridge);
56
+ ctx.logger('dsh-interpreters').info('http gateway registered at /interpreters/api');
57
+ ctx.effect(() => () => { disposeTools?.(); }, 'dsh-interpreters: cleanup');
58
+ }
package/lib/runner.js ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * runner.ts — subprocess execution for `run_python` / `run_node` tools.
3
+ *
4
+ * Spawns the interpreter with `-` (read code from stdin), writes the code
5
+ * to stdin, and collects stdout/stderr with a 1 MB cap per stream.
6
+ * Honours `AbortSignal` and a timeout — both kill the process and report
7
+ * the outcome in the canonical result (C5: non-ideal states are values,
8
+ * not thrown errors).
9
+ *
10
+ * @module dsh-interpreters/runner
11
+ */
12
+ import { spawn } from 'node:child_process';
13
+ /** Maximum captured bytes per stream (stdout / stderr). */
14
+ const MAX_OUTPUT_BYTES = 1024 * 1024;
15
+ /**
16
+ * Execute `code` by piping it into `executable -` (stdin).
17
+ *
18
+ * @param executable - interpreter path (e.g. `python`, `node`, or an absolute path).
19
+ * @param code - source code to pipe via stdin.
20
+ * @param cwd - optional working directory.
21
+ * @param timeoutMs - wall-clock budget; the process is killed with SIGKILL on expiry.
22
+ * @param signal - caller-owned abort signal; aborting kills the process.
23
+ * @returns a {@link RunResult} describing the outcome.
24
+ */
25
+ export function runCode(executable, code, cwd, timeoutMs, signal) {
26
+ return new Promise((resolve) => {
27
+ const start = Date.now();
28
+ if (signal.aborted) {
29
+ resolve({ ok: false, exit_code: -1, stdout: '', stderr: '', duration_ms: 0, timed_out: false, cancelled: true });
30
+ return;
31
+ }
32
+ let child;
33
+ try {
34
+ child = spawn(executable, ['-'], { cwd, windowsHide: true });
35
+ }
36
+ catch (error) {
37
+ resolve({
38
+ ok: false,
39
+ exit_code: -1,
40
+ stdout: '',
41
+ stderr: `Failed to spawn "${executable}": ${String(error)}`,
42
+ duration_ms: Date.now() - start,
43
+ timed_out: false,
44
+ cancelled: false,
45
+ });
46
+ return;
47
+ }
48
+ let stdout = '';
49
+ let stderr = '';
50
+ let stdoutCapped = false;
51
+ let stderrCapped = false;
52
+ let timedOut = false;
53
+ const append = (buf, target) => {
54
+ const str = buf.toString('utf8');
55
+ if (target === 'stdout') {
56
+ if (stdout.length + str.length > MAX_OUTPUT_BYTES && !stdoutCapped) {
57
+ stdout += str.slice(0, MAX_OUTPUT_BYTES - stdout.length);
58
+ stdoutCapped = true;
59
+ }
60
+ else if (!stdoutCapped) {
61
+ stdout += str;
62
+ }
63
+ }
64
+ else {
65
+ if (stderr.length + str.length > MAX_OUTPUT_BYTES && !stderrCapped) {
66
+ stderr += str.slice(0, MAX_OUTPUT_BYTES - stderr.length);
67
+ stderrCapped = true;
68
+ }
69
+ else if (!stderrCapped) {
70
+ stderr += str;
71
+ }
72
+ }
73
+ };
74
+ child.stdout?.on('data', (d) => append(d, 'stdout'));
75
+ child.stderr?.on('data', (d) => append(d, 'stderr'));
76
+ const timer = setTimeout(() => {
77
+ timedOut = true;
78
+ child.kill('SIGKILL');
79
+ }, timeoutMs);
80
+ const onAbort = () => {
81
+ clearTimeout(timer);
82
+ child.kill('SIGKILL');
83
+ };
84
+ signal.addEventListener('abort', onAbort, { once: true });
85
+ const finish = (exitCode) => {
86
+ clearTimeout(timer);
87
+ signal.removeEventListener('abort', onAbort);
88
+ if (stdoutCapped)
89
+ stdout += '\n[stdout truncated at 1 MB]';
90
+ if (stderrCapped)
91
+ stderr += '\n[stderr truncated at 1 MB]';
92
+ resolve({
93
+ ok: exitCode === 0 && !timedOut && !signal.aborted,
94
+ exit_code: exitCode ?? -1,
95
+ stdout,
96
+ stderr,
97
+ duration_ms: Date.now() - start,
98
+ timed_out: timedOut,
99
+ cancelled: signal.aborted,
100
+ });
101
+ };
102
+ child.on('error', (error) => {
103
+ clearTimeout(timer);
104
+ signal.removeEventListener('abort', onAbort);
105
+ resolve({
106
+ ok: false,
107
+ exit_code: -1,
108
+ stdout,
109
+ stderr: stderr + (stderr !== '' ? '\n' : '') + String(error),
110
+ duration_ms: Date.now() - start,
111
+ timed_out: false,
112
+ cancelled: signal.aborted,
113
+ });
114
+ });
115
+ child.on('close', (code) => finish(code));
116
+ child.stdin?.on('error', () => { });
117
+ child.stdin?.write(code, 'utf8');
118
+ child.stdin?.end();
119
+ });
120
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * settings.ts — host-side bridge between the `interpreters` settings namespace
3
+ * and the plugin's other halves (tool registration + RPC gateway).
4
+ *
5
+ * The composition `Config` (cordis.patch.yml) is the first-boot seed; once the
6
+ * `ctx.settings` service mounts, the user-editable layer takes over and live
7
+ * re-registration follows every committed change. Headless assemblies without
8
+ * a settings provider fall back to the composition config (no persistence, no
9
+ * live reload).
10
+ *
11
+ * The bridge pattern mirrors `dsh-advisor/src/settings.ts`: a `source()` thunk
12
+ * the gateway reads in-process, plus an `onChange()` subscription the host
13
+ * entry uses to re-register the tools. This avoids any wire-layer allowlist
14
+ * (the DSH settings RPC domain only serves a fixed namespace set to browser
15
+ * configuration clients; the gateway bypasses it through `/api`).
16
+ *
17
+ * @module dsh-interpreters/settings
18
+ */
19
+ import { settingsNamespace } from '@deepseek-ai/dsh-settings';
20
+ import { Config } from './config.js';
21
+ /** Settings namespace under which interpreter paths persist. */
22
+ export const SETTINGS_NAMESPACE = settingsNamespace('interpreters');
23
+ /**
24
+ * Mirror of the dsh-settings internal `isUnloading` guard. The cordis const
25
+ * enum for fiber state is erased at compile time, so the literal states are
26
+ * matched numerically: 4 = DISPOSED, 5 = UNLOADING.
27
+ */
28
+ function isUnloading(ctx) {
29
+ const state = ctx.fiber?.state;
30
+ return state === 4 || state === 5;
31
+ }
32
+ /**
33
+ * Install the `interpreters` settings namespace and return the bridge.
34
+ *
35
+ * The settings service is reached through `ctx.inject(['settings'], ...)` so a
36
+ * composition without a settings provider still loads the plugin (entry-source
37
+ * fallback, no persistence). Multi-fiber dedupe is handled by catching the
38
+ * `"already registered"` rejection — host composition may mount several
39
+ * concurrent fibers of this plugin, and only the first registration owns the
40
+ * namespace.
41
+ * @param ctx - host context.
42
+ * @param entry - composition-layer config (cordis.patch.yml seed).
43
+ * @returns the bridge the gateway and tool re-registration consume.
44
+ */
45
+ export function installInterpretersSettings(ctx, entry) {
46
+ const listeners = new Set();
47
+ let source = () => entry;
48
+ const notify = () => {
49
+ for (const listener of [...listeners])
50
+ listener();
51
+ };
52
+ ctx.inject(['settings'], (sctx) => {
53
+ let scope;
54
+ try {
55
+ scope = sctx.settings.register(SETTINGS_NAMESPACE, Config, { base: entry });
56
+ }
57
+ catch (error) {
58
+ // Multi-fiber dedupe: the first registration owns the namespace; later
59
+ // fibers stay on the entry source and emit no notifications of their own.
60
+ if (!(error instanceof Error) || !error.message.includes('already registered'))
61
+ throw error;
62
+ ctx.logger('dsh-interpreters').debug('settings namespace already registered — entry-source fallback');
63
+ return;
64
+ }
65
+ source = () => scope.get();
66
+ sctx.effect(() => () => {
67
+ if (isUnloading(ctx))
68
+ return;
69
+ source = () => entry;
70
+ notify();
71
+ });
72
+ notify();
73
+ scope.watch(() => {
74
+ if (isUnloading(ctx))
75
+ return;
76
+ notify();
77
+ });
78
+ });
79
+ return {
80
+ source: () => source(),
81
+ onChange: (cb) => { listeners.add(cb); },
82
+ };
83
+ }
package/lib/tools.js ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * tools.ts — `run_python` and `run_node` model-facing tools.
3
+ *
4
+ * Conventions (per plugin-development-guide.md §3):
5
+ * C4 — execute returns a canonical JSON value; render is a separate pure projection.
6
+ * C5 — timeout and cancellation are non-ideal business outcomes, represented in
7
+ * the value (timed_out / cancelled) rather than thrown.
8
+ * C6 — exec.signal is forwarded to the subprocess via runCode().
9
+ * C10 — no UI-specific formats in the canonical value.
10
+ *
11
+ * The tool `description` is computed from the resolved config at registration
12
+ * time so the model sees the interpreter path. Settings changes dispose the
13
+ * old registration and re-register with the fresh description (host index.ts).
14
+ *
15
+ * @module dsh-interpreters/tools
16
+ */
17
+ import { defineTool } from '@deepseek-ai/dsh-tools';
18
+ import { runCode } from './runner.js';
19
+ /**
20
+ * Build the model-visible description for `run_python`, embedding the
21
+ * configured interpreter path so the model knows exactly which executable
22
+ * will be invoked.
23
+ */
24
+ export function buildPythonDescription(cfg) {
25
+ return 'Execute Python code and return stdout, stderr, and exit code. '
26
+ + 'Code is passed via stdin (`' + cfg.pythonPath + ' -`), so there is no '
27
+ + 'command-line length limit. '
28
+ + 'The Python interpreter is located at: ' + cfg.pythonPath + '\n'
29
+ + 'Use the optional `cwd` parameter to set the working directory.';
30
+ }
31
+ /**
32
+ * Build the model-visible description for `run_node`, embedding the
33
+ * configured interpreter path.
34
+ */
35
+ export function buildNodeDescription(cfg) {
36
+ return 'Execute Node.js code and return stdout, stderr, and exit code. '
37
+ + 'Code is passed via stdin (`' + cfg.nodePath + ' -`), so there is no '
38
+ + 'command-line length limit. '
39
+ + 'The Node.js interpreter is located at: ' + cfg.nodePath + '\n'
40
+ + 'Use the optional `cwd` parameter to set the working directory.';
41
+ }
42
+ function textRender(fn) {
43
+ return (_args, value) => [{ type: 'text', text: fn(value) }];
44
+ }
45
+ export function renderRunCodeOutput(value) {
46
+ const lines = [];
47
+ lines.push(`Exit code: ${value.exit_code} (${value.duration_ms}ms)`);
48
+ if (value.timed_out)
49
+ lines.push('Process was killed after exceeding the timeout.');
50
+ if (value.cancelled)
51
+ lines.push('Process was cancelled by an abort signal.');
52
+ if (value.stdout)
53
+ lines.push(`--- stdout ---\n${value.stdout}`);
54
+ if (value.stderr)
55
+ lines.push(`--- stderr ---\n${value.stderr}`);
56
+ return lines.join('\n');
57
+ }
58
+ const parametersSchema = {
59
+ code: { type: 'string', required: true, description: 'The code to execute.' },
60
+ cwd: { type: 'string', description: 'Optional working directory for the process.' },
61
+ };
62
+ const outputSchema = {
63
+ type: 'object',
64
+ additionalProperties: false,
65
+ properties: {
66
+ ok: { type: 'boolean', required: true, description: 'True if the process exited with code 0.' },
67
+ exit_code: { type: 'integer', required: true, description: 'Process exit code (-1 if the process failed to start).' },
68
+ stdout: { type: 'string', required: true, description: 'Captured stdout output.' },
69
+ stderr: { type: 'string', required: true, description: 'Captured stderr output.' },
70
+ duration_ms: { type: 'integer', required: true, description: 'Wall-clock execution time in milliseconds.' },
71
+ timed_out: { type: 'boolean', required: true, description: 'True if the process was killed due to timeout.' },
72
+ cancelled: { type: 'boolean', required: true, description: 'True if the process was killed due to an abort signal.' },
73
+ },
74
+ };
75
+ /**
76
+ * Register `run_python` and `run_node` tools with descriptions that embed
77
+ * the interpreter paths from `cfg`. Returns a disposer that unregisters
78
+ * both tools — call it before re-registering with a fresh config.
79
+ */
80
+ export function registerTools(ctx, cfg) {
81
+ const disposers = [];
82
+ disposers.push(ctx.tools.register(defineTool({
83
+ name: 'run_python',
84
+ description: buildPythonDescription(cfg),
85
+ parameters: parametersSchema,
86
+ output: {
87
+ schema: outputSchema,
88
+ render: textRender(renderRunCodeOutput),
89
+ },
90
+ execute: async (args, exec) => {
91
+ const a = args;
92
+ return runCode(cfg.pythonPath, a.code, a.cwd, cfg.timeoutMs, exec.signal);
93
+ },
94
+ })));
95
+ disposers.push(ctx.tools.register(defineTool({
96
+ name: 'run_node',
97
+ description: buildNodeDescription(cfg),
98
+ parameters: parametersSchema,
99
+ output: {
100
+ schema: outputSchema,
101
+ render: textRender(renderRunCodeOutput),
102
+ },
103
+ execute: async (args, exec) => {
104
+ const a = args;
105
+ return runCode(cfg.nodePath, a.code, a.cwd, cfg.timeoutMs, exec.signal);
106
+ },
107
+ })));
108
+ return () => { for (const dispose of disposers)
109
+ dispose(); };
110
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * InterpretersCard — the `settings.plugin.item` card for the interpreters
3
+ * configuration.
4
+ *
5
+ * Self-drawn chrome replicating the upstream `PluginCard` contract: the
6
+ * upstream client value face exports no reusable card component, so this
7
+ * card draws its own collapsible `<li>` with the same header button (name
8
+ * over description, dirty pill, rotating chevron, aria) and divided body
9
+ * (readOnly notice, form fields, footer with failed/saved message +
10
+ * Discard/Save). Three fields (pythonPath, nodePath, timeoutMs) are staged
11
+ * through the card's controller; save commits them through the
12
+ * `/api/interpreters/set` gateway channel.
13
+ *
14
+ * @module dsh-interpreters/client/InterpretersCard
15
+ */
16
+ import { type ReactNode } from 'react';
17
+ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
18
+ import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react';
19
+ import { InterpretersCardController, type InterpretersCardState } from './store.ts';
20
+ import type { InterpretersKey } from './locales.ts';
21
+ /** Injected dependencies of {@link InterpretersCard} (slot `inject`). */
22
+ export interface InterpretersCardInjected {
23
+ /** The card controller (loaded on mount, refreshed on pushed invalidations). */
24
+ controller: InterpretersCardController;
25
+ /** uSES subscription hook bound to the store. */
26
+ useSnapshot: SnapshotSelectorHook<InterpretersCardState>;
27
+ }
28
+ /** Props the renderer binds for the card. */
29
+ export type InterpretersCardProps = PropsRuntime<'settings.plugin.item'> & PropsLocale<'interpreters'> & InjectFace<InterpretersCardInjected>;
30
+ /**
31
+ * Render the interpreters card inside the plugin-config section, replicating
32
+ * the upstream PluginCard chrome.
33
+ * @param props - slot-delivered injected dependencies and the synthesized t seat.
34
+ * @returns the card.
35
+ */
36
+ export declare function InterpretersCard(props: InterpretersCardProps): ReactNode;
37
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
38
+ interface LocaleNamespaceMap {
39
+ /** The interpreters card copy. */
40
+ 'interpreters': InterpretersKey;
41
+ }
42
+ }
@@ -0,0 +1,74 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * InterpretersCard — the `settings.plugin.item` card for the interpreters
4
+ * configuration.
5
+ *
6
+ * Self-drawn chrome replicating the upstream `PluginCard` contract: the
7
+ * upstream client value face exports no reusable card component, so this
8
+ * card draws its own collapsible `<li>` with the same header button (name
9
+ * over description, dirty pill, rotating chevron, aria) and divided body
10
+ * (readOnly notice, form fields, footer with failed/saved message +
11
+ * Discard/Save). Three fields (pythonPath, nodePath, timeoutMs) are staged
12
+ * through the card's controller; save commits them through the
13
+ * `/api/interpreters/set` gateway channel.
14
+ *
15
+ * @module dsh-interpreters/client/InterpretersCard
16
+ */
17
+ import { useState } from 'react';
18
+ import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives';
19
+ import { formatFieldNumber, formatFieldText, } from "./store.js";
20
+ import styles from './InterpretersCard.module.css';
21
+ /**
22
+ * Render the interpreters card inside the plugin-config section, replicating
23
+ * the upstream PluginCard chrome.
24
+ * @param props - slot-delivered injected dependencies and the synthesized t seat.
25
+ * @returns the card.
26
+ */
27
+ export function InterpretersCard(props) {
28
+ const { controller, useSnapshot, t } = props;
29
+ const state = useSnapshot(snapshot => snapshot);
30
+ // Load-on-mount: the plugin-config page mounts the card lazily when the
31
+ // user opens the settings panel, so the first mount triggers the first
32
+ // gateway load.
33
+ if (state.status === 'idle')
34
+ void controller.load();
35
+ // Disclosure is card-local USER state (upstream rationale): the healthy
36
+ // card starts collapsed and opens on the header click only. The degraded
37
+ // (unavailable) card renders its notice body ALWAYS visible, so `open` is
38
+ // DERIVED from the current snapshot.
39
+ const [userOpen, setUserOpen] = useState(false);
40
+ const degraded = state.status === 'ready' && !state.available;
41
+ const open = userOpen || degraded;
42
+ const title = t('title');
43
+ const header = (_jsxs("button", { type: "button", className: styles.header, "aria-expanded": open, "aria-label": `${t(open ? 'collapse' : 'expand')}: ${title}`,
44
+ // While degraded the derived open is forced true, so the click must be
45
+ // a no-op — toggling userOpen would silently latch it and pre-open the
46
+ // recovered form.
47
+ onClick: () => { if (!degraded)
48
+ setUserOpen(!userOpen); }, children: [_jsxs("span", { className: styles.headText, children: [_jsx("span", { className: styles.name, children: title }), _jsx("span", { className: styles.description, children: t('intro') })] }), state.dirty ? _jsx("span", { className: styles.pending, children: t('unsaved') }) : null, _jsx(IconChevronDownOutline14, { className: open ? `${styles.chevron} ${styles.chevronOpen}` : styles.chevron })] }));
49
+ let body;
50
+ if (degraded) {
51
+ // The gateway channel is down or the namespace is not served to this
52
+ // client — render the explicit notice and never offer Save.
53
+ body = (_jsxs("div", { className: styles.body, children: [_jsx("p", { className: styles.notice, role: "status", children: t('namespaceUnavailable') }), _jsx("div", { className: styles.footer, children: _jsx("button", { type: "button", className: styles.discard, onClick: () => { void controller.load(); }, children: t('retry') }) })] }));
54
+ }
55
+ else if (state.status === 'ready') {
56
+ const { draft, writable, applyState } = state;
57
+ const saving = applyState.kind === 'saving';
58
+ const busy = !writable || saving;
59
+ const saveDisabled = !state.dirty || saving || !writable;
60
+ const discardDisabled = !state.dirty || saving;
61
+ const errorText = applyState.kind === 'error' ? applyState.message : undefined;
62
+ body = (_jsxs("div", { className: styles.body, children: [!writable ? _jsx("p", { className: styles.readOnly, role: "status", children: t('readOnly') }) : null, applyState.kind === 'saved' ? _jsx("p", { className: styles.savedNotice, role: "status", children: t('save') }) : null, _jsxs("div", { className: styles.form, children: [_jsx(Field, { id: "plugin-config-interpreters-python", label: t('pythonPath'), hint: t('pythonHelp'), text: formatFieldText(draft.pythonPath), disabled: busy, onEdit: (text) => { controller.edit('pythonPath', text); } }), _jsx(Field, { id: "plugin-config-interpreters-node", label: t('nodePath'), hint: t('nodeHelp'), text: formatFieldText(draft.nodePath), disabled: busy, onEdit: (text) => { controller.edit('nodePath', text); } }), _jsx(Field, { id: "plugin-config-interpreters-timeout", label: t('timeoutMs'), hint: t('timeoutHelp'), text: formatFieldNumber(draft.timeoutMs), numeric: true, disabled: busy, onEdit: (text) => { controller.edit('timeoutMs', text); } })] }), _jsxs("div", { className: styles.footer, children: [errorText === undefined ? null : _jsx("p", { className: styles.failed, role: "status", children: errorText }), _jsx("button", { type: "button", className: styles.discard, disabled: discardDisabled, onClick: () => { controller.discard(); }, children: t('discard') }), _jsx("button", { type: "button", className: styles.save, disabled: saveDisabled, onClick: () => { controller.save(); }, children: t(saving ? 'saving' : 'save') })] })] }));
63
+ }
64
+ else {
65
+ // Loading (or the idle→loading transition): the header alone — an open
66
+ // card shows an empty body.
67
+ body = _jsx("div", { className: styles.body });
68
+ }
69
+ return (_jsxs("li", { className: open ? `${styles.card} ${styles.cardOpen}` : styles.card, children: [header, open ? body : null] }));
70
+ }
71
+ /** One staged field control (text or numeric). */
72
+ function Field(props) {
73
+ return (_jsxs("div", { className: styles.field, children: [_jsx("label", { className: styles.fieldLabel, htmlFor: props.id, children: props.label }), _jsx("input", { id: props.id, className: styles.input, type: props.numeric ? 'number' : 'text', ...props.numeric ? { inputMode: 'numeric' } : {}, value: props.text, disabled: props.disabled, onChange: (event) => { props.onEdit(event.target.value); } }), _jsx("p", { className: styles.hint, children: props.hint })] }));
74
+ }