diff --git a/README.md b/README.md index b9ca04a..cf9227b 100644 --- a/README.md +++ b/README.md @@ -113,10 +113,15 @@ dsh plugin --profile web add dsh-better-sidebar@latest 1. git clone https://github.com/omdsh-dev/DSH-better-sidebar.git ~/Code/DSH-better-sidebar cd ~/Code/DSH-better-sidebar && pnpm install && pnpm build 2. ~/.dsh/profiles/web/package.json 的 dependencies 写 "dsh-better-sidebar": "link:<克隆目录绝对路径>" -3. ~/.dsh/profiles/web/cordis.patch.yml 追加挂载行: +3. ~/.dsh/profiles/web/cordis.patch.yml 追加挂载行(需要指定终端 shell 时,在行内加 `config.shell`;`config.shellArgs` 可带参启动,非空时替换默认的 `-l`。不填则自动解析 `$SHELL` / 登录 shell / powershell.exe): - insert: - id: better-sidebar name: 'dsh-better-sidebar' + config: + shell: /bin/zsh + shellArgs: + - --noprofile + - --no-rc 4. 在 ~/.dsh/profiles/web 执行 pnpm install 5. 硬刷新浏览器(Cmd/Ctrl+Shift+R)即可看到效果(client 改动无需重启 DSH;host 半改动才需重启) ``` diff --git a/README_EN.md b/README_EN.md index 5f36d68..fbe6b7d 100644 --- a/README_EN.md +++ b/README_EN.md @@ -144,10 +144,15 @@ To debug local changes or track the dev branch, point the dependency at a local 1. git clone https://github.com/omdsh-dev/DSH-better-sidebar.git ~/Code/DSH-better-sidebar cd ~/Code/DSH-better-sidebar && pnpm install && pnpm build 2. In ~/.dsh/profiles/web/package.json dependencies write "dsh-better-sidebar": "link:" -3. Append this mount line to ~/.dsh/profiles/web/cordis.patch.yml: +3. Append this mount line to ~/.dsh/profiles/web/cordis.patch.yml (to pick the terminal shell, add `config.shell`; `config.shellArgs` starts it with explicit args — when non-empty they replace the default `-l`. When omitted the host resolves `$SHELL` / the login shell / powershell.exe): - insert: - id: better-sidebar name: 'dsh-better-sidebar' + config: + shell: /bin/zsh + shellArgs: + - --noprofile + - --no-rc 4. Run pnpm install in ~/.dsh/profiles/web 5. Restart DSH and hard-refresh ``` diff --git a/cordis.patch.yml b/cordis.patch.yml index d071766..193675a 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -14,6 +14,21 @@ # If the profile still carries the old manual mount line in its own # cordis.patch.yml, remove it before switching to the bundle channel to # avoid double-mounting (two Node halves, two sidebars). +# +# To choose the terminal shell, add `config.shell` to the inserted plugin +# row (or to the profile's manual cordis.patch.yml mount line). When omitted, +# the host keeps resolving $SHELL / the login shell / powershell.exe. +# `config.shellArgs` optionally supplies explicit startup arguments; when +# non-empty they replace the automatic POSIX `-l` login flag: +# +# - insert: +# - id: better-sidebar +# name: 'dsh-better-sidebar' +# config: +# shell: /bin/zsh +# shellArgs: +# - --noprofile +# - --no-rc - insert: - id: better-sidebar name: 'dsh-better-sidebar' diff --git a/src/agent-pty.ts b/src/agent-pty.ts index 32555b2..ce54ca3 100644 --- a/src/agent-pty.ts +++ b/src/agent-pty.ts @@ -192,6 +192,7 @@ export class AgentPtyRegistry { constructor( private readonly shell: string, + private readonly shellArgs: string[] = [], /** The loaded node-pty module (injected so a broken install degrades instead of crashing the plugin). */ private readonly nodePty: NodePtyModule = loadRequiredNodePty(), ) { @@ -216,7 +217,7 @@ export class AgentPtyRegistry { ): string { const uuid = randomUUID() const dims = clampDims(cols, rows) - const pty = this.nodePty.spawn(this.shell, shellSpawnArgs(), { + const pty = this.nodePty.spawn(this.shell, shellSpawnArgs(this.shellArgs), { name: 'xterm-256color', cols: dims.cols, rows: dims.rows, diff --git a/src/client/api.ts b/src/client/api.ts index f7cf822..216916a 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -201,6 +201,9 @@ export const api = { id, ...(reason !== undefined ? { reason } : {}), })), + /** The effective terminal shell and its display name (plugin-global). */ + shellGet: () => + call<{ shell: string; name: string }>('shell.get', {}), /** Read the side card preferences (plugin-global, no session scope). */ settingsGet: () => call<{ value?: unknown; revision?: number; externalDisable?: boolean }>('settings.get', {}), diff --git a/src/client/builtins/index.ts b/src/client/builtins/index.ts index bad5352..b7ec72b 100644 --- a/src/client/builtins/index.ts +++ b/src/client/builtins/index.ts @@ -8,7 +8,7 @@ */ import type { Context } from '../../context-types.ts' import type { BetterSidebarService } from '../service.ts' -import { builtinTabs } from './tabs.tsx' +import { builtinTabs, type BuiltinTabOptions } from './tabs.tsx' import { builtinViewers } from './viewers.tsx' /** @@ -17,9 +17,13 @@ import { builtinViewers } from './viewers.tsx' * disposal). The `ctx` is threaded into tab descriptors that need it * (EditorHost reads `ctx.betterSidebar` for file-viewer matching). */ -export function registerBuiltins(ctx: Context, service: BetterSidebarService): () => void { +export function registerBuiltins( + ctx: Context, + service: BetterSidebarService, + options: BuiltinTabOptions = {}, +): () => void { const disposers: (() => void)[] = [] - for (const tab of builtinTabs(ctx)) { + for (const tab of builtinTabs(ctx, options)) { disposers.push(service.registerTab(tab)) } for (const viewer of builtinViewers()) { diff --git a/src/client/builtins/tabs.tsx b/src/client/builtins/tabs.tsx index e48212d..1eca8d3 100644 --- a/src/client/builtins/tabs.tsx +++ b/src/client/builtins/tabs.tsx @@ -3,7 +3,7 @@ * (editor / git / terminal / browser / subagent / diff) through * the same {@link BetterSidebarService} external plugins use — eating its * own dogfood. The terminal descriptor owns its quota (`TERMINAL_LIMIT`) - * and mints `terminal:` ids through `createTab`; the browser mints + * and mints `terminal:` ids through `createTab`; the browser mints * `browser:` the same way (no quota). The editor IS the files window * (the old standalone explorer merged into it). */ @@ -52,6 +52,20 @@ interface TerminalViewProps { /** How many UI-owned terminals may be open at once (agent-owned ones are uncapped). */ export const TERMINAL_LIMIT = 3 +/** Optional per-registration builtin behavior (currently terminal title). */ +export interface BuiltinTabOptions { + /** Returns the display title for newly opened terminal tabs. */ + terminalTitle?: () => string +} + +/** A client-side uuid for terminal tab identity (not shown in the UI). */ +function terminalUuid(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID() + } + return `t${Date.now().toString(36)}${Math.random().toString(36).slice(2)}` +} + /** Count UI-owned terminals (agent:` tabs excluded — they are the model's). */ function uiTerminalCount(state: SidebarState): number { return allLeaves(state.splits) @@ -60,7 +74,7 @@ function uiTerminalCount(state: SidebarState): number { } /** The 6 built-in tab descriptors. */ -export function builtinTabs(ctx: Context): readonly TabDescriptor[] { +export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): readonly TabDescriptor[] { return [ { id: 'editor', @@ -192,10 +206,12 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] { if (count >= TERMINAL_LIMIT) return null return { tab: { - id: `terminal:${state.nextTerminal}`, + id: `terminal:${terminalUuid()}`, type: 'terminal', - title: `${t('terminal')} ${state.nextTerminal}`, + title: options.terminalTitle?.() ?? t('terminal'), }, + // Keep the legacy counter advancing for compatibility with older + // persisted states; new ids no longer use it. patch: { nextTerminal: state.nextTerminal + 1 }, } }, diff --git a/src/client/index.tsx b/src/client/index.tsx index c2a4b90..04a263e 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -11,7 +11,7 @@ import { createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import type { Context } from '../context-types.ts' -import { createSidebarStore } from './state.ts' +import { allLeaves, createSidebarStore, isAgentTabId } from './state.ts' import { createBetterSidebarService, matchUrlTarget } from './service.ts' import { resetChunks } from './chunk-loader.ts' import { registerBuiltins } from './builtins/index.ts' @@ -66,11 +66,31 @@ export function apply(ctx: Context): void { // are ready by the time the sidebar renders. const service = createBetterSidebarService(sidebarStore) ctx.provide('betterSidebar', service) + // Terminal tab titles use the host's effective shell name (e.g. bash/zsh) + // instead of "Terminal 1". Start with a safe fallback and replace it as + // soon as the host shell info resolves. Tabs created before the response + // arrives keep the fallback title, so also retitle any already-open UI + // terminal tabs that still carry it. + const fallbackTitle = t('terminal') + let terminalTitle = fallbackTitle + void api.shellGet().then(({ name }) => { + terminalTitle = name + const snapshot = service.getSnapshot() + if (snapshot.state === undefined) return + const tabs = allLeaves(snapshot.state.splits) + .concat(allLeaves(snapshot.state.bottomSplits)) + .flatMap(leaf => leaf.tabs) + for (const tab of tabs) { + if (tab.type === 'terminal' && !isAgentTabId(tab.id) && tab.title === fallbackTitle) { + service.updateTab(tab.id, { title: name }) + } + } + }).catch(() => { /* keep fallback */ }) // Register the plugin's own built-in tabs and viewers through the same // service (eating our own dogfood). The disposer unregisters them on // fiber disposal (HMR-safe). ctx.effect( - () => registerBuiltins(ctx, service), + () => registerBuiltins(ctx, service, { terminalTitle: () => terminalTitle }), 'dsh-better-sidebar: register built-in tabs and viewers', ) // A failure anywhere in the client lifecycle must never take the app down diff --git a/src/config.ts b/src/config.ts index f7b3a66..614e592 100644 --- a/src/config.ts +++ b/src/config.ts @@ -53,9 +53,17 @@ export interface SidebarConfig { * 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). + * 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. */ @@ -66,6 +74,7 @@ export const Config: z = z.object({ 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([]), }) /** Fully defaulted sidebar host settings. */ @@ -75,7 +84,10 @@ export interface ResolvedSidebarConfig { 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[] } /** @@ -92,6 +104,7 @@ export function resolveSidebarConfig(config: SidebarConfig | undefined): Resolve terminalsPerSession: config?.terminalsPerSession ?? 3, reconnectGraceMs: config?.reconnectGraceMs ?? 30_000, shell: config?.shell?.trim() ?? '', + shellArgs: config?.shellArgs ?? [], } } diff --git a/src/index.ts b/src/index.ts index 62d32ce..0ae8f4f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,7 +36,7 @@ import { isTrustedApiRequest, isLoopbackHostname } from './trust-fence.ts' import { registerBundleRoute } from './bundle-route.ts' import * as git from './git.ts' import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings' -import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts' +import { defaultShell, ensureSpawnHelper, PtyManager, shellDisplayName } from './pty-manager.ts' import { AgentPtyRegistry, clampDims, type AgentTerminalHandle } from './agent-pty.ts' import { DSH_NODE_PTY_RANGE, @@ -191,12 +191,13 @@ export interface SidebarSettingsFace { update(patch: Record, expectedRevision?: number): Promise<{ value?: unknown; revision?: number }> } -/** Build the API method table bound to the plugin context, pty manager, agent pty registry, and resolved config. */ +/** Build the API method table bound to the plugin context, pty manager, agent pty registry, resolved config, and effective terminal shell. */ function buildApi( ctx: Context, ptyManager: PtyManager | null, agentPtyRegistry: AgentPtyRegistry | null, resolved: ResolvedSidebarConfig, + terminalShell: string, getSettings: () => SidebarSettingsFace | undefined, ): Record { const cwdOf = (payload: unknown): { sessionId: string; cwd: string } => { @@ -363,6 +364,11 @@ function buildApi( // exists. Kill is fenced to the owning session by the jobs registry. 'jobs.output': (payload) => jobsApi.output(payload), 'jobs.kill': (payload) => jobsApi.kill(payload), + // The effective terminal shell and its display name. The client uses + // this to title terminal tabs with the shell name instead of a numbered + // "Terminal N" label; the shell itself is configured through + // `cordis.patch.yml` (`config.shell`) or resolved by the host default. + 'shell.get': () => ({ shell: terminalShell, name: shellDisplayName(terminalShell) }), // The side card preferences. The settings service is optional in the // composition; while absent the routes report undefined and the client // keeps the schema defaults. Writes are revision-guarded: a stale editor @@ -480,13 +486,17 @@ export function apply(ctx: Context, config?: SidebarConfig): void { : `${status.cause}. Repair: ${status.command}` ctx.logger?.warn(`[dsh-better-sidebar] node-pty (${DSH_NODE_PTY_RANGE}) failed to load: ${detail}`) } - const ptyManager = nodePty !== null ? new PtyManager(terminalShell, resolved.terminalsPerSession, nodePty) : null + const ptyManager = nodePty !== null + ? new PtyManager(terminalShell, resolved.terminalsPerSession, resolved.shellArgs, nodePty) + : null // The agent-owned terminal registry: parallel to the UI-tab ptyManager, // keyed by uuid (the model's opaque handle) instead of `${sessionId}:${tabId}`, // uncapped, and torn down with the plugin. The model creates terminals here // through the terminal_create tool; the sidebar view attaches through the // same /sidebar/ws/terminal upgrade with ?uuid=... instead of ?tab=... - const agentPtyRegistry = nodePty !== null ? new AgentPtyRegistry(terminalShell, nodePty) : null + const agentPtyRegistry = nodePty !== null + ? new AgentPtyRegistry(terminalShell, resolved.shellArgs, nodePty) + : null // ── User-facing "Side card" preferences ────────────────────────────────── // Register the namespace with the settings provider so the Settings page @@ -560,7 +570,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void { }) // ── JSON API ──────────────────────────────────────────────────────────── - const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, () => settingsFace) + const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, terminalShell, () => settingsFace) ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/sidebar/api', diff --git a/src/pty-manager.ts b/src/pty-manager.ts index 41a80fa..ae86168 100644 --- a/src/pty-manager.ts +++ b/src/pty-manager.ts @@ -72,6 +72,7 @@ export class PtyManager { constructor( private readonly shell: string, private readonly maxPerSession: number, + private readonly shellArgs: string[] = [], /** The loaded node-pty module (injected so a broken install degrades instead of crashing the plugin). */ private readonly nodePty: NodePtyModule = loadRequiredNodePty(), ) {} @@ -122,7 +123,7 @@ export class PtyManager { sessionId, tabId, cwd, - pty: this.nodePty.spawn(this.shell, shellSpawnArgs(), { + pty: this.nodePty.spawn(this.shell, shellSpawnArgs(this.shellArgs), { name: 'xterm-256color', cols: Math.max(2, Math.floor(cols)), rows: Math.max(2, Math.floor(rows)), @@ -294,11 +295,27 @@ export function defaultShell(options: ShellResolutionOptions = {}): string { return '/bin/bash' } +/** + * A short display name for a shell executable, used as the terminal tab + * title. `/bin/zsh` → `zsh`, `C:\...\powershell.exe` → `powershell`. + * Falls back to the raw value when no basename can be derived. + */ +export function shellDisplayName(shell: string): string { + const normalized = shell.replace(/\\/g, '/') + const base = normalized.slice(normalized.lastIndexOf('/') + 1) + if (base === '') return shell + return base.replace(/\.(exe|cmd|bat)$/i, '') +} + /** * Spawn arguments that make the shell behave like a terminal-emulator tab: * POSIX shells start as login shells (`-l`) so they read the profile files * (`~/.profile`, `~/.zprofile`); Windows PowerShell takes no login flag. + * + * When explicit `configured` args are supplied they REPLACE the platform + * defaults entirely, giving deployments full control over shell startup. */ -export function shellSpawnArgs(): string[] { +export function shellSpawnArgs(configured: string[] = []): string[] { + if (configured.length > 0) return [...configured] return process.platform === 'win32' ? [] : ['-l'] } diff --git a/tests/builtins.spec.ts b/tests/builtins.spec.ts index 80908a3..c556b2f 100644 --- a/tests/builtins.spec.ts +++ b/tests/builtins.spec.ts @@ -15,11 +15,13 @@ import { createBetterSidebarService } from '../src/client/service.ts' import { createSidebarStore } from '../src/client/state.ts' import { allLeaves } from '../src/client/state.ts' import { registerBuiltins } from '../src/client/builtins/index.ts' +import type { BuiltinTabOptions } from '../src/client/builtins/tabs.tsx' +import { t } from '../src/client/locales.ts' -function setup(): { service: ReturnType; store: ReturnType; dispose: () => void } { +function setup(options: BuiltinTabOptions = {}): { service: ReturnType; store: ReturnType; dispose: () => void } { const store = createSidebarStore() const service = createBetterSidebarService(store) - const dispose = registerBuiltins({} as Context, service) + const dispose = registerBuiltins({} as Context, service, options) return { service, store, dispose } } @@ -111,6 +113,30 @@ describe('built-in tab registrations', () => { expect(state.nextBrowser).toBe(3) }) + it('the terminal createTab uses shell-name titles and hidden uuid ids, allowing duplicates', () => { + const { service, store } = setup({ terminalTitle: () => 'bash' }) + store.setSession('s1') + service.openTab({ type: 'terminal' }) + service.openTab({ type: 'terminal' }) + const state = store.getSnapshot().state! + const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs).filter(t => t.type === 'terminal') + expect(tabs).toHaveLength(2) + expect(tabs[0]!.title).toBe('bash') + expect(tabs[1]!.title).toBe('bash') + expect(tabs[0]!.id).toMatch(/^terminal:[0-9a-f-]{36}$/) + expect(tabs[1]!.id).toMatch(/^terminal:[0-9a-f-]{36}$/) + expect(tabs[0]!.id).not.toBe(tabs[1]!.id) + }) + + it('the terminal createTab falls back to the localized terminal label before shell info resolves', () => { + const { service, store } = setup() + store.setSession('s1') + service.openTab({ type: 'terminal' }) + const state = store.getSnapshot().state! + const tab = allLeaves(state.splits).flatMap(leaf => leaf.tabs).find(t => t.type === 'terminal') + expect(tab?.title).toBe(t('terminal')) + }) + it('every built-in tab carries the settings-surface icon', () => { const { service } = setup() for (const tab of service.getTabs()) { diff --git a/tests/plugin-shape.spec.ts b/tests/plugin-shape.spec.ts index b897278..a46f7dc 100644 --- a/tests/plugin-shape.spec.ts +++ b/tests/plugin-shape.spec.ts @@ -37,10 +37,18 @@ describe('dsh-better-sidebar plugin export shape', () => { // The terminal shell config defaults to auto-resolution (empty shell = // the platform chain in defaultShell()). expect(resolved.shell).toBe('') + // Shell args default to an empty list; non-empty values replace the + // automatic platform login flag. + expect(resolved.shellArgs).toEqual([]) const configured = (schema as unknown as { (input: Record | undefined): Record })({ shell: 'pwsh.exe' }) expect(configured.shell).toBe('pwsh.exe') + const configuredWithArgs = (schema as unknown as { + (input: Record | undefined): Record + })({ shell: '/bin/zsh', shellArgs: ['--noprofile', '--no-rc'] }) + expect(configuredWithArgs.shell).toBe('/bin/zsh') + expect(configuredWithArgs.shellArgs).toEqual(['--noprofile', '--no-rc']) }) it('registers the side card preferences schema with the documented defaults', async () => { diff --git a/tests/pty-helpers.spec.ts b/tests/pty-helpers.spec.ts index 0cbdf63..d0d8186 100644 --- a/tests/pty-helpers.spec.ts +++ b/tests/pty-helpers.spec.ts @@ -13,7 +13,7 @@ vi.mock('node:os', async (importOriginal) => { }) import { resolveSidebarConfig } from '../src/config.ts' -import { defaultShell, ensureSpawnHelper } from '../src/pty-manager.ts' +import { defaultShell, ensureSpawnHelper, shellDisplayName, shellSpawnArgs } from '../src/pty-manager.ts' describe('pty helpers', () => { it('prefers an explicit shell, then SHELL, then the account login shell on POSIX', () => { @@ -69,6 +69,22 @@ describe('pty helpers', () => { it('trims the configured shell and defaults it to auto for old documents', () => { expect(resolveSidebarConfig(undefined).shell).toBe('') expect(resolveSidebarConfig({ shell: ' pwsh.exe ' }).shell).toBe('pwsh.exe') + expect(resolveSidebarConfig({ shell: '/bin/zsh', shellArgs: ['--noprofile'] }).shellArgs).toEqual(['--noprofile']) + expect(resolveSidebarConfig(undefined).shellArgs).toEqual([]) + }) + + it('derives a short display name for terminal tab titles', () => { + expect(shellDisplayName('/bin/zsh')).toBe('zsh') + expect(shellDisplayName('/usr/bin/bash')).toBe('bash') + expect(shellDisplayName('C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe')).toBe('powershell') + expect(shellDisplayName('pwsh.exe')).toBe('pwsh') + expect(shellDisplayName('/weird')).toBe('weird') + expect(shellDisplayName('/')).toBe('/') + }) + + it('uses explicit shell args verbatim and keeps platform defaults when none are configured', () => { + expect(shellSpawnArgs(['--noprofile', '--no-rc'])).toEqual(['--noprofile', '--no-rc']) + expect(shellSpawnArgs([])).toEqual(process.platform === 'win32' ? [] : ['-l']) }) it('restores the spawn-helper executable bit idempotently', () => { diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 706da9d..e160b39 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -578,6 +578,17 @@ describe('side card settings routes', () => { expect((result.value as { externalDisable?: boolean }).externalDisable).toBe(true) }) + it('serves the effective terminal shell and its display name', async () => { + const route = mountWithSettings(undefined) + const result = await invoke(route, 'shell.get', {}) + expect(result.ok).toBe(true) + expect(result.value).toMatchObject({ + shell: expect.any(String), + name: expect.any(String), + }) + expect(String((result.value as { name: unknown }).name).length).toBeGreaterThan(0) + }) + it('reads the resolved prefs and writes a patch through the seam', async () => { const route = mountWithSettings(createFakeSettings()) const read = await invoke(route, 'settings.get', {})