Merge remote-tracking branch 'origin/master' into worktree/pr504-retarget-latest-master

# Conflicts:
#	docs/event-producer-consumer.md
#	examples/tui-agent/README.md
#	examples/tui-agent/composition.md
#	examples/tui-agent/cordis.yml
#	examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt
#	examples/tui-agent/tests/tui-keyless-smoke.e2e.ts
#	packages/ui/tui/README.md
#	vitest.config.ts
This commit is contained in:
Tianyi Cui
2026-07-22 20:55:12 +08:00
182 changed files with 8016 additions and 576 deletions

View File

@@ -1,6 +1,6 @@
# tui-agent
The interactive coding agent: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite + subagent delegation + workflows + compaction + JSONL persistence, on the full-screen `dsh-tui` front door, loaded from `cordis.yml`. The sibling [`headless-agent`](../headless-agent/README.md) runs the same capability class as a one-shot pipe-friendly task, and [`acp-agent`](../acp-agent/README.md) serves it over JSON-RPC.
The full-screen interactive coding agent: DeepSeek V4, local bash and filesystem tools, compaction, subagents, workflows and fresh-agent Ralph iteration, plan mode (`/plan` enters and `exit_plan_mode` reviews the exit), timeout/spill policy, and JSONL persistence through [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo), loaded from `cordis.yml`. The sibling [`headless-agent`](../headless-agent/README.md) runs the same capability class as a one-shot pipe-friendly task, and [`acp-agent`](../acp-agent/README.md) serves it over JSON-RPC.
## Run it
@@ -17,7 +17,7 @@ Type a coding task. The agent works through the `read`/`write`/`edit` filesystem
The `todo_write` task tracker is opt-in and not in the shipped config: add `@deepseek-ai/dsh-tool-todo` to `cordis.yml` (or a personal-config overlay under `~/.dsh`) to expose it. Once loaded, the model records a whole-list plan to the session log and the TUI renders it.
The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and — when `todo_write` is loaded — the latest plan. Long tool bodies keep a head/tail preview; Ctrl+O expands or collapses every card. Enter submits or steers while the agent runs, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `/status` expands the current session's identity, activity counts, exact token/cache buckets, context use, and timestamps without interrupting a running turn. `/model` opens a keyboard selector for the current provider catalog; use Up/Down and Enter, or `/model <model>` and `/model <provider>/<model>` for direct selection. `ask_user_question` opens a wide bottom-left keyboard panel with batch progress and numbered options.
The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and — when `todo_write` is loaded — the latest plan. Long tool bodies keep a head/tail preview; Ctrl+O expands or collapses every card. Enter submits or steers while the agent runs, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `/plan` selects plan mode for the next step; `/plan <message>` also submits the message into that step. `/status` expands the current session's identity, activity counts, exact token/cache buckets, context use, and timestamps without interrupting a running turn. `/model` opens a keyboard selector for the current provider catalog; use Up/Down and Enter, or `/model <model>` and `/model <provider>/<model>` for direct selection. `ask_user_question` opens a wide bottom-left keyboard panel with batch progress and numbered options.
### Resuming a prior session
@@ -57,6 +57,7 @@ This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads
| `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`) |
| `workflow-workerthread`, `tool-workflow` | the worker-thread workflow engine and its model-facing `workflow` tool, with child calls routed through the spawn backend |
| `plan-mode` | the plugin-owned `/plan [message]` command, plan-mode prompt policy, tool restrictions, and reviewed `exit_plan_mode` transition |
| `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`)
@@ -70,7 +71,7 @@ The UI-independent with-key suites assemble the full stack programmatically thro
- `tests/todo-write.e2e.ts` — loads the opt-in `todo_write` tool, then a real model drives it and the test verifies the resulting `todo/write` session event.
- `tests/code-mode.e2e.ts` — the with-key Code Mode proof: a real model, a two-tool task, asserting the wire tool list was exactly `[run_code]`, the `tool/code-dispatch` events landed under the parent call, and the curated answer came back.
These self-skip without `DEEPSEEK_API_KEY`. The keyless `tests/tui-keyless-smoke.e2e.ts` boots the real Loader tree in a PTY (the one sanctioned PTY surface): the base boot + `/exit`, a scripted-LLM conversation with a question dialog and tool round-trip, the Code Mode overlay welcome line, and the resume-failure exit path.
These self-skip without `DEEPSEEK_API_KEY`. The keyless `tests/tui-keyless-smoke.e2e.ts` boots the real Loader tree in a PTY (the one sanctioned PTY surface): the base boot + `/plan` + `/exit`, a scripted-LLM conversation with a question dialog and tool round-trip, the Code Mode overlay welcome line, and the resume-failure exit path.
## Snapshot tests

View File

