@sagmans/dsh-tui
v0.8.0
Published
Terminal (CLI TUI) surface for DeepSeek Harness: interactive dsh in a terminal, no browser required
Maintainers
Readme
@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, renders every message 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, nudges an agent whose plan has aged without an update, parks and restores prompt drafts per session, 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. Interactive sessions 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 tuiThe 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 tuiReleases are published, so this path works today. A checkout stays the path for unreleased work.
Install the dogfood skill (optional)
Run this explicit command after you add the plugin:
dsh --profile tui install-skillsThe command copies dsh-tui-dogfood to
~/.agents/skills/dsh-tui-dogfood/. Agents can then load it from any dsh
plugin repository. The skill uses a cloned dsh home, so tests do not change
your real profile. A first install needs no TTY. npm install does not copy
skills into your home.
If the skill exists, the command asks Update existing skill? [y/N] in a
terminal. Only y or yes replaces it. Enter or n keeps the existing
copy. To update without a prompt, including in non-interactive runs, use:
dsh --profile tui install-skills --updateUpdating removes any local changes inside the previous skill directory. The command stages the new copy before replacement and restores the old copy if replacement fails. If cleanup fails after replacement, the new copy stays installed and the command reports the old backup path.
A started session compares every installed copy with the skills this build ships and prints one line when they differ, because an agent loading a stale skill is told the old wiring:
bundled skills changed since they were installed: dsh-tui-update-models · run dsh --profile tui install-skills --updateThe comparison reads content only, over the files this package ships: a
helper's execute bit is the installer's own doing, and a note kept beside a
skill stays unremarked. A skill that was never installed is not drift — a first
install is the prompt above — and a run that never opens the screen, like
list-models, prints nothing.
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-tuiThen 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 sessionUpdate
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 buildA registry install updates with dsh plugin --profile tui update @sagmans/dsh-tui.
Remove
dsh plugin --profile tui remove @sagmans/dsh-tuiThe 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 checkTroubleshooting
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.
npm reports overriding peer dependency while a harness tree resolves, and the lines are not failures. The harness floats its own prereleases, so its dependencies hold overlapping ranges over the same packages and npm names each range it overrides; the install still exits 0. A plugin profile does not have that shape — dsh plugin add resolves one copy of every package this bundle mounts. The plugin's own peers are optional so that npm keeps the host's copy instead of nesting a private one.
| 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 |
| Node warnings, such as ExperimentalWarning: stripTypeScriptTypes …, appear after exit | the TUI holds runtime warnings until it returns the terminal to your shell; startup warnings remain visible before the TUI starts | read the warnings in your shell after exit; no warning-suppression flag is needed |
| 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-tuiUsage
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 minimal # start in a shipped mode other than the default (PTC)
dsh --profile tui --model deepseek-chat
dsh --profile tui --no-color
dsh --profile tui --no-bell # do not ring when a long turn finishes
dsh --profile tui list-models # print every provider/model the picker can reachlist-models prints one line per route the /model picker can reach —
provider/model, a tab, then the model's display name — in picker order:
providers as the llm service registered them, then each provider's own model
order. It writes plain stdout, so it works piped or redirected, never opens the
alternate screen, and exits 0 after printing at least one route. When the
profile has no llm service, when the provider listing cannot be read, or when
nothing is configured to advertise a model, it names the reason on stderr and
exits 1.
Every key below is a shipped default. /keys opens every action the surface
and its library can perform as a list you filter as you type, with the keys in
force, and the keys: section moves any of them — see Keys.
| Key | Action |
|---|---|
| Enter / Shift+Enter | break the line: a prompt is written before it is sent |
| Ctrl+Enter / Alt+Enter / Ctrl+S | submit the prompt |
| Ctrl+C | take back one thing at a time: the draft in the bar, the prompts waiting in the agent's inbox, the running turn, or a child's conversation; with a picker, an approval, a question, or the transcript search open it closes that instead. With nothing left to cancel it does nothing — it never leaves |
| Ctrl+D | leave and print the resume command, when the bar holds no text and nothing is open; a running turn is cancelled first |
| Ctrl+O | open every tool card: its header plus every retained row. Folded, a card is one line, and a shell card keeps its command plus the last 20 rows of output with a hint naming what it dropped |
| Ctrl+Y | show or hide the calls a PTC program dispatched: one two-space-indented row per call under its run_code card, named and argued from the tool's own header and cut at the screen edge; clicking the card's own header does the same for that card alone, and one level only — the program's own rows stay under Ctrl+O; clicking one call's row opens that call in full — the change it declared, then what it produced — for every tool, a failed call included; hidden by default |
| Shift+Tab | expand or fold every thought behind the answers: folded, the row names itself, its token count, and the key; opened, it adds the thought, laid out as markdown; a click decides for one thought instead |
| Ctrl+T | pick the reasoning effort for the next step |
| Ctrl+R | reverse-search recorded prompts: the list opens filtered by whatever is in the bar, enter puts one back, esc keeps the draft |
| Ctrl+X then S | stash the current draft |
| Ctrl+X then L | open this session's stashed drafts |
| Ctrl+X then M | open the model picker |
| Ctrl+X then Y | copy the last answer to the clipboard |
| Ctrl+X then E | edit the draft in $VISUAL (or $EDITOR) and take back what it saves |
| Ctrl+X then ? | search the key map: every action and the keys in force, in a box over the transcript |
| Ctrl+X then U | undo the last prompt: hide its turn, land on the previous answer, and put the prompt back in the bar |
| Ctrl+X then R | redo the undone prompt |
| y / n / Esc / Ctrl+C | allow once, reject, or cancel a pending approval |
| digits / space / ↑↓ / Enter / Esc / Ctrl+C | answer a question: pick or toggle, confirm, or skip one with Esc; Ctrl+C abandons the whole batch with no answers, like an aborted call; 0 answers with your own text in the input bar |
| ↑↓ / Ctrl+P / Ctrl+N | move through the open list: a picker's rows, a question's options, or the completion menu above the bar |
| typing in any picker or question | narrow the rows by fragment (glm53 finds GLM-5.3); backspace widens, esc or Ctrl+C leaves |
| / then Tab | complete commands, including every command this session registered |
| @ | open the workspace file menu, narrowed as you type; a path then Tab still completes a file reference |
| ctrl+shift+f | search the transcript (enter next, shift+enter previous, esc or Ctrl+C 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 (the status line names the key you have now) |
| 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 | open the picker for the configured providers and their models; it heads itself with the route the next step will use, and typing filters it by fragment (glm53 finds GLM-5.3) |
| /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) |
| /model <provider>/<model>/<effort> | use that route and reasoning effort (the effort must be one the route advertises) |
| /preset | pick the agent mode for this session from the roster |
| /preset <id> | switch to that mode, while the session is still blank |
| /new [title] | start a fresh session without leaving the terminal (ctrl+x then n starts one untitled) |
| /reload | compose this session's agent again and replay its transcript, so an edited preset or skill file reaches the session; a running turn or a queued prompt is refused with ctrl+c as the way forward |
| /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 |
| /undo | hide the newest prompt's turn and put that prompt back in the bar (ctrl+x then u) |
| /redo | step forward again after an undo (ctrl+x then r) |
| /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 |
| /history | show how many prompts are recorded and where the file is |
| /history clear | forget every recorded prompt, reporting how many went |
| /theme | open the theme picker: type to filter, the screen paints the row under the cursor |
| /theme <name> | apply a theme by name and write the choice to the settings document |
| /theme tokens | list every styled element and the value in force |
| /theme export <built-in> | copy a built-in into your own themes directory to edit |
| /keys | open the key map as a list you filter as you type; /keys <layer> opens it already narrowed to one layer (see Keys) |
| /stash <draft> | park the text given after the command (ctrl+x then s parks the editor) |
| /stash-pop [index\|id] | put a stashed draft into the editor and remove it (newest by default) |
| /stash-apply [index\|id] | put a stashed draft into the editor and keep it |
| /stash-list | pick from this session's stashed drafts; enter pops the marked one |
| /stash-drop [index\|id] | delete a stashed draft without using it |
| /stash-clear | delete every draft stashed in this session, after a confirmation |
| /quit | leave and print the resume command |
Typing @ opens this workspace's files above the editor, ranked as the fragment is typed the way a fuzzy finder ranks a path list: @edtr reaches src/ui/editor.ts without spelling the separators, a directory offers itself with a trailing slash so typing continues into it, and a path holding a space is quoted. A directory whose own name holds a space offers no row, because the menu stops following the token once one is in it; the files under it are still listed, each quoted whole. The rows are what git tracks or would add, with ignored paths left out, so a suggestion never names build output or a secret the repository deliberately ignores; a tree git does not own is walked instead, skipping node_modules, .git, and the rest of the build litter. A path typed from the working directory still completes on Tab as before.
Ctrl+X starts a chord. For the next two seconds the footer leads with the
prefix alone — enough to say that a key is waiting, without reciting the map —
and a key that finishes nothing is typed as usual rather than swallowed, so a
prefix pressed by accident costs nothing; /help lists the chords, m for the
model picker, p for plan mode, n for a fresh session, y for the last
answer, u to undo the last prompt, r to redo it, s to stash the draft,
l for the stashes, e for the draft in the reader's own editor, and ? for
the key map.
keys.chord.prefix: alt+x starts the chord with another key — or with a list of
them, as so many ways in — and prefixWindow: 0 waits for the next key instead
of lapsing; every second key is a row of its own (chord.model, chord.plan,
chord.new, chord.copy, chord.stash, chord.stashes, chord.editor,
chord.keys, chord.undo, chord.redo), so a chord can be respelled whole. A prefix that is not a modifier
chord, that the surface or the prompt bar already answers (ctrl+c, ctrl+s),
or that the terminal keeps (ctrl+q) is refused with the reason, and the
shipped keymap stays in force. The chords themselves are the commands they stand
for: m, p, n, y, s, l, u, r, and ? ask the same dispatcher
/model, /plan, /new, /copy, /stash, /stash-list, /undo, /redo,
and /keys do; e is the
one chord with no command behind it, because it opens a program rather than
running a line. Plan mode is the one pair that cannot share a name: /plan only
enters, so the chord names /plan off instead when the agent is in plan mode —
or is waiting for the turn boundary to become so — and reads that state from the
plan package rather than from the dock.
An approval or a question draws inline above the editor and takes the keyboard. A question that lists options always adds row 0. other — type your own answer: type or paste an answer the model did not offer, and the seam receives it as that question's free text — replacing a single-select choice, or supplementing a multi-select one. 0, or ↓ past the last option, reaches the row; ↑ walks back to the list with the text kept, and esc does the same from that row, because a question skipped by accident is a question answered twice — an escape from the list skips it. Free text is written in the prompt bar's own editor, drawn under that row: movement, word and line deletion, undo, completion, and multi-line paste are all the editor the reader already uses, and the prompt bar steps aside while a question is open, so a prompt written but not sent comes back untouched once the question is answered. No question hides its answer — the reader is the one who has to check what they are about to send. Every gate row wraps at the screen edge under its own label, so a long option or question is readable rather than cut.
While a turn runs, a prompt submitted into the editor waits in the agent's own inbox instead of disappearing: it is drawn above the editor in the input bar's own frame, faint and italic, and moves into the transcript when the agent takes it — where it keeps that frame in the prompt's own mint shade, so what the reader typed is never mistaken for what the agent said. Its markdown lays out inside that frame, so a list or a fence reads in the same box it was typed into. editor.queued and editor.queued.more restyle or hide the waiting rows; transcript.user restyles the submitted prompt. A reply is drawn in a frame of its own, so one exchange reads as two objects rather than as a box followed by a stream of rows: transcript.assistant.border restyles that frame, and hiding it draws the reply bare. ctrl+c takes them back: an interrupt drops whatever the agent has not started, so the waiting prompts are read first and put into the bar before the turn is stopped.
Copying is read back through what the surface drew rather than through the screen, so a selection is the words alone: dragging across a message takes its box with it on screen, and the surface takes its own frame back out before the text reaches the clipboard — the sides, the padding beside them, and the rules above and below. A selection that covers a whole message, two of them, or a part of one is read the same way, and a row the transcript did not draw — the editor's own bar, a picker's card — is copied exactly as it read, and a selection that was nothing but frame is handed back as the reader made it, because a copy is never emptied. The frame still comes back in a copy taken with the terminal's own selection — Shift held while dragging, or a terminal that keeps selection to itself — which is the one path no program can filter.
When prompt history is enabled, each submitted line is kept in a global history at
$DSH_HOME/prompt-history.json. Typing the start of a prompt that was sent
before draws the rest of the newest match after the cursor in a faint shade.
Ctrl+E takes the whole suggestion and the word-movement key takes the next
word, and both keys fall back to their old meaning the moment nothing is
offered. The history is deliberately global — the same prompt is useful in
every checkout — so nothing records a directory. An exact repeat moves to the
front instead of being stored twice. history.ghost: false keeps reverse
search but stops drawing the suggestion, history.enabled: false stops
recording and offering, and history.maxEntries bounds the file. A file this
build cannot parse is left untouched and writes are refused, so a newer format
is never overwritten; /history names it and the count, and /history clear
forgets everything.
Any other /command goes to the command registry, so /plan, /compact, /goal, and /feedback behave as they do on the other surfaces.
Prompt stash
ctrl+x then s parks the draft the editor is holding and clears it;
/stash <draft> parks a draft typed on the command line. A bare /stash only
says so, because submitting a command consumes the line it was typed on and there
is nothing left of the draft to park. /stash-pop puts a parked draft back and
removes it, so a prompt written for the wrong moment survives a restart instead
of being retyped or sent; l opens the list of them. A stash belongs to the
session it was parked in, so a second terminal in the same checkout never sees
these drafts while resuming the session does; the footer shows stash N while
any are waiting, ranked above the context and cache numbers it shares a row with.
A selector is the number the list shows in brackets — 0 is the newest — or the
entry's own id; leaving it out takes the newest. apply and pop refuse to
overwrite a draft already in the editor, because losing an unsent prompt to a
restore is the one outcome the feature exists to prevent. They refuse while a
question is borrowing the bar for the same reason: a draft written into an answer
would be sent as one. pop writes the editor first and removes the entry second,
so a crash between the two leaves the draft in the bank rather than only in a
terminal that is gone.
Nothing is cleared until the write has landed. A refusal — no room left, a bank
past its cap, another writer holding the lock — leaves the draft in the bar,
including a draft typed after /stash, which is written back into the bar before
the write is attempted. The bar is only cleared while it still holds that same
draft and no question has borrowed it, so an answer typed during the write is
never wiped by a stash finishing.
The bank is one JSON file per session under $DSH_HOME/tui-stash, written with
owner-only permissions (0700 directory, 0600 file) through a no-follow open,
and every directory the path passes through must be owned by the reader (or by
root) and not writable by anyone else — the sticky bit is the only exception,
since it keeps renaming to an entry's owner. Links are walked one hop at a time,
with .. left for the filesystem to resolve against what the link points at, and
a link this user does not own ends the walk: a chain that jumps through a shared
directory is refused at the directory it jumped through. A directory
that another user or a group member could redirect the storage through is refused
rather than trusted, which is also why a group-writable home directory fails the
stash with the offending path named. Every update is a locked read-modify-write
and an atomic temp-and-rename, so two surfaces using one bank cannot lose each
other's entries; reclaiming a lock whose owner is gone is serialized on a
per-bank claim file, and the removal only applies to the lock it judged, so a
holder that released in between cannot have its successor's live lock deleted. A
contender never deletes a lock it did not publish, so losing the name to a
successor costs a retry rather than the successor's turn.
A bank whose session id is not this one, or whose bytes do not parse, is
moved aside as <name>.corrupt-<time> and reported with its path — including when
the directory holding the copy could not be synced. A bank written by a newer
format, or one past the size cap, is refused in place rather than moved, because
neither is corruption. A storage directory a save had to create is flushed
through the directory that names it before the save reports anything. If that
flush fails, the empty directories are taken back so the retry starts clean; a
directory another surface has already saved into is left exactly as it is, because
an entry left unflushed costs durability while a removed bank costs the draft. Drafts are never written to a session log, and control and
Unicode bidi controls are stripped when a draft is stored and again when it is
read, so a hand-edited bank cannot park a terminal escape or a reordering trick in
the bar.
External editor
ctrl+x then e hands the draft to the editor the environment already names:
$VISUAL first, then $EDITOR, split on whitespace with quotes grouping and
nothing else special — no shell, no backslash escapes — so code --wait works
and a quoted path stays one argument. This surface cannot
draw an editor inside its own screen, so it gives the terminal up — the
alternate screen leaves, the child runs on the same tty — and takes it back
when the child exits; the frame is repainted whole, and the bar holds whatever
was saved. Nothing is submitted: a draft written for later survives a detour
through a full editor.
No shell is involved: the configured line is split here and the program is
spawned directly, because an environment value is data and a typo in it must not
become a command. The scratch file is a fresh directory per handoff, mode 0700
with a 0600 file, removed when the editor leaves; the text read back is read
through one handle that follows no link and accepts only a plain file, so a draft
swapped for a link, a fifo, or a device is refused rather than followed, and it
is stripped of control and bidi characters, because it is going into a live
editor rather than being drawn as text. A child that exits non-zero is not a failure —
an editor that refused to save has already said so, and what it did save is what
the reader meant to keep. Nothing configured, a program that could not start, a
save that cannot be read, and a draft past 1 MiB are notices that leave the bar
as it was; the oversized draft is left on disk with its path, because a refusal
must not also be a way to lose the work.
Settings
Every styled element is a named token with a shipped default, and every key is
an action with one, so the surface can be restyled and rebound without touching
code. Released hosts with section APIs store preferences in
$DSH_HOME/settings.yaml, under a dsh-tui: section:
dsh-tui:
theme: deepseek-blue # restyle the whole surface by name (default: violet-orbit)
subcalls: inline # draw the calls a PTC program dispatched (default collapsed)
mermaid: streaming # draw a reply's mermaid fences: off, final, or streaming (default streaming)
tools:
default: { collapsed: true, output: hidden } # how every tool's card starts
bash: { output: tail, tail: 5 } # keep the last five output rows behind bash's fold
read: { collapsed: false } # start reads open
prefixWindow: 2 # seconds a chord waits for its second key; 0 waits for the next key instead
keys:
chord.prefix: ctrl+x # the key that starts a chord; "prefix:" is the older spelling of this row
chord.keys: '?' # quoted: a bare ? is a YAML indicator, not a key
prompt.submit: [ctrl+enter, alt+enter, ctrl+s]
surface.effort: ctrl+t # one key, or a list of them
tui.editor.yank: ctrl+y # any action the library draws, by the id /keys prints
history:
enabled: true # record prompts and offer them back (default true)
ghost: true # draw the dimmed completion; reverse search stays either way (default true)
maxEntries: 2000 # prompts kept, newest first (1-20000, default 2000)
palette:
muted: '#5c5c5c' # one shade quiets every receding element
tokens:
transcript.notice:
fg: '#7a7a7a'
italic: true
tool.title:
fg: accent # a palette name, a hex value, or an index 0-255
bold: true
dock.jobs.heading:
hidden: true # the element renders nothing at allConfig-backed source hosts store the same preference fields in the TUI entry's
config in the active profile patch. Use the actual entry ID, normally tui,
not the legacy dsh-tui namespace. For example:
- id: tui
config:
theme: violet-orbit
history:
enabled: false
ghost: falseThe plugin exports a Config schema for all preferences shown above, including
prefix, prefixWindow, and keys. Launch fields such as sessionId,
model, and provider remain separate from live preference edits.
The settings service owns persistence. TUI does not create another preference store.
It uses released installSection or register APIs, or Config-backed
describe and revision-checked update APIs. Unsupported or read-only writes
show a notice instead of reporting success. A rejected theme selection restores
the applied theme, and /theme tokens reports that same appearance.
On Config-backed hosts, absent history.enabled and history.ghost stay off.
Set each switch to true explicitly to enable it. This prevents recording while
the host's asynchronous legacy import is pending or has failed. Released section
hosts retain their existing defaults. If the host reports unreadable preferences,
history stays off. Readable false switches survive errors in other fields.
Malformed updates retain the last valid appearance and do not overwrite their source.
When public descriptors expose raw user layers, history checks those layers at use time. A readable opt-out survives rejected siblings even without a change event. Another rejected opt-in cannot clear this protection; a valid committed update can. A released-provider limit remains: after an absent user section, rejected scalar sections can produce identical public descriptors. TUI cannot detect that transition without host validity metadata, so normal released defaults remain active.
The inspected source host has no legacy import alias from dsh-tui to tui.
Exporting Config does not resolve that namespace mismatch. The host can rename
settings.yaml to settings.yaml.imported before import completes.
TUI does not retry or restore that file automatically. Preserve current privacy
opt-outs and copy only the intended legacy fields into the actual TUI entry's
config. Keep the original file for rollback.
Live Config edits require the loaded schema runtime to create native volatile references. The released schemastery 3.18.2 implementation used by this checkout's unit tests lacks that API. A source host can resolve a different implementation with the same version label. TUI checks the loaded implementation and actual references, not the package version or CLI identity.
If the loaded schema runtime lacks native support, TUI exports ordinary fields rather than unsupported live metadata. TUI then rejects Config-backed writes, and the host cannot import preferences through its volatile-field settings API. Reading row preferences still works, and ordinary profile changes use the host's reload lifecycle. Native schema-backed hosts can apply live preference updates.
Every field is optional, so a section that changes one shade is enough. The
document is hot-reloaded: an edit restyles a running session and re-arms the
keymap on the next press, and /theme shows each element's effective value and
whether it came from an override, the palette, or the default.
The section is not only shades. By default a PTC program's card arrives alone,
and subcalls: inline draws one row per call it dispatched under that card
instead — or one click on the card's own header, for that card alone.
Ctrl+Y toggles the same choice for the current session, and an edit to the
document re-seeds it. An unknown key or value is refused with a notice naming
it, so a typo cannot quietly do nothing.
The tools block decides how each tool's cards draw. collapsed starts a
tool folded to one header row (default true), and output is hidden
(default) or tail, where tail is how many output rows a folded card keeps
(default 20). A folded row ends a few columns short of the screen edge and
the argument is what gives up that room: a wide terminal shows more of the call,
a narrow one still shows the tool, how it ended, and how much waits behind the
fold. The reserved default row applies to every tool without its own, and a
tool name nothing declares is inert: the surface cannot know which tools a
profile mounts. Clicking a card opens or folds that one message, and a dispatched
call's row opens on the same click to what the tool itself drew for it — an
edit's diff in the diff colours, a read's numbered lines, a command and its
output — followed by the outcome it produced; a call that failed opens to the
reason it reported instead of rows for work that never happened. A program's
card opens one level at a time: its own row draws the calls, and the rows below
them — what the program returned — are the card's, so a click on each answers
for what it drew. Ctrl+O still decides for every message nobody clicked, and
Ctrl+Y for every program whose own header nobody clicked.
The history block tunes the prompt history. enabled: false stops recording
and offering it; ghost: false keeps reverse search but stops the dimmed
completion; maxEntries bounds the file, and an exact repeat moves to the
front rather than being stored twice. editor.ghost styles the suggestion, and
NO_COLOR or --no-color suppresses it entirely, because a suggestion the
reader cannot see but could still accept is worse than none.
A reply whose fenced block names mermaid is drawn as terminal box art instead
of source, laid out at the width the transcript has. mermaid: streaming (the
default) draws a diagram while the reply is still arriving, final waits for
the turn to end, and off leaves every fence exactly as written. A diagram
wider than the terminal, one the renderer cannot draw at all, or one whose source
is larger than a frame can lay out stays as the source fence rather than being
truncated; a settled diagram whose source was only partly readable keeps the
fence and names what was dropped. The drawing is
restyleable like anything else through markdown.diagram.border,
.text, .edge, .edgeLabel, .title, and .warning, so /theme
lists it with the rest. Nothing is lost by drawing: /export and the session
file keep the reply exactly as the model wrote it.
A fenced block whose language is diff or patch is drawn as the change it
describes rather than as one plain code block: file headers and hunk headers
recede, added rows draw green, removed rows draw red, and a row that replaced
another puts the characters that actually changed on a darker band of its own
colour, so a one-word edit reads at a glance instead of as two unrelated lines.
A pair that shares too little to be an edit draws whole-row, unchanged rows keep
the shade a code block always had, and any other language draws exactly as
before. The change is drawn in replies, submitted prompts, and thoughts alike,
because red and green say what the fence means rather than how loudly it is
drawn. The seven elements — markdown.diff.header, .hunk, .context,
.added, .removed, and the .addedEmphasis and .removedEmphasis bands
— are named by every shipped theme and overridden like any other, hidden
included; hiding an emphasis element keeps the row's own colour instead of
leaving a gap, and NO_COLOR draws the fence as plain text. /export and the
session file still keep the fence exactly as the model wrote it.
A theme restyles the whole surface by name, and a theme is a file. The package
ships two: violet-orbit, a port of pi's theme of that name — its palette, plus
the elements it draws its own way — and deepseek-blue, the table written out
in full in the colours the project answers to. violet-orbit is also what a
document naming no theme draws, so the default look is a file you can read, list,
and copy rather than a table compiled in. Your own themes live in
$DSH_HOME/themes/, which the surface creates at start-up and watches, so saving
a file there is how you change the surface you are looking at. A bare /theme
opens the list of them, narrowing as you type, and the screen paints the row under
the cursor as it moves: two themes are compared on your own transcript, and nothing
is written until one is taken, so leaving the list puts back the theme that was in
force. theme: violet-orbit applies one from the document, and a name nothing
answers to is reported with the names that do, drawing the default while you fix it.
Both files name every element and every palette entry, so a copy of one is a
complete theme rather than a diff against something you cannot see.
deepseek-blue is the one to copy to move a single shade, because every element
follows one of its ten palette entries, each taken from DeepSeek's own design
tokens with the token named beside it — and the accent is one line.
/theme export <built-in> writes that copy into your own directory as
<built-in>_export_<n>.yaml, adding one comment naming the release it came from:
the package's own file is replaced whenever the package updates, so the copy is the
only one worth editing. A file whose name is a built-in's is ignored, and reported
at start-up with the rename that fixes it.
A theme is a layer and not a replacement: everything it says nothing about keeps
its shipped appearance, and a tokens: entry of your own still wins over it one
field at a time, so naming a single attribute does not discard the shade the theme
gave that same element. /theme tokens names the theme in force in its heading and
reports each element as override, theme, palette, or default, marking the
themes in your own directory and printing the export hint, so a screen that looks
wrong can be traced to the layer that drew it.
A fenced block whose language is diff or patch is drawn as the change it
describes rather than as one plain code block: file headers and hunk headers
recede, added rows draw green, removed rows draw red, and a row that replaced
another puts the characters that actually changed on a darker band of its own
colour, so a one-word edit reads at a glance instead of as two unrelated lines.
A pair that shares too little to be an edit draws whole-row, unchanged rows keep
the shade a code block always had, and any other language draws exactly as
before. The change is drawn in replies, submitted prompts, and thoughts alike,
because red and green say what the fence means rather than how loudly it is
drawn. The seven elements — markdown.diff.header, .hunk, .context,
.added, .removed, and the .addedEmphasis and .removedEmphasis bands
— are overridden like any other, hidden included; hiding an emphasis element
keeps the row's own colour instead of leaving a gap, and NO_COLOR draws the
fence as plain text. /export and the session file still keep the fence exactly
as the model wrote it.
fg and bg accept #rrggbb, a palette name (default, muted, faint,
accent, arg, warn, added, removed, user, assistant), or an index. A colour is
emitted as 24-bit when the terminal advertises it (COLORTERM) and degraded to
the nearest 256-colour entry or 16-colour slot otherwise; a hue keeps its family
there, so an addition stays green instead of collapsing to black. Muted elements
name the palette rather than a terminal slot, so on anything but a 16-colour
terminal their contrast does not depend on what the reader's colour scheme maps
slot 8 to. faint is the shade below muted: a thought and the row naming it both take
it, and only the row is italic, so the signpost does not compete with the text
it introduces. arg is the pale blue a card gives the argument it was called with,
so tool.args is restyled on its own and stays distinct from the tool's own
label and from its output. user is the mint a submitted prompt takes, so a
reader's own turns stand apart from the reply without reading either.
NO_COLOR and --no-color disable styling entirely, attributes included, and
outrank everything in this section. A token or palette name the surface does not
have is refused with the offending name, and the surface prints the refusal as a
notice when the document loads, so a typo cannot quietly paint nothing.
Terminal text
A tool result, a file's contents, and a model's answer are text a terminal may
read as commands, so the surface reads them first. A whitelisted subset of the
SGR family (1, 2, 3, 4, 7, 9, 21/22, 23, 24, 27, 29, the
30–37/90–97 and 40–47/100–107 slots, 38/48 indexed and RGB, 39/49, and
0) is re-emitted at the session's own colour budget: 24-bit where the terminal
advertises it, 256 or 16 colours otherwise, and nothing at all with --no-color
or NO_COLOR. A tab advances to the next eight-column stop measured from the
column the text starts at, and a carriage return repaints its row in place, so
the last state of a progress bar is the only one drawn.
Everything else a terminal would act on — cursor movement, screen clearing,
private modes, window titles, clipboard writes, hyperlinks — is consumed rather
than shown, and a control byte that is not a sequence is spelled out (\x07)
rather than silently dropped. A full reset inside tool output restores the colour
of the element holding the text, not the terminal default, and the surface never
writes a reset of its own inside a row; a carriage return cannot repaint past the
column the text started at, so indented output cannot reach the frame around it.
Text the surface draws itself — a ghost suggestion, a completion row, a queued prompt, an export — is drawn without colour, because the surface is already painting it and a second style would fight the first.
Keys
Every press the surface answers is an action with an id and a shipped key.
/keys, or Ctrl+X then ?, opens the whole map in a box over the
transcript: one row per action with the keys in force, one row for every key your
map took from the library, and a filter over all of it — gate for a layer,
ctrl+o for a key, stash for what a row does. The heading counts the actions
shown and how many of them you wrote, the box gives up rows rather than grow past
four fifths of the screen, and enter or esc closes it with the transcript
exactly as it was. /keys prompt, surface, chord, gate, question,
picker, and library open it already narrowed to one part of the surface, and
a name that is none of them is refused with the names. The table above is the
complete account of the shipped keys.
An entry is one key or a list of them. A key is a modifier chord
(ctrl/alt/shift joined by +, written in that order), a named key
(enter, escape, tab, space, backspace, delete, home, end,
pageUp, pageDown, the arrows, f1–f12), or a bare character where the
layer reads one: y and n for an approval, or a chord's second key (? for
the key map, m for the model). Ids beginning tui. are pi-tui's own actions,
so the editor, the search, and the transcript move where you tell them to.
Ctrl+P and Ctrl+N ship as alternatives to ↑ and ↓ wherever a list moves — a
picker, a question's options, and the editor's completion menu. They are
ordinary rows: picker.up, picker.down, question.up, question.down,
and the library's tui.select.up/tui.select.down take other keys, or more
of them, like any other row.
Refused, with the reason in a notice and the shipped map left in force: an
action the surface does not have, a key no terminal reports, ctrl+q (the
terminal keeps it), a bare character outside the chord and gate layers, two
actions of one layer on one press, a key the library already answers on a row you
never wrote, a key the viewport reads before the surface sees it, whichever of the
two the map moved onto it (pageUp, or tui.altScreen.search moved onto a
surface key), a chord.prefix that is not a modifier chord or that takes a key
the surface or the prompt bar answers, and prefix: beside
keys.chord.prefix:, which are the same row under two names.
Two rows count as one press when some sequence reaches both, not merely when they
are spelled alike, because one press can arrive as several bytes and one byte can
spell several keys. A bare terminal reports Return for enter and ctrl+m, a
line feed for ctrl+j and, without the keyboard protocol, Return as well, one
control byte carries both ctrl+- and ctrl+_, and an escape with a letter
reaches alt+up as readily as alt+p.
A key the surface or a chord answers is a key the library never sees: that is
how ctrl+y shows nested calls instead of yanking a line in the editor, how
ctrl+c closes the transcript search the library owns, and how ctrl+d leaves
rather than deleting forward while the bar holds nothing. The key map carries a
row for every shadow your map introduces, naming the action that wins and the
library row that loses, and moving the surface key hands the library its own key
back.
Left alone, because they are typing rather than commands: the keys a question's
filter narrows with and the ones that leave its free-text row, the digits and
row 0 that name an option, and the mouse.
One residual escapes that promise, and it is not the surface's to close. After a
component returns its rows, the framework appends a reset to each row and closes
the hyperlink it wraps them in, so a session can still receive a bare ESC[0m
with colour off. It paints nothing. It is recorded here because "no escapes at
all" is otherwise the claim, and because the surface cannot make good on it
alone.
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 |
|---|---|---|
| ptc | PTC (default) | the same agent, reaching its tools through one TypeScript program |
| standard | standard | full agent: editing, shell, search, skills, planning, goals, subagents, workflows |
| 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,
ptc, 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.@sagmans/dsh-tui/todo-guardis the one advisory row this bundle adds to the agent plane: it watches the harness's owntodosandplanprojections and rides the next tool result with a reminder when a plan ages. See Todo discipline.
A question whose id ends in :secret declares its typed answer a credential: the bar hides everything but its first and last four characters, and the free-text row a question with options offers is labelled API KEY. Wording is not a declaration, because hiding every question that mentions a key would hide answers their authors meant to be read.
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.
Tool cards are folded by default: a card draws one header row — the tool, its argument clipped to the configured budget, and the facts the result measured — so a long read, diff, or search cannot bury the conversation. A call that has not answered yet names itself in the running colour, tool.running.title, and ends that row with the whole seconds it has been waiting, in tool.running.elapsed, once it has waited one, so a reader can tell the call they are watching from the one below it that already finished; both give way to the measured facts the moment the result lands. A call that failed names itself in the failed colour, tool.failed.title — including a shell whose command exited non-zero or died on a signal, which is a failure whether or not the tool that ran it said so. A shell card's row also carries the exit status and the count of output rows waiting behind the fold, because its output is the answer the reader asked for and a fold that left no trace of it would read as a call that produced nothing. Clicking a card opens or folds that one message; Ctrl+O opens or folds every card at once, and tools: in the settings decides how each tool starts and whether a fold hides its rows or keeps a tail of them.
A PTC card is the one card with children: every call the run_code program dispatched hangs off the card that made it, and once shown, each draws under the header as the tool's own name and argument, on one row cut at the screen edge whether the card itself is open or folded — a program's work must stay legible without opening its card. Those rows start folded, because a program can dispatch hundreds of calls and what a reader came for is the answer they produced: one click on the card's own header draws them for that card and for that card alone, Ctrl+Y draws them for every card at once, and subcalls: decides what a session starts with. Nothing else arrives with that click: the program's own rows are the level below, and Ctrl+O is what opens them. Each of those rows names its own call in the colour of what that call is doing — tool.subcall.running while the program is waiting on it, tool.failed.title when it failed, and tool.subcall.title once it is back — and a settled row keeps the one line the tool itself drew about its outcome, in tool.terminal.status where that tool declares one. A shell call therefore reads bash pnpm test · exit 0 when it worked and the same row in red, with exit 1, when it did not, so a program's work reads row by row without opening the card. The program's own row is the one card that shows no mark: its timer is what says it is still running, and it is also what the card keeps when the program answers — the total it ran for, drawn in tool.elapsed.done, dimmed and italic, because the work it measured is over. Clicking one of those rows opens that call's argument in full and leaves its neighbours and the card as they were; a shell call also brings back the rows it printed, because the program's return value is all the card itself keeps. Ctrl+Y hides or shows them all, and subcalls: collapsed, the shipped default, starts every session with them hidden; see Settings.
A card's header names the tool, then the argument the call was made with — a path or a command — in the tool.args colour, then the facts the result measured: a read reports its line range, line count, and token size; a file change that carried no prior content to compare against reports its lines and tokens; one that did reports added, changed, and removed lines as +n ~n -n in green, yellow, and red. Each stat is its own token, so any of them can be recoloured or hidden independently, and so are the elements a call in flight is drawn with — hiding tool.running.elapsed leaves the name in its running colour, hiding tool.running.title leaves the seconds counting, hiding tool.elapsed.done takes the total off a program's settled row, and a dispatched row is toned the same way per state with tool.subcall.running and tool.subcall.title. A state is only how a name is painted, so hiding one of those colours leaves the name in the colour a call with no state is read in rather than taking the name away.
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.
Todo discipline
The todo tool and its list belong to the agent; this bundle owns the surface and one advisory guard. @sagmans/dsh-tui/todo-guard mounts host-plane, reads the harness's own todos and plan projections, and — when a non-empty list has gone a threshold of model steps without a todo_write, or a long turn has produced no list at all — rides the next tool result with a model-visible reminder. It never vetoes a call, never steers a stopped turn, and never adds a prompt section, so its request prefix stays stable across deployments. A reminder costs the loop one extra model step to consume; the per-turn cap bounds that. It stays silent in plan mode, in a mode whose catalog has no todo_write, and when the projections are absent.
| Option | Default | Effect |
|---|---|---|
| staleSteps | 6 | model steps a non-empty open list may age before the guard speaks |
| missingListSteps | 12 | steps in a turn before the guard suggests a first list |
| maxRemindersPerTurn | 3 | hard cap on reminders, and on the extra steps they cost, per turn |
| previewItems | 5 | open items quoted in a reminder; the rest become a count |
Override them from the home-level patch, which outranks the profile's own layers. An id-targeted patch replaces the whole config, so restate every field you keep:
# $DSH_HOME/cordis.patch.yml
- id: tui-todo-guard
config:
staleSteps: 4
missingListSteps: 12
maxRemindersPerTurn: 3
previewItems: 5The list the dock and /todo draw follows the same lifetime every other surface shows: it is cleared when the next turn opens, because a fresh task must not inherit the previous turn's checklist.
Herdr
Herdr is a terminal multiplexer for coding agents. When it starts this surface in one of its panes it exports HERDR_ENV=1, HERDR_PANE_ID, and HERDR_SOCKET_PATH, and the pane reports what it is doing over that socket — as the agent dsh, under the source custom:dsh-tui. Away from Herdr (an ordinary terminal, SSH, tmux) the reporter is inert: no socket is opened, and nothing reaches the screen the reader owns.
| This surface | Herdr |
|---|---|
| the screen is taken, before any session opens | idle, claiming the pane's agent row |
| the agent's driver starts, including a run of turns chained through pending work | working |
| a subagent or background job remains live after the driver stops | working until the last one settles |
| an approval or a question takes the keyboard | blocked, with that card's title sent along, and set as the blocked display label |
| the decision settles | working if the driver or background work is running, otherwise idle |
| the agent's driver stops with no live subagent or job | idle |
| a session opens, resumes, forks, or is switched to | its id and reason, plus the dsh_session / dsh_cwd pane tokens |
| exit, signal, or boot failure | herdr pane release-agent, so no row is left waiting on a process that is gone |
A picker is deliberately absent from that table. Herdr answers a transition into blocked with a needs-attention notification and its sound, and 0.9.1 plays it for the focused pane of the active tab as well as for a background one, so a menu the reader opened themselves would ring for their own navigation — once per menu, on the model picker and the reasoning picker behind it — and the row would read as an agent stuck on a decision it never asked for. A gate is the opposite case: the agent asked, and may have asked somebody who walked away.
A wait outranks running work: an agent waiting on a human is not making progress, and the wait is the only thing worth acting on from a wall of panes. The pane follows the agent's driver and the live subagents and jobs it owns. The harness chains turns through a pending inbox inside one driver run, and reporting each turn's end would read as done between two turns of an agent that is still working. A parent driver can also stop while a delegated child or job continues, so its idle transition alone does not make the pane done. The job registry announces settlement before its completion notice wakes the parent, so the pane checks for idle after that handoff instead of briefly announcing done in the middle. Reports are sequenced per source, so a delivery that arrives late cannot undo the state the surface already moved past, and a state Herdr is already showing is not sent again. A report is only counted as made when Herdr acknowledges it: one that failed is tried again — soon after, then with a growing wait — and a session identity Herdr never confirmed travels with the next report of any kind. The retry cannot wait for another state change, because the pane may have nothing left to say: a driver that stopped while the socket was down produces no further event. States still waiting to be sent are collapsed into the newest one, so a socket that was down for a minute is told where the pane is rather than where it has been. The release carries the next number in that same sequence for the same reason: Herdr reads one that cannot beat the pane's last report as stale, and a stale release leaves the row waiting on a process that is gone. It also stops reporting first — a claim landing after the release would take the row back for a process that is leaving — and the report already on the wire is waited for before the row goes back, because Herdr ignores the release of a pane nothing has claimed yet and that late report would then claim it. What is still waiting behind it is dropped rather than sent.
Herdr persists a session reference only for its own built-in integrations, so this pane's session identity travels as metadata tokens instead: a script or a companion plugin reads them back with herdr pane get <id> and resumes that exact conversation with dsh --profile tui --resume=<id>. Herdr holds a token value up to 80 characters and shortens anything longer, so a session id or directory that cannot be sent whole has its token cleared instead: a shortened path would read as a different directory, and a pane must not claim one. What Herdr cannot do is identify the process itself — its detection table and its screen rules both name built-in agents only — so a pane that has not reported yet reads as an ordinary pane, and is not yet a target: herdr agent wait on it fails with agent_not_found until the first report lands. Herdr 0.9.1 keeps the title that accompanies a blocked state without drawing it anywhere, so the same title is sent again as that state's display label — the one Herdr's sidebar renders through its state_text token. The complete default agent rows do not include it:
[ui.sidebar.agents]
rows = [
["state_icon", "machine", "workspace", "tab"],
["state_text"],
["agent"],
]With that row present, a pane waiting on a decision names the tool that asked instead of showing a colour alone. A label longer than the 80 characters Herdr holds whole is cut here rather than server-side, so the ellipsis lands where this surface put it; the message that rides the state report keeps its own, longer bound.
Development
pnpm install
pnpm run typecheck
pnpm test
pnpm run buildA 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
node tools/pty-drive.mjs --prompt 'Run: echo hi' --click 20:12 # press the left button at row 12Keep 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.
The development guide — the gate, the dogfood script, and the difference between a fresh home, a cloned home, and the real one — is DEVELOPMENT.md.
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
