feat(cli): respect /editor preference in Ctrl+X external editor#4310
Conversation
📋 Review SummaryThis PR implements editor preference respect for the 🔍 General Feedback
🎯 Specific Feedback🟡 High
🟢 Medium
🔵 Low
✅ Highlights
|
wenshao
left a comment
There was a problem hiding this comment.
[Suggestion] TextInput component does not propagate preferredEditor — Ctrl+X ignores /editor in secondary inputs
TextInput.tsx:79 creates its own useTextBuffer without calling usePreferredEditor(), yet handles OPEN_EXTERNAL_EDITOR (Ctrl+X) at line 155. This means Ctrl+X in 8+ consumers (AskUserQuestionDialog, PermissionsDialog, ManageModelsDialog, ApiKeyInput, SettingInputPrompt, etc.) silently ignores the user's /editor preference and falls back to $VISUAL/$EDITOR/default.
import { usePreferredEditor } from '../../hooks/usePreferredEditor.js';
const preferredEditor = usePreferredEditor();
const buffer = useTextBuffer({
initialText: value || '',
// ...existing props...
preferredEditor,
});— qwen-latest-series-invite-beta-v28 via Qwen Code /review
d78e65b to
1b98900
Compare
已修复。 commit: a793e9f |
eea141a to
9ced043
Compare
|
Re: @wenshao's review comment about |
wenshao
left a comment
There was a problem hiding this comment.
[Suggestion] Stale JSDoc on TextBuffer.openInExternalEditor
The JSDoc on the TextBuffer interface for openInExternalEditor describes two behaviors that this PR changed:
- "snapshot the previous state once before launching the editor" —
create_undo_snapshotwas moved to after the editor exits (afterreadFileSync, beforeset_text). - "preferred terminal text editor ($VISUAL or $EDITOR, falling back to 'vi')" — the primary resolution path is now
preferredEditorvia/editorsetting, with$VISUAL/$EDITORonly as fallback.
Recommend updating the JSDoc to match the new behavior.
— qwen-latest-series-invite-beta-v28 via Qwen Code /review
wenshao
left a comment
There was a problem hiding this comment.
[Suggestion] useLaunchEditor has divergent editor resolution
useLaunchEditor.ts (consumed by CreationSummary, AgentEditStep, MemoryDialog) has its own editor resolution that diverges from openInExternalEditor on four axes: (1) no isValidEditorType validation (line 44 uses as EditorType | undefined), (2) no --wait for GUI editors, (3) no timeout on spawnSync, (4) no signal handling. Consider refactoring to use getExternalEditorCommand() or at minimum sharing the usePreferredEditor hook.
— qwen-latest-series-invite-beta-v28 via Qwen Code /review
|
Re: review comment about This is already addressed in the current PR. import { usePreferredEditor } from '../../hooks/usePreferredEditor.js';
// ...
const preferredEditor = usePreferredEditor();
const buffer = useTextBuffer({
// ...existing props...
preferredEditor,
});So all consumers of |
|
Re: [Suggestion] Stale JSDoc on TextBuffer.openInExternalEditor Good catch — fixed in 930120e. Updated the JSDoc to reflect:
Re: [Suggestion] useLaunchEditor has divergent editor resolution
That said, sharing |
LaZzyMan
left a comment
There was a problem hiding this comment.
Review
Wires the /editor preference into the Ctrl+X external-editor flow with a clean factoring (getExternalEditorCommand + usePreferredEditor), and meaningfully hardens tempfile handling (private 0700 directory, 0o600 file mode, 30-minute spawn timeout, signal detection, conditional undo snapshot). Test coverage is thorough across the controlled command/args paths.
Two concerns worth tightening before this lands. First, in SANDBOX mode, usePreferredEditor still returns a configured GUI editor (vscode, cursor, zed, etc.) without checking sandbox availability — Ctrl+X then tries to launch a GUI binary the sandbox can't run, causing a fail or hang. Second, on Windows, the $VISUAL/$EDITOR fallback branch promotes user-controlled values to shell: true when the extension matches .cmd|.bat, and then wraps the editor command in naive double quotes without escaping embedded " — exploitability requires a malicious env var in the user's own shell config (so very low real-world risk), but a one-line check that the env value contains no " before opting into shell mode would close it. A small description nit: the PR brief says "tempfile simplified to flat .md", but the code uses mkdtempSync + buffer.txt — the actual implementation is the better design, just update the description.
Verdict
COMMENT — implementation is correct on the controlled plumbing and the security-critical paths; the sandbox gap and the Windows env-var fallback are worth tightening before this lands.
|
@LaZzyMan Thanks for the thorough review! All three points have been addressed in 0adb50d:
|
The Ctrl+X external editor prompt previously ignored the general.preferredEditor setting, always falling back to $VISUAL/$EDITOR env vars. Now it consults the preferred editor first, using the correct --wait flags for GUI editors, and falls back to env vars only when no preference is set or the preferred editor is unavailable. Closes QwenLM#4165
- Fix command injection risk: quote args when needsShell is true - Move writeFileSync inside try/finally with mode 0o600 - Change temp file extension from .md to .txt - Extend needsShell check to cover .bat extension - Fix import formatting in AgentComposer.tsx - Extract usePreferredEditor hook to deduplicate validation - Add 12 tests for openInExternalEditor covering all branches
…Session AppContainer.test.tsx mocks every hook that AppContainer.tsx imports, but the two new hooks (usePreferredEditor from this PR, useWorktreeSession from main's QwenLM#4174) were not mocked — causing the real hooks to execute during tests, crash on missing context, and fail all 47 downstream assertions.
…imeout - Detect .cmd/.bat in env-var fallback path on Windows and enable shell mode with quoted args, matching the preferred-editor path behavior - Add 30-minute timeout to spawnSync to prevent terminal freeze when a GUI editor hangs - Add test cases for both changes
TextInput creates its own useTextBuffer but was not passing preferredEditor, so Ctrl+X in secondary inputs (dialogs, settings prompts, etc.) silently ignored the /editor preference.
- Check spawnSync signal field to avoid reading stale temp file when editor is killed by SIGTERM/SIGKILL - Move undo snapshot creation after successful file read to prevent phantom no-op undo entries on editor failure
- Restore mkdtempSync isolation directory (was flattened to os.tmpdir) - Skip undo snapshot when editor content is unchanged - Update JSDoc to reflect deferred-snapshot behavior - Remove unused crypto import - Add tests: unchanged content skip, tmpDir cleanup, undo precision
Tests hardcoded forward-slash paths which fail on Windows where path.join produces backslashes. Use pathMod.join for the expected temp file path so assertions pass on all platforms.
…ging - Quote editorCmd along with args when shell: true, so Windows paths with spaces (e.g. C:\Program Files\...\code.cmd) survive cmd.exe. - Wrap setRawMode restore in try/catch so a destroyed stdin doesn't skip temp file cleanup. - Include command, shell mode, and resolution source in error log. - Add tests: CRLF normalization, readFileSync failure, editorCmd quoting.
The field was never consumed by any caller — only command, args, and needsShell are destructured. The standalone isTerminalEditor() function already serves the same purpose for openDiff.
Reflect the new editor resolution order (/editor → $VISUAL → $EDITOR → vi) and the moved undo-snapshot timing (after editor exit, not before).
…tInput stdin - Split unlinkSync/rmdirSync into separate try/catch blocks to prevent temp directory leak when unlinkSync throws (regression from main) - Move mkdtempSync inside try block with early return on failure - Pass stdin/setRawMode from TextInput to useTextBuffer so terminal editors (vim/neovim/emacs) correctly toggle raw mode via Ctrl+X
…editor - usePreferredEditor now checks allowEditorTypeInSandbox() and returns undefined for GUI editors when SANDBOX env is set - env/default editor fallback rejects commands containing " or | before enabling shell mode on Windows
…t coverage - Add unsafe-character rejection for opts.editor .cmd paths on Windows - Change env-var unsafe-char handling from throw to graceful return + cleanup - Add debug logging before spawnSync and in setRawMode catch block - Add tests for opts.editor path, .cmd shell mode, and unsafe-char rejection
5619cf9 to
468acc2
Compare
wenshao
left a comment
There was a problem hiding this comment.
Also: the finally block cleanup (line ~2349) uses separate unlinkSync + rmdirSync. When a terminal editor (vim/neovim) is killed by the 30-min timeout or signal, swap files (.buffer.txt.swp) are left behind and rmdirSync silently fails on the non-empty directory, leaking the temp dir. Consider replacing both cleanup steps with fs.rmSync(tmpDir, { recursive: true, force: true }) — consistent with the pattern used elsewhere in the codebase (gemini-converter.ts, claude-converter.ts). — qwen-latest-series-invite-beta-v34 via Qwen Code /review
- Expand Windows unsafe-character regex to include % and ! (cmd.exe variable expansion and delayed expansion) - Remove stale "no hooks needed" comment in TextInput.tsx - Add setRawMode lifecycle test (disable before editor, restore after) - Add default fallback tests for vi (linux) and notepad (win32)
…lback The `[boolean]` tuple annotation conflicts with vitest's `any[][]` mock.calls type, causing TS2345 in CI.
… cleanup Leftover swap files from vim/neovim would cause rmdirSync to silently fail on non-empty directories, leaking temp dirs. Use rmSync with recursive+force to handle this. Also fix stale JSDoc fallback comment.
|
Good catch — fixed in 1e2ae54. Replaced all three Also added a comment in the Tests updated accordingly: removed dead |
- Expand opts.editor and env-var unsafe-char tests to cover %, !, and " independently via it.each, preventing silent regex regressions - Add error-path test verifying setRawMode restore when editor exits with non-zero status
| setRawMode?.(false); | ||
| const { status, error } = spawnSync(editor, [filePath], { | ||
|
|
||
| debugLogger.warn( |
There was a problem hiding this comment.
[Suggestion] debugLogger.warn used for normal operation — should be debugLogger.info
The "launching external editor" message describes a normal, expected code path, not a degraded or anomalous condition. The convention in this file reserves warn for fallback/degraded paths:
debugLogger.info— normal success (line 104: Segmenter init)debugLogger.warn— degraded/fallback (line 106: Segmenter failure, line 270: word boundary fallback, line 2269: preferred editor not found)
Using warn here creates noise in debug logs and may trigger false alerting in log-monitoring setups — every successful Ctrl+X press emits a [WARN] line.
| debugLogger.warn( | |
| debugLogger.info( | |
| `[useTextBuffer] launching external editor (cmd=${editorCmd}, shell=${useShell}, source=${editorSource}, file=${filePath})`, | |
| ); |
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
| } | ||
| }); | ||
|
|
||
| it('should use opts.editor when provided', async () => { |
There was a problem hiding this comment.
[Suggestion] No test proves opts.editor overrides preferredEditor
The priority chain opts.editor > preferredEditor > env/default (implemented at text-buffer.ts:2238) is the core design contract of this method, but no test exercises the case where both opts.editor and preferredEditor are present. If someone accidentally swaps the if/else order, no test would catch it.
Consider adding a test like:
it('opts.editor takes priority over preferredEditor', async () => {
mockGetExternalEditorCommand.mockReturnValue({
command: 'code', args: [expectedTmpFile, '--wait'], needsShell: false,
});
const { result } = renderHook(() =>
useTextBuffer({
initialText: 'hello', viewport, isValidPath: () => false,
preferredEditor: 'vscode',
}),
);
await act(async () => {
await result.current.openInExternalEditor({ editor: 'nano' });
});
expect(mockGetExternalEditorCommand).not.toHaveBeenCalled();
expect(mockSpawnSync).toHaveBeenCalledWith('nano', ...);
});— qwen-latest-series-invite-beta-v34 via Qwen Code /review
| } | ||
| }); | ||
|
|
||
| it.each([ |
There was a problem hiding this comment.
[Suggestion] Pipe | not independently tested in unsafe-char parametrized tests
The regex /["|%!]/ matches 4 characters, but the it.each covers only 3: ", %, !. The " test string (evil"|cmd.cmd) contains |, but it's masked by " — if the regex regressed to /["%!]/, that test would still pass on ".
Add a dedicated entry for | to both it.each arrays (opts.editor and env-var):
{ char: '|', editor: 'pipe|cmd.cmd' },Pipe is the highest-risk character in the set (command chaining in cmd.exe), so independent coverage matters.
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
yiliang114
left a comment
There was a problem hiding this comment.
LGTM. All critical issues from previous rounds are addressed — injection guard, signal handling, undo timing, temp cleanup. Remaining suggestions are non-blocking follow-ups.
pomelo-nwu
left a comment
There was a problem hiding this comment.
Read through @wenshao's multi-round inline review (v28/v34) and @LaZzyMan's earlier pass — those have already covered the windows command-injection regex, opts.editor symmetry, missing test branches, log-level nits, and so on. I'm not going to re-litigate any of those. Five additional concerns remain that I don't see covered anywhere yet:
None of these are individually blocking — every one can resolve as "fix here", "won't fix", or "follow-up issue". I just don't want them to disappear silently.
1. Non-interactive TextInput regression
TextInput.tsx switched from caller-supplied stdin / setRawMode props to an internal useStdin() call. TextInput is a generic component reused outside the Ctrl+X path. Was this verified against:
ink-testing-libraryrendered tests- Non-TTY contexts (CI, headless runs)
- ACP / SDK / DataWorks-embedded paths, where there is no real terminal
The previous prop-driven shape insulated callers from useStdin()'s behavior in non-interactive contexts. Now that's gone, and a regression in any of those paths would surface only in downstream products.
2. Privacy footprint of writing the prompt to disk
buffer.txt now contains the full prompt on disk under /tmp/qwen-edit-*/, with 0o600 (good). For enterprise users the prompt can contain API keys, internal code, or unreleased information.
- Should we call this out in user docs, especially for
/sandboxand enterprise deployments? - Should there be a setting to disable Ctrl+X entirely in high-sensitivity environments?
A follow-up issue is fine.
3. The 30-minute timeout is a magic number
timeout: 30 * 60 * 1000 — what's the reasoning? A user editing a long prompt for over 30 minutes loses everything, and the only signal back is spawnSync returning a non-zero status with no UX indication of why. A heavy vim setup with careful editing can plausibly cross 30 minutes.
Pick one:
- Make it configurable.
- Default to 2 hours.
- At minimum, surface a clear message on timeout-kill instead of folding it into the generic "external editor exited with status" path.
4. Sandbox silent fallback re-creates the original #4165 UX
@wenshao already flagged silent fallback at text-buffer.ts:2241 for the binary-not-found case. There's a sibling case worth distinguishing: in sandbox mode, usePreferredEditor correctly returns undefined when the preferred editor is GUI-only — the binary may very well be installed on the host, but sandbox can't reach it. We then fall back to $VISUAL / $EDITOR / vi.
From the user's point of view this looks identical to the original #4165 bug: "I configured vscode, Ctrl+X still opens vi." The cause is legitimate this time, but the user has no way to know that.
A one-line transient hint on first use would close the loop:
Your preferred editor
vscodeis not available in sandbox mode; using vi instead. Run/editorto change.
This is the kind of detail that separates "feels reliable" from "feels mysterious".
5. Windows CI matrix coverage
The new code adds Windows-specific branches: .cmd/.bat → shell: true, double-quote wrapping, env-var character sanitization. The tests use Object.defineProperty(process, 'platform', { value: 'win32' }) to fake the platform.
Is CI actually running these on a Windows runner, or only on Linux with a mocked process.platform? If the latter, please flag it in the PR description so reviewers know the Windows path has not been validated on a real machine.
Once each of the five has a clear yes / no / follow-up answer, I'm happy to approve. Thanks for the careful work — and for absorbing the long inline review chain. The four bonus hardening fixes (perms, signal detection, conditional undo snapshot, recursive cleanup) are exactly the kind of "saw it, couldn't unsee it" cleanups that compound into a polished product.
中文说明
通读了 @wenshao 多轮 inline review(v28/v34)以及 @LaZzyMan 那一轮,windows 命令注入正则、opts.editor 对称性、测试覆盖、log level 等点已经覆盖,不再赘述。下面 5 点我没看到任何一处提过,希望合并前都有一个明确回应(做 / 不做 / 单开 issue follow up 都可以,不要求全部在这个 PR 里完成),避免静悄悄合进去后埋下隐性坑。
1. 非交互场景下 TextInput 是否回归
TextInput.tsx 从 caller 透传 stdin / setRawMode 改成内部 useStdin()。TextInput 是通用组件,不止 Ctrl+X 路径在用。是否在以下场景做过回归:
ink-testing-library渲染测试- 非 TTY 场景(CI、headless)
- ACP / SDK / 嵌入 DataWorks 等无真实终端的路径
旧路径靠 prop 透传规避了 useStdin() 在非交互场景的不确定性,现在这层隔离没了——这些路径出回归只会在下游产品里浮出来。
2. prompt 全文落盘的隐私面
buffer.txt 现在把完整 prompt 写到磁盘(/tmp/qwen-edit-*/,已加 0o600,OK)。企业用户的 prompt 可能含 API key、内部代码、未发布信息:
- 是否在用户文档里说明(尤其
/sandbox与企业部署语境)? - 是否需要一个 setting 让高敏环境完全关掉 Ctrl+X?
单开一个 follow-up issue 也可以。
3. 30 分钟硬超时是个魔法数字
timeout: 30 * 60 * 1000 缺少 reasoning。用户编辑长 prompt 超过 30 分钟会被强杀、所有内容丢失,且只能从 spawnSync 返回 status≠0 推测,UX 完全不友好。Vim + 重插件 + 仔细编辑跨 30 分钟并不夸张。
三选一:
- 改为可配置
- 默认调到 2 小时
- 至少在被超时杀掉时给出明确提示,而不是吞进 "external editor exited with status"
4. Sandbox 静默回退重现 #4165 抱怨的体验
@wenshao 已经在 text-buffer.ts:2241 指出过"二进制找不到时静默回退"的问题——这里我想补一个兄弟切片:sandbox 模式下,usePreferredEditor 正确地对 GUI editor 返回 undefined(二进制可能就在 host 上、但 sandbox 够不到),然后回退到 $VISUAL / $EDITOR / vi。
用户视角看到的就是 #4165 抱怨的那个 bug:"我设置了 vscode,Ctrl+X 还是开 vi"——只不过这次有合理原因,但用户无从知晓。
建议在 sandbox 下首次按 Ctrl+X 时给一行提示:
Your preferred editor
vscodeis not available in sandbox mode; using vi instead. Run/editorto change.
这是让产品"感觉可靠"而非"感觉神秘"的关键细节。
5. Windows CI matrix 覆盖
新代码引入了 win32 分支(.cmd/.bat 走 shell、双引号包裹、环境变量字符过滤)。测试用 Object.defineProperty(process, 'platform', { value: 'win32' }) 来 mock 平台。CI 是否真的在 Windows runner 上跑?还是只在 Linux + mock 平台?
如果是后者,请在 PR description 里标注"Windows 路径仅 mock 测试通过、未在真机验证",让 reviewer 心里有数。
5 点都有明确回应(不一定都改)后,我就 approve。也再次感谢——尤其是那 4 个顺手补的 hardening fix(权限、信号检测、条件 undo 快照、recursive 清理),是那种"看到就不能不修"的细节,正是这些细节累积成产品的打磨感。
LGTM. All critical issues from previous rounds are addressed — injection guard, signal handling, undo timing, temp cleanup. Remaining suggestions are non-blocking follow-ups.
| ); | ||
| } | ||
| editorCmd = | ||
| process.env['VISUAL'] ?? |
There was a problem hiding this comment.
[Suggestion] Sandbox-aware /editor policy is silently bypassed by this env-var fallback
usePreferredEditor correctly returns undefined for GUI editors under sandbox (via allowEditorTypeInSandbox). But here, an undefined preferredEditor makes resolved null and execution falls through to $VISUAL / $EDITOR / vi|notepad with no sandbox re-check. The sandbox layer (packages/cli/src/utils/sandbox.ts:294) inherits the host process.env wholesale — never strips VISUAL/EDITOR. So a user with /editor zed configured and VISUAL=/Applications/Zed.app/Contents/MacOS/cli set in their shell, running under sandbox-exec: the hook politely refuses to honor /editor zed, then this fallback launches the same Zed binary anyway. The layered defense (/editor allowlist + sandbox check) is hollow whenever the host shell has $VISUAL/$EDITOR set.
Fix: plumb a "blocked by sandbox" signal from usePreferredEditor into openInExternalEditor (e.g., a sentinel like 'blocked' distinct from undefined) and refuse the env-var fallback in that case; or validate the env-var basename against isValidEditorType + allowEditorTypeInSandbox and reject GUI editors when process.env.SANDBOX is set.
— claude-opus-4-7 via Claude Code /qreview
|
|
||
| const wasRaw = stdin?.isRaw ?? false; | ||
| try { | ||
| fs.writeFileSync(filePath, text, { encoding: 'utf8', mode: 0o600 }); |
There was a problem hiding this comment.
[Suggestion] setRawMode refcount leak when writeFileSync throws — corrupts terminal at exit
Pre-PR, writeFileSync lived outside the try, so a throw bypassed both setRawMode(false) and setRawMode(true) — terminal state preserved. The new code moves writeFileSync inside try (good — needed to keep tmpDir cleanup paired). But the ordering is now:
const wasRaw = stdin?.isRaw ?? false;
try {
fs.writeFileSync(filePath, text, ...); // may throw
setRawMode?.(false); // skipped if writeFileSync threw
...
} catch (err) { ... }
finally {
try { if (wasRaw) setRawMode?.(true); } ... // ALWAYS runs
...
}If writeFileSync throws (disk full, EACCES on /tmp, ENOSPC), (false) is skipped but (true) still runs. Ink's raw mode is refcounted — each (true) increments, (false) decrements, and the terminal stays raw while count > 0. One failure → +1 leaked. On app exit, KeypressContext's cleanup decrements by 1 but the count stays > 0, so Ink never disables raw mode — the user's shell prompt is broken until they run stty sane / reset.
The existing test should clean up temp file when writeFileSync throws (line 133) doesn't pass stdin / setRawMode mocks, so this regression is undetected.
Fix: move setRawMode?.(false) before writeFileSync, or gate the restore on a rawDisabled flag set right after the disable call:
let rawDisabled = false;
try {
fs.writeFileSync(filePath, text, { encoding: 'utf8', mode: 0o600 });
setRawMode?.(false);
rawDisabled = true;
// ... spawnSync, readFileSync ...
} catch (err) { ... }
finally {
try { if (rawDisabled && wasRaw) setRawMode?.(true); } catch ...
...
}Also add a regression test: stdin: { isRaw: true } + setRawMode mock + writeFileSync forced to throw, assert setRawMode(false) / setRawMode(true) call counts are balanced.
— claude-opus-4-7 via Claude Code /qreview
| if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(editorCmd)) { | ||
| if (/["|%!]/.test(editorCmd)) { | ||
| debugLogger.error( | ||
| `[useTextBuffer] opts.editor command contains unsafe characters: ${editorCmd}`, |
There was a problem hiding this comment.
[Suggestion] Duplicated Windows .cmd/.bat unsafe-character guard across the two branches
A ~14-line block — platform check + /\.(cmd|bat)$/i regex + /["|%!]/ unsafe-char check + fs.rmSync({ recursive, force }) cleanup + return + useShell = true — appears nearly verbatim here (opts.editor arm, lines 2243-2256) and in the env-var arm (lines 2278-2291). The two blocks differ only in the error-message phrase ("opts.editor command contains" vs "Editor command from environment contains").
This is real lockstep coupling, not speculative DRY — the PR history shows multiple regex updates (/["|]/ → /["|%!]/, unlinkSync/rmdirSync → fs.rmSync) had to touch both arms in sync.
Fix: hoist the guard out. The preferred path already returns needsShell from getExternalEditorCommand. After the if/else sets editorCmd/editorArgs/editorSource, run a single guard for the non-preferred cases:
if (editorSource !== 'preferred' &&
process.platform === 'win32' &&
/\.(cmd|bat)$/i.test(editorCmd)) {
if (/["|%!]/.test(editorCmd)) {
debugLogger.error(
`[useTextBuffer] ${editorSource} editor command contains unsafe characters: ${editorCmd}`,
);
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
return;
}
useShell = true;
}— claude-opus-4-7 via Claude Code /qreview
| let editorSource: 'opts' | 'preferred' | 'env/default' = 'env/default'; | ||
|
|
||
| if (opts.editor) { | ||
| // Explicit programmatic override takes highest priority |
There was a problem hiding this comment.
[Suggestion] opts.editor and preferredEditor branches have asymmetric semantics — API trapdoor
preferredEditor flows through getExternalEditorCommand, which (a) resolves 'vscode' → 'code' / 'code.cmd' via getEditorExecutable, (b) appends --wait for GUI editors, (c) derives needsShell. The opts.editor branch does none of this — editorArgs = [filePath], editorCmd = opts.editor literal, no PATH resolution, no --wait, useShell conditional only on .cmd/.bat suffix of the raw string.
A caller doing openInExternalEditor({ editor: 'vscode' }) (a natural-looking call given the type { editor?: string }) would spawn the literal 'vscode' — not a binary on any platform — yielding ENOENT. If they pass 'code', the GUI editor would fork-detach instantly because --wait is missing, and the function would read back the unchanged temp file and silently no-op. The test at line 647 hard-codes this asymmetry by using 'nano' (a raw binary, not an EditorType), so a future refactor trying to unify the two branches would see the test fail and back off.
Fix: either (a) route opts.editor through getExternalEditorCommand when isValidEditorType(opts.editor), falling through to the raw-string spawn only for non-EditorType strings; or (b) narrow the type to opts: { editor?: string /* raw binary path, NOT EditorType */ } and add a comment here explaining the asymmetry, so a future "fix" doesn't regress the test at line 647.
— claude-opus-4-7 via Claude Code /qreview
| * editor ($VISUAL or $EDITOR, falling back to "vi"). The method blocks | ||
| * until the editor exits, then reloads the file and replaces the in‑memory | ||
| * buffer with whatever the user saved. | ||
| * Opens the current buffer contents in an external editor. Resolution |
There was a problem hiding this comment.
[Suggestion] JSDoc omits opts.editor from the resolution order
This JSDoc lists the resolution order as /editor preference → $VISUAL → $EDITOR → platform default. But the implementation at line 2238 places opts.editor at highest priority ("Explicit programmatic override takes highest priority"). The function's only documented parameter (opts?: { editor?: string } at line 2707) is unmentioned in the doc.
A reader consulting only the JSDoc would not know that the override exists or that it bypasses the entire /editor / sandbox-gate machinery. Combined with the asymmetric semantics flagged at line 2239, this misleads anyone trying to call the function programmatically.
| * Opens the current buffer contents in an external editor. Resolution | |
| * Opens the current buffer contents in an external editor. Resolution | |
| * order: `opts.editor` (programmatic override) → `/editor` preference → `$VISUAL` → `$EDITOR` → platform default (`vi` on Unix, `notepad` on Windows). |
— claude-opus-4-7 via Claude Code /qreview
| } | ||
| }); | ||
|
|
||
| it('should detect .cmd/.bat in env-var fallback and enable shell mode on Windows', async () => { |
There was a problem hiding this comment.
[Suggestion] .bat extension never actually tested on the CLI side, only .cmd
The test name claims .cmd/.bat coverage, but the test input is only code.cmd. No test in this file ever passes a .bat editor — the opts.editor shell-mode test at line ~668, the env-var unsafe-char it.each at line 695, and the opts.editor unsafe-char it.each at line ~852 all use exclusively .cmd strings.
The two /\.(cmd|bat)$/i regex sites in text-buffer.ts (lines 2243 opts.editor arm and 2278 env-var arm) have zero .bat coverage on the CLI side. The matching editor.ts:171 regex IS guarded by editor.test.ts:893, but that only exercises the preferredEditor path through getExternalEditorCommand; the CLI-side env-var and opts.editor branches remain unguarded.
Impact: a typo like /\.cmd$/i or a regex consolidation that drops bat would silently pass the entire test suite while breaking Windows .bat editor handling.
Fix: convert the existing .cmd tests (env-var line 350, opts.editor line ~668) into it.each([{ ext: 'cmd' }, { ext: 'bat' }]) parametrised tests, or add two dedicated .bat smoke tests covering both arms. Bonus: add a .bat case to the unsafe-char it.each at lines 695 and ~852 (e.g., expand%PATH%.bat).
— claude-opus-4-7 via Claude Code /qreview
| expect(falseIdx).toBeLessThan(trueIdx); | ||
| }); | ||
|
|
||
| it('should restore raw mode even when editor fails', async () => { |
There was a problem hiding this comment.
[Suggestion] Missing test for setRawMode(true) throw path inside the finally block
The inner try/catch wrapping setRawMode?.(true) at text-buffer.ts:2335-2342 was added specifically so a stdin-destroyed scenario (remote SSH session dies while editor is open, terminal multiplexer detached, etc.) doesn't prevent the subsequent fs.rmSync cleanup from running.
Both existing raw-mode tests (should disable and restore raw mode around editor session at line 730, this test at line 755) use mockSetRawMode = vi.fn() which never throws. The spawnSync-failure variant only verifies that setRawMode(true) is called, not that rmSync still runs when setRawMode(true) itself throws. A regression removing the inner try/catch (or moving rmSync above it) would silently leak temp directories AND produce an unhandled rejection from the finally — every existing test would still pass.
Fix: add a test that throws from the second setRawMode call and asserts fs.rmSync was still invoked and the promise resolves:
it('should still clean up tmpDir when setRawMode(true) throws', async () => {
mockSetRawMode
.mockImplementationOnce(() => {}) // setRawMode(false) succeeds
.mockImplementationOnce(() => { // setRawMode(true) throws
throw new Error('ERR_STREAM_DESTROYED');
});
// ... run editor ...
expect(fs.rmSync).toHaveBeenCalledWith(
expect.stringContaining('qwen-edit-'),
expect.objectContaining({ recursive: true, force: true }),
);
// and the promise resolved — no unhandled rejection
});— claude-opus-4-7 via Claude Code /qreview
| expect(result.current.text).toBe('original'); | ||
| }); | ||
|
|
||
| it('should skip undo snapshot when content is unchanged', async () => { |
There was a problem hiding this comment.
[Suggestion] should skip undo snapshot when content is unchanged cannot detect the regression it claims to guard
The test asserts text === 'unchanged' before and after undo(). But if the if (newText !== text) guard at text-buffer.ts:2325 were removed and a phantom undo snapshot of identical 'unchanged' text were created, undo() would still revert to that identical snapshot — leaving text === 'unchanged'. The test passes either way.
The whole point of the guard is to keep undo() from cycling through no-op entries (a Ctrl+Z that appears to "do nothing" — an irreversible UX regression). The current assertion structure cannot distinguish "no snapshot was taken" from "a snapshot of identical content was taken".
Fix: insert text before the editor session to establish a baseline undo entry, then run the editor with unchanged content, then call undo() and assert it reverts past the editor session directly to the pre-insert state:
it('should skip undo snapshot when content is unchanged', async () => {
(fs.readFileSync as Mock).mockReturnValue('unchanged');
const { result } = renderHook(() =>
useTextBuffer({ initialText: 'unchanged', viewport, isValidPath: () => false }),
);
// Establish a baseline undo entry before the editor session
act(() => { result.current.insert('X'); });
expect(result.current.text).toBe('Xunchanged');
await act(async () => { await result.current.openInExternalEditor(); });
expect(result.current.text).toBe('Xunchanged'); // unchanged after editor
// undo() should pop the 'X' insertion, NOT a phantom snapshot
act(() => { result.current.undo(); });
expect(result.current.text).toBe('unchanged');
});Or alternatively, assert dispatch was never called with { type: 'create_undo_snapshot' } after spawnSync returned.
— claude-opus-4-7 via Claude Code /qreview
Summary
Closes #4165
Ctrl+Xin the prompt input now opens the external editor configured via/editor(thegeneral.preferredEditorsetting) instead of always falling back to$VISUAL/$EDITOR/ platform default.Changes
packages/core/src/utils/editor.ts— AddedgetExternalEditorCommand()returning{ command, args, needsShell }for anyEditorType. GUI editors (vscode,vscodium,windsurf,cursor,trae,zed) get--wait; terminal editors (vim,neovim,emacs) get raw[filePath]. Also exportedisValidEditorType()type guard.packages/core/src/utils/editor.test.ts— Unit tests forgetExternalEditorCommand.packages/cli/src/ui/hooks/usePreferredEditor.ts— New hook extracting/editorpreference withisValidEditorTypevalidation and sandbox availability check.packages/cli/src/ui/components/shared/text-buffer.ts—openInExternalEditorreadspreferredEditorprop, resolves viagetExternalEditorCommand(), falls back to$VISUAL/$EDITOR/platform default when unset or unavailable. Temp file usesmkdtempSyncfor isolation +buffer.txtwith0o600permissions. Includes signal detection, 30-minute timeout, and conditional undo snapshot (only after successful edit with changed content).packages/cli/src/ui/components/shared/TextInput.tsx— PassespreferredEditor,stdin, andsetRawModetouseTextBufferfor terminal editor support.packages/cli/src/ui/AppContainer.tsx/AgentComposer.tsx— UseusePreferredEditor()hook (no duplicated validation logic).Evidence
Demo Case 1 — Default editor (no
/editorconfigured)Press
Ctrl+Xwithout configuring/editor. The default editor opens:vion Unix/macOS,notepadon Windows (matching$VISUAL→$EDITOR→ platform default fallback chain). Edit, save, and close — content appears in the prompt input.case.1.mp4
Demo Case 2 — Preferred editor (VS Code via
/editor)Run
/editorand selectvscode. PressCtrl+X— VS Code opens with--waitflag. Edit, save, and close the VS Code tab — content appears in the prompt input.case.2.mp4
Linked Issues
Closes #4165
Test Plan
getExternalEditorCommandtext-buffer-external-editor.test.ts--waitand returns content