ソースを参照

feat(ui): add dedicated TUI package

Move the interactive pi-tui front door into @deepseek-ai/dsh-tui while keeping @deepseek-ai/dsh-stdio line-oriented for pipes. Select the terminal package in the demo app, preserve logger ownership, and cover the production Loader composition with a PTY smoke test.
Turtle 1 ヶ月 前
コミット
1119c537d0
49 ファイル変更2808 行追加93 行削除
  1. 1 1
      AGENTS.md
  2. 2 2
      README.i18n.yaml
  3. 1 1
      README.md
  4. 1 1
      README.zh.md
  5. 1 1
      docs/architecture.md
  6. 53 4
      docs/config-catalog.md
  7. 2 2
      docs/cookbook/extension-cookbook.i18n.yaml
  8. 1 1
      docs/cookbook/extension-cookbook.md
  9. 1 1
      docs/cookbook/extension-cookbook.zh.md
  10. 1 1
      docs/core-data-structures/user-interaction.md
  11. 2 2
      docs/development.i18n.yaml
  12. 1 1
      docs/development.md
  13. 1 1
      docs/development.zh.md
  14. 5 5
      docs/event-producer-consumer.md
  15. 3 3
      docs/i18n/translation-prompt.md
  16. 9 1
      docs/module-graph.md
  17. 1 1
      examples/README.md
  18. 7 13
      examples/coding-agent/README.md
  19. 2 2
      examples/coding-agent/composition.md
  20. 8 3
      examples/coding-agent/cordis.yml
  21. 101 0
      examples/coding-agent/tests/tui-keyless-smoke.e2e.ts
  22. 1 1
      examples/cordis-agent/composition.md
  23. 1 1
      examples/echo-agent/README.md
  24. 1 1
      examples/echo-agent/composition.md
  25. 1 1
      examples/echo-agent/cordis.yml
  26. 1 1
      knip.json
  27. 2 2
      packages/examples/README.md
  28. 1 1
      packages/examples/agent-spine-demo/README.md
  29. 14 10
      packages/examples/stdio-demo/README.md
  30. 5 3
      packages/examples/stdio-demo/package.json
  31. 1 1
      packages/examples/stdio-demo/src/bin.ts
  32. 73 13
      packages/examples/stdio-demo/src/index.ts
  33. 43 2
      packages/examples/stdio-demo/tests/stdio-agent.spec.ts
  34. 3 0
      packages/examples/stdio-demo/tsconfig.json
  35. 1 1
      packages/todo/README.md
  36. 1 1
      packages/todo/tool-todo/README.md
  37. 4 3
      packages/ui/README.md
  38. 1 1
      packages/ui/acp/README.md
  39. 56 0
      packages/ui/tui/README.md
  40. 45 0
      packages/ui/tui/package.json
  41. 1284 0
      packages/ui/tui/src/index.ts
  42. 19 0
      packages/ui/tui/tests/plugin-shape.spec.ts
  43. 952 0
      packages/ui/tui/tests/tui.spec.ts
  44. 33 0
      packages/ui/tui/tsconfig.json
  45. 1 1
      packages/ui/user-interaction/README.md
  46. 56 0
      pnpm-lock.yaml
  47. 2 2
      scripts/gen-doc-graphs.ts
  48. 1 0
      tsconfig.build.json
  49. 1 0
      tsconfig.json

+ 1 - 1
AGENTS.md

@@ -26,7 +26,7 @@ packages/    Harness packages at packages/<group>/<pkg>/, all named @deepseek-ai
   cordis/      self-referential toolset: the agent inspects/mounts plugins in its own runtime
   hooks/       Claude Code / Codex hook bridges + shared wire-protocol library
   session-persistence/  persistence seam + JSONL/SQLite backends
-  ui/          ACP/stdio/JSON-RPC bridges; boot, approval, interaction plugins
+  ui/          ACP/stdio/TUI/JSON-RPC bridges; boot, approval, interaction plugins
   examples/    demo bundles (agent-spine + stdio/ACP/JSON-RPC bins) leaves load
   support/     dev/test infrastructure packages
   util/        zero-dependency utilities

+ 2 - 2
README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write
-README.md: 53dd3896eb15800125673e7c44f7de02daca9376
-README.zh.md: ab826f62658248249ec18c57b35c0065c0f909d1
+README.md: 92cbfaa8196b49d2b801776f0e0c178cb5f673dc
+README.zh.md: 28604ad1e2926edd7d4eb91c8027785433a54d32

+ 1 - 1
README.md

@@ -11,7 +11,7 @@ This monorepo is built on the [Cordis](https://github.com/cordiverse/cordis) fra
 ```sh
 pnpm install
 pnpm run test          # vitest
-pnpm run demo:repl     # REPL agent demo (needs DEEPSEEK_API_KEY)
+pnpm run demo:repl     # interactive pi-tui coding agent (needs DEEPSEEK_API_KEY)
 pnpm run demo:acp      # ACP server agent demo (needs DEEPSEEK_API_KEY)
 ```
 

+ 1 - 1
README.zh.md

@@ -11,7 +11,7 @@
 ```sh
 pnpm install
 pnpm run test          # vitest
-pnpm run demo:repl     # REPL agent demo (needs DEEPSEEK_API_KEY)
+pnpm run demo:repl     # interactive pi-tui coding agent (needs DEEPSEEK_API_KEY)
 pnpm run demo:acp      # ACP server agent demo (needs DEEPSEEK_API_KEY)
 ```
 

+ 1 - 1
docs/architecture.md

@@ -139,7 +139,7 @@ Some seams bend the template deliberately. LLM keeps interface and consumer voca
 
 ### Bundles And Apps
 
-`dsh-agent-spine-demo` is the default composition bundle: one plugin loading the shared spine ([README](../packages/examples/agent-spine-demo/README.md)). App packages compose it with a front door and boot `bin`: `dsh-stdio-demo` for terminal REPL, and `dsh-acp-demo` for ACP over JSON-RPC stdio with no stdout logger ([ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` instead boots an external `cordis.yml`; the Python SDK injects the package default only when no explicit config channel is set and drives `dsh-jsonrpc` over line-delimited stdio JSON-RPC ([Python SDK](../python/README.md)). A deployment is a thin `cordis.yml` leaf: swappable backends, one app entry, and optional product tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)).
+`dsh-agent-spine-demo` is the default composition bundle: one plugin loading the shared spine ([README](../packages/examples/agent-spine-demo/README.md)). App packages compose it with a front door and boot `bin`: `dsh-stdio-demo` selects the `dsh-tui` package on interactive terminals and `dsh-stdio` on pipes, while `dsh-acp-demo` serves ACP over JSON-RPC stdio ([ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` instead boots an external `cordis.yml`; the Python SDK injects the package default only when no explicit config channel is set and drives `dsh-jsonrpc` over line-delimited stdio JSON-RPC ([Python SDK](../python/README.md)). A deployment is a thin `cordis.yml` leaf: swappable backends, one app entry, and optional product tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)).
 
 ### Where New Behavior Goes
 

+ 53 - 4
docs/config-catalog.md

@@ -700,7 +700,7 @@ Source: [`packages/ui/stdio/src/index.ts:30`](../packages/ui/stdio/src/index.ts)
  * is the explicit model-facing tool order (forwarded to the system-prompt plugin);
  * fresh sessions use `process.cwd()` as their workspace cwd; resumed sessions
  * keep their persisted cwd. `persistenceRoot` is the JSONL backend's directory;
- * `welcome` is the UI banner.
+ * `welcome` is the UI banner and `ui` configures terminal mode/presentation.
  */
 export interface Config {
   /** Model name for the `main` agent (must have a registered adapter). */
@@ -713,8 +713,10 @@ export interface Config {
   tools?: ToolsConfig
   /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
   persistenceRoot?: string
-  /** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
+  /** Terminal banner printed once on start. Defaults to `'ready.'`. */
   welcome?: string
+  /** Terminal front-door selection and pi-tui presentation settings. */
+  ui?: UiConfig
   /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */
   skills?: agentCore.SkillConfig
   /** Model-facing bash tool config forwarded through agent-core. */
@@ -728,11 +730,22 @@ export interface Config {
    */
   resumeSessionId?: string
 }
+
+/** App-level terminal selection with nested TUI presentation settings. */
+export interface UiConfig {
+  /** Select a concrete front door or infer it from the process streams. */
+  mode?: TerminalMode
+  /** Settings forwarded only when the pi-tui front door is selected. */
+  tui?: uiTui.TuiConfig
+}
+
+/** Terminal front door selected by the app bundle. */
+export type TerminalMode = 'auto' | 'readline' | 'tui'
 ```
 
-Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools)
+Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts)
 
-Source: [`packages/examples/stdio-demo/src/index.ts:36`](../packages/examples/stdio-demo/src/index.ts)
+Source: [`packages/examples/stdio-demo/src/index.ts:73`](../packages/examples/stdio-demo/src/index.ts)
 
 ## `@deepseek-ai/dsh-subagent-acp`
 
@@ -1075,6 +1088,42 @@ export type ToolPresentationMode = 'native' | 'code' | 'both'
 
 Source: [`packages/core/tools/src/index.ts:307`](../packages/core/tools/src/index.ts)
 
+## `@deepseek-ai/dsh-tui`
+
+Requires: `agents` · `userInteraction` · `tools`
+
+```ts config-catalog
+/** Serializable plugin configuration. */
+export interface Config extends TuiConfig {
+  /** Header subtitle. Defaults to `ready.`. */
+  welcome?: string
+  /** Agent id driven by this terminal. Defaults to `main`. */
+  agent?: string
+}
+
+/** Presentation settings for the pi-tui terminal mode. */
+export interface TuiConfig {
+  /** Render model reasoning blocks. */
+  showReasoning?: boolean
+  /** Maximum tool-output lines shown before the card is collapsed. */
+  maxToolOutputLines?: number
+  /** Maximum options visible at once in a user-question dialog. */
+  maxQuestionOptions?: number
+  /** User-question dialog width in terminal columns. */
+  questionDialogWidth?: number
+  /** User-question dialog maximum height in terminal rows. */
+  questionDialogMaxHeight?: number
+  /** Show the terminal's hardware cursor at the pi editor's IME marker. */
+  showHardwareCursor?: boolean
+  /** Apply the built-in ANSI color palette. */
+  color?: boolean
+  /** Terminal window title while the UI is mounted. */
+  title?: string
+}
+```
+
+Source: [`packages/ui/tui/src/index.ts:100`](../packages/ui/tui/src/index.ts)
+
 ## `@deepseek-ai/dsh-user-approval`
 
 ```ts config-catalog

+ 2 - 2
docs/cookbook/extension-cookbook.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write
-extension-cookbook.md: 3474bc116b43f9be57b52e947f9cf99730f7e796
-extension-cookbook.zh.md: 1a605b20fe4e171a948ac2a046193a2f4d884e44
+extension-cookbook.md: 02dc22ff72807629a89d602f49752e6f429587b5
+extension-cookbook.zh.md: a204abdf6656312aea94640c60038af9f81f4f7b

+ 1 - 1
docs/cookbook/extension-cookbook.md

@@ -87,7 +87,7 @@ export function apply(ctx: Context) {
 
 ## Runnable wirings
 
-Three complete examples load their plugin trees from `cordis.yml`: [`examples/echo-agent`](../../examples/echo-agent) (mock model + echo tool — the all-mock skeleton check, `pnpm run demo:echo`), [`examples/coding-agent`](../../examples/coding-agent) (DeepSeek V4 + the bash tool suite behind a terminal REPL UI, `pnpm run demo:repl`), and [`examples/acp-agent`](../../examples/acp-agent) (an agent exposed as an ACP server over JSON-RPC stdio — the client-driver shape, `pnpm run demo:acp`). Each leaf is just its swappable backends plus an app-package entry: the stdio demos load [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo), the ACP demo loads [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), and both app packages share the spine via the [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle.
+Three complete examples load their plugin trees from `cordis.yml`: [`examples/echo-agent`](../../examples/echo-agent) (mock model + echo tool — the all-mock skeleton check, `pnpm run demo:echo`), [`examples/coding-agent`](../../examples/coding-agent) (DeepSeek V4 + the bash tool suite behind a pi-tui coding interface with a readline fallback, `pnpm run demo:repl`), and [`examples/acp-agent`](../../examples/acp-agent) (an agent exposed as an ACP server over JSON-RPC stdio — the client-driver shape, `pnpm run demo:acp`). Each leaf is just its swappable backends plus an app-package entry: the stdio demos load [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo), the ACP demo loads [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), and both app packages share the spine via the [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle.
 
 ## The feature → mechanism map
 

+ 1 - 1
docs/cookbook/extension-cookbook.zh.md

@@ -87,7 +87,7 @@ export function apply(ctx: Context) {
 
 ## 可运行的组装示例
 
-三个完整示例从 `cordis.yml` 加载各自的插件树:[`examples/echo-agent`](../../examples/echo-agent)(mock 模型 + echo 工具——全 mock 骨架检查,`pnpm run demo:echo`)、[`examples/coding-agent`](../../examples/coding-agent)(DeepSeek V4 + bash 工具套件,配合终端 REPL UI,`pnpm run demo:repl`)、[`examples/acp-agent`](../../examples/acp-agent)(通过 JSON-RPC stdio 暴露为 ACP 服务器的 agent——客户端驱动形态,`pnpm run demo:acp`)。每个叶子只是其可替换后端加一个 app 包入口:stdio 演示加载 [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo),ACP 演示加载 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo),两个 app 包通过 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle 共享主干。
+三个完整示例从 `cordis.yml` 加载各自的插件树:[`examples/echo-agent`](../../examples/echo-agent)(mock 模型 + echo 工具——全 mock 骨架检查,`pnpm run demo:echo`)、[`examples/coding-agent`](../../examples/coding-agent)(DeepSeek V4 + bash 工具套件,配合 pi-tui 编码界面,并为管道输入保留 readline 回退,`pnpm run demo:repl`)、[`examples/acp-agent`](../../examples/acp-agent)(通过 JSON-RPC stdio 暴露为 ACP 服务器的 agent——客户端驱动形态,`pnpm run demo:acp`)。每个叶子只是其可替换后端加一个 app 包入口:stdio 演示加载 [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo),ACP 演示加载 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo),两个 app 包通过 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle 共享主干。
 
 ## 功能→机制映射
 

+ 1 - 1
docs/core-data-structures/user-interaction.md

@@ -1,6 +1,6 @@
 # User Interaction
 
-The user-interaction seam of [dsh-user-interaction](../../packages/ui/user-interaction). It is the provider-neutral vocabulary a tool or permission plugin uses when it needs the human to answer before the agent can continue. UI surfaces provide the active `UserInteractionProvider`: `dsh-stdio-demo` renders questions in readline, and `dsh-acp` maps them to ACP form elicitations.
+The user-interaction seam of [dsh-user-interaction](../../packages/ui/user-interaction). It is the provider-neutral vocabulary a tool or permission plugin uses when it needs the human to answer before the agent can continue. UI surfaces provide the active `UserInteractionProvider`: `dsh-stdio-demo` selects keyboard-driven `dsh-tui` overlays or `dsh-stdio` readline prompts, and `dsh-acp` maps questions to ACP form elicitations.
 
 Source: [`packages/ui/user-interaction/src/index.ts`](../../packages/ui/user-interaction/src/index.ts)
 

+ 2 - 2
docs/development.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write
-development.md: b3c338f03f548b4de4b676850732731323d611f1
-development.zh.md: 20f5c585dd378a7b0a3bd6b0af8ebf7dc5e0fd3c
+development.md: 512fdcd89b4d481dd2033bc4f4c2816a04b5feaa
+development.zh.md: 9f2038a99b289d06f16d7d51f0d043caa34a300e

+ 1 - 1
docs/development.md

@@ -109,7 +109,7 @@ The echo demo does not need API credentials:
 pnpm run demo:echo
 ```
 
-The REPL agent demo uses the real DeepSeek adapter and needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:
+The coding-agent demo uses pi-tui interactively, falls back to readline for pipes, and needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:
 
 ```sh
 pnpm run demo:repl

+ 1 - 1
docs/development.zh.md

@@ -109,7 +109,7 @@ echo 演示不需要 API 凭证:
 pnpm run demo:echo
 ```
 
-REPL agent 演示使用真实的 DeepSeek 适配器,需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:
+coding-agent 演示在交互终端中使用 pi-tui,对管道输入回退到 readline,并需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:
 
 ```sh
 pnpm run demo:repl

+ 5 - 5
docs/event-producer-consumer.md

@@ -7,16 +7,16 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 
 | Event | Mode | Declared in | Dispatchers | Listeners |
 | --- | --- | --- | --- | --- |
-| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:139`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio`](../packages/ui/stdio) |
-| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:148`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio) |
-| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:283`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
+| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:139`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) |
+| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:148`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) |
+| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:283`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) |
 | `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:202`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) |
 | `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:212`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
 | `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:167`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
 | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:224`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
 | `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:239`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) |
 | `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:180`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:157`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio`](../packages/ui/stdio) |
+| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:157`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) |
 | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:250`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
 | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:260`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:270`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
@@ -27,7 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) |
 | `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) |
 | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - |
-| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio) |
+| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) |
 | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
 | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:108`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) |
 | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:82`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |

+ 3 - 3
docs/i18n/translation-prompt.md

@@ -123,9 +123,9 @@ Follow the Good versions; these sentence-level examples illustrate error categor
 - Good: `A green gate does not mean the translation is correct.`
 
 ### Code block comments — never translate
-- Source code block contains: `# REPL agent demo (needs DEEPSEEK_API_KEY)`
-- Bad: `# REPL agent 演示(需要 DEEPSEEK_API_KEY)`
-- Good: `# REPL agent demo (needs DEEPSEEK_API_KEY)` (byte-identical)
+- Source code block contains: `# interactive pi-tui coding agent (needs DEEPSEEK_API_KEY)`
+- Bad: `# 交互式 pi-tui 编码 agent(需要 DEEPSEEK_API_KEY)`
+- Good: `# interactive pi-tui coding agent (needs DEEPSEEK_API_KEY)` (byte-identical)
 
 ### Language switcher — English to Chinese
 - Source: `English | [中文](README.zh.md)`

+ 9 - 1
docs/module-graph.md

@@ -98,6 +98,7 @@ flowchart TD
     pkg_permission["permission"]
     pkg_stdio["stdio"]
     pkg_tool_ask_user["tool-ask-user"]
+    pkg_tui["tui"]
     pkg_user_approval["user-approval"]
     pkg_user_interaction["user-interaction"]
   end
@@ -287,6 +288,11 @@ flowchart TD
   pkg_tool_ask_user --> pkg_agent
   pkg_tool_ask_user --> pkg_tools
   pkg_tool_ask_user --> pkg_user_interaction
+  pkg_tui --> pkg_agent
+  pkg_tui --> pkg_llm
+  pkg_tui --> pkg_session
+  pkg_tui --> pkg_tools
+  pkg_tui --> pkg_user_interaction
   pkg_repeat_tool_guard --> pkg_agent
   pkg_repeat_tool_guard --> pkg_tools
   pkg_mcp_client --> pkg_llm
@@ -370,6 +376,7 @@ flowchart TD
   pkg_stdio_demo --> pkg_stdio
   pkg_stdio_demo --> pkg_tool_ask_user
   pkg_stdio_demo --> pkg_tools
