fix(cli): restore bounded OpenTUI dialogs and add migration gates (#14933)

* fix(cli): restore bounded OpenTUI dialogs and add migration gates

* fix(tui): harden migration cleanup and evidence after review

* test(tui): fail acceptance on unhandled errors
This commit is contained in:
ChiGao authored and GitHub committed 2026-10-21 10:05:14 +08:00
1 parent f8c3388470
commit 6e6598e35c
24 files changed
+2192 -181

No files matched your search

+54 -1
View File
@@ -3,8 +3,14 @@ name: 'tui-parity'
on:
pull_request:
paths:
- 'packages/cli/**'
- 'packages/**'
- 'scripts/tui-parity/**'
- 'scripts/build*'
- 'integration-tests/**'
- 'esbuild.config.js'
- '.github/workflows/tui-parity.yml'
- 'package.json'
- 'pnpm-lock.yaml'
workflow_dispatch:
jobs:
@@ -55,10 +61,57 @@ jobs:
QWEN_SKIP_PREPARE: '1'
run: |-
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
- name: 'Isolate TUI fixture settings'
run: |-
set -euo pipefail
umask 077
fixture_root="$(mktemp -d "${RUNNER_TEMP}/qwen-tui-parity.XXXXXX")"
mkdir "$fixture_root/home" "$fixture_root/runtime"
printf '{"$version":4}\n' > "$fixture_root/system.json"
printf '{"$version":4}\n' > "$fixture_root/defaults.json"
{
echo "COREPACK_HOME=${COREPACK_HOME:-${XDG_CACHE_HOME:-${LOCALAPPDATA:-$HOME/.cache}}/node/corepack}"
echo "QWEN_TUI_FIXTURE_ROOT=$fixture_root"
echo "HOME=$fixture_root/home"
echo "QWEN_HOME=$fixture_root/home"
echo "QWEN_RUNTIME_DIR=$fixture_root/runtime"
echo "QWEN_CODE_SYSTEM_SETTINGS_PATH=$fixture_root/system.json"
echo "QWEN_CODE_SYSTEM_DEFAULTS_PATH=$fixture_root/defaults.json"
} >> "$GITHUB_ENV"
- name: 'Build cli'
run: 'npm run build'
- name: 'Component parity snapshots (ink vs opentui)'
run: 'bash scripts/tui-parity/component-parity.sh'
- name: 'Bundle the product for runtime and renderer comparison'
run: 'npm run bundle'
- name: 'Three-leg product acceptance (Node+Ink, Bun+Ink, Bun+OpenTUI)'
env:
QWEN_SANDBOX: 'false'
QWEN_TUI_ACCEPTANCE_OUT: '${{ runner.temp }}/tui-migration-${{ github.run_id }}-${{ github.run_attempt }}'
run: |-
set -euo pipefail
QWEN_HOME="$(mktemp -d "${RUNNER_TEMP}/tui-migration-home.XXXXXX")"
export QWEN_HOME
trap 'rm -rf "$QWEN_HOME"' EXIT
cd integration-tests
npx vitest run renderer-matrix.test.ts interactive/interactive-session-viewport.test.ts --retry=0 --maxWorkers=1 --minWorkers=1 --dangerouslyIgnoreUnhandledErrors=false
failed=0
for leg in node:ink bun:ink bun:opentui; do
export QWEN_E2E_RUNTIME="${leg%%:*}"
export QWEN_E2E_RENDERER="${leg#*:}"
npx vitest run interactive/tui-migration.test.ts --retry=0 --maxWorkers=1 --minWorkers=1 --dangerouslyIgnoreUnhandledErrors=false || failed=1
done
exit "$failed"
- name: 'Upload product acceptance evidence'
if: '${{ always() }}'
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
with:
name: 'tui-migration-${{ github.event.pull_request.head.sha || github.sha }}-attempt-${{ github.run_attempt }}'
path: '${{ runner.temp }}/tui-migration-${{ github.run_id }}-${{ github.run_attempt }}/'
if-no-files-found: 'warn'
- name: 'Remove TUI fixture settings'
if: "${{ always() && env.QWEN_TUI_FIXTURE_ROOT != '' }}"
run: 'rm -rf -- "$QWEN_TUI_FIXTURE_ROOT"'
noflicker:
name: 'OpenTUI no-flicker gate'
@@ -0,0 +1,62 @@
# OpenTUI hook content and region budgets (MYWO-3 / MYWO-13)
[English](2026-10-13-opentui-hook-content.md) | [简体中文](2026-10-13-opentui-hook-content.zh-CN.md)
## Status and baseline
Restored on main baseline `2b5c4deadb9e9dd0d9d740a52ae42f28ad14bf92` as U1 of the [migration delivery plan](2026-10-20-opentui-migration-delivery.md). Candidate verification is tracked separately from implementation.
Source attribution: [#14044](https://github.com/QwenLM/qwen-code/pull/14044), commits `10a98a72ee2c78323c902f7d01f2a05db371a5d0` and `7cb0a86fbf709da0da24f6acb0a14465e59b9c36`, authored by gary.gq with multica-agent. That PR merged into `fix/MYWO-7-short-dialog-budgets`, not main. Restore only its eight-file delta from `e16490eccc5aeffbb680da7c0935b5aebfe82f1b`, preserving subsequent main changes. Historical passing results do not certify this candidate.
## Reproduction
The restored native test script fails against unmodified main `2b5c4deadb` with Bun 1.4.0 and OpenTUI 0.5.10: empty Hooks at width 20 / region 1 paints five extra border rows outside the region. It exits with a business assertion, not an import or compile failure. The test stops there; the detail and Diff paths were not reached by this baseline run.
Historical #14044 observations at its stacked base: sandboxed Diff is 12 rows at width 20 and overpaints regions 1–6; non-sandboxed Diff fits. Hooks detail at width 40 is 102 rows; its long literal head is visible but middle and tail are unreachable, with no scrollbox. Matchers also overpaint regions 1–6. These observations use captureCharFrame and native geometry with mock keyboard input, not a PTY. Width 11 body reachability remains a separate limitation requiring its own evidence.
## Decisions
Reuse the existing scrollDialogLayout budget to retain a content row and shed optional chrome when it cannot fit. Share that policy with sandboxed Diff and hooks. Bound hook details with a native scrollbox; retain literal newlines and make every physical text row independently scrollable to avoid the native multiline Text tail limitation. Use a stacked label/value layout when narrow columns cannot pay the label column. Detail supports wheel, arrow/page keys and Home/End; Esc preserves drill-down semantics. Diff focuses its existing scrollbox so native keyboard scrolling can reach the sandbox notice tail. Viewport changes reset detail scrolling to the head; an unchanged viewport preserves its offset and all content. Lists preserve selection and use existing regionListWindow arrow accounting. No hook configuration, permission or asynchronous resource behavior changes.
## Validation and acceptance
B2-1: capture long literal head/middle/tail and trailing fields, including all intermediate rows. B2-2: capture sandboxed true/false at normal, region 1–6, narrow widths and resize, with no painted character below the region. B2-3: render the matchers path with a region, test returns and measure each of the six chrome leads before changing it. B2-4: native behavior mutation must fail content/overpaint assertions; run related OpenTUI and Ink tests plus build, bundle, typecheck, lint and formatting with original exit codes. B2-5: synchronized bilingual design, independent PR with exact head/base and requirement matrix. See the requirement results and delivery logs below.
## Mechanism and historical audit
Compact mode sheds the title/count, notice/banner, explanatory headers, footer hint and vertical padding; regions 1–2 also omit the border. Payload text stays in a bounded scrollbox, including empty-list explanations. Detail values use a stacked layout whenever an 18-column label would leave less than two columns for wide glyphs. Prewrapping reserves the native scrollbar column and retains literal whitespace/newlines in separate physical-row nodes. It wraps at word boundaries with hard wrapping only for words wider than the available content width (`hard: true`, `trim: false`). Native scans at width 29/detail and width 40/empty state verify complete `settings.json` and `event` words across reachable frames. The title uses one colored-span text run so its painted wrapping equals its combined measurement.
A width or body-budget/compact-border change remounts the read-only scrollbox and resets to the head. Resizing while its viewport is unchanged preserves the offset. Both policies are tested through native scroll position and painted content, not node identity. Esc remains available even when the hint is hidden. Native bordered boxes can paint outside a parent text clip, so explicit geometry and character-frame assertions are both required.
The compact detail view omits its heading so its first row contains a hook field. Word-boundary evidence keeps the frame where each word is seen, and the literal corpus includes Han, kana and Hangul. The two-mode chrome threshold remains intentional; a larger region can restore explanatory chrome and reduce the number of visible list rows. Global-disabled Hooks still needs a separate parity change: Ink presents a disabled state, while this OpenTUI view can hide its banner in compact mode. That gap remains in the capability inventory and blocks final parity acceptance.
On POSIX, review hardening keeps HUP as the first PTY close signal, allows ten seconds for the product's five-second cleanup budget, then kills the owned process group and waits up to five more seconds for exit. Windows uses the PTY backend to terminate the child. Forced cleanup remains a test failure, and callers still release their other fixtures. CI evidence names and directories include the run attempt; fixture settings and runtime directories are isolated before build and removed even after failure. The new acceptance commands explicitly make unhandled errors fatal on every runner. These changes require fresh verification on the resulting commit; earlier acceptance remains historical evidence.
| Historical lead | #14044 source evidence and native run rows at terminal 20 / 40 / 100 | Historical disposition |
| ------------------------- | -------------------------------------------------------------------- | ------------------------------ |
| C1 extensions hint/status | Measured runs; hint 8/3/1, sampled status 10/4/2 | Old undercharge not reproduced |
| C2 MCP step header | Measured header runs; sample 6/2/1 | Old undercharge not reproduced |
| C3 theme/scope footer | Measured runs; theme 5/2/1, scope 4/2/1 | Old undercharge not reproduced |
| C4 theme title | Actual title-column width 3/12/39; 5/2/1 rows | Old undercharge not reproduced |
| C5 theme labels | Name/type clipped to remaining columns; 1/1/1 | Old undercharge not reproduced |
| C6 auth Shell title | shellBodyChromeRows measures title; sample 5/2/1 | Old undercharge not reproduced |
These are historical representative run measurements with charge = native height = painted rows, not current-candidate acceptance or a complete six-dialog short-region certification. No corresponding module is changed in U1.
## Candidate acceptance
| Requirement | Required evidence |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| B2-1 | Native long literal head/middle/tail, all 80 intermediate glyphs, CJK and trailing fields; 20/28/29/40/100 columns, regions 1–6/15/40, keyboard and resize. Width 11 remains outside this acceptance. |
| B2-2 | Sandboxed true/false geometry and region-external character frames, plus End/Home reachability of the sandbox notice. Sandbox must not spawn Git. The native non-sandbox case runs in an empty temporary cwd; long working-tree Diff belongs to product coverage. |
| B2-3 | Native matcher region, selection and Enter, one-step Esc returns, empty matchers/handlers, and unchanged grouping and gates. |
| B2-4 | Focused OpenTUI/Ink tests, native component parity, build, bundle, typecheck, lint and formatting on the exact candidate; retain failures and reruns. Product tests compare Node+Ink, Bun+Ink and Bun+strict OpenTUI. |
| B2-5 | Synchronized bilingual design, preserved source attribution, exact candidate/base and independently reported acceptance. |
The current-candidate results belong in its test report and PR evidence. Until executed they are `NOT_RUN`; the #14044 result table is historical evidence only.
Historical evidence correction (MYWO-17): the original author evidence named `detail-cap` actually removed the empty-state height cap (`renderEmpty`); its log and ZIP map to `empty-cap`, not the detail cap. The subsequent `true-detail-cap` mutation uniquely targeted the detail key plus height. Retain this distinction when reusing the tests; do not copy old mutation results or static key/identity-only assertions into current business acceptance.
## Limits and follow-ups
Windows, Linux, tmux and assistive technology require separate verification. Native headless renderer frames with mock keyboard input are not PTY recordings; product PTY evidence is tracked by U1's migration acceptance plan. Do not close #13758/#12612 or #8662 from this slice: confirmation, transcript and other composite subitems remain. This change does not switch the default renderer or modify Bun runtime, clipboard notification, confirmation architecture or upstream FFI. The six historical chrome leads require current measurements before further changes.
@@ -0,0 +1,62 @@
# OpenTUI hooks 内容与区域预算(MYWO-3 / MYWO-13)
[English](2026-10-13-opentui-hook-content.md) | [简体中文](2026-10-13-opentui-hook-content.zh-CN.md)
## 状态与基线
作为[迁移交付方案](2026-10-20-opentui-migration-delivery.zh-CN.md)的 U1,在 main 基线 `2b5c4deadb9e9dd0d9d740a52ae42f28ad14bf92` 恢复本修复。候选验证与实现状态分别记录。
来源:[#14044](https://github.com/QwenLM/qwen-code/pull/14044),提交 `10a98a72ee2c78323c902f7d01f2a05db371a5d0` 与 `7cb0a86fbf709da0da24f6acb0a14465e59b9c36`,作者为 gary.gq,合作作者为 multica-agent。该 PR 合并到了 `fix/MYWO-7-short-dialog-budgets`,没有进入 main。只恢复它相对 `e16490eccc5aeffbb680da7c0935b5aebfe82f1b` 的八文件差异,保留 main 后续变化。历史通过结果不代表本候选已验收。
## 复现
恢复的原生测试脚本在未修改的 main `2b5c4deadb`、Bun 1.4.0、OpenTUI 0.5.10 上失败:空 Hooks 页面在宽 20 / region 1 时向区域外多绘制五行边框。退出原因是业务断言,不是导入或编译失败。测试在此停止,这轮基线没有执行到详情与 Diff。
#14044 在旧 stacked base 上的历史观察:sandboxed Diff 在宽20时高12行,region1–6越界绘制;非sandboxed Diff能容纳。hooks detail 宽40时高102行,长literal头可见,中尾不可达,没有scrollbox。matchers也在region1–6越界。观察使用captureCharFrame和原生几何、mock键盘输入,非PTY。11列正文可达性仍属需要独立证据的限制。
## 决策
复用已有scrollDialogLayout预算,保留内容行,预算不足时隐藏可选chrome;sandboxed Diff与hooks共用策略。hooks详情用原生scrollbox限制高度,保留literal换行,把每个物理文本行独立渲染,使其可滚动,避开原生多行Text尾部限制。窄列付不起标签列时采用上下排列的标签和值。详情支持滚轮、方向/翻页键、Home/End;Esc保持逐级返回。Diff 聚焦既有 scrollbox,使原生键盘滚动能够到达 sandbox 提示尾部。viewport变化时重置详情滚动到头部,viewport不变时保留偏移与全部内容。列表保留选中项并使用已有regionListWindow箭头预算。不改变hooks配置、权限或异步资源行为。
## 验证与验收
B2-1:绘制长literal头中尾、后续字段及全部中间行。B2-2:sandboxed true/false在正常、region1–6、窄列及resize中,区域外无绘制字符。B2-3:带region到达matchers,测试返回路径,六项chrome线索先逐项测量再修改。B2-4:原生mutation必须触发正文/越界断言失败;相关OpenTUI、Ink测试及build、bundle、typecheck、lint、format均记录原始退出码。B2-5:同步双语设计、独立PR、准确head/base及需求矩阵。实际结果见下方需求矩阵与交付日志。
## 机制与历史审计
紧凑模式隐藏标题/数量、notice/banner、说明性header、footer提示与纵向padding;region1–2还隐藏边框。正文仍在限高scrollbox内,空列表说明也完整保留。18列标签会使值剩余不到2列时,详情采用上下排列以容纳宽字符。预包行支付原生滚动条占用的一列,在独立物理行节点中保留literal空白/换行。默认按词边界换行,仅当词长超过实际正文宽度时硬折(`hard: true`、`trim: false`)。原生扫描宽29详情与宽40空态的可达帧,断言完整的`settings.json`与`event`。标题改为同一文本内的彩色span,使实际包行与合并测量一致。
宽度、正文预算或紧凑边框发生变化时,重新挂载只读scrollbox并回到头部;viewport不变的resize保留偏移。两种策略通过原生滚动位置与正文绘制测试,而非节点identity。提示隐藏时Esc仍可用。原生边框可能绘出父级文本clip,因此同时断言几何和字符帧。
紧凑详情省略标题,使第一行直接包含hook字段。词边界证据保留实际看到该词的帧,literal语料包含汉字、假名和韩文。两档chrome阈值仍是明确的取舍:更大的区域可能恢复说明文字,从而减少可见列表行数。全局禁用Hooks仍需单独对齐:Ink显示禁用状态页,而当前OpenTUI紧凑视图可能隐藏banner。该缺口保留在能力清单中,阻断最终完整对齐验收。
在POSIX上,评审加固保留HUP作为PTY关闭的首个信号,给产品五秒清理预算留出十秒,再终止自有进程组并最多等待五秒确认退出。Windows通过PTY后端终止子进程。强制清理仍判测试失败,调用方仍需释放其他夹具。CI证据名称和目录包含run attempt;构建前隔离夹具设置和运行时目录,失败后也回收。新增验收命令在所有runner上显式将未处理异常判为失败。这些改动必须在最终提交上重新验证,早先验收只保留为历史证据。
| 历史线索 | #14044源码与终端20 / 40 / 100列原生run行数 | 历史处理结论 |
| ---------------------- | ------------------------------------------ | ---------------- |
| C1 extensions提示/状态 | 已测量runs;提示8/3/1,样例状态10/4/2 | 未复现旧少计预算 |
| C2 MCP步骤header | 已测量header runs;样例6/2/1 | 未复现旧少计预算 |
| C3 theme/scope footer | 已测量runs;theme5/2/1,scope4/2/1 | 未复现旧少计预算 |
| C4 theme标题 | 实际标题列宽3/12/39,行数5/2/1 | 未复现旧少计预算 |
| C5 theme列表标签 | 名称/类型按剩余列裁剪;1/1/1 | 未复现旧少计预算 |
| C6 auth Shell标题 | shellBodyChromeRows测量标题;样例5/2/1 | 未复现旧少计预算 |
这些是历史代表性run的测量,charge = native height = painted rows,不是当前候选验收,也不是六个完整dialog的极短区域认证。U1 不修改对应模块。
## 候选验收
| 需求 | 必要证据 |
| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| B2-1 | 原生长literal头中尾、全部80个中间glyph、CJK(中日韩)及后续字段;20/28/29/40/100列,region1–6/15/40、键盘及resize。11列仍不在本轮验收范围。 |
| B2-2 | Sandboxed true/false几何和区域外字符帧,以及End/Home到达sandbox提示尾部。Sandbox不得启动Git。原生非sandbox用例在空临时cwd运行;大型工作区Diff归产品测试覆盖。 |
| B2-3 | 原生matcher region、选择与Enter、逐级Esc返回、空matchers/handlers,且分组和gate不变。 |
| B2-4 | 精确候选上的相关OpenTUI/Ink测试、native component parity、build、bundle、typecheck、lint与format;保留失败及重跑。产品测试比较Node+Ink、Bun+Ink与Bun+strict OpenTUI。 |
| B2-5 | 同步双语设计、保留来源、准确候选/base及独立报告的验收结果。 |
当前候选结果记录在它的测试报告和PR证据中。执行前均为 `NOT_RUN`;#14044 的结果表只作为历史证据。
历史证据纠错(MYWO-17):作者旧证据中的`detail-cap`实际移除了空态`renderEmpty`的高度上限;其日志及ZIP应映射为`empty-cap`,不能证明详情高度上限。后续`true-detail-cap` mutation才用detail key与height唯一定位详情。复用测试时保留这一区别,不把旧mutation结果或静态key/identity-only断言复制为当前业务验收。
## 限制与后续
Windows、Linux、tmux与辅助技术需要独立验证。原生headless renderer及mock键盘帧不是PTY录像;产品PTY证据由U1迁移验收方案跟踪。本片段不关闭#13758/#12612或#8662,confirmation、transcript等复合子项仍在。本改动不切默认renderer,不改Bun运行时、剪贴板通知、confirmation架构或上游FFI。六项历史chrome线索需要当前测量才能继续修改。
@@ -0,0 +1,182 @@
# OpenTUI migration delivery and acceptance
[English](2026-10-20-opentui-migration-delivery.md) |
[简体中文](2026-10-20-opentui-migration-delivery.zh-CN.md)
Status: proposed delivery plan; implementation and acceptance are tracked
separately. Evidence snapshot: 2026-10-20, main `2b5c4deadb`.
## Problem and current state
Moving the interactive runtime to Bun and replacing Ink are separate changes.
Success requires preserving npm and standalone installation, update, process
lifecycle, and the behavior users currently receive from Ink. A merged PR,
successful mocked test, or successful fallback to Ink is insufficient evidence.
The OpenTUI foundation, many dialogs, transcript windowing, and steady caret are
on main. The Hooks and sandbox Diff work in #14044 merged into an old feature
branch, not main. PTY #14046 and the standalone update stack
#14134/#14139/#14142/#14187/#14210 remain open. Current npm installation does not
provide a complete Bun/OpenTUI runtime. Global `qwen` 0.23.0 is only a historical
baseline: strict OpenTUI cannot start because its native package is absent.
## Goals and boundaries
- Run the TUI on Bun while retaining npm, npx, and standalone entry points.
- Inventory every current Ink command, dialog, input action, transcript state,
status indicator, and user setting; trace each to production OpenTUI wiring.
- Compare runtime and renderer changes independently and regress final artifacts.
- Deliver coherent reviewable PRs, normally 2,000–3,000 changed lines including
tests and documentation. Do not pad changes or remove tests to meet a quota.
- Reuse React, existing bridges, node-pty, Vitest, the interactive PTY harness,
xterm, fake model server, and existing parity scripts.
This plan does not adopt Solid, redesign the agent protocol, replace the package
manager, or combine default runtime, default renderer, and Ink removal.
## Delivery batches and dependencies
| Batch | Complete behavior delivered | Dependencies and review policy |
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| R1 | Standalone update preserves runtime flavor, installs and activates a verified release, and rolls back failures | Integrate the five update PRs on current main, retaining source attribution and review findings; target about 3k lines |
| U1 | Hooks and sandbox Diff remain reachable in short terminals; product comparison can run all three runtime/renderer legs | Restore the missing #14044 work and extend existing tests; independent of R1 |
| R2 | PTY spawn, input/output, resize, cancellation, process cleanup, and shell/WebSocket consumers work on Bun | Continue #14046 without mechanically splitting its existing review chain; correctness fixes and acceptance only |
| U2 | Composer and commands preserve editing, history, paste, image ownership, Vim, cancellation, and exactly-once submission | Stack on U1 when its shared fixtures are needed; include production callback wiring |
| R3 | npm/npx and standalone resolve the intended runtime and native assets on each supported platform | Depends on R1/R2; classify interpreter call sites and package final artifacts without requiring a user's global Bun |
| U3 | Current Ink dialogs, status/footer, transcript scrolling, tool states, session restore, and dynamic language changes have explicit coverage | Build on accepted input/dialog contracts; coordinate #13080, #14661, and #14349 rather than silently duplicating contributor work |
| A1 | Release-artifact regression and documented default rollout gates are enforced | Depends on R3/U3 and product acceptance; split only at a complete independently testable boundary if it exceeds review capacity |
The boundaries may move when current-code evidence changes. No artificial
dependency should serialize independent runtime and UI work. Open at most two
ready PRs with four locally accepted draft successors: implement the next cohesive batch while
review proceeds, then rebase onto the actual merged commit and rerun affected
validation before marking its successor ready.
The draft limit was raised on 2026-10-21 so independently verified editor and
status-line batches can enter CI while the first two reviews remain pending.
Each draft still requires a complete approximately 2–3k-line scope and author
plus independent local acceptance; the ready limit and merge gates are unchanged.
Existing #14046 is a size exception because it already has a complete backend
contract and multiple review rounds. Do not add unrelated work to it. Preserve
existing update PRs until their integrated replacement is concrete and reviewed;
then link the replacement explicitly so review findings and attribution survive.
## Runtime and distribution contract
Classify each spawned executable as host runtime, Node-required JavaScript,
bundled Bun JavaScript, native executable, or user command. Do not replace
`process.execPath` mechanically. Shell processes, MCP, hooks, skills, sandbox,
daemon/WebSocket, restart, and updater consumers need matching lifecycle tests.
Node standalone keeps its existing npm transport/configuration behavior. Bun
standalone must work without external Node/npm, preserve the runtime flavor,
validate archive and manifest provenance, smoke-test in an empty isolated
directory, activate atomically, and retain a usable rollback. Invalid manifests
fail closed with accurate recovery instructions. Empty HOME must not turn a
project `.npmrc` into trusted user configuration.
Release evidence covers darwin arm64/x64, Linux arm64/x64, and Windows x64 for the
supported distribution. Report unsupported variants, such as musl where
applicable, explicitly. Test npm global install, npx, relevant install-script and
optional-dependency modes, and standalone on clean PATH without global Bun.
## Ink parity contract
Maintain a capability inventory with: current Ink source, production OpenTUI
call site, input sequence, visible state, side effect, automated case, manual
case when needed, result, and exact candidate revision. An optional callback
with no production caller is not implemented behavior.
Cover slash commands and dialogs; editing and key bindings; history and Vim;
Unicode, CJK, emoji and IME; multiline and bracketed paste; image ownership;
tool approval and cancellation; streaming and interruption; transcript
selection/scroll/resize; session resume; status/footer/loading/error states;
language, theme and screen-reader settings.
Ink is the reference for intended behavior. Record demonstrated old Ink defects
as explicit exceptions instead of reproducing inaccessible or broken behavior.
In short terminals, essential content and escape/return controls must remain
reachable through scrolling and keyboard navigation.
## Comparison and regression strategy
Run the same deterministic scenarios, fake model responses, config, terminal
dimensions, and candidate source through:
| Leg | Runtime | Renderer | Difference isolated |
| --- | ------- | -------------- | ------------------------------------------ |
| A | Node | Ink | Existing contract |
| B | Bun | Ink | Runtime behavior, compared with A |
| C | Bun | strict OpenTUI | Rendering and interaction, compared with B |
Strict means renderer fallback is a failure. Record command, Node/Bun versions,
source SHA, bundle/native asset identity, seed/config, raw ANSI, parsed screen,
requests, exit status, and the assertion that failed. Missing runtime/assets or
platform execution is `NOT_RUN`/`BLOCKED`, never an implicit pass.
Use unit tests for transitions and failure boundaries, native component tests
for layout/scroll/caret, and existing node-pty/xterm product sessions for real
keypresses and side effects. Compare semantic state and model/tool requests in
addition to screens. Normalize only declared unstable IDs and timestamps; do
not erase whitespace, color, cursor, wrapping, or duplicated content defects.
The xterm test terminal uses the Unicode 11 addon so a two-cell emoji painted
with explicit cursor positions is not misread by its default Unicode 6 table.
This is a test-only dependency, not a product renderer change; it does not
certify all grapheme clusters or IME behavior. Session cleanup waits for PTY
exit before deleting fixture directories, and cleanup failures fail acceptance.
Product scenarios include long Hooks/Diff content, small and resized terminals,
multi-turn text/tool streaming, interruption, paste ownership, and restoration.
All sends wait for the harness idle boundary. Zero duplicate messages and
exactly-once submission are hard gates, not permissive counters. Test sync
markers, framing and visible frames where relevant; balanced zero markers do
not demonstrate synchronized output.
PR regression runs the touched capability cases plus a shared smoke set on the
exact head. Integration/release regression runs all three legs and the artifact
installation/update matrix. Real terminal IME, selection, accessibility, and
long-running lifecycle tests retain explicit manual evidence where automation
does not model the terminal protocol.
## OpenTUI and OpenCode upstream evaluation
Main uses OpenTUI 0.5.10. Upstream OpenTUI 0.6.6 and current OpenCode provide
useful fixes and implementation references, but 0.6 introduces native ownership
and lifetime changes. A version bump alone is not an approved solution.
Evaluate in isolation: React API/type compatibility; explicit SyntaxStyle
ownership and renderer close; native package/parser/worker asset matching;
minimum Bun version; Unicode/control-character width; resize/scroll/diff;
and the three-leg product/artifact suite. Keep versions and native ABI coherent.
Retain Qwen's application-level IME, image, caret and transcript safeguards
unless evidence proves an upstream change replaces their contracts. Upgrading
is conditional on a measured benefit and passing gates, not the critical path
for independent parity fixes.
## Completion and rollout gates
1. Runtime gate: required consumers and installed artifacts pass on supported
platforms, including PTY lifecycle, update and rollback.
2. Renderer gate: capability inventory has no unexplained gaps, automated parity
passes, manual terminal checks are recorded, and strict OpenTUI runs the
actual product without fallback.
3. Default rollout gate: both prior gates hold for the release candidate and
recovery/fallback behavior is exercised. Retain accessible Ink until an
equivalent screen-reader path has its own evidence.
4. Removal gate: no remaining Ink-only contracts or distribution fallback
dependencies. Ink removal requires a separate review.
Monitor owned PR heads, substantive comments, CI failures and conflicts every
30 minutes. Separate product defects from runner/infrastructure failures using
logs. Rebase dependent drafts only after recording their old heads; preserve
other workspaces. Post evidence for the new exact head after fixes. Do not
automatically merge, release, or count skipped checks as acceptance.
## Risks and open decisions
The current machine can validate macOS arm64, not the complete platform matrix.
Native lifecycle and IME limitations require explicit external evidence.
Independent changes may land while a stack is reviewed; reconcile the current
Ink inventory and actual merged base before promoting each successor.
Default rollout and a possible OpenTUI upgrade remain conditional decisions
until their respective evidence gates pass.
@@ -0,0 +1,155 @@
# OpenTUI 迁移交付与验收
[English](2026-10-20-opentui-migration-delivery.md) |
[简体中文](2026-10-20-opentui-migration-delivery.zh-CN.md)
状态:拟定交付方案;实现与验收分别跟踪。
证据快照:2026-10-20,main `2b5c4deadb`。
## 问题与当前状态
交互运行时迁移至 Bun 与替换 Ink 是两个独立变化。成功标准必须同时保住
npm 和 standalone 的安装、更新、进程生命周期,以及用户当前从 Ink 获得的
行为。PR 合并、mock 测试成功或成功回退到 Ink 都不足以证明迁移完成。
OpenTUI 基础、大量对话框、长记录窗口化及稳定光标已经进入 main。
#14044 的 Hooks 与 sandbox Diff 工作合并到了旧功能分支,没有进入 main。
PTY #14046 与 standalone 更新栈 #14134/#14139/#14142/#14187/#14210
仍未合并。当前 npm 安装不提供完整的 Bun/OpenTUI 运行时。
全局 `qwen` 0.23.0 只能作为历史基线:strict OpenTUI 因缺少原生包无法启动。
## 目标与边界
- TUI 使用 Bun,保留 npm、npx 和 standalone 入口。
- 盘点当前 Ink 的每个命令、对话框、输入操作、记录状态、状态提示及用户
设置,逐项追踪到 OpenTUI 的生产接线。
- 分别比较运行时和渲染器变化,并回归最终安装产物。
- 提交完整且便于审查的 PR,通常包含测试和文档共 2,000–3,000 行变化。
不为凑行数填充改动,也不为凑行数删除测试。
- 复用 React、现有桥接层、node-pty、Vitest、交互 PTY 测试框架、
xterm、假模型服务和现有 parity 脚本。
本方案不引入 Solid、不重设计 agent 协议、不替换包管理器,也不合并默认
运行时切换、默认渲染器切换与删除 Ink 三个决策。
## 交付批次与依赖
| 批次 | 完整交付的行为 | 依赖与审查策略 |
| ---- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| R1 | Standalone 更新保持运行时类型,安装并激活经过验证的版本,失败时回滚 | 在当前 main 整合五个更新 PR,保留来源和审查结论;目标约 3k 行 |
| U1 | 短终端中的 Hooks 与 sandbox Diff 保持可达;产品对比支持全部三组运行时/渲染器 | 恢复遗漏的 #14044 并扩展现有测试;与 R1 独立 |
| R2 | Bun 下的 PTY 启动、输入输出、resize、取消、进程清理及 shell/WebSocket 消费方正常 | 继续 #14046,不机械拆分既有审查链;仅补正确性与验收 |
| U2 | 输入区和命令保持编辑、历史、粘贴、图片所有权、Vim、取消及恰好一次提交 | 需要 U1 共享 fixture 时才 stack;包含生产回调接线 |
| R3 | 各支持平台的 npm/npx 与 standalone 正确解析运行时及原生资产 | 依赖 R1/R2;分类解释器调用并打包最终产物,不要求用户全局安装 Bun |
| U3 | 当前 Ink 的对话框、状态/footer、记录滚动、工具状态、会话恢复和动态语言切换均有明确覆盖 | 基于已验收的输入/对话框契约;协调 #13080、#14661、#14349,避免悄悄重复贡献者工作 |
| A1 | 落实发布产物回归和默认切换门槛 | 依赖 R3/U3 和产品验收;超过审查容量时,只在完整且可独立测试的边界拆分 |
当前代码证据变化时可以调整边界。不要用人为依赖串行化独立的运行时与 UI
工作。同时最多保留两个 ready PR 和四个已通过本机验收的 draft 后继:审查时实现下一个完整
批次,依赖合并后 rebase 到实际合并提交,重跑受影响验证再转为 ready。
2026-10-21 提高 draft 上限,让已独立验证的编辑事务和状态栏批次在前两条审查
等待期间进入 CI。每条 draft 仍需保持约 2–3k 行的完整范围,并通过作者与独立
本机验收;ready 上限和合并门槛不变。
#14046 已有完整后端契约和多轮审查,作为既有大 PR 例外,不追加无关工作。
更新栈的整合替代 PR 成形并经过审查之前保留原 PR;之后明确链接替代关系,
防止审查问题和贡献来源丢失。
## 运行时与分发契约
每个子进程入口必须分类为宿主运行时、需要 Node 的 JavaScript、随包 Bun
JavaScript、原生程序或用户命令,不能机械替换 `process.execPath`。
Shell、MCP、hooks、skills、sandbox、daemon/WebSocket、restart 和
updater 消费方需要相应生命周期测试。
Node standalone 保留原有 npm 传输和配置行为。Bun standalone 必须在没有
外部 Node/npm 时工作,保持运行时类型,验证归档与 manifest 来源,在空的
隔离目录 smoke test,原子激活,并保留可用回滚。无效 manifest 必须安全拒绝,
同时给出准确恢复说明。空 HOME 不能把项目 `.npmrc` 当作可信用户配置。
发布证据覆盖支持分发中的 darwin arm64/x64、Linux arm64/x64 和 Windows
x64。明确注明不支持的变体,例如适用时的 musl。测试 npm 全局安装、npx、
相关安装脚本及 optional dependency 模式,以及干净 PATH、没有全局 Bun
时的 standalone。
## Ink 对齐契约
维护能力清单:当前 Ink 源码、OpenTUI 生产调用点、输入序列、可见状态、
副作用、自动化用例、必要人工用例、结果及精确候选版本。可选回调没有生产
调用方,就不算完成实现。
覆盖 slash 命令和对话框;编辑及快捷键;历史和 Vim;Unicode、CJK(中日韩)、
emoji 与 IME;多行和 bracketed paste;图片所有权;工具批准与取消;
流式输出及打断;记录选择/滚动/resize;会话恢复;状态/footer/loading/
错误;语言、主题和屏幕阅读器设置。
Ink 是预期行为的参考。已经证明的旧 Ink 缺陷应登记为明确例外,不能复制
不可达或错误的交互。短终端里的必要内容以及退出/返回操作,必须能够通过
滚动和键盘导航访问。
## 对比与回归策略
同一组确定性场景、假模型响应、配置、终端尺寸及候选源码分别运行:
| 组 | 运行时 | 渲染器 | 隔离的变化 |
| --- | ------ | -------------- | ------------------- |
| A | Node | Ink | 现有行为契约 |
| B | Bun | Ink | 与 A 比较运行时行为 |
| C | Bun | strict OpenTUI | 与 B 比较渲染与交互 |
Strict 表示回退渲染器算失败。记录命令、Node/Bun 版本、源码 SHA、
bundle/原生资产标识、seed/配置、原始 ANSI、解析后画面、请求、退出状态
和失败断言。缺少运行时、资产或平台执行记为 `NOT_RUN`/`BLOCKED`,
不能隐含算通过。
单测覆盖状态转移和失败边界,原生组件测试覆盖布局/滚动/光标,现有
node-pty/xterm 产品会话测试真实按键及副作用。除了画面,还比较语义状态和
模型/工具请求。只归一化明确声明的不稳定 ID 和时间戳,不抹除空白、颜色、
光标、换行或重复内容问题。
xterm 测试终端使用 Unicode 11 addon,避免默认 Unicode 6 宽度表误读
用显式光标位置绘制的双格 emoji。这是测试依赖,不改变产品渲染器,也不证明
全部字素簇或 IME 行为正确。清理会话时等待 PTY 退出再删除 fixture 目录;
清理失败也必须使验收失败。
产品场景包括长 Hooks/Diff、小终端与 resize、多轮文本/工具流式、打断、
粘贴所有权及恢复。各次发送之间等待测试框架 idle 边界。零重复消息和恰好
一次提交是硬门槛,不采用宽松计数。适用时检查同步标记、协议边界和可见帧;
零个标记的“平衡”不能证明同步输出。
PR 回归在精确 head 上运行所改能力用例及共享 smoke 集。
集成/发布回归运行全部三组及安装/更新产物矩阵。真实终端的 IME、选择、
无障碍和长时间生命周期测试,在自动化无法模拟终端协议时保留明确人工证据。
## OpenTUI 与 OpenCode 上游评估
Main 使用 OpenTUI 0.5.10。上游 OpenTUI 0.6.6 与当前 OpenCode 提供了
有用的修复和实现参考,但 0.6 引入原生所有权及生命周期变化。仅升版本
不能作为已经通过的解决方案。
隔离评估 React API/类型兼容、SyntaxStyle 显式所有权与 renderer 关闭、
原生包/parser/worker 资产匹配、最低 Bun 版本、Unicode/控制字符宽度、
resize/scroll/diff,以及三组产品/产物测试。保持版本与原生 ABI 一致。
除非证据证明上游替代了相同契约,否则保留 Qwen 的 IME、图片、光标和
长记录应用层保护。只有收益明确且门槛通过才升级;独立对齐修复不等待升版。
## 完成与切换门槛
1. 运行时门槛:所有必要消费方及安装产物在支持平台通过,包括 PTY 生命周期、
更新和回滚。
2. 渲染器门槛:能力清单没有未解释缺口,自动化对比通过,人工终端检查留存,
strict OpenTUI 运行真实产品且没有回退。
3. 默认切换门槛:前两个门槛在发布候选上成立,恢复/回退已实测。
屏幕阅读器的等价路径取得证据之前保留可访问的 Ink。
4. 删除门槛:没有剩余 Ink 独占契约或分发回退依赖。删除 Ink 单独审查。
每 30 分钟跟进所管理 PR 的 head、实质评论、CI 失败及冲突。根据日志区分
产品缺陷和 runner/基础设施失败。记录旧 head 后才能 rebase 依赖 draft,
并保护其他工作区。修复后发布新精确 head 的证据。不自动合并、发布,
也不把跳过的检查算作验收。
## 风险与待定决策
当前机器能够验证 macOS arm64,不能代表完整平台矩阵。原生生命周期与 IME
限制需要明确的外部证据。Stack 审查期间独立变化可能合并;每个后继转 ready
之前,都要重新核对当前 Ink 清单和实际合并基线。默认切换以及可能的 OpenTUI
升版,在各自证据门槛通过之前保持为条件性决策。
@@ -39,12 +39,15 @@ describe('Command output visibility', () => {
});
afterEach(async () => {
await session?.close();
session = undefined;
await server?.close();
server = undefined;
restoreNoProxy();
await rig.cleanup();
try {
await session?.close();
} finally {
session = undefined;
await server?.close();
server = undefined;
restoreNoProxy();
await rig.cleanup();
}
});
it('puts a slash command transcript row on screen', CASE_OPTS, async () => {
@@ -51,8 +51,11 @@ const CRON_CREATE_CONFIRMATION_TIMEOUT = 240_000;
afterEach(async () => {
if (session) {
await session.close();
session = null;
try {
await session.close();
} finally {
session = null;
}
}
});
@@ -0,0 +1,222 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { InteractiveSession } from './interactive-session.js';
describe('InteractiveSession visible evidence', () => {
let session: InteractiveSession | undefined;
afterEach(async () => {
try {
await session?.close();
} finally {
session = undefined;
vi.unstubAllEnvs();
}
});
it('distinguishes visible rows from scrollback and preserves raw ANSI', async () => {
session = await InteractiveSession.start({
rows: 5,
cols: 30,
command: {
bin: process.execPath,
args: [
'-e',
`console.log('SCROLLBACK_ONLY');
for (let i = 0; i < 8; i++) console.log('line ' + i);
console.log('\\x1b[31mType your message\\x1b[0m');
setInterval(() => {}, 1000);`,
],
},
});
await session.idle(300);
expect(await session.screen()).toContain('SCROLLBACK_ONLY');
const viewport = await session.viewport();
expect(viewport).not.toContain('SCROLLBACK_ONLY');
expect(viewport).toContain('Type your message');
expect(viewport.split('\n')).toHaveLength(5);
expect(session.output()).toContain('\x1b[31m');
});
it('resizes both the child PTY and the emulated viewport', async () => {
session = await InteractiveSession.start({
rows: 12,
cols: 80,
command: {
bin: process.execPath,
args: [
'-e',
`console.log('Type your message');
process.stdout.on('resize', () => {
console.log('SIZE:' + process.stdout.columns + 'x' + process.stdout.rows);
});
setInterval(() => {}, 1000);`,
],
},
});
session.resize(40, 6);
await session.waitFor('SIZE:40x6', 5000);
expect((await session.viewport()).split('\n')).toHaveLength(6);
session.resize(80, 12);
await session.waitFor('SIZE:80x12', 5000);
expect((await session.viewport()).split('\n')).toHaveLength(12);
});
it('can isolate child configuration without losing the pinned renderer', async () => {
vi.stubEnv('QWEN_E2E_PRIVATE_SENTINEL', 'must-not-leak');
vi.stubEnv('QWEN_E2E_RENDERER', 'ink');
session = await InteractiveSession.start({
inheritEnv: false,
env: { TERM: 'xterm-256color', QWEN_TUI_RENDERER: 'opentui' },
command: {
bin: process.execPath,
args: [
'-e',
`console.log('Type your message');
console.log('PRIVATE:' + (process.env.QWEN_E2E_PRIVATE_SENTINEL ?? 'absent'));
console.log('RENDERER:' + process.env.QWEN_TUI_RENDERER);
setInterval(() => {}, 1000);`,
],
},
});
await session.idle(300);
expect(session.output()).toContain('PRIVATE:absent');
expect(session.output()).toContain('RENDERER:ink');
expect(session.output()).not.toContain('must-not-leak');
});
it('uses modern emoji widths for explicitly positioned terminal cells', async () => {
session = await InteractiveSession.start({
command: {
bin: process.execPath,
args: [
'-e',
`console.log('Type your message');
process.stdout.write('\\x1b[2;1H甲乙🙂\\x1b[2;7H_DONE');
setInterval(() => {}, 1000);`,
],
},
});
await session.idle(300);
expect(await session.viewport()).toContain('甲乙🙂_DONE');
});
it('excludes retained offscreen columns after narrowing the alternate screen', async () => {
session = await InteractiveSession.start({
cols: 100,
rows: 12,
command: {
bin: process.execPath,
args: [
'-e',
`console.log('Type your message');
process.stdout.write('\\x1b[?1049h\\x1b[1;1HVISIBLE\\x1b[1;60HOFFSCREEN');
setInterval(() => {}, 1000);`,
],
},
});
await session.idle(300);
expect(await session.viewport()).toContain('OFFSCREEN');
session.resize(40, 12);
await session.idle(300);
expect(await session.viewport()).toContain('VISIBLE');
expect(await session.viewport()).not.toContain('OFFSCREEN');
});
it.skipIf(process.platform === 'win32').each([150, 5_100])(
'waits for the child to finish writing after %i ms before removing its directory',
async (delay) => {
const scratch = mkdtempSync(join(tmpdir(), 'qwen-pty-close-'));
const marker = join(scratch, 'exited.txt');
try {
session = await InteractiveSession.start({
command: {
bin: process.execPath,
args: [
'-e',
`process.on('SIGHUP', () => setTimeout(() => {
require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'finished');
process.exit(0);
}, ${delay}));
console.log('Type your message');
setInterval(() => {}, 1000);`,
],
},
});
await session.close();
expect(readFileSync(marker, 'utf8')).toBe('finished');
expect(session.exitResult()?.exitCode).toBe(0);
} finally {
rmSync(scratch, { recursive: true, force: true });
}
},
);
it.skipIf(process.platform === 'win32')(
'reaps a HUP-ignoring PTY group and still reports the forced cleanup',
async () => {
const scratch = mkdtempSync(join(tmpdir(), 'qwen-pty-close-'));
const pidFile = join(scratch, 'pids.json');
let pids: number[] = [];
try {
session = await InteractiveSession.start({
inheritEnv: false,
command: {
bin: process.execPath,
args: [
'-e',
`process.on('SIGHUP', () => {});
const child = require('node:child_process').spawn(process.execPath, ['-e',
"process.on('SIGHUP', () => {}); process.send('ready'); setInterval(() => {}, 1000);"
], { stdio: ['ignore', 'ignore', 'ignore', 'ipc'] });
child.once('message', () => {
require('node:fs').writeFileSync(${JSON.stringify(pidFile)}, JSON.stringify([process.pid, child.pid]));
console.log('Type your message');
});
setInterval(() => {}, 1000);`,
],
},
});
pids = JSON.parse(readFileSync(pidFile, 'utf8')) as number[];
const started = Date.now();
await expect(session.close()).rejects.toThrow(
'PTY exceeded its close grace period',
);
expect(Date.now() - started).toBeLessThan(20_000);
expect(session.exitResult()?.signal).toBe(9);
for (const pid of pids) {
await expect
.poll(
() => {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
},
{ timeout: 2_000 },
)
.toBe(false);
}
} finally {
for (const pid of pids) {
try {
process.kill(pid, 'SIGKILL');
} catch {
// The assertions above, before this fallback, prove cleanup.
}
}
rmSync(scratch, { recursive: true, force: true });
}
},
);
});
@@ -30,9 +30,12 @@ describe('InteractiveSession.start ready-prompt budget', () => {
let session: InteractiveSession | undefined;
afterEach(async () => {
await session?.close();
session = undefined;
vi.unstubAllEnvs();
try {
await session?.close();
} finally {
session = undefined;
vi.unstubAllEnvs();
}
});
it('keeps the tight 30s signal outside self-hosted runners', async () => {
@@ -22,7 +22,9 @@ import * as pty from '@lydell/node-pty';
import stripAnsi from 'strip-ansi';
// @xterm/headless is CJS — use default import + destructure
import xtermHeadless from '@xterm/headless';
import unicode11 from '@xterm/addon-unicode11';
const { Terminal } = xtermHeadless;
const { Unicode11Addon } = unicode11;
type Terminal = InstanceType<typeof Terminal>;
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -82,6 +84,8 @@ export interface InteractiveSessionOptions {
args?: string[];
/** Spawn this instead of the built CLI bundle (drives stub processes in tests). */
command?: { bin: string; args: string[] };
/** Replace inherited environment for deterministic, credential-free fixtures. */
inheritEnv?: boolean;
}
export class InteractiveSession {
@@ -91,12 +95,16 @@ export class InteractiveSession {
private pendingWrite: Promise<void> = Promise.resolve();
private closed = false;
private readonly exited: Promise<void>;
private exitStatus: { exitCode: number; signal?: number } | undefined;
private constructor(ptyProcess: pty.IPty, terminal: Terminal) {
this.ptyProcess = ptyProcess;
this.terminal = terminal;
this.exited = new Promise<void>((resolve) => {
ptyProcess.onExit(() => resolve());
this.exited = new Promise((resolve) => {
ptyProcess.onExit((status) => {
this.exitStatus = status;
resolve();
});
});
ptyProcess.onData((data) => {
@@ -135,7 +143,7 @@ export class InteractiveSession {
const cwd = options?.cwd ?? join(__dirname, '..', '..');
const args = options?.args ?? [];
const baseEnv = { ...process.env };
const baseEnv = options?.inheritEnv === false ? {} : { ...process.env };
delete baseEnv['NO_COLOR'];
// The renderer matrix pins QWEN_TUI_RENDERER last so a test's own env
// cannot silently switch the renderer mid-matrix.
@@ -151,6 +159,8 @@ export class InteractiveSession {
scrollback: 1000,
allowProposedApi: true,
});
terminal.loadAddon(new Unicode11Addon());
terminal.unicode.activeVersion = '11';
const target = options?.command ?? {
bin: resolveE2eCliCommand(pickE2eRenderer()),
@@ -284,6 +294,31 @@ export class InteractiveSession {
return lines.join('\n');
}
async viewport(): Promise<string> {
await this.flush();
const buffer = this.terminal.buffer.active;
return Array.from(
{ length: this.terminal.rows },
(_, row) =>
buffer
.getLine(buffer.viewportY + row)
?.translateToString(true, 0, this.terminal.cols) ?? '',
).join('\n');
}
resize(cols: number, rows: number): void {
this.terminal.resize(cols, rows);
this.ptyProcess.resize(cols, rows);
}
output(): string {
return this.rawOutput;
}
exitResult(): { exitCode: number; signal?: number } | undefined {
return this.exitStatus;
}
/**
* Poll the screen until `predicate` returns true.
* Returns the screen text when matched.
@@ -317,18 +352,51 @@ export class InteractiveSession {
} catch {
// Process may have already exited
}
// kill() only delivers the signal; the CLI can still be flushing state
// into the test directory. Wait for the exit event so a following case
// that reuses the directory cannot race those writes.
let exitTimeout: NodeJS.Timeout | undefined;
await Promise.race([
this.exited,
new Promise<void>((resolve) => {
exitTimeout = setTimeout(resolve, 5_000);
}),
]);
if (exitTimeout) clearTimeout(exitTimeout);
await this.flush();
this.terminal.dispose();
const waitForExit = async (milliseconds: number) => {
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
this.exited,
new Promise<never>((_, reject) => {
timeout = setTimeout(
() =>
reject(new Error(`PTY did not exit within ${milliseconds}ms`)),
milliseconds,
);
}),
]);
} finally {
clearTimeout(timeout);
}
};
try {
try {
// The product's cleanup budget is 5s; allow time for signal delivery.
await waitForExit(10_000);
} catch (error) {
if (!this.exitStatus) {
try {
if (process.platform === 'win32') {
this.ptyProcess.kill('SIGKILL');
} else {
// forkpty owns a session/process group, including its children.
process.kill(-this.ptyProcess.pid, 'SIGKILL');
}
} catch (killError) {
if ((killError as NodeJS.ErrnoException).code !== 'ESRCH') {
throw killError;
}
}
await waitForExit(5_000);
}
await this.flush();
throw new Error('PTY exceeded its close grace period', {
cause: error,
});
}
await this.flush();
} finally {
this.terminal.dispose();
}
}
}
@@ -454,34 +454,40 @@ describe.sequential(`Mods model tools (${renderer})`, () => {
failure = error;
throw error;
} finally {
if (session) {
writeFileSync(
join(evidence, 'pty.ansi'),
(session as unknown as { rawOutput: string }).rawOutput,
);
writeFileSync(join(evidence, 'screen.txt'), await session.screen());
await session.close();
try {
if (session) {
writeFileSync(
join(evidence, 'pty.ansi'),
(session as unknown as { rawOutput: string }).rawOutput,
);
writeFileSync(join(evidence, 'screen.txt'), await session.screen());
await session.close();
}
} catch (error) {
failure ??= error;
} finally {
json(join(evidence, 'requests.json'), server.requests);
await server.close();
json(join(evidence, 'manifest.json'), {
mode,
nonce,
renderer,
strict: renderer === 'opentui',
scratch,
bundleHash: digest(readFileSync(bundle)),
fixtureHash: digest(source),
oracleHash: digest(oracle),
settingsHash: digest(JSON.stringify(settings)),
head: execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: repo,
encoding: 'utf8',
}).trim(),
pass: !failure,
failure: failure ? String(failure) : undefined,
});
}
json(join(evidence, 'requests.json'), server.requests);
await server.close();
json(join(evidence, 'manifest.json'), {
mode,
nonce,
renderer,
strict: renderer === 'opentui',
scratch,
bundleHash: digest(readFileSync(bundle)),
fixtureHash: digest(source),
oracleHash: digest(oracle),
settingsHash: digest(JSON.stringify(settings)),
head: execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: repo,
encoding: 'utf8',
}).trim(),
pass: !failure,
failure: failure ? String(failure) : undefined,
});
}
if (failure) throw failure;
},
150000,
);
@@ -0,0 +1,351 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { E2E_MEMORY_SETTINGS_DEFAULTS } from '../e2e-memory-defaults.js';
import { startFakeOpenAIServer } from '../fake-openai-server.js';
import { pickE2eRenderer, resolveE2eCliCommand } from '../renderer-matrix.js';
import { InteractiveSession } from './interactive-session.js';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const renderer = pickE2eRenderer();
const runtime = resolveE2eCliCommand(renderer);
const bundle = join(root, 'dist/cli.js');
const digest = (value: string | Buffer) =>
createHash('sha256').update(value).digest('hex');
const writeJson = (path: string, value: unknown) =>
writeFileSync(path, JSON.stringify(value, null, 2) + '\n');
const prompts = ['migration first 中文🙂', 'migration second turn'];
const answers = ['FIRST_REPLY_甲乙🙂_DONE', 'SECOND_REPLY_丙丁🙂_DONE'];
function artifactFiles(
directory: string,
prefix = '',
): Array<[string, string]> {
return readdirSync(directory, { withFileTypes: true })
.sort((a, b) => a.name.localeCompare(b.name))
.flatMap((entry): Array<[string, string]> => {
const path = join(directory, entry.name);
const name = prefix + entry.name;
return entry.isDirectory()
? artifactFiles(path, name + '/')
: [[name, digest(readFileSync(path))]];
});
}
async function visible(
session: InteractiveSession,
predicate: (screen: string) => boolean,
description: string,
): Promise<string> {
const deadline = Date.now() + 20_000;
do {
const screen = await session.viewport();
if (predicate(screen)) return screen;
const exit = session.exitResult();
if (exit) {
throw new Error(
`${description}: CLI exited (${JSON.stringify(exit)})\n${screen}`,
);
}
await new Promise((resolve) => setTimeout(resolve, 100));
} while (Date.now() < deadline);
throw new Error(
`${description}\nVisible screen:\n${await session.viewport()}`,
);
}
describe.sequential(`TUI migration (${runtime}+${renderer})`, () => {
it('preserves multi-turn submission and Hooks navigation across resize', async () => {
const scratch = mkdtempSync(join(tmpdir(), 'qwen-tui-migration-'));
const workspace = join(scratch, 'workspace');
const home = join(scratch, 'qwen-home');
const runtimeDir = join(scratch, 'runtime');
const evidenceRoot =
process.env['QWEN_TUI_ACCEPTANCE_OUT'] ??
join(root, '.qwen/e2e-tests/tui-migration');
for (const path of [workspace, home, runtimeDir, evidenceRoot]) {
mkdirSync(path, { recursive: true });
}
const evidence = mkdtempSync(join(evidenceRoot, `${runtime}-${renderer}-`));
const frames: Record<string, string> = {};
let session: InteractiveSession | undefined;
let failure: string | undefined;
const server = await startFakeOpenAIServer(({ requestIndex }) => ({
contentChunks:
requestIndex < answers.length
? [answers[requestIndex].slice(0, 6), answers[requestIndex].slice(6)]
: ['UNEXPECTED_EXTRA_REQUEST'],
}));
try {
writeJson(join(home, 'settings.json'), {
security: {
auth: { selectedType: 'openai' },
folderTrust: { enabled: false },
},
general: { enableAutoUpdate: false, showSessionRecap: false },
ui: { enableFollowupSuggestions: false },
memory: E2E_MEMORY_SETTINGS_DEFAULTS,
hooks: {
PreToolUse: [
{
matcher: '*',
hooks: [
{
type: 'command',
command:
'/usr/bin/true # ' +
'HOOK_LONG_DETAIL_'.repeat(24) +
' HOOK_END',
},
],
},
],
},
});
session = await InteractiveSession.start({
cols: 100,
rows: 30,
cwd: workspace,
inheritEnv: false,
env: {
PATH: process.env['PATH'],
HOME: scratch,
QWEN_HOME: home,
QWEN_RUNTIME_DIR: runtimeDir,
TERM: 'xterm-256color',
LANG: 'en_US.UTF-8',
QWEN_CODE_LANG: 'en',
QWEN_SANDBOX: 'false',
QWEN_CODE_DISABLE_CRON: '1',
QWEN_CODE_SKIP_UPDATE_CHECK_ONCE: 'true',
QWEN_CODE_MODELS_DEV_REFRESH: 'off',
NO_PROXY: 'localhost,127.0.0.1',
no_proxy: 'localhost,127.0.0.1',
},
args: [
'--auth-type',
'openai',
'--openai-api-key',
'fake-key',
'--openai-base-url',
server.baseUrl,
'--model',
'fake-model',
'--approval-mode',
'default',
],
});
await visible(
session,
(screen) =>
screen.includes('Type your message') &&
!screen.includes('Initializing...'),
'ready prompt must be visible after initialization',
);
for (let index = 0; index < prompts.length; index++) {
await session.idle(300);
await session.send(prompts[index]);
frames[`turn-${index}`] = await visible(
session,
(screen) => screen.includes(answers[index]),
`response ${index} must reach the viewport`,
);
expect(frames[`turn-${index}`].split(answers[index])).toHaveLength(2);
await session.idle(300);
expect(server.requests).toHaveLength(index + 1);
const messages = server.requests[index].body['messages'] as Array<{
role: string;
content: unknown;
}>;
const users = messages.filter((message) => message.role === 'user');
expect(users).toHaveLength(index + 1);
for (let turn = 0; turn <= index; turn++) {
const content = users[turn].content;
const parts =
typeof content === 'string'
? [{ type: 'text', text: content }]
: (content as Array<{ type: string; text?: string }>);
expect(
parts.filter((part) => !part.text?.startsWith('<system-reminder>')),
).toEqual([{ type: 'text', text: prompts[turn] }]);
}
if (index > 0) {
expect(
JSON.stringify(messages.filter((m) => m.role === 'assistant')),
).toContain(answers[index - 1]);
}
}
await session.idle(300);
await session.send('/hooks');
await visible(
session,
(screen) => screen.includes('Hooks'),
'Hooks opens',
);
for (const label of ['Matchers', 'Matcher:', 'Command:']) {
await session.idle(300);
session.pressKey('\r');
await visible(
session,
(screen) => screen.includes(label),
`Hooks navigation reaches ${label}`,
);
}
if (renderer === 'opentui') {
await session.idle(300);
session.pressKey('\x1b[F');
}
frames['detail-wide'] = await visible(
session,
(screen) => screen.includes('HOOK_END'),
'wide detail shows the complete command',
);
for (const [cols, rows] of [
[80, 12],
[40, 10],
]) {
session.resize(cols, rows);
await session.idle(300);
frames[`detail-${cols}x${rows}-head`] = await session.viewport();
expect(frames[`detail-${cols}x${rows}-head`].split('\n')).toHaveLength(
rows,
);
if (renderer === 'opentui') {
session.pressKey('\x1b[F');
frames[`detail-${cols}x${rows}-end`] = await visible(
session,
(screen) => screen.includes('help.'),
`End reaches the final instructions at ${cols}x${rows}`,
);
for (
let step = 0;
step < 40 && !(await session.viewport()).includes('HOOK_END');
step++
) {
session.pressKey('\x1b[A');
await session.idle(100);
}
frames[`detail-${cols}x${rows}-command-tail`] = await visible(
session,
(screen) => screen.includes('HOOK_END'),
`the command tail is reachable at ${cols}x${rows}`,
);
await session.idle(300);
session.pressKey('\x1b[H');
await visible(
session,
(screen) => screen.includes('Event:'),
`Home returns to the detail head at ${cols}x${rows}`,
);
}
}
session.resize(100, 30);
await session.idle(300);
if (renderer === 'opentui') session.pressKey('\x1b[F');
frames['detail-restored'] = await visible(
session,
(screen) => screen.includes('HOOK_END'),
'restored terminal retains the full detail',
);
expect(frames['detail-restored'].split('\n')).toHaveLength(30);
for (const [step, label] of [
['handlers', 'Matcher:'],
['matchers', 'Matchers'],
['events', 'PostToolUse'],
]) {
session.pressKey('\x1b');
await session.idle(300);
frames[`back-${step}`] = await visible(
session,
(screen) => screen.includes(label) && !screen.includes('Command:'),
`Escape returns to ${step}, keeping the dialog open`,
);
}
session.pressKey('\x1b');
await session.idle(300);
frames['prompt-restored'] = await visible(
session,
(screen) =>
screen.includes('Type your message') && !screen.includes('Command:'),
'Escape returns to the prompt',
);
expect(server.requests).toHaveLength(2);
} catch (error) {
failure =
error instanceof Error ? (error.stack ?? error.message) : String(error);
} finally {
try {
if (session) {
frames['final'] = await session.viewport();
}
try {
await session?.close();
} finally {
await server.close();
rmSync(scratch, { recursive: true, force: true });
}
} catch (error) {
const cleanupFailure =
error instanceof Error ? error.message : String(error);
failure = [failure, cleanupFailure].filter(Boolean).join('\n');
} finally {
if (session) {
writeFileSync(join(evidence, 'terminal.ansi'), session.output());
}
const manifest = artifactFiles(join(root, 'dist'));
writeJson(join(evidence, 'bundle-manifest.json'), manifest);
writeJson(join(evidence, 'frames.json'), frames);
writeJson(join(evidence, 'requests.json'), server.requests);
writeJson(join(evidence, 'result.json'), {
result: failure ? 'FAIL' : 'PASS',
failure,
source: execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: root,
encoding: 'utf8',
}).trim(),
workingDiffSha256: digest(
execFileSync('git', ['diff', 'HEAD'], { cwd: root }),
),
bundleSha256: digest(readFileSync(bundle)),
bundleManifestSha256: digest(JSON.stringify(manifest)),
fixtureSha256: digest(readFileSync(fileURLToPath(import.meta.url))),
lockSha256: digest(readFileSync(join(root, 'pnpm-lock.yaml'))),
runtime,
runtimeVersion: execFileSync(runtime, ['--version'], {
encoding: 'utf8',
}).trim(),
renderer,
strict: renderer === 'opentui',
exit: session?.exitResult(),
terminalUnicodeVersion: '11',
platform: process.platform,
arch: process.arch,
inkException:
renderer === 'ink'
? 'Existing short-terminal Hooks clipping is recorded, not asserted as desired behavior.'
: undefined,
});
console.log(`TUI migration evidence: ${evidence}`);
}
}
if (failure) throw new Error(failure);
});
});
@@ -54,12 +54,15 @@ describe.skipIf(pickE2eRenderer() === 'opentui')(
let restoreNoProxy: (() => void) | undefined;
afterEach(async () => {
await session?.close();
session = undefined;
await server?.close();
server = undefined;
restoreNoProxy?.();
await rig?.cleanup();
try {
await session?.close();
} finally {
session = undefined;
await server?.close();
server = undefined;
restoreNoProxy?.();
await rig?.cleanup();
}
});
it.each([
+40 -6
View File
@@ -7,6 +7,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
E2E_RENDERER_ENV_VAR,
E2E_RUNTIME_ENV_VAR,
e2eRendererEnv,
pickE2eRenderer,
resolveE2eCliCommand,
@@ -24,13 +25,18 @@ vi.mock('node:child_process', async (importOriginal) => {
describe('renderer-matrix', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.resetAllMocks();
});
it('defaults to ink so node-only runners keep their behavior', () => {
expect(pickE2eRenderer({})).toBe('ink');
expect(pickE2eRenderer({ [E2E_RENDERER_ENV_VAR]: '' })).toBe('ink');
expect(pickE2eRenderer({ [E2E_RENDERER_ENV_VAR]: 'garbage' })).toBe('ink');
});
it('rejects a misspelled renderer instead of silently testing Ink', () => {
expect(() => pickE2eRenderer({ [E2E_RENDERER_ENV_VAR]: 'openui' })).toThrow(
/QWEN_E2E_RENDERER must be ink or opentui/,
);
});
it('opts into opentui via QWEN_E2E_RENDERER', () => {
@@ -52,14 +58,14 @@ describe('renderer-matrix', () => {
});
it('uses node for ink', () => {
expect(resolveE2eCliCommand('ink')).toBe('node');
expect(resolveE2eCliCommand('ink', {})).toBe('node');
expect(mockSpawnSync).not.toHaveBeenCalled();
});
it('uses bun for opentui when bun is on PATH', () => {
mockSpawnSync.mockReturnValueOnce({ error: null, status: 0 });
expect(resolveE2eCliCommand('opentui')).toBe('bun');
expect(resolveE2eCliCommand('opentui', {})).toBe('bun');
expect(mockSpawnSync).toHaveBeenCalledWith(
'bun',
['--version'],
@@ -73,8 +79,36 @@ describe('renderer-matrix', () => {
status: null,
});
expect(() => resolveE2eCliCommand('opentui')).toThrow(
/QWEN_E2E_RENDERER=opentui requires bun/,
expect(() => resolveE2eCliCommand('opentui', {})).toThrow(
/bun\+opentui E2E leg requires bun/,
);
});
it('runs Ink on Bun when explicitly requested', () => {
mockSpawnSync.mockReturnValueOnce({ status: 0 });
expect(
resolveE2eCliCommand('ink', { [E2E_RUNTIME_ENV_VAR]: ' Bun ' }),
).toBe('bun');
expect(mockSpawnSync).toHaveBeenCalledOnce();
});
it.each([{ error: new Error('ENOENT'), status: null }, { status: 1 }])(
'fails the Bun+Ink leg when the runtime probe fails: %j',
(result) => {
mockSpawnSync.mockReturnValueOnce(result);
expect(() =>
resolveE2eCliCommand('ink', { [E2E_RUNTIME_ENV_VAR]: 'bun' }),
).toThrow(/bun\+ink E2E leg requires bun/);
},
);
it('rejects unknown runtimes and unsupported combinations', () => {
expect(() =>
resolveE2eCliCommand('ink', { [E2E_RUNTIME_ENV_VAR]: 'deno' }),
).toThrow(/QWEN_E2E_RUNTIME must be node or bun/);
expect(() =>
resolveE2eCliCommand('opentui', { [E2E_RUNTIME_ENV_VAR]: 'node' }),
).toThrow(/opentui requires bun in the E2E matrix/);
expect(mockSpawnSync).not.toHaveBeenCalled();
});
});
+29 -7
View File
@@ -10,6 +10,9 @@
* `QWEN_E2E_RENDERER` selects which renderer the interactive suite drives:
* - 'ink' (default): node runs dist/cli.js with QWEN_TUI_RENDERER=ink.
* - 'opentui': bun runs dist/cli.js with QWEN_TUI_RENDERER=opentui.
* `QWEN_E2E_RUNTIME=bun` also runs the Ink leg on Bun, separating runtime
* regressions (Node+Ink vs Bun+Ink) from renderer regressions (Bun+Ink vs
* Bun+OpenTUI). Missing runtimes and invalid selections fail the run.
*
* The overlay pins the renderer for the spawned CLI, and the opentui leg
* also sets QWEN_TUI_RENDERER_STRICT: the product gate (selectTuiRenderer)
@@ -29,13 +32,17 @@ import { spawnSync } from 'node:child_process';
export type E2eRenderer = 'ink' | 'opentui';
export const E2E_RENDERER_ENV_VAR = 'QWEN_E2E_RENDERER';
export const E2E_RUNTIME_ENV_VAR = 'QWEN_E2E_RUNTIME';
export function pickE2eRenderer(
env: NodeJS.ProcessEnv = process.env,
): E2eRenderer {
const requested = env[E2E_RENDERER_ENV_VAR]?.trim().toLowerCase();
if (requested === 'opentui') return 'opentui';
return 'ink';
if (!requested || requested === 'ink') return 'ink';
if (requested === 'opentui') return requested;
throw new Error(
`${E2E_RENDERER_ENV_VAR} must be ink or opentui; received ${requested}.`,
);
}
/**
@@ -43,14 +50,29 @@ export function pickE2eRenderer(
* ink, bun for opentui (the locked @opentui/core needs bun:ffi — CI node
* builds cannot load node:ffi).
*/
export function resolveE2eCliCommand(renderer: E2eRenderer): string {
if (renderer === 'ink') return 'node';
export function resolveE2eCliCommand(
renderer: E2eRenderer,
env: NodeJS.ProcessEnv = process.env,
): string {
const requested = env[E2E_RUNTIME_ENV_VAR]?.trim().toLowerCase();
const runtime = requested || (renderer === 'opentui' ? 'bun' : 'node');
if (runtime !== 'node' && runtime !== 'bun') {
throw new Error(
`${E2E_RUNTIME_ENV_VAR} must be node or bun; received ${runtime}.`,
);
}
if (renderer === 'opentui' && runtime !== 'bun') {
throw new Error(
`${E2E_RENDERER_ENV_VAR}=opentui requires bun in the E2E matrix; ` +
`set ${E2E_RUNTIME_ENV_VAR}=bun.`,
);
}
if (runtime === 'node') return runtime;
const probe = spawnSync('bun', ['--version'], { encoding: 'utf-8' });
if (probe.error || probe.status !== 0) {
throw new Error(
`${E2E_RENDERER_ENV_VAR}=opentui requires bun to load the OpenTUI ` +
`native renderer, but \`bun\` was not found on PATH. Install bun ` +
`(https://bun.sh) or unset ${E2E_RENDERER_ENV_VAR}.`,
`The ${runtime}+${renderer} E2E leg requires bun, but \`bun --version\` ` +
`failed. Install bun (https://bun.sh) and rerun the same leg.`,
);
}
return 'bun';
+2
View File
@@ -95,6 +95,7 @@
"test:integration:cli:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests cli",
"test:integration:cli:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests cli",
"test:integration:interactive:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests interactive",
"test:integration:interactive:bun-ink:sandbox:none": "cross-env QWEN_E2E_RENDERER=ink QWEN_E2E_RUNTIME=bun QWEN_SANDBOX=false vitest run --root ./integration-tests interactive",
"test:integration:interactive:opentui:sandbox:none": "cross-env QWEN_E2E_RENDERER=opentui QWEN_SANDBOX=false vitest run --root ./integration-tests interactive",
"test:integration:interactive:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests interactive",
"test:terminal-bench": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests",
@@ -179,6 +180,7 @@
"@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^3.1.1",
"@vitest/eslint-plugin": "^1.3.4",
"@xterm/addon-unicode11": "0.9.0",
"@xterm/headless": "^5.5.0",
"@xterm/xterm": "^6.0.0",
"cross-env": "^7.0.3",
@@ -0,0 +1,549 @@
/* eslint-disable react/no-unknown-property */
/** @jsxImportSource @opentui/react */
/** Real native frames with mocked keyboard input; this is not a PTY test. */
import { act } from 'react';
import { DISPLAY_HOOK_EVENTS } from '../src/ui/components/hooks/constants.js';
import { HookEventName } from '@qwen-code/qwen-code-core/hooks/types.js';
import type { Config } from '@qwen-code/qwen-code-core/config/config.js';
import type { LoadedSettings } from '../src/config/settings.js';
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { testRender } from '@opentui/react/test-utils';
import { useTerminalDimensions } from '@opentui/react';
import { ScrollBoxRenderable, type Renderable } from '@opentui/core';
import { OpenTuiDiffDialog } from '../src/ui/opentui/dialogs-misc.js';
import { OpenTuiHooksDialog } from '../src/ui/opentui/dialogs-hooks.js';
import { dialogAreaWidth } from '../src/ui/opentui/dialogs-shared.js';
type Setup = Awaited<ReturnType<typeof testRender>>;
const settings = { merged: {} } as LoadedSettings;
const steps = ['events', 'matchers', 'handlers', 'detail'];
const literal =
'§ 甲乙丙 カナ 한글 ' +
Array.from(
{ length: 80 },
(_, i) => String.fromCharCode(0x400 + i) + ' ' + 'word '.repeat(4),
).join('\n') +
' ¶ ' +
'word '.repeat(80) +
'¤';
const config = {
getDisableAllHooks: () => false,
isSafeMode: () => false,
getBareMode: () => false,
isTrustedFolder: () => true,
getSessionId: () => undefined,
getHookSystem: () => ({
getAllHooks: () =>
Array.from({ length: 15 }, (_, i) => ({
eventName: 'PreToolUse',
matcher: i === 0 ? 'Read' : `matcher-${i}`,
enabled: true,
source: 'user',
config: {
type: 'command',
command: literal,
timeout: 123,
async: true,
statusMessage: '※',
},
})),
}),
} as unknown as Config;
function all(n: Renderable): Renderable[] {
return [n, ...n.getChildren().flatMap(all)];
}
function summary(s: Setup, region: number) {
const painted = s.captureCharFrame().split('\n');
const nodes = all(s.renderer.root);
assert(
!painted.slice(region).some((r: string) => r.trim()),
`paint outside region ${region}: ${JSON.stringify(painted.slice(region).filter((r: string) => r.trim()))}`,
);
const frame = s.renderer.root.getChildren()[0]?.getChildren()[0];
assert(
frame && frame.height <= region,
`native frame ${frame?.height} exceeds ${region}`,
);
return {
frameHeight: s.renderer.root.getChildren()[0]?.getChildren()[0]?.height,
overpaint: painted.slice(region).some((r: string) => r.trim()),
outside: painted.slice(region).filter((r: string) => r.trim()),
drawn: painted.slice(0, region),
scrolls: nodes
.filter((n) => n instanceof ScrollBoxRenderable)
.map((n) => ({
height: n.height,
scrollHeight: n.scrollHeight,
scrollTop: n.scrollTop,
})),
};
}
async function renderDiff(width: number, region: number, sandboxed: boolean) {
let s: Setup | undefined;
await act(async () => {
s = await testRender(
<box
height={region}
width={dialogAreaWidth(width)}
marginLeft={2}
overflow="hidden"
flexDirection="column"
>
<OpenTuiDiffDialog
config={
{ getShellExecutionSandbox: () => sandboxed } as unknown as Config
}
settings={settings}
onClose={() => {}}
availableTerminalHeight={region}
/>
</box>,
{ width, height: 60 },
);
});
assert(s);
const setup = s;
setup.renderer.start();
await act(async () => setup.waitForVisualIdle());
return s;
}
async function emptyHooks() {
for (const event of [HookEventName.PreToolUse, HookEventName.Stop]) {
let setup: Setup | undefined;
function Responsive() {
const { width, height } = useTerminalDimensions();
return (
<box
height={height - 60}
width={dialogAreaWidth(width)}
marginLeft={2}
overflow="hidden"
flexDirection="column"
>
<OpenTuiHooksDialog
settings={settings}
onClose={() => {}}
availableTerminalHeight={height - 60}
/>
</box>
);
}
await act(async () => {
setup = await testRender(<Responsive />, { width: 100, height: 100 });
});
assert(setup);
const ready = setup;
ready.renderer.start();
try {
await act(async () => ready.waitForVisualIdle());
for (let i = 0; i < DISPLAY_HOOK_EVENTS.indexOf(event); i++) {
await act(async () => ready.mockInput.pressKey('ARROW_DOWN'));
}
await act(async () => ready.mockInput.pressEnter());
await act(async () => ready.waitForVisualIdle());
for (const width of [20, 40, 100])
for (const region of [1, 2, 3, 4, 5, 6, 11, 12, 15, 40]) {
await act(async () => ready.resize(width, region + 60));
await act(async () => ready.waitForVisualIdle());
if (width === 40 && region === 15) {
const sb = all(ready.renderer.root).find(
(n): n is ScrollBoxRenderable => n instanceof ScrollBoxRenderable,
);
assert(sb, 'missing empty scrollbox');
const wordFrames: Record<string, string> = {};
const words = ['event', 'settings.json'];
for (let top = 0; top <= sb.scrollHeight; top++) {
sb.scrollTo(top);
await ready.renderOnce();
summary(ready, region);
const frame = ready.captureCharFrame();
for (const word of words) {
if (frame.includes(word)) wordFrames[word] ??= frame;
}
}
console.log(
JSON.stringify({
case: 'empty-word-boundaries',
event,
width,
region,
wordFrames,
}),
);
assert(
words.every((word) => wordFrames[word]?.includes(word)),
'empty 40x15: event/settings.json must be visible as complete words',
);
}
console.log(
JSON.stringify({
case: 'empty-hooks',
event,
width,
region,
...summary(ready, region),
}),
);
}
} finally {
await act(async () => ready.renderer.destroy());
}
}
}
async function main() {
await emptyHooks();
for (const sandboxed of [true, false])
for (const width of [20, 40, 100])
for (const region of [1, 2, 3, 4, 5, 6, 15]) {
const setup = await renderDiff(width, region, sandboxed);
try {
console.log(
JSON.stringify({
case: 'diff',
sandboxed,
width,
region,
...summary(setup, region),
}),
);
if (sandboxed) {
const scroll = all(setup.renderer.root).find(
(node): node is ScrollBoxRenderable =>
node instanceof ScrollBoxRenderable,
);
assert(scroll, 'sandbox notice has no scrollbox');
await act(async () => setup.mockInput.pressKey('END'));
await act(async () => setup.waitForVisualIdle());
assert(
setup.captureCharFrame().includes('tool.'),
`sandbox ${width}x${region}: End cannot reach the notice tail`,
);
if (scroll.scrollHeight > scroll.height) {
assert(scroll.scrollTop > 0, 'End did not scroll the notice');
}
await act(async () => setup.mockInput.pressKey('HOME'));
await act(async () => setup.waitForVisualIdle());
assert.equal(
scroll.scrollTop,
0,
'Home returns to the notice head',
);
summary(setup, region);
}
} finally {
await act(async () => setup.renderer.destroy());
}
}
for (const sandboxed of [true, false]) {
let closes = 0;
let s: Setup | undefined;
function Responsive() {
const { width, height } = useTerminalDimensions();
return (
<box
height={height - 60}
width={dialogAreaWidth(width)}
marginLeft={2}
overflow="hidden"
flexDirection="column"
>
<OpenTuiDiffDialog
config={
{ getShellExecutionSandbox: () => sandboxed } as unknown as Config
}
settings={settings}
onClose={() => closes++}
availableTerminalHeight={height - 60}
/>
</box>
);
}
await act(async () => {
s = await testRender(<Responsive />, { width: 100, height: 100 });
});
assert(s);
const setup = s;
setup.renderer.start();
await act(async () => setup.waitForVisualIdle());
try {
for (const [width, region] of [
[20, 6],
[40, 15],
[100, 2],
[40, 40],
]) {
await act(async () => setup.resize(width, region + 60));
await act(async () => setup.waitForVisualIdle());
console.log(
JSON.stringify({
case: 'diff-resize',
sandboxed,
width,
region,
...summary(setup, region),
}),
);
}
await act(async () => setup.mockInput.pressEscape());
await act(async () => setup.waitForVisualIdle());
assert.equal(closes, 1, 'Diff Esc closes');
console.log(JSON.stringify({ case: 'diff-escape', sandboxed, closes }));
} finally {
await act(async () => setup.renderer.destroy());
}
}
for (const step of steps) {
let closes = 0;
let s: Setup | undefined;
function ResponsiveHooks() {
const { width, height } = useTerminalDimensions();
return (
<box
height={height - 60}
width={dialogAreaWidth(width)}
marginLeft={2}
overflow="hidden"
flexDirection="column"
>
<OpenTuiHooksDialog
config={config}
settings={settings}
onClose={() => closes++}
availableTerminalHeight={height - 60}
/>
</box>
);
}
await act(async () => {
s = await testRender(<ResponsiveHooks />, { width: 100, height: 100 });
});
assert(s);
const setup = s;
setup.renderer.start();
await act(async () => setup.waitForVisualIdle());
try {
for (let j = 0; j < steps.indexOf(step); j++) {
await act(async () => setup.mockInput.pressEnter());
await act(async () => setup.waitForVisualIdle());
}
console.log(
JSON.stringify({
case: 'hooks-step-entry',
step,
frame: setup.captureCharFrame(),
}),
);
for (const width of [20, 28, 29, 40, 100])
for (const region of [1, 2, 3, 4, 5, 6, 15, 40]) {
await act(async () => setup.resize(width, region + 60));
await act(async () => setup.waitForVisualIdle());
console.log(
JSON.stringify({
case: 'hooks-resize',
width,
region,
step,
...summary(setup, region),
}),
);
if (step === 'matchers' && width === 100 && region === 15) {
assert(
setup.captureCharFrame().includes('matcher-2'),
'last budgeted matcher absent',
);
assert(
!setup.captureCharFrame().includes('matcher-3'),
'matcher exceeds paid window',
);
for (let index = 0; index < 14; index++) {
await act(async () => setup.mockInput.pressKey('ARROW_DOWN'));
await act(async () => setup.waitForVisualIdle());
}
assert.match(
setup.captureCharFrame(),
/›\s+15\.\s+matcher-14/,
'final matcher must be visibly selected',
);
await act(async () => setup.mockInput.pressEnter());
await act(async () => setup.waitForVisualIdle());
assert(
setup.captureCharFrame().includes('Matcher: matcher-14'),
'Enter did not commit the visibly selected matcher',
);
summary(setup, region);
await act(async () => setup.mockInput.pressEscape());
await act(async () => setup.waitForVisualIdle());
}
if (step === 'detail') {
const sb: ScrollBoxRenderable | undefined = all(
setup.renderer.root,
).find(
(n): n is ScrollBoxRenderable => n instanceof ScrollBoxRenderable,
);
const seen = Object.fromEntries(
[
'§',
'甲',
'乙',
'丙',
'カ',
'ナ',
'한',
'글',
'¶',
'¤',
'※',
...Array.from({ length: 80 }, (_, i) =>
String.fromCharCode(0x400 + i),
),
].map((k) => [k, false]),
);
let settingsWordFrame: string | undefined;
const scan = () => {
const f = setup.captureCharFrame();
if (f.includes('settings.json')) settingsWordFrame ??= f;
for (const k of Object.keys(seen))
seen[k as keyof typeof seen] ||= f.includes(k);
};
scan();
assert(
sb instanceof ScrollBoxRenderable,
'missing detail scrollbox',
);
assert.equal(sb.scrollTop, 0, 'resize resets detail to head');
if (region === 1) {
assert(
setup.captureCharFrame().includes('Event:'),
'one-row detail starts with a hook field',
);
assert(
!setup.captureCharFrame().includes('Hook details'),
'one-row detail sheds its heading',
);
}
for (let top = 0; top <= sb.scrollHeight; top++) {
sb.scrollTo(top);
await setup.renderOnce();
scan();
}
assert(sb, `detail ${width}x${region}: no native scrollbox`);
assert(
Object.values(seen).every(Boolean),
`detail ${width}x${region}: unreachable glyphs ${Object.entries(
seen,
)
.filter(([, v]) => !v)
.map(([k]) => k)
.join('')}`,
);
if (width === 29 && region === 15) {
console.log(
JSON.stringify({
case: 'detail-word-boundaries',
width,
region,
frame: settingsWordFrame,
}),
);
assert(
settingsWordFrame?.includes('settings.json'),
'detail 29x15: settings.json must be visible as a complete word',
);
}
if (region === 40) {
const press = async (key: string) => {
await act(async () => setup.mockInput.pressKey(key));
await act(async () => setup.waitForVisualIdle());
};
await press('HOME');
assert.equal(sb.scrollTop, 0);
await press('ARROW_DOWN');
assert.equal(sb.scrollTop, 1);
await press('ARROW_UP');
assert.equal(sb.scrollTop, 0);
await press('\x1b[6~');
assert.equal(sb.scrollTop, sb.height);
await press('\x1b[5~');
assert.equal(sb.scrollTop, 0);
await press('END');
assert(sb.scrollTop > 0);
const previousTop = sb.scrollTop;
await act(async () => setup.resize(width, 110));
await act(async () => setup.waitForVisualIdle());
summary(setup, 50);
const unchanged = all(setup.renderer.root).find(
(node): node is ScrollBoxRenderable =>
node instanceof ScrollBoxRenderable,
);
assert.equal(
unchanged?.scrollTop,
previousTop,
'unchanged viewport preserves scroll',
);
await act(async () => setup.resize(width, 100));
await act(async () => setup.waitForVisualIdle());
assert(
s
.captureCharFrame()
.replace(/[│╭╰╯╮─▀▄█\s]/g, '')
.includes('help.'),
'instruction tail unreachable',
);
}
console.log(
JSON.stringify({
case: 'literal',
width,
region,
seen,
hasScrollbox: Boolean(sb),
}),
);
}
}
for (let j = 0; j <= steps.indexOf(step); j++) {
await act(async () => setup.mockInput.pressEscape());
await act(async () => setup.waitForVisualIdle());
assert.equal(
closes,
j === steps.indexOf(step) ? 1 : 0,
`hooks ${step} Esc ${j + 1}`,
);
console.log(
JSON.stringify({
case: 'hooks-escape',
step,
back: j + 1,
closes,
frame: setup.captureCharFrame(),
}),
);
}
} finally {
await act(async () => setup.renderer.destroy());
}
}
}
const originalCwd = process.cwd();
const originalGitCeiling = process.env['GIT_CEILING_DIRECTORIES'];
const emptyCwd = await mkdtemp(join(tmpdir(), 'qwen-hook-content-'));
try {
// The non-sandbox case is an empty preview, independent of local changes.
process.env['GIT_CEILING_DIRECTORIES'] = dirname(emptyCwd);
process.chdir(emptyCwd);
await main();
} catch (error) {
console.error(error);
process.exitCode = 1;
} finally {
process.chdir(originalCwd);
if (originalGitCeiling === undefined) {
delete process.env['GIT_CEILING_DIRECTORIES'];
} else {
process.env['GIT_CEILING_DIRECTORIES'] = originalGitCeiling;
}
await rm(emptyCwd, { recursive: true, force: true });
}
@@ -32,9 +32,9 @@ const mocks = vi.hoisted(() => {
) => {
const config = key === undefined ? props : { ...props, key };
const children = (config?.children ?? null) as React.ReactNode;
if (type === 'box' || type === 'text') {
if (type === 'box' || type === 'text' || type === 'scrollbox') {
return React.createElement(
type === 'box' ? 'div' : 'span',
type === 'text' ? 'span' : 'div',
key === undefined ? null : { key },
children,
);
@@ -565,6 +565,72 @@ describe('OpenTuiHooksDialog', () => {
expect(screen.queryByText('[command] ./c.sh')).toBeNull();
});
it('windows matchers and commits the visible final selection', async () => {
render(
<OpenTuiHooksDialog
config={configWith({
entries: Array.from({ length: 12 }, (_, index) => ({
eventName: HookEventName.PreToolUse,
matcher: `matcher-${index}`,
config: { type: HookType.Command, command: './lint.sh' },
})),
})}
settings={settingsWith()}
onClose={vi.fn()}
availableTerminalHeight={15}
/>,
);
await press('return');
expect(screen.getByText('matcher-2')).toBeTruthy();
expect(screen.queryByText('matcher-3')).toBeNull();
for (let index = 0; index < 11; index++) await press('down');
expect(screen.getByText('matcher-11')).toBeTruthy();
await press('return');
expect(screen.getByText('PreToolUse - Matcher: matcher-11')).toBeTruthy();
await press('escape');
await press('escape');
expect(screen.getByText('PreToolUse')).toBeTruthy();
});
it('retains a selectable row at a one-row region and walks back from details', async () => {
const onClose = vi.fn();
render(
<OpenTuiHooksDialog
config={configWith({
entries: [
{
eventName: HookEventName.PreToolUse,
matcher: 'Read',
config: { type: HookType.Command, command: 'HEAD\nMIDDLE\nTAIL' },
},
],
})}
settings={settingsWith()}
onClose={onClose}
availableTerminalHeight={1}
/>,
);
expect(screen.queryByText('Hooks')).toBeNull();
for (
let i = 0;
i < DISPLAY_HOOK_EVENTS.indexOf(HookEventName.PreToolUse);
i++
) {
await press('down');
}
await press('return');
expect(screen.getByText('Read')).toBeTruthy();
await press('return');
await press('return');
expect(screen.queryByText('Hook details')).toBeNull();
expect(screen.getByText('Event:')).toBeTruthy();
expect(screen.getByText('HEAD')).toBeTruthy();
expect(screen.getByText('MIDDLE')).toBeTruthy();
expect(screen.getByText('TAIL')).toBeTruthy();
for (let i = 0; i < 4; i++) await press('escape');
expect(onClose).toHaveBeenCalledOnce();
});
it('clips a wrapping matcher name to the one row the window charges for it', async () => {
const longMatcher = 'm'.repeat(200);
render(
+197 -94
View File
@@ -14,7 +14,9 @@
* `/goal` and the SDK registered for this session.
*/
import { useMemo, useState } from 'react';
import { useMemo, useRef, useState } from 'react';
import wrapAnsi from 'wrap-ansi';
import type { ScrollBoxRenderable } from '@opentui/core';
import { useKeyboard, useTerminalDimensions } from '@opentui/react';
import type { Config } from '@qwen-code/qwen-code-core/config/config.js';
import {
@@ -46,7 +48,12 @@ import {
useDialogSelect,
type DialogListItem,
} from './dialogs-shared.js';
import { regionListWindow, wrappedRows } from './dialogs-core.js';
import {
chromeRows,
regionListWindow,
scrollDialogLayout,
wrappedRows,
} from './dialogs-core.js';
import { clampDialogHeight } from '../utils/layoutUtils.js';
import {
@@ -61,6 +68,37 @@ import { C } from './theme.js';
const MAX_ROWS = 12;
const DETAIL_LABEL_WIDTH = 18;
function DetailText({
value,
width,
dim = false,
bold = false,
}: {
value: string;
width: number;
dim?: boolean;
bold?: boolean;
}) {
return (
<box width={width} flexDirection="column" flexShrink={0}>
{/* Separate physical rows avoid native multiline Text losing its tail when scrolled. */}
{wrapAnsi(value, width, { hard: true, trim: false })
.split('\n')
.map((line, index) => (
<text
key={index}
height={1}
flexShrink={0}
fg={dim ? C.dim : C.text}
attributes={bold ? 1 : 0}
>
{line}
</text>
))}
</box>
);
}
export type HooksDialogView =
| { step: 'events' }
| { step: 'matchers'; event: HookEventName }
@@ -378,36 +416,49 @@ export function OpenTuiHooksDialog({
: view.step === 'detail'
? t('Esc to go back')
: t('Enter to select · Esc to go back');
const eventsWindow = regionListWindow(
const listChrome = (measuredRows: number) => ({
fixed: 7,
runs: [{ text: footerText, width: contentWidth }],
measuredRows,
});
const eventsChrome = listChrome(
headerRows + noticeRows + bannerRows + eventsNoteRows,
);
const matchersChrome = listChrome(
headerRows + matchersHeaderRows + noticeRows + bannerRows,
);
const handlersChrome = listChrome(
headerRows + handlersHeaderRows + noticeRows + bannerRows,
);
const detailChrome = {
...listChrome(headerRows + noticeRows + bannerRows),
fixed: 6,
};
const currentChrome =
view.step === 'events'
? eventsChrome
: view.step === 'matchers'
? matchersChrome
: view.step === 'handlers' ||
(view.step === 'detail' && !handlerRows[view.index])
? handlersChrome
: detailChrome;
const bodyLayout = scrollDialogLayout(
regionHeight,
{
fixed: 7,
runs: [{ text: footerText, width: contentWidth }],
measuredRows: headerRows + noticeRows + bannerRows + eventsNoteRows,
},
eventItems.length,
MAX_ROWS,
);
const matchersWindow = regionListWindow(
regionHeight,
{
fixed: 7,
runs: [{ text: footerText, width: contentWidth }],
measuredRows: headerRows + matchersHeaderRows + noticeRows + bannerRows,
},
matcherItems.length,
MAX_ROWS,
);
const handlersWindow = regionListWindow(
regionHeight,
{
fixed: 7,
runs: [{ text: footerText, width: contentWidth }],
measuredRows: headerRows + handlersHeaderRows + noticeRows + bannerRows,
},
handlerItems.length,
chromeRows(currentChrome),
MAX_ROWS,
);
const windowFor = (chrome: typeof eventsChrome, count: number) =>
regionListWindow(
regionHeight,
bodyLayout.compact ? { fixed: bodyLayout.border ? 2 : 0 } : chrome,
count,
MAX_ROWS,
);
const eventsWindow = windowFor(eventsChrome, eventItems.length);
const matchersWindow = windowFor(matchersChrome, matcherItems.length);
const handlersWindow = windowFor(handlersChrome, handlerItems.length);
const bodyScroll = useRef<ScrollBoxRenderable>(null);
// Every list row is charged one physical row in the windows above, so each
// label's runs clip to the columns the row actually owns: the content width
// minus DialogSelect's indicator and number columns.
@@ -448,7 +499,17 @@ export function OpenTuiHooksDialog({
});
useKeyboard((key) => {
if (toOriginalKey(key).name !== 'escape') return;
const name = toOriginalKey(key).name;
if (bodyScroll.current) {
const scroll = bodyScroll.current;
if (name === 'up') scroll.scrollBy(-1);
if (name === 'down') scroll.scrollBy(1);
if (name === 'pageup') scroll.scrollBy(-bodyLayout.bodyRows);
if (name === 'pagedown') scroll.scrollBy(bodyLayout.bodyRows);
if (name === 'home') scroll.scrollTo(0);
if (name === 'end') scroll.scrollTo(scroll.scrollHeight);
}
if (name !== 'escape') return;
const previous = previousHooksDialogView(view);
if (previous) {
setView(previous);
@@ -478,12 +539,14 @@ export function OpenTuiHooksDialog({
const renderEvents = () => (
<box flexDirection="column">
<text fg={C.dim}>
{t(
'This menu is read-only. To add or modify hooks, edit settings.json directly or ask Qwen Code.',
)}
</text>
<box marginTop={1} flexDirection="column">
{!bodyLayout.compact ? (
<text fg={C.dim}>
{t(
'This menu is read-only. To add or modify hooks, edit settings.json directly or ask Qwen Code.',
)}
</text>
) : null}
<box marginTop={bodyLayout.compact ? 0 : 1} flexDirection="column">
<DialogSelect
items={eventItems}
activeIndex={eventSelect.activeIndex}
@@ -530,18 +593,28 @@ export function OpenTuiHooksDialog({
);
const renderEmpty = (message: string) => (
<box flexDirection="column">
<text fg={C.dim}>{message}</text>
<text fg={C.dim}>
{t('To add hooks, edit settings.json directly or ask Qwen.')}
</text>
</box>
<scrollbox
ref={bodyScroll}
key={`empty-${width}-${bodyLayout.bodyRows}-${bodyLayout.compact}`}
height={bodyLayout.bodyRows}
flexShrink={1}
stickyScroll={false}
>
<DetailText value={message} width={Math.max(1, contentWidth - 1)} dim />
<DetailText
value={t('To add hooks, edit settings.json directly or ask Qwen.')}
width={Math.max(1, contentWidth - 1)}
dim
/>
</scrollbox>
);
const renderMatchers = (event: HookEventName) => (
<box flexDirection="column">
<text fg={C.text}>{`${event} - ${t('Matchers')}`}</text>
<box marginTop={1} flexDirection="column">
{!bodyLayout.compact ? (
<text fg={C.text}>{`${event} - ${t('Matchers')}`}</text>
) : null}
<box marginTop={bodyLayout.compact ? 0 : 1} flexDirection="column">
{matcherItems.length === 0 ? (
renderEmpty(t('No hooks configured for this event.'))
) : (
@@ -583,13 +656,17 @@ export function OpenTuiHooksDialog({
const renderHandlers = (event: HookEventName, matcher?: string) => (
<box flexDirection="column">
<text fg={C.text}>
{matcher === undefined
? event
: `${event} - ${t('Matcher:')} ${sanitizeTerminalLine(matcher)}`}
</text>
<text fg={C.dim}>{getHookShortDescription(event)}</text>
<box marginTop={1} flexDirection="column">
{!bodyLayout.compact ? (
<>
<text fg={C.text}>
{matcher === undefined
? event
: `${event} - ${t('Matcher:')} ${sanitizeTerminalLine(matcher)}`}
</text>
<text fg={C.dim}>{getHookShortDescription(event)}</text>
</>
) : null}
<box marginTop={bodyLayout.compact ? 0 : 1} flexDirection="column">
{handlerItems.length === 0 ? (
renderEmpty(
matcher === undefined
@@ -649,32 +726,54 @@ export function OpenTuiHooksDialog({
</box>
);
const renderDetail = (row: HooksListingRow) => (
<box flexDirection="column">
<text fg={C.text} attributes={1}>
{t('Hook details')}
</text>
<box marginTop={1} flexDirection="column">
const renderDetail = (row: HooksListingRow) => {
// The native scrollbox reserves one column for its vertical scrollbar.
const scrollContentWidth = Math.max(1, contentWidth - 1);
const stacked = scrollContentWidth < DETAIL_LABEL_WIDTH + 2;
const valueWidth = stacked
? scrollContentWidth
: scrollContentWidth - DETAIL_LABEL_WIDTH;
return (
<scrollbox
ref={bodyScroll}
key={`detail-${width}-${bodyLayout.bodyRows}-${bodyLayout.compact}`}
height={bodyLayout.bodyRows}
flexShrink={1}
stickyScroll={false}
>
{!bodyLayout.compact ? (
<DetailText
value={t('Hook details')}
width={scrollContentWidth}
bold
/>
) : null}
{hookDetailFields(row).map(([label, value]) => (
<box key={label} flexDirection="row">
<box width={DETAIL_LABEL_WIDTH} flexShrink={0}>
<text fg={C.dim}>{label}</text>
</box>
<box flexGrow={1}>
<text fg={C.text}>{value}</text>
</box>
<box
key={label}
flexDirection={stacked ? 'column' : 'row'}
flexShrink={0}
>
<DetailText
value={label}
width={stacked ? scrollContentWidth : DETAIL_LABEL_WIDTH}
dim
/>
<DetailText value={value} width={valueWidth} />
</box>
))}
</box>
<box marginTop={1}>
<text fg={C.dim}>
{t(
'To modify or remove this hook, edit settings.json directly or ask Qwen to help.',
)}
</text>
</box>
</box>
);
<box marginTop={1}>
<DetailText
value={t(
'To modify or remove this hook, edit settings.json directly or ask Qwen to help.',
)}
width={scrollContentWidth}
dim
/>
</box>
</scrollbox>
);
};
let body;
let footer: string;
@@ -706,29 +805,33 @@ export function OpenTuiHooksDialog({
}
return (
<DialogFrame>
<box flexDirection="row">
<text fg={C.accent} attributes={1}>
{t('Hooks')}
</text>
<text fg={C.dim}>{` · ${countText}`}</text>
</box>
{notice
? notice.split('\n').map((line, index) => (
<text key={`notice-${index}`} fg={C.dim}>
{line}
</text>
))
: null}
{banner ? (
<box marginTop={1}>
<text fg={C.yellow}>{banner}</text>
</box>
<DialogFrame bodyLayout={bodyLayout}>
{!bodyLayout.compact ? (
<>
<text>
<span fg={C.accent} attributes={1}>
{t('Hooks')}
</span>
<span fg={C.dim}>{` · ${countText}`}</span>
</text>
{notice
? notice.split('\n').map((line, index) => (
<text key={`notice-${index}`} fg={C.dim}>
{line}
</text>
))
: null}
{banner ? (
<box marginTop={1}>
<text fg={C.yellow}>{banner}</text>
</box>
) : null}
</>
) : null}
<box marginTop={1} flexDirection="column">
<box marginTop={bodyLayout.compact ? 0 : 1} flexDirection="column">
{body}
</box>
<FooterHint text={footer} />
{!bodyLayout.compact ? <FooterHint text={footer} /> : null}
</DialogFrame>
);
}
+15 -11
View File
@@ -21,6 +21,7 @@ import {
useState,
type ReactNode,
} from 'react';
import wrapAnsi from 'wrap-ansi';
import { useRenderer, useTerminalDimensions } from '@opentui/react';
import type { Config } from '@qwen-code/qwen-code-core/config/config.js';
import type { SessionListItem } from '@qwen-code/qwen-code-core/services/sessionService.js';
@@ -570,16 +571,6 @@ export function OpenTuiDiffDialog({
alive = false;
};
}, [sandboxed]);
if (sandboxed) {
return (
<Shell title="Diff" onClose={onClose}>
<text fg={C.dim}>
Diff preview unavailable in tool sandbox. Run git diff through the
Shell tool.
</text>
</Shell>
);
}
return (
<Shell title="Diff" onClose={onClose} bodyLayout={bodyLayout}>
<scrollbox
@@ -588,8 +579,21 @@ export function OpenTuiDiffDialog({
marginTop={bodyLayout.compact ? 0 : 1}
flexShrink={1}
stickyScroll={false}
focused
>
{lines.length === 0 ? (
{sandboxed ? (
wrapAnsi(
'Diff preview unavailable in tool sandbox. Run git diff through the Shell tool.',
Math.max(1, dialogContentWidth(width) - 1),
{ hard: true, trim: false },
)
.split('\n')
.map((line, index) => (
<text key={index} height={1} flexShrink={0} fg={C.dim}>
{line}
</text>
))
) : lines.length === 0 ? (
<text fg={C.dim}>{'no working-tree changes'}</text>
) : (
lines.map((l, i) => (
+12 -1
View File
@@ -26,6 +26,7 @@ import { keyMatchers, Command } from '../keyMatchers.js';
import { toOriginalKey } from './key-map.js';
import { getCachedStringWidth, truncateToWidth } from '../utils/textUtils.js';
import {
type scrollDialogLayout,
applyNumberSelectKey,
computeInitialActiveIndex,
findNextEnabledIndex,
@@ -90,13 +91,23 @@ export function DialogFrame(props: {
* region, with the unused rows blank below it.
*/
fill?: boolean;
bodyLayout?: ReturnType<typeof scrollDialogLayout>;
}) {
return (
<box
key={
props.bodyLayout?.compact
? `compact-${props.bodyLayout.border}`
: 'normal'
}
flexDirection="column"
borderStyle="rounded"
border={props.bodyLayout?.border === false ? [] : true}
borderColor={props.borderColor ?? C.borderDefault}
padding={1}
paddingLeft={1}
paddingRight={1}
paddingTop={props.bodyLayout?.compact ? 0 : 1}
paddingBottom={props.bodyLayout?.compact ? 0 : 1}
flexGrow={props.fill ? 1 : 0}
// Region-mounted, like the sibling frames: a shrinkable frame lets a
// short region squeeze the body's unsized text rows to zero and paint
+8
View File
@@ -103,6 +103,9 @@ importers:
'@vitest/eslint-plugin':
specifier: ^1.3.4
version: 1.3.4([email protected]([email protected])([email protected]))([email protected])([email protected])([email protected](@types/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected]))([email protected])([email protected])([email protected]))
'@xterm/addon-unicode11':
specifier: 0.9.0
version: 0.9.0
'@xterm/headless':
specifier: ^5.5.0
version: 5.5.0
@@ -5487,6 +5490,9 @@ packages:
'@xterm/[email protected]':
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
'@xterm/[email protected]':
resolution: {integrity: sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==}
'@xterm/[email protected]':
resolution: {integrity: sha512-5xXB7kdQlFBP82ViMJTwwEc3gKCLGKR/eoxQm4zge7GPBl86tCdI0IdPJjoKd8mUSFXz5V7i/25sfsEkP4j46g==}
@@ -15065,6 +15071,8 @@ snapshots:
'@xterm/[email protected]': {}
'@xterm/[email protected]': {}
'@xterm/[email protected]': {}
'@xterm/[email protected]': {}
+38
View File
@@ -8,6 +8,44 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { parse } from 'yaml';
describe('TUI product evidence', () => {
const workflow = parse(
readFileSync('.github/workflows/tui-parity.yml', 'utf8'),
);
const steps = workflow.jobs.parity.steps;
it('treats unhandled errors as acceptance failures on every runner', () => {
const product = steps.find((step) =>
step.name?.startsWith('Three-leg product acceptance'),
);
const commands = product.run
.split('\n')
.filter((line) => line.includes('npx vitest run'));
expect(commands).toHaveLength(2);
for (const command of commands) {
expect(command).toContain('--dangerouslyIgnoreUnhandledErrors=false');
}
});
it('keeps fresh evidence retrievable when the same head is rerun', () => {
const upload = steps.find(
(step) => step.name === 'Upload product acceptance evidence',
);
const product = steps.find((step) =>
step.name?.startsWith('Three-leg product acceptance'),
);
expect(upload.if).toContain('always()');
expect(upload.with.name).toContain('github.event.pull_request.head.sha');
expect(upload.with.name).toContain('${{ github.run_attempt }}');
const artifactName = (attempt) =>
upload.with.name.replace('${{ github.run_attempt }}', String(attempt));
expect(artifactName(1)).not.toBe(artifactName(2));
expect(upload.with.path.replace(/\/$/, '')).toBe(
product.env.QWEN_TUI_ACCEPTANCE_OUT,
);
});
});
describe('e2e workflow', () => {
const workflow = readFileSync('.github/workflows/e2e.yml', 'utf8');
const e2eRunScript = readFileSync('.github/scripts/run-e2e-tests.sh', 'utf8');
+1
View File
@@ -12,3 +12,4 @@ bun packages/cli/scripts/opentui-stats-parity.tsx
bun packages/cli/scripts/opentui-command-parity.ts
bun packages/cli/scripts/opentui-wrap-parity.ts
bun packages/cli/scripts/opentui-dialog-layout.tsx
bun packages/cli/scripts/opentui-hook-content.tsx