feat(terminal): shell 覆盖去引号进解析、标题跟随覆盖、close reason 机器标记
This commit is contained in:
+27
-9
@@ -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<string, unknown>
|
||||
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:<name>`,
|
||||
* 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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+32
-2
@@ -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', {})
|
||||
|
||||
Reference in New Issue
Block a user