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