Skip to content
dsh.fish
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 20 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

  • 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.
Source