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终端操作的用户。

패키지
dsh-terminal
호환성
미검증
버전
0.1.0-alpha.11
라이선스
MIT
최근 업데이트
2026. 9. 30.

같은 패키지 이름의 다른 저장소

설치

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:vecnode/vncode#b3a6a0d53b96ef29d452af3962e8d726a122db3a&path:packages/dsh-terminal

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.

PieceValue
rowterminal (cordis.patch.yml, an insert)
header controlconversation.session.header.utilities, order: 30
dockshell.overlay (the layout package's root-scoped list), order: 50
client injectslots (code), @deepseek-ai/dsh-client-ui-conversation (package)
primitives usedTooltip 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 viewread-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-bottom either. 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 MutationObserver on the frame's style attribute (a drag rewrites it every frame), a ResizeObserver on the two columns the dock spans, a transitionend on the frame, and a resize listener. The ResizeObserver is 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. transitionend is 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.height at call time (module state, so there is no stale closure).
  • Intent is separate from geometry. data-open is user intent; data-suspended is 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 localStorage and 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 on pointerup and pointercancel. body.dst-dragging carries 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.
  • 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. adoptDecision is exported in __internals and 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 (Cmd on macOS). A bare Ctrl+C stays 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 carries data-appearance so 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:

EventWhat 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/messagea 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