Skip to content

feat(cli): respect /editor preference in Ctrl+X external editor#4310

Merged
pomelo-nwu merged 21 commits into
QwenLM:mainfrom
dreamWB:worktree-feat-editor-pref-prompt-4165
May 21, 2026
Merged

feat(cli): respect /editor preference in Ctrl+X external editor#4310
pomelo-nwu merged 21 commits into
QwenLM:mainfrom
dreamWB:worktree-feat-editor-pref-prompt-4165

Conversation

@dreamWB

@dreamWB dreamWB commented May 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes #4165

Ctrl+X in the prompt input now opens the external editor configured via /editor (the general.preferredEditor setting) instead of always falling back to $VISUAL / $EDITOR / platform default.

Changes

  • packages/core/src/utils/editor.ts — Added getExternalEditorCommand() returning { command, args, needsShell } for any EditorType. GUI editors (vscode, vscodium, windsurf, cursor, trae, zed) get --wait; terminal editors (vim, neovim, emacs) get raw [filePath]. Also exported isValidEditorType() type guard.
  • packages/core/src/utils/editor.test.ts — Unit tests for getExternalEditorCommand.
  • packages/cli/src/ui/hooks/usePreferredEditor.ts — New hook extracting /editor preference with isValidEditorType validation and sandbox availability check.
  • packages/cli/src/ui/components/shared/text-buffer.tsopenInExternalEditor reads preferredEditor prop, resolves via getExternalEditorCommand(), falls back to $VISUAL/$EDITOR/platform default when unset or unavailable. Temp file uses mkdtempSync for isolation + buffer.txt with 0o600 permissions. 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 — Passes preferredEditor, stdin, and setRawMode to useTextBuffer for terminal editor support.
  • packages/cli/src/ui/AppContainer.tsx / AgentComposer.tsx — Use usePreferredEditor() hook (no duplicated validation logic).

Evidence

Demo Case 1 — Default editor (no /editor configured)

Press Ctrl+X without configuring /editor. The default editor opens: vi on Unix/macOS, notepad on 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 /editor and select vscode. Press Ctrl+X — VS Code opens with --wait flag. Edit, save, and close the VS Code tab — content appears in the prompt input.

case.2.mp4

Linked Issues

Closes #4165

Test Plan

  • Unit tests for getExternalEditorCommand
  • 24 external editor tests in text-buffer-external-editor.test.ts
  • Full test suite passes
  • Manual test: default editor (vi) opens and returns content
  • Manual test: VS Code opens with --wait and returns content

@github-actions

Copy link
Copy Markdown
Contributor

📋 Review Summary

This PR implements editor preference respect for the Ctrl+X external editor feature, allowing users to configure their preferred editor via /editor command instead of relying solely on environment variables. The implementation is well-structured with good test coverage and proper fallback behavior. Overall, this is a solid enhancement that improves user experience while maintaining backward compatibility.

🔍 General Feedback

  • Good separation of concerns: Core editor logic lives in packages/core/src/utils/editor.ts, while UI integration is in packages/cli/src/ui/components/
  • Comprehensive test coverage: 14 new test cases covering isTerminalEditor and getExternalEditorCommand functions
  • Proper fallback chain: Maintains the $VISUAL$EDITOR → platform default fallback when no preferred editor is configured
  • Type safety: Uses isValidEditorType() type guard consistently to avoid unsafe casts
  • Cross-platform awareness: Handles Windows-specific cases (.cmd files, shell requirements)

🎯 Specific Feedback

🟡 High

  • packages/cli/src/ui/components/agent-view/AgentComposer.tsx:27 - Import statement has incorrect formatting. The import line shows:
    } from '@qwen-code/qwen-code-core';
     isValidEditorType } from '@qwen-code/qwen-code-core';
    This appears to be a formatting issue that will cause a syntax error. Should be:
    isValidEditorType,
    } from '@qwen-code/qwen-code-core';

🟢 Medium

  • packages/cli/src/ui/components/shared/text-buffer.ts:2232-2238 - The fallback logic for when preferredEditor is set but getExternalEditorCommand returns null could be improved. Currently it logs a warning but doesn't explain why the editor wasn't found (executable missing vs invalid type). Consider adding more specific error messaging or checking if the executable exists before attempting to use it.

  • packages/core/src/utils/editor.ts:160 - The getExternalEditorCommand function returns null for invalid editor types, but this silent failure could be confusing. Consider whether callers should be prevented from passing invalid types at compile time, or if there should be better error handling when an editor type becomes unsupported.

  • packages/cli/src/ui/components/shared/text-buffer.ts:2217 - The temp file path changed from a temp directory (qwen-edit-XXXXX/buffer.txt) to a flat file (qwen-edit-UUID.md). While simpler, using .md extension assumes markdown content. Consider whether this extension is appropriate for all use cases, or if no extension would be more flexible.

