diff --git a/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs b/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs index 776abe7e20..cf8cee1270 100644 --- a/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs +++ b/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs @@ -111,8 +111,20 @@ public AnsiOutput (AppModel appModel = AppModel.FullScreen) } else if (PlatformDetection.IsUnixLike ()) { - // duplicate stdout so we don't mess with Console.Out's FD - int fdCopy = UnixIOHelper.dup (UnixIOHelper.STDOUT_FILENO); + // duplicate the controlling terminal output fd so we don't mess with it. + // When stdout is redirected this is /dev/tty rather than STDOUT_FILENO, + // allowing TUI rendering to appear on the real terminal even when the + // app's stdout participates in a shell pipeline. + int outputFd = TerminalDevice.OutputFd; + + if (outputFd < 0) + { + Trace.Lifecycle (nameof (AnsiOutput), "Init", "Console output stream is not writable. Running in degraded mode."); + + return; + } + + int fdCopy = UnixIOHelper.dup (outputFd); if (fdCopy == -1) { diff --git a/Terminal.Gui/Drivers/Driver.cs b/Terminal.Gui/Drivers/Driver.cs index 3e92150251..870aa79154 100644 --- a/Terminal.Gui/Drivers/Driver.cs +++ b/Terminal.Gui/Drivers/Driver.cs @@ -1,6 +1,4 @@ -using System.Runtime.InteropServices; - -namespace Terminal.Gui.Drivers; +namespace Terminal.Gui.Drivers; /// /// Holds global driver settings and cross-driver utility methods. @@ -45,18 +43,27 @@ public static bool Force16Colors public static SizeDetectionMode SizeDetection { get; set; } = SizeDetectionMode.AnsiQuery; /// - /// Determines whether the process is attached to a real terminal (i.e. stdin/stdout - /// are connected to a console device rather than redirected or running inside a test harness). Set the environment - /// variable "DisableRealDriverIO=1" to skip real terminal detection and force this method to return false, which is - /// required for running in test harnesses that do not have a real terminal attached. + /// Determines whether the process has a controlling terminal usable for TUI rendering and input. + /// Returns when either: + /// + /// stdin/stdout are connected to a console device, or + /// + /// stdin/stdout are redirected (e.g. via a shell pipeline such as + /// result=$(myapp) or myapp | jq) but a controlling terminal is available + /// via /dev/tty on Unix or CONIN$/CONOUT$ on Windows. + /// + /// + /// Set the environment variable DisableRealDriverIO=1 to skip real terminal detection and + /// force this method to return false, which is required for running in test harnesses that do not + /// have a real terminal attached. /// /// - /// When this method returns, if standard input is connected to a console device; - /// otherwise . + /// When this method returns, if a terminal device is available for input + /// (either stdin or the controlling terminal); otherwise . /// /// - /// When this method returns, if standard output is connected to a console device; - /// otherwise . + /// When this method returns, if a terminal device is available for output + /// (either stdout or the controlling terminal); otherwise . /// /// if both input and output are attached to a terminal; otherwise . public static bool IsAttachedToTerminal (out bool inputAttached, out bool outputAttached) @@ -69,13 +76,8 @@ public static bool IsAttachedToTerminal (out bool inputAttached, out bool output return false; } - if (RuntimeInformation.IsOSPlatform (OSPlatform.Windows)) - { - return WindowsConsoleHelper.IsAttachedToTerminal (out inputAttached, out outputAttached); - } - - inputAttached = UnixIOHelper.IsTerminal (UnixIOHelper.STDIN_FILENO); - outputAttached = UnixIOHelper.IsTerminal (UnixIOHelper.STDOUT_FILENO); + inputAttached = TerminalDevice.IsInputAttached; + outputAttached = TerminalDevice.IsOutputAttached; return inputAttached && outputAttached; } diff --git a/Terminal.Gui/Drivers/TerminalDevice.cs b/Terminal.Gui/Drivers/TerminalDevice.cs new file mode 100644 index 0000000000..839384a2f9 --- /dev/null +++ b/Terminal.Gui/Drivers/TerminalDevice.cs @@ -0,0 +1,467 @@ +using System.Runtime.InteropServices; + +// ReSharper disable IdentifierTypo +// ReSharper disable StringLiteralTypo +// ReSharper disable InconsistentNaming + +namespace Terminal.Gui.Drivers; + +/// +/// Resolves the controlling terminal device for input and output, preferring the standard +/// streams (stdin/stdout) when they are connected to a terminal, and falling back to the +/// controlling TTY (/dev/tty on Unix, CONIN$/CONOUT$ on Windows) when +/// either stream is redirected. +/// +/// +/// +/// Tools such as fzf, gum, and dialog use this technique so a TUI +/// can still render and read input even when the application's stdout or stdin participates +/// in a shell pipeline (e.g. result=$(myapp) or myapp | jq). +/// +/// +/// All members are lazily initialized and cached for the lifetime of the process. +/// +/// +internal static class TerminalDevice +{ + private static readonly Lock _lock = new (); + private static bool _initialized; + + // Unix-side resolved file descriptors. -1 means "no terminal device available". + private static int _inputFd = -1; + private static int _outputFd = -1; + + // Did we open /dev/tty ourselves (so we should close it on dispose)? + private static int _ownedInputFd = -1; + private static int _ownedOutputFd = -1; + + // Windows-side resolved handles. nint.Zero means "no terminal device available". + private static nint _inputHandle = nint.Zero; + private static nint _outputHandle = nint.Zero; + + // Did we open CONIN$/CONOUT$ ourselves (so we should close them on dispose)? + private static nint _ownedInputHandle = nint.Zero; + private static nint _ownedOutputHandle = nint.Zero; + + private static bool _inputAttached; + private static bool _outputAttached; + + /// + /// Gets a Unix file descriptor that can be used to read terminal input. + /// Returns when stdin is a tty, the fd of an + /// opened /dev/tty when stdin is redirected but a controlling terminal exists, + /// or -1 when no terminal device is available. + /// + public static int InputFd + { + get + { + EnsureInitialized (); + + return _inputFd; + } + } + + /// + /// Gets a Unix file descriptor that can be used to write terminal output. + /// Returns when stdout is a tty, the fd of an + /// opened /dev/tty when stdout is redirected but a controlling terminal exists, + /// or -1 when no terminal device is available. + /// + public static int OutputFd + { + get + { + EnsureInitialized (); + + return _outputFd; + } + } + + /// + /// Gets a Windows handle that can be used to read terminal input. Returns the standard + /// input handle when stdin is a console, a handle opened via CONIN$ when stdin is + /// redirected but a console exists, or when no console is + /// available. + /// + public static nint InputHandle + { + get + { + EnsureInitialized (); + + return _inputHandle; + } + } + + /// + /// Gets a Windows handle that can be used to write terminal output. Returns the standard + /// output handle when stdout is a console, a handle opened via CONOUT$ when stdout + /// is redirected but a console exists, or when no console is + /// available. + /// + public static nint OutputHandle + { + get + { + EnsureInitialized (); + + return _outputHandle; + } + } + + /// + /// Gets whether a terminal input device (the standard input or /dev/tty/CONIN$) + /// is available. + /// + public static bool IsInputAttached + { + get + { + EnsureInitialized (); + + return _inputAttached; + } + } + + /// + /// Gets whether a terminal output device (the standard output or /dev/tty/CONOUT$) + /// is available. + /// + public static bool IsOutputAttached + { + get + { + EnsureInitialized (); + + return _outputAttached; + } + } + + /// + /// Resets the cached terminal device state so the next access re-resolves it. + /// Intended for testing. + /// + internal static void ResetForTesting () + { + lock (_lock) + { + CloseOwnedHandles (); + + _initialized = false; + _inputFd = -1; + _outputFd = -1; + _ownedInputFd = -1; + _ownedOutputFd = -1; + _inputHandle = nint.Zero; + _outputHandle = nint.Zero; + _ownedInputHandle = nint.Zero; + _ownedOutputHandle = nint.Zero; + _inputAttached = false; + _outputAttached = false; + } + } + + private static void EnsureInitialized () + { + if (_initialized) + { + return; + } + + lock (_lock) + { + if (_initialized) + { + return; + } + + // When the test harness sets DisableRealDriverIO, skip real terminal detection entirely. + if (string.Equals (Environment.GetEnvironmentVariable ("DisableRealDriverIO"), "1", StringComparison.Ordinal)) + { + _initialized = true; + + return; + } + + try + { + if (RuntimeInformation.IsOSPlatform (OSPlatform.Windows)) + { + InitializeWindows (); + } + else + { + InitializeUnix (); + } + } + catch + { + // Best effort: any failure leaves us in the "no terminal" state. + } + + _initialized = true; + + // Make sure any owned descriptors get closed when the process exits. + try + { + AppDomain.CurrentDomain.ProcessExit += (_, _) => CloseOwnedHandles (); + } + catch + { + // ignore + } + } + } + + private static void InitializeUnix () + { + // Prefer the standard streams when they are connected to a terminal. + if (UnixIOHelper.IsTerminal (UnixIOHelper.STDIN_FILENO)) + { + _inputFd = UnixIOHelper.STDIN_FILENO; + _inputAttached = true; + } + + if (UnixIOHelper.IsTerminal (UnixIOHelper.STDOUT_FILENO)) + { + _outputFd = UnixIOHelper.STDOUT_FILENO; + _outputAttached = true; + } + + // If either side is missing, try to open the controlling terminal directly. + if (_inputFd != -1 && _outputFd != -1) + { + return; + } + + try + { + int ttyFd = open ("/dev/tty", O_RDWR | O_NOCTTY); + + if (ttyFd < 0) + { + return; + } + + // Verify it is actually a terminal. + if (!UnixIOHelper.IsTerminal (ttyFd)) + { + _ = close (ttyFd); + + return; + } + + // Since we reached this point, at least one of (_inputFd, _outputFd) is -1 (the + // early return above would have fired otherwise), guaranteeing that ttyFd is + // assigned below. + if (_inputFd == -1) + { + // stdin was redirected: claim the /dev/tty fd we just opened for input. + _inputFd = ttyFd; + _ownedInputFd = ttyFd; + _inputAttached = true; + } + + if (_outputFd == -1) + { + if (_ownedInputFd == ttyFd) + { + // We opened /dev/tty for input above; reuse the same fd for write — it was + // opened O_RDWR and we avoid burning a second fd on the same device. + _outputFd = ttyFd; + _outputAttached = true; + } + else + { + // stdin was already a real terminal, so ttyFd was not claimed for input. + // Claim it for output now (this is the `myapp | jq` case). + _outputFd = ttyFd; + _ownedOutputFd = ttyFd; + _outputAttached = true; + } + } + } + catch (DllNotFoundException) + { + // libc not available; nothing more we can do. + } + } + + private static void InitializeWindows () + { + nint stdIn = GetStdHandle (STD_INPUT_HANDLE); + nint stdOut = GetStdHandle (STD_OUTPUT_HANDLE); + + if (stdIn != nint.Zero && stdIn != new nint (-1) && GetConsoleMode (stdIn, out _)) + { + _inputHandle = stdIn; + _inputAttached = true; + } + + if (stdOut != nint.Zero && stdOut != new nint (-1) && GetConsoleMode (stdOut, out _)) + { + _outputHandle = stdOut; + _outputAttached = true; + } + + if (_inputHandle != nint.Zero && _outputHandle != nint.Zero) + { + return; + } + + // Fall back to opening the console directly. + if (_inputHandle == nint.Zero) + { + nint h = CreateFile ( + "CONIN$", + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + nint.Zero, + OPEN_EXISTING, + 0, + nint.Zero); + + if (h != new nint (-1) && h != nint.Zero && GetConsoleMode (h, out _)) + { + _inputHandle = h; + _ownedInputHandle = h; + _inputAttached = true; + } + else if (h != new nint (-1) && h != nint.Zero) + { + _ = CloseHandle (h); + } + } + + if (_outputHandle == nint.Zero) + { + nint h = CreateFile ( + "CONOUT$", + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + nint.Zero, + OPEN_EXISTING, + 0, + nint.Zero); + + if (h != new nint (-1) && h != nint.Zero && GetConsoleMode (h, out _)) + { + _outputHandle = h; + _ownedOutputHandle = h; + _outputAttached = true; + } + else if (h != new nint (-1) && h != nint.Zero) + { + _ = CloseHandle (h); + } + } + } + + private static void CloseOwnedHandles () + { + if (_ownedInputFd != -1) + { + try + { + _ = close (_ownedInputFd); + } + catch + { + // ignore + } + + _ownedInputFd = -1; + } + + // Guard against double-closing: when stdin and stdout both fall back to /dev/tty we + // share a single fd between them, so it is recorded as the owned fd for both ends. + // _ownedInputFd was already closed above (and reset to -1 there), so we only need to + // close _ownedOutputFd when it refers to a distinct descriptor. + if (_ownedOutputFd != -1 && _ownedOutputFd != _ownedInputFd) + { + try + { + _ = close (_ownedOutputFd); + } + catch + { + // ignore + } + + _ownedOutputFd = -1; + } + + if (_ownedInputHandle != nint.Zero) + { + try + { + _ = CloseHandle (_ownedInputHandle); + } + catch + { + // ignore + } + + _ownedInputHandle = nint.Zero; + } + + if (_ownedOutputHandle != nint.Zero) + { + try + { + _ = CloseHandle (_ownedOutputHandle); + } + catch + { + // ignore + } + + _ownedOutputHandle = nint.Zero; + } + } + + #region P/Invoke (Unix) + + private const int O_RDWR = 2; + private const int O_NOCTTY = 0x100; + + [DllImport ("libc", SetLastError = true)] + private static extern int open (string path, int oflag); + + [DllImport ("libc", SetLastError = true)] + private static extern int close (int fd); + + #endregion + + #region P/Invoke (Windows) + + private const int STD_INPUT_HANDLE = -10; + private const int STD_OUTPUT_HANDLE = -11; + private const uint GENERIC_READ = 0x80000000; + private const uint GENERIC_WRITE = 0x40000000; + private const uint FILE_SHARE_READ = 0x00000001; + private const uint FILE_SHARE_WRITE = 0x00000002; + private const uint OPEN_EXISTING = 3; + + [DllImport ("kernel32.dll", SetLastError = true)] + private static extern nint GetStdHandle (int nStdHandle); + + [DllImport ("kernel32.dll")] + private static extern bool GetConsoleMode (nint hConsoleHandle, out uint lpMode); + + [DllImport ("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern nint CreateFile ( + string lpFileName, + uint dwDesiredAccess, + uint dwShareMode, + nint lpSecurityAttributes, + uint dwCreationDisposition, + uint dwFlagsAndAttributes, + nint hTemplateFile); + + [DllImport ("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle (nint hObject); + + #endregion +} diff --git a/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs b/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs index 410e68c537..6126fe6bc4 100644 --- a/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs +++ b/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs @@ -245,7 +245,7 @@ public static bool IsInputAvailable (Pollfd [] pollMap, int timeoutMs = 0) } /// - /// Reads bytes from stdin. + /// Reads bytes from stdin (or the controlling TTY when stdin is redirected). /// /// Buffer to read into /// Number of bytes actually read @@ -254,7 +254,16 @@ public static bool TryReadStdin (byte [] buffer, out int bytesRead) { try { - bytesRead = read (STDIN_FILENO, buffer, buffer.Length); + int fd = TerminalDevice.InputFd; + + if (fd < 0) + { + bytesRead = 0; + + return false; + } + + bytesRead = read (fd, buffer, buffer.Length); return bytesRead >= 0; } @@ -267,7 +276,7 @@ public static bool TryReadStdin (byte [] buffer, out int bytesRead) } /// - /// Writes bytes to stdout. + /// Writes bytes to stdout (or the controlling TTY when stdout is redirected). /// /// Buffer containing data to write /// True if write was successful, false otherwise @@ -275,7 +284,14 @@ public static bool TryWriteStdout (byte [] buffer) { try { - int written = write (STDOUT_FILENO, buffer, buffer.Length); + int fd = TerminalDevice.OutputFd; + + if (fd < 0) + { + return false; + } + + int written = write (fd, buffer, buffer.Length); return written >= 0; } @@ -305,14 +321,21 @@ public static bool TryWriteStdout (string text) } /// - /// Flushes the stdin input queue. + /// Flushes the stdin (or controlling TTY) input queue. /// /// True if flush was successful, false otherwise public static bool TryFlushStdin () { try { - return tcflush (STDIN_FILENO, TCIFLUSH) == 0; + int fd = TerminalDevice.InputFd; + + if (fd < 0) + { + return false; + } + + return tcflush (fd, TCIFLUSH) == 0; } catch { @@ -321,12 +344,19 @@ public static bool TryFlushStdin () } /// - /// Waits until all output written to stdout has been transmitted to the terminal. + /// Waits until all output written to stdout (or the controlling TTY) has been transmitted to the terminal. /// Prefers tcdrain; falls back to fsync. /// public static void FlushStdout () { - if (tcdrain (STDOUT_FILENO) == 0) + int fd = TerminalDevice.OutputFd; + + if (fd < 0) + { + return; + } + + if (tcdrain (fd) == 0) { return; } @@ -334,7 +364,7 @@ public static void FlushStdout () // fallback try { - fsync (STDOUT_FILENO); + fsync (fd); } catch { @@ -350,7 +380,7 @@ public static void FlushStdout () public static bool IsTerminal (int fd) => isatty (fd) == 1; /// - /// Gets the terminal size using ioctl. + /// Gets the terminal size using ioctl on the controlling output device. /// /// Output size (width, height) /// True if size was retrieved successfully, false otherwise @@ -358,13 +388,22 @@ public static bool TryGetTerminalSize (out Size size) { try { + int fd = TerminalDevice.OutputFd; + + if (fd < 0) + { + size = new Size (80, 25); + + return false; + } + var ioctlResult = 0; WinSize ws; if (RuntimeInformation.OSArchitecture == Architecture.Arm64 && (RuntimeInformation.IsOSPlatform (OSPlatform.OSX) || RuntimeInformation.IsOSPlatform (OSPlatform.FreeBSD))) { - ioctlResult = ioctl_arm64 (STDOUT_FILENO, + ioctlResult = ioctl_arm64 (fd, TIOCGWINSZ, 0, 0, @@ -376,7 +415,7 @@ public static bool TryGetTerminalSize (out Size size) } else { - ioctlResult = ioctl (STDOUT_FILENO, TIOCGWINSZ, out ws); + ioctlResult = ioctl (fd, TIOCGWINSZ, out ws); } if (ioctlResult == 0) @@ -400,13 +439,21 @@ public static bool TryGetTerminalSize (out Size size) } /// - /// Creates a poll map for monitoring stdin. + /// Creates a poll map for monitoring terminal input (stdin or the controlling TTY when stdin is redirected). /// + /// + /// When reports no terminal device (-1), this method + /// still returns a poll map populated with rather than returning + /// . The fd will be invalid and poll will report + /// POLLNVAL, so no input will be consumed; this preserves the non-null contract + /// callers (e.g. AnsiInput) rely on for the lifetime of the input loop. + /// /// Initialized Pollfd array public static Pollfd [] CreateStdinPollMap () { Pollfd [] pollMap = new Pollfd [1]; - pollMap [0].fd = STDIN_FILENO; + int fd = TerminalDevice.InputFd; + pollMap [0].fd = fd >= 0 ? fd : STDIN_FILENO; pollMap [0].events = (short)Condition.PollIn; return pollMap; diff --git a/Terminal.Gui/Drivers/UnixHelpers/UnixRawModeHelper.cs b/Terminal.Gui/Drivers/UnixHelpers/UnixRawModeHelper.cs index 1b48a6d17f..e24994c301 100644 --- a/Terminal.Gui/Drivers/UnixHelpers/UnixRawModeHelper.cs +++ b/Terminal.Gui/Drivers/UnixHelpers/UnixRawModeHelper.cs @@ -37,6 +37,7 @@ internal sealed class UnixRawModeHelper : IDisposable private Termios _originalTermios; private bool _haveSavedTermios; private bool _disposed; + private int _termiosFd = -1; private EventHandler? _processExitHandler; private ConsoleCancelEventHandler? _cancelKeyHandler; @@ -64,8 +65,22 @@ public bool TryEnable () try { + // Use the controlling terminal input fd: when stdin is redirected (e.g. `myapp | jq`) + // this is /dev/tty rather than STDIN_FILENO, so termios settings still apply to the + // real terminal device. + int fd = TerminalDevice.InputFd; + + if (fd < 0) + { + Logging.Warning ("No terminal input device available. Cannot enable raw mode."); + + return false; + } + + _termiosFd = fd; + // Get current terminal attributes - int result = tcgetattr (STDIN_FILENO, out _originalTermios); + int result = tcgetattr (_termiosFd, out _originalTermios); if (result != 0) { @@ -99,7 +114,7 @@ public bool TryEnable () } // Apply raw mode settings - result = tcsetattr (STDIN_FILENO, TCSANOW, ref raw); + result = tcsetattr (_termiosFd, TCSANOW, ref raw); if (result != 0) { @@ -151,7 +166,7 @@ public void Restore () try { - int result = tcsetattr (STDIN_FILENO, TCSANOW, ref _originalTermios); + int result = tcsetattr (_termiosFd, TCSANOW, ref _originalTermios); if (result != 0) { diff --git a/Terminal.Gui/Drivers/WindowsHelpers/WindowsVTInputHelper.cs b/Terminal.Gui/Drivers/WindowsHelpers/WindowsVTInputHelper.cs index 7fcc78be49..a122dfeac3 100644 --- a/Terminal.Gui/Drivers/WindowsHelpers/WindowsVTInputHelper.cs +++ b/Terminal.Gui/Drivers/WindowsHelpers/WindowsVTInputHelper.cs @@ -31,9 +31,6 @@ internal sealed class WindowsVTInputHelper : IDisposable // to use GetNumberOfConsoleInputEvents to poll for availability. With such APIs, this helper class would be unnecessary. // If this were the case, the only API the ANSI driver would require on Windows is GetStdHandle and ReadFile. - [DllImport ("kernel32.dll", SetLastError = true)] - private static extern nint GetStdHandle (int nStdHandle); - [DllImport ("kernel32.dll")] private static extern bool GetConsoleMode (nint hConsoleHandle, out uint lpMode); @@ -54,7 +51,6 @@ internal sealed class WindowsVTInputHelper : IDisposable #endregion // Console mode flags - private const int STD_INPUT_HANDLE = -10; private const uint ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200; private const uint ENABLE_PROCESSED_INPUT = 0x0001; private const uint ENABLE_LINE_INPUT = 0x0002; @@ -110,7 +106,10 @@ public bool TryEnable () try { - InputHandle = GetStdHandle (STD_INPUT_HANDLE); + // Use the controlling terminal input handle: when stdin is redirected (e.g. piping + // input into the app) this is the handle obtained from CONIN$ rather than the + // standard input handle, so VT input still reaches the real console. + InputHandle = TerminalDevice.InputHandle; if (InputHandle == nint.Zero || InputHandle == new nint (-1)) { diff --git a/Terminal.Gui/Drivers/WindowsHelpers/WindowsVTOutputHelper.cs b/Terminal.Gui/Drivers/WindowsHelpers/WindowsVTOutputHelper.cs index d9eddfa2f4..de89005b42 100644 --- a/Terminal.Gui/Drivers/WindowsHelpers/WindowsVTOutputHelper.cs +++ b/Terminal.Gui/Drivers/WindowsHelpers/WindowsVTOutputHelper.cs @@ -20,10 +20,6 @@ internal sealed class WindowsVTOutputHelper : IDisposable private const uint ENABLE_VIRTUAL_TERMINAL_PROCESSING = 4; private const uint ENABLE_PROCESSED_OUTPUT = 1; - private const int STD_OUTPUT_HANDLE = -11; - - [DllImport ("kernel32.dll", SetLastError = true)] - private static extern nint GetStdHandle (int nStdHandle); [DllImport ("kernel32.dll")] private static extern bool SetConsoleMode (nint hConsoleHandle, uint dwMode); @@ -81,7 +77,10 @@ public bool TryEnable () try { - OutputHandle = GetStdHandle (STD_OUTPUT_HANDLE); + // Use the controlling terminal output handle: when stdout is redirected (e.g. + // `myapp | jq`) this is the handle obtained from CONOUT$ rather than the standard + // output handle, so VT sequences still reach the real console. + OutputHandle = TerminalDevice.OutputHandle; if (OutputHandle == nint.Zero || OutputHandle == new nint (-1)) { @@ -177,11 +176,12 @@ public void Write (StringBuilder output) } /// - /// Flushes the stdout handle via FlushFileBuffers. + /// Flushes the controlling console output handle via FlushFileBuffers. + /// No-op when no terminal output device is available. /// public static void FlushStdout () { - nint h = GetStdHandle (STD_OUTPUT_HANDLE); + nint h = TerminalDevice.OutputHandle; if (h != nint.Zero && h != new nint (-1)) { diff --git a/Tests/UnitTestsParallelizable/Drivers/TerminalDeviceTests.cs b/Tests/UnitTestsParallelizable/Drivers/TerminalDeviceTests.cs new file mode 100644 index 0000000000..a94fb45ba4 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Drivers/TerminalDeviceTests.cs @@ -0,0 +1,158 @@ +#nullable enable +using Terminal.Gui.Drivers; + +namespace DriverTests; + +/// +/// Tests for — the helper that resolves the controlling terminal +/// device for input/output, falling back to /dev/tty (Unix) or CONIN$/CONOUT$ +/// (Windows) when stdin/stdout are redirected. +/// +[Collection ("Driver Tests")] +public class TerminalDeviceTests (ITestOutputHelper output) +{ + private readonly ITestOutputHelper _output = output; + + [Fact] + // Copilot + public void IsInputAttached_And_IsOutputAttached_AreFalse_WhenDisableRealDriverIO () + { + // Arrange — emulate the test-harness environment used elsewhere in the repo. + string? prev = Environment.GetEnvironmentVariable ("DisableRealDriverIO"); + + try + { + Environment.SetEnvironmentVariable ("DisableRealDriverIO", "1"); + TerminalDevice.ResetForTesting (); + + // Act + bool inputAttached = TerminalDevice.IsInputAttached; + bool outputAttached = TerminalDevice.IsOutputAttached; + + // Assert — when the harness disables real driver IO, no terminal device is + // ever returned, so the AnsiDriver stays in degraded mode in CI. + Assert.False (inputAttached); + Assert.False (outputAttached); + Assert.Equal (-1, TerminalDevice.InputFd); + Assert.Equal (-1, TerminalDevice.OutputFd); + Assert.Equal (nint.Zero, TerminalDevice.InputHandle); + Assert.Equal (nint.Zero, TerminalDevice.OutputHandle); + } + finally + { + Environment.SetEnvironmentVariable ("DisableRealDriverIO", prev); + TerminalDevice.ResetForTesting (); + } + } + + [Fact] + // Copilot + public void Driver_IsAttachedToTerminal_ReturnsFalse_WhenDisableRealDriverIO () + { + // Arrange — Driver.IsAttachedToTerminal must continue to honour the harness override + // even after routing through TerminalDevice. + string? prev = Environment.GetEnvironmentVariable ("DisableRealDriverIO"); + + try + { + Environment.SetEnvironmentVariable ("DisableRealDriverIO", "1"); + TerminalDevice.ResetForTesting (); + + // Act + bool result = Driver.IsAttachedToTerminal (out bool inputAttached, out bool outputAttached); + + // Assert + Assert.False (result); + Assert.False (inputAttached); + Assert.False (outputAttached); + } + finally + { + Environment.SetEnvironmentVariable ("DisableRealDriverIO", prev); + TerminalDevice.ResetForTesting (); + } + } + + [Fact] + // Copilot + public void ResetForTesting_ClearsCachedState () + { + // Arrange — populate the cache once. + TerminalDevice.ResetForTesting (); + bool _ = TerminalDevice.IsInputAttached; + + // Act — reset, then change the env var and re-resolve to ensure values are not cached + // across resets. + string? prev = Environment.GetEnvironmentVariable ("DisableRealDriverIO"); + + try + { + Environment.SetEnvironmentVariable ("DisableRealDriverIO", "1"); + TerminalDevice.ResetForTesting (); + + // Assert — after reset+disable, lookups return the disabled state. + Assert.False (TerminalDevice.IsInputAttached); + Assert.False (TerminalDevice.IsOutputAttached); + } + finally + { + Environment.SetEnvironmentVariable ("DisableRealDriverIO", prev); + TerminalDevice.ResetForTesting (); + } + } + + [Fact] + // Copilot + public void TryWriteStdout_ReturnsFalse_WhenNoTerminalDevice () + { + // Arrange + string? prev = Environment.GetEnvironmentVariable ("DisableRealDriverIO"); + + try + { + Environment.SetEnvironmentVariable ("DisableRealDriverIO", "1"); + TerminalDevice.ResetForTesting (); + + // Act — TryWriteStdout must gracefully no-op when no terminal device is available + // rather than writing to fd 1 (which would corrupt the redirected stdout stream). + bool result = UnixIOHelper.TryWriteStdout ([0x41, 0x42, 0x43]); + + // Assert + Assert.False (result); + } + finally + { + Environment.SetEnvironmentVariable ("DisableRealDriverIO", prev); + TerminalDevice.ResetForTesting (); + } + } + + [Fact] + // Copilot + public void TryReadStdin_ReturnsFalse_WhenNoTerminalDevice () + { + // Arrange + string? prev = Environment.GetEnvironmentVariable ("DisableRealDriverIO"); + + try + { + Environment.SetEnvironmentVariable ("DisableRealDriverIO", "1"); + TerminalDevice.ResetForTesting (); + byte [] buffer = new byte [16]; + + // Act + bool result = UnixIOHelper.TryReadStdin (buffer, out int bytesRead); + + // Assert — TryReadStdin must not silently read from STDIN_FILENO when no terminal + // device is available, otherwise we would consume bytes intended for the app's + // redirected stdin pipeline (e.g. `echo foo | myapp`). + Assert.False (result); + Assert.Equal (0, bytesRead); + } + finally + { + Environment.SetEnvironmentVariable ("DisableRealDriverIO", prev); + TerminalDevice.ResetForTesting (); + } + } +}