From 8aa9441627dfc2473ed2f624b97bd052ab7a90ac Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Tue, 8 Sep 2026 21:21:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(terminal):=20shell=20=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E5=8E=BB=E5=BC=95=E5=8F=B7=E8=BF=9B=E8=A7=A3=E6=9E=90=E3=80=81?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E8=B7=9F=E9=9A=8F=E8=A6=86=E7=9B=96=E3=80=81?= =?UTF-8?q?close=20reason=20=E6=9C=BA=E5=99=A8=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.ts | 36 +++++++++++++++++++++++++++--------- tests/smoke.spec.ts | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 59 insertions(+), 11 deletions(-) diff --git a/src/index.ts b/src/index.ts index be48e16..4ac489a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,7 +41,7 @@ import { registerBundleRoute } from './bundle-route.ts' import { launchExternal } from './open-external.ts' import * as git from './git.ts' import { SettingsConflictError } from '@deepseek-ai/dsh-settings' -import { defaultShell, ensureSpawnHelper, PtyManager, shellDisplayName } from './pty-manager.ts' +import { defaultShell, ensureSpawnHelper, PtyManager, shellDisplayName, splitShellArgs, unquotePath } from './pty-manager.ts' import { AgentPtyRegistry, armPtyResizeGate, tryResizePty, type AgentTerminalHandle } from './agent-pty.ts' import { DSH_NODE_PTY_RANGE, @@ -250,11 +250,11 @@ function shellOverridesOf(getSettings: () => SidebarSettingsFace | undefined): { const value = settings?.get().value if (value === null || typeof value !== 'object') return {} const record = value as Record - const shell = typeof record.terminalShell === 'string' ? record.terminalShell.trim() : '' + const shell = typeof record.terminalShell === 'string' ? unquotePath(record.terminalShell.trim()) : '' const args = typeof record.terminalShellArgs === 'string' ? record.terminalShellArgs.trim() : '' return { shell: shell === '' ? undefined : shell, - shellArgs: args === '' ? undefined : args.split(/\s+/).filter(Boolean), + shellArgs: args === '' ? undefined : splitShellArgs(args), } } @@ -559,11 +559,14 @@ function buildApi( // Subagent live previews: one batch request per refresh; the route folds // the newest text/tool activity of every running child in the tree. 'subagents.live': (payload) => subagentLiveApi.live(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 effective terminal shell and its display name: the settings-page + // override when set (quotes stripped), else the boot-time resolution. + // The client titles terminal tabs with the name, so a changed setting is + // visible on the next opened tab without a plugin restart. + 'shell.get': () => { + const effective = shellOverridesOf(getSettings).shell ?? terminalShell + return { shell: effective, name: shellDisplayName(effective) } + }, // 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 @@ -1176,6 +1179,21 @@ async function attachAgentList( } } +/** + * The WS close reason for a failed terminal attach. A missing configured + * shell gets a SHORT machine-readable marker (`shell-not-found:`, + * capped — a WS close reason allows at most 123 bytes) that the client maps + * to a localized, actionable banner; every other failure keeps the raw + * message (the model-side tool errors read it verbatim). + */ +export function wsCloseReasonOf(error: unknown): string { + if (error instanceof SidebarError && error.code === 'shell-not-found') { + const name = shellDisplayName(String(error.meta?.shell ?? '')).slice(0, 100) + return `shell-not-found:${name}` + } + return error instanceof Error ? error.message : String(error) +} + /** * Wire one terminal socket to its pty: replay transcript, pump both ways. * Two attach modes share the wire protocol: @@ -1304,7 +1322,7 @@ async function attachTerminal( } }) } catch (error) { - ws.close(1011, error instanceof Error ? error.message : String(error)) + ws.close(1011, wsCloseReasonOf(error)) } } diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 99a834d..54a3770 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -9,7 +9,8 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSyn import { tmpdir } from 'node:os' import { join, resolve as resolvePath } from 'node:path' import { SettingsConflictError, type SettingsNamespace } from '@deepseek-ai/dsh-settings' -import { apply, mediaTypeForPath } from '../src/index.ts' +import { apply, mediaTypeForPath, wsCloseReasonOf } from '../src/index.ts' +import { SidebarError } from '../src/wire.ts' import { encodeHtmlUrl } from '../src/html-route.ts' import * as git from '../src/git.ts' import { listDirectory } from '../src/fs-tree.ts' @@ -835,7 +836,10 @@ describe('side card settings routes', () => { } return { register(ns: string, schema: unknown) { - namespaces.set(ns, { schema, value: undefined, revision: 0 }) + // Preserve a pre-seeded value: tests stage prefs through the `pre` + // map before the plugin mounts and registers the same namespace. + const existing = namespaces.get(ns) + namespaces.set(ns, { schema, value: existing?.value ?? undefined, revision: 0 }) return { get: () => ({}), watch: () => () => {}, update: async () => {}, replace: async () => {} } }, describe() { @@ -933,6 +937,32 @@ describe('side card settings routes', () => { expect(String((result.value as { name: unknown }).name).length).toBeGreaterThan(0) }) + it('shell.get reflects the settings-page override with the quotes stripped', async () => { + const route = mountWithSettings(createFakeSettings({ + 'dsh-better-sidebar': { + terminalShell: '"C:\\Program Files\\PowerShell\\7\\pwsh.exe"', + terminalShellArgs: '-NoLogo', + }, + })) + const result = await invoke(route, 'shell.get', {}) + expect(result.ok).toBe(true) + expect(result.value).toMatchObject({ + shell: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', + name: 'pwsh', + }) + }) + + it('maps a shell-not-found failure to the machine-readable close reason', async () => { + expect(wsCloseReasonOf(new SidebarError( + 'shell-not-found', + 'shell executable not found: "C:\\Program Files\\PowerShell\\7\\pwsh.exe"', + 400, + { shell: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' }, + ))).toBe('shell-not-found:pwsh') + expect(wsCloseReasonOf(new Error('boom'))).toBe('boom') + expect(wsCloseReasonOf('plain')).toBe('plain') + }) + it('reads the resolved prefs and writes a patch through the seam', async () => { const route = mountWithSettings(createFakeSettings()) const read = await invoke(route, 'settings.get', {})