Skip to main content

vtcode_core/tools/
terminal_app.rs

1use std::fs;
2use std::io;
3use std::path::{Path, PathBuf};
4use std::process::{Command, Stdio};
5use std::time::Duration;
6
7use crate::utils::file_utils::read_file_with_context_sync;
8use anyhow::{Context, Result, anyhow};
9use ratatui::crossterm::ExecutableCommand;
10use ratatui::crossterm::event;
11use ratatui::crossterm::terminal::{
12    Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
13    is_raw_mode_enabled,
14};
15use tempfile::NamedTempFile;
16use tracing::debug;
17use vtcode_commons::{EditorTarget, MultiErrors};
18
19const TERMINAL_EVENT_DRAIN_WAIT: Duration = Duration::from_millis(10);
20
21/// Result from running a terminal application
22#[derive(Debug)]
23pub struct TerminalAppResult {
24    /// Exit code from the application
25    pub exit_code: i32,
26    /// Whether the application completed successfully
27    pub success: bool,
28}
29
30/// Runtime configuration for launching an external editor.
31#[derive(Debug, Clone)]
32pub struct EditorLaunchConfig {
33    /// Preferred editor command override (supports args, e.g. `code --wait`)
34    pub preferred_editor: Option<String>,
35    /// Wait for the editor process to exit before returning.
36    pub wait_for_editor: bool,
37}
38
39impl Default for EditorLaunchConfig {
40    fn default() -> Self {
41        Self { preferred_editor: None, wait_for_editor: true }
42    }
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum TerminalCommandStrategy {
47    Shell,
48    PowerShell,
49}
50
51/// Manages launching terminal applications
52pub struct TerminalAppLauncher {
53    workspace_root: PathBuf,
54}
55
56impl TerminalAppLauncher {
57    /// Create a new terminal app launcher
58    pub fn new(workspace_root: PathBuf) -> Self {
59        Self { workspace_root }
60    }
61
62    /// Launch user's preferred editor with optional file
63    ///
64    /// If a file is provided, it will be opened in the editor.
65    /// If no file is provided, a temporary file will be created and its
66    /// contents returned after editing.
67    ///
68    /// Uses the configured editor command, then VISUAL/EDITOR, then common editor defaults.
69    ///
70    /// # Errors
71    ///
72    /// Returns an error if the editor fails to launch or if file operations fail.
73    pub fn launch_editor(&self, file: Option<PathBuf>) -> Result<Option<String>> {
74        self.launch_editor_with_config(file, EditorLaunchConfig::default())
75    }
76
77    /// Launch user's preferred editor with explicit launch configuration.
78    ///
79    /// `preferred_editor`, when set, takes precedence over VISUAL/EDITOR env vars.
80    pub fn launch_editor_with_config(
81        &self,
82        file: Option<PathBuf>,
83        config: EditorLaunchConfig,
84    ) -> Result<Option<String>> {
85        let target = file.map(|path| EditorTarget::new(path, None));
86        self.launch_editor_target_with_config(target, config)
87    }
88
89    /// Launch user's preferred editor with an optional file target and location.
90    ///
91    /// `preferred_editor`, when set, takes precedence over VISUAL/EDITOR env vars.
92    pub fn launch_editor_target_with_config(
93        &self,
94        target: Option<EditorTarget>,
95        config: EditorLaunchConfig,
96    ) -> Result<Option<String>> {
97        self.launch_editor_target_with_config_and_reuse(target, config, false)
98    }
99
100    /// Launch an existing file without waiting for a GUI editor process.
101    ///
102    /// Recognized GUI editor adapters receive their focus/reuse-window flags,
103    /// while terminal editors retain the synchronous suspend-and-wait behavior.
104    pub fn launch_editor_target_non_waiting(
105        &self,
106        target: EditorTarget,
107        preferred_editor: Option<String>,
108    ) -> Result<()> {
109        self.launch_editor_target_with_config_and_reuse(
110            Some(target),
111            EditorLaunchConfig { preferred_editor, wait_for_editor: false },
112            true,
113        )
114        .map(|_| ())
115    }
116
117    fn launch_editor_target_with_config_and_reuse(
118        &self,
119        target: Option<EditorTarget>,
120        config: EditorLaunchConfig,
121        reuse_existing_window: bool,
122    ) -> Result<Option<String>> {
123        let (target, is_temp) = if let Some(target) = target {
124            (target, false)
125        } else {
126            // Create temp file for editing
127            let temp = NamedTempFile::new().context("failed to create temporary file for editing")?;
128            // Keep temp file alive by persisting it
129            let (_, path) = temp.keep().context("failed to persist temporary file")?;
130            (EditorTarget::new(path, None), true)
131        };
132        let file_path = target.path().to_path_buf();
133        let mut wait_for_editor = is_temp || config.wait_for_editor;
134        let preferred_editor = config
135            .preferred_editor
136            .as_deref()
137            .map(str::trim)
138            .filter(|value| !value.is_empty())
139            .map(ToOwned::to_owned);
140
141        debug!(
142            path = %file_path.display(),
143            wait_for_editor,
144            "launching editor"
145        );
146
147        let mut cmd = if let Some(preferred) = preferred_editor.as_deref() {
148            debug!("using configured preferred editor command: {}", preferred);
149            Self::build_editor_command_from_string_with_reuse(
150                preferred,
151                &target,
152                wait_for_editor,
153                reuse_existing_window,
154            )
155            .with_context(|| format!("failed to parse tools.editor.preferred_editor '{preferred}'"))?
156        } else if let Some(env_command) = Self::editor_command_from_env() {
157            debug!("using editor command from environment: {}", env_command);
158            Self::build_editor_command_from_string_with_reuse(
159                &env_command,
160                &target,
161                wait_for_editor,
162                reuse_existing_window,
163            )
164            .with_context(|| format!("failed to parse editor command '{env_command}'"))?
165        } else {
166            // If EDITOR/VISUAL not set, search for available editors in PATH
167            debug!("EDITOR/VISUAL not set, searching for available editors");
168            Self::try_common_editors(&target, wait_for_editor, reuse_existing_window).context(
169                "failed to detect editor: set tools.editor.preferred_editor, \
170                 or set EDITOR/VISUAL, or install an editor in PATH",
171            )?
172        };
173
174        if !wait_for_editor {
175            let program = cmd.get_program().to_string_lossy().to_string();
176            if Self::program_requires_terminal(&program) {
177                debug!(
178                    program = %program,
179                    "forcing synchronous launch for terminal-based editor"
180                );
181                wait_for_editor = true;
182            }
183        }
184
185        if wait_for_editor {
186            self.suspend_terminal_for_command(|| {
187                let status = cmd
188                    .current_dir(&self.workspace_root)
189                    .status()
190                    .context("failed to spawn editor")?;
191
192                if !status.success() {
193                    return Err(anyhow!("editor exited with non-zero status: {}", status.code().unwrap_or(-1)));
194                }
195
196                Ok(())
197            })?;
198        } else {
199            let mut child = cmd
200                .current_dir(&self.workspace_root)
201                .stdin(Stdio::null())
202                .stdout(Stdio::null())
203                .stderr(Stdio::null())
204                .spawn()
205                .context("failed to spawn editor")?;
206            std::thread::Builder::new()
207                .name("vtcode-editor-reaper".to_string())
208                .spawn(move || {
209                    if let Err(error) = child.wait() {
210                        debug!(%error, "failed to reap detached editor process");
211                    }
212                })
213                .context("failed to supervise detached editor process")?;
214        }
215
216        // Read temp file contents if it was a temp file
217        let content = if is_temp {
218            let content = read_file_with_context_sync(&file_path, "edited temporary file")
219                .context("failed to read edited content from temporary file")?;
220            fs::remove_file(&file_path).context("failed to remove temporary file")?;
221            Some(content)
222        } else {
223            None
224        };
225
226        Ok(content)
227    }
228
229    #[cfg(test)]
230    fn build_editor_command_from_string(
231        command: &str,
232        target: &EditorTarget,
233        wait_for_editor: bool,
234    ) -> Result<Command> {
235        Self::build_editor_command_from_string_with_reuse(command, target, wait_for_editor, false)
236    }
237
238    fn build_editor_command_from_string_with_reuse(
239        command: &str,
240        target: &EditorTarget,
241        wait_for_editor: bool,
242        reuse_existing_window: bool,
243    ) -> Result<Command> {
244        let tokens = shell_words::split(command).with_context(|| format!("invalid editor command: {command}"))?;
245        let (program, args) = tokens.split_first().ok_or_else(|| anyhow!("editor command cannot be empty"))?;
246        let adapter = EditorAdapter::from_program(program);
247        let mut cmd = Command::new(program);
248        cmd.args(editor_args_with_wait_policy(adapter, args, wait_for_editor));
249        if reuse_existing_window
250            && matches!(adapter, EditorAdapter::Vscode)
251            && !args.iter().any(|arg| arg == "--reuse-window")
252        {
253            cmd.arg("--reuse-window");
254        }
255        Self::append_editor_target_args(&mut cmd, program, target);
256        Ok(cmd)
257    }
258
259    /// Try common editors in priority order as fallback when EDITOR/VISUAL not set.
260    fn try_common_editors(
261        target: &EditorTarget,
262        wait_for_editor: bool,
263        reuse_existing_window: bool,
264    ) -> Result<Command> {
265        let candidate = Self::common_editor_command().ok_or_else(|| {
266            anyhow!(
267                "no editor found in PATH. Install an editor (e.g. nvim, code, zed, emacs), \
268                 or configure tools.editor.preferred_editor"
269            )
270        })?;
271        debug!("found fallback editor: {}", candidate);
272        Self::build_editor_command_from_string_with_reuse(candidate, target, wait_for_editor, reuse_existing_window)
273    }
274
275    fn editor_command_from_env() -> Option<String> {
276        ["VISUAL", "EDITOR"]
277            .into_iter()
278            .find_map(|key| std::env::var(key).ok())
279            .map(|value| value.trim().to_string())
280            .filter(|value| !value.is_empty())
281    }
282
283    /// Return whether a configured editor command names a terminal editor.
284    pub fn editor_command_requires_terminal(command: Option<&str>) -> bool {
285        if let Some(command) = command.filter(|value| !value.trim().is_empty()) {
286            return Self::program_from_command(command)
287                .is_some_and(|program| Self::program_requires_terminal(&program));
288        }
289
290        if let Some(environment_command) = Self::editor_command_from_env() {
291            return Self::program_from_command(&environment_command)
292                .is_some_and(|program| Self::program_requires_terminal(&program));
293        }
294
295        Self::common_editor_command()
296            .and_then(Self::program_from_command)
297            .is_some_and(|program| Self::program_requires_terminal(&program))
298    }
299
300    fn common_editor_command() -> Option<&'static str> {
301        common_editor_candidates().iter().copied().find(|candidate| {
302            Self::program_from_command(candidate).is_some_and(|program| which::which(program).is_ok())
303        })
304    }
305
306    fn program_from_command(command: &str) -> Option<String> {
307        shell_words::split(command).ok()?.into_iter().next()
308    }
309
310    fn program_requires_terminal(program: &str) -> bool {
311        let normalized = Path::new(program)
312            .file_name()
313            .and_then(|name| name.to_str())
314            .unwrap_or(program)
315            .to_ascii_lowercase();
316
317        matches!(normalized.as_str(), "vi" | "vim" | "nvim" | "nano" | "emacs" | "pico" | "hx" | "helix")
318    }
319
320    fn append_editor_target_args(cmd: &mut Command, program: &str, target: &EditorTarget) {
321        let adapter = EditorAdapter::from_program(program);
322        let file_path = target.path();
323
324        match (adapter, target.point()) {
325            (EditorAdapter::Vscode, Some(point)) => {
326                cmd.arg("-g");
327                cmd.arg(format_location_arg(file_path, point.line, point.column));
328            }
329            (EditorAdapter::ColonLocation, Some(point)) => {
330                cmd.arg(format_location_arg(file_path, point.line, point.column));
331            }
332            (EditorAdapter::Vim, Some(point)) => {
333                if let Some(column) = point.column {
334                    cmd.arg(format!("+call cursor({},{})", point.line, column));
335                } else {
336                    cmd.arg(format!("+{}", point.line));
337                }
338                cmd.arg(file_path);
339            }
340            _ => {
341                cmd.arg(file_path);
342            }
343        }
344    }
345
346    /// Suspend terminal UI state and run external command
347    ///
348    /// This is the unified method for launching external applications while
349    /// properly managing terminal state. It follows the Ratatui recipe:
350    /// <https://ratatui.rs/recipes/apps/spawn-vim/>
351    ///
352    /// The sequence ensures:
353    /// 1. Event handler is stopped (if applicable)
354    /// 2. Alternate screen is left
355    /// 3. Pending events are drained (CRITICAL!)
356    /// 4. Raw mode is disabled
357    /// 5. External command runs freely
358    /// 6. Raw mode is re-enabled
359    /// 7. Alternate screen is re-entered
360    /// 8. Terminal is cleared (removes artifacts)
361    /// 9. Event handler is restarted (if applicable)
362    ///
363    /// # Errors
364    ///
365    /// Returns an error if terminal state management fails or command fails.
366    fn suspend_terminal_for_command<F, T>(&self, f: F) -> Result<T>
367    where
368        F: FnOnce() -> Result<T>,
369    {
370        let was_raw_mode = match is_raw_mode_enabled() {
371            Ok(enabled) => enabled,
372            Err(error) => {
373                debug!(%error, "failed to query raw mode status; assuming non-raw terminal state");
374                false
375            }
376        };
377
378        if was_raw_mode {
379            // Purge the alternate viewport before leaving so the external
380            // app never reveals the last TUI frame in the main scrollback
381            // (mirrors the canonical `panic_hook::restore_tui` ordering).
382            // Best-effort: a failed clear must not block leaving.
383            let _ = io::stdout().execute(Clear(ClearType::All));
384            // Leave alternate screen
385            io::stdout()
386                .execute(LeaveAlternateScreen)
387                .context("failed to leave alternate screen")?;
388
389            // CRITICAL: Drain any pending crossterm events BEFORE disabling raw mode.
390            // This prevents the external app from receiving garbage input (like terminal
391            // capability responses or buffered keystrokes) that might have been sent to the TUI.
392            drain_terminal_events();
393
394            // Disable raw mode
395            disable_raw_mode().context("failed to disable raw mode")?;
396        }
397
398        // Run the command
399        let result = f();
400
401        if was_raw_mode {
402            // Always attempt every restore step so we minimize the chance of leaving the terminal
403            // in a partially restored state.
404            let mut restore_errors: MultiErrors<String> = MultiErrors::new();
405
406            if let Err(error) = enable_raw_mode() {
407                restore_errors.push(format!("failed to re-enable raw mode: {error}"));
408            }
409
410            if let Err(error) = io::stdout().execute(EnterAlternateScreen) {
411                restore_errors.push(format!("failed to re-enter alternate screen: {error}"));
412            }
413
414            // Drain terminal replies triggered by the fullscreen app while raw mode is active.
415            // Without this, Kitty keyboard protocol responses can be interpreted as shell input
416            // after returning from apps like lazygit.
417            drain_terminal_events();
418
419            // This prevents ANSI escape codes from external apps' background color requests
420            // from appearing in the TUI.
421            if let Err(error) = io::stdout().execute(Clear(ClearType::All)) {
422                restore_errors.push(format!("failed to clear terminal: {error}"));
423            }
424
425            if !restore_errors.is_empty() {
426                return match result {
427                    Ok(_) => Err(anyhow!("terminal restore failed: {restore_errors}")),
428                    Err(command_error) => {
429                        Err(command_error.context(format!("terminal restore also failed: {restore_errors}")))
430                    }
431                };
432            }
433        }
434
435        result
436    }
437
438    pub fn run_command_with_strategy(
439        &self,
440        command: &str,
441        strategy: TerminalCommandStrategy,
442    ) -> Result<TerminalAppResult> {
443        self.suspend_terminal_for_command(|| {
444            let mut cmd = match strategy {
445                TerminalCommandStrategy::Shell => {
446                    #[cfg(target_os = "windows")]
447                    {
448                        let mut command_builder = Command::new("cmd");
449                        command_builder.arg("/C").arg(command);
450                        command_builder
451                    }
452                    #[cfg(not(target_os = "windows"))]
453                    {
454                        let mut command_builder = Command::new("/bin/sh");
455                        command_builder.arg("-lc").arg(command);
456                        command_builder
457                    }
458                }
459                TerminalCommandStrategy::PowerShell => {
460                    let mut command_builder = if cfg!(target_os = "windows") {
461                        Command::new("powershell")
462                    } else {
463                        Command::new("pwsh")
464                    };
465                    command_builder.arg("-NoLogo").arg("-NoProfile").arg("-Command").arg(command);
466                    command_builder
467                }
468            };
469
470            let status = cmd
471                .current_dir(&self.workspace_root)
472                .status()
473                .with_context(|| format!("failed to spawn update command: {command}"))?;
474
475            Ok(TerminalAppResult {
476                exit_code: status.code().unwrap_or(-1),
477                success: status.success(),
478            })
479        })
480    }
481
482    /// Launch git interface (Lazygit or interactive git)
483    ///
484    /// This will attempt to launch Lazygit if available, otherwise falls back
485    /// to an interactive git command.
486    ///
487    /// # Errors
488    ///
489    /// Returns an error if the git interface fails to launch.
490    pub fn launch_git_interface(&self) -> Result<()> {
491        self.suspend_terminal_for_command(|| {
492            let git_cmd = if which::which("lazygit").is_ok() {
493                "lazygit"
494            } else {
495                "git"
496            };
497
498            let status = Command::new(git_cmd)
499                .current_dir(&self.workspace_root)
500                .status()
501                .with_context(|| format!("failed to spawn {git_cmd}"))?;
502
503            if !status.success() {
504                return Err(anyhow!("{} exited with non-zero status: {}", git_cmd, status.code().unwrap_or(-1)));
505            }
506
507            Ok(())
508        })
509    }
510}
511
512fn drain_terminal_events() {
513    if event::poll(TERMINAL_EVENT_DRAIN_WAIT).unwrap_or(false) {
514        let _ = event::read();
515    }
516
517    while event::poll(Duration::from_millis(0)).unwrap_or(false) {
518        let _ = event::read();
519    }
520}
521
522#[derive(Debug, Clone, Copy, PartialEq, Eq)]
523enum EditorAdapter {
524    Plain,
525    Vscode,
526    ColonLocation,
527    Mate,
528    MacOpen,
529    Vim,
530}
531
532impl EditorAdapter {
533    fn from_program(program: &str) -> Self {
534        let program = Path::new(program)
535            .file_name()
536            .and_then(|name| name.to_str())
537            .unwrap_or(program)
538            .to_ascii_lowercase();
539
540        match program.as_str() {
541            "code" | "code-insiders" => Self::Vscode,
542            "zed" | "subl" => Self::ColonLocation,
543            "mate" => Self::Mate,
544            "open" => Self::MacOpen,
545            "nvim" | "vim" | "vi" => Self::Vim,
546            _ => Self::Plain,
547        }
548    }
549
550    /// Flags that make this adapter block until the edited document is saved.
551    ///
552    /// Terminal editors block inherently and unknown commands have no known
553    /// wait protocol, so both report an empty set.
554    fn wait_flags(self) -> &'static [&'static str] {
555        match self {
556            EditorAdapter::Vscode | EditorAdapter::ColonLocation | EditorAdapter::Mate => &["--wait", "-w"],
557            EditorAdapter::MacOpen => &["-W"],
558            EditorAdapter::Plain | EditorAdapter::Vim => &[],
559        }
560    }
561
562    /// Flag appended when a waiting launch is requested but the user's command
563    /// omitted it. VS Code and other editor CLIs default to a detached window,
564    /// so `code` without `--wait` would return before the buffer is saved and
565    /// the launcher's read-back would see stale (or empty) content.
566    fn default_wait_flag(self) -> Option<&'static str> {
567        self.wait_flags().first().copied()
568    }
569}
570
571/// Build the editor arguments honoring the caller's wait policy.
572///
573/// When `wait_for_editor` is set, GUI adapters that launch detached windows
574/// receive their canonical wait flag if the user's command omitted it, so the
575/// launcher blocks until the buffer is saved and the read-back is accurate.
576/// When it is unset, wait flags are stripped so the open returns immediately.
577fn editor_args_with_wait_policy(adapter: EditorAdapter, args: &[String], wait_for_editor: bool) -> Vec<String> {
578    if !wait_for_editor {
579        return args.iter().filter(|arg| !matches_wait_flag(adapter, arg)).cloned().collect();
580    }
581
582    let mut effective = args.to_vec();
583    if !args.iter().any(|arg| matches_wait_flag(adapter, arg))
584        && let Some(flag) = adapter.default_wait_flag()
585    {
586        effective.push(flag.to_string());
587    }
588    effective
589}
590
591fn common_editor_candidates() -> &'static [&'static str] {
592    if cfg!(target_os = "windows") {
593        &[
594            "code --wait",
595            "code",
596            "zed --wait",
597            "zed",
598            "subl -w",
599            "subl",
600            "notepad++",
601            "notepad",
602        ]
603    } else if cfg!(target_os = "macos") {
604        &[
605            "code --wait",
606            "code",
607            "zed --wait",
608            "zed",
609            "subl -w",
610            "subl",
611            "mate -w",
612            "mate",
613            "open -a TextEdit",
614            "nvim",
615            "vim",
616            "vi",
617            "nano",
618            "emacs",
619        ]
620    } else {
621        &[
622            "code --wait",
623            "code",
624            "zed --wait",
625            "zed",
626            "subl -w",
627            "subl",
628            "mate -w",
629            "mate",
630            "nvim",
631            "vim",
632            "vi",
633            "nano",
634            "emacs",
635        ]
636    }
637}
638
639fn matches_wait_flag(adapter: EditorAdapter, arg: &str) -> bool {
640    adapter.wait_flags().contains(&arg)
641}
642
643fn format_location_arg(path: &Path, line: usize, column: Option<usize>) -> String {
644    let column = column.unwrap_or(1);
645    format!("{}:{}:{}", path.display(), line, column)
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651    use std::ffi::OsStr;
652
653    #[test]
654    fn test_launcher_creation() {
655        let launcher = TerminalAppLauncher::new(PathBuf::from("/tmp"));
656        // Just verify it can be created without panicking
657        assert_eq!(launcher.workspace_root, PathBuf::from("/tmp"));
658    }
659
660    #[test]
661    fn test_build_editor_command_supports_arguments() {
662        let command = TerminalAppLauncher::build_editor_command_from_string(
663            "code --wait",
664            &EditorTarget::new(PathBuf::from("/tmp/test.rs"), None),
665            true,
666        )
667        .expect("command should parse");
668        let args: Vec<String> = command.get_args().map(|value| value.to_string_lossy().to_string()).collect();
669
670        assert_eq!(command.get_program(), OsStr::new("code"));
671        assert_eq!(args, vec!["--wait".to_string(), "/tmp/test.rs".to_string()]);
672    }
673
674    #[test]
675    fn test_build_editor_command_rejects_empty_string() {
676        let result = TerminalAppLauncher::build_editor_command_from_string(
677            "   ",
678            &EditorTarget::new(PathBuf::from("/tmp/test.rs"), None),
679            true,
680        );
681        result.unwrap_err();
682    }
683
684    #[test]
685    fn test_build_editor_command_uses_vscode_go_to_location() {
686        let command = TerminalAppLauncher::build_editor_command_from_string(
687            "code --wait",
688            &EditorTarget::new(PathBuf::from("/tmp/test.rs"), Some(":12:4".to_string())),
689            true,
690        )
691        .expect("command should parse");
692        let args: Vec<String> = command.get_args().map(|value| value.to_string_lossy().to_string()).collect();
693
694        assert_eq!(args, vec!["--wait".to_string(), "-g".to_string(), "/tmp/test.rs:12:4".to_string()]);
695    }
696
697    #[test]
698    fn test_build_editor_command_uses_colon_location_for_zed() {
699        let command = TerminalAppLauncher::build_editor_command_from_string(
700            "zed",
701            &EditorTarget::new(PathBuf::from("/tmp/test.rs"), Some(":12".to_string())),
702            true,
703        )
704        .expect("command should parse");
705        let args: Vec<String> = command.get_args().map(|value| value.to_string_lossy().to_string()).collect();
706
707        // A waiting launch injects the adapter's wait flag so read-back blocks
708        // until the buffer is saved.
709        assert_eq!(args, vec!["--wait".to_string(), "/tmp/test.rs:12:1".to_string()]);
710    }
711
712    #[test]
713    fn test_build_editor_command_uses_cursor_command_for_vim() {
714        let command = TerminalAppLauncher::build_editor_command_from_string(
715            "nvim",
716            &EditorTarget::new(PathBuf::from("/tmp/test.rs"), Some(":12:4".to_string())),
717            true,
718        )
719        .expect("command should parse");
720        let args: Vec<String> = command.get_args().map(|value| value.to_string_lossy().to_string()).collect();
721
722        assert_eq!(args, vec!["+call cursor(12,4)".to_string(), "/tmp/test.rs".to_string()]);
723    }
724
725    #[test]
726    fn test_build_editor_command_degrades_unknown_commands_to_file_only() {
727        let command = TerminalAppLauncher::build_editor_command_from_string(
728            "custom-editor --flag",
729            &EditorTarget::new(PathBuf::from("/tmp/test.rs"), Some(":12:4".to_string())),
730            true,
731        )
732        .expect("command should parse");
733        let args: Vec<String> = command.get_args().map(|value| value.to_string_lossy().to_string()).collect();
734
735        assert_eq!(args, vec!["--flag".to_string(), "/tmp/test.rs".to_string()]);
736    }
737
738    #[test]
739    fn test_build_editor_command_strips_vscode_wait_flag_when_not_waiting() {
740        let command = TerminalAppLauncher::build_editor_command_from_string(
741            "code --wait",
742            &EditorTarget::new(PathBuf::from("/tmp/test.rs"), Some(":12:4".to_string())),
743            false,
744        )
745        .expect("command should parse");
746        let args: Vec<String> = command.get_args().map(|value| value.to_string_lossy().to_string()).collect();
747
748        assert_eq!(args, vec!["-g".to_string(), "/tmp/test.rs:12:4".to_string()]);
749    }
750
751    #[test]
752    fn test_build_editor_command_injects_wait_flag_for_bare_vscode() {
753        let command = TerminalAppLauncher::build_editor_command_from_string(
754            "code",
755            &EditorTarget::new(PathBuf::from("/tmp/test.rs"), None),
756            true,
757        )
758        .expect("command should parse");
759        let args: Vec<String> = command.get_args().map(|value| value.to_string_lossy().to_string()).collect();
760
761        assert_eq!(args, vec!["--wait".to_string(), "/tmp/test.rs".to_string()]);
762    }
763
764    #[test]
765    fn test_build_editor_command_keeps_short_wait_flag_without_duplicating() {
766        let command = TerminalAppLauncher::build_editor_command_from_string(
767            "code -w",
768            &EditorTarget::new(PathBuf::from("/tmp/test.rs"), None),
769            true,
770        )
771        .expect("command should parse");
772        let args: Vec<String> = command.get_args().map(|value| value.to_string_lossy().to_string()).collect();
773
774        assert_eq!(args, vec!["-w".to_string(), "/tmp/test.rs".to_string()]);
775    }
776
777    #[test]
778    fn test_build_editor_command_strips_short_wait_flag_when_not_waiting() {
779        let command = TerminalAppLauncher::build_editor_command_from_string(
780            "code -w",
781            &EditorTarget::new(PathBuf::from("/tmp/test.rs"), None),
782            false,
783        )
784        .expect("command should parse");
785        let args: Vec<String> = command.get_args().map(|value| value.to_string_lossy().to_string()).collect();
786
787        assert_eq!(args, vec!["/tmp/test.rs".to_string()]);
788    }
789
790    #[test]
791    fn test_build_editor_command_reuses_vscode_window_for_non_waiting_opens() {
792        let command = TerminalAppLauncher::build_editor_command_from_string_with_reuse(
793            "code --wait",
794            &EditorTarget::new(PathBuf::from("/tmp/test.rs"), Some(":12:4".to_string())),
795            false,
796            true,
797        )
798        .expect("command should parse");
799        let args: Vec<String> = command.get_args().map(|value| value.to_string_lossy().to_string()).collect();
800
801        assert_eq!(
802            args,
803            vec![
804                "--reuse-window".to_string(),
805                "-g".to_string(),
806                "/tmp/test.rs:12:4".to_string()
807            ]
808        );
809    }
810
811    #[test]
812    fn test_build_editor_command_does_not_add_reuse_flags_to_custom_editors() {
813        let command = TerminalAppLauncher::build_editor_command_from_string_with_reuse(
814            "custom-editor --focus",
815            &EditorTarget::new(PathBuf::from("/tmp/test.rs"), Some(":12:4".to_string())),
816            false,
817            true,
818        )
819        .expect("command should parse");
820        let args: Vec<String> = command.get_args().map(|value| value.to_string_lossy().to_string()).collect();
821
822        assert_eq!(args, vec!["--focus".to_string(), "/tmp/test.rs".to_string()]);
823    }
824
825    #[test]
826    fn test_build_editor_command_strips_sublime_wait_flag_when_not_waiting() {
827        let command = TerminalAppLauncher::build_editor_command_from_string(
828            "subl -w",
829            &EditorTarget::new(PathBuf::from("/tmp/test.rs"), None),
830            false,
831        )
832        .expect("command should parse");
833        let args: Vec<String> = command.get_args().map(|value| value.to_string_lossy().to_string()).collect();
834
835        assert_eq!(args, vec!["/tmp/test.rs".to_string()]);
836    }
837
838    #[test]
839    fn test_program_requires_terminal_detects_terminal_editors() {
840        assert!(TerminalAppLauncher::program_requires_terminal("nvim"));
841        assert!(TerminalAppLauncher::program_requires_terminal("/usr/bin/vim"));
842        assert!(TerminalAppLauncher::program_requires_terminal("helix"));
843        assert!(!TerminalAppLauncher::program_requires_terminal("code"));
844        assert!(!TerminalAppLauncher::program_requires_terminal("zed"));
845    }
846}