From c7a7c7e5af18165cb4633d1702a4a8119378419c Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Sat, 8 Aug 2026 11:28:43 +0800 Subject: [PATCH] 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). --- README.md | 2 ++ src/client/Sidebar.tsx | 17 ++++++++++++++++- src/client/TabBar.tsx | 7 +++++++ src/client/TerminalView.tsx | 33 +++++++++++++++++++++++++++++---- src/client/locales.ts | 8 ++++++++ src/client/sidebar.module.css | 19 +++++++++++++++++++ src/client/state.ts | 18 ++++++++++++------ src/index.ts | 5 ++++- src/pty-manager.ts | 33 +++++++++++++++++++++++++++++++-- tests/unit.spec.ts | 32 ++++++++++++++++++++++++++++++++ 10 files changed, 160 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index ddba404..f714779 100644 --- a/README.md +++ b/README.md @@ -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`。 ## 安全边界 diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 2a9c3eb..a2449c2 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -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 }) { }} />
+ {t('explorer')} {cwd !== undefined ? ` · ${cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd}` : ''} diff --git a/src/client/TabBar.tsx b/src/client/TabBar.tsx index a5c52b6..0e3ae80 100644 --- a/src/client/TabBar.tsx +++ b/src/client/TabBar.tsx @@ -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) + } + }} > {tab.title} +
+ )} + {fatal === null && !connected &&
{t('disconnected')}
}
) diff --git a/src/client/locales.ts b/src/client/locales.ts index 7948223..2ab4923 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -18,6 +18,10 @@ const zh = { close: '关闭', collapse: '折叠侧边栏', expand: '展开侧边栏', + expandFullscreen: '展开为全屏', + restoreFullscreen: '还原宽度', + terminalError: '终端连接失败', + terminalRetry: '重试', refresh: '刷新', save: '保存', saved: '已保存', @@ -75,6 +79,10 @@ const en: Record = { 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', diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index feb92c7..d112fd4 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -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 { diff --git a/src/client/state.ts b/src/client/state.ts index 214eebd..088f7c2 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -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. */ diff --git a/src/index.ts b/src/index.ts index ef9f560..6b70139 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 isTrustedApiRequest(req, trustedHosts) const ptyManager = new PtyManager(defaultShell(), TERMINALS_PER_SESSION) diff --git a/src/pty-manager.ts b/src/pty-manager.ts index f0e948f..c5092cf 100644 --- a/src/pty-manager.ts +++ b/src/pty-manager.ts @@ -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' } diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts index f303e88..ee15b2e 100644 --- a/tests/unit.spec.ts +++ b/tests/unit.spec.ts @@ -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 }] },