🔵 Low

  • packages/core/src/utils/editor.ts:142 - The isTerminalEditor function uses an inline array with .includes(). For consistency with the existing codebase pattern (seen in isValidEditorType), consider using a similar structure or extracting the terminal editor list to a constant for easier maintenance.

  • packages/core/src/utils/editor.ts:148-154 - The ExternalEditorCommand interface documentation could benefit from a brief example showing typical values for needsShell and when it's relevant.

  • packages/cli/src/ui/AppContainer.tsx:719-722 - The prefEditorRaw validation pattern is duplicated in both AppContainer.tsx and AgentComposer.tsx. Consider extracting this into a utility function or custom hook to reduce duplication.

✅ Highlights

  • Excellent test coverage: The 14 new tests thoroughly cover both terminal and GUI editor behaviors, including Windows-specific edge cases for .cmd executables
  • Smart fallback design: The implementation gracefully degrades when preferred editor is unavailable, maintaining the existing environment variable chain
  • Proper --wait flag handling: GUI editors correctly receive the --wait flag to block execution until the editor closes, which is critical for the feature to work correctly
  • Type-safe integration: Both AppContainer.tsx and AgentComposer.tsx use isValidEditorType() guard before passing the editor value, preventing runtime type errors
  • Clean temp file management: Using crypto.randomUUID() for unique temp filenames is cleaner than the previous directory-based approach

Comment thread packages/cli/src/ui/components/shared/text-buffer.ts
Comment thread packages/cli/src/ui/components/shared/text-buffer.ts
Comment thread packages/cli/src/ui/components/shared/text-buffer.ts Outdated
Comment thread packages/cli/src/ui/components/shared/text-buffer.ts Outdated
Comment thread packages/cli/src/ui/components/shared/text-buffer.ts Outdated
Comment thread packages/cli/src/ui/components/agent-view/AgentComposer.tsx Outdated
Comment thread packages/cli/src/ui/AppContainer.tsx Outdated
Comment thread packages/core/src/utils/editor.ts Outdated

@wenshao wenshao 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.

