dsh-single-terminal is a real-terminal drawer plugin for the DeepSeek Harness (DSH) host. It docks an interactive PTY terminal (xterm.js) to the bottom of the web app — type into it, Ctrl-C it, resize it, open as many tabs as you need.
- Real PTY, not an emulator — each tab is a true pseudo-terminal
(ConPTY on Windows, forkpty on POSIX) driven by
node-pty; interactive REPLs, full-screen programs and Ctrl-C all behave like a native terminal - Shell picker — Windows: PowerShell (default) / pwsh 7 / CMD / Git Bash /
WSL (shells that are not installed are hidden automatically) + custom shells
from config; POSIX:
$SHELL/ bash / zsh / fish - Two drawer modes — Docked pushes the page content up (no occlusion),
Overlay floats above it; drag the top edge to resize, the drawer remembers
mode and height. Both modes share the same frosted-glass style (translucent
background +
backdrop-filterblur) — the mode only decides docking behavior. Open/close slides with the host's easing curve and honorsprefers-reduced-motion. - Theme following — the drawer and the terminal palette follow the host's theme (light / dark / custom themes) live; no separate theme config.
- Keep-alive sessions — terminals survive page refreshes and drawer close/reopen; on reconnect the recent output is replayed from a ring buffer. Opening the drawer with no terminal yet auto-creates one with the default shell.
- Workspace-aware cwd — when the current session belongs to a workspace,
new terminals (including the auto-created one) start in that workspace root
directory, no manual
cdneeded; with no session / no workspace thedefaultCwdrules apply. - Bilingual UI — follows the host interface language (中文 / English);
Alt+Ctoggles the drawer
Screenshot of the terminal drawer (dark theme following, frosted-glass style): see preview.md.
- Header entry (
conversation.session.header.utilities): a panel-bottom icon button in the session header's utilities row toggles the drawer. Hovering shows a bubble tooltip (the hostTooltipcomponent) with the shortcut;Alt+Ctoggles from anywhere except while typing inside the terminal (thereAlt+Cis passed to the shell asESC c). - Drawer (
shell.overlay): a frame-level bottom drawer with- a tab strip — one PTY session per tab, independent shells, close button per tab (terminates the whole process tree; the pid is verified gone),
- a
+button (new tab with the default shell) and a▸menu (all shells found on this machine; unavailable ones are not listed), - a mode switch (Docked / Overlay), a connection status dot and a collapse button,
- a drag handle on the top edge (pointer-capture drag, min 140px).
- Docked mode pushes the app frame up with
padding-bottomon the frame root element (no host hook exists for bottom docks); when the anchor cannot be found it silently falls back to overlay. - Model tools: none — this plugin is UI-only by design.
Schemastery Config (renders on the host Plugins settings page), and / or the
profile cordis.patch.yml:
- insert:
- id: dsh-single-terminal
name: dsh-single-terminal
config:
defaultShell: powershell # powershell | pwsh | cmd | gitbash | wsl | <custom id>
defaultCwd: home # home | workspace | absolute path
scrollbackLimit: 200000 # replay ring buffer, bytes per session
fontSize: 13
fontFamily: Consolas, "Cascadia Mono", "Courier New", monospace
customShells:
- id: nu
name: Nushell
command: nu # resolved through PATH
args: []defaultShell— shell used by the+button; when unavailable it falls back (powershellon Windows,$SHELL/bashon POSIX).defaultCwd— start directory when there is no workspace context:home(default) starts in the user home;workspaceis reserved (currently resolves to home); an absolute path must exist. When the current session belongs to a workspace the workspace root takes precedence (see above).customShells— extra launchers;commandmay be an absolute path or a name resolved throughPATH(withPATHEXTon Windows).
# Local development
dsh plugin --profile web add ./dsh-single-terminal
# Published: npm / tarball / GitHub
dsh plugin --profile web add dsh-single-terminal
dsh plugin --profile web add ./dsh-single-terminal-0.1.0.tgz
dsh plugin --profile web add github:you/dsh-single-terminal#<sha>
dsh --profile web # start (restart required for the host half to load)node-pty is a native dependency of the host half (
dependencies, kept external and loaded viacreateRequire). It ships prebuilds for common platforms; on unusual platforms a C/C++ toolchain is needed forpnpm installto compile it. The browser half inlines xterm.js entirely — no runtime dependency there.
The build toolchain is tsc + tsdown (no vite): tsc -b type-checks and
emits declarations, while tsdown (Rolldown core) bundles the host half
(lib/index.js, ESM) and the browser half (lib/client.js, single-file CJS
__ModuleLoader__ factory with auto banner wrapping). Dependency management
uses pnpm 10 (the pnpm-lock.yaml is committed and CI installs with
--frozen-lockfile). Build artifacts are committed to git, so git installs
need no build:
pnpm install # install per pnpm-lock.yaml
pnpm run build # clean lib → tsc -b (declarations) → tsdown (both halves)
pnpm run verify # simulate the host module table to check lib/client.js (optional)
pnpm run release # check + build + verify + npm version patch + push tags (triggers the publish workflow)Pushing a v* tag (pnpm run release bumps the patch version, rebuilds, and
tags/pushes automatically) triggers
.github/workflows/publish.yml — a single
release job that: sets up Node 26 → pnpm install --frozen-lockfile →
pnpm run check → pnpm run build → pnpm run verify → pnpm pack →
creates a GitHub Release (auto-generated changelog, tarball attached) →
publishes to npm via Trusted Publishing (OIDC --provenance, no
NPM_TOKEN secret; the npm package must have this repository configured as a
Trusted Publisher).
Requirements: Node ≥ 22.19 (or ≥ 24) + pnpm 10 (the packageManager field
pins the pnpm version).
pnpm install # includes node-pty + ws (runtime) and @xterm/* (bundled into the client)
pnpm run check # whole-tree TypeScript type check (tsc -b)
pnpm run build # clean lib → tsc -b (declarations) → tsdown (both halves)
pnpm run watch # tsdown watch mode
pnpm run verify # simulate the host seed table to check lib/client.js loads├── src/ # Source
│ ├── host/ # Host half: index.ts (entry, ws route + config), hub.ts (session hub + frame protocol), shells.ts (registry + probing), types.ts
│ └── client/ # Browser half: plugin.tsx (slots), drawer.tsx, term.tsx, controller.ts, ws.ts, styles.ts, theme.ts, toggle.tsx, i18n.ts ...
├── lib/ # Build artifacts (committed: git installs need no build)
│ ├── index.js # Host half (tsdown, ESM)
│ ├── client.js # Browser half (tsdown → __ModuleLoader__ factory, xterm inlined)
│ └── types/ # Type declarations (generated by tsc -b)
├── assets/preview/ # Screenshots referenced by README / preview.md
├── scripts/ # verify-client.mjs (host-seed simulation check), gen-xterm-css.mjs (regenerates src/client/xterm-css.ts)
├── tsdown.config.ts # tsdown build config (node half + client bundle banner wrapper)
├── tsconfig.json # solution: references tsconfig.host.json / tsconfig.client.json
├── cordis.patch.yml # Bundle patch: plugin row referenced by package name (no paths)
├── package.json # dsh.bundle + dsh.client(web) manifests + peerDependencies
├── README.md # This file (English)
├── README.zh.md # 中文文档
└── preview.md # Screenshot preview (references assets/preview/*.png)
- Why the plugin ships its own node-pty: the host
subprocessterminal primitive (SubprocessTerminalHandle) intentionally exposes noresize, which a resize-following terminal needs; a plugin-ownednode-ptygets the fullwrite / resize / killcontrol surface with the same ConPTY/forkpty substrate the host uses. - Transport: a dedicated WebSocket route (
/api/dsh-single-terminal.ws) registered throughctx.webServer.registerUpgrade, gated byctx.connection.requestRejection(same trusted-host fence as the host API gateway). The client connects same-origin and rides thedsh-authcookie. - Session model: sessions live in a hub
Mapindependent of sockets — page refresh / reconnect re-lists, adopts live sessions andattaches with areplayof the ring buffer. Exited sessions are pruned so dead tabs never resurrect. Multiple browser tabs may attach to one session (output is broadcast, input is merged). - Frame protocol: JSON text frames; client → host
open / input / resize / close / list / attach / ping, host → clienthello / shells / opened / data / replay / exit / error / pong.input/resizeare size-capped and clamped server-side. - Windows process tree: closing a tab runs
pty.kill()and additionallytaskkill /T /Fon the session pid — ConPTY closure alone can leave PowerShell (+PSReadLine) alive; POSIX kills the foreground process group (kill(-pid)). - Theme following: all plugin CSS consumes the host's semantic alias
tokens (
--dsw-alias-*, defined onbodyand flipped bybody[data-ds-dark-theme]), so light / dark / custom themes apply without plugin-side logic. The xterm palette is computed at runtime: alias token values are read via a hidden probe element (getComputedStyle), the background is re-composed with the frosted alpha, and aMutationObserveron the body attribute re-applies the palette — theme switches (including custom themes projected by the host's ThemePresenter) update live. - Renderer strategy: xterm 5 ships DOM renderer only by default
(
allowTransparencyworks there, while WebGL canvases are opaque). Both modes share the frosted style, so the plugin stays on the DOM renderer with a translucent terminal background under the frosted blur in every mode — the WebGL addon was removed rather than swapped at runtime. - The official
deepseek-harnessproject is not modified; all UI sits in existing slots (shell.overlay,conversation.session.header.utilities).
