Bundle
dsh-web-terminal
A terminal dock merged into the DSH web GUI: hideable, split-screen with the chat column, and integrable with terminal-designed TUI plugins (host registerView service + window CustomEvent channel). Windows ConPTY via node-pty, xterm.js in the browser, WebSocket bridge with backpressure.
- Source
- qichuang321
- stars
- 2 stars
- License
- Apache-2.0
- Updated
- Updated 16 hours ago
Readme
# dsh-web-terminal
A terminal dock merged into the DSH web GUI: **hideable**, **split-screen with the
chat column** when visible, and **integrable with terminal-designed TUI plugins**.
```
┌─────────────────────────────┐
│ session header [ 终端 ⇧ ] │ ← toggle button (conversation.session.header.actions)
├─────────────────────────────┤
│ conversation (chat) │ ← stays mounted & stateful
│ ──────────────────────── │ ← drag handle (resize)
│ [Shell] [Panel] ×│ ← dock tab bar
│ xterm.js terminal / TUI │ ← full-emulation ConPTY
└─────────────────────────────┘
```
- **Hide / show**: header button or `Ctrl+`` — the dock, the xterm instance and
the PTY session all survive the toggle (nothing is torn down; VS Code style).
- **Split-screen**: the dock is absolutely docked into the bottom band of the
center column; the chat keeps its own scrollport above; height is draggable
(120–900 px) and persisted in `localStorage` (`dsh.webTerminal.v1`).
- **TUI integration**: any terminal-designed TUI runs unchanged inside the PTY
(vim, htop, tianshu-tui, …), plus an
extension API so plugins can register their own views.
## Install
```sh
dsh plugin --profile web add link:D:\Deepseek Harness\dsh-web-terminal
# restart the web GUI, then use the header toggle or Ctrl+`
```
The plugin mounts via `cordis.patch.yml` (bundle patch layer, dsh-ssh pattern):
host half = `lib/index.js` (node-pty PTY + WebSocket bridge + registry service),
browser half = `lib/client.js` (the dock).
## Architecture
| Piece | File | Notes |
| --- | --- | --- |
| Plugin entry (host) | `src/index.ts` | `webServer` inject, routes + `webTerminal` service under `ctx.effect` |
| Routes + WS terminal | `src/routes.ts` | loopback fence, `WebSocketServer({noServer:true})`, 1MB/512KB backpressure |
| PTY layer | `src/pty.ts` | node-pty direct (`^1.1.0`, prebuilt win32-x64 ConPTY), TERM/COLORTERM env, `powershell.exe` default |
| Shared protocol | `src/protocol.ts` | frame types + `WT_API` paths + clamps |
| TUI registry | `src/tui-registry.ts` | `webTerminal.registerView(def)` extension seam |
| Client entry | `src/client/index.ts` | dock mount, header toggle slot, `Ctrl+\`` |
| Dock UI | `src/client/dock.tsx` | xterm + FitAddon, tabs, drag resize, TUI channel |
| TUI channel | `src/client/tui-channel.ts` | window CustomEvent channel |
| Demo adapter | `src/examples/*` | one dom view (`demo:panel`, proves the CustomEvent channel) |
### Wire protocol (`/api/dsh-web-terminal/*`, loopback-fenced)
```
server→client: {type:'ready',view,cols,rows} | {type:'output',data}
| {type:'title',title?} | {type:'exit',code,error?}
client→server: {type:'input',data} | {type:'resize',cols,rows}
```
## TUI plugin integration API
**Host side** — any DSH plugin registers a view inside its own `ctx.effect`:
```ts
const webTerminal = ctx.get('webTerminal')
ctx.effect(() => webTerminal?.registerView({
id: 'my-plugin:my-tui', // namespaced 'pluginId:viewId'
label: 'My TUI',
order: 10,
spawn: ({ cols, rows, env }) => ({
command: 'npx',
args: ['--yes', 'my-tui'],
shell: true, // run through the default Windows shell
env: { ...env, MY_FLAG: '1' },
}),
}), 'my-plugin: tui view')
```
**Client side** — the browser half of the same plugin:
```ts
import { dispatchRegister, dispatchUnregister, onTuiEvent, TUI_EVENTS } from 'dsh-web-terminal/client'
ctx.effect(() => {
dispatchRegister({ id: 'my-plugin:my-tui', label: 'My TUI', kind: 'pty' })
return () => dispatchUnregister('my-plugin:my-tui')
}, 'my-plugin: tui client')
```
Events: adapter→dock `register` / `unregister`; dock→adapter `ready` (re-register,
self-heal), `activate` ({id,container} — dom views render here), `deactivate`,
`view-state` ({id,state:'running'|'exit'|'error',code?,error?}).
## Security
- Every route + the WS upgrade carry the loopback-only fence (remoteAddress ∈
{127.0.0.1, ::1, ::ffff:127.0.0.1}, Host localhost, `sec-fetch-site` and
Origin checks) — LAN-exposed dsh web deployments must not serve these.
- Client frames are strictly validated; only `input`/`resize` are accepted,
cols/rows clamped.
- The PTY is killed on socket close/error, tab close, dock unmount, and plugin
dispose (`ctx.effect` disposer); no fs access from the browser.
## Windows caveats
- `node-pty` never sets `TERM` from the `name` option on Windows — the plugin
sets `TERM=xterm-256color`/`COLORTERM=truecolor` explicitly in the env.
- `kill(signal)` throws on Windows — bare `kill()` only; Ctrl+C is `\u0003`.
- Default shell: `powershell.exe` (pwsh if installed, `cmd.exe` last resort).
`cmd.exe` may need `chcp 65001` for UTF-8 TUI output.
- ConPTY emits CRLF natively — the bridge never converts EOL (`convertEol:false`).
## Build
```sh
pnpm install # node-pty ^1.1.0 prebuilt win32-x64 — no compiler needed
pnpm build # tsc typecheck + tsdown → lib/index.js + lib/client.js
```
Install
dsh plugin --profile web add github:qichuang321/dsh-web-terminal
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-web-terminal from the hub
- This package builds from source on install. pnpm will ask you to allow its build script — that is permission to run the package’s code on your machine, outside the agent sandbox. Only allow sources you trust.
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.