vecnode/dsh-vn-plugins--packages-dsh-terminal ↗★ 0
dsh-terminal
在Web界面底部集成真实的终端Shell
AI 分析
适合需要在对话界面下方直接运行PowerShell或系统Shell进行命令行操作的用户。
安装
npx -p @deepseek-ai/dsh dsh plugin --profile web add github:vecnode/dsh-vn-plugins#e4849fa724696ea1b9cca8fe58995151fb2d0ee1&path:packages/dsh-terminal说明文档
阅读完整 README ↗dsh-terminal (alpha.3)
Terminal is a bottom dock for the DeepSeek Harness web GUI: a real shell, in the app, under the conversation. A header button — the same 28px round control the right bar's own toggle wears, sitting immediately right of Open In... — opens a horizontal panel that starts at the right edge of the left bar, runs to the full width of the page, and sits under the middle and right columns. Those two columns make room for it and only those two: the left bar keeps its full height, and nothing in it moves.
Inside the panel is xterm.js talking to a real PTY over an authenticated
WebSocket: ConPTY PowerShell on Windows, the login shell on macOS/Linux. Prompts,
colors, TUI programs, Ctrl+C, resizes and scrollback all behave like a terminal
because it is one.
How it plugs in
Nothing shipped is patched, no core row is disabled, and nothing is forked: this package adds surface. It contributes two things and owns one row.
| Piece | Value |
|---|---|
| row | terminal (cordis.patch.yml, an insert) |
| header control | conversation.session.header.utilities, order: 30 |
| dock | shell.overlay (the layout package's root-scoped list), order: 50 |
client inject | slots (code), @deepseek-ai/dsh-client-ui-conversation (package) |
| primitives used | Tooltip only — the terminal glyph is drawn here |
| Node routes | /api/dsh-terminal/health, /api/dsh-terminal/vendor/xterm.js, /api/dsh-terminal/vendor/xterm.css |
| Node upgrade | /api/dsh-terminal/pty (WebSocket, authenticated) |
Why the header list and not the corner. conversation.session.header.corner
is a single-occupant slot that the right bar's toggle already owns, so
registering there would replace it. ...header.utilities is a list: Open In
sits at -10, the pack's Themes at -20, and this control at 30 — the last
utility, directly left of the corner. Change that one number to move the button.
Why shell.overlay and not a second React root. The dock has to escape the
frame's overflow:hidden to sit at the very bottom of the window, and it has to
live in the app's tree to inherit its React context. Both hold at once: the
overlay layer is rendered inside the frame, and the dock is position:fixed,
which no ancestor's overflow can clip. The layer's own z-index:20 also puts the
dock above the columns (10/11) and below a fullscreen right bar (40) for free.
Geometry
The dock is not a grid child of the app frame (that would mean writing foreign nodes into a React-managed container), so it positions itself:
- Left edge — the frame's columns are an inline
gridTemplateColumns: px minmax(0,1fr) px, so the RESOLVED computed style carries the left bar's width in px. No hashed class names, and it follows the left bar opening, collapsing (0) and being dragged. - Room — taken from the middle and right columns only, as their own
height: calc(100% - px); on close each gets back the inline height it had before this plugin ever ran. Both are found without hashed class names: the layout marks the right column itself (data-rightbar-col), the middle column is its immediately preceding sibling in the frame, and the frame's first element child — the left bar — is explicitly never one of them.- Not the frame's height. The frame has a single grid row, so shrinking the frame shortens the left column with it: alpha.1 did exactly that, and the left bar's contents visibly slid up the moment the dock opened.
- Not
padding-bottomeither. The right column's panel is absolutely positioned inside it, and an absolute child is placed against its ancestor's padding box, so padding would leave that panel where it was and the dock would cover its bottom. A height shortens the column itself.
- Live tracking — a
MutationObserveron the frame'sstyleattribute (a drag rewrites it every frame), aResizeObserveron the two columns the dock spans, atransitionendon the frame, and aresizelistener. TheResizeObserveris what follows the left bar being collapsed or expanded: that is animated, so the grid tracks are rewritten once and then transitioned — the mutation reports the pre-transition value and never fires again (alpha.2 left the dock standing at the old edge), while the columns' size changes on every frame of the transition.transitionendis the final snap. - Intent is separate from geometry.
data-openis user intent;data-suspendedis derived (a fullscreen right bar takes the viewport, and the dock yields and hands the columns their height back for the duration). The observer only ever writes the derived one — the first spike run failed exactly here, reopening the dock on the very close that restored the frame's height. - Resizing — the grip drags the height (120px … 70% of the viewport) and it is
remembered in
localStorage; every change re-fits the emulator: rows and columns are recomputed from the new box, the new size goes to the PTY, and the view is put back on the end of the output. A drag therefore never leaves a stale screen with the wrong number of lines, and never hides the newest ones.
What it does
- Several terminals per conversation (up to 8): the bar's chips switch
between them,
+opens another, a chip's×kills that one shell. The dock is bound to the conversation that opened it; switching conversation closes it. - Clipboard is
Ctrl+Shift+C/Ctrl+Shift+V(Cmdon macOS). A bareCtrl+Cstays SIGINT, which is the whole reason for the shift. - Follows the app's appearance: the palette is re-applied from
body[data-ds-dark-theme]and re-paints the live terminals; the dock element carriesdata-appearanceso the appearance in force is visible. - Survives a reload. Detaching (page reload, closing the dock) closes the socket but not the shell: the PTY is kept for five minutes, and a reattach replays the retained scrollback (256 KiB ring). Nothing is left running for ever — an unattached session is reaped.
- Graceful degradation. If the host has no PTY, the dock says so with the reason instead of failing; the row still mounts and the rest of the pack is untouched.
The PTY comes from the harness, not from this pack
A browser terminal needs a real pty, and the harness already installs
node-pty (prebuilds for win32-x64/arm64 ConPTY, darwin-x64/arm64,
linux-x64/arm64) as part of its own dependency closure. So this package
installs nothing and builds nothing native. What it does have to do is find
it: an out-of-tree plugin's own path is this repository, and Node resolves bare
specifiers by walking up from the importing FILE, so import('node-pty') from
here fails. lib/pty.js therefore resolves through, in order:
process.argv[1]— the running entry, whose parent walk lands in that installation'snode_modules;$DSH_HOME/profiles—@deepseek-ai/dsh-app-bootmirrors the installation closure into$DSH_HOME/profiles/node_modulesfor exactly this;- this package's own directory — so a future line where
node-ptyis a declared dependency here works unchanged.
ws is loaded the same way. Because node-pty is a harness internal rather than
a published API, resolution failure is a first-class outcome: GET /api/dsh-terminal/health answers available:false with the reason, and the dock
renders it.
The dock is an unsandboxed shell. That is what a terminal is: it does not
pass through the file-policy sandbox that the model's tools obey. The gate is the
connection's own authentication, checked before the socket reaches ws
(connection.requestRejection, then a raw 401/403 written into the socket — the
same two-step the product's own WebSocket mux performs).
Wire protocol
Text frames; a control frame is prefixed with U+0000 so that cat of a JSON
file can never be mistaken for one.
| Direction | Frame |
|---|---|
| → | \0{"t":"init","session":"","slot":0,"cols":80,"rows":24} (always first) |
| → | \0{"t":"resize","cols":N,"rows":N} · \0{"t":"kill"} · \0{"t":"ping"} |
| → | anything else: written to the shell verbatim |
| ← | \0{"t":"ready","key","index","shell","cwd","pid","cols","rows","attached","replay"} |
| ← | \0{"t":"exit","code","signal"} · \0{"t":"closed","reason"} · \0{"t":"pong"} · \0{"t":"error","code","message"} |
| ← | anything else: raw terminal output |
Backpressure is honest, not invisible: past 4 MiB of unflushed socket bytes the
PTY is paused (IPty.pause) and resumed once the queue drains. Nothing is
dropped. A heartbeat (ping/pong) drops dead sockets. pid is null in the
first ready frame on Windows, where node-pty reports 0 until ConPTY attaches.
Files
package.json one dsh bundle: the row, plus the client half
cordis.patch.yml bundle layer: inserts the 'terminal' row (nothing else)
lib/index.js Node half: the routes above + the authenticated upgrade
lib/shell.js the ONE per-OS file: which shell this host runs
lib/pty.js node-pty resolution, the session registry, the reaper
lib/client.js browser half (module-table bundle, no build step)
lib/vendor/xterm.js GENERATED - vendored xterm.js classic bundle (window.DSHTerminal)
lib/vendor/xterm.css GENERATED - its stylesheet, served beside it
vendor/package.json + vendor/entry.js - reproducible build inputs
Regenerating the vendored xterm bundle
cd packages/dsh-terminal/vendor
npm install
npx --yes esbuild entry.js --bundle --minify --format=iife --global-name=DSHTerminal \
--target=es2020 --outfile=../lib/vendor/xterm.js
cp node_modules/@xterm/xterm/css/xterm.css ../lib/vendor/xterm.css
(On Windows use ..\lib\vendor\xterm.js in the last argument and Copy-Item for
the stylesheet.) The bundle is generated: never edit lib/vendor/* by hand.
Checks
node scripts/checks/check-client-bundles.mjs # bundle id, both seats, order 30, markup, geometry invariants
node scripts/checks/check-node-routes.mjs # routes, etag, and a LIVE shell over a real socket
The node check drives the real protocol against a real PTY when this host has
one (and says so when it does not): init → ready → a command answered →
kill, a JSON line proven to be shell input rather than a control frame, and an
unauthenticated upgrade refused with 401.
The client check cannot run effects, so the geometry promises are pinned at the
source level: this bundle must never write the frame's height, it must inset the
two columns it spans, it must re-fit (and follow the end) on a resize, and it
must track the left bar through both the mutation observer and the column
ResizeObserver (plus the transitionend snap).
The behaviour itself was verified in a real browser engine while it was built —
including that the left bar's height and contents are byte-for-byte where they
were before the dock opened, that the middle and right columns end exactly at the
dock's top edge, that growing then shrinking the dock takes the visible rows from
13 → 16 → 6 with the newest output on screen throughout, and that the dock follows
an animated sidebar collapse and expand (the case that failed in alpha.2: with
the tracking removed, that check reports the dock stuck at its old edge).
Alpha notes
- alpha.3 — collapsing or expanding the left bar left the dock at its old left
edge. The left bar is animated: one grid rewrite, then a CSS transition, so the
MutationObserveron that rewrite reports the pre-transition track and is never called again. AResizeObserveron the two columns the dock spans (whose size changes on every frame of the transition) now follows it, with atransitionendsnap as the backstop. Measured in the engine: the callback sees260pxwhile the track animates260 → 171 → 62 → 60. - alpha.2 — two reports from the first run, both fixed here:
- resizing the dock left the emulator at its old size, so the line count was wrong and the newest output could sit out of view. Every size change now re-fits (rows/cols from the new box), tells the PTY, and scrolls to the end;
- opening the dock shortened the left bar (the frame's single grid row was
being shrunk with it), so its contents slid up. The room now comes from the
middle and right columns as their own height — the left bar is never touched,
and it is handed back exactly on close.
2 also changed the mechanism, not just the numbers:
padding-bottomwas tried first and is wrong for the right column, whose panel is absolutely positioned inside it against its ancestor's padding box.
Known limits
- One PTY per (conversation, slot); slots are capped at 8 per conversation.
- Sessions do not survive a harness restart (they are process-local), and the scrollback ring is 256 KiB — older output is dropped, not paged.
- The dock is positioned from the frame's resolved grid tracks, and the two
columns it insets are found from the layout's own
data-rightbar-colmarker plus sibling order: a harness line that stops usinggrid-template-columnsfor the columns, or that puts something else between the middle and right columns, needscolumnsForupdated (there is no layout service API for a bottom region). - A fullscreen right bar suspends the dock while it is up.
- Windows reports
pid: nullin the firstreadyframe (see above).