@@ -47,6 +47,8 @@ flowchart LR
cfg --> plugin_tui_tool_workflow
plugin_tui_tool_ralph["tool-ralph<br/>@deepseek-ai/dsh-tool-ralph"]
cfg --> plugin_tui_tool_ralph
plugin_tui_plan_mode["plan-mode<br/>@deepseek-ai/dsh-plan-mode"]
cfg --> plugin_tui_plan_mode
plugin_tui_fs_local["fs-local<br/>@deepseek-ai/dsh-fs-local"]
cfg --> plugin_tui_fs_local
plugin_tui_fs_policy["fs-policy<br/>@deepseek-ai/dsh-fs-policy"]
@@ -81,6 +83,7 @@ flowchart LR
| `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` |
| `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` |
| `tool-ralph` | `@deepseek-ai/dsh-tool-ralph` |
| `plan-mode` | `@deepseek-ai/dsh-plan-mode` |
| `fs-local` | `@deepseek-ai/dsh-fs-local` |
| `fs-policy` | `@deepseek-ai/dsh-fs-policy` |
| `tool-fs` | `@deepseek-ai/dsh-tool-fs` |

View File

@@ -122,6 +122,24 @@
- id: tool-ralph
name: '@deepseek-ai/dsh-tool-ralph'
# Plan mode gives the TUI a plugin-owned /plan [message] command; the exit
# review rides the TUI's user-interaction provider.
- id: plan-mode
name: '@deepseek-ai/dsh-plan-mode'
config:
section: |
You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.
The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode.
Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out.
Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions.
When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation.
# Policy loads before the model-facing filesystem tools so writes and edits require
# an observed file. This single-session app resolves relative paths from the process cwd.
- id: fs-local

View File

@@ -15,6 +15,11 @@
- id: token-meter
name: '@deepseek-ai/dsh-token-meter'
- id: plan-mode
name: '@deepseek-ai/dsh-plan-mode'
config:
section: 'Stay in plan mode for this scripted TUI test.'
- id: tui-agent
name: '@deepseek-ai/dsh-tui-demo'
config:

View File

@@ -1,7 +1,7 @@
terminal 100x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "Reply with exactly the word: — DSH TUI snapshot"
cursor hidden column=1 viewportRow=26 bufferRow=26
cursor hidden column=1 viewportRow=28 bufferRow=28
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
@@ -11,50 +11,53 @@ buffer
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| <blank>
4| " "
4| " Entering plan mode (applies from the next step). "
style 1-48 fg=bright-black
5| <blank>
6| "▌ "
style 0-0 fg=bright-blue
5| "▌ You "
7| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
6| "▌ Reply with exactly the word: ONE. No tools. "
8| "▌ Reply with exactly the word: ONE. No tools. "
style 0-0 fg=bright-blue
7| "▌ "
9| "▌ "
style 0-0 fg=bright-blue
8| <blank>
9| " Reasoning "
10| <blank>
11| " Reasoning "
style 1-9 fg=bright-black italic
10| " The user wants me to reply with exactly the word \"ONE\" and use no tools. "
12| " The user wants me to reply with exactly the word \"ONE\" and use no tools. "
style 1-72 fg=bright-black italic
11| <blank>
12| " Assistant "
13| <blank>
14| " Assistant "
style 1-9 fg=bright-magenta bold
13| " ONE "
14| <blank>
15| "▌ "
15| " ONE "
16| <blank>
17| "▌ "
style 0-0 fg=bright-blue
16| "▌ You "
18| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
17| "▌ Reply with exactly the word: TWO. No tools. "
19| "▌ Reply with exactly the word: TWO. No tools. "
style 0-0 fg=bright-blue
18| "▌ "
20| "▌ "
style 0-0 fg=bright-blue
19| <blank>
20| " Reasoning "
21| <blank>
22| " Reasoning "
style 1-9 fg=bright-black italic
21| " The user wants me to reply with exactly the word \"TWO\" and no tools. "
23| " The user wants me to reply with exactly the word \"TWO\" and no tools. "
style 1-68 fg=bright-black italic
22| <blank>
23| " Assistant "
24| <blank>
25| " Assistant "
style 1-9 fg=bright-magenta bold
24| " TWO "
25| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
26| " "
style 1-1 inverse
26| " TWO "
27| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
28| "deepseek-v4-flash /workspace/project ↑2.9k ↓41 cache 49% 3% co"
28| " "
style 1-1 inverse
29| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
30| "deepseek-v4-flash /workspace/project ↑2.9k ↓41 cache 49% 3% co"
style 0-92 dim
style 95-99 dim
29-35| <blank>
31-35| <blank>

View File

