feat(cli): respect /editor preference in Ctrl+X external editor#4310
feat(cli): respect /editor preference in Ctrl+X external editor#4310dreamWB wants to merge 21 commits into
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 |
| case 'emacs': | ||
| return { | ||
| command: executable, | ||
| args: [filePath], |
There was a problem hiding this comment.
This finding flags filePath flowing into a shell command. The filePath here is a process-generated temp path (os.tmpdir() + "qwen-edit-" + UUID + ".txt"), not arbitrary user input.
At the spawnSync call site in text-buffer.ts, args are wrapped in double quotes when shell: true is needed for .cmd/.bat files on Windows. This handles spaces and common separators in the temp path. Full cmd.exe metacharacter escaping is not warranted — if an attacker controls TEMP/TMP env vars, they already have broader process control.
commit: 9ced043
| case 'zed': { | ||
| return { | ||
| command: executable, | ||
| args: [filePath, '--wait'], |
There was a problem hiding this comment.
Same analysis as above — this is the GUI editor branch returning [filePath, "--wait"]. Both values are program-controlled: filePath is a temp path generated by this process, and "--wait" is a hardcoded constant. Double-quoting at the shell execution boundary is sufficient for this threat model.
commit: 9ced043
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.
The args passed to cmd.exe are program-controlled (tmpdir path + fixed flags), never arbitrary user input. cmd.exe does not expand $() or backticks inside double quotes. This matches Claude Code's approach.
- 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
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