Skip to main content

gpui_cn/terminal/
pty.rs

1//! A local program in a pty, as a [`ByteSource`].
2//!
3//! The child environment follows Ghostty: `TERM=xterm-ghostty` with the
4//! shipped terminfo database, `COLORTERM`, `TERM_PROGRAM`, the shell
5//! integration scripts injected per shell, and the terminal-specific
6//! variables of other emulators removed.
7//!
8//! Derived from Herdr (Apache-2.0).
9
10use std::io;
11use std::path::{Path, PathBuf};
12use std::sync::Mutex;
13
14use portable_pty::{CommandBuilder, PtySize, native_pty_system};
15
16#[cfg(any(unix, windows))]
17use crate::terminal::engine::ForegroundProcess;
18use crate::terminal::engine::{ByteHandle, ByteSink, ByteSource, ExitStatus};
19use crate::terminal::frame::Viewport;
20use crate::terminal::options::{LocalTerminalOptions, ShellIntegration, WorkingDirectory};
21
22/// A program to run in a local pty.
23#[derive(Debug)]
24pub struct LocalPty {
25    options: LocalTerminalOptions,
26}
27
28impl LocalPty {
29    /// A pty for `options`.
30    #[must_use]
31    pub fn new(options: LocalTerminalOptions) -> Self {
32        Self { options }
33    }
34}
35
36fn size(viewport: Viewport) -> PtySize {
37    PtySize {
38        rows: viewport.rows(),
39        cols: viewport.columns(),
40        pixel_width: u16::try_from(u32::from(viewport.columns()) * viewport.cell_width())
41            .unwrap_or(u16::MAX),
42        pixel_height: u16::try_from(u32::from(viewport.rows()) * viewport.cell_height())
43            .unwrap_or(u16::MAX),
44    }
45}
46
47fn detect_shell(program: &Path) -> ShellIntegration {
48    match program.file_name().and_then(|n| n.to_str()) {
49        Some("bash") => ShellIntegration::Bash,
50        Some("zsh") => ShellIntegration::Zsh,
51        Some("fish") => ShellIntegration::Fish,
52        Some("elvish") => ShellIntegration::Elvish,
53        Some("nu") => ShellIntegration::Nushell,
54        _ => ShellIntegration::None,
55    }
56}
57
58fn prepend_path_list(existing: Option<std::ffi::OsString>, dir: &Path, default: &str) -> String {
59    let existing = existing
60        .map(|v| v.to_string_lossy().into_owned())
61        .filter(|v| !v.is_empty())
62        .unwrap_or_else(|| default.to_owned());
63    format!("{}:{existing}", dir.display())
64}
65
66/// The user's home: `HOME`, or `USERPROFILE` on Windows.
67fn home_dir() -> Option<PathBuf> {
68    let var = |name| std::env::var_os(name).filter(|value| !value.is_empty());
69    let home = if cfg!(windows) {
70        var("USERPROFILE").or_else(|| var("HOME"))
71    } else {
72        var("HOME")
73    };
74    home.map(PathBuf::from)
75}
76
77/// Build the command, following Ghostty's environment rules.
78fn command(options: &LocalTerminalOptions, resources: &Path, terminfo: &Path) -> CommandBuilder {
79    let program = options
80        .program
81        .clone()
82        .unwrap_or_else(crate::shell::default_shell);
83    let integration = match options.shell_integration {
84        ShellIntegration::Detect => detect_shell(&program),
85        other => other,
86    };
87    let mut args: Vec<String> = options.args.clone();
88    let mut cmd = CommandBuilder::new(&program);
89
90    match &options.cwd {
91        WorkingDirectory::Inherit => {}
92        WorkingDirectory::Home => {
93            if let Some(home) = home_dir() {
94                cmd.cwd(home);
95            }
96        }
97        WorkingDirectory::Path(path) => {
98            let fallback = home_dir();
99            if path.is_dir() {
100                cmd.cwd(path);
101            } else if let Some(home) = fallback.filter(|h| h.is_dir()) {
102                cmd.cwd(home);
103            } else {
104                cmd.cwd("/");
105            }
106        }
107    }
108
109    for name in crate::shell::FOREIGN_TERMINAL_ENV {
110        cmd.env_remove(name);
111    }
112    for name in &options.env_remove {
113        cmd.env_remove(name);
114    }
115    cmd.env("TERM", ghostty_vt::terminfo::TERM);
116    cmd.env("TERMINFO", terminfo);
117    cmd.env("COLORTERM", "truecolor");
118    cmd.env("TERM_PROGRAM", "ghostty");
119    cmd.env(
120        "TERM_PROGRAM_VERSION",
121        ghostty_vt::build_info::pinned_version(),
122    );
123    cmd.env("GHOSTTY_RESOURCES_DIR", resources);
124    if std::env::var_os("LANG").is_none_or(|v| v.is_empty()) {
125        cmd.env("LANG", "en_US.UTF-8");
126    }
127    if options.program.is_none() {
128        cmd.env("SHELL", &program);
129    }
130    if !options.shell_integration_features.is_empty() && integration != ShellIntegration::None {
131        cmd.env(
132            "GHOSTTY_SHELL_FEATURES",
133            options.shell_integration_features.join(","),
134        );
135    }
136
137    let integration_dir = resources.join("shell-integration");
138    match integration {
139        ShellIntegration::None | ShellIntegration::Detect => {}
140        ShellIntegration::Bash => {
141            // Ghostty starts bash in POSIX mode with ENV pointing at its
142            // script, which then loads the normal startup files itself.
143            if let Some(env) = std::env::var_os("ENV") {
144                cmd.env("GHOSTTY_BASH_ENV", env);
145            }
146            cmd.env("ENV", integration_dir.join("bash").join("ghostty.bash"));
147            let mut inject = String::from("1");
148            if options.login {
149                inject.push_str(" --login");
150            }
151            cmd.env("GHOSTTY_BASH_INJECT", inject);
152            if std::env::var_os("HISTFILE").is_none()
153                && let Some(home) = std::env::var_os("HOME")
154            {
155                cmd.env("HISTFILE", PathBuf::from(home).join(".bash_history"));
156                cmd.env("GHOSTTY_BASH_UNEXPORT_HISTFILE", "1");
157            }
158            args.insert(0, "--posix".to_owned());
159        }
160        ShellIntegration::Zsh => {
161            if let Some(old) = std::env::var_os("ZDOTDIR") {
162                cmd.env("GHOSTTY_ZSH_ZDOTDIR", old);
163            }
164            cmd.env("ZDOTDIR", integration_dir.join("zsh"));
165        }
166        ShellIntegration::Fish | ShellIntegration::Elvish | ShellIntegration::Nushell => {
167            cmd.env("GHOSTTY_SHELL_INTEGRATION_XDG_DIR", &integration_dir);
168            cmd.env(
169                "XDG_DATA_DIRS",
170                prepend_path_list(
171                    std::env::var_os("XDG_DATA_DIRS"),
172                    &integration_dir,
173                    "/usr/local/share:/usr/share",
174                ),
175            );
176            if integration == ShellIntegration::Nushell {
177                args.push("--execute".to_owned());
178                args.push("use ghostty *".to_owned());
179            }
180        }
181    }
182    for (name, value) in &options.env {
183        cmd.env(name, value);
184    }
185    // The user's shell starts as a login shell the way Ghostty starts it:
186    // through login(1) on macOS, with `-l` elsewhere. Bash and Nushell
187    // log in through their integration instead of `-l`.
188    let (program, args) = if options.login && options.program.is_none() {
189        if cfg!(target_os = "macos") {
190            crate::shell::login_command(&program, &args).into_parts()
191        } else if matches!(
192            integration,
193            ShellIntegration::Bash | ShellIntegration::Nushell
194        ) {
195            (program, args)
196        } else {
197            crate::shell::login_command(&program, &args).into_parts()
198        }
199    } else {
200        (program, args)
201    };
202    let mut argv = vec![program.into_os_string()];
203    argv.extend(args.into_iter().map(Into::into));
204    *cmd.get_argv_mut() = argv;
205    cmd
206}
207
208struct Handle {
209    #[cfg(unix)]
210    io: crate::terminal::pty_io::PollIo,
211    #[cfg(not(unix))]
212    io: crate::terminal::pty_io::WriterThread,
213    #[cfg(unix)]
214    master: Mutex<Box<dyn portable_pty::MasterPty + Send>>,
215    /// Sends sizes to the thread that owns the pseudoconsole, since a
216    /// ConPTY resize can block, as herdr does.
217    #[cfg(windows)]
218    resizer: Mutex<std::sync::mpsc::Sender<PtySize>>,
219    /// The job the program and everything it starts run in.
220    #[cfg(windows)]
221    job: Option<crate::terminal::process_windows::Job>,
222    child: Mutex<Box<dyn portable_pty::Child + Send + Sync>>,
223    /// The file name of the program the terminal started, such as `zsh`.
224    #[cfg(unix)]
225    program: Option<String>,
226    /// The last foreground process group and its process, kept while the
227    /// group has the foreground.
228    #[cfg(unix)]
229    foreground: Mutex<Option<(libc::pid_t, ForegroundProcess)>>,
230}
231
232impl ByteHandle for Handle {
233    fn write(&self, bytes: &[u8]) -> io::Result<usize> {
234        self.io.write(bytes)
235    }
236
237    #[cfg(unix)]
238    fn resize(&self, viewport: Viewport) -> io::Result<()> {
239        self.master
240            .lock()
241            .unwrap_or_else(std::sync::PoisonError::into_inner)
242            .resize(size(viewport))
243            .map_err(io::Error::other)
244    }
245
246    #[cfg(windows)]
247    fn resize(&self, viewport: Viewport) -> io::Result<()> {
248        self.resizer
249            .lock()
250            .unwrap_or_else(std::sync::PoisonError::into_inner)
251            .send(size(viewport))
252            .map_err(|_| io::Error::from(io::ErrorKind::BrokenPipe))
253    }
254
255    fn try_wait(&self) -> io::Result<Option<ExitStatus>> {
256        let mut child = self
257            .child
258            .lock()
259            .unwrap_or_else(std::sync::PoisonError::into_inner);
260        Ok(child.try_wait()?.map(|status| ExitStatus {
261            code: status.exit_code().try_into().ok(),
262            signal: status.signal().map(str::to_owned),
263            runtime: std::time::Duration::ZERO,
264            abnormal: false,
265        }))
266    }
267
268    fn close(&self) {
269        self.io.close();
270        let mut child = self
271            .child
272            .lock()
273            .unwrap_or_else(std::sync::PoisonError::into_inner);
274        // The shell leads its own session; its jobs end with it, even
275        // when the shell itself has already exited, as in herdr.
276        #[cfg(unix)]
277        if let Some(leader) = child
278            .process_id()
279            .and_then(|pid| libc::pid_t::try_from(pid).ok())
280        {
281            crate::terminal::process::end_session(leader, || {
282                matches!(child.try_wait(), Ok(Some(_)))
283            });
284            return;
285        }
286        // The program and everything it started are in the job.
287        #[cfg(windows)]
288        if let Some(job) = &self.job {
289            job.terminate();
290        }
291        if matches!(child.try_wait(), Ok(None)) {
292            let _ = child.kill();
293        }
294    }
295
296    #[cfg(windows)]
297    fn foreground(&self) -> Option<ForegroundProcess> {
298        let root = self
299            .child
300            .lock()
301            .unwrap_or_else(std::sync::PoisonError::into_inner)
302            .process_id()?;
303        let entry = crate::terminal::process_windows::foreground(root)?;
304        Some(ForegroundProcess {
305            pid: entry.pid,
306            name: entry.name,
307            argv: Vec::new(),
308            cwd: None,
309            shell: entry.pid == root,
310        })
311    }
312
313    #[cfg(unix)]
314    fn foreground(&self) -> Option<ForegroundProcess> {
315        use crate::terminal::process;
316        let pgid = self
317            .master
318            .lock()
319            .unwrap_or_else(std::sync::PoisonError::into_inner)
320            .process_group_leader()?;
321        let mut last = self
322            .foreground
323            .lock()
324            .unwrap_or_else(std::sync::PoisonError::into_inner);
325        if let Some((group, known)) = last.as_mut()
326            && *group == pgid
327        {
328            let pid = libc::pid_t::try_from(known.pid).ok()?;
329            // The same group and the same program, unless it called exec.
330            if process::name(pid).as_deref() == Some(known.name.as_str()) {
331                // Only a shell at its prompt changes directory.
332                if known.shell {
333                    known.cwd = process::cwd(pid).or(known.cwd.take());
334                }
335                return Some(known.clone());
336            }
337        }
338        let described = process::describe_group(pgid)?;
339        let shell = self
340            .program
341            .as_deref()
342            .is_some_and(|program| is_program(&described, program));
343        let known = ForegroundProcess {
344            pid: u32::try_from(described.pid).ok()?,
345            name: described.name,
346            argv: described.argv,
347            cwd: described.cwd,
348            shell,
349        };
350        *last = Some((pgid, known.clone()));
351        Some(known)
352    }
353}
354
355/// Whether `process` runs `program`, by executable name, or by its first
356/// argument, which a login shell starts with a dash, as `-zsh`.
357#[cfg(unix)]
358fn is_program(process: &crate::terminal::process::Described, program: &str) -> bool {
359    // Linux keeps 15 bytes of the name.
360    let short = &program[..program.len().min(15)];
361    process.name == program
362        || process.name == short
363        || process.argv.first().is_some_and(|arg0| {
364            let arg0 = arg0.strip_prefix('-').unwrap_or(arg0);
365            Path::new(arg0).file_name().and_then(|n| n.to_str()) == Some(program)
366        })
367}
368
369impl ByteSource for LocalPty {
370    fn start(
371        self: Box<Self>,
372        sink: ByteSink,
373        viewport: Viewport,
374    ) -> io::Result<Box<dyn ByteHandle>> {
375        let resources = ghostty_vt::shell_integration::resources_dir()?;
376        let terminfo = ghostty_vt::terminfo::dir()?;
377        let cmd = command(&self.options, &resources, &terminfo);
378        #[cfg(unix)]
379        let program = self
380            .options
381            .program
382            .clone()
383            .unwrap_or_else(crate::shell::default_shell)
384            .file_name()
385            .and_then(|name| name.to_str())
386            .map(str::to_owned);
387        let pty = native_pty_system()
388            .openpty(size(viewport))
389            .map_err(io::Error::other)?;
390        let child = pty.slave.spawn_command(cmd).map_err(io::Error::other)?;
391        drop(pty.slave);
392        // Everything the program starts joins its job, so closing the
393        // terminal ends the whole tree. Without a job, close still kills
394        // the program itself.
395        #[cfg(windows)]
396        let job = child.process_id().and_then(|pid| {
397            let job = crate::terminal::process_windows::Job::new().ok()?;
398            job.assign(pid).ok()?;
399            Some(job)
400        });
401        #[cfg(unix)]
402        let io = crate::terminal::pty_io::PollIo::start(
403            pty.master
404                .as_raw_fd()
405                .ok_or_else(|| io::Error::other("the pty has no descriptor"))?,
406            sink,
407        )?;
408        #[cfg(not(unix))]
409        let io = {
410            let reader = pty.master.try_clone_reader().map_err(io::Error::other)?;
411            let writer = pty.master.take_writer().map_err(io::Error::other)?;
412            let writable = sink.clone();
413            crate::terminal::pty_io::spawn_reader(reader, sink)?;
414            crate::terminal::pty_io::WriterThread::start(writer, move || writable.writable())?
415        };
416        #[cfg(windows)]
417        let resizer = {
418            let (tx, rx) = std::sync::mpsc::channel::<PtySize>();
419            let master = pty.master;
420            std::thread::Builder::new()
421                .name("gpui-cn-pty-resize".to_owned())
422                .spawn(move || {
423                    while let Ok(mut size) = rx.recv() {
424                        // Only the latest size matters.
425                        while let Ok(next) = rx.try_recv() {
426                            size = next;
427                        }
428                        let _ = master.resize(size);
429                    }
430                })?;
431            Mutex::new(tx)
432        };
433        Ok(Box::new(Handle {
434            io,
435            #[cfg(unix)]
436            master: Mutex::new(pty.master),
437            #[cfg(windows)]
438            resizer,
439            #[cfg(windows)]
440            job,
441            child: Mutex::new(child),
442            #[cfg(unix)]
443            program,
444            #[cfg(unix)]
445            foreground: Mutex::new(None),
446        }))
447    }
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453
454    #[test]
455    fn shell_detection_uses_the_file_name() {
456        assert_eq!(detect_shell(Path::new("/bin/zsh")), ShellIntegration::Zsh);
457        assert_eq!(
458            detect_shell(Path::new("/opt/homebrew/bin/fish")),
459            ShellIntegration::Fish
460        );
461        assert_eq!(
462            detect_shell(Path::new("/usr/bin/nu")),
463            ShellIntegration::Nushell
464        );
465        assert_eq!(detect_shell(Path::new("/bin/sh")), ShellIntegration::None);
466    }
467
468    #[test]
469    fn xdg_data_dirs_are_prepended_with_the_default_when_unset() {
470        let dir = Path::new("/res/shell-integration");
471        assert_eq!(
472            prepend_path_list(None, dir, "/usr/local/share:/usr/share"),
473            "/res/shell-integration:/usr/local/share:/usr/share"
474        );
475        assert_eq!(
476            prepend_path_list(
477                Some("/opt/share".into()),
478                dir,
479                "/usr/local/share:/usr/share"
480            ),
481            "/res/shell-integration:/opt/share"
482        );
483    }
484
485    #[test]
486    fn zsh_gets_zdotdir_and_a_login_flag() {
487        let options =
488            LocalTerminalOptions::default().with_program("/bin/zsh", Vec::<String>::new());
489        let cmd = command(&options, Path::new("/res"), Path::new("/ti"));
490        let env: std::collections::HashMap<_, _> = cmd
491            .iter_extra_env_as_str()
492            .map(|(k, v)| (k.to_owned(), v.to_owned()))
493            .collect();
494        let zdotdir = Path::new("/res").join("shell-integration").join("zsh");
495        assert_eq!(env.get("ZDOTDIR").map(String::as_str), zdotdir.to_str());
496        assert_eq!(env.get("TERM").map(String::as_str), Some("xterm-ghostty"));
497        assert_eq!(env.get("TERMINFO").map(String::as_str), Some("/ti"));
498        assert!(
499            !cmd.get_argv().iter().any(|a| a == "-l"),
500            "an explicit program is not made a login shell"
501        );
502    }
503}