lore/console.rs
1//! The terminal device itself, independent of the inherited streams.
2
3use std::fs::{File, OpenOptions};
4use std::io::Write;
5
6use anyhow::{Context, Result};
7
8/// Opens the terminal this process is attached to.
9///
10/// Neither inherited stream is safe to write to. stdout belongs to the shell
11/// integration, which reads the chosen command from it, and a PSReadLine key
12/// handler hands the child a redirected stderr, so anything written there
13/// disappears into a pipe.
14pub fn device() -> Result<File> {
15 let path = if cfg!(windows) { "CONOUT$" } else { "/dev/tty" };
16
17 OpenOptions::new()
18 .read(true)
19 .write(true)
20 .open(path)
21 .with_context(|| format!("failed to open the terminal device ({path})"))
22}
23
24/// Puts a message in front of the user, wherever it can still be seen.
25///
26/// Falls back to stderr, which is better than nothing when there is no terminal
27/// at all, such as under a test harness or a redirect.
28pub fn report(message: &str) {
29 if let Ok(mut device) = device()
30 && device.write_all(message.as_bytes()).is_ok()
31 {
32 return;
33 }
34
35 eprint!("{message}");
36}
37
38/// Puts the controlling terminal on stdin when something else is there.
39///
40/// zsh runs a widget's commands with stdin on `/dev/null`. The terminal library
41/// then falls back to opening `/dev/tty`, and on macOS the kernel refuses to
42/// poll that device: the answer to the cursor position question is never seen,
43/// and the picker waits for it forever. The pseudo terminal's own path, such as
44/// `/dev/ttys003`, has no such problem, so it is found by session and dup'd
45/// onto stdin before the terminal library looks. Nothing here reads stdin for
46/// anything else.
47///
48/// Best effort: when the terminal cannot be found, the picker proceeds as it
49/// would have and whatever happens next is reported the usual way.
50#[cfg(target_os = "macos")]
51pub fn adopt_terminal_as_stdin() {
52 use std::os::fd::AsRawFd;
53
54 // SAFETY: isatty only inspects a descriptor number.
55 if unsafe { libc::isatty(libc::STDIN_FILENO) } == 1 {
56 return;
57 }
58
59 let Some(terminal) = controlling_terminal() else {
60 return;
61 };
62
63 // SAFETY: both descriptors are open and owned by this process; dup2 leaves
64 // `terminal` untouched, and the copy on stdin outlives it.
65 unsafe {
66 libc::dup2(terminal.as_raw_fd(), libc::STDIN_FILENO);
67 }
68}
69
70/// The pseudo terminal this process's session is attached to, if any.
71///
72/// Only `ttys*` devices are tried. Anything else under `/dev/tty*` is a
73/// serial or Bluetooth port, and opening one of those blocks waiting for a
74/// carrier that will never come.
75#[cfg(target_os = "macos")]
76fn controlling_terminal() -> Option<File> {
77 use std::os::fd::AsRawFd;
78 use std::os::unix::fs::OpenOptionsExt;
79
80 // SAFETY: getsid(0) asks about the calling process and cannot fail for it.
81 let session = unsafe { libc::getsid(0) };
82
83 std::fs::read_dir("/dev")
84 .ok()?
85 .flatten()
86 .filter(|entry| entry.file_name().to_string_lossy().starts_with("ttys"))
87 .find_map(|entry| {
88 let file = OpenOptions::new()
89 .read(true)
90 .write(true)
91 .custom_flags(libc::O_NOCTTY)
92 .open(entry.path())
93 .ok()?;
94
95 // SAFETY: the descriptor is open and owned by `file`.
96 let owner = unsafe { libc::tcgetsid(file.as_raw_fd()) };
97 (owner == session).then_some(file)
98 })
99}
100
101#[cfg(not(target_os = "macos"))]
102pub fn adopt_terminal_as_stdin() {}