Skip to main content

omp_tui/
ttyid.rs

1//! Stable identifiers for the terminal attached to standard input.
2
3use std::{ffi::OsString, path::Path};
4
5use omp_core::{Str, fmts};
6
7#[cfg(unix)]
8mod platform {
9	use std::path::PathBuf;
10
11	pub(super) fn tty_path() -> Option<PathBuf> {
12		crate::tty::override_path().or_else(|| nix::unistd::ttyname(std::io::stdin()).ok())
13	}
14}
15
16#[cfg(windows)]
17mod platform {
18	use windows_sys::Win32::{
19		Foundation::INVALID_HANDLE_VALUE,
20		System::Console::{GetConsoleMode, GetStdHandle, STD_INPUT_HANDLE},
21	};
22
23	pub(super) fn has_console() -> bool {
24		let handle = unsafe { GetStdHandle(STD_INPUT_HANDLE) };
25		if handle.is_null() || handle == INVALID_HANDLE_VALUE {
26			return false;
27		}
28		let mut mode = 0;
29		unsafe { GetConsoleMode(handle, &mut mode) != 0 }
30	}
31}
32
33/// Stable identifier used when neither a TTY nor a terminal environment
34/// variable is available.
35pub const UNKNOWN_TERMINAL_ID: &str = "unknown";
36
37/// Resolve a stable identifier for the terminal attached to standard input.
38///
39/// The TTY device name takes priority. When standard input is not a TTY, common
40/// multiplexer and terminal-emulator environment variables are consulted from
41/// the innermost to the outermost terminal.
42#[must_use]
43pub fn terminal_id() -> Str {
44	#[cfg(unix)]
45	{
46		let tty_path = platform::tty_path();
47		terminal_id_with(tty_path.as_deref(), |name| std::env::var_os(name))
48	}
49	#[cfg(windows)]
50	{
51		let id = terminal_id_with(None, |name| std::env::var_os(name));
52		if id != UNKNOWN_TERMINAL_ID || !platform::has_console() {
53			return id;
54		}
55		"console".into()
56	}
57}
58
59/// Resolve a terminal identifier from an injected TTY path and environment.
60///
61/// This is the deterministic core of [`terminal_id`]. The environment callback
62/// should return the value for the requested variable, or `None` when unset.
63#[must_use]
64pub fn terminal_id_with(
65	tty_path: Option<&Path>,
66	mut env: impl FnMut(&str) -> Option<OsString>,
67) -> Str {
68	if let Some(id) = tty_path.and_then(normalize_tty_path) {
69		return id;
70	}
71
72	if let Some(pane) = nonempty_env(&mut env, "ZELLIJ_PANE_ID") {
73		if let Some(session) = nonempty_env(&mut env, "ZELLIJ_SESSION_NAME") {
74			let session = session.replace(['/', '\\'], "-");
75			return fmts!("zellij-{session}-{pane}");
76		}
77		return fmts!("zellij-{pane}");
78	}
79
80	for (name, prefix) in [
81		("TMUX_PANE", "tmux"),
82		("CMUX_SURFACE_ID", "cmux"),
83		("KITTY_WINDOW_ID", "kitty"),
84		("WEZTERM_PANE", "wezterm"),
85		("TERM_SESSION_ID", "apple"),
86		("WT_SESSION", "wt"),
87	] {
88		if let Some(value) = nonempty_env(&mut env, name) {
89			return fmts!("{prefix}-{value}");
90		}
91	}
92
93	UNKNOWN_TERMINAL_ID.into()
94}
95
96fn normalize_tty_path(path: &Path) -> Option<Str> {
97	let path = path.to_str()?;
98	let relative = path.strip_prefix("/dev/")?;
99	if relative.is_empty() {
100		return None;
101	}
102	Some(Str::from(relative.replace('/', "-")))
103}
104
105fn nonempty_env(env: &mut impl FnMut(&str) -> Option<OsString>, name: &str) -> Option<String> {
106	env(name)?
107		.into_string()
108		.ok()
109		.filter(|value| !value.is_empty())
110}
111
112#[cfg(test)]
113mod tests {
114	use std::{collections::HashMap, ffi::OsString, path::Path};
115
116	use super::{UNKNOWN_TERMINAL_ID, terminal_id_with};
117
118	#[test]
119	fn normalizes_posix_tty_paths() {
120		let no_env = |_: &str| None;
121		assert_eq!(terminal_id_with(Some(Path::new("/dev/pts/3")), no_env), "pts-3");
122		assert_eq!(terminal_id_with(Some(Path::new("/dev/ttys004")), no_env), "ttys004");
123	}
124
125	#[test]
126	fn tty_path_precedes_environment() {
127		assert_eq!(
128			terminal_id_with(Some(Path::new("/dev/pts/8")), |_| Some("ignored".into())),
129			"pts-8"
130		);
131	}
132
133	#[test]
134	fn environment_uses_exact_precedence() {
135		let variables = HashMap::from([
136			("ZELLIJ_PANE_ID", "1"),
137			("ZELLIJ_SESSION_NAME", "work/tree\\leaf"),
138			("TMUX_PANE", "%2"),
139			("CMUX_SURFACE_ID", "3"),
140			("KITTY_WINDOW_ID", "4"),
141			("WEZTERM_PANE", "5"),
142			("TERM_SESSION_ID", "6"),
143			("WT_SESSION", "7"),
144		]);
145		let env = |name: &str| variables.get(name).map(OsString::from);
146		assert_eq!(terminal_id_with(None, env), "zellij-work-tree-leaf-1");
147
148		let ordered = [
149			("TMUX_PANE", "tmux-%2"),
150			("CMUX_SURFACE_ID", "cmux-3"),
151			("KITTY_WINDOW_ID", "kitty-4"),
152			("WEZTERM_PANE", "wezterm-5"),
153			("TERM_SESSION_ID", "apple-6"),
154			("WT_SESSION", "wt-7"),
155		];
156		for (index, &(winner, expected)) in ordered.iter().enumerate() {
157			let env = |name: &str| {
158				ordered[index..]
159					.iter()
160					.find(|&&(candidate, _)| candidate == name)
161					.map(|_| OsString::from(variables[name]))
162			};
163			assert_eq!(terminal_id_with(None, env), expected, "winner: {winner}");
164		}
165	}
166
167	#[test]
168	fn empty_and_non_unicode_values_are_ignored() {
169		let env = |name: &str| match name {
170			"TMUX_PANE" => Some(OsString::new()),
171			"KITTY_WINDOW_ID" => Some("9".into()),
172			_ => None,
173		};
174		assert_eq!(terminal_id_with(None, env), "kitty-9");
175		assert_eq!(terminal_id_with(None, |_| None), UNKNOWN_TERMINAL_ID);
176	}
177}