/** * Serializable configuration and defaults for the sidebar host half. Loader * schema validation normally fills defaults; {@link resolveSidebarConfig} * applies the same defaults for direct callers that bypass the Loader. * @module dsh-apes-sidebar/config */ import z from '@deepseek-ai/schemastery' import { TERMINAL_FONT_SIZE_DEFAULT, TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN, TITLE_BAR_STRIP_DEFAULT, TITLE_BAR_STRIP_MAX, TITLE_BAR_STRIP_MIN, type SidebarPrefs, } from './prefs-shared.ts' export { SIDEBAR_PREFS_DEFAULTS, SIDEBAR_PREFS_NS, TERMINAL_FONT_SIZE_DEFAULT, TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN, TITLE_BAR_STRIP_DEFAULT, TITLE_BAR_STRIP_MAX, TITLE_BAR_STRIP_MIN, type SidebarPrefs, } from './prefs-shared.ts' /** * Tunable sidebar host limits (every field optional; defaults fill in). * * Since 0.2.0-rc.2 the loader auto-registers the plugin's `Config` schema * under ns = package name, and the settings service's `update()` only accepts * patches to fields marked `volatile`. The user-facing side-card preferences * (formerly the dead `PrefsSchema` export) are therefore MERGED into this * schema with `extra('volatile', true)` so the settings routes can persist * them. The deployment fields above (readLimit/shell/…) are deliberately * NON-volatile: they are set from `cordis.patch.yml` / profile config, not * from the user's settings page, and must not be writable through * `settings.update`. */ export interface SidebarConfig extends Partial { /** Read cap of one text file (bytes); larger files return truncated. */ readLimit?: number /** Media route cap (bytes); larger binaries are refused. */ mediaLimit?: number /** Upload route cap (bytes); larger files are refused without touching disk. */ uploadLimit?: number /** Explorer row bound of one level. */ listLimit?: number /** Terminals per session. */ terminalsPerSession?: number /** How long a disconnected terminal process survives awaiting a reconnect. */ reconnectGraceMs?: number /** * Terminal shell (absolute path or bare executable name) for BOTH the UI * terminal tabs and the model-facing `terminal_*` tools. Empty = auto: * POSIX follows `$SHELL` then the account login shell; Windows follows * `DSH_SIDEBAR_SHELL`, then probes for `pwsh.exe`, then falls back to the * inbox `powershell.exe` (5.1). Set it from `cordis.patch.yml` / profile * plugin config, e.g. `config: { shell: /bin/zsh }`. */ shell?: string /** * Optional arguments passed to the shell executable. When non-empty these * REPLACE the automatic platform defaults (POSIX `-l` / Windows none), so * the deployment has full control over how the shell starts. When omitted * the existing default behavior is kept. */ shellArgs?: string[] } /** Schemastery schema for the plugin configuration (deployment + user prefs). */ export const Config: z = z.object({ // ── Deployment fields (NON-volatile; set from cordis.patch.yml / profile) ── readLimit: z.number().step(1).min(1).default(512 * 1024), mediaLimit: z.number().step(1).min(1).default(20 * 1024 * 1024), uploadLimit: z.number().step(1).min(1).default(128 * 1024 * 1024), listLimit: z.number().step(1).min(1).default(1000), terminalsPerSession: z.number().step(1).min(1).default(3), reconnectGraceMs: z.number().step(1).min(0).default(30_000), shell: z.string().default(''), shellArgs: z.array(z.string()).default([]), // ── User-facing side-card preferences (volatile; writable via settings.update) ── // 0.2.0-rc.2: the settings service's `update()` only accepts patches to fields // whose schema node (or an ancestor) is marked `volatile`. Every user-editable // preference below is tagged `extra('volatile', true)` so the /sidebar/settings // routes can persist them. These were formerly the dead `PrefsSchema` export; // merging them into `Config` (the schema the loader actually registers) is what // makes the settings page switches stick instead of 400 → optimistic rollback. autoOpenSubagent: z.boolean().default(true).extra('volatile', true), autoOpenJobs: z.boolean().default(true).extra('volatile', true), agentTerminalTools: z.boolean().default(false).extra('volatile', true), agentOpenTools: z.boolean().default(false).extra('volatile', true), bottomPanelAutoTerminal: z.boolean().default(true).extra('volatile', true), terminalFontFamily: z.string().default('').extra('volatile', true), terminalFontSize: z.number().step(1).min(TERMINAL_FONT_SIZE_MIN).max(TERMINAL_FONT_SIZE_MAX).default(TERMINAL_FONT_SIZE_DEFAULT).extra('volatile', true), editorExplorer: z.boolean().default(false).extra('volatile', true), workspaceFence: z.boolean().default(true).extra('volatile', true), terminalShell: z.string().default('').extra('volatile', true), terminalShellArgs: z.string().default('').extra('volatile', true), titleBarScheme: z.union([z.const('auto'), z.const('web'), z.const('preset'), z.const('custom')]).extra('volatile', true), titleBarPresetId: z.string().extra('volatile', true), customCss: z.string().extra('volatile', true), titleBarCompat: z.boolean().default(false).extra('volatile', true), titleBarStripPx: z.number().step(1).min(TITLE_BAR_STRIP_MIN).max(TITLE_BAR_STRIP_MAX).default(TITLE_BAR_STRIP_DEFAULT).extra('volatile', true), htmlViewerNoSandbox: z.boolean().default(false).extra('volatile', true), htmlViewerDefaultUnsafe: z.boolean().default(false).extra('volatile', true), browserNoSandbox: z.boolean().default(false).extra('volatile', true), browserInterceptLinks: z.boolean().default(true).extra('volatile', true), browserInterceptHttp: z.boolean().default(true).extra('volatile', true), browserInterceptHttps: z.boolean().default(false).extra('volatile', true), browserAllowedLoopback: z.string().default('').extra('volatile', true), // Per-feature enable switches are OPEN maps (any tab/viewer id, built-in or // external): an absent key means enabled, so old documents resolve to {} // (everything on) with no migration. Non-boolean values fail validation. // The whole dict node is volatile, so any key beneath it is writable. tabsEnabled: z.dict(z.boolean()).default({}).extra('volatile', true), viewersEnabled: z.dict(z.boolean()).default({}).extra('volatile', true), // Plugin-owned settings blobs (v0.12.0+) are an OPEN nested map: any // descriptor id may carry any JSON-serializable values. This is the // "settings seam" opening — without it the seam would drop third-party // keys as unknown schema fields. pluginSettings: z.dict(z.dict(z.any())).default({}).extra('volatile', true), }) /** Fully defaulted sidebar host settings. */ export interface ResolvedSidebarConfig { readLimit: number mediaLimit: number uploadLimit: number listLimit: number terminalsPerSession: number reconnectGraceMs: number /** The configured terminal shell; empty means the host auto-resolves it. */ shell: string /** Explicit shell arguments; empty means use the platform defaults. */ shellArgs: string[] } /** * Apply direct-call defaults after Loader schema validation has normally run. * * @param config - Deployment-provided sidebar host settings. * @returns Complete settings consumed by the host half. */ export function resolveSidebarConfig(config: SidebarConfig | undefined): ResolvedSidebarConfig { return { readLimit: config?.readLimit ?? 512 * 1024, mediaLimit: config?.mediaLimit ?? 20 * 1024 * 1024, uploadLimit: config?.uploadLimit ?? 128 * 1024 * 1024, listLimit: config?.listLimit ?? 1000, terminalsPerSession: config?.terminalsPerSession ?? 3, reconnectGraceMs: config?.reconnectGraceMs ?? 30_000, shell: config?.shell?.trim() ?? '', shellArgs: config?.shellArgs ?? [], } } // ── User-facing "Side card" preferences ───────────────────────────────────── /** Schemastery schema for the user-facing preferences (validated by the settings service). */ export const PrefsSchema: z = z.object({ autoOpenSubagent: z.boolean().default(true), autoOpenJobs: z.boolean().default(true), agentTerminalTools: z.boolean().default(false), agentOpenTools: z.boolean().default(false), bottomPanelAutoTerminal: z.boolean().default(true), terminalFontFamily: z.string().default(''), terminalFontSize: z.number().step(1).min(TERMINAL_FONT_SIZE_MIN).max(TERMINAL_FONT_SIZE_MAX).default(TERMINAL_FONT_SIZE_DEFAULT), editorExplorer: z.boolean().default(false), workspaceFence: z.boolean().default(true), terminalShell: z.string().default(''), terminalShellArgs: z.string().default(''), titleBarScheme: z.union([z.const('auto'), z.const('web'), z.const('preset'), z.const('custom')]), titleBarPresetId: z.string(), customCss: z.string(), titleBarCompat: z.boolean().default(false), titleBarStripPx: z.number().step(1).min(TITLE_BAR_STRIP_MIN).max(TITLE_BAR_STRIP_MAX).default(TITLE_BAR_STRIP_DEFAULT), htmlViewerNoSandbox: z.boolean().default(false), htmlViewerDefaultUnsafe: z.boolean().default(false), browserNoSandbox: z.boolean().default(false), browserInterceptLinks: z.boolean().default(true), browserInterceptHttp: z.boolean().default(true), browserInterceptHttps: z.boolean().default(false), browserAllowedLoopback: z.string().default(''), // Per-feature enable switches are OPEN maps (any tab/viewer id, built-in or // external): an absent key means enabled, so old documents resolve to {} // (everything on) with no migration. Non-boolean values fail validation. tabsEnabled: z.dict(z.boolean()).default({}), viewersEnabled: z.dict(z.boolean()).default({}), // Plugin-owned settings blobs (v0.12.0+) are an OPEN nested map: any // descriptor id may carry any JSON-serializable values. This is the // "settings seam" opening — without it the seam would drop third-party // keys as unknown schema fields. pluginSettings: z.dict(z.dict(z.any())).default({}), })