dsh-tui
sagmans/dsh-tui
Terminal (CLI TUI) surface for DeepSeek Harness: interactive dsh in a terminal, no browser required
Установка
dsh plugin --profile web add github:sagmans/dsh-tuiREADME
@sagmans/dsh-tui
Interactive terminal (TUI) surface for DeepSeek Harness: use dsh in a terminal instead of a browser.
Status: v1 feature-complete; published on npm as @sagmans/dsh-tui. The surface owns the alternate screen, streams assistant text as markdown, renders every tool's own card, answers approvals and questions, restores and names stored conversations, switches model mid-session, runs any of the four shipped agent modes and switches between them before a session's first turn, reads a child agent's conversation in place, keeps the goal, plan mode, todo list, delegations, and background jobs above the editor with a status line below it, and hands the terminal back on every graceful exit. Publication is tag-driven with GitHub OIDC provenance and no stored npm token; see RELEASE.md.
Install
A profile keeps this plugin as one bundle layer. Install it from a checkout of this repository, or from the registry (0.1.0 or later). Both paths need Node.js >= 22.19 and pnpm on PATH. Both need a real terminal: stdin and stdout must be TTYs.
From a plugin checkout
A linked profile loads the package's built entry point. Build the checkout before you add it:
cd "$PLUGIN_CHECKOUT"
CI=true pnpm install && pnpm run build
dsh plugin --profile tui add "$PWD"
dsh --profile tui
The add command creates the tui profile on first use and records a link to the directory. Keep the checkout in place. If you move or delete it, the link breaks, and a later plugin install removes the bundle from the layer list (see Troubleshooting).
From the registry
dsh plugin --profile tui add @sagmans/dsh-tui@latest
dsh --profile tui
Releases are published, so this path works today. A checkout stays the path for unreleased work.
Confirm the plugin mounted
The profile records its layers in $DSH_HOME/profiles/tui/package.json (~/.dsh by default). @sagmans/dsh-tui must appear in dsh.profile.bundles:
node -p "require((process.env.DSH_HOME ?? require('node:os').homedir() + '/.dsh') + '/profiles/tui/package.json').dsh.profile.bundles.join('\n')"
# @deepseek-ai/dsh-base
# @sagmans/dsh-tui
Then check that the surface is mounted. A pipe is not a terminal, so this command must refuse before it takes the screen over:
echo hi | dsh --profile tui
# dsh-tui: both stdin and stdout must be TTYs; run this profile from a terminal or SSH session
Update
Rebuild a linked checkout, then start the next session. The link itself does not change:
cd "$PLUGIN_CHECKOUT" && git pull && CI=true pnpm install && pnpm run build
A registry install updates with dsh plugin --profile tui update @sagmans/dsh-tui.
Remove
dsh plugin --profile tui remove @sagmans/dsh-tui
The profile then keeps @deepseek-ai/dsh-base and no application, so dsh --profile tui waits with no output. Add the plugin again to use the profile.
Launching from a harness checkout
pnpm dsh --profile tui is the sanctioned launcher, but pnpm verifies that dependencies are current before it runs any script, and a checkout whose postinstall refuses to take over a user-owned core.hooksPath fails that check — the process exits before the surface starts. Any of these reaches the surface:
pnpm --config.verify-deps-before-run=false dsh --profile tui # from the checkout
CI=true pnpm dsh --profile tui # also suppresses the check
node "$CHECKOUT/apps/cli/lib/bin.js" --profile tui # needs neither pnpm nor the check
Troubleshooting
Two facts explain most failures.
A linked profile is a link, not a copy. The profile points at a directory, so a checkout that moves or disappears breaks it.
dsh plugin install removes a bundle it cannot resolve, and says nothing. The command reconciles dsh.profile.bundles against the installed dependencies. A bundle whose path does not resolve leaves the list, and the command still exits 0. The next launch composes @deepseek-ai/dsh-base alone. No application plugin mounts, so nothing reads the command line: dsh --profile tui then prints nothing and never exits, and --help waits with it.
| Symptom | Cause | Fix |
|---|---|---|
dsh: cannot resolve profile bundle "@sagmans/dsh-tui" ... | the linked checkout moved or was deleted | dsh plugin --profile tui add "$PLUGIN_CHECKOUT" |
dsh --profile tui prints nothing and never exits | the bundle left dsh.profile.bundles, usually after a broken link and a plugin install | confirm the layer list, then run the add command again |
dsh-tui: both stdin and stdout must be TTYs | stdin or stdout is a pipe, a file, or a CI runner | run the command from a terminal |
Changes under src/ have no effect | a linked profile loads lib/, not src/ | pnpm run build in the plugin checkout |
pnpm dsh --profile tui exits before the surface appears | pnpm's dependency check fails on the harness checkout's own postinstall | see Launching from a harness checkout |
--preset <id> is refused, because the session's agent preset is fixed | a session keeps the mode that composed it, and this session already took a turn | /preset <id> before the first turn, or resume without --preset |
--resume <id> starts a new session | the id is a bare UUID | pass the stored id, tui-session-… included; a bare --resume opens the picker |
dsh: profile "tui" does not exist | the profile is not created yet | the add command creates it |
The full recovery from a broken link:
cd "$PLUGIN_CHECKOUT" && CI=true pnpm install && pnpm run build
dsh plugin --profile tui add "$PWD"
node -p "require((process.env.DSH_HOME ?? require('node:os').homedir() + '/.dsh') + '/profiles/tui/package.json').dsh.profile.bundles.join('\n')"
# @deepseek-ai/dsh-base
# @sagmans/dsh-tui
Usage
dsh --profile tui # new session in the current directory
dsh --profile tui --resume # pick a stored session, titled by its first prompt
dsh --profile tui --resume <session-id>
dsh --profile tui --preset ptc # start in one of the shipped agent modes
dsh --profile tui --model deepseek-chat
dsh --profile tui --no-color
dsh --profile tui --no-bell # do not ring when a long turn finishes
| Key | Action |
|---|---|
| Enter | submit the prompt |
| Ctrl+C | interrupt the running turn, or leave when idle |
| Ctrl+O | show every line of the tool cards instead of their preview |
| Ctrl+T | show the reasoning behind an answer instead of its summary |
y / n / Esc | allow once, reject, or cancel a pending approval |
| digits / space / ↑↓ / Enter / Esc | answer a question: pick or toggle, confirm, or skip one |
/ then Tab | complete commands, including every command this session registered |
@ or a path then Tab | complete workspace file references |
ctrl+shift+f | search the transcript (enter next, shift+enter previous, esc close) |
home / end | jump to the start or the end of the transcript |
ctrl+down | jump to the next prompt |
ctrl+b | leave a child's conversation and return to this session |
| mouse wheel, drag | scroll, and copy a selection through OSC 52 |
/help | list registered and local commands |
/status | show the session id, model, permissions, context, and directory |
/model | show the route the next step will use, and the providers available |
/model <provider> | list that provider's advertised models |
/model <provider>/<model> | use that route from the next step on (session only, nothing is written to settings) |
/preset | pick the agent mode for this session from the roster |
/preset <id> | switch to that mode, while the session is still blank |
/jobs | list background jobs with their state and duration |
/jobs read <id> / /jobs kill <id> | show the tail of a job's output, or stop it |
/subagents | list the delegations this session started, with their provider and age |
/subagents open <id|last> | read a child's own conversation in place; ctrl+b comes back |
/subagents kill <id> | stop a live child agent |
/fork [title] | branch this conversation after its last completed turn and continue in the branch |
/rename <title> | title this session; the picker shows it instead of the session id |
/export [path] | write the visible transcript as markdown (default dsh-session-<id>.md) |
/resume | open another stored session without leaving the terminal |
/clear | clear the visible transcript |
/quit | leave and print the resume command |
Any other /command goes to the command registry, so /plan, /compact, /goal, and /feedback behave as they do on the other surfaces.
Modes
A mode is an agent preset: the plugin composition an agent's own scope joins. It decides that agent's tools, prompt sections, skills, and planning rows, which is why a mode is fixed once a session has produced a turn — it is what composed the agent that answered.
Four ship, under the ids a session log records:
--preset | Mode | What the agent gets |
|---|---|---|
standard | standard | full agent: editing, shell, search, skills, planning, goals, subagents, workflows |
ptc | PTC | the same agent, reaching its tools through one TypeScript program |
minimal | minimal | one tool: a persistent shell |
cordis | creator | harness authoring: runtime inspection and composition guidance |
A session takes its mode from the first of these that applies:
--preset <id>, refused before the terminal is taken over when the roster does not ship that id./presetwhile the session is still blank: a bare command opens the picker,/preset <id>switches directly, and the choice is written to the log.- The roster's default,
standard, when nobody names one.
The mode is re-read rather than remembered: resuming mounts what that session's own log recorded, resuming with a --preset that disagrees with it is refused instead of silently ignored, and forking inherits the mode of the conversation being branched. The status line names the mode, and /status lists it with the rest.
How it works
The package is a Cordis plugin bundle that stacks over @deepseek-ai/dsh-base:
@sagmans/dsh-tui/startupparses this app's own flags and publishes the launch identity.@deepseek-ai/dsh-agent-presetsis the roster of modes, holding the id a session starts in when nobody names one.@deepseek-ai/dsh-code-runtime-worker-threadand@deepseek-ai/dsh-cordis-host-runnerare the host machinery PTC mode and creator mode need; only the Web bundle shipped them, so a terminal profile has to mount them to offer those modes at all.@sagmans/dsh-tuiowns the terminal: it creates or resumes one agent throughctx.agents, foldssession/eventinto transcript rows and work state, renders them with@earendil-works/pi-tui, and releases the terminal on exit, on a boot failure, and on a signal.
The fold is durable-only: the live stream decorates the row that is still being written, and everything else — cards, reasoning, work state, compaction markers — comes from the log, so a resumed session renders what the live one did. Subagent start and finish are the exception: they arrive as service events, and the transcript shows them as decoration because the durable record of a delegation is the tool call that asked for it.
The bundle also takes the base's global agent rows out of the composition, twenty-three of them. Every one is a row the shipped modes supply per session instead, so leaving it mounted registers the same tool names in two layers and doubles each prompt section it owns. What stays mounted is the host: sessions, storage, models, permissions, jobs, and the command registry.
Development
pnpm install
pnpm run typecheck
pnpm test
pnpm run build
A linked profile loads the built entry point, so edits under src/ are invisible to dsh --profile tui until pnpm run build runs. Drive the real surface end to end — it rebuilds first, allocates a PTY, sends a prompt, and prints what the screen showed:
node tools/pty-drive.mjs --prompt 'Reply with exactly: pong'
node tools/pty-drive.mjs --prompt 'Run: echo hi' --approve 20 # answer the approval gate
node tools/pty-drive.mjs --home /tmp/scratch-home --seconds 20 # no credentials: proves failures are visible
Keep verification off your real home: install the profile into a throwaway one and copy only the credentials it needs.
S=$(mktemp -d)
cp ~/.dsh/.credentials.yaml ~/.dsh/settings.yaml "$S/" && chmod 600 "$S"/*.yaml
DSH_HOME="$S" dsh plugin --profile tui add "$PWD"
node tools/pty-drive.mjs --home "$S" --prompt 'Reply with exactly: pong'
Test specs import plugin sources through the @/ alias. Under this test runner the spec file is resolved with a root-relative id, so parent-relative imports (../src/...) do not resolve; the alias and its matching tsconfig.test.json path mapping avoid that.
Manual acceptance
The automated checks drive a real PTY, but they run on this machine's terminal. These are the checks only a terminal on your desk can answer; each line is what to do and what it should look like.
| Check | Expected |
|---|---|
DSH_HOME=$(mktemp -d) dsh plugin --profile tui add "$PWD" from a built checkout | the profile is created, dsh.profile.bundles lists the plugin, and dsh --profile tui reaches a prompt |
| the same over SSH | the interface arrives intact; keys and mouse work on the host, with no local echo doubling |
| inside tmux or screen | wheel scroll and ctrl+shift+f search work; dragging selects text |
| a light terminal and a dark one | the interface follows the terminal's own palette; nothing becomes unreadable |
NO_COLOR=1 dsh --profile tui | no styling anywhere, layout unchanged |
dsh --profile tui --no-bell | a turn that runs for minutes still ends silently |
dsh --profile tui --preset ptc, then a turn | the status line names ptc, and the agent reaches its tools through one TypeScript program rather than one shell call at a time |
/preset on a fresh session | the picker lists four modes, marks the current one, and the switch survives a resume |
/preset minimal after a turn | refused, naming the reason; the session keeps the mode it composed with |
--resume --preset <mode> and then picking a session that runs another mode | the list stays open and says why that row cannot be taken; esc leaves the picker |
dsh --profile tui --preset nope | exits non-zero naming the modes that do exist, before the alternate screen appears |
| arrow keys in a picker, or on a question's options, in a terminal that reports key events (Kitty, WezTerm, Ghostty, iTerm2) | one press moves one row, and holding a key still repeats; a terminal that sends only the legacy sequence behaves the same |
| resize the window mid-turn | the transcript rewraps; the dock, editor, and status row stay put |
| a 40-column terminal | rows end in … instead of wrapping into the next line |
echo hi | dsh --profile tui | refuses with a non-zero exit and a message naming the TTY requirement |
/quit, Ctrl+C while idle, kill -TERM <pid> | the shell returns with cursor, echo, mouse, and title restored |
Releasing
Published artefacts carry a provenance attestation, which only a CI provider can issue, so releases ship from the tag workflow rather than a laptop.
- Bump
versioninpackage.json, land it onmainthrough a reviewed PR, and wait for CI to pass on the merged SHA. - Tag that SHA with a signed tag and push it. The tag ruleset admits repository admins only.
.github/workflows/release.ymlre-runs typecheck, tests, and the package smoke; the publish job then waits for a maintainer's approval on thenpm-releaseenvironment before it publishes with OIDC trusted publishing and automatic provenance.
The workflow stores no npm token: the registry trusts release.yml on the npm-release environment, and scripts/npm/release.py creates both the environment and that trust. The full runbook is RELEASE.md.
Limitations
- Two different things are called a preset. The agent mode (
--preset,/preset) is fixed once a session has produced a turn; the permission preset (/permission <preset>, named in the status line) can change at any time. /modelchanges the route for the running session only. Catalog membership is advisory — an adapter may accept an id it does not advertise.- Scrolling is the mouse wheel, or the terminal's own scrollback keys where it offers them.
- A turn that ran longer than ten seconds rings the terminal bell when it ends, because the reader may have walked away;
--no-bellturns that off. - The dock shows the goal, plan mode, the todo list, and any background job still running; the transcript marks where older history was compacted away.
/plantoggles plan mode;/plan <message>also steers that message, which is the base command's own behaviour. - Background jobs and subagent runs are live process state, not durable events: they disappear when the run ends, and a resumed session starts with an empty board and roster.
- Reading a child's conversation does not move the terminal: commands, approvals, and the status line stay with the session you launched, and the transcript is the only thing that switches.
- Delete is unimplemented: the session store exposes no delete, and the surface does not reach around that seam into its files.
/forkcovers the case that needs it — it branches into a new session and leaves the original alone. - Approvals and questions render inline and take the keyboard; a question batch is answered in order.
- Styling uses the standard 16 ANSI colors and terminal defaults, so light and dark terminals follow their own theme.
- Tool text, model text, and file content are escaped before rendering, so a hostile result cannot inject terminal control sequences; the cost is that a literal tab shows as \x09.
License
MIT
Похожие плагины
archify (deepseek-harness)
tt-a1i/archify
WeKnora (dsh-weknora)
tencent/weknora
weknora
tencent/weknora
BrowserSkill (dsh-plugin-browserskill)
tencent/browserskill