feat(terminal): 支持配置 shell 与 shellArgs,终端标题使用 shell 名 (#125)

- 支持通过 cordis.patch.yml 指定终端 shell
- 支持 shellArgs 带参启动终端 shell
- 终端标题改用 shell 名,内部使用 uuid 管理
This commit is contained in:
NuperAki
2026-08-18 19:56:04 +08:00
committed by GitHub
parent 498528d2eb
commit c973d6ec1b
15 changed files with 193 additions and 23 deletions
+2 -1
View File
@@ -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,
+3
View File
@@ -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', {}),
+7 -3
View File
@@ -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()) {
+20 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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']
}