vtcode_commons/tty.rs
1//! TTY detection capability shared across VT Code crates.
2//!
3//! This module owns the canonical [`TtyExt`] extension trait for standard I/O
4//! streams. Both `vtcode-core` (`crate::utils::tty`) and `vtcode-ui`
5//! (`crate::tui::utils::tty`) re-export it so historical import paths keep
6//! resolving; extend the contract here instead of forking it per crate.
7//!
8//! # Example
9//!
10//! ```rust
11//! use std::io;
12//! use vtcode_commons::tty::TtyExt;
13//!
14//! if io::stdout().is_tty_ext() {
15//! // Terminal-specific features available.
16//! }
17//! ```
18
19use std::io;
20use std::io::IsTerminal;
21
22use crate::color_policy::no_color_env_active;
23
24/// Extension trait for TTY detection on standard I/O streams.
25///
26/// This trait extends [`IsTerminal`] with convenient methods for checking TTY
27/// capabilities with consistent `NO_COLOR` / `FORCE_COLOR` handling.
28pub trait TtyExt {
29 /// Returns `true` if this stream is connected to a terminal.
30 fn is_tty_ext(&self) -> bool;
31
32 /// Returns `true` if this stream supports ANSI color codes.
33 ///
34 /// This checks both TTY status and the `NO_COLOR` / `FORCE_COLOR`
35 /// environment variables.
36 fn supports_color(&self) -> bool;
37
38 /// Returns `true` if this stream supports interactive features.
39 ///
40 /// Interactive features include cursor movement, color, and other
41 /// terminal capabilities that require a real terminal.
42 fn is_interactive(&self) -> bool;
43}
44
45impl TtyExt for io::Stdout {
46 fn is_tty_ext(&self) -> bool {
47 self.is_terminal()
48 }
49
50 fn supports_color(&self) -> bool {
51 if !self.is_terminal() {
52 return false;
53 }
54
55 // Check NO_COLOR with strict non-empty semantics.
56 if no_color_env_active() {
57 return false;
58 }
59
60 // Check for FORCE_COLOR environment variable
61 if std::env::var_os("FORCE_COLOR").is_some() {
62 return true;
63 }
64
65 true
66 }
67
68 fn is_interactive(&self) -> bool {
69 self.is_terminal() && self.supports_color()
70 }
71}
72
73impl TtyExt for io::Stderr {
74 fn is_tty_ext(&self) -> bool {
75 self.is_terminal()
76 }
77
78 fn supports_color(&self) -> bool {
79 if !self.is_terminal() {
80 return false;
81 }
82
83 // Check NO_COLOR with strict non-empty semantics.
84 if no_color_env_active() {
85 return false;
86 }
87
88 // Check for FORCE_COLOR environment variable
89 if std::env::var_os("FORCE_COLOR").is_some() {
90 return true;
91 }
92
93 true
94 }
95
96 fn is_interactive(&self) -> bool {
97 self.is_terminal() && self.supports_color()
98 }
99}
100
101impl TtyExt for io::Stdin {
102 fn is_tty_ext(&self) -> bool {
103 self.is_terminal()
104 }
105
106 fn supports_color(&self) -> bool {
107 // Stdin doesn't output color, but we check if it's interactive
108 self.is_terminal()
109 }
110
111 fn is_interactive(&self) -> bool {
112 self.is_terminal()
113 }
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119
120 #[test]
121 fn interactive_is_tty_and_color_for_output_streams() {
122 // Absolute values depend on the environment (pty vs pipe), so assert
123 // the contract relationship instead: interactive == tty && color.
124 for (interactive, tty, color) in [
125 (io::stdout().is_interactive(), io::stdout().is_tty_ext(), io::stdout().supports_color()),
126 (io::stderr().is_interactive(), io::stderr().is_tty_ext(), io::stderr().supports_color()),
127 ] {
128 assert_eq!(interactive, tty && color);
129 }
130 }
131
132 #[test]
133 fn stdin_answers_are_pure_tty_checks() {
134 // Stdin never emits color: its answers track raw TTY status rather
135 // than consulting NO_COLOR / FORCE_COLOR like the output streams.
136 assert_eq!(io::stdin().supports_color(), io::stdin().is_tty_ext());
137 assert_eq!(io::stdin().is_interactive(), io::stdin().is_tty_ext());
138 }
139}