diff --git a/AGENTS.md b/AGENTS.md index 23d7c0263b..f695fbac3b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ packages/ Harness packages at packages///, 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 diff --git a/README.i18n.yaml b/README.i18n.yaml index 790812344d..628d7168da 100644 --- a/README.i18n.yaml +++ b/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 diff --git a/README.md b/README.md index 53dd3896eb..92cbfaa819 100644 --- a/README.md +++ b/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) ``` diff --git a/README.zh.md b/README.zh.md index ab826f6265..28604ad1e2 100644 --- a/README.zh.md +++ b/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) ``` diff --git a/docs/architecture.md b/docs/architecture.md index 848980a45a..0f6de3af03 100644 --- a/docs/architecture.md +++ b/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 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 91fe7c6497..79aa6ce17a 100644 --- a/docs/config-catalog.md +++ b/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 diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index 207ea114e0..598e9827b1 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/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 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 3474bc116b..02dc22ff72 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/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 diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 1a605b20fe..a204abdf66 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/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 共享主干。 ## 功能→机制映射 diff --git a/docs/core-data-structures/user-interaction.md b/docs/core-data-structures/user-interaction.md index 4edc415039..afe61af475 100644 --- a/docs/core-data-structures/user-interaction.md +++ b/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) diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 494eb09922..1d687a3f90 100644 --- a/docs/development.i18n.yaml +++ b/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 diff --git a/docs/development.md b/docs/development.md index b3c338f03f..512fdcd89b 100644 --- a/docs/development.md +++ b/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 diff --git a/docs/development.zh.md b/docs/development.zh.md index 20f5c585dd..9f2038a99b 100644 --- a/docs/development.zh.md +++ b/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 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d8c924b24d..b67788ea6b 100644 --- a/docs/event-producer-consumer.md +++ b/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) | diff --git a/docs/i18n/translation-prompt.md b/docs/i18n/translation-prompt.md index 8bc15d6e8e..4dd877bea4 100644 --- a/docs/i18n/translation-prompt.md +++ b/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)` diff --git a/docs/module-graph.md b/docs/module-graph.md index 82763bdc55..e5d56709a8 100644 --- a/docs/module-graph.md +++ b/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) | diff --git a/examples/README.md b/examples/README.md index 5db1e18372..25c017824f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -15,7 +15,7 @@ Run with: `pnpm run demo:echo`. When prompted, type "echo " 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. diff --git a/examples/coding-agent/README.md b/examples/coding-agent/README.md index 9e627324d4..c1c4747a22 100644 --- a/examples/coding-agent/README.md +++ b/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= 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) diff --git a/examples/coding-agent/composition.md b/examples/coding-agent/composition.md index 90ff8222b9..35835953ac 100644 --- a/examples/coding-agent/composition.md +++ b/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
console logger
pre-created main agent"] + plugin_coding_stdio_agent --> frontdoor_stdio["dsh-tui (TTY) / dsh-stdio (pipes)
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"] diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index 61e389ebc5..568988bd59 100644 --- a/examples/coding-agent/cordis.yml +++ b/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: | diff --git a/examples/coding-agent/tests/tui-keyless-smoke.e2e.ts b/examples/coding-agent/tests/tui-keyless-smoke.e2e.ts new file mode 100644 index 0000000000..a1057ba02f --- /dev/null +++ b/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 { + 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) +}) diff --git a/examples/cordis-agent/composition.md b/examples/cordis-agent/composition.md index 6a08c379fc..499379482f 100644 --- a/examples/cordis-agent/composition.md +++ b/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
console logger
pre-created main agent"] + plugin_cordis_stdio_agent --> frontdoor_stdio["dsh-tui (TTY) / dsh-stdio (pipes)
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"] diff --git a/examples/echo-agent/README.md b/examples/echo-agent/README.md index d6156130db..183268f5da 100644 --- a/examples/echo-agent/README.md +++ b/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 ". 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. diff --git a/examples/echo-agent/composition.md b/examples/echo-agent/composition.md index 5160bd20b7..bdc4d7e21d 100644 --- a/examples/echo-agent/composition.md +++ b/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
console logger
pre-created main agent"] + plugin_echo_stdio_agent --> frontdoor_stdio["dsh-tui (TTY) / dsh-stdio (pipes)
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"] diff --git a/examples/echo-agent/cordis.yml b/examples/echo-agent/cordis.yml index fbf998e770..516c53522e 100644 --- a/examples/echo-agent/cordis.yml +++ b/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: diff --git a/knip.json b/knip.json index b978da3a76..502ded1657 100644 --- a/knip.json +++ b/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": { ".": { diff --git a/packages/examples/README.md b/packages/examples/README.md index 5ee0206d0f..558bcb205c 100644 --- a/packages/examples/README.md +++ b/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. diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index eb56f9515c..a1f98d8167 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/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. diff --git a/packages/examples/stdio-demo/README.md b/packages/examples/stdio-demo/README.md index 98f639491e..96704998c4 100644 --- a/packages/examples/stdio-demo/README.md +++ b/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. diff --git a/packages/examples/stdio-demo/package.json b/packages/examples/stdio-demo/package.json index 3fe11f47a2..d20e210b8f 100644 --- a/packages/examples/stdio-demo/package.json +++ b/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:^", diff --git a/packages/examples/stdio-demo/src/bin.ts b/packages/examples/stdio-demo/src/bin.ts index 462e821e50..278bb5faca 100644 --- a/packages/examples/stdio-demo/src/bin.ts +++ b/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 */ diff --git a/packages/examples/stdio-demo/src/index.ts b/packages/examples/stdio-demo/src/index.ts index 73f163d7f4..055e266498 100644 --- a/packages/examples/stdio-demo/src/index.ts +++ b/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 = 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 { + 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 = 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 = 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 */ diff --git a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts index b5fb0e0207..472afaa0b7 100644 --- a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts +++ b/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(run: () => Promise): Promise { } 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. diff --git a/packages/examples/stdio-demo/tsconfig.json b/packages/examples/stdio-demo/tsconfig.json index bb360810a5..3145047c03 100644 --- a/packages/examples/stdio-demo/tsconfig.json +++ b/packages/examples/stdio-demo/tsconfig.json @@ -38,6 +38,9 @@ { "path": "../../ui/stdio" }, + { + "path": "../../ui/tui" + }, { "path": "../../ui/tool-ask-user" }, diff --git a/packages/todo/README.md b/packages/todo/README.md index b6a9ce2fc5..bfe5ec7503 100644 --- a/packages/todo/README.md +++ b/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. diff --git a/packages/todo/tool-todo/README.md b/packages/todo/tool-todo/README.md index 0b07539f3d..f6097abe96 100644 --- a/packages/todo/tool-todo/README.md +++ b/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 diff --git a/packages/ui/README.md b/packages/ui/README.md index 699a11bdb4..3dd26d80a8 100644 --- a/packages/ui/README.md +++ b/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. diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index 3650cf27d4..27346efcec 100644 --- a/packages/ui/acp/README.md +++ b/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 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md new file mode 100644 index 0000000000..1e678beac8 --- /dev/null +++ b/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. diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json new file mode 100644 index 0000000000..e960f33e65 --- /dev/null +++ b/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" + } +} diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts new file mode 100644 index 0000000000..14f45242d7 --- /dev/null +++ b/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 = 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 = 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 => 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() + + 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(([, 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['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() + private mode: 'options' | 'custom' + private error = '' + private readonly input = new Input() + private readonly options: NonNullable + 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[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 +} + +function activeSurfaceSeqs(session: Session): Set { + return new Set(session.surface.nodes.map(node => node.seq)) +} + +function activeToolCallIds(session: Session, active: ReadonlySet): Set { + const ids = new Set() + 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 | undefined + const tokens = sessionTokens(agent.session) + const toolCards = new Map() + const allToolCards = new Set() + const liveErrors = new Set() + 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): 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((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 => { + 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 { + 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 */ diff --git a/packages/ui/tui/tests/plugin-shape.spec.ts b/packages/ui/tui/tests/plugin-shape.spec.ts new file mode 100644 index 0000000000..d1e4b92f3d --- /dev/null +++ b/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 + 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') + }) +}) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts new file mode 100644 index 0000000000..e4ffe2efd0 --- /dev/null +++ b/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 { + await new Promise(resolve => setTimeout(resolve, 25)) +} + +async function setup(options: { + status?: AgentStatus + config?: Config + tools?: Record + 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>): Promise { + 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 = { + 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() + }) +}) diff --git a/packages/ui/tui/tsconfig.json b/packages/ui/tui/tsconfig.json new file mode 100644 index 0000000000..f3a85f76e4 --- /dev/null +++ b/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" + } + ] +} diff --git a/packages/ui/user-interaction/README.md b/packages/ui/user-interaction/README.md index bd5c5281ca..b758ad09e0 100644 --- a/packages/ui/user-interaction/README.md +++ b/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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1a3a78030..e4a9019a7f 100644 --- a/pnpm-lock.yaml +++ b/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: diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 31df485e1d..cbe9272ac1 100644 --- a/scripts/gen-doc-graphs.ts +++ b/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
console logger
pre-created main agent"]`) + lines.push(` ${appNode} --> ${nodeId('frontdoor', 'stdio')}["dsh-tui (TTY) / dsh-stdio (pipes)
pre-created main agent"]`) } else if (pluginName === '@deepseek-ai/dsh-acp-demo') { lines.push(` ${appNode} --> ${nodeId('frontdoor', 'acp')}["@deepseek-ai/dsh-acp
JSON-RPC stdio bridge
sessions created by client"]`) } diff --git a/tsconfig.build.json b/tsconfig.build.json index 3a57169005..7655069a29 100644 --- a/tsconfig.build.json +++ b/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" }, diff --git a/tsconfig.json b/tsconfig.json index 6585a987d6..1f18ea3a55 100644 --- a/tsconfig.json +++ b/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" },