Files
deepseek-harness/packages/ui-stdio
Tianyi Cui 072f97c184 refactor(examples): extract reusable logic into tested packages
Logic that lived under examples/ was outside the per-file 100% coverage
gate (examples/ are not workspaces) and, in the stdio-UI case, duplicated
across two examples. Move it into packages/ so it is gated and de-duped.

- packages/ui-stdio (new): unify the two diverged stdio-chat.ts copies into
  one @deepseek-ai/dsh-ui-stdio plugin (welcome/agent Config). A test-only
  I/O seam (createStdioChat(ctx, config, runtime)) keeps process streams out
  of the serializable config and makes every render/EOF/disposal branch
  unit-testable. Per-file 100%. echo/coding cordis.yml now load the package;
  both src/stdio-chat.ts deleted.
- packages/llm-replay (new): move examples/acp-agent/src/llm-replay.ts (+ its
  spec) here so its derive/parse/replay branches fall under the coverage gate.
  cordis.snapshot.yml + README rewired to the package name; added apply/env
  /assertNever/abort tests to reach per-file 100%.
- examples/{echo,coding}-agent: keyless Loader-path e2e smokes that boot the
  real cordis.yml (no key) — the guard a hand-mounted unit test cannot be for
  the unwrapExports/export-shape class (postmortem 0001). examples/AGENTS.md
  codifies the keyless+with-key smoke convention (keyless-by-nature exception
  for echo-agent).
- AGENTS.md: a scoped, removal-triggered pre-release stance (foundation over
  blast radius). packages/README.md: new rows + a FIXME to later regroup ALL
  packages into a hierarchy. Wiring: tsconfig paths/refs, publint, knip,
  module-graph.

Verified: typecheck, lint, test:coverage (887 tests, 100%), build, hygiene,
doc-sync, test:snapshot (10), test:e2e (6 keyless pass, with-key self-skip).
2026-06-19 12:42:28 +08:00
..

@deepseek-ai/dsh-ui-stdio

A minimal stdio (readline) UI, as a plugin. It reads lines from stdin and feeds them to an agent (send when idle, steer while a turn is running), and renders that agent's streamed output and tool activity to stdout. A UI is "just a plugin" here — it only consumes the agent/* event taxonomy plus the agents service (inject: ['agents']), so the same plugin drives any example or product surface.

This package consolidates what were two near-identical copies under examples/echo-agent and examples/coding-agent. The coding copy was a superset; this package IS that superset — dimmed chain-of-thought rendering plus robust piped-stdin EOF handling — with the per-consumer differences moved into Config.

Config

Key Type Default Notes
welcome string 'ready.' Banner printed once on start, before the first > prompt.
agent string 'main' Id of the agent to drive and render.
- id: ui-stdio
  name: '@deepseek-ai/dsh-ui-stdio'
  config:
    welcome: 'coding-agent ready. Give it a coding task.'

Rendering

  • agent/stream-chunktext-delta is written verbatim; reasoning-delta is wrapped in the dim SGR (\x1B[2m … \x1B[0m) so the chain-of-thought is visually subordinate to the answer. Reasoning rendering is inert when no reasoning-delta chunks arrive (e.g. a mock model), so it is always on.
  • agent/turn-start / agent/turn-end — a [<agent> turn N] header and a trailing > prompt.
  • session/eventtool/call renders [tool call] name(args); tool/result renders the joined text blocks as [tool result] ….

The I/O seam

The production entry point apply(ctx, config) binds the real process streams. The testable core is createStdioChat(ctx, config, runtime), where runtime: StdioRuntime supplies input / output / exit. This seam is deliberately not part of the serializable Config (streams and functions do not belong in YAML config); it exists so the render, EOF, and disposal branches can be exercised with fakes instead of hijacking globals.

Piped-stdin exit

On stdin EOF the plugin exits the process, but carefully:

  • No work submitted (empty stdin, blank-only lines): exit immediately — no turn will ever start, so there is nothing to wait for. Gating on an observed running here would hang forever.
  • Work submitted: exit the next time the agent settles to idle after having been observed running. agent.send() does not synchronously flip status to running, so requiring an observed running first (sawRunning) avoids exiting in the gap before the turn starts and dropping work; and the loop batches several queued messages into one turn, so the exit keys off the idle transition rather than counting sends.

Disposal (HMR or fiber teardown) closes the readline interface, which also fires close — a disposed guard ensures teardown never calls process.exit.

Plugin export shape

Named name / inject / Config / apply, with no default export: the cordis Loader's unwrapExports does exports.default ?? exports, so a stray default would collapse the module to the bare function and drop the inject namespace (see docs/postmortem/0001). The keyless Loader-path e2e smokes in examples/{echo,coding}-agent guard this end-to-end.