vecnode/vncode--packages-dsh-terminal ↗★ 3
dsh-terminal
Terminal: a real shell in a bottom dock of the vncode Web GUI, plus a live transcript of the agent's OWN terminal use. A header button (the last entry in the header's utilities list, left of the right bar's toggle) 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, which make room for it while the left bar keeps its full height and its layout untouched. Inside: xterm.js (vendored, no build step) attached over an authenticated WebSocket to a real PTY from the harness's own node-pty - ConPTY PowerShell on Windows, the login shell on macOS/Linux - one shell per conversation, several terminals per dock, re-fitting on every resize so the visible lines match the panel and the newest output stays in view. An Agent button on the dock's bar toggles to a read-only view of the commands the conversation recorded (bash/pwsh/run_code/terminal_send, grouped under the prompt that asked for them, with exit status, duration and output), served from the HOST's own copy of the conversation log so it fills in as soon as the app opens. That button is the ONLY Agent control: it toggles between the log and the terminal that was last on screen, wearing the log's own state: a pulse while a command runs, the count of what failed, a warning when the conversation's log cannot be read here, and the counts in its tooltip, with a dot on the header control while something is running. Follows the app's light/dark theme and copies/pastes with Ctrl+Shift+C/V. Alpha. 适合需要在界面底部直接使用系统Shell并查看AI终端操作的用户。
同名パッケージの別リポジトリ
インストール
npx -p @deepseek-ai/dsh dsh plugin --profile web add github:vecnode/vncode#b3a6a0d53b96ef29d452af3962e8d726a122db3a&path:packages/dsh-terminalドキュメント
README 全文を読む ↗dsh-terminal (alpha.10)
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, the last entry in the header's utilities list — 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.
Right beside it — behind one Agent button, which toggles between the two — is
a live transcript of the agent's own terminal use: every
bash/pwsh/run_code/terminal_send call this conversation recorded, grouped
under the prompt that asked for it, with its exit status, duration and output. It
is a second view of the conversation you are already in, not a second shell: see
below.
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/activity (read-only), /api/dsh-terminal/vendor/xterm.js, /api/dsh-terminal/vendor/xterm.css |
| Node upgrade | /api/dsh-terminal/pty (WebSocket, authenticated) |
| agent view | read-only: the /activity route answers a filtered tail of the conversation's session events, folded in the browser by the same pure fold the check drives |
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. The left bar's height and its contents stay exactly where they are.
- 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 — while the columns' size changes on every frame of the transition.transitionendis the final snap.- Installed once, not per height. Placement and tracking are two effects:
placing the dock is a two-write effect, and the observers are installed while
the dock is open and read
dock.heightat call time (module state, so there is no stale closure).
- Installed once, not per height. Placement and tracking are two effects:
placing the dock is a two-write effect, and the observers are installed while
the dock is open and read
- 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. - Resizing — the grip drags the height (120px … 70% of the viewport), and it is
remembered in both
localStorageand the pack's shared section. Every change re-fits the emulator: rows and columns are recomputed from the new box and the view is put back on the end of the output, so a drag never leaves a stale screen with the wrong number of lines and never hides the newest ones. Three rules keep the drag itself honest:- the height comes from the pointer's Y and the values captured at
pointerdown, never from the dock's rect — the grip moves as the dock moves, so a handler that measured it would chase itself. Moves are coalesced to one per animation frame, the pointer is captured for the duration, and the drag closes onpointerupandpointercancel.body.dst-draggingcarries the row-resize cursor and no text selection while the pointer is down; - the PTY is told the new size at most every
SIZE_WIRE_MS(120ms) while the drag runs, and always once when it settles. The emulator is re-fitted on every frame — that is what makes the line count follow the pointer — but every size message makes the shell redraw its prompt, and one per frame is ~60 prompt redraws a second; - the shared section is written once, 400ms after the drag settles (plus a flush at release), never per pointer move.
- the height comes from the pointer's Y and the values captured at
- An accepted shared view moves the dock only when it is news — never while
the pointer is down, while a write of ours is on the wire, or for the value
already in force.
adoptDecisionis exported in__internalsand pinned behaviourally by the tracked check: the arithmetic is four numbers and two flags.
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. - The agent's own terminal use, beside your shell: the bar's Agent button toggles the panel to a read-only transcript of every command this conversation ran — grouped under the prompt that asked for it, with the tool, the working folder, the duration, the exit status and the output. It is the only Agent control. See The agent's own terminal use below.
- The chip strip scrolls sideways once the terminals outgrow it. While there
is real overflow the strip grows a
‹and a›— one page per click, and they dim at the ends — a bare wheel over the strip moves it, and the chip on screen is always scrolled into view, so a terminal added with+(or picked from the strip) never lands out of sight. The+sits outside the strip and is therefore always reachable. The strip wears those arrows instead of a scrollbar: a classic scrollbar on a 24px row costs more height than it explains and would shift the whole bar the moment one more chip appeared. - 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 agent view degrades the same way: a host whose routes are not reachable, or a conversation that is not open on this host, says so in the panel instead of throwing in a render.
The agent's own terminal use
Your shell and the agent's commands are two different worlds. The agent runs
bash/pwsh through the harness's own shell tool, in the harness's own process;
it never touches the PTY in this panel, and this panel cannot attach to it. So
the second view is not a second terminal: it is a transcript of what the
conversation recorded, drawn in the dock because that is where you are already
looking. You keep your shell, and you can see — without reading a single message
— what the agent ran.
The switch is a toggle, and it is the only one. Agent in the dock's bar
switches the panel to the log and switches it back to the terminal you were on
(not to slot 1) — one control, one meaning: the button is on exactly while the
log is what the panel shows, and picking any terminal chip turns it off. The
button wears the log's own state: a pulse while a command runs, the number of
commands that failed as a red badge, the counts in its tooltip, a dot on the
header control while something is running, and — the one warning nothing else on
the dock can show — the warning tone and a ⚠ when this conversation's log
cannot be read here, with the host's own reason in the tooltip. Every one of
those numbers is the COMMANDS' (alpha.11). A tool call that is not an executing
tool — a read, a grep, an edit — is a row under the All tools filter and
never a number on the control: before alpha.11 any failed tool incremented the
failure count, so a conversation whose only tool call was a failed read wore a
red 1 badge and a red header dot while its own tooltip said 0 commands, 1
failed, nothing run yet in one breath. The other family's running and failed rows
are counted separately, so All tools can still be described honestly. The toggle
is remembered per origin in
localStorage — a view preference, which is exactly why it may be remembered
while the dock's open state deliberately is not: the panel is a window onto a
process, and a boolean that resets merely re-hides the log. It is localStorage
and not the pack's durable section on purpose: promoting it would mean adding a
field to dsh-ui-state's schema, i.e. changing another package's data contract
for a boolean.
One cell, one sentinel. The log is not a PTY slot — there is no shell behind
it and nothing to attach to — so it rides the same dock.active cell as the
terminals under ACTIVITY_VIEW = -1, an index no slot has. That is what lets the
runtime hide every emulator with the code it already had while keeping the
emulators mounted: a shell is a process, and hiding it must not detach it.
What it shows. One row per executing tool call — bash, pwsh (foreground
and persistent), run_code, terminal_send — grouped under the human prompt
that preceded it, so the log reads what you asked, then what it ran. Each row
carries the tool, the command (or the sent text), the working folder when the
call named one, the duration, and a status pill: running, exit N,
killed · SIG, error, or done. Output is clamped to 12 lines with
Show all N lines; long output is never hidden outright. A row expands on
click (or on Enter/Space — the head is a role="button"), and carries three
actions: Copy command, Copy output, and Run in Terminal.
A row is readable at a glance, in any theme. The row's status is drawn as a
rail down each side — left and right, so a long command line cannot leave
the mark behind, and the exit-0 green counts. The row's head — the
clickable line that drops the output down, i.e. the line a reader actually scans
— wears a light wash of that same colour, so the command lines separate from
their own output without the output losing contrast. One custom property
(--dst-accent) holds the tone per status, so the rails, the wash and the pill
can never disagree: green for exit 0, amber while running, red on a failure,
a signal or an error. The wash is mixed with transparent rather than with a
surface colour, which lightens a light theme, darkens a dark theme, and leaves
the label's own themed colour alone.
Run in Terminal TYPES the command into your active shell; it does not submit
it. You get the agent's command line in your prompt, to read, edit or run —
which is the whole point of having both halves in one panel. It is offered only
for a single-line command: a multi-line command would have its newlines
submitted as they were typed, so the row says multi-line instead of offering a
foot-gun.
Filters. Commands (the default) / All tools switches every other tool call
into the log as a one-line row with its own status and a summary taken from its
arguments (file_path, pattern, query, …); Failures narrows to what went
wrong. The filter is applied at RENDER time: changing it never re-reads the
conversation.
Following is a scroll position, not a mode. The view follows the tail while you are at the bottom, stops the moment you scroll up (a Follow ↓ pill appears in its bar to come back), and shows the newest command otherwise.
Where the data comes from — the conversation's own durable session events,
served by this package's read-only Node route GET /api/dsh-terminal/activity
and folded in the browser by the same pure fold the tracked check drives:
| Event | What it contributes |
|---|---|
tool/call | { turn, step, callId, name, arguments } — arguments is the RAW JSON string, so the command appears the moment the call is dispatched |
tool/result | { turn, step, message, error?, meta? } — message.content[0] is the ToolResultBlock: its content is the output, its isError the failure flag |
user/message | a prompt when source.kind === 'user' (every other kind is injected context, and does not open a group) |
The route is read-only, filtered and bounded: only those three event types are
sent (an assistant message with its embedded stream is neither sent nor needed),
injected context and non-human user/message events are dropped on the host, and
the answer is the tail — at most 400 events and roughly 512 KiB, newest first
— with hasMore stating that older ones were left out rather than truncating
silently. The newest event is always included even if it alone is oversized: a
single enormous command must not leave the panel with nothing to draw. A
conversation that is