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:
@@ -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
@@ -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}` : ''}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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 }] },
|
||||
|
||||
Reference in New Issue
Block a user