+  pkg_stdio_demo --> pkg_tui
   pkg_stdio_demo --> pkg_user_interaction
 ```
 
@@ -437,6 +444,7 @@ flowchart TD
 | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
 | [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) |
 | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
+| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
 | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) |
 | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) |
 | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
@@ -452,4 +460,4 @@ flowchart TD
 | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
 | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
 | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
-| [`stdio-demo`](../packages/examples/stdio-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`stdio`](../packages/ui/stdio), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
+| [`stdio-demo`](../packages/examples/stdio-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`stdio`](../packages/ui/stdio), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction) |

+ 1 - 1
examples/README.md

@@ -15,7 +15,7 @@ Run with: `pnpm run demo:echo`. When prompted, type "echo <something>" to trigge
 
 ## coding-agent
 
-A REPL agent demo: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite, `subagent` delegation, and the `todo_write` task tracker on the same `@deepseek-ai/dsh-stdio-demo` app. The UI is a terminal readline REPL.
+A coding-agent demo: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite, `subagent` delegation, and the `todo_write` task tracker on the same `@deepseek-ai/dsh-stdio-demo` app. Interactive runs use the pi-tui coding interface; pipes use readline.
 
 Run with: `pnpm run demo:repl` (needs `DEEPSEEK_API_KEY` in the environment or a gitignored repo-root `.env`). See [coding-agent/README.md](coding-agent/README.md) for details.
 

+ 7 - 13
examples/coding-agent/README.md

@@ -1,6 +1,6 @@
 # coding-agent
 
-The REPL agent demo wiring: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite + subagent delegation + `todo_write` + stdio chat + JSONL persistence, loaded from `cordis.yml`. The UI is a terminal readline REPL.
+The coding-agent demo wiring: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite + subagent delegation + `todo_write` + terminal chat + JSONL persistence, loaded from `cordis.yml`. Interactive runs use the pi-tui coding interface; piped runs use readline.
 
 ## Run it
 
@@ -11,15 +11,9 @@ The REPL agent demo wiring: DeepSeek V4 + the `read`/`write`/`edit` filesystem t
 pnpm run demo:repl
 ```
 
-Type a coding task. The agent works through the `read`/`write`/`edit` filesystem tools for ordinary file operations and `bash` (+ the generic `task_output` / `task_list` / `task_kill` for background tasks) for shell commands, searches, and test runs, each in a fresh `bash -c` (the system prompt tells the model to pass `workdir` instead of `cd`). Both the fs tools and bash resolve relative paths against the session workspace. It can also delegate with `subagent`/`subagent_fork` and track multi-step work with `todo_write` (a whole-list task tracker rendered as a checklist). Reasoning streams dimmed; tool calls/results render inline.
+Type a coding task. The agent works through the `read`/`write`/`edit` filesystem tools for ordinary file operations and `bash` (+ the generic `task_output` / `task_list` / `task_kill` for background tasks) for shell commands, searches, and test runs, each in a fresh `bash -c` (the system prompt tells the model to pass `workdir` instead of `cd`). Both the fs tools and bash resolve relative paths against the session workspace. It can also delegate with `subagent`/`subagent_fork` and track multi-step work with `todo_write`.
 
-```
-> fix the failing test in /path/to/project
-[main turn 1] (reasoning…)
-  [tool call] bash({"command": "node --test", "workdir": "/path/to/project"})
-  [tool result] … [exit code: 1]
-  …
-```
+The TUI renders resumed Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and the latest todo list. Enter submits or steers while the agent is running; Ctrl+O expands cards, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `ask_user_question` opens a keyboard-driven overlay rather than taking over the editor.
 
 ### Resuming a prior session
 
@@ -29,7 +23,7 @@ Each run starts a fresh session by default (its event log lands under `./.sessio
 RESUME_SESSION_ID=<prior-session-id> pnpm run demo:repl
 ```
 
-The id is wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); unset, the agent starts a new session. A missing/unreadable id is non-fatal — it logs a warning and starts no `main` agent.
+The id is wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); unset, the agent starts a new session. A missing or unreadable id starts no `main` agent, so unset it or choose an existing session id.
 
 ## Code Mode
 
@@ -48,17 +42,17 @@ and watch the transcript: one `run_code` call, a program looping over tools, and
 
 ## What each leaf entry demonstrates
 
-This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads one app package, and adds product tools that are intentionally outside the shared spine. The spine (sessions, system-prompt, tools, agents, invariants, `agent-loop`) and the front-door cluster (console logger, JSONL persistence, readline UI, the pre-created `main` agent) live inside the [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo) app and the [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle it loads; the leaf wires the backends and model-facing optional tools:
+This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads one app package, and adds product tools that are intentionally outside the shared spine. The spine (sessions, system-prompt, tools, agents, invariants, `agent-loop`) and the front-door cluster (JSONL persistence, TTY-selected `dsh-tui`/`dsh-stdio` channels, the pre-created `main` agent) live inside the [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo) app and the [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle it loads; the leaf wires the backends and model-facing optional tools:
 
 | Entry | Demonstrates |
 |---|---|
 | `hmr` (`@cordisjs/plugin-hmr`) | the dev/demo edit-reload loop — a **leaf** entry (not baked into the app) because it is Loader-only and needs `node --expose-internals`, which `demo:repl` passes |
 | `llm-deepseek` | real `LlmAdapter` via config (`!!js process.env.…` secrets); swap one line to `@deepseek-ai/dsh-llm-pi-ai` for the library-backed twin |
 | `bash` (`dsh-bash-local`) | the executor implementation — the swappable half of the bash seam. The model-facing `bash` schema (`tool-bash`) and generic `task_*` controls (`tool-tasks`) come from `dsh-agent-spine-demo`, so only the executor is a leaf choice |
-| `stdio-agent` (`@deepseek-ai/dsh-stdio-demo`) | the app bundle: the agent-spine demo + console logger + JSONL persistence + readline UI + a pre-created `main` agent. Its config carries the model, system prompt, `persistenceRoot` (`./.sessions`), and `resumeSessionId` — so persistence and the agent are configured here, not wired as separate leaf plugins |
+| `stdio-agent` (`@deepseek-ai/dsh-stdio-demo`) | the app bundle: the agent-spine demo + JSONL persistence + TTY-selected `dsh-tui`/`dsh-stdio` channels + a pre-created `main` agent. Its config carries the model, system prompt, `persistenceRoot` (`./.sessions`), `resumeSessionId`, and optional `ui` presentation settings |
 | `subagent`, `subagent-spawn`, `subagent-fork` | the subagent provider registry plus the two in-process backends: a fresh child and a child seeded with the parent's completed-turn prefix |
 | `tool-subagent`, `tool-subagent-fork` | two model-facing `dsh-tool-subagent` loads, each bound to a different provider and exposed under a distinct tool name (`subagent`, `subagent_fork`) |
-| `tool-todo` | the model-facing `todo_write` tool; writes the whole task list to the session log and renders as a checklist in stdio |
+| `tool-todo` | the model-facing `todo_write` tool; writes the whole task list to the session log and renders as a persistent TUI plan or readline checklist |
 | `fs-local`, `fs-policy`, `tool-fs` | the filesystem stack: the local `ctx.fs` provider, the read-before-write/edit policy gate (on the `fs/*` event gate), and the model-facing `read`/`write`/`edit` tools. Relative paths resolve against the session workspace |
 
 ## End-to-end tests (`pnpm run test:e2e`, key-gated)

+ 2 - 2
examples/coding-agent/composition.md

@@ -3,7 +3,7 @@
 
 # Coding Agent App Composition
 
-The coding REPL demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package.
+The coding-agent demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package.
 
 ```mermaid
 flowchart LR
@@ -18,7 +18,7 @@ flowchart LR
   cfg --> plugin_coding_stdio_agent
   plugin_coding_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"]
   plugin_coding_stdio_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"]
-  plugin_coding_stdio_agent --> frontdoor_stdio["readline UI<br/>console logger<br/>pre-created main agent"]
+  plugin_coding_stdio_agent --> frontdoor_stdio["dsh-tui (TTY) / dsh-stdio (pipes)<br/>pre-created main agent"]
   bundle_agent_core --> spine_llm["ctx.llm"]
   bundle_agent_core --> spine_sessions["ctx.sessions"]
   bundle_agent_core --> spine_tools["ctx.tools + tool-bash"]

+ 8 - 3
examples/coding-agent/cordis.yml

@@ -1,6 +1,6 @@
-# REPL agent with swappable DeepSeek and local-bash backends. `dsh-stdio-demo`
-# supplies the agent spine, generic task controls, logging, JSONL persistence,
-# readline UI, and `main` agent.
+# Coding agent with swappable DeepSeek and local-bash backends. `dsh-stdio-demo`
+# supplies the agent spine, generic task controls, JSONL persistence, TTY-selected
+# `dsh-tui`/`dsh-stdio` terminal front doors, and `main` agent.
 # HMR remains a leaf because it requires Loader internals; `demo:repl` passes
 # `--expose-internals`. The app bin loads the gitignored root `.env`; this file
 # reads `DEEPSEEK_API_KEY` and optional `DEEPSEEK_BASE_URL` through `!!js`.
@@ -38,6 +38,11 @@
     resumeSessionId: !!js process.env.RESUME_SESSION_ID
     persistenceRoot: './.sessions'
     welcome: 'agent REPL ready. Give it a coding task.'
+    ui:
+      mode: auto
+      tui:
+        showReasoning: true
+        maxToolOutputLines: 12
     # Keep the persona to identity and behavior; tool plugins own tool guidance.
     # The loop resolves {{model}} from this agent's configuration.
     persona: |

+ 101 - 0
examples/coding-agent/tests/tui-keyless-smoke.e2e.ts

@@ -0,0 +1,101 @@
+import { spawn } from 'node:child_process'
+import { mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke'
+
+const binScript = fileURLToPath(new URL('../../../packages/examples/stdio-demo/src/bin.ts', import.meta.url))
+const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
+const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
+const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
+
+const PTY_DRIVER = String.raw`
+import errno, os, pty, select, signal, sys, time
+node, tsx_loader, bin_script, config_path, tsconfig_path, cwd = sys.argv[1:]
+env = os.environ.copy()
+env.update({
+    "DEEPSEEK_API_KEY": "keyless-tui-no-call",
+    "DSH_HOME": os.path.join(cwd, ".dsh"),
+    "DSH_AGENTS_HOME": os.path.join(cwd, ".agents"),
+    "TSX_TSCONFIG_PATH": tsconfig_path,
+})
+pid, fd = pty.fork()
+if pid == 0:
+    os.chdir(cwd)
+    os.execvpe(node, [node, "--expose-internals", "--import", tsx_loader, bin_script, config_path], env)
+
+output = bytearray()
+sent_exit = False
+deadline = time.monotonic() + 25
+status = None
+while time.monotonic() < deadline:
+    ready, _, _ = select.select([fd], [], [], 0.05)
+    if ready:
+        try:
+            chunk = os.read(fd, 65536)
+        except OSError as error:
+            if error.errno != errno.EIO:
+                raise
+            chunk = b""
+        if chunk:
+            output.extend(chunk)
+    if not sent_exit and b"agent REPL ready." in output:
+        os.write(fd, b"/exit\r")
+        sent_exit = True
+    waited, candidate = os.waitpid(pid, os.WNOHANG)
+    if waited == pid:
+        status = candidate
+        break
+
+if status is None:
+    os.kill(pid, signal.SIGKILL)
+    _, status = os.waitpid(pid, 0)
+sys.stdout.buffer.write(output)
+if not sent_exit:
+    sys.stderr.write("TUI did not render its welcome marker before timeout\n")
+    sys.exit(124)
+if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 0:
+    sys.stderr.write("TUI child did not exit cleanly\n")
+    sys.exit(125)
+`
+
+async function runTuiLoaderSmoke(): Promise<string> {
+  const cwd = await mkdtemp(join(tmpdir(), 'coding-tui-smoke-'))
+  try {
+    return await new Promise((resolve, reject) => {
+      const child = spawn('python3', [
+        '-c',
+        PTY_DRIVER,
+        process.execPath,
+        tsxLoader,
+        binScript,
+        configPath,
+        tsconfigPath,
+        cwd,
+      ], { stdio: ['ignore', 'pipe', 'pipe'] })
+      let stdout = ''
+      let stderr = ''
+      child.stdout.setEncoding('utf8')
+      child.stdout.on('data', (chunk: string) => { stdout += chunk })
+      child.stderr.setEncoding('utf8')
+      child.stderr.on('data', (chunk: string) => { stderr += chunk })
+      child.once('error', reject)
+      child.once('exit', (code) => {
+        if (code === 0) resolve(stdout)
+        else reject(new Error(`TUI PTY smoke exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`))
+      })
+    })
+  } finally {
+    await rm(cwd, { recursive: true, force: true })
+  }
+}
+
+describe('coding-agent TUI keyless smoke (real Loader tree in a PTY)', () => {
+  it('boots pi-tui, renders the configured banner, accepts /exit, and restores the terminal', async () => {
+    const output = await runTuiLoaderSmoke()
+    expect(output).toContain('DEEPSEEK')
+    expect(output).toContain('agent REPL ready.')
+  }, LOADER_SMOKE_TEST_TIMEOUT_MS)
+})

+ 1 - 1
examples/cordis-agent/composition.md

@@ -24,7 +24,7 @@ flowchart LR
   cfg --> plugin_cordis_stdio_agent
   plugin_cordis_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"]
   plugin_cordis_stdio_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"]
-  plugin_cordis_stdio_agent --> frontdoor_stdio["readline UI<br/>console logger<br/>pre-created main agent"]
+  plugin_cordis_stdio_agent --> frontdoor_stdio["dsh-tui (TTY) / dsh-stdio (pipes)<br/>pre-created main agent"]
   bundle_agent_core --> spine_llm["ctx.llm"]
   bundle_agent_core --> spine_sessions["ctx.sessions"]
   bundle_agent_core --> spine_tools["ctx.tools + tool-bash"]

+ 1 - 1
examples/echo-agent/README.md

@@ -4,7 +4,7 @@ Runnable demo: stdin chat with a scripted mock model and an echo tool. The all-m
 
 ## What it shows
 
-This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo) app (which bundles the whole [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) spine, the console logger, JSONL persistence, the readline UI, and a pre-created `main` agent), and swaps in two example-local backends plus `hmr`:
+This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo) app (which bundles the whole [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) spine, JSONL persistence, the TTY-selected `dsh-tui`/`dsh-stdio` front doors, and a pre-created `main` agent), and swaps in two example-local backends plus `hmr`:
 
 - `mock-llm.ts` — a mock `LlmAdapter` that streams scripted responses and calls the `echo` tool when the user types "echo <something>". Registered with `ctx.llm.registerAdapter(['mock-echo'], …)`.
 - `echo-tool.ts` — a tool registered via `ctx.tools.register(defineTool(…))` with typed `execute` args; echoes text back uppercased.

+ 1 - 1
examples/echo-agent/composition.md

@@ -20,7 +20,7 @@ flowchart LR
   cfg --> plugin_echo_stdio_agent
   plugin_echo_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"]
   plugin_echo_stdio_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"]
-  plugin_echo_stdio_agent --> frontdoor_stdio["readline UI<br/>console logger<br/>pre-created main agent"]
+  plugin_echo_stdio_agent --> frontdoor_stdio["dsh-tui (TTY) / dsh-stdio (pipes)<br/>pre-created main agent"]
   bundle_agent_core --> spine_llm["ctx.llm"]
   bundle_agent_core --> spine_sessions["ctx.sessions"]
   bundle_agent_core --> spine_tools["ctx.tools + tool-bash"]

+ 1 - 1
examples/echo-agent/cordis.yml

@@ -22,7 +22,7 @@
 - id: bash
   name: '@deepseek-ai/dsh-bash-local'
 
-# The app pre-creates `main` on the mock model and supplies logging, persistence, and readline UI.
+# The app pre-creates `main` on the mock model and supplies persistence plus TTY-selected `dsh-tui`/`dsh-stdio` front doors.
 - id: stdio-agent
   name: '@deepseek-ai/dsh-stdio-demo'
   config:

+ 1 - 1
knip.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://unpkg.com/knip@5/schema.json",
   "exclude": ["duplicates"],
-  "ignoreBinaries": ["bwrap", "sandbox-exec"],
+  "ignoreBinaries": ["bwrap", "python3", "sandbox-exec"],
   "ignoreWorkspaces": ["vendor/*", "python/sdk-runtime"],
   "workspaces": {
     ".": {

+ 2 - 2
packages/examples/README.md

@@ -5,11 +5,11 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling
 | Package | npm name | Role |
 |---|---|---|
 | `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin (`timer` + `llm` + sessions + system-prompt + tools + skills + agents + invariants + `tool-bash` + `tool-skill` + `agent-loop`) |
-| `stdio-demo/` | `@deepseek-ai/dsh-stdio-demo` | Terminal stdio chat app: the spine + console logger + readline UI + a pre-created `main` agent, with a boot `bin` |
+| `stdio-demo/` | `@deepseek-ai/dsh-stdio-demo` | Terminal chat app: the spine + JSONL persistence + TTY-selected `dsh-tui`/`dsh-stdio` front door + a pre-created `main` agent, with a boot `bin` |
 | `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` |
 | `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | Bin-only runtime that boots an external `cordis.yml` for the stdio JSON-RPC SDK client |
 
-`agent-spine-demo` is the shared bundle; `stdio-demo` and `acp-demo` compose it with opposite front-door clusters (console logger + readline UI vs the stdout-owning ACP bridge) and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches.
+`agent-spine-demo` is the shared bundle; `stdio-demo` and `acp-demo` compose it with terminal and ACP front-door clusters and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches.
 
 These are **not** product API. The spine pieces they bundle live in [`core/`](../core/README.md), the bridges/channels/boot-glue in [`ui/`](../ui/README.md), and the swappable backends (LLM adapter, bash executor) in their capability groups; a demo bundle just picks one concrete composition of them. Swap or fork one freely.
 

+ 1 - 1
packages/examples/agent-spine-demo/README.md

@@ -33,7 +33,7 @@ The spine is everything COMMON to every front door. The swappable and front-door
 - **the LLM adapter** — the bundle ships the abstract `llm` service; the leaf registers a concrete adapter on `ctx.llm` (`llm-deepseek`, `llm-pi-ai`, `llm-replay`).
 - **the bash executor** — the bundle ships `tool-bash` (the consumer schema); the leaf provides `ctx.bash` (`bash-local` or a sandboxed impl).
 - **non-local skill providers** — the bundle ships the skill registry, the local filesystem provider, and the `skill` tool; deployments can add other providers such as embedded or remote catalogs as siblings.
-- **presentation + per-app infra** — the stdio UI / ACP bridge, a console logger, `hmr`. These form the coupled "front-door cluster" that the app packages ([`dsh-stdio-demo`](../../examples/stdio-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/README.md)) bake in. `timer` is in the spine (common to both, stdout-silent); a console logger is NOT (it writes to stdout, which the ACP bridge reserves for JSON-RPC).
+- **presentation + per-app infra** — the terminal (`dsh-tui` / `dsh-stdio`) or ACP front door and `hmr`. These form the coupled front-door cluster that the app packages ([`dsh-stdio-demo`](../../examples/stdio-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/README.md)) bake in. `timer` is in the spine because it is common and stdout-silent; front doors own stdout and remain outside.
 
 This is the [interface/implementation/consumer seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md) raised to the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the front door.
 

+ 14 - 10
packages/examples/stdio-demo/README.md

@@ -1,8 +1,8 @@
 # @deepseek-ai/dsh-stdio-demo
 
-The **terminal stdio chat app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-spine-demo`](../../examples/agent-spine-demo/README.md)) with the front-door cluster a terminal chat needs, and a `bin` that boots a leaf `cordis.yml`.
+The **terminal chat app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-spine-demo`](../../examples/agent-spine-demo/README.md)) with JSONL persistence, human interaction, a pre-created `main` agent, and a TTY-selected pi-tui/readline terminal front door. Its `bin` boots a leaf `cordis.yml`.
 
-It is the readline counterpart to [`@deepseek-ai/dsh-acp-demo`](../acp-demo/README.md): both consume the same spine, but each bakes in the OPPOSITE front-door cluster.
+It is the terminal counterpart to [`@deepseek-ai/dsh-acp-demo`](../acp-demo/README.md): both consume the same spine, while ACP reserves stdout for JSON-RPC and creates sessions from the client.
 
 ## What it bakes in
 
@@ -10,14 +10,15 @@ A terminal chat always wants the same cluster, so the package owns it rather tha
 
 | Plugin | Why it is here |
 |---|---|
-| `@cordisjs/plugin-logger-console` | the console logger — stdout is just the terminal here, so logging to it is correct (the ACP app must NOT have this) |
 | `@deepseek-ai/dsh-agent-spine-demo` | the spine, pre-creating a `main` agent from this app's `model` with `process.cwd()` as the fresh session cwd and carrying its `persona` |
 | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` |
 | `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools |
 | `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool |
-| `@deepseek-ai/dsh-stdio` | the readline UI, bound to the `main` agent |
+| `@cordisjs/plugin-logger-console` | readline diagnostics for non-TTY operation; omitted from the fullscreen TUI path |
+| `@deepseek-ai/dsh-stdio` | the line-oriented terminal channel, bound to `main` for pipes and automation |
+| `@deepseek-ai/dsh-tui` | the interactive pi-tui channel, bound to `main` for TTY pairs |
 
-`@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`.
+`@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, not baked in here: it is a Loader-only, subprocess-only dev plugin whose constructor needs `node --expose-internals` plus a live `loader`. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`.
 
 The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapter (`llm-deepseek` for the real model, or the mock `mock-llm` for a demo) and a bash executor (`bash-local`) — `hmr`, plus this app's [`Config`](#config). The whole plugin tree a run loads is therefore: this app's cluster, the spine inside `agent-core`, `hmr`, and the two leaf backends.
 
@@ -33,7 +34,8 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte
 | `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` |
 | `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` |
 | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
-| `welcome` | `ready.` | the stdin-chat banner |
+| `welcome` | `ready.` | terminal banner / TUI subtitle |
+| `ui` | owner defaults | app mode selection and nested `dsh-tui` presentation config |
 | `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) |
 
 Fresh stdio sessions use the process launch directory as `session.header.cwd`, so project-scoped features such as skill discovery and default bash workdir follow the directory where `dsh-stdio-demo` was started. Resumed sessions keep the cwd stored in the persisted session header.
@@ -45,7 +47,7 @@ Fresh stdio sessions use the process launch directory as `session.header.cwd`, s
 ## Example leaf `cordis.yml`
 
 ```yaml
-# A REPL agent demo: hmr + the DeepSeek adapter + local bash, then this app.
+# A coding-agent demo: hmr + the DeepSeek adapter + local bash, then this app.
 - id: hmr
   name: '@cordisjs/plugin-hmr'
   config:
@@ -64,6 +66,8 @@ Fresh stdio sessions use the process launch directory as `session.header.cwd`, s
   config:
     model: deepseek-v4-flash
     persona: 'You are a coding assistant powered by the {{model}} model.'
+    ui:
+      mode: auto
 ```
 
 Swap `llm-deepseek` for a `mock-llm` leaf plugin and you have the echo demo — "swap the backend, keep the app".
@@ -72,9 +76,9 @@ Swap `llm-deepseek` for a `mock-llm` leaf plugin and you have the echo demo —
 
 ### Composed terminal agent request
 
-**What the model sees**: Through `dsh-agent-spine-demo`, the `main` agent receives the harness identity, configured persona, skill catalog, and visible tools; this app also composes the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user). Each readline submission becomes a user message.
+**What the model sees**: Through `dsh-agent-spine-demo`, the `main` agent receives the harness identity, configured persona, skill catalog, and visible tools; this app also composes the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user). Each terminal submission becomes a user message; submissions made while the agent runs steer the active turn.
 
-**Token effect**: Child prompt and schema costs repeat per request; user input and tool history grow until compaction. The welcome banner, logger output, and rendered transcript are terminal-only and add zero model tokens.
+**Token effect**: Child prompt and schema costs repeat per request; user input and tool history grow until compaction. The TUI/readline banners and rendered transcripts are terminal-only and add zero model tokens.
 
 ### Human-answer result
 
@@ -84,6 +88,6 @@ Swap `llm-deepseek` for a `mock-llm` leaf plugin and you have the echo demo —
 
 ## Known Limitations and Deferred Work
 
-- **One pre-created `main` agent drives the readline UI** — there is no multi-session or concurrent-agent surface in this app; a run is one conversation.
+- **One pre-created `main` agent drives the terminal UI** — there is no multi-session or concurrent-agent surface in this app; a run is one conversation.
 - **The front-door cluster is fixed in code** — the JSONL persistence backend and the ask-user tooling are baked; a different composition is a leaf-level sibling entry or another app package.
 - **The question tool is not an approval answerer** — this app mounts `user-interaction` and `ask_user_question`, but not `ctx.approval`; a `tools/pre-execute` `ask` therefore fails closed unless the leaf composes an approval service and terminal answerer.

+ 5 - 3
packages/examples/stdio-demo/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-stdio-demo",
-  "description": "Terminal stdio chat app: the agent-spine-demo bundle + console logger + readline UI + a pre-created main agent, with a bin to boot a leaf cordis.yml",
+  "description": "Terminal chat app: agent spine + JSONL persistence + TTY pi-tui/readline front-door selection + pre-created main agent",
   "version": "0.0.1",
   "private": true,
   "type": "module",
@@ -32,14 +32,15 @@
   "peerDependencies": {
     "@cordisjs/plugin-include": "^1.0.4",
     "@cordisjs/plugin-loader": "^1.0.0-rc.5",
-    "@deepseek-ai/dsh-app-boot": "^0.0.1",
     "@cordisjs/plugin-logger-console": "^1.0.0",
+    "@deepseek-ai/dsh-app-boot": "^0.0.1",
     "@deepseek-ai/dsh-agent": "^0.0.1",
     "@deepseek-ai/dsh-llm": "^0.0.1",
     "@deepseek-ai/dsh-agent-spine-demo": "^0.0.1",
     "@deepseek-ai/dsh-session": "^0.0.1",
     "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
     "@deepseek-ai/dsh-stdio": "^0.0.1",
+    "@deepseek-ai/dsh-tui": "^0.0.1",
     "@deepseek-ai/dsh-tool-ask-user": "^0.0.1",
     "@deepseek-ai/dsh-tools": "^0.0.1",
     "@deepseek-ai/dsh-user-interaction": "^0.0.1",
@@ -49,8 +50,8 @@
   "devDependencies": {
     "@cordisjs/plugin-include": "workspace:^",
     "@cordisjs/plugin-loader": "workspace:^",
-    "@deepseek-ai/dsh-app-boot": "workspace:^",
     "@cordisjs/plugin-logger-console": "workspace:^",
+    "@deepseek-ai/dsh-app-boot": "workspace:^",
     "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-agent-spine-demo": "workspace:^",
@@ -58,6 +59,7 @@
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
     "@deepseek-ai/dsh-stdio": "workspace:^",
+    "@deepseek-ai/dsh-tui": "workspace:^",
     "@deepseek-ai/dsh-tool-ask-user": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-user-interaction": "workspace:^",

+ 1 - 1
packages/examples/stdio-demo/src/bin.ts

@@ -2,7 +2,7 @@
 /**
  * Boot a stdio app from a leaf `cordis.yml`; usage is `dsh-stdio-demo [config]`, defaulting to the
  * cwd file. Shared `.env` loading, fail-loud Loader guards, and settled-tree boot live in
- * dsh-app-boot. The echo and REPL demos invoke this bin with their own leaf configs.
+ * dsh-app-boot. The echo and coding-agent demos invoke this bin with their own leaf configs.
  * @module @deepseek-ai/dsh-stdio-demo/bin
  */
 

+ 73 - 13
packages/examples/stdio-demo/src/index.ts

@@ -1,8 +1,9 @@
 /**
  * The stdio chat app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}) plus the
- * coupled front-door cluster a terminal chat needs — a console logger, the independently
- * packaged readline UI, JSONL session persistence, the user-interaction seam with its
- * `ask_user_question` tool, and a pre-created `main` agent the UI drives.
+ * coupled front-door cluster a terminal chat needs — the independently packaged
+ * pi-tui and readline front doors, JSONL session persistence, the user-interaction
+ * seam with its `ask_user_question` tool, and a pre-created `main` agent the UI
+ * drives. Interactive terminals use `dsh-tui`; pipes use `dsh-stdio` plus logging.
  * Swappable adapters, executors, optional tools, and HMR stay in the leaf. This
  * Loader plugin intentionally exposes named exports only; a default export
  * would hide its `Config` schema (see docs/postmortem/0001).
@@ -20,9 +21,45 @@ import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
 import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
 import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
 import * as uiStdio from '@deepseek-ai/dsh-stdio'
+import * as uiTui from '@deepseek-ai/dsh-tui'
 
 export const name = 'stdio-demo'
 
+/** Terminal front door selected by the app bundle. */
+export type TerminalMode = 'auto' | 'readline' | 'tui'
+
+/** App-level terminal selection with nested TUI presentation settings. */
+export interface UiConfig {
+  /** Select a concrete front door or infer it from the process streams. */
+  mode?: TerminalMode
+  /** Settings forwarded only when the pi-tui front door is selected. */
+  tui?: uiTui.TuiConfig
+}
+
+const terminalModeSchema = z.union(['auto', 'readline', 'tui'] as const).default('auto')
+
+/** Schemastery schema for the app-level terminal selection. */
+export const UiConfigSchema: z<UiConfig> = z.object({
+  mode: terminalModeSchema,
+  tui: uiTui.TuiConfigSchema,
+})
+
+/**
+ * Resolve the app's terminal front door.
+ *
+ * @param config - App-level terminal selection.
+ * @param isTTY - Whether both process streams are interactive TTYs.
+ * @returns The concrete UI package to mount.
+ */
+export function resolveTerminalMode(config: UiConfig | undefined, isTTY: boolean): Exclude<TerminalMode, 'auto'> {
+  const mode = config?.mode ?? 'auto'
+  if (mode === 'auto') return isTTY ? 'tui' : 'readline'
+  if (mode === 'tui' && !isTTY) {
+    throw new Error('stdio-demo: TUI mode requires both stdin and stdout to be TTYs; use mode "readline" for pipes')
+  }
+  return mode
+}
+
 /**
  * App config: the swappable per-demo values, each routed to where the app wires
  * it. `model`/`resumeSessionId` configure the pre-created `main` agent (through
@@ -31,7 +68,7 @@ export const name = 'stdio-demo'
  * is the explicit model-facing tool order (forwarded to the system-prompt plugin);
  * fresh sessions use `process.cwd()` as their workspace cwd; resumed sessions
  * keep their persisted cwd. `persistenceRoot` is the JSONL backend's directory;
- * `welcome` is the UI banner.
+ * `welcome` is the UI banner and `ui` configures terminal mode/presentation.
  */
 export interface Config {
   /** Model name for the `main` agent (must have a registered adapter). */
@@ -44,8 +81,10 @@ export interface Config {
   tools?: ToolsConfig
   /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
   persistenceRoot?: string
-  /** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
+  /** Terminal banner printed once on start. Defaults to `'ready.'`. */
   welcome?: string
+  /** Terminal front-door selection and pi-tui presentation settings. */
+  ui?: UiConfig
   /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */
   skills?: agentCore.SkillConfig
   /** Model-facing bash tool config forwarded through agent-core. */
@@ -72,6 +111,7 @@ export const Config: z<Config> = z.object({
   // apply() fallbacks through named constants while retaining both boundaries.
   persistenceRoot: z.string().default('./.sessions'),
   welcome: z.string().default('ready.'),
+  ui: UiConfigSchema,
   skills: agentCore.SkillConfigSchema,
   toolBash: agentCore.ToolBashConfigSchema,
   toolTasks: agentCore.ToolTasksConfigSchema,
@@ -79,14 +119,18 @@ export const Config: z<Config> = z.object({
 })
 
 /**
- * Compose the spine with the stdio front door. The console logger comes first
- * (infra), then the agent-spine-demo bundle pre-creating the `main` agent from this
- * app's `model`/`resumeSessionId` with the deployment `persona`, then the JSONL
- * backend, then the readline UI bound to `main`. The `hmr` dev-reload plugin is
- * a leaf concern (see the module doc), so it is not mounted here.
+ * Compose the spine with one terminal front door. Interactive TTY pairs mount
+ * `dsh-tui` without a console exporter; pipes mount the readline `dsh-stdio`
+ * channel with the console logger. The `hmr` dev-reload plugin remains a leaf
+ * concern.
+ *
+ * @param ctx - Context receiving the app's child plugins.
+ * @param config - App configuration routed to the spine and front door.
+ * @param isTTY - Whether both terminal streams are interactive TTYs.
  */
-export function apply(ctx: Context, config: Config): void {
-  ctx.plugin(ConsoleExporter)
+export function composeTerminalApp(ctx: Context, config: Config, isTTY: boolean): void {
+  const mode = resolveTerminalMode(config.ui, isTTY)
+  if (mode === 'readline') ctx.plugin(ConsoleExporter)
   ctx.plugin(agentCore, {
     ...config.persona !== undefined ? { persona: config.persona } : {},
     ...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
@@ -104,5 +148,21 @@ export function apply(ctx: Context, config: Config): void {
   ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
   ctx.plugin(UserInteractionService)
   ctx.plugin(toolAskUser)
-  ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.', agent: 'main' })
+  if (mode === 'tui') {
+    ctx.plugin(uiTui, {
+      ...config.ui?.tui,
+      welcome: config.welcome ?? 'ready.',
+      agent: 'main',
+    })
+  } else {
+    ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.', agent: 'main' })
+  }
+}
+
+/** Compose the configured terminal front door with the agent app. */
+/* v8 ignore start -- production stream capability wiring; composeTerminalApp is unit-covered,
+   and the coding-agent PTY smoke covers the interactive process path */
+export function apply(ctx: Context, config: Config): void {
+  composeTerminalApp(ctx, config, process.stdin.isTTY && process.stdout.isTTY)
 }
+/* v8 ignore stop */

+ 43 - 2
packages/examples/stdio-demo/tests/stdio-agent.spec.ts

@@ -10,8 +10,8 @@ import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
 import * as stdioAgent from '../src/index.ts'
 
 /**
- * Unit coverage for app composition and config forwarding: console logger, pre-created main agent,
- * agent-core spine, JSONL backend, and readline UI. HMR is a Loader-only leaf concern covered by the
+ * Unit coverage for app composition and config forwarding: pre-created main agent, agent-core spine,
+ * JSONL backend, and adaptive terminal UI. HMR is a Loader-only leaf concern covered by the
  * keyless echo smoke; this tier pins the export shape because an inject-less app could otherwise
  * survive namespace collapse while silently losing its schema.
  */
@@ -65,6 +65,47 @@ async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
 }
 
 describe('dsh-stdio-demo app', () => {
+  it('selects readline for pipes and dsh-tui for interactive terminal pairs', () => {
+    expect(stdioAgent.resolveTerminalMode(undefined, false)).toBe('readline')
+    expect(stdioAgent.resolveTerminalMode(undefined, true)).toBe('tui')
+    expect(stdioAgent.resolveTerminalMode({ mode: 'readline' }, true)).toBe('readline')
+    expect(stdioAgent.resolveTerminalMode({ mode: 'tui' }, true)).toBe('tui')
+    expect(() => stdioAgent.resolveTerminalMode({ mode: 'tui' }, false)).toThrow('requires both stdin and stdout')
+  })
+
+  it('composes only the selected terminal package and keeps TUI settings in dsh-tui', () => {
+    const calls: Array<{ name: string; config: unknown }> = []
+    const ctx = {
+      plugin(plugin: { name?: string }, config?: unknown) {
+        calls.push({ name: plugin.name ?? '', config })
+      },
+    } as unknown as Context
+
+    stdioAgent.composeTerminalApp(ctx, {
+      model: 'mock',
+      welcome: 'TUI ready',
+      ui: { mode: 'tui', tui: { color: false, maxToolOutputLines: 3 } },
+    }, true)
+    expect(calls.map(call => call.name)).toContain('ui-tui')
+    expect(calls.map(call => call.name)).not.toContain('ui-stdio')
+    expect(calls.map(call => call.name)).not.toContain('ConsoleExporter')
+    expect(calls.find(call => call.name === 'ui-tui')?.config).toMatchObject({
+      agent: 'main', welcome: 'TUI ready', color: false, maxToolOutputLines: 3,
+    })
+
+    calls.length = 0
+    stdioAgent.composeTerminalApp(ctx, { model: 'mock', ui: { mode: 'tui' } }, true)
+    expect(calls.find(call => call.name === 'ui-tui')?.config).toMatchObject({
+      agent: 'main', welcome: 'ready.',
+    })
+
+    calls.length = 0
+    stdioAgent.composeTerminalApp(ctx, { model: 'mock', ui: { mode: 'readline' } }, false)
+    expect(calls.map(call => call.name)).toContain('ui-stdio')
+    expect(calls.map(call => call.name)).toContain('ConsoleExporter')
+    expect(calls.map(call => call.name)).not.toContain('ui-tui')
+  })
+
   it('composes the spine + front-door cluster and pre-creates the main agent', async () => {
     const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-demo-spec', skills: await isolatedSkillsConfig() })
     // The spine services (brought up by the agent-core bundle) are all present.

+ 3 - 0
packages/examples/stdio-demo/tsconfig.json

@@ -38,6 +38,9 @@
     {
       "path": "../../ui/stdio"
     },
+    {
+      "path": "../../ui/tui"
+    },
     {
       "path": "../../ui/tool-ask-user"
     },

+ 1 - 1
packages/todo/README.md

@@ -6,4 +6,4 @@ The model-facing todo tool. A single **product** package — there is no interfa
 |---|---|---|
 | `tool-todo/` | Model-facing `todo_write` tool; writes the whole list to the session log (`todo/write`) | (registers on `ctx.tools`) |
 
-The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs render off `session/event`: the [stdio app's readline UI](../examples/stdio-demo) prints the list, the [ACP bridge](../ui/acp) maps it to a `plan` sessionUpdate.
+The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs render off `session/event`: the [terminal app](../examples/stdio-demo) shows a persistent TUI plan or readline checklist, while the [ACP bridge](../ui/acp) maps it to a `plan` sessionUpdate.

+ 1 - 1
packages/todo/tool-todo/README.md

@@ -18,7 +18,7 @@ Beyond the schema's type/required/enum checks, `execute` rejects an empty or dup
 
 ## Rendering
 
-The tool writes only the session event; it does not render. UIs subscribe to `session/event` and render the `todo/write` data themselves: the [stdio app's readline UI](../../examples/stdio-demo) prints a glyphed checklist, and the [ACP bridge](../../ui/acp) maps the list to a `plan` sessionUpdate (synthesizing the `priority` ACP requires).
+The tool writes only the session event; it does not render. UIs subscribe to `session/event` and render the `todo/write` data themselves: the [terminal app](../../examples/stdio-demo) shows a persistent TUI plan or readline checklist, and the [ACP bridge](../../ui/acp) maps the list to a `plan` sessionUpdate (synthesizing the `priority` ACP requires).
 
 ## Export shape
 

+ 4 - 3
packages/ui/README.md

@@ -9,12 +9,13 @@ Integrations that expose the agent to an external editor or client. These are **
 | `permission/` | User-facing permission presets (`workspace-write`/`danger-full-access`): one product-level select bundling the sandbox-mode and approval-policy knobs, written through to their session events | `ctx.permission` |
 | `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` |
 | `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) |
