Back to home@rbelem

dsh-tui

No description

Stars
0
Language
Rust
Created
Aug 14, 2026
Updated
Aug 18, 2026

Introduction

dsh-tui

A Rust terminal client for the deepseek-harness gateway (dsh) — a terminal surface at parity with its web UI. It attaches to the gateway over the wire protocol (RPC + host frames): browse workspaces and sessions, chat with the agent, cancel or retry turns, rename/fork/archive sessions, create sessions, and switch themes or UI locale.

The gateway lifecycle follows the herdr model: if nothing is listening on the resolved port at launch, dsh-tui starts dsh web itself in the background (it keeps running after the TUI exits) and stops it only via dsh-tui server stop.

Version 0.1.0. ~36 commits, 293 tests.

Install

npm (entry bundle + platform prebuilds)

The published packages:

PackageContents
@rbelem/dsh-tuientry bundle: cordis patch layer + runtime glue plugin that spawns the TUI with DSH_PORT
@rbelem/dsh-tui-linux-x64 / -linux-arm64prebuilt binary for linux x64 / arm64
@rbelem/dsh-tui-darwin-x64 / -darwin-arm64prebuilt binary for macOS x64 / arm64

The bundle pins the four platform packages as exact-version optionalDependencies; npm picks the matching one by its os/cpu fields. Install through the harness plugin mechanism:

dsh plugin --profile tui add @rbelem/dsh-tui

Boot (gateway + TUI) or attach-only:

dsh --profile tui              # boots the gateway (OS-assigned port) and spawns the TUI
dsh --profile tui --port 8080  # fixed port
dsh --profile tui --no-spawn   # gateway only; attach later with the binary + DSH_PORT
dsh-tui --port <port>          # attach to any running gateway

See bundle/README.md for the full bundle contract.

From source

Prerequisites: a Rust toolchain (rustup) and devbox (devbox.json pins rustup@latest).

git clone <repo> dsh-tui
cd dsh-tui
devbox run -- cargo build --release
# binary at target/release/dsh-tui

Gateway: auto-start by default

dsh-tui attaches to a dsh web gateway from the deepseek-harness repo. By default it boots the gateway itself when nothing is listening: the resolved port probes at launch, and a dead port spawns dsh web detached (stdout+stderr → $XDG_STATE_HOME/dsh-tui/gateway.log). The gateway persists after the TUI exits — stop it explicitly with dsh-tui server stop. The gateway boots without a provider key (browse/attach/list all work); submitting prompts requires a provider configured in the environment — without one, a run fails with a turn error surfaced in the UI (no crash).

