1use 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#[derive(Debug)]
24pub struct LocalPty {
25 options: LocalTerminalOptions,
26}
27
28impl LocalPty {
29 #[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
66fn 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
77fn 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 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 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 #[cfg(windows)]
218 resizer: Mutex<std::sync::mpsc::Sender<PtySize>>,
219 #[cfg(windows)]
221 job: Option<crate::terminal::process_windows::Job>,
222 child: Mutex<Box<dyn portable_pty::Child + Send + Sync>>,
223 #[cfg(unix)]
225 program: Option<String>,
226 #[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 #[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 #[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 if process::name(pid).as_deref() == Some(known.name.as_str()) {
331 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#[cfg(unix)]
358fn is_program(process: &crate::terminal::process::Described, program: &str) -> bool {
359 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 #[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 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}