Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions packages/nx/src/native/pseudo_terminal/pseudo_terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,16 +91,42 @@ impl PseudoTerminal {
let quiet_clone = quiet.clone();
let running_clone = running.clone();

let parser = Arc::new(RwLock::new(Parser::new(h, w, 10000)));
// Scrollback size matches PtyInstance::SCROLLBACK_SIZE (1000 rows).
// Larger values make resize reparse and all_contents_formatted() more
// expensive, which blocks the parser write lock and starves rendering.
const SCROLLBACK_SIZE: usize = 1000;
// When raw output exceeds this threshold, compact the parser to prevent
// unbounded Vec growth. Without this, extend_from_slice() eventually
// triggers multi-hundred-millisecond reallocs under the write lock,
// causing the TUI to hang progressively worse as output accumulates.
const MAX_RAW_OUTPUT_BYTES: usize = 5 * 1024 * 1024; // 5 MB

let parser = Arc::new(RwLock::new(Parser::new(h, w, SCROLLBACK_SIZE)));
let parser_clone = parser.clone();
let stdout_tx_clone = stdout_tx.clone();
std::thread::spawn(move || {
let mut stdout = std::io::stdout();
let mut buf = [0; 8 * 1024];
// Local buffer for batching parser writes when inside the TUI.
// Under firehose output, reader.read() returns a full 8KB buffer
// on every call. Without batching, the parser write lock is
// acquired on every 8KB chunk, leaving almost no gap for the
// rendering thread's try_read(). By accumulating locally and
// only flushing when the read returns short (PTY caught up) or
// the buffer exceeds a threshold, we reduce lock acquisitions
// and give rendering predictable windows to read.
let mut pending_tui_buf: Vec<u8> = Vec::new();
const BATCH_THRESHOLD: usize = 64 * 1024;

'read_loop: loop {
if let Ok(len) = reader.read(&mut buf) {
if len == 0 {
// EOF — flush any remaining buffered data before exiting
if is_within_nx_tui && !pending_tui_buf.is_empty() {
let mut parser = parser_clone.write();
parser.process(&pending_tui_buf);
pending_tui_buf.clear();
}
break;
}
stdout_tx_clone
Expand All @@ -111,7 +137,34 @@ impl PseudoTerminal {
debug!("Read {} bytes", len);
if is_within_nx_tui {
trace!("Processing data via vt100 for use in tui");
parser_clone.write().process(&buf[..len]);
pending_tui_buf.extend_from_slice(&buf[..len]);
// Batch: only take the parser write lock when the PTY
// reader has caught up (short read) or we've accumulated
// enough data. Under firehose output, full-buffer reads
// (len == buf.len()) indicate more data is immediately
// available, so we defer processing. Short reads mean
// we've drained the PTY buffer and the next read will
// block, giving us a natural processing window.
let should_flush =
len < buf.len() || pending_tui_buf.len() >= BATCH_THRESHOLD;
if should_flush {
let mut parser = parser_clone.write();
parser.process(&pending_tui_buf);
pending_tui_buf.clear();
// Compact when raw output grows too large. Replays
// the formatted screen state (bounded by SCROLLBACK_SIZE)
// through a fresh parser, keeping raw_output small.
if parser.get_raw_output().len() > MAX_RAW_OUTPUT_BYTES {
let screen = parser.screen();
let (rows, cols) = screen.size();
let formatted = screen.all_contents_formatted();
let scrollback = screen.scrollback();
let mut compacted = Parser::new(rows, cols, SCROLLBACK_SIZE);
compacted.process(&formatted);
compacted.screen_mut().set_scrollback(scrollback);
*parser = compacted;
}
}
}

if !quiet {
Expand Down
16 changes: 6 additions & 10 deletions packages/nx/src/native/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1896,10 +1896,8 @@ impl App {
continue;
}

// With shared dimensions, we only need to call resize once per PTY instance
// The shared Arc<RwLock<(u16, u16)>> ensures all references see the update
let mut pty_clone = pty.as_ref().clone();
pty_clone.resize(pty_height, pty_width)?;
// Async resize avoids blocking the event loop for large terminal outputs
pty.resize_async(pty_height, pty_width);

// If dimensions changed, mark for sort
if current_rows != pty_height {
Expand Down Expand Up @@ -2087,10 +2085,9 @@ impl App {
allow_interactive && in_progress && pty.can_be_interactive();
terminal_pane_data.pty = Some(pty.clone());

// Resize PTY to match terminal pane dimensions
// Resize PTY to match terminal pane dimensions (async to avoid blocking render)
let (pty_height, pty_width) = TerminalPane::calculate_pty_dimensions(pane_area);
let mut pty_clone = pty.as_ref().clone();
pty_clone.resize(pty_height, pty_width).ok();
pty.resize_async(pty_height, pty_width);
} else {
terminal_pane_data.pty = None;
terminal_pane_data.can_be_interactive = false;
Expand Down Expand Up @@ -2321,15 +2318,14 @@ impl App {
if let Some(pty_instance) = state.get_pty_instance(&selection_id) {
self.terminal_pane_data[pane_idx].pty = Some(pty_instance.clone());

// Immediately resize PTY to match the current terminal pane dimensions
// Async resize PTY to match the current terminal pane dimensions
if let Some(pane_area) = self
.layout_areas
.as_ref()
.and_then(|la| la.terminal_panes.get(pane_idx))
{
let (pty_height, pty_width) = TerminalPane::calculate_pty_dimensions(*pane_area);
let mut pty_clone = pty_instance.as_ref().clone();
pty_clone.resize(pty_height, pty_width).ok();
pty_instance.resize_async(pty_height, pty_width);
}
} else {
self.terminal_pane_data[pane_idx].pty = None;
Expand Down
58 changes: 18 additions & 40 deletions packages/nx/src/native/tui/inline_app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,6 @@ pub struct InlineApp {
// === Scrollback Rendering ===
/// Track scrollback line count per task for incremental rendering
task_scrollback_lines: HashMap<String, usize>,
/// Track last rendered scrollback lines per task for buffered rendering
task_last_rendered_scrollback: HashMap<String, usize>,
/// Counter for buffering scrollback renders (render every 20th iteration)
scrollback_render_counter: u32,
/// Total lines inserted above TUI (for cleanup on exit)
Expand Down Expand Up @@ -126,7 +124,7 @@ impl InlineApp {
countdown_popup: CountdownPopup::new(),
is_interactive: false,
task_scrollback_lines: HashMap::new(),
task_last_rendered_scrollback: HashMap::new(),

scrollback_render_counter: 0,
total_inserted_lines: 0,
status_message: None,
Expand Down Expand Up @@ -156,7 +154,7 @@ impl InlineApp {
// Reset all scrollback tracking when mode switching
// PTYs will be resized in init(), which changes scrollback calculations
task_scrollback_lines: HashMap::new(),
task_last_rendered_scrollback: HashMap::new(),

// Start at 19 so the first render (increment to 20) will trigger scrollback rendering
// This ensures existing PTY content from full-screen mode is immediately displayed
scrollback_render_counter: 19,
Expand Down Expand Up @@ -239,11 +237,10 @@ impl InlineApp {
rows
);

// Clone the PTY instance, resize it, and replace the Arc
let mut pty_clone = pty_arc.as_ref().clone();
if pty_clone.resize(rows, cols).is_ok() {
state.register_pty_instance(task_id.to_string(), Arc::new(pty_clone));
}
// Async resize moves the expensive reparse off the event loop.
// Scrollback rendering is skipped until the resize completes
// (detected via is_resize_pending).
pty_arc.resize_async(rows, cols);
}

Some(())
Expand Down Expand Up @@ -615,8 +612,6 @@ impl TuiApp for InlineApp {
fn on_pty_registered(&mut self, task_id: &str) {
// Initialize scrollback tracking for inline mode
self.task_scrollback_lines.insert(task_id.to_string(), 0);
self.task_last_rendered_scrollback
.insert(task_id.to_string(), 0);
}

/// Override to resize interactive PTYs to inline dimensions
Expand Down Expand Up @@ -758,31 +753,22 @@ impl InlineApp {
let pty = pty.clone();
drop(state);

// Get last rendered scrollback line count for this task
let last_rendered_lines = self
.task_last_rendered_scrollback
.get(current_task)
.copied()
.unwrap_or(0);
const MAX_LINES_PER_RENDER: usize = 250;

// Get buffered scrollback content since last render
let buffered_scrollback_lines =
pty.get_buffered_scrollback_content_for_inline(last_rendered_lines);

// Update tracking for next buffered render
let current_scrollback_lines = pty.get_scrollback_line_count();
// Get buffered scrollback content produced by the background thread.
// The background thread tracks its own cursor into the scrollback
// region and appends new lines to pending_lines. We drain up to
// MAX_LINES_PER_RENDER per frame — the rest stay in pending_lines.
let (buffered_scrollback_lines, current_scrollback_lines) =
pty.get_buffered_scrollback_content_for_inline(MAX_LINES_PER_RENDER);

self.task_scrollback_lines
.insert(current_task.clone(), current_scrollback_lines);

// Render buffered scrollback above TUI using terminal.insert_before
// Render in batches to avoid overwhelming the terminal
if !buffered_scrollback_lines.is_empty() {
const MAX_LINES_PER_RENDER: usize = 250;

// Calculate how many lines to render this cycle
let lines_to_render = buffered_scrollback_lines.len().min(MAX_LINES_PER_RENDER);
let batch = &buffered_scrollback_lines[0..lines_to_render];
let lines_to_render = buffered_scrollback_lines.len();
let batch = &buffered_scrollback_lines[..];

use crate::native::tui::theme::THEME;
use ratatui::style::Style;
Expand All @@ -809,17 +795,10 @@ impl InlineApp {
// Track total lines inserted for cleanup on exit
self.total_inserted_lines += height as u32;

// Update last rendered count to reflect what we actually rendered
// This is incremental - we only advance by the batch size
let new_last_rendered = last_rendered_lines + lines_to_render;
self.task_last_rendered_scrollback
.insert(current_task.clone(), new_last_rendered);

tracing::trace!(
"render_scrollback_above_tui: Updated last_rendered from {} to {} (remaining: {})",
last_rendered_lines,
new_last_rendered,
current_scrollback_lines - new_last_rendered
"render_scrollback_above_tui: Rendered {} lines (total scrollback: {})",
lines_to_render,
current_scrollback_lines
);
} else {
tracing::error!(
Expand Down Expand Up @@ -1375,7 +1354,6 @@ mod tests {

// Verify scrollback tracking initialized
assert_eq!(app.task_scrollback_lines.get("app1"), Some(&0));
assert_eq!(app.task_last_rendered_scrollback.get("app1"), Some(&0));

// Verify PTY registered in state
let state_ref = app.get_state();
Expand Down
Loading
Loading