跳到主要内容

dsh-tui

已验证

@sagmans/dsh-tui · v0.14.0 · MIT

Terminal (CLI TUI) surface for DeepSeek Harness: interactive dsh in a terminal, no browser required

安装

dsh plugin add @sagmans/dsh-tui

用 dsh --profile default --dump-config 确认 layer 已生效 —— 参见安装指南。

源码

标签

作者

说明文档

@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 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.

Install the dogfood skill (optional)

Run this explicit command after you add the plugin:

dsh --profile tui install-skills

The 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 --update

Updating 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 --update

The 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-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.

Use the installed Harness launcher

The manifest declares >=0.2.0-rc.2 <0.3.0, but lists only 0.2.0-rc.2 as a verified release. Range membership alone does not prove compatibility. Run dsh --version and use that installed release for dogfooding. pnpm dsh from a Harness source checkout runs a development host, not the verified installed release. It can migrate settings.yaml; do not use it for TUI dogfooding.

The surface probes required and optional services at startup, and selects settings APIs by capability. These checks do not validate the host version or prove full-profile behavior. The dogfood helper and PTY driver separately check the launcher against the manifest's verified releases.

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.

npm can report overriding peer dependency while a harness tree resolves. A warning alone does not mean the install failed; check its exit status. This plugin pins its mounted harness dependencies to 0.2.0-rc.2 and declares optional harness peers as *. Optional peers avoid automatic installation when absent; neither open peers nor exact dependencies guarantee consumer deduplication. The consumer install smoke checks package-copy counts for its selected release and install paths, not every host in the declared range.

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
TUI preferences disappear after launching a Harness source checkout that host treats settings.yaml as migration input, not live preferences use the verified installed release with a cloned home; preserve the original settings file
--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 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 reach

list-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 the current stash bank's 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; with no path, $DSH_HOME/exports/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 the current stash bank's drafts; enter pops the marked one
/stash-drop [index|id] delete a stashed draft without using it
/stash-clear delete every draft in the current stash bank, 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.

Plugin keymaps

The bundle publishes tuiKeymaps before the surface starts. Optional plugins register their own actions; the terminal does not assign their meaning. Actions use namespaced plugin.<owner>.<action> IDs and either the chord or surface layer. Approval gates, questions, and library-owned editor bindings are not plugin registration layers.

import type { Context } from '@deepseek-ai/cordis'
import type { KeymapRegistry } from '@sagmans/dsh-tui/keymaps'

const REGISTRY = 'tuiKeymaps'
const ACTION_ID = 'plugin.example.options'
const KEY = 'v'
const LABEL = 'example options'
const PICKER = { title: 'Example options', rows: [{ id: 'inspect', name: 'Inspect' }] }

export function apply(ctx: Context): void {
  ctx.inject([REGISTRY], owner => {
    const registry = owner.get(REGISTRY) as KeymapRegistry
    registry.register(owner, {
      id: ACTION_ID,
      layer: 'chord',
      defaultKeys: [KEY],
      label: LABEL,
      handler: async ports => {
        const picked = await ports.pick(PICKER)
        if (picked !== undefined) ports.notice(LABEL + ': ' + picked)
      },
    })
  })
}

Handlers receive the current route, generic row-picker ports, and a notice sink. The registering Context owns the action; disposal removes its bindings, help rows, and handler. Duplicate IDs and conflicting effective bindings are refused before publication. Registration and removal update the open surface and disarm any pending chord.

Users can rebind plugin IDs through the same keys preference mapping as builtin actions. On Config-backed profiles, add keys directly to the existing TUI row's config, preserving its launch fields. Older settings-backed hosts use the tui.keys section. Preferences for absent plugins remain stored but inactive; registration validates them before activation. A plugin can register afterEffort(owner, handler) to offer a follow-up after confirmed effort selection. Cancelling the effort picker invokes no follow-up. An action may supply routeHint(route), a synchronous optional model qualifier owned by the same Context. The footer reads live qualifiers beside effort; failed or control-bearing hints stay hidden.

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 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 a separate prompt frame keeps what the reader typed distinct from 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 text, transcript.user.border its frame, and editor.border the input bar. 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. By default, a stash belongs to the absolute working directory where the TUI started: new or resumed sessions in the same worktree share drafts. Set stash.scope: session on the TUI row in the profile's cordis.patch.yml to keep each session's drafts separate:

- id: tui
  config:
    sessionId: !!js ctx.tuiStartup.sessionId
    resume: !!js ctx.tuiStartup.resume
    resumePicker: !!js ctx.tuiStartup.resumePicker
    model: !!js ctx.tuiStartup.model
    provider: !!js ctx.tuiStartup.provider
    preset: !!js ctx.tuiStartup.preset
    color: !!js ctx.tuiStartup.color
    bell: !!js ctx.tuiStartup.bell
    stash:
      scope: session

A profile override replaces the row's config, so keep its startup mappings as shown. Editing the bundle's tui row config instead needs only the stash block.

Use scope: path (the default) to share drafts by directory. Changing the scope does not move existing drafts; switch back to the old scope to read them. The footer shows stash N while drafts 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.

Each path or session bank is one JSON file 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. On the supported line a preference lives in the active profile's own patch, under the TUI entry's config; /theme <name> writes a chosen theme there for you. Use the actual entry ID, normally tui, not the legacy dsh-tui namespace. Merge preferences into the existing config; preserve its startup !!js fields. The verified host replaces config wholesale, so a partial replacement loses the launch identity.

- id: tui
  config:
    theme: violet-orbit
    history:
      enabled: false
      ghost: false

A released host that keeps a dsh-tui: section reads the same fields from $DSH_HOME/settings.yaml instead, and reads every one shown below:

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)
  spacing:
    padding: 1                # columns that inset the conversation and the work board (0-3, default 1)
    messages: 1               # blank rows above and below a prompt or a reply card (0-3, default 1)
    steps: 1                  # blank rows where a step of a turn opens, grouping a thought with its calls (0-3, default 1)
  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 all

The 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 legacy section-style 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. Legacy section-style settings providers 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 section-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 section-provider defaults remain active.

The verified 0.2.0-rc.2 settings service has no legacy import alias from dsh-tui to tui. Exporting Config does not resolve that namespace mismatch. The service renames 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. This plugin therefore declares the schemastery release that provides the API, because an implementation without it wraps nothing: every preference field stays ordinary data and a Config-backed write never reaches the document the reader is looking at. TUI checks the loaded implementation and the actual references, not the package version or CLI identity, so a host that resolves an older copy gets the refusal instead of an import that silently does nothing.

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 an ordinary profile change - the entry's own config in a profile patch - is applied by the host's reload lifecycle.

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 six: violet-orbit, a port of pi's theme of that name — its palette, plus the elements it draws its own way — deepseek-blue, the table written out in full in the colours the project answers to, polar-drift, that table anchored on the cyan #27CFF5, pine-slope, the same table around the pine #173802, wine-thicket, the same table around the plum #240413, and marine-static, the same table around the deep teal #041F21. The pine, the plum, and the teal are dark enough that they mark the band under a selected row by hue alone, while the accent is lifted out of their own hue to where text reads. Each of them tints its neutrals through that hue, holds the same relative ladder of shades, and leaves the semantic shades where a reader already reads them. 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.

Every shipped file names every element and every palette entry, so a copy of one is a complete theme rather than a diff against something you cannot see. Copying deepseek-blue is how 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. polar-drift reads the same way, with the anchor as its own 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. Submitted prompts, assistant replies, and the editor have separate border tokens: transcript.user.border, transcript.assistant.border, and editor.border. In violet-orbit, their default shades are #6f76c9, #a89771, and #6f76c9. An existing editor.border override now affects only the editor, not submitted prompts.

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 seat an agent's own scope takes in this profile's roster. The rows that make up that agent — its tools, prompt sections, skills, and planning — belong to the bundle shipping each of them, and the profile's own layers compose them, so a mode names a composition rather than assembling one. A mode is fixed once a session has produced a turn, because the log records the seat the agent that answered ran in.