[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

Comment thread packages/cli/src/ui/components/shared/text-buffer.ts
Comment thread packages/cli/src/ui/components/shared/text-buffer.ts
@dreamWB dreamWB force-pushed the worktree-feat-editor-pref-prompt-4165 branch from d78e65b to 1b98900 Compare May 19, 2026 08:01
@dreamWB

dreamWB commented May 19, 2026

Copy link
Copy Markdown
Collaborator Author

[Suggestion] TextInput component does not propagate preferredEditor — Ctrl+X ignores /editor in secondary inputs

已修复。TextInput 组件现在调用 usePreferredEditor() 并将结果传递给 useTextBuffer,确保所有二级输入场景(对话框、设置页等)中的 Ctrl+X 都会尊重 /editor 偏好设置。同步更新了 TextInput.test.tsx 的 mock。

commit: a793e9f

Comment thread packages/core/src/utils/editor.ts Fixed
Comment thread packages/core/src/utils/editor.ts Fixed
Comment thread packages/core/src/utils/editor.ts Fixed
Comment thread packages/core/src/utils/editor.ts Fixed
Comment thread packages/cli/src/ui/components/shared/text-buffer.ts
Comment thread packages/cli/src/ui/components/shared/text-buffer.ts Outdated
Comment thread packages/cli/src/ui/hooks/usePreferredEditor.ts
Comment thread packages/cli/src/ui/components/shared/text-buffer.ts
Comment thread packages/core/src/utils/editor.ts
Comment thread packages/cli/src/ui/components/shared/text-buffer.ts
Comment thread packages/cli/src/ui/components/shared/text-buffer.ts Outdated
@dreamWB dreamWB force-pushed the worktree-feat-editor-pref-prompt-4165 branch from eea141a to 9ced043 Compare May 19, 2026 09:27
@dreamWB

dreamWB commented May 19, 2026

Copy link
Copy Markdown
Collaborator Author

Re: @wenshao's review comment about TextInput not propagating preferredEditor — already fixed in commit a793e9f7f. TextInput.tsx line 80 calls usePreferredEditor() and passes it to useTextBuffer at line 88. All TextInput consumers (dialogs, settings prompts, etc.) now respect the /editor preference via Ctrl+X.

@wenshao wenshao 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.

[Suggestion] Stale JSDoc on TextBuffer.openInExternalEditor

The JSDoc on the TextBuffer interface for openInExternalEditor describes two behaviors that this PR changed:

  1. "snapshot the previous state once before launching the editor" — create_undo_snapshot was moved to after the editor exits (after readFileSync, before set_text).
  2. "preferred terminal text editor ($VISUAL or $EDITOR, falling back to 'vi')" — the primary resolution path is now preferredEditor via /editor setting, with $VISUAL/$EDITOR only as fallback.

Recommend updating the JSDoc to match the new behavior.

— qwen-latest-series-invite-beta-v28 via Qwen Code /review

Comment thread packages/core/src/utils/editor.ts

@wenshao wenshao 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.

[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

Comment thread packages/cli/src/ui/components/shared/text-buffer.ts Outdated
Comment thread packages/core/src/utils/editor.ts Outdated
Comment thread packages/cli/src/ui/components/shared/text-buffer.ts
Comment thread packages/cli/src/ui/components/shared/text-buffer.ts
Comment thread packages/cli/src/ui/components/shared/text-buffer.ts Outdated
Comment thread packages/cli/src/ui/components/shared/text-buffer.ts Outdated
@dreamWB

dreamWB commented May 19, 2026

Copy link
Copy Markdown
Collaborator Author

Re: review comment about TextInput not propagating preferredEditor

This is already addressed in the current PR. TextInput.tsx imports usePreferredEditor at line 12 and passes it to useTextBuffer at line 88:

import { usePreferredEditor } from '../../hooks/usePreferredEditor.js';
// ...
const preferredEditor = usePreferredEditor();
const buffer = useTextBuffer({
  // ...existing props...
  preferredEditor,
});

So all consumers of TextInput (including AskUserQuestionDialog, PermissionsDialog, etc.) get the /editor preference automatically.

@dreamWB

dreamWB commented May 19, 2026

Copy link
Copy Markdown
Collaborator Author

Re: [Suggestion] Stale JSDoc on TextBuffer.openInExternalEditor

Good catch — fixed in 930120e. Updated the JSDoc to reflect:

  • Resolution order: /editor preference → $VISUAL$EDITORvi
  • Undo snapshot is now taken after the editor exits and only when content changed

Re: [Suggestion] useLaunchEditor has divergent editor resolution

useLaunchEditor and openInExternalEditor serve different purposes — useLaunchEditor opens a diff view (via openDiff / getDiffCommand), while openInExternalEditor opens a single file for editing (via getExternalEditorCommand). The four "divergences" are actually correct behavior for each use case:

  1. as EditorTypeuseLaunchEditor gets its editor type from useSettings which already validates; adding isValidEditorType is redundant there
  2. No --waitopenDiff handles --wait internally in getDiffCommand
  3. No timeout — diff review has no natural timeout (user may spend significant time reviewing)
  4. No signal handling — openDiff uses its own error handling via the Promise rejection path

That said, sharing usePreferredEditor hook in useLaunchEditor is a good idea to centralize the settings → validated EditorType logic. Worth a follow-up PR.

Comment thread packages/core/src/utils/editor.ts Dismissed
Comment thread packages/core/src/utils/editor.ts Dismissed
Comment thread packages/cli/src/ui/components/shared/text-buffer.ts Outdated
Comment thread packages/cli/src/ui/components/shared/text-buffer.ts
Comment thread packages/cli/src/ui/components/shared/text-buffer.ts
Comment thread packages/core/src/utils/editor.ts
Comment thread packages/core/src/utils/editor.ts
Comment thread packages/cli/src/ui/components/shared/TextInput.tsx
Comment thread packages/cli/src/ui/components/shared/text-buffer.ts
@LaZzyMan LaZzyMan added the type/feature-request New feature or enhancement request label May 20, 2026
Comment thread packages/cli/src/ui/components/shared/text-buffer.ts Outdated
Comment thread packages/cli/src/ui/components/shared/text-buffer.ts Outdated
Comment thread packages/cli/src/ui/hooks/usePreferredEditor.ts

@LaZzyMan LaZzyMan 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.

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.

Comment thread packages/cli/src/ui/components/shared/text-buffer.ts
Comment thread packages/cli/src/ui/components/shared/text-buffer.ts
Comment thread packages/cli/src/ui/hooks/usePreferredEditor.ts
Comment thread packages/cli/src/ui/hooks/usePreferredEditor.ts
Comment thread packages/cli/src/ui/components/shared/text-buffer.ts Outdated
Comment thread packages/cli/src/ui/components/shared/text-buffer-external-editor.test.ts Outdated
@dreamWB

dreamWB commented May 20, 2026

Copy link
Copy Markdown
Collaborator Author

@LaZzyMan Thanks for the thorough review! All three points have been addressed in 0adb50d:

  1. Sandbox gapusePreferredEditor now calls allowEditorTypeInSandbox() and returns undefined for GUI editors (vscode, cursor, zed, etc.) when SANDBOX env is set. The Ctrl+X path then falls through to the env/default editor instead of attempting a GUI launch.

  2. Windows env-var safety — The $VISUAL/$EDITOR fallback now rejects commands containing " or | before opting into shell: true, closing the cmd.exe metacharacter injection vector.

  3. PR description — Already updated in the previous push to accurately state mkdtempSync + buffer.txt (not flat .md).

Comment thread packages/cli/src/ui/components/shared/text-buffer.ts
Comment thread packages/cli/src/ui/components/shared/text-buffer.ts
Comment thread packages/core/src/utils/editor.ts
Comment thread packages/cli/src/ui/components/shared/text-buffer-external-editor.test.ts Outdated
dreamWB added 5 commits May 20, 2026 15:30
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.
dreamWB added 11 commits May 20, 2026 15:30
- 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
@dreamWB dreamWB force-pushed the worktree-feat-editor-pref-prompt-4165 branch from 5619cf9 to 468acc2 Compare May 20, 2026 07:30
Comment thread packages/cli/src/ui/components/shared/text-buffer.ts

@wenshao wenshao 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.

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

Comment thread packages/cli/src/ui/components/shared/TextInput.tsx
Comment thread packages/cli/src/ui/components/shared/text-buffer.ts Outdated
Comment thread packages/cli/src/ui/components/shared/text-buffer.ts
Comment thread packages/cli/src/ui/components/shared/TextInput.tsx
Comment thread packages/cli/src/ui/components/shared/text-buffer.ts Outdated
Comment thread packages/cli/src/ui/components/shared/text-buffer.ts
dreamWB added 3 commits May 20, 2026 16:54
- 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.
@dreamWB

dreamWB commented May 20, 2026

Copy link
Copy Markdown
Collaborator Author

Good catch — fixed in 1e2ae54. Replaced all three unlinkSync + rmdirSync call sites (two early-return unsafe-char paths + the main finally block) with a single fs.rmSync(tmpDir, { recursive: true, force: true }), consistent with the pattern in gemini-converter.ts / claude-converter.ts.

Also added a comment in the finally block noting the Windows residual: EPERM/EBUSY from locked swap files may cause a partial delete — the outer catch keeps it non-fatal.

Tests updated accordingly: removed dead unlinkSync mock, added cleanup-failure-doesn't-propagate test, strengthened unsafe-char assertions to verify full { recursive: true, force: true } args.

Comment thread packages/cli/src/ui/components/shared/text-buffer-external-editor.test.ts Outdated
Comment thread packages/cli/src/ui/components/shared/text-buffer-external-editor.test.ts Outdated
- 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(

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.

[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.

Suggested change
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 () => {

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.

[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([

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.

[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 yiliang114 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.

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 pomelo-nwu 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.

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-library rendered 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 /sandbox and 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 vscode is not available in sandbox mode; using vi instead. Run /editor to 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/.batshell: 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 vscode is not available in sandbox mode; using vi instead. Run /editor to 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 清理),是那种"看到就不能不修"的细节,正是这些细节累积成产品的打磨感。

@LaZzyMan LaZzyMan 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.

LGTM!

@pomelo-nwu pomelo-nwu dismissed wenshao’s stale review May 21, 2026 02:50

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 pomelo-nwu merged commit 60d8ffa into QwenLM:main May 21, 2026
20 checks passed
);
}
editorCmd =
process.env['VISUAL'] ??

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.

[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 });

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.

[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}`,

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.

[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/rmdirSyncfs.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

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.

[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

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.

[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.

Suggested change
* 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 () => {

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.

[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 () => {

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.

[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 () => {

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.

[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

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

Labels

type/feature-request New feature or enhancement request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

/editor preference should apply to prompt external editor

6 participants