fix(core): make running a single task more transparent (#31163)

<!-- Please make sure you have read the submission guidelines before
posting an PR -->
<!--
https://github.com/nrwl/nx/blob/master/CONTRIBUTING.md#-submitting-a-pr
-->

<!-- Please make sure that your commit message follows our format -->
<!-- Example: `fix(nx): must begin with lowercase` -->

<!-- If this is a particularly complex change or feature addition, you
can request a dedicated Nx release for this pull request branch. Mention
someone from the Nx team or the `@nrwl/nx-pipelines-reviewers` and they
will confirm if the PR warrants its own release for testing purposes,
and generate it for you if appropriate. -->

## Current Behavior
<!-- This is the behavior we have today -->

Running a single task is in this uncanny framed state and doesn't exit
immediately.

<img width="1317" alt="image"
src="https://github.com/user-attachments/assets/13e7463f-9eb0-48db-95f2-c09e203d494f"
/>

## Expected Behavior
<!-- This is the behavior we should expect with the changes in this PR
-->

Running a single task isn't framed and exits immediately.

This also fixes scrolling in interactive mode based on whether or not
the underlying terminal is in application cursor mode or not.


![image](https://github.com/user-attachments/assets/c32c85e7-edd6-4aba-9a15-bc75cc9ee5ba)


## Related Issue(s)
<!-- Please link the issue being fixed so it gets closed when this is
merged. -->

Fixes #

---------

Co-authored-by: JamesHenry <james@henry.sc>
This commit is contained in:
Jason Jean 2025-05-13 17:09:39 -04:00 committed by GitHub
parent a31226437e
commit 3d73fd30a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 197 additions and 130 deletions

View File

@ -1,5 +1,5 @@
use color_eyre::eyre::Result; use color_eyre::eyre::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEventKind}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
use hashbrown::HashSet; use hashbrown::HashSet;
use napi::bindgen_prelude::External; use napi::bindgen_prelude::External;
use napi::threadsafe_function::{ErrorStrategy, ThreadsafeFunction}; use napi::threadsafe_function::{ErrorStrategy, ThreadsafeFunction};
@ -214,14 +214,22 @@ impl App {
return; return;
} }
if self.tasks.len() > 1 {
self.begin_exit_countdown() self.begin_exit_countdown()
} else {
self.quit();
}
}
fn quit(&mut self) {
self.quit_at = Some(std::time::Instant::now());
} }
fn begin_exit_countdown(&mut self) { fn begin_exit_countdown(&mut self) {
let countdown_duration = self.tui_config.auto_exit.countdown_seconds(); let countdown_duration = self.tui_config.auto_exit.countdown_seconds();
// If countdown is disabled, exit immediately // If countdown is disabled, exit immediately
if countdown_duration.is_none() { if countdown_duration.is_none() {
self.quit_at = Some(std::time::Instant::now()); self.quit();
return; return;
} }
@ -708,27 +716,32 @@ impl App {
self.user_has_interacted = true; self.user_has_interacted = true;
} }
if matches!(self.focus, Focus::MultipleOutput(_)) {
self.handle_mouse_event(mouse).ok();
return Ok(false);
}
match mouse.kind { match mouse.kind {
MouseEventKind::ScrollUp => { MouseEventKind::ScrollUp => {
if matches!(self.focus, Focus::MultipleOutput(_)) { if matches!(self.focus, Focus::TaskList) {
self.dispatch_action(Action::PreviousTask);
} else {
self.handle_key_event(KeyEvent::new( self.handle_key_event(KeyEvent::new(
KeyCode::Up, KeyCode::Up,
KeyModifiers::empty(), KeyModifiers::empty(),
)) ))
.ok(); .ok();
} else if matches!(self.focus, Focus::TaskList) {
self.dispatch_action(Action::PreviousTask);
} }
} }
MouseEventKind::ScrollDown => { MouseEventKind::ScrollDown => {
if matches!(self.focus, Focus::MultipleOutput(_)) { if matches!(self.focus, Focus::TaskList) {
self.dispatch_action(Action::NextTask);
} else {
self.handle_key_event(KeyEvent::new( self.handle_key_event(KeyEvent::new(
KeyCode::Down, KeyCode::Down,
KeyModifiers::empty(), KeyModifiers::empty(),
)) ))
.ok(); .ok();
} else if matches!(self.focus, Focus::TaskList) {
self.dispatch_action(Action::NextTask);
} }
} }
_ => {} _ => {}
@ -790,6 +803,7 @@ impl App {
} }
let frame_area = self.frame_area.unwrap(); let frame_area = self.frame_area.unwrap();
let tasks_list_hidden = self.is_task_list_hidden();
let layout_areas = self.layout_areas.as_mut().unwrap(); let layout_areas = self.layout_areas.as_mut().unwrap();
if self.debug_mode { if self.debug_mode {
@ -970,6 +984,7 @@ impl App {
); );
let terminal_pane = TerminalPane::new() let terminal_pane = TerminalPane::new()
.minimal(tasks_list_hidden && self.tasks.len() == 1)
.pty_data(terminal_pane_data) .pty_data(terminal_pane_data)
.continuous(task.continuous); .continuous(task.continuous);
@ -1330,6 +1345,15 @@ impl App {
let _ = self.handle_pty_resize(); let _ = self.handle_pty_resize();
} }
fn handle_mouse_event(&mut self, mouse_event: MouseEvent) -> io::Result<()> {
if let Focus::MultipleOutput(pane_idx) = self.focus {
let terminal_pane_data = &mut self.terminal_pane_data[pane_idx];
terminal_pane_data.handle_mouse_event(mouse_event)
} else {
Ok(())
}
}
/// Forward key events to the currently focused pane, if any. /// Forward key events to the currently focused pane, if any.
fn handle_key_event(&mut self, key: KeyEvent) -> io::Result<()> { fn handle_key_event(&mut self, key: KeyEvent) -> io::Result<()> {
if let Focus::MultipleOutput(pane_idx) = self.focus { if let Focus::MultipleOutput(pane_idx) = self.focus {

View File

@ -138,7 +138,7 @@ impl LayoutManager {
self.mode = match self.mode { self.mode = match self.mode {
LayoutMode::Auto => { LayoutMode::Auto => {
// If we are in auto mode, we need to figure out our current orientation and set the mode to the opposite. // If we are in auto mode, we need to figure out our current orientation and set the mode to the opposite.
if self.is_vertical_layout_preferred(area.width, area.height, self.task_count) { if self.is_vertical_layout_preferred(area.width, area.height) {
LayoutMode::Horizontal LayoutMode::Horizontal
} else { } else {
LayoutMode::Vertical LayoutMode::Vertical
@ -249,9 +249,7 @@ impl LayoutManager {
fn calculate_layout_visible_task_list(&self, area: Rect) -> LayoutAreas { fn calculate_layout_visible_task_list(&self, area: Rect) -> LayoutAreas {
// Determine whether to use vertical or horizontal layout // Determine whether to use vertical or horizontal layout
let use_vertical = match self.mode { let use_vertical = match self.mode {
LayoutMode::Auto => { LayoutMode::Auto => self.is_vertical_layout_preferred(area.width, area.height),
self.is_vertical_layout_preferred(area.width, area.height, self.task_count)
}
LayoutMode::Vertical => true, LayoutMode::Vertical => true,
LayoutMode::Horizontal => false, LayoutMode::Horizontal => false,
}; };
@ -401,17 +399,7 @@ impl LayoutManager {
/// - Terminal aspect ratio /// - Terminal aspect ratio
/// - Number of tasks (single task prefers vertical layout) /// - Number of tasks (single task prefers vertical layout)
/// - Minimum dimensions requirements /// - Minimum dimensions requirements
fn is_vertical_layout_preferred( fn is_vertical_layout_preferred(&self, terminal_width: u16, terminal_height: u16) -> bool {
&self,
terminal_width: u16,
terminal_height: u16,
task_count: usize,
) -> bool {
// If there's only a single task, prefer vertical layout
if task_count <= 1 {
return true;
}
// Calculate aspect ratio (width/height) // Calculate aspect ratio (width/height)
let aspect_ratio = terminal_width as f32 / terminal_height as f32; let aspect_ratio = terminal_width as f32 / terminal_height as f32;
@ -557,27 +545,6 @@ mod tests {
assert_eq!(task_list.height, 100 / 3); assert_eq!(task_list.height, 100 / 3);
} }
#[test]
fn test_auto_mode_prefers_vertical_for_single_task() {
let mut layout_manager = LayoutManager::new(5);
let area = create_test_area(200, 60); // Wide terminal that would normally use horizontal
layout_manager.set_mode(LayoutMode::Auto);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Single);
layout_manager.set_task_count(1); // Single task
let layout = layout_manager.calculate_layout(area);
assert!(layout.task_list.is_some());
// Even though terminal is wide, layout should be vertical for a single task
let task_list = layout.task_list.unwrap();
assert_eq!(task_list.x, 0);
assert_eq!(task_list.y, 0);
assert_eq!(task_list.width, 200);
assert_eq!(task_list.height, 60 / 3);
}
#[test] #[test]
fn test_forced_vertical_mode() { fn test_forced_vertical_mode() {
let mut layout_manager = LayoutManager::new(5); let mut layout_manager = LayoutManager::new(5);
@ -840,19 +807,6 @@ mod tests {
insta::assert_snapshot!(terminal.backend()); insta::assert_snapshot!(terminal.backend());
} }
/// Visual test for auto mode with single task (should be vertical regardless of terminal size)
#[test]
fn test_visualize_auto_mode_single_task() {
let mut layout_manager = LayoutManager::new(5);
layout_manager.set_mode(LayoutMode::Auto);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Single);
layout_manager.set_task_count(1);
let terminal = render_layout(120, 30, &layout_manager);
insta::assert_snapshot!(terminal.backend());
}
/// Visual test for auto mode with tall terminal /// Visual test for auto mode with tall terminal
#[test] #[test]
fn test_visualize_auto_mode_tall_terminal() { fn test_visualize_auto_mode_tall_terminal() {

View File

@ -1,34 +0,0 @@
---
source: packages/nx/src/native/tui/components/layout_manager.rs
expression: terminal.backend()
---
"┌Task List─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘"
" "
"┌Terminal Pane 1───────────────────────────────────────────────────────────────────────────────────────────────────────┐"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘"

View File

@ -1,5 +1,5 @@
use arboard::Clipboard; use arboard::Clipboard;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
use ratatui::{ use ratatui::{
buffer::Buffer, buffer::Buffer,
layout::{Alignment, Rect}, layout::{Alignment, Rect},
@ -97,11 +97,8 @@ impl TerminalPaneData {
KeyCode::Char(c) => { KeyCode::Char(c) => {
pty_mut.write_input(c.to_string().as_bytes())?; pty_mut.write_input(c.to_string().as_bytes())?;
} }
KeyCode::Up => { KeyCode::Up | KeyCode::Down => {
pty_mut.write_input(b"\x1b[A")?; pty_mut.handle_arrow_keys(key);
}
KeyCode::Down => {
pty_mut.write_input(b"\x1b[B")?;
} }
KeyCode::Enter => { KeyCode::Enter => {
pty_mut.write_input(b"\r")?; pty_mut.write_input(b"\r")?;
@ -120,6 +117,26 @@ impl TerminalPaneData {
Ok(()) Ok(())
} }
pub fn handle_mouse_event(&mut self, event: MouseEvent) -> io::Result<()> {
if let Some(pty) = &mut self.pty {
let mut pty_mut = pty.as_ref().clone();
if self.is_interactive {
pty_mut.send_mouse_event(event);
} else {
match event.kind {
MouseEventKind::ScrollUp => {
pty_mut.scroll_up();
}
MouseEventKind::ScrollDown => {
pty_mut.scroll_down();
}
_ => {}
}
}
}
Ok(())
}
pub fn set_interactive(&mut self, interactive: bool) { pub fn set_interactive(&mut self, interactive: bool) {
self.is_interactive = interactive; self.is_interactive = interactive;
} }
@ -171,6 +188,7 @@ impl TerminalPaneState {
pub struct TerminalPane<'a> { pub struct TerminalPane<'a> {
pty_data: Option<&'a mut TerminalPaneData>, pty_data: Option<&'a mut TerminalPaneData>,
is_continuous: bool, is_continuous: bool,
minimal: bool,
} }
impl<'a> TerminalPane<'a> { impl<'a> TerminalPane<'a> {
@ -178,6 +196,7 @@ impl<'a> TerminalPane<'a> {
Self { Self {
pty_data: None, pty_data: None,
is_continuous: false, is_continuous: false,
minimal: false,
} }
} }
@ -191,6 +210,11 @@ impl<'a> TerminalPane<'a> {
self self
} }
pub fn minimal(mut self, minimal: bool) -> Self {
self.minimal = minimal;
self
}
fn get_status_icon(&self, status: TaskStatus) -> Span { fn get_status_icon(&self, status: TaskStatus) -> Span {
match status { match status {
TaskStatus::Success TaskStatus::Success
@ -320,34 +344,47 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
let status_icon = self.get_status_icon(state.task_status); let status_icon = self.get_status_icon(state.task_status);
let block = Block::default() let mut title = vec![];
.title(Line::from(if state.is_focused {
vec![ if self.minimal {
status_icon.clone(), title.push(Span::styled(
Span::raw(format!("{} ", state.task_name)) " NX ",
.style(Style::default().fg(THEME.primary_fg)), Style::default().fg(THEME.primary_fg).bold().bg(base_style
] .fg
.expect("Base style should have foreground color")),
));
title.push(Span::raw(" "));
} else { } else {
vec![ title.push(status_icon.clone());
status_icon.clone(), }
Span::raw(format!("{} ", state.task_name)) title.push(Span::styled(
.style(Style::default().fg(THEME.secondary_fg)), format!("{} ", state.task_name),
Style::default().fg(if state.is_focused {
THEME.primary_fg
} else {
THEME.secondary_fg
}),
));
if state.is_next_tab_target { if state.is_next_tab_target {
let tab_target_text = Span::raw("Press <tab> to focus output ") let tab_target_text =
.remove_modifier(Modifier::DIM); Span::raw("Press <tab> to focus output ").remove_modifier(Modifier::DIM);
// In light themes, use the primary fg color for the tab target text to make sure it's clearly visible // In light themes, use the primary fg color for the tab target text to make sure it's clearly visible
if !THEME.is_dark_mode { if !THEME.is_dark_mode {
tab_target_text.fg(THEME.primary_fg) title.push(tab_target_text.fg(THEME.primary_fg));
} else { } else {
tab_target_text title.push(tab_target_text);
} }
} else { }
Span::raw("")
}, let block = Block::default()
] .title(title)
}))
.title_alignment(Alignment::Left) .title_alignment(Alignment::Left)
.borders(Borders::ALL) .borders(if self.minimal {
Borders::NONE
} else {
Borders::ALL
})
.border_type(if state.is_focused { .border_type(if state.is_focused {
BorderType::Thick BorderType::Thick
} else { } else {
@ -485,8 +522,40 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
scrollbar.render(safe_area, buf, &mut state.scrollbar_state); scrollbar.render(safe_area, buf, &mut state.scrollbar_state);
} }
// Show interactive/readonly status for focused, in progress tasks // Show instructions to quit in minimal mode if somehow terminal became non-interactive
if state.task_status == TaskStatus::InProgress && state.is_focused { if self.minimal && !self.is_currently_interactive() {
let top_text = Line::from(vec![
Span::styled("quit: ", Style::default().fg(THEME.primary_fg)),
Span::styled("q ", Style::default().fg(THEME.info)),
]);
let mode_width = top_text
.spans
.iter()
.map(|span| span.content.len())
.sum::<usize>();
// Ensure text doesn't extend past safe area
if mode_width as u16 + 3 < safe_area.width {
let top_right_area = Rect {
x: safe_area.x + safe_area.width - mode_width as u16 - 3,
y: safe_area.y,
width: mode_width as u16 + 2,
height: 1,
};
Paragraph::new(top_text)
.alignment(Alignment::Right)
.style(border_style)
.render(top_right_area, buf);
}
// Show interactive/readonly status for focused, in progress tasks, when not in minimal mode
} else if state.task_status == TaskStatus::InProgress
&& state.is_focused
&& pty_data.can_be_interactive
&& !self.minimal
{
// Bottom right status // Bottom right status
let bottom_text = if self.is_currently_interactive() { let bottom_text = if self.is_currently_interactive() {
Line::from(vec![ Line::from(vec![

View File

@ -1,11 +1,12 @@
use super::utils::normalize_newlines;
use crossterm::event::{KeyCode, KeyEvent, MouseEvent, MouseEventKind};
use std::{ use std::{
io::{self, Write}, io::{self, Write},
sync::{Arc, Mutex, RwLock}, sync::{Arc, Mutex, RwLock},
}; };
use tracing::debug;
use vt100_ctt::Parser; use vt100_ctt::Parser;
use super::utils::normalize_newlines;
/// A wrapper that provides access to the terminal screen without cloning /// A wrapper that provides access to the terminal screen without cloning
/// ///
/// This struct uses a read lock guard internally to maintain the lock on the parser while /// This struct uses a read lock guard internally to maintain the lock on the parser while
@ -93,6 +94,58 @@ impl PtyInstance {
Ok(()) Ok(())
} }
pub fn handle_arrow_keys(&mut self, event: KeyEvent) {
let alternative_screen = self.parser.read().unwrap().screen().alternate_screen();
debug!("Alternate Screen: {:?}", alternative_screen);
if !alternative_screen {
match event.code {
KeyCode::Up => {
self.scroll_up();
}
KeyCode::Down => {
self.scroll_down();
}
_ => {}
}
} else {
match event.code {
KeyCode::Up => {
self.write_input(b"\x1b[A").ok();
}
KeyCode::Down => {
self.write_input(b"\x1b[B").ok();
}
_ => {}
}
}
}
pub fn send_mouse_event(&mut self, event: MouseEvent) {
let alternative_screen = self.parser.read().unwrap().screen().alternate_screen();
debug!("Alternate Screen: {:?}", alternative_screen);
if !alternative_screen {
match event.kind {
MouseEventKind::ScrollUp => {
self.scroll_up();
}
MouseEventKind::ScrollDown => {
self.scroll_down();
}
_ => {}
}
} else {
match event.kind {
MouseEventKind::ScrollUp => {
self.write_input(b"\x1b[A").ok();
}
MouseEventKind::ScrollDown => {
self.write_input(b"\x1b[B").ok();
}
_ => {}
}
}
}
pub fn get_screen(&self) -> Option<PtyScreenRef> { pub fn get_screen(&self) -> Option<PtyScreenRef> {
self.parser self.parser
.read() .read()

View File

@ -3,6 +3,7 @@ use color_eyre::eyre::Result;
use crossterm::{ use crossterm::{
cursor, cursor,
event::{Event as CrosstermEvent, KeyEvent, KeyEventKind, MouseEvent}, event::{Event as CrosstermEvent, KeyEvent, KeyEventKind, MouseEvent},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen}, terminal::{EnterAlternateScreen, LeaveAlternateScreen},
}; };
use futures::{FutureExt, StreamExt}; use futures::{FutureExt, StreamExt};
@ -170,7 +171,7 @@ impl Tui {
let _ = THEME.is_dark_mode; let _ = THEME.is_dark_mode;
debug!("Enabling Raw Mode"); debug!("Enabling Raw Mode");
crossterm::terminal::enable_raw_mode()?; crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(std::io::stderr(), EnterAlternateScreen, cursor::Hide)?; execute!(std::io::stderr(), EnterAlternateScreen, cursor::Hide)?;
self.start(); self.start();
Ok(()) Ok(())
} }
@ -179,7 +180,7 @@ impl Tui {
self.stop()?; self.stop()?;
if crossterm::terminal::is_raw_mode_enabled()? { if crossterm::terminal::is_raw_mode_enabled()? {
self.flush()?; self.flush()?;
crossterm::execute!(std::io::stderr(), LeaveAlternateScreen, cursor::Show)?; execute!(std::io::stderr(), LeaveAlternateScreen, cursor::Show)?;
crossterm::terminal::disable_raw_mode()?; crossterm::terminal::disable_raw_mode()?;
} }
Ok(()) Ok(())