@@ -70,17 +70,21 @@ const SELECT_PRO_MODEL = [
] as const
describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
it('boots pi-tui, sweeps the borderless banner in, accepts /exit, and restores the terminal', async () => {
it('boots pi-tui, sweeps the borderless banner in, enters plan mode, and restores the terminal', async () => {
// With no configured welcome the borderless banner sweeps in left-to-right;
// the detail line's session id (`main-session-<uuid>`) renders only once
// the sweep reaches it, so it marks a settled banner.
const output = await smoke({
label: 'tui-agent boot',
actions: [{ waitFor: 'main-session-', send: '/exit\r' }],
actions: [
{ waitFor: 'main-session-', send: '/plan\r' },
{ waitFor: 'Entering plan mode (applies from the next step).', send: '/exit\r' },
],
})
expect(output).toContain('DEEPSEEK')
expect(output).toContain('HARNESS')
expect(output).toContain('main-session-')
expect(output).toContain('Entering plan mode (applies from the next step).')
// Borderless: no box-drawing frame around the banner.
expect(output).not.toContain('╭')
expect(output).not.toContain('╮')
@@ -94,7 +98,7 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
configPath: scriptedConfigPath,
actions: [
...SELECT_PRO_MODEL,
{ waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: 'exercise the TUI\r' },
{ waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: '/plan exercise the TUI\r' },
{ waitFor: 'How should the scripted run proceed?', send: '\r' },
{ waitFor: 'Decision received. Scripted TUI run complete.', send: '' },
// Session title: the first user message drives the first-message-llm
@@ -108,6 +112,7 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
],
})
expect(output).toContain('I need one decision before I continue.')
expect(output).toContain('Entering plan mode (applies from the next step).')
expect(output).toContain(String.raw`\x1b]2;MODEL_CONTROLLED\x07`)
expect(output).toContain(String.raw`\x1b[999CMODEL_CURSOR`)
expect(output).toContain(String.raw`\x9b31mMODEL_C1`)

View File

@@ -15,6 +15,7 @@ import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
import PlanModeService from '@deepseek-ai/dsh-plan-mode'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { SessionId } from '@deepseek-ai/dsh-session'
@@ -45,6 +46,7 @@ interface Scenario {
expectedTools: string[]
expectedEventCounts?: Record<string, number>
childSessions?: number
enterPlanMode?: boolean
recorded: boolean
seedWorkspace?: boolean
/**
@@ -60,6 +62,8 @@ const SCENARIOS: Scenario[] = [
name: 'multi-turn-conversation',
composition: 'native',
expectedTools: [],
expectedEventCounts: { 'plan/mode': 1 },
enterPlanMode: true,
recorded: true,
},
{
@@ -213,6 +217,9 @@ async function mountScenarioContext(
await ctx.plugin(ToolWorkflow)
await ctx.plugin(ToolRalph)
await ctx.plugin(CommandService)
if (scenario.enterPlanMode === true) {
await ctx.plugin(PlanModeService, { section: 'Snapshot plan mode instructions.' })
}
if (scenario.composition === 'code' || scenario.composition === 'advanced') {
await ctx.plugin(WorkerCodeRuntime, {})
}
@@ -277,7 +284,17 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
})
await settleTerminal(terminal)
for (const prompt of prompts) {
let remainingPrompts = prompts
if (scenario.enterPlanMode === true) {
const firstPrompt = prompts[0]!
terminal.send(`/plan ${firstPrompt}`)
terminal.send('\r')
await agent.whenIdle()
await settleTerminal(terminal)
remainingPrompts = prompts.slice(1)
}
for (const prompt of remainingPrompts) {
terminal.send(prompt)
terminal.send('\r')
await agent.whenIdle()
@@ -289,6 +306,18 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
for (const [type, count] of Object.entries(scenario.expectedEventCounts ?? {})) {
expect(events.filter(event => event.type === type), `${scenario.name} must emit ${type}`).toHaveLength(count)
}
if (scenario.enterPlanMode === true) {
expect(ctx.planMode.get(agent)).toEqual({ active: true })
const planMode = events.find(event => event.type === 'plan/mode')
const firstHeader = events.find(event => event.type === 'request/header')
if (planMode === undefined || firstHeader === undefined) {
throw new Error('plan-mode command snapshot needs plan/mode before its first request/header')
}
expect(planMode.seq).toBeLessThan(firstHeader.seq)
expect(firstHeader.data.header.system).toContain('Snapshot plan mode instructions.')
const firstMessage = events.find(event => event.type === 'user/message')
expect(firstMessage?.data.content).toEqual([{ type: 'text', text: prompts[0] }])
}
expect(events.filter(event => event.type === 'tool/result').every(event => !event.data.isError)).toBe(true)
expect(events.filter(event => event.type === 'turn/end').every(event => event.data.reason.kind !== 'error')).toBe(true)
if (scenario.name === 'dynamic-workflow' || scenario.name === 'cordis-dynamic-toolchain') {