Skip to content

fix(cli): hide unavailable external editor option - #10746

Open
SLP-DEV1 wants to merge 12 commits into
QwenLM:mainfrom
SLP-DEV1:fix/external-editor-availability
Open

fix(cli): hide unavailable external editor option#10746
SLP-DEV1 wants to merge 12 commits into
QwenLM:mainfrom
SLP-DEV1:fix/external-editor-availability

Conversation

@SLP-DEV1

@SLP-DEV1 SLP-DEV1 commented Sep 1, 2026

Copy link
Copy Markdown

Summary

  • verify the configured preferred editor is actually available before offering Modify with external editor in edit confirmations
  • memoize the availability check so the synchronous executable lookup runs only when the confirmation type or preferred editor changes, not on every Ink re-render
  • keep the existing IDE-diff and hideModify behavior unchanged
  • consolidate regression coverage into the existing ToolConfirmationMessage.test.tsx suite

Why

A stale general.preferredEditor setting can remain after an editor is uninstalled or removed from PATH. The confirmation UI previously checked only whether the setting was present, so it could offer an action that could not be launched.

The core package already exposes isEditorAvailable(), which checks editor validity, executable availability, and sandbox allowance. Reusing it keeps the UI aligned with the existing editor-selection logic. Because that check can synchronously probe the executable, the result is memoized per confirmation type/preferred editor instead of running on every render.

Fixes #10745

Testing

Updated the existing external editor option tests in ToolConfirmationMessage.test.tsx to cover:

  • available preferred editor -> option is shown
  • unavailable preferred editor -> option is hidden
  • availability lookup is called once across a re-render with the same editor
  • no preferred editor -> option remains hidden
  • hideModify -> option remains hidden

@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Sep 1, 2026
@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

⚠️ Qwen Triage ended earlyview run. It stopped before finishing; check the run log.

⚠️ Qwen Triage 提前结束 —— 查看运行。未跑完,请查看运行日志。

qqqys
qqqys previously requested changes Sep 1, 2026

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical: this PR breaks the repo's own unit suite on any machine without VS Code installed (incl. all CI runners).

ToolConfirmationMessage.test.tsx (unchanged by this PR) contains the pre-existing test external editor option > should show "Modify with external editor" when preferredEditor is set (line 595), which sets preferredEditor: 'vscode' and asserts the option renders. It does NOT mock @qwen-code/qwen-code-core (only useLaunchEditor). With the gate changed to isEditorAvailable(preferredEditor), that test now depends on the host environment: isEditorAvailable('vscode')checkHasEditorTypecommandExists('code') (execSync('command -v code')), which is false on CI runners, so the option disappears and the test fails.

Reproduced locally at this head (62b1788): existing suite is 40/40 green at base; after applying only this PR's two hunks, exactly that 1 test fails — AssertionError: expected ... to contain 'Modify with external editor' (the select renders only options 1-3). Failure is deterministic anywhere code is not on PATH; it is masked only on dev machines that have VS Code installed. Note the new ToolConfirmationMessage.externalEditor.test.tsx passes because it explicitly mocks isEditorAvailable — the old file was not given the same treatment. The PR's product CI lanes have not run yet (triage ended early / action_required), so this is not yet visible in checks.

Fix direction: mock isEditorAvailable in ToolConfirmationMessage.test.tsx the same way the new test file does (or otherwise make the existing assertion environment-independent).


Critical:本 PR 会在任何未安装 VS Code 的机器上(包括所有 CI runner)打破仓库自身的单元测试。

ToolConfirmationMessage.test.tsx(本 PR 未修改)中已存在的测试 external editor option > should show "Modify with external editor" when preferredEditor is set(第 595 行)设置 preferredEditor: 'vscode' 并断言该选项出现。该文件没有 mock @qwen-code/qwen-code-core(只 mock 了 useLaunchEditor)。门槛改为 isEditorAvailable(preferredEditor) 后,该测试的结果取决于运行环境:isEditorAvailable('vscode')checkHasEditorTypecommandExists('code')execSync('command -v code')),在 CI runner 上为 false,选项不再渲染,测试失败。

已在该 head(62b1788)本地复现:base 下 40/40 全绿;仅应用本 PR 的两处 hunk 后,恰好这 1 个测试失败。在没有 code 命令的机器上确定性失败,只在装了 VS Code 的开发机上被掩盖。新增的 ToolConfirmationMessage.externalEditor.test.tsx 之所以通过,是因为它显式 mock 了 isEditorAvailable——旧文件没有做同样处理。该 PR 的产品 CI lane 尚未运行(triage 提前结束),所以 checks 里还看不到。

修复方向:在 ToolConfirmationMessage.test.tsx 中以与新增测试文件相同的方式 mock isEditorAvailable(或让该断言与环境无关)。

Comment thread packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx Outdated
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review did not complete successfully. The review pipeline failed before a review could be posted. A transient error is retried automatically; if you are seeing this, retry with @qwen-code /review. See workflow logs.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline.