The port resolves CLI > env > config > default 3080 (the dsh web profile's composed default):

dsh-tui --port 4000              # CLI wins
DSH_PORT=4000 dsh-tui            # env
# config.toml: [gateway] port = 4000
dsh-tui                          # default 3080, nothing to set

A manually started gateway works the same way — it is detected by the probe and attached to as-is:

# terminal 1: the gateway
dsh web --port 8765

# terminal 2: the TUI
dsh-tui --port 8765

To disable auto-start (keep the pure manual flow), set [gateway] auto_start = false in ~/.config/dsh-tui/config.toml — a dead port then errors with the "no gateway reachable" message.

Usage

With no sessions, a hero screen invites a new session. Once attached, the layout is:

  • Sidebar — workspace groups, an ungrouped group for sessions no workspace claims, and a collapsed archived (N) header at the foot (archived sessions are excluded from navigation in v1).
  • Chat panel — the active session's history: user messages, assistant responses (markdown, reasoning, images where the terminal supports them), tool activity, approvals, and queue items.
  • Composer — the prompt input at the bottom; Enter submits, Shift+Enter inserts a newline, / and @ open command/skill completion popups.

The TUI attaches to the most recently updated non-blank session and streams new events over the mux downlink.

Keymap

KeysAction
j/k, /scroll chat / move sidebar selection / move picker selection
g/Home, G/Endjump to top / bottom of the chat
Ctrl+d / Ctrl+uscroll half a page (chat)
Ctrl+dquit from the composer (EOF)
Entersubmit composer; switch to the selected sidebar session; apply picker selection
Tabcycle focus: chat → composer → sidebar
Ctrl+w then h/j/k/lmove focus between panes (sidebar / chat / composer)
Escreturn to chat (closes popups, pickers, editors)
nnew-session picker (chat or sidebar focus; j/k move, Enter create)
rrename the selected sidebar session (inline editor: type, Enter commit, Esc cancel)
ffork the selected sidebar session
aarchive the selected sidebar session
varm mouse selection mode in the chat (v select · esc cancel; drag to select, release to copy)
iopen the image viewer on the session's images (chat focus)
ttoggle the tool details line (started/duration/schema) of the tool row in view (chat focus)
stoggle the narrow-terminal session drawer (below 80 columns)
qquit (chat or sidebar focus)
Ctrl+plauncher: fuzzy search over commands, cached skills, and settings actions
Ctrl+ttheme picker (j/k move, Enter apply, Esc close)
Ctrl+,settings view (note: unreachable from a raw terminal byte stream — crossterm maps 0x0c to Ctrl+l; use the launcher's "open settings" action)
Ctrl+lcycle UI locale (en ↔ zh), persisted
Ctrl+ccancel the running turn; quit when idle
Ctrl+qquit
Alt+qqueue popup: j/k scroll, x remove, s steer, e edit, Esc close
Shift+Enterinsert a newline in the composer (see composer notes below)

Composer editing: arrows / Home / End move the caret, Backspace / Delete edit, Esc returns to the chat. Shift+Enter inserts a newline (web parity; requires a CSI-u / kitty-keyboard-protocol terminal — kitty, WezTerm, Alacritty ≥0.13, foot, Ghostty, Windows Terminal ≥1.19. On legacy terminals Shift+Enter arrives as plain Enter and submits instead — graceful degradation, nothing breaks).

Mouse

Mouse capture is on (click-to-select sessions, wheel scrolls 3 lines per tick, status indicators). In the chat, v arms selection mode:

  • v, then drag: select text; release copies it to the clipboard (OSC 52) and exits the mode; Esc cancels. The status line shows a copied · N chars flash on success.
  • double-click a word: selects the word (CJK runs stay whole); dragging after extends the selection from the word.
  • wheel while selecting scrolls the viewport — the selection stays anchored to the text underneath.
  • clicking a ▸ N skills header row expands or collapses the folded skill list in that message (a header click never starts a selection).

The chat's margins (the 2/2 padding) anchor at the clamped edge, so a drag always has a starting point. Below 80 columns, s opens the session drawer (full titles; Esc/click-outside closes); the affordance at the chat's top-left toggles it.

Terminal escape hatch: while mouse capture is active, holding Shift while dragging or wheeling bypasses the app's capture — the terminal's own selection and scrolling take over (standard xterm/kitty behavior; the app never sees those events). Use that when you want the terminal's native copy instead of dsh-tui's.

Configuration

  • Settings view — opened from the launcher (Ctrl+P → "open settings"). Driven by the gateway's settings.describe/settings.update; the live gateway exposes namespaces including ui-theme, locale, ui-conversation, and ui-onboarding, rendered as schema-driven forms.
  • Themes — 15 bundled themes (catppuccin ×4, kanagawa, tokyonight ×3, gruvbox, dracula, solarized, nord, rose-pine, everforest, one-dark); Ctrl+T opens the picker, Enter applies and persists. User themes load from ~/.config/dsh-tui/themes/*.toml. With no explicit theme, the default follows the detected terminal/system scheme — catppuccin frappe (dark) / catppuccin latte (light) on truecolor terminals, falling back to the terminal-following neutral look (truecolor when COLORTERM is set, else 256-color) when detection fails.
  • Locale — zh/en, keyed string tables; Ctrl+L cycles and persists; CJK width is handled.
  • Config file~/.config/dsh-tui/ (isolated from the host config in tests via XDG_CONFIG_HOME). A [keymap] section rebinds shortcuts by action name (see the table above for the defaults); key specs look like "ctrl+q", "shift+enter", "alt+q", "g". An absent or unparseable spec falls back to the built-in default; the config applies at startup.

Development

Toolchain: devbox (devbox.jsonrustup@latest).

devbox run -- cargo test        # 293 tests
devbox run -- cargo check --all-targets
devbox run -- cargo clippy --all-targets -- -D warnings
devbox run -- cargo fmt --check

Live-gateway smoke test

tests/live_smoke.rs runs the real binary in a PTY against a REAL dsh web gateway that the test starts itself (isolated DSH_HOME, port 18765 or a free port) and kills on teardown. Gated by an env var so the default suite stays green without external infra:

DSH_LIVE_SMOKE=1 devbox run -- cargo test --test live_smoke -- --nocapture --test-threads=1

The smoke is provider-adaptive: it probes session.models.routable — with a provider it asserts the prompt round-trip and streamed answer; without one it asserts the graceful turn-error surface. Attach, sidebar, nav, settings, theme, and catalog flows run either way.

Release

The release pipeline (scripts/release/, .github/workflows/release.yml):

  1. build.sh --target <triple> builds one target into dist/dsh-tui-<target>/ (binary + SHA256SUMS, byte-pinned) — darwin targets build on macOS runners.
  2. prebuild-packages.sh assembles the four prebuilds/<platform>/ packages from the dist artifacts.
  3. The v* tag workflow uploads each target's assets to the GitHub release under target-unique names: dsh-tui-<target> and dsh-tui-<target>.SHA256SUMS (verify with sha256sum -c after renaming the binary to the listed name dsh-tui).

npm publishing is manual (workflow_dispatch, .github/workflows/publish.yml), reusing the same artifacts — no rebuild:

bash scripts/release/publish.sh            # dry-run: pack + publish --dry-run for all 5
bash scripts/release/publish.sh --publish  # real publish (requires npm auth)

Platform packages publish first, the entry bundle last; the script checks npm whoami and refuses to publish unauthenticated, and fail-fasts with a report of which packages landed.

Parity

See PARITY.md for the feature-by-feature parity contract with the web UI (acceptance bars + testing hooks), and .scratch/dsh-tui/issues/ for the decision records.