Skip to content

Commit 6754cde

Browse files
authored
fix(core): drain stdin on exit to prevent escape sequence leakage (#34134)
## Current Behavior When exiting the TUI (especially via Ctrl+C), escape sequences leak to the terminal: ``` ^[]11;rgb:2121/2121/2121^[\^[[55;1R^[[?62;22;52c ``` This happens because the TUI queries terminal background color via OSC 11 to detect dark/light mode. The terminal responds with an escape sequence, but if the program exits before fully consuming the response, it appears in the terminal output. ## Expected Behavior Clean terminal state after TUI exits, with no escape sequence artifacts. ## Related Issue(s) N/A - discovered during development ## Solution Added `drain_stdin()` function that polls and consumes any pending terminal events before disabling raw mode. This clears any lingering OSC responses (like the background color query response) before the terminal is restored. ```rust fn drain_stdin() { use std::time::Duration; while crossterm::event::poll(Duration::from_millis(5)).unwrap_or(false) { let _ = crossterm::event::read(); } } ``` The 5ms timeout is long enough to catch pending responses but short enough not to noticeably delay exit.
1 parent 4202f2c commit 6754cde

3 files changed

Lines changed: 37 additions & 7 deletions

File tree

packages/nx/src/native/tui/lifecycle.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,10 @@ pub fn restore_terminal() -> napi::Result<()> {
571571
// Clear terminal progress indicator
572572
App::clear_terminal_progress();
573573

574+
// Drain pending terminal responses (e.g., OSC color query responses)
575+
// to prevent escape sequences from leaking to the terminal on exit
576+
super::tui::drain_stdin();
577+
574578
if crossterm::terminal::is_raw_mode_enabled()? {
575579
crossterm::execute!(
576580
std::io::stderr(),

packages/nx/src/native/tui/tui.rs

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use crate::native::tui::theme::THEME;
33
use color_eyre::eyre::Result;
44
use crossterm::{
55
cursor,
6-
event::{Event as CrosstermEvent, KeyEvent, KeyEventKind},
6+
event::{self, Event as CrosstermEvent, KeyEvent, KeyEventKind},
77
execute,
88
terminal::{EnterAlternateScreen, LeaveAlternateScreen},
99
};
@@ -53,6 +53,18 @@ pub struct Tui {
5353
pub current_mode: TuiMode,
5454
}
5555

56+
/// Drain any pending input from stdin to prevent escape sequence leakage.
57+
/// This consumes terminal responses (e.g., from OSC color queries) that may
58+
/// arrive after a query was sent but before the response was fully read.
59+
pub(crate) fn drain_stdin() {
60+
// Poll and consume any pending terminal events
61+
// Note: Duration::ZERO can incorrectly return false with use-dev-tty feature
62+
// See: https://github.com/crossterm-rs/crossterm/issues/839
63+
while event::poll(Duration::from_millis(5)).unwrap_or(false) {
64+
let _ = event::read();
65+
}
66+
}
67+
5668
impl Tui {
5769
pub fn new() -> Result<Self> {
5870
Self::new_with_mode(TuiMode::FullScreen)
@@ -75,15 +87,23 @@ impl Tui {
7587
// non-interactive environments, stdin is redirected so these queries hang.
7688
// By checking upfront, we avoid flakiness from deferred initialization.
7789
let inline_terminal = if Self::is_stdin_interactive() {
78-
let inline_height = crossterm::terminal::size()
79-
.map(|(_cols, rows)| rows)
80-
.unwrap_or(24);
81-
Some(ratatui::Terminal::with_options(
90+
let size = crossterm::terminal::size();
91+
debug!("Terminal size: {:?}", size);
92+
let inline_height = size.map(|(_cols, rows)| rows).unwrap_or(24);
93+
ratatui::Terminal::with_options(
8294
CrosstermBackend::new(std::io::stderr()),
8395
ratatui::TerminalOptions {
8496
viewport: ratatui::Viewport::Inline(inline_height),
8597
},
86-
)?)
98+
)
99+
.inspect(|_| {
100+
debug!(
101+
"Inline terminal created successfully with height {}",
102+
inline_height
103+
)
104+
})
105+
.inspect_err(|e| debug!("Inline terminal not created: {}", e))
106+
.ok()
87107
} else {
88108
debug!("Inline terminal not created: stdin is not a TTY");
89109
None
@@ -278,6 +298,10 @@ impl Tui {
278298
if crossterm::terminal::is_raw_mode_enabled()? {
279299
self.flush()?;
280300

301+
// Drain pending terminal responses (e.g., OSC color query responses)
302+
// to prevent escape sequences from leaking to the terminal on exit
303+
drain_stdin();
304+
281305
// Only leave alternate screen if we're in full-screen mode
282306
match self.current_mode {
283307
TuiMode::FullScreen => {

packages/nx/src/tasks-runner/is-tui-enabled.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,10 @@ export function shouldUseTui(
2929
skipCapabilityCheck = process.env.NX_TUI_SKIP_CAPABILITY_CHECK === 'true'
3030
) {
3131
// If the current terminal/environment is not capable of displaying the TUI, we don't run it
32+
const hasValidSize = process.stdout.columns > 0 && process.stdout.rows > 0;
3233
const isCapable =
33-
skipCapabilityCheck || (process.stderr.isTTY && isUnicodeSupported());
34+
skipCapabilityCheck ||
35+
(process.stderr.isTTY && isUnicodeSupported() && hasValidSize);
3436

3537
if (typeof nxArgs.tui === 'boolean') {
3638
if (nxArgs.tui && !isCapable) {

0 commit comments

Comments
 (0)