-| `stdio/` | Terminal readline channel over `ctx.agents`, `session/event`, and `ctx.userInteraction`; agent lifecycle stays with app/developer code | (drives `ctx.agents`) |
+| `stdio/` | Line-oriented terminal channel for pipes and automation; drives `ctx.agents`, renders `session/event`, and answers `ctx.userInteraction` | (drives `ctx.agents`) |
+| `tui/` | Interactive pi-tui terminal channel for TTY sessions; renders `session/event`, tool presentation intents, and answers `ctx.userInteraction` | (drives `ctx.agents`) |
 | `jsonrpc/` | Stdio JSON-RPC server for out-of-process SDK clients | (drives `ctx.agents`) |
 | `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) |
 
-A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). The [`stdio`](stdio/README.md) plugin is the unstructured readline analogue of the `acp` bridge; app bundles and SDK projects compose it explicitly with the services and tools their product profile selects.
+A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). The [`stdio`](stdio/README.md) and [`tui`](tui/README.md) plugins are the two terminal front doors: one is line-oriented for pipes, the other is interactive for TTYs. App bundles and SDK projects compose the appropriate channel explicitly with the services and tools their product profile selects.
 
 `user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with their UI channel owners. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and the app/bridge packages provide concrete providers.
 
-The runnable app bundles that bake these bridges into boot bins — the stdio chat app, the ACP server app, and the JSON-RPC SDK-runtime bin — live in [`examples/`](../examples/README.md) (`stdio-demo`, `acp-demo`, `jsonrpc-demo`), each composed over the [`agent-spine-demo`](../examples/agent-spine-demo/README.md) bundle. `ui/` keeps the reusable bridge/channel plugins and the `app-boot` glue; each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools.
+The runnable app bundles that bake these bridges into boot bins — the terminal chat app, the ACP server app, and the JSON-RPC SDK-runtime bin — live in [`examples/`](../examples/README.md) (`stdio-demo`, `acp-demo`, `jsonrpc-demo`), each composed over the [`agent-spine-demo`](../examples/agent-spine-demo/README.md) bundle. `ui/` keeps the reusable bridge/channel plugins and the `app-boot` glue; each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools.

+ 1 - 1
packages/ui/acp/README.md

@@ -2,7 +2,7 @@
 
 Agent Client Protocol bridge over JSON-RPC stdio. Editors can create or resume agents, stream their events, answer questions and approvals, and render tool calls. One connection supports multiple isolated sessions; Zed is the primary compatibility target.
 
