26 KiB
@deepseek-ai/dsh-tui
English | 中文
The interactive terminal front door for DeepSeek Harness agents, built on @earendil-works/pi-tui. It requires stdin and stdout TTYs; scripts and Loader pipes should use the one-shot @deepseek-ai/dsh-cli-demo app instead.
The implemented TUI feature Agent Note owns the front-door decision; the file-reference autocomplete Agent Note owns path-only @file behavior; the terminal-state snapshot Agent Note owns its verification strategy.
Interactive terminals on macOS, Linux, and Windows are supported. Windows uses pi-tui's native console VT-input handling, and the Windows support Agent Note owns the platform decision and ConPTY process verification.
This package owns interactive terminal presentation and input only. It injects agents, commands, llm, systemPrompt, tokenMeter, tools, and userInteraction, optionally reads a skills service (present only when one is mounted), then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing ask_user_question tool remain separate composition entries.
After terminal startup succeeds, the package provides the terminal-local ctx.tui extension service. A plugin that injects it can call openOverlay() with a component factory and constrained layout options; the host exposes the viewport, semantic theme, display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through host.display(). The interactive-extension Agent Note owns the boundary and rejected alternatives.
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's presentCall / presentResult intent to terminal, diff, or generic cards, keeps the standing todo/write plan above the editor (cleared on the next turn/start), and presents ctx.userInteraction questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with welcome before a title exists, and the terminal window title becomes <session title> — <configured title>. A durable llm/retry event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with ctx.llm.resolveModelInfo() context for the current route, displays context unknown when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and esc interrupt replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear.
An embedding may provide TuiRuntime.formatCwd when its logical workspace label differs from the session's host directory. The override changes only the footer label; tools continue to use the session cwd.
Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible \xNN text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling.
Typing @ at a token boundary searches files and directories under the session working directory. A bare fuzzy query uses a reusable bounded workspace index; a query containing / lists that directory directly, and selecting a folder keeps completion open for descent. Whitespace-bearing paths are inserted as @"path with spaces". Selecting a file inserts only its path and a trailing space: the TUI does not read it, attach hidden context, or replace it with a reference object. When a model-facing read tool is registered, the TUI adds one fixed system-prompt instruction telling the model to read an explicit path when its contents are needed.
When optional ctx.sessionReferences is mounted, the same @ menu also offers metadata-only session candidates, inserts @[label](dsh-session:<payload>), and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses agent.steer() or agent.followup() from the status after that asynchronous preparation, so idle follow-ups still dispatch agent/prompt-submit while in-turn steering joins at a checkpoint without that hook.
While the agent is running, ordinary editor submissions call agent.steer(); otherwise they call agent.followup(). A slash at the start of the submitted line enters ctx.commands instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; dsh-plan-mode uses that contract for /plan [message]. The TUI registers /help, /model, /clear, /palette, /reload, /resume, /status, and /exit as agent-scoped definitions; every other effective command joins autocomplete and /help dynamically, as do /skill: completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the Enter sends steering, Esc cancels hint; while steering messages wait to reach the model it inserts a N queued · badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
/model opens the advisory ctx.llm catalog as a keyboard selector: a filter box above the list narrows rows by a case-insensitive substring over each row's provider/model label, model name, and description, keeping the highlighted row selected when it survives the filter; Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape clears a non-empty filter before a second Escape closes it. When an adapter does not advertise a default effort, the cycle also includes provider default, which clears an explicit selection; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including off when present—and does not synthesize, clamp, or transfer an effort between models. /model <model> still selects an unambiguous model id directly, while /model <provider>/<model> selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces {{provider}} and {{model}}, and applies the same provider/model/reasoning-effort target through agent/request; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local.
/reload (EXPERIMENTAL, dev-only) re-reads every file-backed loader config tree and applies the diff to the running app — the HMR watcher's config path, invoked manually; it needs the cordis Loader in the context and degrades to a warning without one, runs only while the agent is idle, and refuses re-entry while a reload is in flight. Module-source hot reload remains watcher-owned. When a skills service is mounted, /skill:<name> [instructions] loads that skill's instructions into the conversation as a user turn; autocomplete lists user-invocable skills, and exact invocation rejects a skill whose user policy disables it.
The footer sums the session's reported usage as ↑<uncached input> ↓<output>, followed by cache <rate>% once any input has been billed — the share of billed prompt tokens (uncached input plus cache reads and writes) served from the provider cache, rounded to a percent. It also compares token-meter pressure with ctx.llm.resolveModelInfo() context for the current route (omitting the context share when the adapter has no capacity metadata) and shows the current model and tool-card mode; the right side clips first when the footer is narrow.
/status adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, selected reasoning effort or default behavior, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer.
/resume opens a full-viewport keyboard selector instead of a centered dialog. Two scopes cover the same candidate set: the current workspace, which it opens on, and all workspaces, which Tab toggles to. The scope line under the search field names the active scope and the count the other holds, and each row in the all-workspaces scope also reports its own workspace. Toggling clears the search and selection so the highlighted row always belongs to the visible list.
Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Candidates are sorted by last logged activity and searchable by log-backed title or session id, and by workspace label in the all-workspaces scope; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, a session with no recorded workspace to run in, or a session whose logged provider has no current adapter remains visible but disabled; a workspace other than the current one is a scope rather than a disabled reason, because resume enters that directory.
Selection repeats those checks and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned TuiRuntime.handoffResume with the selected id and the workspace re-read at preflight: process cwd, not the restored session header, is what filesystem and shell tools resolve against, so the host must enter that directory. Where process.execve is available, the shipped dsh host chdirs into it before disposing the app and replacing its process, and rejects an unreachable directory while the terminal can still be restored. Resume restores the same SessionId, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or /goal resume.
The exit line is launcher-owned, not configurable. A launcher provides TUI_GOODBYE_MESSAGE_KEY on the boot context — for the shipped dsh, the command that resumes this session — and exiting prints it verbatim after the terminal is released; absent, exiting prints nothing. Only the launcher knows how it was invoked, so only it can name a command that works. The TUI escapes terminal controls before rendering and never executes the text. A launcher that also supplies MAIN_SESSION_ID_KEY fixes which session the mounted app binds to, so resume survives any config-level patch.
A launcher can seed a fresh session's first turn by providing INITIAL_SKILL_KEY (the skill name) on the boot context; the TUI auto-invokes it exactly as a typed /skill:<name>, once the chat is live. The shipped dsh migrate/dsh upgrade set it and only for a fresh session, so a resumed session never re-invokes the skill; an unknown name is reported as a notice.
Config
| Key | Default | Meaning |
|---|---|---|
welcome |
— | Banner subtitle line until the session has a logged title; unset, the banner sweeps in with no subtitle |
sessionId |
main |
Exact shared agent/session identity driven by the terminal |
showReasoning |
true |
Render reasoning blocks |
maxToolOutputLines |
6 |
Output lines retained across a collapsed tool card's head/tail preview |
maxQuestionOptions |
8 |
Visible options in a question panel |
maxModelOptions |
8 |
Visible models in the model selector |
maxResumeOptions |
8 |
Visible sessions in the resume selector |
questionDialogWidth |
200 |
Question-panel width in columns, clamped to the terminal |
questionDialogMaxHeight |
20 |
Question-panel maximum rows |
modelDialogWidth |
76 |
Model-selector width in columns |
modelDialogMaxHeight |
20 |
Model-selector maximum rows |
fileSearchMaxResults |
20 |
Maximum file and directory candidates shown for one @ query |
fileSearchMaxEntries |
10000 |
Maximum paths retained in the bounded workspace index used by bare fuzzy queries |
fileSearchExcludedDirectories |
['.git', 'node_modules'] |
Directory basenames omitted from traversal and direct completion |
showHardwareCursor |
false |
Show the hardware cursor at pi-tui's IME marker |
color |
true |
Apply the built-in ANSI palette (see Color) |
title |
DeepSeek Harness |
Product suffix for the terminal window title. |
- id: terminal
name: '@deepseek-ai/dsh-tui'
config:
welcome: 'Coding agent ready.'
sessionId: main-session-123
showReasoning: true
maxToolOutputLines: 6
fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist']
Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe agent-loop/config-start-failed; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops extension admission, unloads the ctx.tui provider and its dependent plugins, aborts running commands, removes the TUI definitions, stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR. A user exit disposes the application root so sibling resources close, then exits; a five-second fallback prevents one stuck disposer from trapping the process.
Color
Every SGR code the TUI emits lives in one table, paletteSpec in components/theme.ts, which createPalette derives its wrappers from and /palette prints; no component writes an escape of its own. The table holds only the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so the TUI stays readable on light and dark backgrounds alike — the startup banner's brand gradient is the one deliberate exception. Body text keeps the terminal's default foreground rather than a fixed shade.
There is one role per visual meaning: dim is the single recessed tone and accent the single emphasis color, while success and error double as a diff's added and removed lines. Colors and attributes are separately typed, so bold(accent(x)) compiles and accent(error(x)) does not — SGR has no color stack, so nesting one color inside another silently drops the outer color at the inner one's close. Attributes occupy independent SGR groups and compose with any color in either order. Run /palette to see every role as your terminal renders it, with its SGR pair.
Grouped regions (user prompts, assistant replies, tool cards) are separated by a bold, underlined role header in the role color and blank-line spacing rather than a filled block or a per-line prefix, so a mouse drag-select copies the message text without any leading bar or indent; a tool card's status (pending, error, success) shows in its colored, underlined title glyph and title. Inside a tool card, the whole body — presenter title, a terminal $ command and cwd, and the tool's own output — renders in one dim tone, so only the status-colored header carries color and the body reads as one recessed block instead of a run of competing shades; an injected-context card's prose is the same tone as its header. A diff card's +/- lines and a [signal …] marker stay colored, because there the color is the meaning rather than emphasis. The question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set color: false to strip all styling.
Model Experience
Interactive prompt input
What the model sees
Each non-empty ordinary editor submission becomes one text block, sent with agent.followup() while the target agent is idle and agent.steer() while it is running. A session mention becomes readable @label text plus the durable untrusted context defined by dsh-session-reference; its full JSON is hidden behind a compact reference card. Slash commands and keybindings are TUI-only; command results remain terminal notices. A command producer may schedule a separate agent input, such as the optional message accepted by /plan [message].
Token effect
Submitted text is retained under the agent loop's normal session-history and compaction rules. Headers, the logged title, cards, Markdown rendering, status lines, plans, and help text add no tokens.
KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
File-reference autocomplete
What the model sees
A selected file remains ordinary user text such as @src/index.ts or @"docs/design notes.md"; autocomplete adds no content block, durable context, or special reference payload. When read is registered, every request from this TUI agent also contains the following fixed system-prompt section. The model decides whether the task requires the file contents and calls read through the normal tool loop when it does; a path alone is not evidence that the file was inspected.
Exact system-prompt text
Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.
Token effect
Autocomplete itself adds no tokens. The selected path contributes only its ordinary user-text tokens; the fixed instruction contributes system-prompt tokens whenever read is available. File contents consume context only after a model-selected read call returns them.
KV Cache effect
The fixed instruction is part of the stable system-prompt prefix and is reusable across turns. Each selected path is append-only user text; a later read result appends the requested contents through the ordinary tool transcript.
Session model selection
What the model sees
The /model command text and keyboard-selector input are not logged or sent. New steps receive the selected provider/model route in prompt variables and the selected provider/model/reasoning-effort target in request routing.
Token effect
The selector adds no messages. A target change may alter interpolated system-prompt text and sends subsequent requests to the selected model.
KV Cache effect
Changing provider or model enters that target's cache domain; no cache reuse across distinct targets is assumed.
Manual skill invocation
What the model sees
A /skill:<name> [instructions] submission loads the named skill and delivers one text block: a <skill name="…"> element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same followup-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill: autocomplete and exact invocation apply invocation.userInvocable, while invocation.modelInvocable does not restrict this surface. User-disabled skills are omitted from autocomplete and rejected before exact-name loading; the loaded definition is rechecked for a policy race. Autocomplete retains its last complete skill snapshot and refetches after skills/change; an incomplete observation preserves the prior menu, a complete empty observation clears it, and a catalog arriving while a slash-name draft is open immediately re-queries that draft. The skill service is an optional peer; this policy check uses its type contract without introducing a runtime package dependency.
Token effect
The rendered skill block and trailing instructions are retained as one user turn under the agent loop's normal session-history and compaction rules; a repeated invocation appends the body again.
KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
Interactive user-question answers
What the model sees
When a consumer calls ctx.userInteraction.ask(), this provider presents each question in order and returns selected option labels or custom text. Abort, cancellation, or UI disposal becomes Error: ask_user_question was interrupted before the user answered through dsh-tool-ask-user.
Token effect
Waiting and terminal overlays add no tokens; the resolved answer or error is model-visible only through the calling tool or plugin's result.
KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
Known Limitations and Deferred Work
- Resume has no cross-process session lock — the selector rejects sessions known to be live in its own runtime, but another process can resume the same persisted id before or during handoff. The all-workspaces scope makes this reachable in one step, since a session another host is driving in a different directory is now selectable. Deployments that can run concurrent hosts must coordinate ownership outside the TUI.
- One configured session owns the transcript and editor — questions from other agents can still use the shared overlay provider, but session rendering and prompt input remain bound to
sessionId. - Tool cards are text terminal presentations — terminal, diff, and generic cards use tool-owned titles/content, but session content currently has no image block for inline image rendering.
- Non-TTY operation is intentionally unsupported — app bundles that need automation must compose a one-shot or server front door (
dsh-cli-demo,dsh-acp) rather than expecting an internal fallback. - Manual
/skill:invocation always reloads the full skill body — the TUI does not detect a skill already present in the conversation, so repeated invocations append its instructions again. - File discovery is host-workspace discovery — autocomplete reads the TUI process's session
cwd, while the selected text is later interpreted by the configuredreadtool. Deployments that mount a remote or virtual filesystem must keep those namespaces aligned or provide another completion surface. - File search uses explicit directory exclusions, not ignore files —
.gitandnode_modulesare excluded by default and deployments may configure more basenames, but.gitignoreand.ignoreare not interpreted. Directory symlinks are not traversed.