Not explored to full depth (tool budget reached): "agent 1a": running ToolConfirmationMessage.externalEditor.test.tsx / ToolConfirmationMessage.test.tsx to confirm they pass — the worktree and the parent checkout have ….

— qwen3.8-max via Qwen Code /review (v0.22.3)

Comment thread packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx Outdated

SLP-DEV1 commented Sep 2, 2026

Copy link
Copy Markdown
Author

@qwen-code /review

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline.

Not explored to full depth (tool budget reached): "agent 1a": did not mutation-verify that the new "configured editor is unavailable" test goes RED when editorAvailable is reverted to the old preferredEditor gate (I ve…; "agent 1a": did not determine empirically whether the provider-less rerender surfaces a swallowed React uncaught error (no console output appeared with --silent=false , ….

— qwen3.8-max-2026-09-02 via Qwen Code /review (v0.22.3)

Comment thread packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx Outdated
Comment thread packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
Comment thread packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx

SLP-DEV1 commented Sep 2, 2026

Copy link
Copy Markdown
Author

@qwen-code /review

@SLP-DEV1
SLP-DEV1 requested a review from qqqys September 2, 2026 12:02

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not explored to full depth (tool budget reached): "agent reverse-audit (round 2)": did not execute npx vitest run src/ui/components/messages/ToolConfirmationMessage.test.tsx myself — relied on R2-1's confirmed measurement (43 tests, 1 failed…; "agent reverse-audit (round 2)": did not run tsc --noEmit or ESLint over the two changed files — verified the new fixture and prop types by comparison against the identical pre-existing fixtu…; "agent 1c": could not execute ToolConfirmationMessage.test.tsx — the review worktree has no node_modules and no packages/core/dist , so the Critical's red/green state …; "agent 1d": none — but I could not execute the test to observe the failure directly: this review worktree has no node_modules and no packages/core/dist , so npx vitest ….

Convergence: round 3 posted 1 inline comment(s), 1 of them reported for the first time; the previous round posted 4 (4 new). Findings keep coming back to the same files: packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx (findings in round 2; 1 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. (Observation only — nothing was withheld from this review because of this observation.)

— qwen3.8-max-2026-09-02 via Qwen Code /review (v0.22.3)

Comment thread packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx Outdated

SLP-DEV1 commented Sep 4, 2026

Copy link
Copy Markdown
Author

@qwen-code /review

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found. LGTM! ✅

— qwen3.8-max via Qwen Code /review (v0.23.0)

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues. LGTM! ✅

Deferred under the convergence posture (round 5, not a blocker) — recorded, not requested in this round:

  • packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx:630 — [review] Rerender test hand-copies provider tree; use withProviders
  • packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx:75 — [review] editorAvailable memo encodes full gate; hideModify checked twice

— qwen3.8-max via Qwen Code /review (v0.23.0)

@qqqys

qqqys commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Withdrawing my 2026-09-01 Critical — verified fixed at b4fa9ccb6b; but product CI has never run on this head

Two separate things. Please read the second one before treating this PR as CI-green.

1. My Critical is fixed, and the fix is load-bearing

My 2026-09-01 review blocked this PR because ToolConfirmationMessage.test.tsx did not mock @qwen-code/qwen-code-core, so the pre-existing test external editor option > should show "Modify with external editor" when preferredEditor is set became host-dependent: isEditorAvailable('vscode')checkHasEditorType → probes for the code binary, which is absent on CI runners.

At head the test file adds exactly the mock I asked for (vi.hoisted + vi.mock('@qwen-code/qwen-code-core', importOriginal) with isEditorAvailableMock defaulting to Boolean(editor)), and the separate ToolConfirmationMessage.externalEditor.test.tsx was consolidated into the existing suite.

Both changed files are byte-identical to the GitHub blobs at b4fa9ccb6b:

file sha256
ToolConfirmationMessage.tsx f940c552a994f4aa24535e840b97960a55c7dd3288a4eadd4d747672c407eaa8
ToolConfirmationMessage.test.tsx f8d8b2472d15e46ab269752feeef68be7fe53e2e2e648ce0f808878b3e9bf20c

Unit matrix, cd packages/cli && npx vitest run src/ui/components/messages/ToolConfirmationMessage.test.tsx:

arm product file test file result
base (main 7a3b6cb6bd) main main 40 passed, exit 0
PR head b4fa9ccb6b head head 43 passed, exit 0
M1 (mutant) head main, i.e. the isEditorAvailable mock removed 1 failed | 39 passed, exit 1

M1 fails on exactly the test my Critical named, with exactly the assertion I reported:

× ToolConfirmationMessage > external editor option > should show "Modify with external editor" when preferredEditor is set
  → AssertionError: expected '\n ╭──────────────────────╮\n │      …' to contain 'Modify with external editor'

So the defect was real, the PR closes it, and the new mock is what closes it — removing it brings the failure straight back. This is not a false pass.

2. tmux E2E — real TUI, real build, A/B against main

I built the head tree (packages/cli compiled from the patched sources) and ran main's existing build as the control. Build witness, counted in the compiled dist/src/ui/components/messages/ToolConfirmationMessage.js:

  • head build: editorAvailable , isEditorAvailable
  • main build: editorAvailable

Each arm boots the real TUI in tmux (200×50) against a local stub OpenAI-compatible server that returns a write_file tool call, so a genuine edit confirmation renders. The single variable is whether code resolves on PATH.

arm build code on PATH options rendered "Modify with external editor"
E1 PR head no 3 absent
E2 PR head yes (planted stub code) 4 present
E3 main base no 4 present ❌ ← the reported defect

E3 is #10745 reproduced on main: the dialog offers an action that cannot be launched. E1 is the same environment at PR head, correctly hiding it. E2 proves the fix does not over-suppress — the option still appears when the editor really is available.

E1, as captured from the pane:

? WriteFile Writing to target.txt ←
  1 hello from the stub model
  Apply this change?
  › 1. Yes, allow once
    2. Yes, allow always
    3. No, suggest changes (esc)
⠏ Waiting for user confirmation...

E2 and E3 render the same dialog with 3. Modify with external editor inserted before the cancel row (4 options).

Harness notes, so the result can be judged: env -i with an isolated HOME and QWEN_RUNTIME_DIR (verified afterwards — nothing was written into the real ~/.qwen), a pre-trusted workspace, and tools.approvalMode: "default" set explicitly because the repo default is now ApprovalMode.AUTO, under which the classifier would auto-approve and the dialog would never render. The dialog was dismissed with Escape; target.txt was not written in any arm.

I also checked whether the memo's two extra short-circuit clauses change behavior. Neither does:

  • confirmationDetails.type === 'edit' — the option push already sits inside if (confirmationDetails.type === 'edit'), so a non-edit confirmation never reached it at base either.
  • !compactModerenderedOptions discards the options array entirely and builds a fixed set when compactMode is true, so the editor option was never rendered in compact mode at base either. The clause only avoids a wasted synchronous probe, which is what the new should NOT probe editor availability in compactMode test pins.

No Critical found. I am dismissing my stale CHANGES_REQUESTED (it was anchored at 62b17884d9) so it stops blocking a defect that no longer exists.

3. ⚠️ Not merge-ready yet: product CI has never executed on this head

I am deliberately not certifying this PR as mergeable, and the reason is not the code.

At b4fa9ccb6b these workflow runs are all completed / action_required, i.e. they were never allowed to start:

Qwen Code CI            :: action_required
Security Checks         :: action_required
Qwen Autofix            :: action_required
tui-parity              :: action_required
Comment Attachment Guard:: action_required (×2)
🧐 Qwen Pull Request Review :: action_required (×1)

The 12 check-runs that did complete are all triage/automation — 7 success, 5 skipped, 0 failure:

review-pr, precheck-pr / precheck, assign, label, authorize,
delay-automatic-review, Remind on force-push,
fallback-comment, publish-resolution, ack-review-request,
resolve-pr, review-config   (the last five skipped)

Because those are green, the commit's statusCheckRollup reads SUCCESS. That rollup is automation-only. There is no Test (ubuntu/windows/macos), no Lint & Static, no Integration Tests, and no web-shell E2E Smoke result at this head — the lanes that would actually exercise the changed component and its test file have not run at all. This is the same condition I noted on 2026-09-01, and it is still true three head-commits later.

For contrast, #10902 at the same moment shows Qwen Code CI :: success across 35 runs, so this is specific to this PR needing a maintainer to approve the fork's workflow run.

Action needed from a maintainer: approve the pending workflow run on this head. My local measurements above cover the component and its suite, but they are not a substitute for the product lanes — in particular nothing upstream has yet run the full packages/cli suite with this change, which is precisely the risk my original Critical was about. Once those lanes are green I have no objection to this PR.


撤回我 2026-09-01 的 Critical —— 已在 b4fa9ccb6b 验证修复;但本 head 上产品 CI 从未运行

1. 我的 Critical 已修复,且修复是有效的。 head 的测试文件加上了我要求的那个 mock(vi.hoisted + vi.mock('@qwen-code/qwen-code-core', importOriginal)),独立的 ToolConfirmationMessage.externalEditor.test.tsx 也已合并进现有 suite。两个改动文件与 GitHub 上 b4fa9ccb6b 的 blob 逐字节一致。单元测试矩阵:base(main)40 passed / exit 0;PR head 43 passed / exit 0;变异体 M1(head 产品代码 + 去掉 mock 的 main 测试文件)1 failed | 39 passed / exit 1,失败的正是我当初点名的那个测试,断言信息也与我当初报告的完全一致。所以缺陷真实存在、本 PR 确实修好了、而且就是这个 mock 在起作用。

2. tmux E2E(真实 TUI + 真实构建 + 与 main 做 A/B)。 编译产物见证:head 构建里 editorAvailable 出现 2 次,main 构建里 0 次。每个 arm 在 tmux(200×50) 里启动真实 TUI,由本地 stub OpenAI 服务返回一个 write_file tool call,从而渲染出真实的编辑确认框;唯一变量是 code 是否在 PATH 上。结果:E1(head,无 code)3 个选项、不含"Modify with external editor";E2(head,植入 code)4 个选项、包含;E3(main,无 code)4 个选项、包含 —— 这正是 #10745 在 main 上的复现。E3↔E1 证明修复生效,E2 证明没有过度抑制。另外我核对了 memo 新增的两个短路条件(type === 'edit'!compactMode):两者都不改变行为,选项 push 本就在 type === 'edit' 分支内,而 compactMode 为真时 renderedOptions 会整体丢弃 options 数组,所以 base 下紧凑模式本来也不会渲染该选项;这两个条件只是避免一次无用的同步探测。

3. ⚠️ 尚不具备合入条件:本 head 上产品 CI 从未执行。 我刻意把这个 PR 判定为可合入,原因不在代码。在 b4fa9ccb6b 上,Qwen Code CISecurity ChecksQwen Autofixtui-parityComment Attachment Guard 等 run 全部是 completed / action_required,即从未被允许启动。真正跑完的 12 个 check-run 全是 triage/自动化(7 success / 5 skipped / 0 failure),因此该 commit 的 statusCheckRollup 显示 SUCCESS —— 但这个 rollup 只覆盖自动化,本 head 上没有任何 TestLint & StaticIntegration Testsweb-shell E2E Smoke 结果。作为对照,同一时刻 #10902Qwen Code CI :: success(共 35 个 run),所以这是本 PR(fork)需要 maintainer 批准 workflow 运行的问题。

需要 maintainer 处理: 批准该 head 上待运行的 workflow。我上面的本地测量覆盖了该组件及其测试,但不能替代产品 lane —— 尤其上游至今没有在这个改动下跑过完整的 packages/cli suite,而这恰好就是我当初那个 Critical 所针对的风险。那些 lane 变绿之后,我对本 PR 没有异议。

我已 dismiss 自己那条陈旧的 CHANGES_REQUESTED(锚定在 62b17884d9),以免它继续阻塞一个已不存在的缺陷。

@qqqys
qqqys dismissed their stale review September 4, 2026 19:51

Withdrawing my 2026-09-01 Critical: it is verified FIXED at head b4fa9cc. The test file now mocks isEditorAvailable via vi.hoisted + vi.mock('@qwen-code/qwen-code-core', importOriginal), which is exactly the fix direction I asked for. Evidence: base 40 passed / head 43 passed / mutant M1 (head product code + the unmocked main test file) reproduces my original failure byte-for-byte (1 failed | 39 passed, 'expected ... to contain Modify with external editor'), so the mock is load-bearing. A tmux A/B on real builds also confirms the behaviour: main offers 'Modify with external editor' when code is absent from PATH (3 options vs 4), PR head hides it, and still shows it when a code stub is on PATH. No Critical found. Full measurements in issuecomment-5545711481. NOTE: I am NOT certifying mergeability - product CI (Qwen Code CI / Security Checks / tui-parity) is still action_required at this head and has never executed; the green statusCheckRollup is triage automation only.

@wenshao

wenshao commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Local verification at b4fa9ccb6b — real TUI A/B, mutation matrix, syscall-level probe

I built a full local verification environment for this PR and ran it end to end. My verdict: the fix is correct and complete, and I have no blocking objection. The one red lane on this head (Test (ubuntu-latest)) is a pre-existing main breakage, not this PR — details in §6.

This builds on the earlier round on this PR rather than repeating it; everything below is new evidence (9-arm TUI matrix instead of 3, product-file mutation matrix, a syscall count for the memoization claim, the IDE-diff clause, lint/typecheck, both full packages/cli suites, and CI triage).

Integrity of what I tested. Both changed files in my worktree are byte-identical to the GitHub blobs at b4fa9ccb6b:

file sha256
ToolConfirmationMessage.tsx f940c552a994f4aa24535e840b97960a55c7dd3288a4eadd4d747672c407eaa8
ToolConfirmationMessage.test.tsx f8d8b2472d15e46ab269752feeef68be7fe53e2e2e648ce0f808878b3e9bf20c

Two full bundles were built from source, PR head and merge-base 419e8d57b2. Build witness, read out of the emitted chunk that carries the option label:

PR head : ... !confirmationDetails.hideModify && (!config.getIdeMode() || !isDiffingEnabled) && editorAvailable) {
base    : ... !confirmationDetails.hideModify && (!config.getIdeMode() || !isDiffingEnabled) && preferredEditor) {

1. Real TUI A/B — 9 arms

Each arm boots the real bundled CLI under the repo's own integration-tests/terminal-capture harness (node-pty + xterm.js in headless Chromium) against a scripted local OpenAI-compatible server that returns a write_file tool call, so a genuine edit confirmation renders. Isolated HOME/QWEN_RUNTIME_DIR, tools.approvalMode: "default" set explicitly (the repo default is now AUTO, under which the dialog never renders), and a synthetic PATH whose only editor binaries are the ones each arm plants.

arm build preferredEditor editor on PATH SANDBOX option offered options
A1 base vscode no yes ❌ (#10745) 4
A2 head vscode no no 3
A3 head vscode yes yes 4
A4 head vscode yes set no 3
A5 base vscode yes set yes 4
A6 head sublime (invalid) no 3
A7 base sublime (invalid) yes 4
A8 head vim yes yes 4
A9 base vscode no yes, then selected 4

A1↔A2 is the bug and the fix. A3 and A8 prove the gate does not over-suppress — the option still appears whenever the editor is genuinely launchable, for a GUI editor and for a terminal editor.

fig1

fig2


2. Two behaviour changes the PR body does not mention (both improvements)

isEditorAvailable() is isValidEditorType() && checkHasEditorType() && allowEditorTypeInSandbox(), so swapping it in for a truthiness check also closes two cases beyond "editor uninstalled":

  • A6/A7 — an invalid preferredEditor value. "sublime" is not a supported EditorType; the old gate accepted any non-empty string and offered a permanently dead action. Head hides it.
  • A4/A5 — sandbox mode. With SANDBOX set, GUI editors cannot be launched, and allowEditorTypeInSandbox() says so. Head hides the option; base offers it even though code is on PATH. This matches what usePreferredEditor() already does for the Ctrl+X composer editor, so it is consistent with existing repo policy — but it is a behaviour change and deserves a line in the PR description.

fig3


3. What the offered option actually did on base (A9)

I did not just check that the row disappears — I selected it on the base build. The scheduler's ModifyWithEditor branch only early-returns when getPreferredEditor() is falsy (AppContainer.tsx:2313 hands it the raw setting), so a stale "vscode" sails through to modifyWithEditoropenDiffspawn:

x WriteFile {"file_path":".../ws/target.txt","content":"hello from the scripted model\n"}
  spawn code ENOENT

The pending edit is cancelled, not just "the editor didn't open" — the user loses the turn and has to ask again. That is the concrete harm this PR removes.

fig4


4. Mutation matrix — every new guard is load-bearing and pinned

cd packages/cli && npx vitest run src/ui/components/messages/ToolConfirmationMessage.test.tsx, PR tests kept, product file mutated:

mutant result test that flips RED
M0 control 43 passed
M1 product file reverted to base 2 failed should show … when preferredEditor is set + should NOT show … when the configured editor is unavailable
M2 drop !compactMode from the memo 1 failed should NOT probe editor availability in compactMode
M3 drop type === 'edit' from the memo 1 failed should NOT probe editor availability for a non-edit confirmation
M4 drop !hideModify from the memo 1 failed should NOT show … when hideModify is true
M5 defeat memoization (Math.random() in deps) 1 failed should show … when preferredEditor is set

Every clause the PR adds is killed by exactly one test, and no test is vacuous. M1 in particular is the RED/GREEN that had not been run before: reverting only the product file, keeping the new tests, flips exactly the two tests that describe the fix.

fig5


5. The memoization claim, measured at the syscall level

isEditorAvailable() ends in execSync("command -v code"), so I counted the actual subprocess spawns with strace -f -e trace=execve around a real confirmation dialog left on screen for 12 s:

arm /bin/sh -c "command -v code" spawns at boot at dialog time
base 1 1 0
PR head (memoized) 2 1 1
PR head, memo defeated 3 1 2

Three things follow, and I think they resolve the earlier performance concern in both directions:

  • The PR costs exactly one extra synchronous probe per edit-confirmation dialog. Measured cost of one probe on this box: ~1.2 ms.
  • The memo is real but modest: without it this scenario spends 2 probes instead of 1, not "one per Ink re-render" — Ink simply does not re-render this component that often. The PR body slightly oversells it.
  • For scale: every arm, base included, already spawns 11 of these probes at startup (code, codium, cursor, emacs, nvim, trae, vim, windsurf, zed, zeditor, plus rg). One more probe per dialog is well inside the noise of what boot already pays.

6. Static checks, full suites, and the red CI lane

  • npx eslint <both changed files> --max-warnings 0exit 0.
  • packages/cli tsc --noEmitexit 0.
  • IDE-diff behaviour is unchanged, verified directly rather than by reading: with getIdeMode() === true and isDiffingEnabled() === true the option is hidden on both arms; with diffing disabled it is shown on both arms.
  • Full packages/cli suite, both arms (28 588 tests). Head: 11 failures, of which 10 are identical on the base arm (environment: root-owned housekeeping tests, ink cursor rendering, serve) and the 11th (src/ui/opentui/dialogs-modes.test.tsx, an OpenTUI output-style dialog, unrelated to this change) passes 3/3 in isolation on both arms — a flake under a 4 480-suite parallel run.
  • The red Test (ubuntu-latest, Node 22.x) lane on this head is not this PR. It fails on src/acp-integration/acpAgent.test.ts > QwenAgent runtime-root pinning choke point, an architectural guard asserting that acpAgent.ts never names runWithAcpRuntimeOutputDir directly. Line 9200 does exactly that, and it was introduced by feat(serve): add session turn navigation protocol #10751 (8a0a9c6614, merged into main on 2026-09-04), which this branch inherited through its main merge. I reproduced the identical failure locally on the merge-base 419e8d57b2 with this PR's changes absent, and main's own CI run 33932327186 fails on the same test. Nothing to do here beyond waiting for the main fix.

7. Non-blocking observations

  1. The memo omits the IDE-diff clause. The render gate is !hideModify && (!getIdeMode() || !isDiffingEnabled) && editorAvailable, but the memo covers only !compactMode, type === 'edit', !hideModify, preferredEditor !== undefined. Measured: under ideMode + diffing the head arm still calls isEditorAvailable once (base: 0) for an option that can never be rendered. Cosmetic at ~1.2 ms, but it is the one remaining gap in the "only probe when it can matter" property that the new compactMode and non-edit tests otherwise pin.
  2. withProviders already exists for the rerender case. src/test-utils/render.tsx exports it with the comment "Use this to rebuild the exact same root structure for rerender() calls". The new memoization test hand-builds its own tree(height) with only SettingsContext + KeypressProvider, silently dropping ConfigContext and ShellFocusContext. It works today, but it diverges from the suite's shared harness for no gain.
  3. hideModify is evaluated twice — once into the hideModify local for the memo, once again as !confirmationDetails.hideModify in the render gate. M4 shows the memo copy is deliberate (probe avoidance), so this is fine; a one-line comment saying so would stop the next reader from "simplifying" it away.
  4. A second, parallel editor-resolution path. usePreferredEditor() already does validity + sandbox filtering and is what AppContainer, AgentComposer and TextInput use. This component keeps reading the raw setting and now adds its own availability call. Composing usePreferredEditor() with an availability check would leave one resolution path instead of two.
  5. The scheduler-side gate is still a truthiness check. AppContainer.tsx:2313 passes the raw setting into coreToolScheduler, whose ModifyWithEditor branch only bails on a falsy value — the exact path that produces the A9 spawn code ENOENT. After this PR the TUI no longer offers the only route that reaches it, so this is not a live bug; but the defence is now UI-only, and it would come back for any non-TUI client that emits ModifyWithEditor, or if the editor disappears between render and Enter. A one-line isEditorAvailable(raw) ? raw : undefined there would make it defence-in-depth. Out of scope for this PR.

Harness. Two full bundles built from source (head + merge-base); real bundled CLI driven under integration-tests/terminal-capture against a scripted local OpenAI-compatible server; isolated HOME/QWEN_RUNTIME_DIR per arm (verified afterwards — every per-arm artifact landed in the isolated home, and no file under the real ~/.qwen references any arm's workspace path); strace -f -e trace=execve for the probe counts. target.txt was never actually written in any arm. Screenshots are real terminal renders (xterm.js), hosted at wenshao/qwen-code@902897240d.


中文版本

b4fa9ccb6b 上的本地验证 —— 真实 TUI A/B、变异矩阵、系统调用级探测

我为这个 PR 搭了一套完整的本地验证环境并跑通了全流程。结论:修复正确且完整,我没有阻塞意见。 本 head 上唯一变红的 lane(Test (ubuntu-latest))是 main 上已有的问题,与本 PR 无关,详见 §6。

本轮是在此前那轮之上做增量,不重复既有结论;下面全部是新证据(9 个 TUI arm 而非 3 个、产品文件的变异矩阵、用系统调用数量核实 memo 的说法、IDE-diff 分支、lint/typecheck、两个 arm 的完整 packages/cli 套件,以及 CI 定性)。

被测对象的一致性。 我 worktree 里两个改动文件与 b4fa9ccb6b 上的 GitHub blob 逐字节一致:

文件 sha256
ToolConfirmationMessage.tsx f940c552a994f4aa24535e840b97960a55c7dd3288a4eadd4d747672c407eaa8
ToolConfirmationMessage.test.tsx f8d8b2472d15e46ab269752feeef68be7fe53e2e2e648ce0f808878b3e9bf20c

从源码构建了两份完整 bundle:PR head 与 merge-base 419e8d57b2。构建见证(取自承载该选项文案的 chunk):

PR head : ... !confirmationDetails.hideModify && (!config.getIdeMode() || !isDiffingEnabled) && editorAvailable) {
base    : ... !confirmationDetails.hideModify && (!config.getIdeMode() || !isDiffingEnabled) && preferredEditor) {

1. 真实 TUI A/B —— 9 个 arm

每个 arm 都用仓库自带的 integration-tests/terminal-capture(node-pty + 无头 Chromium 里的 xterm.js)启动真实打包后的 CLI,由本地脚本化 OpenAI 兼容服务返回一个 write_file tool call,从而渲染出真实的编辑确认框。每个 arm 使用隔离的 HOME/QWEN_RUNTIME_DIR,显式设置 tools.approvalMode: "default"(仓库默认已是 AUTO,那样确认框根本不会出现),并构造 PATH,其中唯一的编辑器可执行文件就是该 arm 自己植入的。

arm 构建 preferredEditor PATH 上有编辑器 SANDBOX 是否提供该选项 选项数
A1 base vscode ❌(#10745 4
A2 head vscode 3
A3 head vscode 4
A4 head vscode 已设 3
A5 base vscode 已设 4
A6 head sublime(非法) 3
A7 base sublime(非法) 4
A8 head vim 4
A9 base vscode ,并实际选中 4

A1↔A2 就是缺陷与修复本身。A3、A8 证明没有过度抑制 —— 只要编辑器真的可启动,选项依旧出现,GUI 编辑器和终端编辑器都是如此。

2. PR 描述未提及的两处行为变化(都是改进)

isEditorAvailable() 等于 isValidEditorType() && checkHasEditorType() && allowEditorTypeInSandbox(),因此用它替换真值判断,顺带还关掉了「编辑器被卸载」之外的两种情形:

  • A6/A7 —— 非法的 preferredEditor 取值。 "sublime" 不是受支持的 EditorType;旧的判断只要非空字符串就放行,于是长期提供一个永远无法执行的动作。head 会隐藏它。
  • A4/A5 —— sandbox 模式。 设置了 SANDBOX 时 GUI 编辑器无法启动,allowEditorTypeInSandbox() 已经这么规定。head 隐藏该选项;base 即使 codePATH 上也照样提供。这与 usePreferredEditor()Ctrl+X 编辑器的既有处理一致,因此符合仓库既有策略 —— 但确实是行为变化,值得在 PR 描述里补一句。

3. 在 base 上选中该选项究竟会发生什么(A9)

我不只是确认那一行消失了 —— 我在 base 构建上真的选中了它。scheduler 的 ModifyWithEditor 分支只在 getPreferredEditor()假值时提前返回(AppContainer.tsx:2313 传进去的是原始设置),所以一个失效的 "vscode" 会一路走到 modifyWithEditoropenDiffspawn

x WriteFile {"file_path":".../ws/target.txt","content":"hello from the scripted model\n"}
  spawn code ENOENT

待确认的编辑被取消了,而不仅仅是「编辑器没打开」—— 用户丢掉了这一轮,必须重新提问。这就是本 PR 消除的具体损害。

4. 变异矩阵 —— 新增的每个条件都有效且被单一测试钉住

cd packages/cli && npx vitest run src/ui/components/messages/ToolConfirmationMessage.test.tsx,保留 PR 的测试,只变异产品文件:

变异体 结果 变红的测试
M0 对照 43 passed
M1 产品文件回退到 base 2 failed should show … when preferredEditor is set + should NOT show … when the configured editor is unavailable
M2 去掉 memo 里的 !compactMode 1 failed should NOT probe editor availability in compactMode
M3 去掉 memo 里的 type === 'edit' 1 failed should NOT probe editor availability for a non-edit confirmation
M4 去掉 memo 里的 !hideModify 1 failed should NOT show … when hideModify is true
M5 令 memo 失效(deps 里加 Math.random() 1 failed should show … when preferredEditor is set

PR 新增的每个条件都恰好被一个测试杀死,没有空转的测试。其中 M1 正是此前一直没做的 RED/GREEN:只回退产品文件、保留新测试,恰好翻红描述该修复的那两个测试。

5. 用系统调用核实 memo 的说法

isEditorAvailable() 最终落到 execSync("command -v code"),因此我用 strace -f -e trace=execve 统计了真实确认框在屏幕上停留 12 秒期间的实际子进程数:

arm /bin/sh -c "command -v code" 次数 启动时 弹框期间
base 1 1 0
PR head(有 memo) 2 1 1
PR head(memo 失效) 3 1 2

由此得到三点,我认为它从正反两面都了结了此前的性能顾虑:

  • 本 PR 每个编辑确认框恰好多出一次同步探测。本机实测单次成本约 1.2 ms
  • memo 的作用真实但有限:没有它,本场景是 2 次而不是 1 次,并非「每次 Ink 重渲染一次」—— Ink 本来就不会那么频繁地重渲染这个组件。PR 描述这一句略有夸大。
  • 作为量级参照:所有 arm(含 base)在启动时本来就会做 11 次同类探测(codecodiumcursoremacsnvimtraevimwindsurfzedzeditor,外加 rg)。每个弹框多一次,完全落在启动本身已付出的噪声之内。

6. 静态检查、完整套件与变红的 CI lane

  • npx eslint <两个改动文件> --max-warnings 0exit 0
  • packages/cli tsc --noEmitexit 0
  • IDE-diff 行为未变,这是直接验证而非阅读得出的:getIdeMode() === trueisDiffingEnabled() === true 时,两个 arm 都隐藏该选项;关闭 diffing 后,两个 arm 都显示。
  • 两个 arm 的完整 packages/cli 套件(28 588 个测试)。head 侧 11 个失败,其中 10 个在 base arm 上完全相同(环境因素:以 root 运行的 housekeeping 测试、ink 光标渲染、serve),第 11 个(src/ui/opentui/dialogs-modes.test.tsx,OpenTUI 输出样式对话框,与本改动无关)在两个 arm 上单独跑 3/3 全绿 —— 是 4 480 个 suite 并行下的抖动。
  • 本 head 上变红的 Test (ubuntu-latest, Node 22.x) 不是本 PR 造成的。 它挂在 src/acp-integration/acpAgent.test.ts > QwenAgent runtime-root pinning choke point —— 一个断言 acpAgent.ts 不得直接引用 runWithAcpRuntimeOutputDir 的架构守卫。第 9200 行正是这么写的,而它由 feat(serve): add session turn navigation protocol #107518a0a9c6614,2026-09-04 合入 main)引入,本分支通过合并 main 继承了它。我在 merge-base 419e8d57b2(不含本 PR 改动)上本地复现了完全相同的失败,且 main 自身的 CI run 33932327186 也挂在同一个测试上。这里除了等 main 修好之外无需处理。

7. 非阻塞观察

  1. memo 没有包含 IDE-diff 条件。 渲染判断是 !hideModify && (!getIdeMode() || !isDiffingEnabled) && editorAvailable,而 memo 只覆盖了 !compactModetype === 'edit'!hideModifypreferredEditor !== undefined。实测:在 ideMode + diffing 下 head arm 仍会调用一次 isEditorAvailable(base 为 0),而该选项此时根本不可能被渲染。1.2 ms 级别只是观感问题,但它是「只在可能有用时才探测」这一性质仅存的缺口 —— 新增的 compactMode 与非 edit 测试恰恰是在钉这条性质。
  2. withProviders 本来就是为 rerender 场景准备的。 src/test-utils/render.tsx 导出了它,注释写着 "Use this to rebuild the exact same root structure for rerender() calls"。新增的 memo 测试自己手搭了 tree(height),只包了 SettingsContext + KeypressProvider,悄悄少了 ConfigContextShellFocusContext。目前能跑通,但无谓地偏离了套件共用的测试脚手架。
  3. hideModify 被判断了两次 —— 一次算进 memo 用的 hideModify 局部变量,一次又在渲染判断里写 !confirmationDetails.hideModify。M4 说明 memo 里那一份是刻意保留的(用于避免探测),所以没问题;加一行注释说明这点,可以避免下一个读者把它「简化」掉。
  4. 出现了第二条并行的编辑器解析路径。 usePreferredEditor() 已经在做合法性 + sandbox 过滤,AppContainerAgentComposerTextInput 用的都是它。而本组件依旧读原始设置,如今又自己加了一次可用性调用。用 usePreferredEditor() 组合一次可用性检查,就只剩一条解析路径。
  5. scheduler 那一侧仍然是真值判断。 AppContainer.tsx:2313 把原始设置传进 coreToolScheduler,其 ModifyWithEditor 分支只在假值时退出 —— 正是产生 A9 中 spawn code ENOENT 的那条路径。本 PR 之后 TUI 不再提供通往它的唯一入口,所以这不是活跃缺陷;但防线现在只在 UI 层,任何非 TUI 客户端发出 ModifyWithEditor、或编辑器在渲染与回车之间消失,问题都会回来。在那里加一行 isEditorAvailable(raw) ? raw : undefined 就能做成纵深防御。不属于本 PR 范围。

验证环境说明。 从源码构建了两份完整 bundle(head + merge-base);用 integration-tests/terminal-capture 驱动真实打包 CLI,对接本地脚本化 OpenAI 兼容服务;每个 arm 使用隔离的 HOME/QWEN_RUNTIME_DIR(事后已核对:每个 arm 的产物都落在隔离 home 内,真实 ~/.qwen 下没有任何文件引用 arm 的工作区路径);探测计数使用 strace -f -e trace=execve。所有 arm 中 target.txt 都没有被真正写入。截图是 xterm.js 的真实终端渲染,托管在 wenshao/qwen-code@902897240d


🤖 Generated with Claude Code — Claude Opus 5 (1M context)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/self-reported The linked issue was opened by the PR author (self-reported)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Edit confirmation offers "Modify with external editor" when the configured editor is unavailable

4 participants