feat(terminal): 支持配置 shell 与 shellArgs,终端标题使用 shell 名 (#125)
- 支持通过 cordis.patch.yml 指定终端 shell - 支持 shellArgs 带参启动终端 shell - 终端标题改用 shell 名,内部使用 uuid 管理
This commit is contained in:
@@ -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 半改动才需重启)
|
||||
```
|
||||
|
||||
+6
-1
@@ -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:<absolute path of the clone>"
|
||||
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
|
||||
```
|
||||
|
||||
@@ -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'
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
@@ -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', {}),
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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:<n>` ids through `createTab`; the browser mints
|
||||
* and mints `terminal:<uuid>` ids through `createTab`; the browser mints
|
||||
* `browser:<n>` 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 },
|
||||
}
|
||||
},
|
||||
|
||||
+22
-2
@@ -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
|
||||
|
||||
+14
-1
@@ -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<SidebarConfig> = 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 ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-5
@@ -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<string, unknown>, 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<string, ApiMethod> {
|
||||
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',
|
||||
|
||||
+19
-2
@@ -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']
|
||||
}
|
||||
|
||||
+28
-2
@@ -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<typeof createBetterSidebarService>; store: ReturnType<typeof createSidebarStore>; dispose: () => void } {
|
||||
function setup(options: BuiltinTabOptions = {}): { service: ReturnType<typeof createBetterSidebarService>; store: ReturnType<typeof createSidebarStore>; 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()) {
|
||||
|
||||
@@ -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<string, unknown> | undefined): Record<string, unknown>
|
||||
})({ shell: 'pwsh.exe' })
|
||||
expect(configured.shell).toBe('pwsh.exe')
|
||||
const configuredWithArgs = (schema as unknown as {
|
||||
(input: Record<string, unknown> | undefined): Record<string, unknown>
|
||||
})({ 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 () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', {})
|
||||
|
||||
Reference in New Issue
Block a user