Four ship, under the ids a session log records:

--preset Mode What this bundle makes of the id
ptc PTC (default) the agent this profile composes, with its tools reached through one TypeScript program
standard standard the same agent, its tools called directly
minimal minimal the same agent, as this profile composes it
cordis creator the same agent, for authoring the composition itself

Only PTC declares anything of its own: the presentation its tools take. The other three ids are the harness's own mode names, kept so a session log reads the same here as in the browser profile, and what each of them runs is the profile's composition — a profile that wants a smaller minimal declares that composition in its own layers.

A session takes its mode from the first of these that applies:

  1. --preset <id>, refused before the terminal is taken over when the roster does not ship that id.
  2. /preset while the session is still blank: a bare command opens the picker, /preset <id> switches directly, and the choice is written to the log.
  3. 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/startup parses this app's own flags and publishes the launch identity.
  • The roster of modes holds the id a session starts in when nobody names one: @deepseek-ai/dsh-agent-preset-registry owns that service on the supported line, and each mode arrives as a row of @deepseek-ai/dsh-agent-preset, so this bundle declares the four modes itself. Both are mounted through this bundle's own host/roster entry point, because a patch row is applied before any service exists and the registry and the modes arrive with the harness's own agent services — after the surface that needs the roster. A mode names a selection rather than a composition: the plugins an agent runs on belong to the bundle shipping each of them, and the profile's own layers compose them, so the other shipped modes declare no plugin row and the patch disables none. The one row a mode does own is its presentation: ptc mounts @deepseek-ai/dsh-agent-tool-presentation with mode: ptc, because the form an agent's tools take is declared per agent — a session's own row cannot say it, and a mode without the row presents natively whatever its id says.
  • @deepseek-ai/dsh-cordis-host-runner is the host machinery creator mode needs, and the base mounts no such row, so a terminal profile mounts it through this bundle's own host/runner entry point. The code runtime PTC mode runs programs against is a base row of its own (ptc-runtime) on the supported line, so this bundle mounts none of its own. The registry, preset, and runner packages are plain dependencies pinned to 0.2.0-rc.2; these pins do not guarantee one copy in every consumer tree.
  • @sagmans/dsh-tui owns the terminal: it creates or resumes one agent through ctx.agents, folds session/event into 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-guard is the one advisory row this bundle adds to the agent plane: it watches the harness's own todos and plan projections 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.

Air follows the log's own boundaries rather than every row. A prompt and a reply are objects of the transcript, so each frame opens and closes on blank rows — spacing.messages, one by default — and two boundaries that meet keep the wider ask instead of the sum, so one break never reads as two. A step is the other boundary: the loop's step/start opens it, which is what keeps a thought and the calls it made in one group and puts the next step's work underneath it, spacing.steps rows down. Nothing else is spaced — a reply after a reply, a folded thought's signpost, and the cards under the message that asked for them all stay flush — and every count accepts 0, which draws the rows exactly as the surface drew them before the air existed. spacing.padding insets the conversation and the work board under it instead of sitting at a seam, while the bar the reader types in, the prompts queued behind it, and the footer keep the window's full width; all three counts are read per frame, so an edit in the row's settings document lands on the session already on screen.

The work board opens on a blank row of its own, so the conversation above it and the todos, jobs, and delegations below it are two regions rather than one stream. The blank row takes no row when the board has nothing to report: with no work on the board nothing is drawn and the bar sits directly under the transcript.

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, on a skill card, the skill it loaded in the tool.skill colour, 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. Hiding tool.skill is the same promise for a subject: the skill keeps its name in the label's colour. /export and a dispatched row spell the name too, so the words survive where no colour is.

The bundle disables no base row. The profile's layers supply the agent tools, prompt sections, skills, and planning. The shipped modes do not duplicate those rows; PTC adds only its tool-presentation row.

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
`mis