-It is a **client-driver / UI plugin**, the structured analogue of the readline `stdio-chat` plugin — NOT a loop change and NOT a [capability seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`.
+It is a **client-driver / UI plugin**, the structured analogue of the terminal `dsh-tui`/`dsh-stdio` channels — NOT a loop change and NOT a [capability seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`.
 
 ## Service / plugin
 

+ 56 - 0
packages/ui/tui/README.md

@@ -0,0 +1,56 @@
+# @deepseek-ai/dsh-tui
+
+The interactive terminal front door for DeepSeek Harness agents, built on [`@earendil-works/pi-tui`](https://www.npmjs.com/package/@earendil-works/pi-tui). It requires stdin and stdout TTYs; scripts and Loader pipes should compose [`@deepseek-ai/dsh-stdio`](../stdio/README.md) instead.
+
+This package owns interactive terminal presentation and input only. It injects `agents`, `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries.
+
+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 latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions as keyboard-driven overlays. Surface replacement events rebuild the transcript so compacted history does not reappear.
+
+While the agent is running, editor submissions call `agent.steer()`; otherwise they call `agent.send()`. Ctrl+C or Escape cancels a running turn. Ctrl+O expands tool cards, Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/help`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` provide the same actions without key chords.
+
+## Config
+
+| Key | Default | Meaning |
+|---|---|---|
+| `welcome` | `ready.` | Header subtitle |
+| `agent` | `main` | Agent id driven by the terminal |
+| `showReasoning` | `true` | Render reasoning blocks |
+| `maxToolOutputLines` | `12` | Collapsed tool-card output limit |
+| `maxQuestionOptions` | `8` | Visible options in a question overlay |
+| `questionDialogWidth` | `72` | Question-overlay width in columns |
+| `questionDialogMaxHeight` | `20` | Question-overlay maximum rows |
+| `showHardwareCursor` | `false` | Show the hardware cursor at pi-tui's IME marker |
+| `color` | `true` | Apply the built-in ANSI palette |
+| `title` | `DeepSeek Harness` | Terminal window title |
+
+```yaml
+- id: terminal
+  name: '@deepseek-ai/dsh-tui'
+  config:
+    welcome: 'Coding agent ready.'
+    agent: main
+    showReasoning: true
+    maxToolOutputLines: 12
+```
+
+Startup fails before mounting when either process stream is not a TTY. Disposal 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.
+
+## Model Experience
+
+### Interactive prompt input
+
+**What the model sees**: Each non-empty editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. Slash commands and keybindings are TUI-only.
+
+**Token effect**: Submitted text is retained under the agent loop's normal session-history and compaction rules. Headers, cards, Markdown rendering, status lines, plans, and help text add no tokens.
+
+### 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.
+
+## Known Limitations and Deferred Work
+
+- **One configured agent 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 `agent`.
+- **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 select `dsh-stdio` before mounting this plugin rather than expecting an internal fallback.

+ 45 - 0
packages/ui/tui/package.json

@@ -0,0 +1,45 @@
+{
+  "name": "@deepseek-ai/dsh-tui",
+  "description": "Interactive pi-tui terminal front door for DeepSeek Harness agents",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ],
+  "license": "BSD-3-Clause",
+  "peerDependencies": {
+    "@deepseek-ai/dsh-agent": "^0.0.1",
+    "@deepseek-ai/dsh-llm": "^0.0.1",
+    "@deepseek-ai/dsh-session": "^0.0.1",
+    "@deepseek-ai/dsh-tools": "^0.0.1",
+    "@deepseek-ai/dsh-user-interaction": "^0.0.1",
+    "cordis": "^4.0.0-rc.6"
+  },
+  "dependencies": {
+    "@earendil-works/pi-tui": "0.80.7",
+    "schemastery": "^3.18.0"
+  },
+  "devDependencies": {
+    "@cordisjs/plugin-loader": "workspace:^",
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^",
+    "@deepseek-ai/dsh-user-interaction": "workspace:^",
+    "cordis": "^4.0.0-rc.6"
+  }
+}

+ 1284 - 0
packages/ui/tui/src/index.ts

@@ -0,0 +1,1284 @@
+/**
+ * Interactive pi-tui front door for DeepSeek Harness agents. It renders the
+ * durable session transcript, drives one configured agent, and provides
+ * keyboard-driven user-interaction dialogs without owning agent lifecycle.
+ * @module @deepseek-ai/dsh-tui
+ */
+
+import { homedir } from 'node:os'
+import { relative, resolve, sep } from 'node:path'
+import {
+  Box,
+  CombinedAutocompleteProvider,
+  Container,
+  Editor,
+  Input,
+  Key,
+  Loader,
+  Markdown,
+  Spacer,
+  Text,
+  TUI,
+  ProcessTerminal,
+  matchesKey,
+  truncateToWidth,
+  visibleWidth,
+  wrapTextWithAnsi,
+  type Component,
+  type EditorTheme,
+  type Focusable,
+  type MarkdownTheme,
+  type OverlayHandle,
+  type SelectListTheme,
+  type Terminal,
+} from '@earendil-works/pi-tui'
+import type { Context } from 'cordis'
+import z from 'schemastery'
+import { AgentId, type Agent, type AgentStatus } from '@deepseek-ai/dsh-agent'
+import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
+import type { Session, SessionEvent, TodoItem } from '@deepseek-ai/dsh-session'
+import type {
+  FileDiff,
+  TerminalCallView,
+  ToolCallView,
+  ToolDefinition,
+  ToolResultView,
+} from '@deepseek-ai/dsh-tools'
+import {
+  UserInteractionError,
+  type AskUserQuestionAnswer,
+  type AskUserQuestionAnswerItem,
+  type AskUserQuestionItem,
+  type AskUserQuestionRequest,
+} from '@deepseek-ai/dsh-user-interaction'
+
+export const name = 'ui-tui'
+export const inject = ['agents', 'userInteraction', 'tools']
+
+/** Presentation settings for the pi-tui terminal mode. */
+export interface TuiConfig {
+  /** Render model reasoning blocks. */
+  showReasoning?: boolean
+  /** Maximum tool-output lines shown before the card is collapsed. */
+  maxToolOutputLines?: number
+  /** Maximum options visible at once in a user-question dialog. */
+  maxQuestionOptions?: number
+  /** User-question dialog width in terminal columns. */
+  questionDialogWidth?: number
+  /** User-question dialog maximum height in terminal rows. */
+  questionDialogMaxHeight?: number
+  /** Show the terminal's hardware cursor at the pi editor's IME marker. */
+  showHardwareCursor?: boolean
+  /** Apply the built-in ANSI color palette. */
+  color?: boolean
+  /** Terminal window title while the UI is mounted. */
+  title?: string
+}
+
+const showReasoningSchema = z.boolean().default(true)
+const maxToolOutputLinesSchema = z.number().step(1).min(1).default(12)
+const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8)
+const questionDialogWidthSchema = z.number().step(1).min(20).default(72)
+const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
+const showHardwareCursorSchema = z.boolean().default(false)
+const colorSchema = z.boolean().default(true)
+const titleSchema = z.string().default('DeepSeek Harness')
+
+/** Schemastery schema for presentation settings embedded by app bundles. */
+export const TuiConfigSchema: z<TuiConfig> = z.object({
+  showReasoning: showReasoningSchema,
+  maxToolOutputLines: maxToolOutputLinesSchema,
+  maxQuestionOptions: maxQuestionOptionsSchema,
+  questionDialogWidth: questionDialogWidthSchema,
+  questionDialogMaxHeight: questionDialogMaxHeightSchema,
+  showHardwareCursor: showHardwareCursorSchema,
+  color: colorSchema,
+  title: titleSchema,
+})
+
+/** Serializable plugin configuration. */
+export interface Config extends TuiConfig {
+  /** Header subtitle. Defaults to `ready.`. */
+  welcome?: string
+  /** Agent id driven by this terminal. Defaults to `main`. */
+  agent?: string
+}
+
+export const Config: z<Config> = z.object({
+  welcome: z.string().default('ready.'),
+  agent: z.string().default('main'),
+  showReasoning: showReasoningSchema,
+  maxToolOutputLines: maxToolOutputLinesSchema,
+  maxQuestionOptions: maxQuestionOptionsSchema,
+  questionDialogWidth: questionDialogWidthSchema,
+  questionDialogMaxHeight: questionDialogMaxHeightSchema,
+  showHardwareCursor: showHardwareCursorSchema,
+  color: colorSchema,
+  title: titleSchema,
+})
+
+/** Fully defaulted TUI presentation settings. */
+export interface ResolvedTuiConfig {
+  showReasoning: boolean
+  maxToolOutputLines: number
+  maxQuestionOptions: number
+  questionDialogWidth: number
+  questionDialogMaxHeight: number
+  showHardwareCursor: boolean
+  color: boolean
+  title: string
+}
+
+/** Runtime boundary used by the interactive TUI. */
+export interface TuiRuntime {
+  /** Terminal implementation; production uses pi-tui's `ProcessTerminal`. */
+  terminal: Terminal
+  /** Exit hook used by `/exit`, Ctrl+D, or Ctrl+C while idle. */
+  exit(code: number): void
+}
+
+/**
+ * Apply direct-call defaults after Loader schema validation has normally run.
+ *
+ * @param config - Deployment-provided terminal presentation settings.
+ * @returns Complete settings consumed by the TUI renderer.
+ */
+export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConfig {
+  return {
+    showReasoning: config?.showReasoning ?? true,
+    maxToolOutputLines: config?.maxToolOutputLines ?? 12,
+    maxQuestionOptions: config?.maxQuestionOptions ?? 8,
+    questionDialogWidth: config?.questionDialogWidth ?? 72,
+    questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20,
+    showHardwareCursor: config?.showHardwareCursor ?? false,
+    color: config?.color ?? true,
+    title: config?.title ?? 'DeepSeek Harness',
+  }
+}
+
+interface Palette {
+  accent: (text: string) => string
+  accent2: (text: string) => string
+  text: (text: string) => string
+  muted: (text: string) => string
+  dim: (text: string) => string
+  success: (text: string) => string
+  warning: (text: string) => string
+  error: (text: string) => string
+  code: (text: string) => string
+  added: (text: string) => string
+  removed: (text: string) => string
+  bold: (text: string) => string
+  italic: (text: string) => string
+  underline: (text: string) => string
+  strike: (text: string) => string
+  userBg: (text: string) => string
+  pendingBg: (text: string) => string
+  successBg: (text: string) => string
+  errorBg: (text: string) => string
+  selectedBg: (text: string) => string
+}
+
+function ansi(open: string, close: string, enabled: boolean): (text: string) => string {
+  return enabled ? text => `\x1b[${open}m${text}\x1b[${close}m` : text => text
+}
+
+function createPalette(enabled: boolean): Palette {
+  return {
+    accent: ansi('38;5;39', '39', enabled),
+    accent2: ansi('38;5;141', '39', enabled),
+    text: ansi('38;5;252', '39', enabled),
+    muted: ansi('38;5;245', '39', enabled),
+    dim: ansi('38;5;240', '39', enabled),
+    success: ansi('38;5;78', '39', enabled),
+    warning: ansi('38;5;221', '39', enabled),
+    error: ansi('38;5;203', '39', enabled),
+    code: ansi('38;5;215', '39', enabled),
+    added: ansi('38;5;78', '39', enabled),
+    removed: ansi('38;5;203', '39', enabled),
+    bold: ansi('1', '22', enabled),
+    italic: ansi('3', '23', enabled),
+    underline: ansi('4', '24', enabled),
+    strike: ansi('9', '29', enabled),
+    userBg: ansi('48;5;236', '49', enabled),
+    pendingBg: ansi('48;5;235', '49', enabled),
+    successBg: ansi('48;5;22', '49', enabled),
+    errorBg: ansi('48;5;52', '49', enabled),
+    selectedBg: ansi('48;5;24', '49', enabled),
+  }
+}
+
+function markdownTheme(palette: Palette): MarkdownTheme {
+  return {
+    heading: text => palette.accent(text),
+    link: text => palette.accent(text),
+    // pi-tui requires this URL slot but its current Markdown renderer does not invoke it.
+    /* v8 ignore next */
+    linkUrl: text => palette.dim(text),
+    code: text => palette.code(text),
+    codeBlock: text => palette.text(text),
+    codeBlockBorder: text => palette.dim(text),
+    quote: text => palette.muted(text),
+    quoteBorder: text => palette.accent2(text),
+    hr: text => palette.dim(text),
+    listBullet: text => palette.accent(text),
+    bold: text => palette.bold(text),
+    italic: text => palette.italic(text),
+    strikethrough: text => palette.strike(text),
+    underline: text => palette.underline(text),
+  }
+}
+
+function selectTheme(palette: Palette): SelectListTheme {
+  return {
+    selectedPrefix: palette.accent,
+    selectedText: palette.accent,
+    description: palette.muted,
+    scrollInfo: palette.dim,
+    noMatch: palette.warning,
+  }
+}
+
+function contentText(content: readonly ContentBlock[]): string {
+  const parts: string[] = []
+  for (const block of content) {
+    switch (block.type) {
+      case 'text':
+      case 'reasoning':
+        parts.push(block.text)
+        break
+      case 'tool-call':
+        parts.push(`${block.name}(${block.arguments})`)
+        break
+      case 'tool-result':
+        parts.push(contentText(block.content))
+        break
+      default: {
+        const rawType = (block as { type?: unknown }).type
+        parts.push(`[${typeof rawType === 'string' ? rawType : 'content'}]`)
+        break
+      }
+    }
+  }
+  return parts.join('')
+}
+
+function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning'): string {
+  return content
+    .filter((block): block is Extract<ContentBlock, { type: typeof type }> => block.type === type)
+    .map(block => block.text)
+    .join('\n\n')
+}
+
+class HeaderComponent implements Component {
+  constructor(
+    private readonly agent: Agent,
+    private readonly welcome: string,
+    private readonly palette: Palette,
+  ) {}
+
+  invalidate(): void {}
+
+  render(width: number): string[] {
+    const usable = Math.max(1, width - 4)
+    const title = `${this.palette.bold(this.palette.accent('DEEPSEEK'))} ${this.palette.bold('HARNESS')}`
+    const model = this.agent.options.model ?? 'model unset'
+    const detail = `${this.agent.id}  •  ${model}  •  ${this.agent.session.id}`
+    const top = this.palette.accent(`╭${'─'.repeat(Math.max(0, width - 2))}╮`)
+    const bottom = this.palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`)
+    const lines = [title, this.palette.muted(this.welcome), this.palette.dim(detail)]
+      .flatMap(line => wrapTextWithAnsi(line, usable))
+      .map((line) => {
+        const clipped = truncateToWidth(line, usable, '')
+        return `${this.palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, usable - visibleWidth(clipped)))} ${this.palette.accent('│')}`
+      })
+    return [top, ...lines, bottom]
+  }
+}
+
+class UserMessageComponent extends Box {
+  constructor(text: string, palette: Palette, mdTheme: MarkdownTheme, label = 'You') {
+    super(1, 1, value => palette.userBg(value))
+    this.addChild(new Text(palette.bold(palette.accent(label)), 0, 0))
+    this.addChild(new Markdown(text, 0, 0, mdTheme, { color: value => palette.text(value) }, {
+      preserveOrderedListMarkers: true,
+      preserveBackslashEscapes: true,
+    }))
+  }
+}
+
+class AssistantMessageComponent extends Container {
+  constructor(content: readonly ContentBlock[], showReasoning: boolean, palette: Palette, mdTheme: MarkdownTheme) {
+    super()
+    const reasoning = textBlocks(content, 'reasoning').trim()
+    const text = textBlocks(content, 'text').trim()
+    if (reasoning && showReasoning) {
+      this.addChild(new Spacer(1))
+      this.addChild(new Text(palette.italic(palette.muted('Reasoning')), 1, 0))
+      this.addChild(new Markdown(reasoning, 1, 0, mdTheme, {
+        color: value => palette.muted(value),
+        italic: true,
+      }))
+    }
+    if (text) {
+      this.addChild(new Spacer(1))
+      this.addChild(new Text(palette.bold(palette.accent2('Assistant')), 1, 0))
+      this.addChild(new Markdown(text, 1, 0, mdTheme, { color: value => palette.text(value) }))
+    }
+  }
+}
+
+interface StreamingBlock {
+  type: string
+  text: string
+}
+
+class StreamingAssistantComponent extends Container {
+  private readonly blocks = new Map<number, StreamingBlock>()
+
+  constructor(
+    private showReasoning: boolean,
+    private readonly palette: Palette,
+    private readonly mdTheme: MarkdownTheme,
+  ) {
+    super()
+  }
+
+  update(chunk: StreamChunk): void {
+    if (chunk.type === 'block-start') {
+      this.blocks.set(chunk.index, { type: chunk.blockType, text: '' })
+    } else if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') {
+      const type = chunk.type === 'text-delta' ? 'text' : 'reasoning'
+      const block = this.blocks.get(chunk.index) ?? { type, text: '' }
+      block.text += chunk.text
+      this.blocks.set(chunk.index, block)
+    } else if (chunk.type === 'block-end' && (chunk.block.type === 'text' || chunk.block.type === 'reasoning')) {
+      this.blocks.set(chunk.index, { type: chunk.block.type, text: chunk.block.text })
+    }
+    this.rebuild()
+  }
+
+  setShowReasoning(show: boolean): void {
+    this.showReasoning = show
+    this.rebuild()
+  }
+
+  private rebuild(): void {
+    this.clear()
+    const content: ContentBlock[] = [...this.blocks.entries()]
+      .sort(([left], [right]) => left - right)
+      .flatMap<ContentBlock>(([, block]) => {
+        if (block.type === 'text') return [{ type: 'text', text: block.text }]
+        if (block.type === 'reasoning') return [{ type: 'reasoning', text: block.text }]
+        return []
+      })
+    const component = new AssistantMessageComponent(content, this.showReasoning, this.palette, this.mdTheme)
+    for (const child of component.children) this.addChild(child)
+  }
+}
+
+interface ParsedArguments {
+  value: unknown
+  valid: boolean
+}
+
+function parseArguments(raw: string): ParsedArguments {
+  try {
+    return { value: JSON.parse(raw), valid: true }
+  } catch {
+    return { value: raw, valid: false }
+  }
+}
+
+function pretty(value: unknown): string {
+  if (typeof value === 'string') return value
+  // The lib declaration narrows `unknown` to a string-returning overload, but
+  // JSON.stringify returns undefined for runtime values such as symbols.
+  const serialized = JSON.stringify(value, null, 2) as string | undefined
+  return serialized ?? String(value)
+}
+
+function diffLines(diff: FileDiff, palette: Palette): string[] {
+  const lines = [palette.bold(diff.path)]
+  if (diff.oldText !== null) {
+    for (const line of diff.oldText.split('\n')) lines.push(palette.removed(`- ${line}`))
+  }
+  for (const line of diff.newText.split('\n')) lines.push(palette.added(`+ ${line}`))
+  return lines
+}
+
+class ToolCardComponent implements Component {
+  private result: { content: ContentBlock[]; isError: boolean; meta?: unknown } | undefined
+  private expanded = false
+  private callView: ToolCallView
+  private resultView: ToolResultView | undefined
+
+  constructor(
+    private readonly name: string,
+    private readonly parsed: ParsedArguments,
+    private readonly definition: ToolDefinition | undefined,
+    private readonly maxOutputLines: number,
+    private readonly palette: Palette,
+  ) {
+    this.callView = this.presentCall()
+  }
+
+  private presentCall(): ToolCallView {
+    if (this.parsed.valid && this.definition?.presentCall) {
+      try {
+        const view = this.definition.presentCall(this.parsed.value)
+        if (view !== undefined) return view
+      } catch (error: unknown) {
+        return { card: 'generic', title: this.name, rawInput: `Presenter failed: ${String(error)}` }
+      }
+    }
+    return { card: 'generic', title: this.name, rawInput: this.parsed.value }
+  }
+
+  updateResult(event: Extract<SessionEvent, { type: 'tool/result' }>['data']): void {
+    this.result = {
+      content: [...event.content],
+      isError: event.isError,
+      ...event.meta !== undefined ? { meta: event.meta } : {},
+    }
+    if (this.parsed.valid && this.definition?.presentResult) {
+      try {
+        const view = this.definition.presentResult(this.parsed.value, this.result)
+        if (view !== undefined) this.resultView = view
+      } catch (error: unknown) {
+        this.resultView = { card: 'generic', content: [{ type: 'text', text: `Presenter failed: ${String(error)}` }] }
+      }
+    }
+  }
+
+  setExpanded(expanded: boolean): void {
+    this.expanded = expanded
+  }
+
+  invalidate(): void {}
+
+  render(width: number): string[] {
+    const isError = this.result?.isError ?? false
+    const glyph = this.result === undefined ? this.palette.warning('◌') : isError ? this.palette.error('✕') : this.palette.success('✓')
+    const body = this.renderBody()
+    const title = truncateToWidth(`${glyph} ${this.title()}`, Math.max(1, width - 4), '')
+    const visibleBody = this.expanded || body.length <= this.maxOutputLines
+      ? body
+      : [...body.slice(0, this.maxOutputLines), this.palette.dim(`… ${body.length - this.maxOutputLines} more lines (Ctrl+O to expand)`)]
+    const box = new Box(1, visibleBody.length > 0 ? 1 : 0, (value) => {
+      if (this.result === undefined) return this.palette.pendingBg(value)
+      return isError ? this.palette.errorBg(value) : this.palette.successBg(value)
+    })
+    box.addChild(new Text(this.palette.bold(title), 0, 0))
+    if (visibleBody.length > 0) box.addChild(new Text(visibleBody.join('\n'), 0, 0))
+    return box.render(width)
+  }
+
+  private title(): string {
+    return this.resultView?.title ?? this.callView.title
+  }
+
+  private renderBody(): string[] {
+    const view = this.resultView ?? this.callView
+    if (view.card === 'terminal') {
+      const pending = this.callView.card === 'terminal' ? this.callView : undefined
+      const lines: string[] = []
+      if (pending?.description) lines.push(this.palette.muted(pending.description))
+      if (pending?.cwd) lines.push(this.palette.dim(pending.cwd))
+      if (this.resultView?.card === 'terminal') {
+        if (this.resultView.output) lines.push(...this.resultView.output.split('\n'))
+        if (this.resultView.exitCode !== undefined) lines.push(this.palette.dim(`[exit ${this.resultView.exitCode}]`))
+        if (this.resultView.signal !== undefined) lines.push(this.palette.error(`[signal ${this.resultView.signal}]`))
+      } else if (this.result === undefined) {
+        // A pending terminal view is the call view itself; TerminalCallView requires a title.
+        lines.push(this.palette.code(`$ ${(pending as TerminalCallView).title}`))
+      } else {
+        lines.push(...contentText(this.result.content).split('\n'))
+      }
+      return lines.filter(Boolean)
+    }
+    if (view.card === 'diff') {
+      return view.diffs.flatMap((diff, index) => [
+        ...index > 0 ? [''] : [],
+        ...diffLines(diff, this.palette),
+      ])
+    }
+    const content = view.content ?? this.result?.content
+    const lines: string[] = []
+    if (content !== undefined) lines.push(...contentText(content).split('\n'))
+    const rawInput = this.result === undefined && this.callView.card === 'generic'
+      ? this.callView.rawInput
+      : undefined
+    if (rawInput !== undefined) lines.push(...pretty(rawInput).split('\n'))
+    return lines.filter((line, index, all) => line.length > 0 || (index > 0 && index < all.length - 1))
+  }
+}
+
+class TodoComponent implements Component {
+  private todos: readonly TodoItem[] = []
+
+  constructor(private readonly palette: Palette) {}
+
+  update(todos: readonly TodoItem[]): void {
+    this.todos = todos
+  }
+
+  invalidate(): void {}
+
+  render(width: number): string[] {
+    if (this.todos.length === 0) return []
+    const lines = [this.palette.bold(this.palette.accent('Plan'))]
+    for (const todo of this.todos) {
+      const prefix = todo.status === 'completed'
+        ? this.palette.success('✓')
+        : todo.status === 'in_progress'
+          ? this.palette.warning('●')
+          : this.palette.dim('○')
+      const text = todo.status === 'completed' ? this.palette.muted(todo.content) : todo.content
+      lines.push(truncateToWidth(`  ${prefix} ${text}`, width, ''))
+    }
+    return ['', ...lines]
+  }
+}
+
+function formatTokens(value: number): string {
+  if (value < 1_000) return String(value)
+  if (value < 10_000) return `${(value / 1_000).toFixed(1)}k`
+  if (value < 1_000_000) return `${Math.round(value / 1_000)}k`
+  return `${(value / 1_000_000).toFixed(1)}m`
+}
+
+function formatCwd(cwd: string | undefined): string {
+  if (cwd === undefined) return 'cwd unset'
+  const home = homedir()
+  const rel = relative(resolve(home), resolve(cwd))
+  if (rel === '') return '~'
+  if (rel !== '..' && !rel.startsWith(`..${sep}`)) return `~${sep}${rel}`
+  return cwd
+}
+
+function sessionTokens(session: Session): { input: number; output: number } {
+  let input = 0
+  let output = 0
+  for (const event of session.events) {
+    if (event.type !== 'assistant/message' || event.data.usage === undefined) continue
+    input += event.data.usage.inputTokens
+    output += event.data.usage.outputTokens
+  }
+  return { input, output }
+}
+
+class FooterComponent implements Component {
+  constructor(
+    private readonly agent: Agent,
+    private readonly palette: Palette,
+    private readonly toolsExpanded: () => boolean,
+    private readonly showReasoning: () => boolean,
+    private readonly tokens: () => { input: number; output: number },
+  ) {}
+
+  invalidate(): void {}
+
+  render(width: number): string[] {
+    const { input, output } = this.tokens()
+    const left = `${formatCwd(this.agent.session.header.cwd)}  ↑${formatTokens(input)} ↓${formatTokens(output)}`
+    const right = `${this.agent.status}  reasoning:${this.showReasoning() ? 'on' : 'off'}  tools:${this.toolsExpanded() ? 'expanded' : 'compact'}`
+    const leftStyled = this.palette.dim(left)
+    const available = Math.max(0, width - visibleWidth(left) - 2)
+    const rightClipped = truncateToWidth(right, available, '')
+    const gap = ' '.repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(rightClipped)))
+    return [truncateToWidth(`${leftStyled}${gap}${this.palette.dim(rightClipped)}`, width, '')]
+  }
+}
+
+interface QuestionSelection {
+  selected: string[]
+  custom?: string
+}
+
+class QuestionDialog implements Component, Focusable {
+  private selectedIndex = 0
+  private selected = new Set<number>()
+  private mode: 'options' | 'custom'
+  private error = ''
+  private readonly input = new Input()
+  private readonly options: NonNullable<AskUserQuestionItem['options']>
+  focused = false
+
+  constructor(
+    private readonly question: AskUserQuestionItem,
+    private readonly maxVisible: number,
+    private readonly palette: Palette,
+    private readonly done: (selection: QuestionSelection) => void,
+    private readonly cancel: () => void,
+  ) {
+    this.options = question.options ?? []
+    this.mode = this.options.length > 0 ? 'options' : 'custom'
+    this.input.onSubmit = (value) => { this.submitCustom(value) }
+    this.input.onEscape = () => {
+      if (this.options.length > 0) {
+        this.mode = 'options'
+        this.error = ''
+      } else {
+        this.cancel()
+      }
+    }
+  }
+
+  invalidate(): void {
+    this.input.invalidate()
+  }
+
+  handleInput(data: string): void {
+    this.invalidate()
+    if (this.mode === 'custom') {
+      this.input.focused = this.focused
+      this.input.handleInput(data)
+      return
+    }
+    const options = this.options
+    if (matchesKey(data, Key.up)) {
+      this.selectedIndex = this.selectedIndex === 0 ? options.length - 1 : this.selectedIndex - 1
+    } else if (matchesKey(data, Key.down)) {
+      this.selectedIndex = this.selectedIndex === options.length - 1 ? 0 : this.selectedIndex + 1
+    } else if (matchesKey(data, Key.space) && this.question.multiSelect) {
+      if (this.selected.has(this.selectedIndex)) this.selected.delete(this.selectedIndex)
+      else this.selected.add(this.selectedIndex)
+    } else if (matchesKey(data, Key.enter)) {
+      const indices = this.question.multiSelect ? [...this.selected].sort((a, b) => a - b) : [this.selectedIndex]
+      if (indices.length === 0) {
+        this.error = 'Select at least one option, or press C for a custom answer.'
+        return
+      }
+      this.done({ selected: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) })
+    } else if (data.toLowerCase() === 'c') {
+      this.mode = 'custom'
+      this.error = ''
+    } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
+      this.cancel()
+    }
+  }
+
+  private submitCustom(value: string): void {
+    const custom = value.trim()
+    if (custom === '') {
+      this.error = 'Enter an answer before submitting.'
+      return
+    }
+    this.done({ selected: [], custom })
+  }
+
+  render(width: number): string[] {
+    this.input.focused = this.focused
+    const innerWidth = Math.max(1, width - 4)
+    const title = this.question.header ?? 'Question'
+    const topLabel = ` ${title} `
+    const top = `╭${topLabel}${'─'.repeat(Math.max(0, width - visibleWidth(topLabel) - 2))}╮`
+    const lines: string[] = [this.palette.accent(top)]
+    const push = (line: string): void => {
+      const clipped = truncateToWidth(line, innerWidth, '')
+      lines.push(`${this.palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${this.palette.accent('│')}`)
+    }
+    for (const line of wrapTextWithAnsi(this.palette.bold(this.question.question), innerWidth)) push(line)
+    push('')
+    if (this.mode === 'custom') {
+      for (const line of this.input.render(innerWidth)) push(line)
+      push(this.palette.dim(this.options.length > 0 ? 'Enter submit • Esc options' : 'Enter submit • Esc cancel'))
+    } else {
+      const options = this.options
+      const start = Math.max(0, Math.min(
+        this.selectedIndex - Math.floor(this.maxVisible / 2),
+        options.length - this.maxVisible,
+      ))
+      const end = Math.min(options.length, start + this.maxVisible)
+      for (let index = start; index < end; index += 1) {
+        // `index < end <= options.length`; the options array is borrowed immutably for this dialog.
+        const option = options[index] as NonNullable<AskUserQuestionItem['options']>[number]
+        const cursor = index === this.selectedIndex ? this.palette.accent('›') : ' '
+        const mark = this.question.multiSelect
+          ? this.selected.has(index) ? this.palette.success('[x]') : '[ ]'
+          : index === this.selectedIndex ? this.palette.accent('●') : this.palette.dim('○')
+        const description = option.description ? this.palette.muted(` — ${option.description}`) : ''
+        const line = `${cursor} ${mark} ${option.label}${description}`
+        push(index === this.selectedIndex ? this.palette.selectedBg(line) : line)
+      }
+      if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`))
+      push(this.palette.dim(this.question.multiSelect
+        ? '↑↓ navigate • Space toggle • Enter submit • C custom • Esc cancel'
+        : '↑↓ navigate • Enter select • C custom • Esc cancel'))
+    }
+    if (this.error) push(this.palette.error(this.error))
+    lines.push(this.palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`))
+    return lines
+  }
+}
+
+interface PendingQuestion {
+  request: AskUserQuestionRequest
+  index: number
+  answers: AskUserQuestionAnswerItem[]
+  resolve(answer: AskUserQuestionAnswer): void
+  reject(error: unknown): void
+  onAbort: () => void
+  overlay: OverlayHandle | undefined
+}
+
+/** Lifecycle handle for a mounted interactive terminal channel. */
+export interface TuiController {
+  /** Stop rendering, restore the terminal, and reject pending questions. */
+  dispose(): Promise<void>
+}
+
+function activeSurfaceSeqs(session: Session): Set<number> {
+  return new Set(session.surface.nodes.map(node => node.seq))
+}
+
+function activeToolCallIds(session: Session, active: ReadonlySet<number>): Set<string> {
+  const ids = new Set<string>()
+  for (const event of session.events) {
+    if (event.type !== 'assistant/message' || !active.has(event.seq)) continue
+    for (const block of event.data.content) {
+      if (block.type === 'tool-call') ids.add(block.id)
+    }
+  }
+  return ids
+}
+
+/**
+ * Start the interactive pi-tui channel for an already-created target agent.
+ * @param ctx - agent, tools, session-event, and user-interaction context.
+ * @param config - target agent, banner, and TUI presentation config.
+ * @param runtime - terminal and process-exit boundary.
+ * @returns lifecycle controller used by the Cordis effect disposer.
+ */
+export function createTuiChat(
+  ctx: Context,
+  config: Config,
+  runtime: TuiRuntime,
+): TuiController {
+  const agentId = AgentId(config.agent ?? 'main')
+  const agent = ctx.agents.get(agentId)
+  if (agent === undefined) throw new Error(`ui-tui: agent "${agentId}" is not running`)
+  const resolved = resolveTuiConfig(config)
+  const palette = createPalette(resolved.color)
+  const mdTheme = markdownTheme(palette)
+  const ui = new TUI(runtime.terminal, resolved.showHardwareCursor)
+  const chat = new Container()
+  const todoContainer = new Container()
+  const statusContainer = new Container()
+  const editor = new Editor(ui, {
+    borderColor: palette.dim,
+    selectList: selectTheme(palette),
+  } satisfies EditorTheme, { paddingX: 1 })
+  const todo = new TodoComponent(palette)
+  let showReasoning = resolved.showReasoning
+  let toolsExpanded = false
+  let streaming: StreamingAssistantComponent | undefined
+  let statusLoader: Loader | undefined
+  let disposed = false
+  let shuttingDown: Promise<void> | undefined
+  const tokens = sessionTokens(agent.session)
+  const toolCards = new Map<string, ToolCardComponent>()
+  const allToolCards = new Set<ToolCardComponent>()
+  const liveErrors = new Set<string>()
+  const questionQueue: PendingQuestion[] = []
+  let activeQuestion: PendingQuestion | undefined
+
+  const welcome = config.welcome ?? 'ready.'
+  const header = new HeaderComponent(agent, welcome, palette)
+  const footer = new FooterComponent(agent, palette, () => toolsExpanded, () => showReasoning, () => tokens)
+  ui.addChild(header)
+  ui.addChild(chat)
+  ui.addChild(statusContainer)
+  todoContainer.addChild(todo)
+  ui.addChild(todoContainer)
+  ui.addChild(editor)
+  ui.addChild(footer)
+  ui.setFocus(editor)
+  runtime.terminal.setTitle(resolved.title)
+
+  const requestRender = (): void => {
+    footer.invalidate()
+    ui.requestRender()
+  }
+
+  const appendNotice = (message: string, kind: 'info' | 'warning' | 'error' = 'info'): void => {
+    const color = kind === 'error' ? palette.error : kind === 'warning' ? palette.warning : palette.muted
+    chat.addChild(new Spacer(1))
+    chat.addChild(new Text(color(message), 1, 0))
+    requestRender()
+  }
+
+  const clearStatus = (): void => {
+    statusLoader?.stop()
+    statusLoader = undefined
+    statusContainer.clear()
+    runtime.terminal.setProgress(false)
+  }
+
+  const setStatus = (status: AgentStatus): void => {
+    clearStatus()
+    editor.borderColor = status === 'running' ? text => palette.accent(text) : text => palette.dim(text)
+    if (status === 'running') {
+      statusLoader = new Loader(ui, text => palette.accent(text), text => palette.muted(text), 'Working — Enter sends steering, Esc cancels')
+      statusContainer.addChild(statusLoader)
+      runtime.terminal.setProgress(true)
+    }
+    requestRender()
+  }
+
+  const parsedTool = (event: Extract<SessionEvent, { type: 'tool/call' }>): ToolCardComponent => {
+    const parsed = parseArguments(event.data.arguments)
+    const card = new ToolCardComponent(
+      event.data.name,
+      parsed,
+      ctx.tools.get(event.data.name, agent),
+      resolved.maxToolOutputLines,
+      palette,
+    )
+    card.setExpanded(toolsExpanded)
+    toolCards.set(event.data.callId, card)
+    allToolCards.add(card)
+    return card
+  }
+
+  const renderEvent = (event: SessionEvent, options: { addHistory: boolean; renderChunks: boolean }): void => {
+    switch (event.type) {
+      case 'user/message': {
+        const text = contentText(event.data.content).trim()
+        if (text) {
+          chat.addChild(new Spacer(1))
+          chat.addChild(new UserMessageComponent(text, palette, mdTheme))
+          if (options.addHistory) editor.addToHistory(text)
+        }
+        break
+      }
+      case 'steering/message': {
+        const text = contentText(event.data.content).trim()
+        if (text) {
+          chat.addChild(new Spacer(1))
+          chat.addChild(new UserMessageComponent(text, palette, mdTheme, 'Steering'))
+        }
+        break
+      }
+      case 'context/message': {
+        const text = contentText(event.data.content).trim()
+        if (text) {
+          const source = event.data.source.kind === 'plugin' ? event.data.source.plugin : event.data.source.kind
+          chat.addChild(new Spacer(1))
+          chat.addChild(new Text(palette.dim(`Context · ${source}`), 1, 0))
+          chat.addChild(new Text(palette.muted(text), 1, 0))
+        }
+        break
+      }
+      case 'prompt/blocked':
+        appendNotice(`Prompt blocked: ${event.data.reason}`, 'warning')
+        break
+      case 'assistant/chunk':
+        if (options.renderChunks) {
+          if (streaming === undefined) {
+            streaming = new StreamingAssistantComponent(showReasoning, palette, mdTheme)
+            chat.addChild(streaming)
+          }
+          streaming.update(event.data.chunk)
+        }
+        break
+      case 'assistant/message': {
+        if (streaming !== undefined) {
+          const index = chat.children.indexOf(streaming)
+          if (index >= 0) chat.children.splice(index, 1)
+          streaming = undefined
+        }
+        const component = new AssistantMessageComponent(event.data.content, showReasoning, palette, mdTheme)
+        if (component.children.length > 0) chat.addChild(component)
+        break
+      }
+      case 'tool/call':
+        chat.addChild(new Spacer(1))
+        chat.addChild(parsedTool(event))
+        break
+      case 'tool/result': {
+        let card = toolCards.get(event.data.callId)
+        if (card === undefined) {
+          card = new ToolCardComponent('tool', { value: {}, valid: true }, undefined, resolved.maxToolOutputLines, palette)
+          chat.addChild(new Spacer(1))
+          chat.addChild(card)
+          allToolCards.add(card)
+        }
+        card.updateResult(event.data)
+        toolCards.delete(event.data.callId)
+        break
+      }
+      case 'todo/write':
+        todo.update(event.data.todos)
+        break
+      case 'turn/end':
+        if (event.data.reason.kind === 'error') {
+          const key = `${event.data.turn}:${event.data.reason.step}`
+          if (!liveErrors.delete(key)) appendNotice(event.data.reason.message, 'error')
+        } else if (event.data.reason.kind === 'aborted') {
+          appendNotice(event.data.reason.reason ?? 'Turn cancelled.', 'warning')
+        } else if (event.data.reason.kind === 'max-tokens') {
+          appendNotice('The model reached its output-token limit.', 'warning')
+        } else if (event.data.reason.kind === 'rejected') {
+          appendNotice(`Turn rejected: ${event.data.reason.reason}`, 'warning')
+        } else if (event.data.reason.kind === 'interrupted') {
+          appendNotice('The previous process ended during this turn.', 'warning')
+        }
+        break
+      default:
+        break
+    }
+  }
+
+  const rebuildTranscript = (populateHistory: boolean): void => {
+    chat.clear()
+    toolCards.clear()
+    allToolCards.clear()
+    streaming = undefined
+    const active = activeSurfaceSeqs(agent.session)
+    const activeCalls = activeToolCallIds(agent.session, active)
+    for (const event of agent.session.events) {
+      const isSurface = event.type === 'user/message'
+        || event.type === 'assistant/message'
+        || event.type === 'tool/result'
+        || event.type === 'context/message'
+        || event.type === 'steering/message'
+      if (isSurface && !active.has(event.seq)) continue
+      if (event.type === 'tool/call' && !activeCalls.has(event.data.callId)) continue
+      renderEvent(event, { addHistory: populateHistory, renderChunks: false })
+    }
+    requestRender()
+  }
+
+  const removeAbortListener = (pending: PendingQuestion): void => {
+    pending.request.signal?.removeEventListener('abort', pending.onAbort)
+  }
+
+  const rejectQuestion = (pending: PendingQuestion): void => {
+    pending.overlay?.hide()
+    pending.overlay = undefined
+    removeAbortListener(pending)
+    pending.reject(new UserInteractionError(
+      'ask_user_question was interrupted before the user answered',
+      'ASK_ABORTED',
+    ))
+  }
+
+  const startNextQuestion = (): void => {
+    if (activeQuestion !== undefined || disposed) return
+    const pending = questionQueue.shift()
+    if (pending === undefined) return
+    activeQuestion = pending
+    const show = (): void => {
+      const question = pending.request.questions[pending.index]
+      if (question === undefined) {
+        activeQuestion = undefined
+        removeAbortListener(pending)
+        pending.resolve({ answers: pending.answers })
+        startNextQuestion()
+        return
+      }
+      const dialog = new QuestionDialog(
+        question,
+        resolved.maxQuestionOptions,
+        palette,
+        (selection) => {
+          pending.overlay?.hide()
+          pending.overlay = undefined
+          pending.answers.push({ id: question.id, ...selection })
+          pending.index += 1
+          show()
+        },
+        () => {
+          activeQuestion = undefined
+          rejectQuestion(pending)
+          startNextQuestion()
+        },
+      )
+      pending.overlay = ui.showOverlay(dialog, {
+        width: resolved.questionDialogWidth,
+        maxHeight: resolved.questionDialogMaxHeight,
+        anchor: 'center',
+        margin: 1,
+      })
+      requestRender()
+    }
+    show()
+  }
+
+  const disposeUserInteraction = ctx.userInteraction.registerProvider({
+    ask(request) {
+      return new Promise<AskUserQuestionAnswer>((resolveAnswer, reject) => {
+        const pending: PendingQuestion = {
+          request,
+          index: 0,
+          answers: [],
+          resolve: resolveAnswer,
+          reject,
+          overlay: undefined,
+          onAbort: () => {
+            if (activeQuestion === pending) {
+              activeQuestion = undefined
+              rejectQuestion(pending)
+              startNextQuestion()
+              return
+            }
+            // A non-active pending ask remains in the queue until this listener settles it.
+            questionQueue.splice(questionQueue.indexOf(pending), 1)
+            rejectQuestion(pending)
+          },
+        }
+        request.signal?.addEventListener('abort', pending.onAbort, { once: true })
+        questionQueue.push(pending)
+        startNextQuestion()
+      })
+    },
+  })
+
+  const shutdown = (exitProcess: boolean): Promise<void> => {
+    shuttingDown ??= (async () => {
+      disposed = true
+      clearStatus()
+      if (activeQuestion !== undefined) {
+        const pending = activeQuestion
+        activeQuestion = undefined
+        rejectQuestion(pending)
+      }
+      for (const pending of questionQueue.splice(0)) rejectQuestion(pending)
+      disposeUserInteraction()
+      await runtime.terminal.drainInput(100, 20)
+      ui.stop()
+      if (exitProcess) runtime.exit(0)
+    })()
+    return shuttingDown
+  }
+
+  const requestExit = (): void => {
+    if (agent.status === 'running') {
+      agent.cancel('terminal exit requested')
+      appendNotice('Cancelling the active turn before exit…', 'warning')
+      void agent.whenIdle().then(() => shutdown(true))
+      return
+    }
+    void shutdown(true)
+  }
+
+  editor.setAutocompleteProvider(new CombinedAutocompleteProvider([
+    { name: 'help', description: 'Show keyboard shortcuts and commands' },
+    { name: 'clear', description: 'Clear the transcript view (session history is unchanged)' },
+    { name: 'cancel', description: 'Cancel the active turn' },
+    { name: 'reasoning', description: 'Toggle reasoning blocks' },
+    { name: 'tools', description: 'Expand or collapse all tool cards' },
+    { name: 'redraw', description: 'Invalidate components and redraw the terminal' },
+    { name: 'exit', description: 'Exit after the active turn reaches idle' },
+  ], agent.session.header.cwd ?? process.cwd()))
+
+  const toggleTools = (): void => {
+    toolsExpanded = !toolsExpanded
+    for (const card of allToolCards) card.setExpanded(toolsExpanded)
+    appendNotice(`Tool cards ${toolsExpanded ? 'expanded' : 'collapsed'}.`)
+  }
+
+  const toggleReasoning = (): void => {
+    showReasoning = !showReasoning
+    const activeStreaming = streaming
+    rebuildTranscript(false)
+    if (activeStreaming !== undefined) {
+      streaming = activeStreaming
+      streaming.setShowReasoning(showReasoning)
+      chat.addChild(activeStreaming)
+    }
+    appendNotice(`Reasoning blocks ${showReasoning ? 'shown' : 'hidden'}.`)
+  }
+
+  const showHelp = (): void => {
+    chat.addChild(new Spacer(1))
+    chat.addChild(new Text(palette.bold(palette.accent('Keyboard shortcuts')), 1, 0))
+    chat.addChild(new Text([
+      'Enter send • Shift/Alt+Enter newline • Up/Down prompt history',
+      'Esc cancel active turn • Ctrl+O expand tool cards • Ctrl+R toggle reasoning',
+      'Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit',
+      '/help /clear /cancel /reasoning /tools /redraw /exit',
+    ].map(line => palette.muted(line)).join('\n'), 1, 0))
+    requestRender()
+  }
+
+  editor.onSubmit = (value: string) => {
+    const text = value.trim()
+    if (text === '') return
+    editor.addToHistory(text)
+    editor.setText('')
+    switch (text) {
+      case '/help':
+        showHelp()
+        return
+      case '/clear':
+        chat.clear()
+        requestRender()
+        return
+      case '/cancel':
+        if (agent.status === 'running') agent.cancel('cancelled from terminal')
+        else appendNotice('The agent is already idle.')
+        return
+      case '/reasoning':
+        toggleReasoning()
+        return
+      case '/tools':
+        toggleTools()
+        return
+      case '/redraw':
+        ui.invalidate()
+        ui.requestRender(true)
+        return
+      case '/exit':
+        requestExit()
+        return
+      default:
+        if (text.startsWith('/')) {
+          appendNotice(`Unknown command: ${text}`, 'warning')
+          return
+        }
+    }
+    if (agent.status === 'disposed') {
+      appendNotice(`Agent "${agent.id}" is disposed.`, 'error')
+    } else if (agent.status === 'running') {
+      agent.steer([{ type: 'text', text }])
+    } else {
+      agent.send([{ type: 'text', text }])
+    }
+  }
+
+  const removeInputListener = ui.addInputListener((data) => {
+    if (activeQuestion !== undefined) return undefined
+    if (matchesKey(data, Key.ctrl('o'))) {
+      toggleTools()
+      return { consume: true }
+    }
+    if (matchesKey(data, Key.ctrl('r'))) {
+      toggleReasoning()
+      return { consume: true }
+    }
+    if (matchesKey(data, Key.ctrl('l'))) {
+      ui.invalidate()
+      ui.requestRender(true)
+      return { consume: true }
+    }
+    if (matchesKey(data, Key.escape) && agent.status === 'running') {
+      agent.cancel('cancelled from terminal')
+      return { consume: true }
+    }
+    if (matchesKey(data, Key.ctrl('c'))) {
+      if (agent.status === 'running') {
+        agent.cancel('cancelled from terminal')
+      } else if (editor.getText() !== '') {
+        editor.setText('')
+      } else {
+        requestExit()
+      }
+      return { consume: true }
+    }
+    if (matchesKey(data, Key.ctrl('d'))) {
+      if (agent.status === 'running') appendNotice('Cancel the active turn before exiting.', 'warning')
+      else requestExit()
+      return { consume: true }
+    }
+    return undefined
+  })
+
+  const disposeSessionEvents = ctx.on('session/event', (session, event) => {
+    if (session !== agent.session) return
+    if (event.type === 'assistant/message' && event.data.usage !== undefined) {
+      tokens.input += event.data.usage.inputTokens
+      tokens.output += event.data.usage.outputTokens
+    }
+    if ('surfaceOp' in event && typeof event.surfaceOp === 'object') {
+      rebuildTranscript(false)
+      return
+    }
+    renderEvent(event, { addHistory: false, renderChunks: true })
+    requestRender()
+  })
+  const disposeStatus = ctx.on('agent/status', (subject, status) => {
+    if (subject !== agent) return
+    setStatus(status)
+  })
+  const disposeError = ctx.on('agent/error', (subject, turn, step, error) => {
+    if (subject !== agent) return
+    liveErrors.add(`${turn}:${step}`)
+    appendNotice(error.message, 'error')
+  })
+  const disposeAgent = ctx.on('agent/disposed', (subject) => {
+    if (subject !== agent) return
+    clearStatus()
+    appendNotice(`Agent "${agent.id}" was disposed.`, 'warning')
+  })
+
+  const detachListeners = (): void => {
+    removeInputListener()
+    disposeSessionEvents()
+    disposeStatus()
+    disposeError()
+    disposeAgent()
+  }
+
+  rebuildTranscript(true)
+  setStatus(agent.status)
+  try {
+    ui.start()
+  } catch (error: unknown) {
+    disposed = true
+    detachListeners()
+    clearStatus()
+    disposeUserInteraction()
+    ui.stop()
+    throw error
+  }
+
+  return {
+    async dispose(): Promise<void> {
+      detachListeners()
+      await shutdown(false)
+    },
+  }
+}
+
+/**
+ * Open the pi-tui channel once its configured agent exists.
+ *
+ * @param ctx - Context supplying the agent registry, tools, and event stream.
+ * @param config - Target agent and presentation configuration.
+ * @param runtime - Terminal and process-exit boundary.
+ */
+export function mountTui(ctx: Context, config: Config, runtime: TuiRuntime): void {
+  const agentId = AgentId(config.agent ?? 'main')
+  const start = (): void => {
+    ctx.effect(() => {
+      const controller = createTuiChat(ctx, config, runtime)
+      return () => controller.dispose()
+    }, 'ui-tui')
+  }
+  if (ctx.agents.get(agentId) !== undefined) {
+    start()
+    return
+  }
+  const dispose = ctx.on('agent/created', (agent) => {
+    if (agent.id !== agentId) return
+    dispose()
+    start()
+  })
+}
+
+/** Cordis entry point using the process terminal; explicit TUI composition requires a TTY pair. */
+/* v8 ignore start -- production process wiring; fake-terminal tests cover mountTui/createTuiChat,
+   and the coding-agent PTY smoke covers the real entry */
+export function apply(ctx: Context, config: Config): void {
+  if (!process.stdin.isTTY || !process.stdout.isTTY) {
+    throw new Error('ui-tui: both stdin and stdout must be TTYs; use @deepseek-ai/dsh-stdio for pipes')
+  }
+  mountTui(ctx, config, {
+    terminal: new ProcessTerminal(),
+    exit: code => process.exit(code),
+  })
+}
+/* v8 ignore stop */

