mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge pull request #442 from deepseek-harness/codex/tui-windows-contract
Support and verify the TUI on Windows
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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
|
||||
2026-07-20-windows-tui-support.md: 6b728486dd50faac067933ce06f883447aae821f
|
||||
2026-07-20-windows-tui-support.zh.md: 2b53b05ff6231361d79b4304181dc0e6d8e24e68
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Support the TUI on Windows
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-20-windows-tui-support.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The full-screen TUI delegates raw input, ANSI rendering, resize events, and terminal restoration to pi-tui's `ProcessTerminal`. That dependency contains a native Windows console path, but the repository's real-process smoke used Python's POSIX-only `pty` and `termios` modules. Skipping that smoke on Windows would leave the supported product path without coverage for startup, input, interaction, failure reporting, or restoration.
|
||||
|
||||
The TUI platform contract must follow the runtime shipped to users rather than the portability of one test driver. A platform exclusion is justified only when the product has an unsupported runtime dependency or a demonstrated semantic gap.
|
||||
|
||||
## Decision
|
||||
|
||||
[`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) supports interactive terminals on Windows as well as macOS and Linux. The product continues to use pi-tui's `ProcessTerminal`; on Windows it enables virtual-terminal input after raw mode and avoids the Unix-only `SIGWINCH` refresh. DeepSeek Harness adds no platform rejection or reduced Windows mode.
|
||||
|
||||
The real Loader smoke selects a native pseudo-terminal boundary by host. macOS and Linux retain the Python POSIX PTY driver. Windows uses `node-pty` and ConPTY. Both drivers receive the same launch command, environment, terminal dimensions, marker-gated input actions, timeout, expected exit code, and output assertions, and all three smoke scenarios run on every supported platform.
|
||||
|
||||
`node-pty` is a test-only dependency of the examples workspace. Its reviewed native install script is explicitly enabled in `pnpm-workspace.yaml`; production TUI packages do not acquire a new dependency or subprocess layer.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Declare the TUI unsupported on Windows** — rejected because the pinned terminal runtime implements Windows console input explicitly and the harness has no POSIX-only production dependency. A documentation-only exclusion would discard an existing product path to accommodate a test harness gap.
|
||||
- **Run the POSIX driver through MSYS, Cygwin, or WSL** — rejected because that would test a compatibility environment rather than the native Windows console path users run.
|
||||
- **Use `node-pty` on every host** — rejected because the established POSIX driver already provides the macOS and Linux boundary; replacing it would widen the runtime change without improving those hosts. Platform-specific drivers reserve the `node-pty` runtime path for Windows while sharing one scenario contract.
|
||||
- **Rely on renderer unit tests and semantic terminal snapshots** — rejected because fake terminals do not prove Loader boot, real raw input, process exit, or terminal restoration at the operating-system boundary.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The Windows artifact lane executes the startup, scripted interaction, resume-failure, and restoration scenarios, and the suite has no supported-platform skip.
|
||||
- The Windows process proof depends on ConPTY and a pinned `node-pty` release; changing that dependency or its allowed install script requires native-boundary review.
|
||||
- The two PTY drivers can differ internally, but shared inputs and assertions keep their observable TUI contract aligned.
|
||||
- Windows support remains bounded by the Node and pi-tui versions shipped by the repository; unsupported historical Windows console environments do not receive a compatibility layer.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: 在 Windows 上支持 TUI
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-20-windows-tui-support.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
全屏 TUI 将原始输入、ANSI 渲染、终端尺寸变更事件和终端恢复委托给 pi-tui 的 `ProcessTerminal`。该依赖已实现原生 Windows 控制台路径,但仓库的真实进程冒烟测试此前使用 Python 中仅适用于 POSIX 的 `pty` 和 `termios` 模块。若在 Windows 上跳过该测试,这条受支持的产品路径便会缺少针对启动、输入、交互、失败报告和终端恢复的测试覆盖率。
|
||||
|
||||
TUI 平台契约必须以交付给用户的运行时为准,而不是取决于某个测试驱动程序的可移植性。只有产品存在不受支持的运行时依赖,或已证实存在语义缺口时,排除某个平台才有依据。
|
||||
|
||||
## 决策
|
||||
|
||||
[`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 在 Windows、macOS 和 Linux 上均支持交互式终端。产品继续使用 pi-tui 的 `ProcessTerminal`;在 Windows 上,它会在进入原始模式后启用虚拟终端输入,并避开仅适用于 Unix 的 `SIGWINCH` 刷新。DeepSeek Harness 不增加平台拒绝逻辑,也不采用功能受限的 Windows 模式。
|
||||
|
||||
真实 Loader 冒烟测试根据宿主选择原生伪终端边界。macOS 和 Linux 继续使用 Python POSIX PTY 驱动,Windows 则使用 `node-pty` 和 ConPTY。两种驱动接收相同的启动命令、环境、终端尺寸、以标记为触发条件的输入动作、超时、预期退出码和输出断言;3 个冒烟场景都会在每个受支持平台上运行。
|
||||
|
||||
`node-pty` 是 examples 工作区仅供测试使用的依赖。该依赖经评审的原生安装脚本在 `pnpm-workspace.yaml` 中显式启用;生产 TUI 包(package)不会新增依赖或子进程层。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **声明 TUI 不支持 Windows**:不予采纳,因为固定版本的终端运行时已显式实现 Windows 控制台输入,且 harness 没有仅适用于 POSIX 的生产依赖。仅通过文档排除 Windows,等于为迁就测试 harness 的缺口而舍弃现有产品路径。
|
||||
- **通过 MSYS、Cygwin 或 WSL 运行 POSIX 驱动**:不予采纳,因为这会测试兼容环境,而不是用户实际运行的原生 Windows 控制台路径。
|
||||
- **在所有宿主上使用 `node-pty`**:不予采纳,因为现有 POSIX 驱动已经为 macOS 和 Linux 提供所需边界;替换该驱动会扩大运行时变更范围,却不会给这两个宿主带来改进。按平台选择驱动,仅在 Windows 上启用 `node-pty` 运行时路径,同时共享同一份场景契约。
|
||||
- **依赖渲染器单元测试和语义终端快照**:不予采纳,因为模拟终端无法证明 Loader 启动、真实原始输入、进程退出或操作系统边界上的终端恢复。
|
||||
|
||||
## 后果
|
||||
|
||||
- Windows 产物 lane 执行启动、脚本化交互、配置恢复失败和终端恢复场景,这套测试不会在任何受支持平台上跳过。
|
||||
- Windows 进程级验证依赖 ConPTY 和固定版本的 `node-pty`;变更该依赖或允许执行的安装脚本时,必须进行原生边界评审。
|
||||
- 两种 PTY 驱动的内部实现可以不同,但共享的输入和断言会使其可观测 TUI 契约保持一致。
|
||||
- Windows 支持范围以仓库交付的 Node 和 pi-tui 版本为界;不受支持的旧版 Windows 控制台环境不会获得兼容层。
|
||||
@@ -50,5 +50,8 @@
|
||||
"@deepseek-ai/dsh-web": "workspace:*",
|
||||
"@deepseek-ai/dsh-web-fetch-local": "workspace:*",
|
||||
"@deepseek-ai/dsh-workflow-workerthread": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"node-pty": "1.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { spawn } from 'node:child_process'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
|
||||
import { resolveExampleLaunch, type ExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
|
||||
|
||||
const PTY_DRIVER = String.raw`
|
||||
const POSIX_PTY_DRIVER = String.raw`
|
||||
import errno, json, os, pty, select, signal, sys, time
|
||||
node, launch_args_json, launch_env_json, cwd, actions_json, expected_exit, timeout_seconds = sys.argv[1:]
|
||||
env = os.environ.copy()
|
||||
@@ -71,9 +71,103 @@ export interface TuiPtySmokeOptions {
|
||||
readonly timeoutMs?: number
|
||||
}
|
||||
|
||||
function definedEnv(env: NodeJS.ProcessEnv): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined),
|
||||
)
|
||||
}
|
||||
|
||||
async function runPosixPtySmoke(
|
||||
launch: ExampleLaunch,
|
||||
cwd: string,
|
||||
options: TuiPtySmokeOptions,
|
||||
timeoutMs: number,
|
||||
): Promise<string> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn('python3', [
|
||||
'-c',
|
||||
POSIX_PTY_DRIVER,
|
||||
launch.command,
|
||||
JSON.stringify(launch.args),
|
||||
JSON.stringify(launch.env),
|
||||
cwd,
|
||||
JSON.stringify(options.actions ?? []),
|
||||
String(options.expectedExitCode ?? 0),
|
||||
String(timeoutMs / 1_000),
|
||||
], { 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 })
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
reject(new Error(`${options.label} PTY driver did not exit. stdout:\n${stdout}\nstderr:\n${stderr}`))
|
||||
}, timeoutMs + 5_000)
|
||||
child.once('error', (error) => { clearTimeout(timer); reject(error) })
|
||||
child.once('exit', (code) => {
|
||||
clearTimeout(timer)
|
||||
if (code === 0) resolve(stdout)
|
||||
else reject(new Error(`${options.label} PTY driver exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function runWindowsPtySmoke(
|
||||
launch: ExampleLaunch,
|
||||
cwd: string,
|
||||
options: TuiPtySmokeOptions,
|
||||
timeoutMs: number,
|
||||
): Promise<string> {
|
||||
const pty = await import('node-pty')
|
||||
return await new Promise((resolve, reject) => {
|
||||
const actions = options.actions ?? []
|
||||
const expectedExitCode = options.expectedExitCode ?? 0
|
||||
let output = ''
|
||||
let actionIndex = 0
|
||||
let timedOut = false
|
||||
const terminal = pty.spawn(launch.command, launch.args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
cwd,
|
||||
env: definedEnv({
|
||||
...process.env,
|
||||
...launch.env,
|
||||
COLUMNS: '100',
|
||||
LINES: '30',
|
||||
}),
|
||||
})
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true
|
||||
terminal.kill()
|
||||
}, timeoutMs)
|
||||
terminal.onData((chunk) => {
|
||||
output += chunk
|
||||
while (actionIndex < actions.length && output.includes(actions[actionIndex]!.waitFor)) {
|
||||
terminal.write(actions[actionIndex]!.send)
|
||||
actionIndex += 1
|
||||
}
|
||||
})
|
||||
terminal.onExit(({ exitCode, signal }) => {
|
||||
clearTimeout(timer)
|
||||
if (timedOut) {
|
||||
reject(new Error(`${options.label} PTY process did not exit before ${String(timeoutMs)}ms. output:\n${output}`))
|
||||
} else if (actionIndex !== actions.length) {
|
||||
reject(new Error(`${options.label} completed ${String(actionIndex)}/${String(actions.length)} PTY actions. output:\n${output}`))
|
||||
} else if (exitCode !== expectedExitCode) {
|
||||
reject(new Error(`${options.label} expected exit ${String(expectedExitCode)}, got ${String(exitCode)} (signal ${String(signal)}). output:\n${output}`))
|
||||
} else {
|
||||
resolve(output)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot an example in a real pseudo-terminal, drive marker-gated input, and
|
||||
* return the captured terminal bytes after the expected process exit.
|
||||
* Boot an example in a real pseudo-terminal (ConPTY on Windows), drive
|
||||
* marker-gated input, and return captured bytes after the expected process exit.
|
||||
* @param options - launch paths, environment, actions, and expected exit code.
|
||||
* @returns complete pseudo-terminal output.
|
||||
*/
|
||||
@@ -92,35 +186,10 @@ export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise<strin
|
||||
...options.env,
|
||||
},
|
||||
})
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn('python3', [
|
||||
'-c',
|
||||
PTY_DRIVER,
|
||||
launch.command,
|
||||
JSON.stringify(launch.args),
|
||||
JSON.stringify(launch.env),
|
||||
cwd,
|
||||
JSON.stringify(options.actions ?? []),
|
||||
String(options.expectedExitCode ?? 0),
|
||||
String(timeoutMs / 1_000),
|
||||
], { 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 })
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
reject(new Error(`${options.label} PTY driver did not exit. stdout:\n${stdout}\nstderr:\n${stderr}`))
|
||||
}, timeoutMs + 5_000)
|
||||
child.once('error', (error) => { clearTimeout(timer); reject(error) })
|
||||
child.once('exit', (code) => {
|
||||
clearTimeout(timer)
|
||||
if (code === 0) resolve(stdout)
|
||||
else reject(new Error(`${options.label} PTY driver exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`))
|
||||
})
|
||||
})
|
||||
if (process.platform === 'win32') {
|
||||
return await runWindowsPtySmoke(launch, cwd, options, timeoutMs)
|
||||
}
|
||||
return await runPosixPtySmoke(launch, cwd, options, timeoutMs)
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
@@ -8,8 +8,7 @@ const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
|
||||
const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url))
|
||||
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
|
||||
|
||||
// The Python PTY driver imports the POSIX-only pty and termios modules.
|
||||
describe.skipIf(process.platform === 'win32')('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
|
||||
describe('tui-agent 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 runTuiPtySmoke({
|
||||
label: 'tui-agent boot',
|
||||
|
||||
@@ -4,6 +4,8 @@ The interactive terminal front door for DeepSeek Harness agents, built on [`@ear
|
||||
|
||||
The implemented [TUI feature Agent Note](../../../.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) owns the front-door decision; the [terminal-state snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns its verification strategy.
|
||||
|
||||
Interactive terminals on macOS, Linux, and Windows are supported. Windows uses pi-tui's native console VT-input handling, and the [Windows support Agent Note](../../../.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md) owns the platform decision and ConPTY process verification.
|
||||
|
||||
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.
|
||||
|
||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@@ -227,6 +227,10 @@ importers:
|
||||
'@deepseek-ai/dsh-workflow-workerthread':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/workflow/workflow-workerthread
|
||||
devDependencies:
|
||||
node-pty:
|
||||
specifier: 1.1.0
|
||||
version: 1.1.0
|
||||
|
||||
packages/bash/bash:
|
||||
devDependencies:
|
||||
@@ -6179,6 +6183,9 @@ packages:
|
||||
neo-async@2.6.2:
|
||||
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
|
||||
|
||||
node-addon-api@7.1.1:
|
||||
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||
|
||||
node-addon-landlock-run-linux-arm64@0.0.0-test.0:
|
||||
resolution: {integrity: sha512-oJsXcC33qKl9mWYx0n9YPJ2pUAoY39PoIX0Gx4lDrSCTEvENFrEaODAsQYNY+eEGpn9YMN7E+FOftvea3/1FqQ==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -6256,6 +6263,9 @@ packages:
|
||||
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
node-pty@1.1.0:
|
||||
resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==}
|
||||
|
||||
non-layered-tidy-tree-layout@2.0.2:
|
||||
resolution: {integrity: sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==}
|
||||
|
||||
@@ -10605,6 +10615,8 @@ snapshots:
|
||||
|
||||
neo-async@2.6.2: {}
|
||||
|
||||
node-addon-api@7.1.1: {}
|
||||
|
||||
node-addon-landlock-run-linux-arm64@0.0.0-test.0:
|
||||
optional: true
|
||||
|
||||
@@ -10673,6 +10685,10 @@ snapshots:
|
||||
fetch-blob: 3.2.0
|
||||
formdata-polyfill: 4.0.10
|
||||
|
||||
node-pty@1.1.0:
|
||||
dependencies:
|
||||
node-addon-api: 7.1.1
|
||||
|
||||
non-layered-tidy-tree-layout@2.0.2:
|
||||
optional: true
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ peerDependencyRules:
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
lefthook: true
|
||||
# Cross-platform PTY boundary for the TUI process smoke, including ConPTY on Windows.
|
||||
node-pty: true
|
||||
# Pulled in by @earendil-works/pi-ai (optional LLM API backend). pnpm lists
|
||||
# them only because they ship lifecycle scripts, but those are no-ops we don't
|
||||
# need, so we deny them — install still succeeds.
|
||||
|
||||
Reference in New Issue
Block a user