terminal: fix posix_spawnp failure (spawn-helper exec bit), half-viewport default, fullscreen toggle, middle-click close

- Root cause of the endless 'disconnected, reconnecting' banner: pnpm
  strips the executable bit from node-pty's prebuilt spawn-helper, so every
  spawn failed with posix_spawnp failed and the client retried forever.
  ensureSpawnHelper() restores 0755 at plugin activation (mirrors
  dsh-pty-local's postinstall; works for link installs too). Verified
  against the live GUI: terminal now connects and echoes.
- TerminalView stops auto-reconnecting on a server refusal (close 1011 with
  a reason): shows the reason plus a manual retry instead of spinning.
- defaultShell() also guards an empty SHELL.
- New sessions open at half the viewport width; width clamping now allows
  filling the window, and a header fullscreen button expands the sidebar to
  the full viewport (toggle restores half).
- Middle-click on a tab closes it (auxclick button 1, autoscroll
  suppressed).
- Tests: +2 (shell fallback, spawn-helper exec bit idempotent).
This commit is contained in:
Menghuan1918
2026-08-08 11:28:43 +08:00
parent 58b4bd71ba
commit c7a7c7e5af
10 changed files with 160 additions and 14 deletions
+2
View File
@@ -61,6 +61,8 @@ pnpm watch # tsdown --watch(client bundle 热重建)
## 依赖说明
- `node-pty` / `ws` / `xterm` / `@xterm/addon-fit` / `clsx`:npm 常规依赖(node-pty 使用随包预编译二进制,无源码编译)。
- pnpm 会剥离 node-pty 预编译 `spawn-helper` 的可执行位(`posix_spawnp failed`);插件启动时自动恢复(
`ensureSpawnHelper`,与 `@deepseek-ai/dsh-pty-local` 的 postinstall 同思路,对 link 安装也生效)。
- `@deepseek-ai/*` 包仅以 `link:` 指向 `~/.dsh/source/current`(DSH 源码 checkout,构建产物已存在),仅用于类型;运行时只经模块表消费 `dsh-client-ui-primitives`。
## 安全边界
+16 -1
View File
@@ -12,7 +12,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { IconChevronRightOutline14, IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconChevronRightOutline14, IconFullscreenOutline16, IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../context-types.ts'
import {
allLeaves, closeTab, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openTab,
@@ -101,6 +101,9 @@ export function Sidebar(props: { ctx: Context }) {
const widthDrag = useRef({ startX: 0, startWidth: 0 })
const [draggingWidth, setDraggingWidth] = useState(false)
// Whether the panel currently fills the viewport (fullscreen expansion).
const fullscreen = state !== undefined && window.innerWidth - state.width < 8
const actions: WorkbenchActions = useMemo(() => ({
closeTab: (paneId, tabId) => { sidebarStore.reduce(s => closeTab(s, paneId, tabId)) },
activateTab: (paneId, tabId) => {
@@ -215,6 +218,18 @@ export function Sidebar(props: { ctx: Context }) {
}}
/>
<div className={css.panelHeader}>
<button
type="button"
className={css.iconButton}
aria-label={fullscreen ? t('restoreFullscreen') : t('expandFullscreen')}
title={fullscreen ? t('restoreFullscreen') : t('expandFullscreen')}
onClick={() => {
const viewport = window.innerWidth
sidebarStore.reduce(s => setWidth(s, fullscreen ? Math.round(viewport / 2) : viewport))
}}
>
<IconFullscreenOutline16 />
</button>
<span className={css.panelTitle}>
{t('explorer')}
{cwd !== undefined ? ` · ${cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd}` : ''}
+7
View File
@@ -96,6 +96,13 @@ export function TabBar(props: {
if (payload !== null) onDropTab(payload, tab.id)
}}
onClick={() => { onActivate(tab.id) }}
onAuxClick={(event) => {
// Middle-click closes the tab (and suppresses autoscroll).
if (event.button === 1) {
event.preventDefault()
onClose(tab.id)
}
}}
>
<span className={css.tabTitle}>{tab.title}</span>
<button
+29 -4
View File
@@ -2,8 +2,10 @@
* The interactive terminal: xterm.js over a WebSocket to the host pty.
* The host replays the session's transcript on connect, then streams live
* output; input frames are raw text, resize frames are JSON with
* type:"resize". Disconnects (page refresh, host restart) reconnect with a
* delay — the pty process survives the socket.
* type:"resize". Transient disconnects (page refresh, host restart) reconnect
* automatically; a server-side refusal (close code 1011 with a reason, e.g.
* a failed pty spawn) stops the loop and shows the reason with a manual
* retry, so the banner never spins forever.
*/
import { useEffect, useRef, useState } from 'react'
import { Terminal } from 'xterm'
@@ -16,6 +18,8 @@ export function TerminalView(props: { sessionId: string; tabId: string }) {
const { sessionId, tabId } = props
const hostRef = useRef<HTMLDivElement>(null)
const [connected, setConnected] = useState(false)
const [fatal, setFatal] = useState<string | null>(null)
const connectRef = useRef<(() => void) | null>(null)
useEffect(() => {
const host = hostRef.current
@@ -54,19 +58,27 @@ export function TerminalView(props: { sessionId: string; tabId: string }) {
socket = new WebSocket(wsUrl())
socket.onopen = () => {
setConnected(true)
setFatal(null)
sendResize()
}
socket.onmessage = (event) => {
if (typeof event.data === 'string') term.write(event.data)
}
socket.onclose = () => {
socket.onclose = (event) => {
setConnected(false)
// A server-side refusal carries a close code + reason; retrying it
// forever would only spin the banner, so surface it with a retry.
if (event.code === 1011 && event.reason !== '') {
setFatal(event.reason)
return
}
if (!closed) retry = window.setTimeout(connect, 2000)
}
socket.onerror = () => {
socket?.close()
}
}
connectRef.current = connect
const inputSub = term.onData((data) => {
if (socket !== null && socket.readyState === WebSocket.OPEN) socket.send(data)
@@ -89,12 +101,25 @@ export function TerminalView(props: { sessionId: string; tabId: string }) {
inputSub.dispose()
socket?.close()
term.dispose()
connectRef.current = null
}
}, [sessionId, tabId])
return (
<div className={css.terminalWrap}>
{!connected && <div className={css.terminalBanner}>{t('disconnected')}</div>}
{fatal !== null && (
<div className={css.terminalBanner}>
{t('terminalError')}: {fatal}
<button
type="button"
className={css.terminalRetry}
onClick={() => { setFatal(null); connectRef.current?.() }}
>
{t('terminalRetry')}
</button>
</div>
)}
{fatal === null && !connected && <div className={css.terminalBanner}>{t('disconnected')}</div>}
<div ref={hostRef} className={css.terminal} />
</div>
)
+8
View File
@@ -18,6 +18,10 @@ const zh = {
close: '关闭',
collapse: '折叠侧边栏',
expand: '展开侧边栏',
expandFullscreen: '展开为全屏',
restoreFullscreen: '还原宽度',
terminalError: '终端连接失败',
terminalRetry: '重试',
refresh: '刷新',
save: '保存',
saved: '已保存',
@@ -75,6 +79,10 @@ const en: Record<keyof typeof zh, string> = {
close: 'Close',
collapse: 'Collapse sidebar',
expand: 'Expand sidebar',
expandFullscreen: 'Expand to full width',
restoreFullscreen: 'Restore width',
terminalError: 'Terminal connection failed',
terminalRetry: 'Retry',
refresh: 'Refresh',
save: 'Save',
saved: 'Saved',
+19
View File
@@ -579,12 +579,31 @@
.terminalBanner {
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 3px 10px;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-state-warn-label);
background: var(--dsw-alias-state-warn-tertiary);
}
.terminalRetry {
flex: none;
padding: 1px 8px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
background: var(--dsw-alias-bg-layer-2);
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xxxs-strong-11);
cursor: pointer;
}
.terminalRetry:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
/* ── Git ───────────────────────────────────────────────────────────────── */
.git {
+12 -6
View File
@@ -64,14 +64,16 @@ function uid(prefix: string): string {
return `${prefix}:${nextIdCounter}`
}
/** A fresh default state: open, default width, one explorer tab in one pane. */
export function makeDefaultState(): SidebarState {
/** A fresh default state: open, one explorer tab in one pane. `width` is
* the caller's preferred panel width (default PANEL_DEFAULT); the store
* seeds new sessions with half the viewport. */
export function makeDefaultState(width = PANEL_DEFAULT): SidebarState {
const leaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
leaf.tabs = [{ id: uid('tab'), type: 'explorer', title: 'Explorer' }]
leaf.active = leaf.tabs[0]!.id
return {
panelOpen: true,
width: PANEL_DEFAULT,
width,
activePane: leaf.id,
nextTerminal: 1,
expanded: [],
@@ -315,9 +317,11 @@ export function togglePanel(state: SidebarState): SidebarState {
return { ...state, panelOpen: !state.panelOpen }
}
/** Set the panel width (clamped to the contract range). */
/** Set the panel width (clamped to the contract range; the upper bound is
* the viewport so the fullscreen expansion can fill the window). */
export function setWidth(state: SidebarState, width: number): SidebarState {
return { ...state, width: Math.min(PANEL_MAX, Math.max(PANEL_MIN, Math.round(width))) }
const max = typeof window !== 'undefined' ? Math.max(PANEL_MIN, window.innerWidth) : PANEL_MAX
return { ...state, width: Math.min(max, Math.max(PANEL_MIN, Math.round(width))) }
}
/** Toggle a directory in the explorer expansion set. */
@@ -368,7 +372,9 @@ function loadState(sessionId: string): SidebarState {
} catch {
// Corrupt or unavailable storage: fall through to the default.
}
return makeDefaultState()
// New sessions open at half the viewport (VSCode-like default split).
const half = typeof window !== 'undefined' ? Math.max(PANEL_MIN, Math.round(window.innerWidth / 2)) : PANEL_DEFAULT
return makeDefaultState(half)
}
/** The session-scoped store: one state per conversation, localStorage-backed. */
+4 -1
View File
@@ -19,7 +19,7 @@ import type { Context } from './context-types.ts'
import { parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
import { isTrustedApiRequest } from './trust-fence.ts'
import * as git from './git.ts'
import { defaultShell, PtyManager } from './pty-manager.ts'
import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts'
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
/** Plugin identity for cordis.yml rows. */
@@ -194,6 +194,9 @@ function buildApi(ctx: Context, ptyManager: PtyManager): Record<string, ApiMetho
* @param ctx - host plugin context (httpServer, sessions, loader).
*/
export function apply(ctx: Context): void {
// pnpm strips the executable bit from node-pty's prebuilt spawn-helper;
// restore it before any terminal can spawn (idempotent).
ensureSpawnHelper()
const trustedHosts = trustedHostsOf(ctx)
const fence = (req: IncomingMessage): boolean => isTrustedApiRequest(req, trustedHosts)
const ptyManager = new PtyManager(defaultShell(), TERMINALS_PER_SESSION)
+31 -2
View File
@@ -6,12 +6,40 @@
* connection replays history before live data. Sessions die only when the
* tab is closed or the plugin tears down.
*/
import { chmodSync, existsSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { createRequire } from 'node:module'
import * as nodePty from 'node-pty'
import { SidebarError } from './wire.ts'
/** Per-terminal transcript bound (bytes kept for replay). */
const TRANSCRIPT_LIMIT = 1 << 20
/**
* Restore the executable bit pnpm strips from node-pty's prebuilt
* spawn-helper (the macOS helper that forks and sets up the pty). Without it
* every spawn fails with `posix_spawnp failed`. Idempotent; mirrors
* @deepseek-ai/dsh-pty-local's ensure-spawn-helper postinstall, run at
* plugin activation so link-installed deployments get the fix too.
*/
export function ensureSpawnHelper(): void {
if (process.platform === 'win32') return
try {
const require = createRequire(import.meta.url)
const entry = require.resolve('node-pty')
const packageRoot = dirname(dirname(entry))
const candidates = [
join(packageRoot, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper'),
join(packageRoot, 'build', 'Release', 'spawn-helper'),
]
for (const helper of candidates) {
if (existsSync(helper)) chmodSync(helper, 0o755)
}
} catch {
// Resolution or chmod failure: the terminal surfaces its own spawn error.
}
}
/** One live terminal. */
export interface SidebarPty {
/** `${sessionId}:${tabId}` registry key. */
@@ -115,8 +143,9 @@ export class PtyManager {
}
}
/** The interactive shell for this platform. */
/** The interactive shell for this platform (empty SHELL falls back). */
export function defaultShell(): string {
if (process.platform === 'win32') return 'powershell.exe'
return process.env.SHELL ?? '/bin/bash'
const shell = process.env.SHELL
return shell !== undefined && shell.trim() !== '' ? shell : '/bin/bash'
}
+32
View File
@@ -6,6 +6,7 @@ import {
resizeSplit, splitPane, toggleExpanded, type SidebarState, type SplitNode,
} from '../src/client/state.ts'
import { producedForClosing, resolveSidebarPath, selectProducedFiles } from '../src/client/produced-files.ts'
import { defaultShell, ensureSpawnHelper } from '../src/pty-manager.ts'
describe('fs-tree', () => {
it('sorts directories first, then names case-insensitively', () => {
@@ -175,6 +176,37 @@ describe('sidebar state', () => {
})
})
describe('pty helpers', () => {
it('falls back from an empty SHELL to a usable shell', () => {
const previous = process.env.SHELL
try {
process.env.SHELL = ''
expect(defaultShell()).toBe('/bin/bash')
delete process.env.SHELL
expect(defaultShell()).toBe('/bin/bash')
} finally {
if (previous === undefined) delete process.env.SHELL
else process.env.SHELL = previous
}
})
it('restores the spawn-helper executable bit idempotently', () => {
// On non-Windows the helper must exist and be executable after the fix.
if (process.platform === 'win32') return
ensureSpawnHelper()
ensureSpawnHelper()
const { existsSync } = require('node:fs') as typeof import('node:fs')
const { dirname, join } = require('node:path') as typeof import('node:path')
const { createRequire } = require('node:module') as typeof import('node:module')
const entry = createRequire(import.meta.url).resolve('node-pty')
const root = dirname(dirname(entry))
const helper = join(root, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper')
expect(existsSync(helper)).toBe(true)
const { statSync } = require('node:fs') as typeof import('node:fs')
expect((statSync(helper).mode & 0o111) !== 0).toBe(true)
})
})
describe('produced-files derivation', () => {
const diffResult = (path: string) => ({
kind: 'tool-result', isError: false, callView: { card: 'diff', locations: [{ path }] },