+ 19 - 0
packages/ui/tui/tests/plugin-shape.spec.ts

@@ -0,0 +1,19 @@
+import { describe, expect, it } from 'vitest'
+import Loader from '@cordisjs/plugin-loader'
+import * as tui from '../src/index.ts'
+
+/** Real Loader export-path guard for the namespace TUI plugin. */
+describe('dsh-tui plugin export shape', () => {
+  it('preserves name, inject, Config, and apply through Loader unwrapping', () => {
+    expect('default' in tui).toBe(false)
+    expect(typeof tui.apply).toBe('function')
+
+    const loader = Object.create(Loader.prototype) as Loader
+    const unwrapped = loader.unwrapExports(tui) as Record<string, unknown>
+    expect(unwrapped).toBe(tui)
+    expect(unwrapped.name).toBe('ui-tui')
+    expect(unwrapped.inject).toEqual(['agents', 'userInteraction', 'tools'])
+    expect(unwrapped.Config).toBeDefined()
+    expect(typeof unwrapped.apply).toBe('function')
+  })
+})

+ 952 - 0
packages/ui/tui/tests/tui.spec.ts

@@ -0,0 +1,952 @@
+import { describe, expect, it, vi } from 'vitest'
+import { Context } from 'cordis'
+import type { Terminal } from '@earendil-works/pi-tui'
+import AgentRegistry, { AgentId, type Agent, type AgentStatus } from '@deepseek-ai/dsh-agent'
+import type { ContentBlock } from '@deepseek-ai/dsh-llm'
+import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
+import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
+import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
+import {
+  createTuiChat,
+  mountTui,
+  resolveTuiConfig,
+  type Config,
+  type TuiRuntime,
+} from '../src/index.ts'
+
+class FakeTerminal implements Terminal {
+  columns = 88
+  rows = 32
+  kittyProtocolActive = false
+  output = ''
+  title = ''
+  progress: boolean[] = []
+  started = 0
+  stopped = 0
+  drainInput = vi.fn(() => Promise.resolve())
+  private onInput: (data: string) => void = () => {}
+  private onResize: () => void = () => {}
+
+  start(onInput: (data: string) => void, onResize: () => void): void {
+    this.started += 1
+    this.onInput = onInput
+    this.onResize = onResize
+  }
+
+  stop(): void {
+    this.stopped += 1
+  }
+
+  write(data: string): void {
+    this.output += data
+  }
+
+  moveBy(lines: number): void {
+    this.output += `[move:${lines}]`
+  }
+
+  hideCursor(): void {
+    this.output += '[hide]'
+  }
+
+  showCursor(): void {
+    this.output += '[show]'
+  }
+
+  clearLine(): void {
+    this.output += '[clear-line]'
+  }
+
+  clearFromCursor(): void {
+    this.output += '[clear-rest]'
+  }
+
+  clearScreen(): void {
+    this.output += '[clear-screen]'
+  }
+
+  setTitle(title: string): void {
+    this.title = title
+  }
+
+  setProgress(active: boolean): void {
+    this.progress.push(active)
+  }
+
+  send(data: string): void {
+    this.onInput(data)
+  }
+
+  resize(columns: number, rows = this.rows): void {
+    this.columns = columns
+    this.rows = rows
+    this.onResize()
+  }
+}
+
+interface FakeAgent extends Agent {
+  status: AgentStatus
+  sent: ContentBlock[][]
+  steered: ContentBlock[][]
+  cancelled: string[]
+}
+
+async function tick(): Promise<void> {
+  await new Promise(resolve => setTimeout(resolve, 25))
+}
+
+async function setup(options: {
+  status?: AgentStatus
+  config?: Config
+  tools?: Record<string, ToolDefinition>
+  beforeMount?: (session: Session) => void
+  cwd?: string | null
+} = {}) {
+  const ctx = new Context()
+  await ctx.plugin(SessionStore)
+  await ctx.plugin(AgentRegistry)
+  await ctx.plugin(UserInteractionService)
+  const tools = options.tools ?? {}
+  ctx.provide('tools', {
+    get(name: string) {
+      return tools[name]
+    },
+  } as never)
+  const session = ctx.sessions.create(
+    SessionId('main-session'),
+    options.cwd === null ? undefined : { meta: { cwd: options.cwd ?? process.cwd() } },
+  )
+  options.beforeMount?.(session)
+  const sent: ContentBlock[][] = []
+  const steered: ContentBlock[][] = []
+  const cancelled: string[] = []
+  const agent: FakeAgent = {
+    id: AgentId('main'),
+    options: { model: 'deepseek-v4-flash' },
+    session,
+    status: options.status ?? 'idle',
+    ctx,
+    sent,
+    steered,
+    cancelled,
+    send(content) {
+      sent.push(content)
+    },
+    steer(content) {
+      steered.push(content)
+    },
+    inject() {},
+    cancel(reason) {
+      cancelled.push(reason ?? '')
+    },
+    whenIdle() {
+      return Promise.resolve()
+    },
+  }
+  ctx.agents.register(agent)
+  const terminal = new FakeTerminal()
+  const exit = vi.fn()
+  const controller = createTuiChat(ctx, Object.assign({
+    welcome: 'Coding agent ready.',
+    agent: 'main',
+    color: false,
+  }, options.config), { terminal, exit })
+  await tick()
+  return { ctx, session, agent, terminal, exit, controller }
+}
+
+async function dispose(setupResult: Awaited<ReturnType<typeof setup>>): Promise<void> {
+  await setupResult.controller.dispose()
+  await setupResult.ctx.fiber.dispose()
+}
+
+function appendUser(session: Session, text: string): void {
+  session.append('user/message', {
+    content: [{ type: 'text', text }],
+    source: { kind: 'user' },
+  }, { surfaceOp: 'append' })
+}
+
+function appendAssistant(session: Session, content: ContentBlock[], usage?: { inputTokens: number; outputTokens: number }): void {
+  session.append('assistant/message', {
+    turn: 1,
+    step: 0,
+    content,
+    ...usage === undefined ? {} : { usage },
+  }, { surfaceOp: 'append' })
+}
+
+describe('TUI config', () => {
+  it('defaults every direct-call TUI option', () => {
+    expect(resolveTuiConfig(undefined)).toEqual({
+      showReasoning: true,
+      maxToolOutputLines: 12,
+      maxQuestionOptions: 8,
+      questionDialogWidth: 72,
+      questionDialogMaxHeight: 20,
+      showHardwareCursor: false,
+      color: true,
+      title: 'DeepSeek Harness',
+    })
+    expect(resolveTuiConfig({
+      showReasoning: false,
+      maxToolOutputLines: 2,
+      maxQuestionOptions: 3,
+      questionDialogWidth: 60,
+      questionDialogMaxHeight: 14,
+      showHardwareCursor: true,
+      color: false,
+      title: 'DSH',
+    })).toEqual({
+      showReasoning: false,
+      maxToolOutputLines: 2,
+      maxQuestionOptions: 3,
+      questionDialogWidth: 60,
+      questionDialogMaxHeight: 14,
+      showHardwareCursor: true,
+      color: false,
+      title: 'DSH',
+    })
+  })
+})
+
+describe('pi-tui chat lifecycle and transcript', () => {
+  it('renders its header, footer, replay, streaming answer, todos, and status', async () => {
+    const result = await setup({
+      beforeMount(session) {
+        appendUser(session, 'restored prompt')
+        appendAssistant(session, [
+          { type: 'reasoning', text: 'restored thought' },
+          { type: 'text', text: '**restored answer**' },
+        ], { inputTokens: 1_250, outputTokens: 42 })
+        session.append('todo/write', {
+          todos: [
+            { content: 'read code', status: 'completed' },
+            { content: 'write tests', status: 'in_progress' },
+            { content: 'ship', status: 'pending' },
+          ],
+        })
+      },
+    })
+
+    expect(result.terminal.started).toBe(1)
+    expect(result.terminal.title).toBe('DeepSeek Harness')
+    expect(result.terminal.output).toContain('DEEPSEEK')
+    expect(result.terminal.output).toContain('Coding agent ready.')
+    expect(result.terminal.output).toContain('restored prompt')
+    expect(result.terminal.output).toContain('restored thought')
+    expect(result.terminal.output).toContain('restored answer')
+    expect(result.terminal.output).toContain('write tests')
+    expect(result.terminal.output).toContain('↑1.3k ↓42')
+
+    result.agent.status = 'running'
+    result.ctx.emit('agent/status', result.agent, 'running')
+    result.session.append('user/message', { content: [{ type: 'text', text: '   ' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
+    result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: 'steering note' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
+    result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
+    result.session.append('context/message', { content: [{ type: 'text', text: 'user context' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
+    result.session.append('context/message', { content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
+    result.session.append('prompt/blocked', { content: [{ type: 'text', text: 'blocked' }], source: { kind: 'user' }, reason: 'test policy' })
+    appendAssistant(result.session, [])
+    result.session.append('turn/end', { turn: 9, reason: { kind: 'aborted' } })
+    result.session.append('turn/end', { turn: 10, reason: { kind: 'completed' } })
+    result.session.append('step/start', { turn: 11, step: 0 })
+    result.session.append('assistant/chunk', {
+      turn: 2,
+      step: 0,
+      chunk: { type: 'block-start', index: 0, blockType: 'reasoning' },
+    })
+    result.session.append('assistant/chunk', {
+      turn: 2,
+      step: 0,
+      chunk: { type: 'reasoning-delta', index: 0, text: 'live thought' },
+    })
+    result.session.append('assistant/chunk', {
+      turn: 2,
+      step: 0,
+      chunk: { type: 'reasoning-delta', index: 9, text: 'unannounced thought' },
+    })
+    result.session.append('assistant/chunk', {
+      turn: 2,
+      step: 0,
+      chunk: { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'live thought complete' } },
+    })
+    result.session.append('assistant/chunk', {
+      turn: 2,
+      step: 0,
+      chunk: { type: 'block-start', index: 1, blockType: 'text' },
+    })
+    result.session.append('assistant/chunk', {
+      turn: 2,
+      step: 0,
+      chunk: { type: 'text-delta', index: 1, text: 'live answer' },
+    })
+    result.session.append('assistant/chunk', {
+      turn: 2,
+      step: 0,
+      chunk: { type: 'block-end', index: 1, block: { type: 'text', text: 'live answer done' } },
+    })
+    result.session.append('assistant/chunk', {
+      turn: 2,
+      step: 0,
+      chunk: { type: 'block-start', index: 2, blockType: 'tool-call' },
+    })
+    result.session.append('assistant/chunk', {
+      turn: 2,
+      step: 0,
+      chunk: { type: 'block-end', index: 2, block: { type: 'tool-call', id: 'stream-tool' as never, name: 'tool', arguments: '{}' } },
+    })
+    result.session.append('assistant/chunk', {
+      turn: 2,
+      step: 0,
+      chunk: { type: 'tool-call-delta', index: 2, id: 'stream-tool' as never, argumentsDelta: '{}' },
+    })
+    result.session.append('assistant/chunk', {
+      turn: 2,
+      step: 0,
+      chunk: { type: 'usage', usage: { inputTokens: 1, outputTokens: 2 } },
+    })
+    await tick()
+    expect(result.terminal.output).toContain('live thought')
+    result.terminal.send('\x12')
+    await tick()
+    appendAssistant(result.session, [{ type: 'text', text: 'final live answer' }], { inputTokens: 500, outputTokens: 8 })
+    await tick()
+
+    expect(result.terminal.output).toContain('Working')
+    expect(result.terminal.output).toContain('Steering')
+    expect(result.terminal.output).toContain('user context')
+    expect(result.terminal.output).toContain('Prompt blocked')
+    expect(result.terminal.output).toContain('Turn cancelled')
+    expect(result.terminal.output).toContain('final live answer')
+    expect(result.terminal.output).toContain('↑1.8k ↓50')
+    expect(result.terminal.progress).toContain(true)
+
+    result.session.append('assistant/chunk', {
+      turn: 3,
+      step: 0,
+      chunk: { type: 'text-delta', index: 0, text: 'cleared stream' },
+    })
+    result.terminal.send('/clear')
+    result.terminal.send('\r')
+    appendAssistant(result.session, [{ type: 'text', text: 'answer after clear' }])
+    await tick()
+    expect(result.terminal.output).toContain('answer after clear')
+
+    result.agent.status = 'idle'
+    result.ctx.emit('agent/status', result.agent, 'idle')
+    await tick()
+    expect(result.terminal.progress.at(-1)).toBe(false)
+    await dispose(result)
+    expect(result.terminal.stopped).toBe(1)
+    expect(result.terminal.drainInput).toHaveBeenCalledWith(100, 20)
+  })
+
+  it('renders the ANSI palette and every markdown/content style', async () => {
+    const result = await setup({
+      config: { color: true },
+      beforeMount(session) {
+        session.append('user/message', {
+          content: [
+            { type: 'text', text: '# Heading\n\n[link](https://example.com) `code`\n\n```ts\nconst x = 1\n```\n\n> quote\n\n---\n\n- item\n\n**bold** *italic* ~~strike~~' },
+            { type: 'tool-call', id: 'nested' as never, name: 'nested_tool', arguments: '{}' },
+            { type: 'tool-result', toolCallId: 'nested' as never, content: [{ type: 'reasoning', text: 'nested result' }] },
+            { type: 'future-block' } as never,
+            {} as never,
+          ],
+          source: { kind: 'user' },
+        }, { surfaceOp: 'append' })
+        appendAssistant(session, [
+          { type: 'reasoning', text: 'styled reasoning' },
+          { type: 'text', text: 'styled answer' },
+        ], { inputTokens: 2_000_000, outputTokens: 1_500_000 })
+        session.append('todo/write', { todos: [
+          { content: 'done', status: 'completed' },
+          { content: 'active', status: 'in_progress' },
+          { content: 'later', status: 'pending' },
+        ] })
+      },
+    })
+    result.terminal.send('/')
+    await tick()
+    result.terminal.send('zz')
+    await tick()
+    result.terminal.send('\x0c')
+    await tick()
+
+    expect(result.terminal.output).toContain('\x1b[')
+    expect(result.terminal.output).toContain('Heading')
+    expect(result.terminal.output).toContain('nested_tool({})')
+    expect(result.terminal.output).toContain('nested result')
+    expect(result.terminal.output).toContain('[future-block]')
+    expect(result.terminal.output).toContain('[content]')
+    expect(result.terminal.output).toContain('↑2.0m ↓1.5m')
+    await dispose(result)
+  })
+
+  it('suppresses stale replay chunks and does not duplicate editor history on rebuild', async () => {
+    const result = await setup({
+      beforeMount(session) {
+        appendUser(session, 'first prompt')
+        appendUser(session, 'second prompt')
+        session.append('assistant/chunk', {
+          turn: 2,
+          step: 0,
+          chunk: { type: 'text-delta', index: 0, text: 'stale partial response' },
+        })
+      },
+    })
+
+    expect(result.terminal.output).not.toContain('stale partial response')
+    result.terminal.send('/reasoning')
+    result.terminal.send('\r')
+    result.terminal.send('\x1b[A')
+    result.terminal.send('\x1b[A')
+    result.terminal.send('\x1b[A')
+    result.terminal.send('\r')
+    expect(result.agent.sent).toEqual([[{ type: 'text', text: 'first prompt' }]])
+    await dispose(result)
+  })
+
+  it('formats large token totals and cwd variants', async () => {
+    const homeResult = await setup({
+      cwd: process.env.HOME ?? process.cwd(),
+      beforeMount(session) {
+        appendAssistant(session, [{ type: 'text', text: 'home' }], { inputTokens: 25_000, outputTokens: 10_000 })
+      },
+    })
+    expect(homeResult.terminal.output).toContain('~  ↑25k ↓10k')
+    await dispose(homeResult)
+
+    const unsetResult = await setup({ cwd: null })
+    expect(unsetResult.terminal.output).toContain('cwd unset')
+    await dispose(unsetResult)
+
+    const outsideResult = await setup({ cwd: '/opt' })
+    expect(outsideResult.terminal.output).toContain('/opt')
+    await dispose(outsideResult)
+  })
+
+  it('sends, steers, handles commands, global keys, and disposed-agent input', async () => {
+    const result = await setup()
+
+    result.terminal.send('do the work')
+    result.terminal.send('\r')
+    expect(result.agent.sent).toEqual([[{ type: 'text', text: 'do the work' }]])
+
+    result.terminal.send('   ')
+    result.terminal.send('\r')
+
+    result.agent.status = 'running'
+    result.terminal.send('steer it')
+    result.terminal.send('\r')
+    expect(result.agent.steered).toEqual([[{ type: 'text', text: 'steer it' }]])
+
+    result.terminal.send('\x1b')
+    result.terminal.send('\x04')
+    result.terminal.send('\x03')
+    result.terminal.send('\x12')
+    result.terminal.send('\x0f')
+    result.terminal.send('/cancel')
+    result.terminal.send('\r')
+    expect(result.agent.cancelled).toContain('cancelled from terminal')
+
+    result.agent.status = 'idle'
+    for (const command of ['/help', '/reasoning', '/tools', '/redraw']) {
+      result.terminal.send(command)
+      result.terminal.send('\r')
+      await tick()
+    }
+    for (const command of ['/clear', '/cancel', '/wat']) {
+      result.terminal.send(command)
+      result.terminal.send('\r')
+    }
+    await tick()
+    result.terminal.send('draft')
+    result.terminal.send('\x03')
+    result.terminal.send('\x04')
+    await tick()
+
+    expect(result.terminal.output).toContain('Keyboard shortcuts')
+    expect(result.terminal.output).toContain('Reasoning blocks')
+    expect(result.terminal.output).toContain('Tool cards')
+    expect(result.terminal.output).toContain('already idle')
+    expect(result.terminal.output).toContain('Unknown command')
+    expect(result.exit).toHaveBeenCalledWith(0)
+    await result.controller.dispose()
+    await result.ctx.fiber.dispose()
+
+    const ctrlCExit = await setup()
+    ctrlCExit.terminal.send('\x03')
+    await tick()
+    expect(ctrlCExit.exit).toHaveBeenCalledWith(0)
+    await ctrlCExit.controller.dispose()
+    await ctrlCExit.ctx.fiber.dispose()
+
+    const disposedAgent = await setup()
+    disposedAgent.agent.status = 'disposed'
+    disposedAgent.terminal.send('late input')
+    disposedAgent.terminal.send('\r')
+    await tick()
+    expect(disposedAgent.terminal.output).toContain('is disposed')
+    await dispose(disposedAgent)
+  })
+
+  it('cancels before /exit while running and handles agent errors/disposal', async () => {
+    const result = await setup({ status: 'running' })
+    result.terminal.send('/exit')
+    result.terminal.send('\r')
+    await tick()
+    expect(result.agent.cancelled).toContain('terminal exit requested')
+    expect(result.exit).toHaveBeenCalledWith(0)
+
+    const events = await setup()
+    const unrelatedSession = events.ctx.sessions.create(SessionId('unrelated-session'))
+    const unrelatedAgent = { ...events.agent, id: AgentId('unrelated'), session: unrelatedSession }
+    unrelatedSession.append('todo/write', { todos: [{ content: 'hidden', status: 'pending' }] })
+    events.ctx.emit('agent/status', unrelatedAgent, 'running')
+    events.ctx.emit('agent/error', unrelatedAgent, 1, 1, new Error('hidden error'))
+    events.ctx.emit('agent/disposed', unrelatedAgent)
+    events.ctx.emit('agent/error', events.agent, 3, 2, new Error('live failure'))
+    events.session.append('turn/end', { turn: 3, reason: { kind: 'error', step: 2, message: 'live failure' } })
+    events.session.append('turn/end', { turn: 4, reason: { kind: 'error', step: 1, message: 'durable failure' } })
+    events.session.append('turn/end', { turn: 5, reason: { kind: 'aborted', reason: 'stopped' } })
+    events.session.append('turn/end', { turn: 6, reason: { kind: 'max-tokens' } })
+    events.session.append('turn/end', { turn: 7, reason: { kind: 'rejected', reason: 'policy' } })
+    events.session.append('turn/end', { turn: 8, reason: { kind: 'interrupted' } })
+    events.ctx.emit('agent/disposed', events.agent)
+    await tick()
+    expect(events.terminal.output).toContain('live failure')
+    expect(events.terminal.output).toContain('durable failure')
+    expect(events.terminal.output).toContain('stopped')
+    expect(events.terminal.output).toContain('output-token limit')
+    expect(events.terminal.output).toContain('Turn rejected')
+    expect(events.terminal.output).toContain('previous process ended')
+    expect(events.terminal.output).toContain('was disposed')
+    await dispose(events)
+  })
+})
+
+describe('tool cards and surface replay', () => {
+  const tools: Record<string, ToolDefinition> = {
+    bash: {
+      name: 'bash', description: '', parameters: {}, execute: async () => [],
+      presentCall: () => ({ card: 'terminal', title: 'printf hello', description: 'Run command', cwd: '/tmp' }),
+      presentResult: () => ({ card: 'terminal', output: 'hello\nworld\nthird', exitCode: 0 }),
+    },
+    signal: {
+      name: 'signal', description: '', parameters: {}, execute: async () => [],
+      presentCall: () => ({ card: 'terminal', title: 'sleep 10' }),
+      presentResult: () => ({ card: 'terminal', signal: 'SIGTERM' }),
+    },
+    edit: {
+      name: 'edit', description: '', parameters: {}, execute: async () => [],
+      presentCall: () => ({
+        card: 'diff',
+        title: 'Edit files',
+        diffs: [
+          { path: 'a.txt', oldText: 'old', newText: 'new' },
+          { path: 'b.txt', oldText: 'before', newText: 'after' },
+        ],
+      }),
+      presentResult: () => ({ card: 'diff', diffs: [{ path: 'a.txt', oldText: null, newText: 'created' }] }),
+    },
+    generic: {
+      name: 'generic', description: '', parameters: {}, execute: async () => [],
+      presentCall: () => ({ card: 'generic', title: 'Inspect value', rawInput: { alpha: 1 } }),
+      presentResult: () => ({ card: 'generic', title: 'Inspected', content: [{ type: 'text', text: 'result text' }] }),
+    },
+    throwing: {
+      name: 'throwing', description: '', parameters: {}, execute: async () => [],
+      presentCall: () => { throw new Error('call presenter boom') },
+      presentResult: () => { throw new Error('result presenter boom') },
+    },
+    rawTerminal: {
+      name: 'rawTerminal', description: '', parameters: {}, execute: async () => [],
+      presentCall: () => ({ card: 'terminal', title: 'raw command' }),
+    },
+    undefinedViews: {
+      name: 'undefinedViews', description: '', parameters: {}, execute: async () => [],
+      presentCall: () => undefined,
+      presentResult: () => undefined,
+    },
+    empty: {
+      name: 'empty', description: '', parameters: {}, execute: async () => [],
+      presentCall: () => ({ card: 'generic', title: 'Empty card' }),
+    },
+    terminalResult: {
+      name: 'terminalResult', description: '', parameters: {}, execute: async () => [],
+      presentCall: () => ({ card: 'generic', title: 'Becomes terminal' }),
+      presentResult: () => ({ card: 'terminal', output: 'converted terminal' }),
+    },
+    symbolic: {
+      name: 'symbolic', description: '', parameters: {}, execute: async () => [],
+      presentCall: () => ({ card: 'generic', title: 'Symbol input', rawInput: Symbol('input') }),
+    },
+  }
+
+  it('uses terminal, diff, generic, fallback, and collapsed tool presentations', async () => {
+    const result = await setup({ tools, config: { maxToolOutputLines: 1 } })
+    const calls = [
+      ['c1', 'bash', '{"command":"printf hello"}'],
+      ['c2', 'signal', '{}'],
+      ['c3', 'edit', '{}'],
+      ['c4', 'generic', '{}'],
+      ['c5', 'throwing', '{}'],
+      ['c6', 'unknown', 'not-json'],
+      ['c7', 'rawTerminal', '{"value":"raw"}'],
+      ['c8', 'undefinedViews', '{"value":8}'],
+      ['c10', 'empty', '{}'],
+      ['c11', 'terminalResult', '{}'],
+      ['c12', 'symbolic', '{}'],
+    ] as const
+    appendAssistant(result.session, [
+      { type: 'text', text: 'Calling tools' },
+      ...calls.map(([id, name, args]) => ({
+        type: 'tool-call' as const, id: id as never, name, arguments: args,
+      })),
+    ])
+    for (const [id, name, args] of calls) {
+      result.session.append('tool/call', { turn: 1, step: 0, callId: id as never, name, arguments: args })
+    }
+    await tick()
+    expect(result.terminal.output).toContain('$ raw command')
+    result.terminal.send('/reasoning')
+    result.terminal.send('\r')
+    await tick()
+    expect(result.terminal.output).toContain('call presenter boom')
+    expect(result.terminal.output).toContain('Symbol(input)')
+    result.session.append('tool/result', {
+      turn: 1, step: 0, callId: 'c1' as never, content: [{ type: 'text', text: 'raw bash' }], isError: false,
+    }, { surfaceOp: 'append' })
+    result.session.append('tool/result', {
+      turn: 1, step: 0, callId: 'c2' as never, content: [{ type: 'text', text: 'stopped' }], isError: true,
+    }, { surfaceOp: 'append' })
+    result.session.append('tool/result', {
+      turn: 1, step: 0, callId: 'c3' as never, content: [{ type: 'text', text: 'done' }], isError: false,
+    }, { surfaceOp: 'append' })
+    result.session.append('tool/result', {
+      turn: 1, step: 0, callId: 'c4' as never, content: [{ type: 'text', text: 'raw generic' }], isError: false,
+    }, { surfaceOp: 'append' })
+    result.session.append('tool/result', {
+      turn: 1, step: 0, callId: 'c5' as never, content: [{ type: 'text', text: 'raw throwing' }], isError: false,
+      meta: { value: 1 },
+    }, { surfaceOp: 'append' })
+    result.session.append('tool/result', {
+      turn: 1, step: 0, callId: 'c7' as never,
+      content: [
+        { type: 'tool-call', id: 'inner' as never, name: 'inner', arguments: '{}' },
+        { type: 'tool-result', toolCallId: 'inner' as never, content: [{ type: 'text', text: 'nested output' }] },
+        { type: 'future-result' } as never,
+      ],
+      isError: false,
+    }, { surfaceOp: 'append' })
+    result.session.append('tool/result', {
+      turn: 1, step: 0, callId: 'c8' as never, content: [{ type: 'text', text: '\nundefined presenter output\n\nkept tail\n' }], isError: false,
+    }, { surfaceOp: 'append' })
+    result.session.append('tool/result', {
+      turn: 1, step: 0, callId: 'c11' as never, content: [{ type: 'text', text: '\nconverted terminal\n\nfinished\n' }], isError: false,
+    }, { surfaceOp: 'append' })
+    result.session.append('tool/result', {
+      turn: 1, step: 0, callId: 'orphan' as never, content: [{ type: 'text', text: 'orphan result' }], isError: false,
+    }, { surfaceOp: 'append' })
+    await tick()
+
+    const output = result.terminal.output
+    expect(output).toContain('Run command')
+    expect(output).toContain('printf hello')
+    expect(output).toContain('more lines')
+    expect(output).toContain('SIGTERM')
+    expect(output).toContain('Edit files')
+    expect(output).toContain('Inspected')
+    expect(output).toContain('result text')
+    expect(output).toContain('Presenter failed')
+    expect(output).toContain('not-json')
+    expect(output).toContain('nested output')
+    expect(output).toContain('[future-result]')
+    expect(output).toContain('undefined presenter output')
+    expect(output).toContain('Empty card')
+    expect(output).toContain('converted terminal')
+    expect(output).toContain('orphan result')
+
+    result.terminal.send('/redraw')
+    result.terminal.send('\r')
+    await tick()
+    result.terminal.send('\x0f')
+    await tick()
+    expect(result.terminal.output).toContain('world')
+    expect(result.terminal.output).toContain('+ created')
+    await dispose(result)
+  })
+
+  it('rebuilds after a surface replacement and hides shadowed tool calls', async () => {
+    const result = await setup({ tools })
+    appendUser(result.session, 'old prompt')
+    const assistant = result.session.append('assistant/message', {
+      turn: 1,
+      step: 0,
+      content: [{ type: 'tool-call', id: 'old-call' as never, name: 'bash', arguments: '{}' }],
+    }, { surfaceOp: 'append' })
+    result.session.append('tool/call', {
+      turn: 1, step: 0, callId: 'old-call' as never, name: 'bash', arguments: '{}',
+    })
+    const toolResult = result.session.append('tool/result', {
+      turn: 1, step: 0, callId: 'old-call' as never, content: [{ type: 'text', text: 'old output' }], isError: false,
+    }, { surfaceOp: 'append' })
+    const start = result.session.surface.nodes[0]?.seq as number
+    result.session.append('context/message', {
+      content: [{ type: 'text', text: 'summary replacement' }],
+      source: { kind: 'plugin', plugin: 'compact' },
+    }, {
+      surfaceOp: { op: 'replace', start, end: toolResult.seq },
+      sourceEventSeqs: [start, assistant.seq, toolResult.seq],
+    })
+    await tick()
+
+    result.terminal.resize(89)
+    await tick()
+    const lastFullRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
+    expect(lastFullRender).toContain('summary replacement')
+    expect(lastFullRender).not.toContain('old output')
+    await dispose(result)
+  })
+})
+
+describe('TUI user-interaction dialogs', () => {
+  it('answers single-select, multi-select, custom, and optionless questions', async () => {
+    const result = await setup({ config: { maxQuestionOptions: 1 } })
+
+    const single = result.ctx.userInteraction.ask({
+      questions: [{
+        id: 'mode', header: 'Mode', question: 'Choose a mode',
+        options: [{ label: 'Safe', description: 'Use checks' }, { label: 'Fast' }],
+      }],
+    })
+    await tick()
+    expect(result.terminal.output).toContain('Choose a mode')
+    expect(result.terminal.output).toContain('1/2')
+    result.terminal.send('\x1b[B')
+    result.terminal.send('\r')
+    await expect(single).resolves.toEqual({ answers: [{ id: 'mode', selected: ['Fast'] }] })
+
+    const multi = result.ctx.userInteraction.ask({
+      questions: [{ id: 'targets', question: 'Pick targets', multiSelect: true, options: [{ label: 'Code' }, { label: 'Docs' }] }],
+    })
+    await tick()
+    result.terminal.send(' ')
+    result.terminal.send('\x1b[B')
+    result.terminal.send(' ')
+    result.terminal.send('\r')
+    await expect(multi).resolves.toEqual({ answers: [{ id: 'targets', selected: ['Code', 'Docs'] }] })
+
+    const custom = result.ctx.userInteraction.ask({
+      questions: [{ id: 'other', question: 'Choose or type', options: [{ label: 'Default' }] }],
+    })
+    await tick()
+    result.terminal.send('c')
+    result.terminal.send('my choice')
+    result.terminal.send('\r')
+    await expect(custom).resolves.toEqual({ answers: [{ id: 'other', selected: [], custom: 'my choice' }] })
+
+    const free = result.ctx.userInteraction.ask({ questions: [{ id: 'note', question: 'Add a note' }] })
+    await tick()
+    result.terminal.send('\r')
+    await tick()
+    expect(result.terminal.output).toContain('Enter an answer before submitting')
+    result.terminal.send('ship it')
+    result.terminal.send('\r')
+    await expect(free).resolves.toEqual({ answers: [{ id: 'note', selected: [], custom: 'ship it' }] })
+    await dispose(result)
+  })
+
+  it('handles option wrapping, deselection errors, and returning from custom input', async () => {
+    const result = await setup({ config: { color: true } })
+    const single = result.ctx.userInteraction.ask({
+      questions: [{ id: 'single', question: 'Single options', options: [{ label: 'One' }, { label: 'Two' }] }],
+    })
+    const singleRejected = expect(single).rejects.toMatchObject({ code: 'ASK_ABORTED' })
+    await tick()
+    expect(result.terminal.output).toContain('Two')
+    result.terminal.send('\x03')
+    await singleRejected
+
+    const answer = result.ctx.userInteraction.ask({
+      questions: [{
+        id: 'options',
+        question: 'Exercise options',
+        multiSelect: true,
+        options: [{ label: 'One', description: 'first' }, { label: 'Two' }],
+      }],
+    })
+    const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
+    await tick()
+    result.terminal.send('\x1b[A')
+    result.terminal.send('\x1b[B')
+    result.terminal.send('\x1b[B')
+    result.terminal.send('\x1b[A')
+    result.terminal.send(' ')
+    await tick()
+    result.terminal.send('x')
+    result.terminal.send(' ')
+    result.terminal.send('\r')
+    await tick()
+    expect(result.terminal.output).toContain('Select at least one option')
+    result.terminal.send('c')
+    await tick()
+    result.terminal.send('\x1b')
+    await tick()
+    expect(result.terminal.output).toContain('Space toggle')
+    result.terminal.send('\x03')
+    await rejected
+    await dispose(result)
+  })
+
+  it('asks batches in order and rejects cancelled or aborted work', async () => {
+    const result = await setup()
+    const preAborted = new AbortController()
+    preAborted.abort()
+    await expect(result.ctx.userInteraction.ask({
+      questions: [{ id: 'pre-aborted', question: 'Already cancelled?' }],
+      signal: preAborted.signal,
+    })).rejects.toMatchObject({ code: 'ASK_ABORTED' })
+
+    const batch = result.ctx.userInteraction.ask({
+      questions: [
+        { id: 'first', question: 'First?', options: [{ label: 'Yes' }] },
+        { id: 'second', question: 'Second?' },
+      ],
+    })
+    await tick()
+    result.terminal.send('\r')
+    await tick()
+    expect(result.terminal.output).toContain('Second?')
+    result.terminal.send('done')
+    result.terminal.send('\r')
+    await expect(batch).resolves.toEqual({ answers: [
+      { id: 'first', selected: ['Yes'] },
+      { id: 'second', selected: [], custom: 'done' },
+    ] })
+
+    const cancelled = result.ctx.userInteraction.ask({ questions: [{ id: 'cancel', question: 'Cancel?' }] })
+    const cancelledExpectation = expect(cancelled).rejects.toMatchObject({ code: 'ASK_ABORTED' })
+    await tick()
+    result.terminal.send('\x1b')
+    await cancelledExpectation
+
+    const controller = new AbortController()
+    const active = result.ctx.userInteraction.ask({ questions: [{ id: 'active', question: 'Active?' }], signal: controller.signal })
+    const queuedController = new AbortController()
+    const queued = result.ctx.userInteraction.ask({ questions: [{ id: 'queued', question: 'Queued?' }], signal: queuedController.signal })
+    const activeExpectation = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' })
+    const queuedExpectation = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' })
+    await tick()
+    queuedController.abort()
+    controller.abort()
+    await activeExpectation
+    await queuedExpectation
+    await dispose(result)
+  })
+
+  it('rejects active and queued dialogs on disposal', async () => {
+    const result = await setup()
+    const active = result.ctx.userInteraction.ask({ questions: [{ id: 'active', question: 'Active?' }] })
+    const queued = result.ctx.userInteraction.ask({ questions: [{ id: 'queued', question: 'Queued?' }] })
+    const activeExpectation = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' })
+    const queuedExpectation = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' })
+    await tick()
+    await result.controller.dispose()
+    await activeExpectation
+    await queuedExpectation
+    await expect(result.ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Late?' }] }))
+      .rejects.toMatchObject({ code: 'NO_PROVIDER' })
+    await result.ctx.fiber.dispose()
+  })
+})
+
+describe('terminal mounting', () => {
+  it('starts immediately when the configured agent already exists', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(AgentRegistry)
+    await ctx.plugin(UserInteractionService)
+    ctx.provide('tools', { get: () => undefined } as never)
+    const session = ctx.sessions.create(SessionId('mounted-session'))
+    ctx.agents.register({
+      id: AgentId('main'), options: {}, session, status: 'idle', ctx,
+      send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
+    })
+    const terminal = new FakeTerminal()
+    mountTui(ctx, { color: false }, { terminal, exit: vi.fn() })
+    await tick()
+    expect(terminal.started).toBe(1)
+    await ctx.fiber.dispose()
+  })
+
+  it('waits for its configured agent before starting the TUI', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(AgentRegistry)
+    await ctx.plugin(UserInteractionService)
+    ctx.provide('tools', { get: () => undefined } as never)
+    const terminal = new FakeTerminal()
+    mountTui(ctx, { agent: 'main', color: false }, { terminal, exit: vi.fn() })
+    expect(terminal.started).toBe(0)
+
+    const otherSession = ctx.sessions.create(SessionId('other-session'))
+    ctx.agents.register({
+      id: AgentId('other'), options: {}, session: otherSession, status: 'idle', ctx,
+      send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
+    })
+    expect(terminal.started).toBe(0)
+
+    const session = ctx.sessions.create(SessionId('late-session'))
+    const agent = {
+      id: AgentId('main'), options: {}, session, status: 'idle', ctx,
+      send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
+    } as Agent
+    ctx.agents.register(agent)
+    await tick()
+    expect(terminal.started).toBe(1)
+    await ctx.fiber.dispose()
+  })
+
+  it('rolls back providers, listeners, and terminal state when startup fails', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(AgentRegistry)
+    await ctx.plugin(UserInteractionService)
+    ctx.provide('tools', { get: () => undefined } as never)
+    const session = ctx.sessions.create(SessionId('failed-start-session'))
+    ctx.agents.register({
+      id: AgentId('main'), options: {}, session, status: 'running', ctx,
+      send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
+    })
+    const terminal = new FakeTerminal()
+    terminal.start = () => { throw new Error('terminal startup failed') }
+
+    expect(() => createTuiChat(ctx, { color: false }, { terminal, exit: vi.fn() }))
+      .toThrow('terminal startup failed')
+    expect(terminal.stopped).toBe(1)
+    expect(terminal.progress).toEqual([false, true, false])
+    await expect(ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Late?' }] }))
+      .rejects.toMatchObject({ code: 'NO_PROVIDER' })
+    session.append('assistant/chunk', {
+      turn: 1,
+      step: 0,
+      chunk: { type: 'text-delta', index: 0, text: 'must not render' },
+    })
+    await tick()
+    expect(terminal.output).not.toContain('must not render')
+    await ctx.fiber.dispose()
+  })
+
+  it('throws when createTuiChat is called without the configured agent', async () => {
+    const ctx = new Context()
+    await ctx.plugin(AgentRegistry)
+    await ctx.plugin(UserInteractionService)
+    ctx.provide('tools', { get: () => undefined } as never)
+    const runtime: TuiRuntime = { terminal: new FakeTerminal(), exit: vi.fn() }
+    expect(() => createTuiChat(ctx, { agent: 'missing' }, runtime)).toThrow('is not running')
+    await ctx.fiber.dispose()
+  })
+})

+ 33 - 0
packages/ui/tui/tsconfig.json

@@ -0,0 +1,33 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../../vendor/schemastery"
+    },
+    {
+      "path": "../../core/agent"
+    },
+    {
+      "path": "../../core/session"
+    },
+    {
+      "path": "../../llm/llm"
+    },
+    {
+      "path": "../../core/tools"
+    },
+    {
+      "path": "../user-interaction"
+    }
+  ]
+}

+ 1 - 1
packages/ui/user-interaction/README.md

@@ -21,7 +21,7 @@ When an answer includes `custom`, `selected` is empty; custom text is an overrid
 
 ## Role
 
-This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; UI front doors such as the `stdio-agent` readline module and the `acp` bridge provide the provider. The loop stays unchanged: a tool call awaits a promise, and the tool result resumes the normal agent loop.
+This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; UI front doors such as the interactive `dsh-tui`, line-oriented `dsh-stdio`, and structured `dsh-acp` channels provide the provider. The loop stays unchanged: a tool call awaits a promise, and the tool result resumes the normal agent loop.
 
 ## Model Experience
 

+ 56 - 0
pnpm-lock.yaml

@@ -583,6 +583,9 @@ importers:
       '@deepseek-ai/dsh-tools':
         specifier: workspace:^
         version: link:../../core/tools
+      '@deepseek-ai/dsh-tui':
+        specifier: workspace:^
+        version: link:../../ui/tui
       '@deepseek-ai/dsh-user-interaction':
         specifier: workspace:^
         version: link:../../ui/user-interaction
@@ -1673,6 +1676,37 @@ importers:
         specifier: ^4.0.0-rc.7
         version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
 
+  packages/ui/tui:
+    dependencies:
+      '@earendil-works/pi-tui':
+        specifier: 0.80.7
+        version: 0.80.7
+      schemastery:
+        specifier: ^3.18.0
+        version: 3.18.0
+    devDependencies:
+      '@cordisjs/plugin-loader':
+        specifier: workspace:^
+        version: link:../../../vendor/loader
+      '@deepseek-ai/dsh-agent':
+        specifier: workspace:^
+        version: link:../../core/agent
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../../core/session
+      '@deepseek-ai/dsh-tools':
+        specifier: workspace:^
+        version: link:../../core/tools
+      '@deepseek-ai/dsh-user-interaction':
+        specifier: workspace:^
+        version: link:../user-interaction
+      cordis:
+        specifier: ^4.0.0-rc.6
+        version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader)
+
   packages/ui/user-approval:
     dependencies:
       schemastery:
@@ -2519,6 +2553,10 @@ packages:
     engines: {node: '>=22.19.0'}
     hasBin: true
 
+  '@earendil-works/pi-tui@0.80.7':
+    resolution: {integrity: sha512-1B2++fLZfgI3XMzW2BTpuDuam2uyHnUUEmsOvi5R0Ne9RAt59WjFV0G8ozX6l1Xafa9P5Y3eT4aDtRr/v/CUTA==}
+    engines: {node: '>=22.19.0'}
+
   '@emnapi/core@1.10.0':
     resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
 
@@ -4293,6 +4331,10 @@ packages:
     resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==}
     engines: {node: '>=18'}
 
+  get-east-asian-width@1.6.0:
+    resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==}
+    engines: {node: '>=18'}
+
   get-intrinsic@1.3.0:
     resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
     engines: {node: '>= 0.4'}
@@ -4773,6 +4815,11 @@ packages:
     engines: {node: '>= 20'}
     hasBin: true
 
+  marked@18.0.5:
+    resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==}
+    engines: {node: '>= 20'}
+    hasBin: true
+
   math-intrinsics@1.1.0:
     resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
     engines: {node: '>= 0.4'}
@@ -6157,6 +6204,11 @@ snapshots:
       - ws
       - zod
 
+  '@earendil-works/pi-tui@0.80.7':
+    dependencies:
+      get-east-asian-width: 1.6.0
+      marked: 18.0.5
+
   '@emnapi/core@1.10.0':
     dependencies:
       '@emnapi/wasi-threads': 1.2.1
@@ -7861,6 +7913,8 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  get-east-asian-width@1.6.0: {}
+
   get-intrinsic@1.3.0:
     dependencies:
       call-bind-apply-helpers: 1.0.2
@@ -8308,6 +8362,8 @@ snapshots:
 
   marked@16.4.2: {}
 
+  marked@18.0.5: {}
+
   math-intrinsics@1.1.0: {}
 
   mdast-util-find-and-replace@3.0.2:

+ 2 - 2
scripts/gen-doc-graphs.ts

@@ -407,7 +407,7 @@ const APP_EXAMPLES = [
     title: 'Coding Agent App Composition',
     label: 'examples/coding-agent',
     config: 'examples/coding-agent/cordis.yml',
-    summary: 'The coding REPL demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package.',
+    summary: 'The coding-agent demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package.',
   },
   {
     id: 'cordis',
@@ -435,7 +435,7 @@ function renderAppExpansion(lines: string[], appNode: string, pluginName: string
   lines.push(`  ${appNode} --> ${agentCore}["@deepseek-ai/dsh-agent-spine-demo"]`)
   lines.push(`  ${appNode} --> ${jsonl}["@deepseek-ai/dsh-session-persistence-jsonl"]`)
   if (pluginName === '@deepseek-ai/dsh-stdio-demo') {
-    lines.push(`  ${appNode} --> ${nodeId('frontdoor', 'stdio')}["readline UI<br/>console logger<br/>pre-created main agent"]`)
+    lines.push(`  ${appNode} --> ${nodeId('frontdoor', 'stdio')}["dsh-tui (TTY) / dsh-stdio (pipes)<br/>pre-created main agent"]`)
   } else if (pluginName === '@deepseek-ai/dsh-acp-demo') {
     lines.push(`  ${appNode} --> ${nodeId('frontdoor', 'acp')}["@deepseek-ai/dsh-acp<br/>JSON-RPC stdio bridge<br/>sessions created by client"]`)
   }

+ 1 - 0
tsconfig.build.json

@@ -61,6 +61,7 @@
     { "path": "./packages/ui/app-boot" },
     { "path": "./packages/ui/jsonrpc" },
     { "path": "./packages/examples/jsonrpc-demo" },
+    { "path": "./packages/ui/tui" },
     { "path": "./packages/ui/stdio" },
     { "path": "./packages/examples/stdio-demo" },
     { "path": "./packages/support/llm-replay" },

+ 1 - 0
tsconfig.json

@@ -72,6 +72,7 @@
     { "path": "./packages/ui/app-boot" },
     { "path": "./packages/ui/jsonrpc" },
     { "path": "./packages/examples/jsonrpc-demo" },
+    { "path": "./packages/ui/tui" },
     { "path": "./packages/ui/stdio" },
     { "path": "./packages/examples/stdio-demo" },
     { "path": "./packages/support/llm-replay" },