A terminal client for the DeepSeek Harness.
Needs Bun, Node.js, pnpm and git.
git clone https://github.com/Cass67/deepseek-tui.git
cd deepseek-tui
node scripts/install.mjs
./bin/deepseek-tuiThe installer fetches and builds the harness next door, installs this app, and checks it works. Takes a few minutes the first time, mostly building. Re-run it any time.
It uses Cass67/deepseek-harness
because the TUI needs the L2 runtime methods — settings, skills, agent
presets, session controls, session/resume, and provider auth — that upstream
does not ship on its JSON-RPC surface. The fork's main tracks upstream
through dsh-v0.1.2-alpha.3 and carries the L2 surface on top; the merged
tree is the runtime this app boots.
The TUI itself is a thin client: it runs the harness as a subprocess over stdio
JSON-RPC and renders the session event stream. It launches the harness's own
dsh --profile sdk runtime (dsh-base + dsh-sdk-app) with this repository's
cordis.yml applied as a --patch overlay, so the app composes only its own
defaults and additions on the upstream runtime. The runtime's home is this
repository's .dsh-home/ (gitignored), keeping profiles, credentials, and
skill state out of the shared ~/.dsh. Built with Bun, React and
OpenTUI.
./bin/deepseek-tuiThe launcher preserves its caller's working directory as the agent workspace, and refuses to start with a pointer to the installer if the harness is missing or unbuilt.
| Variable | Effect |
|---|---|
DSH_PROVIDER / DSH_MODEL |
Pin the route. Overrides the remembered one. |
DSH_MAX_TOKENS |
Output cap per turn. Defaults to 16384; some routes allow far more. |
DSH_CWD |
Agent workspace. Defaults to the launcher's cwd. |
DSH_RUNTIME_NODE |
Node binary that boots the harness runtime. Defaults to the caller's Node; when the |
| TUI runs under Bun the runtime is launched with a real Node instead. | |
LOCAL_LLM_BASE_URL |
Endpoint for the local-llm-router route. Defaults to http://localhost:3200/v1. |
CONTEXT7_API_KEY |
Raises context7 rate limits. Optional. |
Without DSH_PROVIDER/DSH_MODEL, a new session resumes the last route you
selected, persisted in the agent-default-model settings namespace.
A host name local to one network belongs in your own config, not in this repo.
Set LOCAL_LLM_BASE_URL, or override the route per machine in
${XDG_CONFIG_HOME:-~/.config}/deepseek-tui/providers.yaml — settings there win
over cordis.yml:
llm-pi-ai:
providers:
local-llm-router:
baseURL: http://my-box:3200/v1Every configurable knob, which file owns it, and a complete annotated
providers.yaml are in docs/configuration.md.
Ctrl+P and /provider expose 38 routes (37 installed catalog routes plus the configured local router), including ordinary OpenAI API access and OpenAI Codex subscription access as separate routes. Routes inherit the provider runtime's native endpoint, model catalog, modality metadata, reasoning levels, environment credential discovery, API-key setup, and OAuth/subscription login methods.
Selecting a provider opens its action page. Choose API-key setup to enter provider credentials in a masked field, or choose a provider subscription method to open its validated HTTP(S) authorization/device link and complete the callback or manual challenge. OpenAI uses an ordinary OPENAI_API_KEY; OpenAI Codex uses ChatGPT Plus/Pro OAuth. Credentials are stored in ${DSH_HOME:-~/.dsh}/.credentials.yaml with an owner-private directory/file — the store is refused outright if group or other permission bits are set — and never enter chat text, session history, or clipboard output. Disconnect removes the stored provider credential. Environment credentials shown in .env.example remain supported and are never copied into the store.
Like OpenCode, catalog configuration stays separate from credentials. Custom providers and overrides live in ${XDG_CONFIG_HOME:-~/.config}/deepseek-tui/providers.yaml and hot-reload through Harness settings:
llm-pi-ai:
providers:
my-gateway:
displayName: My Gateway
# Omit apiKeyEnv to enable masked API-key entry in provider actions.
# Set apiKeyEnv: MY_GATEWAY_API_KEY instead for environment-only auth.
api: openai-completions
baseURL: https://gateway.example/v1
models:
- id: my-model
name: My Model
contextWindow: 131072
maxTokens: 16384
input: [text, image]A catalog route can be overridden with only changed fields; omit models to retain its complete installed model catalog.
| Command | Action |
|---|---|
/model [model-id] |
Select an exact current-provider model, or open authenticated models when omitted. |
/models |
Fuzzy-search models across every authenticated provider. |
/provider [provider-id] |
Open provider actions for authentication, disconnect, and model selection. |
/reasoning [level] |
Pick an adapter-declared reasoning level for current model. |
/new |
Close the current live agent and start a blank session on the selected route. |
/sessions |
Browse durable sessions by title/time and explicitly resume a saved session. |
/attach <path> |
Validate, durably save, and queue a PNG, JPEG, WebP, or GIF for the next prompt. |
/unattach |
Clear queued images without sending them. |
/clear |
Clear displayed messages without changing model context or durable session history. |
/compact |
Run Harness manual compaction outside the model-message stream. |
/cancel |
Request cancellation of the active turn; ready state returns when Harness reports idle. |
/status |
Show session id, route, lifecycle state, token totals, and theme. |
/search <query> |
Search displayed transcript text case-insensitively. |
/copy [last|all] |
Copy latest user/assistant message or full conversation transcript, including current streamed text. |
/shell <command> |
Run one owned, bounded local shell process group outside model history. |
/theme [name] |
Open the 23-theme picker or select and remember an exact theme id. |
/thinking [on|off] |
Show or hide the model's reasoning above each answer; remembered across restarts. Off by default. |
/permission |
Switch the sandbox + approval preset (runtime-discovered from the harness). |
/help |
Show local and runtime-discovered commands. |
/quit |
Shut down the Harness runtime and exit. |
Unknown slash commands are offered to the Harness command registry and never sent to the model as ordinary messages. Live status uses an animated activity label for model waits, reasoning, tool execution, and context compaction; elapsed seconds remain visible during silent operations. Automatic compaction uses qwen3.6-flash while conversation turns retain the selected route, reducing checkpoint latency without changing chat-model selection. Reasoning stays out of final-answer Markdown — /thinking on renders it as a dimmed block above the answer instead — completed tool output is collapsed, and fenced Go, Python, Bash, Rust, JavaScript, and TypeScript blocks use Tree-sitter highlighting. A leading !command is shorthand for /shell command; only one shell command runs at once. Shell commands run in the caller workspace with a 120-second timeout and bounded output, never enter model-visible history, and own a detached process group cleaned up with TERM then KILL on cancellation or shutdown. Clipboard notices distinguish verified host writes from unverified terminal OSC52 attempts.
Built-in themes: Tokyo Night, Dracula, Nord, Catppuccin Mocha/Latte, Gruvbox Dark/Light, Solarized Dark/Light, One Dark, Monokai, Rosé Pine, Everforest, Kanagawa, Ayu Dark, GitHub Dark/Light, Ocean, Synthwave, Matrix, Sepia, Minimal Light, and High Contrast.
Enter: send; during an active model turn, queue follow-up promptShift+Enter: newlineTab: complete a slash command- Double-click a composer word: select it and copy it to host clipboard
Cmd+A/Cmd+C/Cmd+V: composer shortcuts when terminal forwards Command keysCtrl+Shift+A,Ctrl+Y,Ctrl+V: terminal-forwarded select-all/copy/pasteCtrl+Shift+C/Ctrl+Shift+VorCtrl+Insert/Shift+Insert: additional terminal fallbacks- Ordinary terminal bracketed paste remains supported
Esc: reject/cancel a pending interaction; otherwise cancel active shell, close overlay, or cancel active model turnCtrl+F: transcript search entryCtrl+R: durable session pickerCtrl+L: fuzzy model picker across authenticated providersCtrl+P: provider pickerCtrl+T: reasoning-level picker for current modelCtrl+S: settings overlay (model, reasoning, theme, session, shortcuts)Ctrl+K: skill pickerCtrl+A: agent-preset pickerCtrl+D: working-directory pickerCtrl+E: trajectory (session event timeline)Ctrl+O: deliverables (files written, commands run)Ctrl+C: cancel active shell or model turn; otherwise quit
Persisted sessions remain under <workspace>/.sessions. /sessions reads them through the Harness session-query API, reconstructs visible history from validated events, and resumes only an explicitly selected saved session; the TUI never parses JSONL files.
Harness approval and question requests preempt ordinary overlays. Approval defaults to rejection, Esc rejects or cancels, and question batches accept option labels or numbers, comma-separated multi-select values, and custom text. Shutdown rejects or cancels pending requests before closing the runtime. The default local bash executor does not request approval for ordinary commands; the approval modal activates when a composed Harness consumer raises an approval request.
/attach accepts only regular files contained in the caller workspace. Dragging one PNG, JPEG, WebP, or GIF into the composer recognizes the terminal-pasted absolute path and explicitly admits that external image. Both paths reject symlinks, query deployment image limits, then read at most maxImageBytes + 1 through one file handle. Only a basename and durable attachment reference enter UI/model history; local paths and base64 never do. The selected catalog model must advertise image input. Attachment admission and prompt admission are serialized. Enter during an active turn snapshots queued images into that follow-up, clears them from the composer, and dispatches queued prompts FIFO after each turn becomes idle. Composer input remains enabled during model work without resetting the active stream. The runtime verifies declared media type and image bytes through its durable attachment service.
cordis.yml plus the spine expose 47 model-facing tools — 21 of the 24 tool
packages in the harness's generated docs/tool-catalog.md, plus MCP.
| Area | Tools |
|---|---|
| Files | read, write, edit, read_image, str_replace_editor, glob, grep |
| Shell | bash (sandbox-confined; background runs become jobs) |
| Terminal | terminal_open, terminal_read, terminal_send, terminal_close, terminal_list, terminal_signal |
| Jobs | job_list, job_output, job_kill |
| Delegation | subagent, subagent_fork, claude_code, codex, send_message, interrupt_agent, list_agents, report |
| Planning | exit_plan_mode, create_goal, get_goal, update_goal, todo_write |
| Workflow | workflow, ralph |
| Web | web_search, web_fetch |
| Sessions | session_search, session_trace, session_event_read, session_event_search, session_event_trace |
| Schedule | schedule_create, schedule_list, schedule_delete |
| Code | lsp, run_code |
| Skills | skill |
| Ask | ask_user_question |
| MCP | mcp__context7__resolve-library-id, mcp__context7__query-docs |
Sandbox. dsh-fs-sandbox and dsh-bash-sandbox replace the plain local
backends, so writes and commands are confined by ctx.sandboxPolicy — seatbelt
on macOS, bwrap/landlock on Linux. Default mode is workspace-write;
/permission switches presets. A write outside the workspace comes back
[sandbox: file access denied under workspace-write mode].
Delegation. subagent/subagent_fork run in-process. claude_code and
codex shell out to the real product CLIs in the same workspace, so a child
can be a genuinely different agent. Both are one-shot.
MCP. One row per server in cordis.yml; context7 ships wired up. MCP tools
register asynchronously after the server connects, so they appear shortly after
boot rather than at startup.
Skills. Read from .dsh/skills/<name>/SKILL.md, with name and
description frontmatter required. This repo ships mount-harness-plugin.
Hooks. Claude-Code format from <workspace>/.claude/settings.json, Codex
format from $CODEX_HOME/config.toml. A missing file registers no handlers. A
PreToolUse hook can deny a call outright — see
docs/configuration.md.
Ctrl+A. Named by the kind of work, not the model — each preset's composition
picks its own route, so retuning is a two-line edit.
| Preset | For | Route |
|---|---|---|
build |
The main worker. Plans, edits, runs things, sees a change through. | qwen-token-plan |
research |
Light and fast. Reads, searches, looks up docs. | local-llm-router |
verify |
Checks work already done. Routed independently of build. |
qwen-token-plan |
build and verify sit on a catalog route, so they work on a fresh clone.
research is the local-router example and needs a server at
LOCAL_LLM_BASE_URL; repoint it in .dsh/presets/research/agent.cordis.yml if
you do not run one.
They live in .dsh/presets/<id>/: agent.cordis.yml is the per-session
composition, preset.yml the display name and description, and the directory
name is the id. Selecting one writes the agent-presets settings namespace —
there is no /preset command.
node scripts/install.mjs --check # is the harness present, correct and built?
node scripts/preflight.mjs # does the harness answer what this app callsPoint them elsewhere with DSH_HARNESS_PATH (default ../deepseek-harness),
DSH_HARNESS_REPO or DSH_HARNESS_REF (default main; the tag pin was
retired because the TUI now boots the merged fork main).
Against upstream harness the app starts and then fails as soon as anything reads settings — the settings overlay, both pickers, and last-model-restore. That is the symptom of the wrong harness, not a bug here.
bunx tsc --noEmit # types
bun test # unit tests
bun run build # bundle to dist/
bunx eslint srcBecause most behaviour lives in a subprocess, several checks drive the real harness instead of mocking it:
| Script | Checks |
|---|---|
scripts/install.mjs --check |
The harness checkout exists, is on the right revision, and is built |
scripts/preflight.mjs |
The linked harness has the L2 methods this client calls |
scripts/boot-test.mjs |
The composition loads and reports its commands |
scripts/l2-probe.mjs |
Which L2 methods the harness answers |
scripts/tool-audit.mjs |
Which tools actually register, by reading the schemas sent to the model |
scripts/tool-exec-check.mjs |
Tools genuinely execute, including a sandbox refusal that must fail |
scripts/settings-roundtrip.mjs |
settings/get → set → read-back → restore |
scripts/route-persistence-check.mjs |
The remembered route survives a process restart |
tool-audit and tool-exec-check stand up a stub OpenAI-compatible endpoint,
so they need no live model. The endpoint must stream: the harness always sends
stream: true, and a plain JSON body is silently discarded and retried.
Mounting a plugin does not mean its tools register — several need a provider or
a config flag first. Measure with tool-audit rather than reading cordis.yml.
Everything else the harness ships is deliberately absent:
dsh-subagent-dsh-sdk,dsh-subagent-acp— need a second runtime or an ACP agent to talk to.dsh-web-search-exa,dsh-web-search-perplexity— need API keys.dsh-tool-pwsh,dsh-pwsh-*,dsh-sandbox-windows-acl— Windows only.dsh-tool-cordis— ships in no tree by design; dynamic code reaches the real runtime.dsh-tool-bash-persistent— superseded by the terminal tools, and would collide withbash.dsh-fs-local,dsh-bash-local— replaced by their sandbox equivalents.dsh-hook-protocol,dsh-jobs,dsh-shell,dsh-code-runtime,dsh-sandbox— types-only or abstract bases; mounting them fails to load.
Apache-2.0. The DeepSeek Harness packages it links against are MIT and retain their own terms — see NOTICE.