diff --git a/packages/nx/src/native/pseudo_terminal/pseudo_terminal.rs b/packages/nx/src/native/pseudo_terminal/pseudo_terminal.rs index 9295917682..82adaefeb9 100644 --- a/packages/nx/src/native/pseudo_terminal/pseudo_terminal.rs +++ b/packages/nx/src/native/pseudo_terminal/pseudo_terminal.rs @@ -5,7 +5,6 @@ use crossterm::{ terminal::{disable_raw_mode, enable_raw_mode}, tty::IsTty, }; -use itertools::Itertools; use napi::bindgen_prelude::*; use portable_pty::{CommandBuilder, NativePtySystem, PtyPair, PtySize, PtySystem}; use std::io::stdout; diff --git a/packages/nx/src/native/tui/app.rs b/packages/nx/src/native/tui/app.rs index ae756d078b..c7b19bdf0b 100644 --- a/packages/nx/src/native/tui/app.rs +++ b/packages/nx/src/native/tui/app.rs @@ -10,6 +10,7 @@ use ratatui::widgets::Paragraph; use tokio::sync::mpsc; use tokio::sync::mpsc::UnboundedSender; use tracing::debug; +use tracing::trace; use crate::native::pseudo_terminal::pseudo_terminal::{ParserArc, WriterArc}; use crate::native::tasks::types::{Task, TaskResult}; @@ -154,6 +155,10 @@ impl App { return; } + self.begin_exit_countdown() + } + + fn begin_exit_countdown(&mut self) { let countdown_duration = self.tui_config.auto_exit.countdown_seconds(); // If countdown is disabled, exit immediately if countdown_duration.is_none() { @@ -241,20 +246,29 @@ impl App { // Record that the user has interacted with the app self.user_has_interacted = true; - // Handle Ctrl+C to quit - if key.code == KeyCode::Char('c') && key.modifiers == KeyModifiers::CONTROL { - self.is_forced_shutdown = true; - // Quit immediately - self.quit_at = Some(std::time::Instant::now()); - return Ok(true); - } - // Get tasks list component to check interactive mode before handling '?' key if let Some(tasks_list) = self .components .iter_mut() .find_map(|c| c.as_any_mut().downcast_mut::()) { + if matches!(self.focus, Focus::MultipleOutput(_)) + && tasks_list.is_interactive_mode() + { + return match key.code { + KeyCode::Char('z') if key.modifiers == KeyModifiers::CONTROL => { + // Disable interactive mode when Ctrl+Z is pressed + tasks_list.set_interactive_mode(false); + Ok(false) + } + _ => { + // The TasksList will forward the key event to the focused terminal pane + tasks_list.handle_key_event(key).ok(); + Ok(false) + } + }; + } + // Only handle '?' key if we're not in interactive mode and the countdown popup is not open if matches!(key.code, KeyCode::Char('?')) && !tasks_list.is_interactive_mode() @@ -286,18 +300,26 @@ impl App { .iter_mut() .find_map(|c| c.as_any_mut().downcast_mut::()) { - if !countdown_popup.is_scrollable() { - countdown_popup.cancel_countdown(); - self.quit_at = None; - self.focus = self.previous_focus; - return Ok(false); - } match key.code { - KeyCode::Up | KeyCode::Char('k') => { + KeyCode::Char('q') => { + // Quit immediately + trace!("Confirming shutdown"); + self.quit_at = Some(std::time::Instant::now()); + return Ok(true); + } + KeyCode::Char('c') if key.modifiers == KeyModifiers::CONTROL => { + // Quit immediately + trace!("Confirming shutdown"); + self.quit_at = Some(std::time::Instant::now()); + return Ok(true); + } + KeyCode::Up | KeyCode::Char('k') if countdown_popup.is_scrollable() => { countdown_popup.scroll_up(); return Ok(false); } - KeyCode::Down | KeyCode::Char('j') => { + KeyCode::Down | KeyCode::Char('j') + if countdown_popup.is_scrollable() => + { countdown_popup.scroll_down(); return Ok(false); } @@ -312,6 +334,21 @@ impl App { return Ok(false); } + if let Some(tasks_list) = self + .components + .iter_mut() + .find_map(|c| c.as_any_mut().downcast_mut::()) + { + // Handle Q or Ctrl+C to trigger countdown + if key.code == KeyCode::Char('c') && key.modifiers == KeyModifiers::CONTROL + || (!tasks_list.filter_mode && key.code == KeyCode::Char('q')) + { + self.is_forced_shutdown = true; + self.begin_exit_countdown(); + return Ok(false); + } + } + // If shortcuts popup is open, handle its keyboard events if matches!(self.focus, Focus::HelpPopup) { match key.code { @@ -375,37 +412,36 @@ impl App { match tasks_list.get_focus() { Focus::MultipleOutput(_) => { - if tasks_list.is_interactive_mode() { - // Send all other keys to the task list (and ultimately through the terminal pane to the PTY) - tasks_list.handle_key_event(key).ok(); - } else { - // Handle navigation and special actions - match key.code { - KeyCode::Tab => { - tasks_list.focus_next(); - self.focus = tasks_list.get_focus(); - } - KeyCode::BackTab => { - tasks_list.focus_previous(); - self.focus = tasks_list.get_focus(); - } - // Add our new shortcuts here - KeyCode::Char('c') => { - tasks_list.handle_key_event(key).ok(); - } - KeyCode::Char('u') | KeyCode::Char('d') - if key.modifiers.contains(KeyModifiers::CONTROL) => - { - tasks_list.handle_key_event(key).ok(); - } - KeyCode::Char('b') => { - tasks_list.toggle_task_list(); - self.focus = tasks_list.get_focus(); - } - _ => { - // Forward other keys for interactivity, scrolling (j/k) etc - tasks_list.handle_key_event(key).ok(); - } + // Handle navigation and special actions + match key.code { + KeyCode::Tab => { + tasks_list.focus_next(); + self.focus = tasks_list.get_focus(); + } + KeyCode::BackTab => { + tasks_list.focus_previous(); + self.focus = tasks_list.get_focus(); + } + KeyCode::Esc => { + tasks_list.set_focus(Focus::TaskList); + self.focus = Focus::TaskList; + } + // Add our new shortcuts here + KeyCode::Char('c') => { + tasks_list.handle_key_event(key).ok(); + } + KeyCode::Char('u') | KeyCode::Char('d') + if key.modifiers.contains(KeyModifiers::CONTROL) => + { + tasks_list.handle_key_event(key).ok(); + } + KeyCode::Char('b') => { + tasks_list.toggle_task_list(); + self.focus = tasks_list.get_focus(); + } + _ => { + // Forward other keys for interactivity, scrolling (j/k) etc + tasks_list.handle_key_event(key).ok(); } } return Ok(false); @@ -462,7 +498,7 @@ impl App { match c { '/' => { if tasks_list.filter_mode { - tasks_list.exit_filter_mode(); + tasks_list.persist_filter(); } else { tasks_list.enter_filter_mode(); } @@ -509,6 +545,13 @@ impl App { self.focus = tasks_list.get_focus(); } } + KeyCode::Enter if is_filter_mode => { + tasks_list.persist_filter(); + } + KeyCode::Enter => { + tasks_list.focus_current_task_terminal_pane(); + self.focus = tasks_list.get_focus(); + } _ => {} }, Focus::MultipleOutput(_idx) => match key.code { diff --git a/packages/nx/src/native/tui/components/help_popup.rs b/packages/nx/src/native/tui/components/help_popup.rs index 33711a4bd1..de043aae83 100644 --- a/packages/nx/src/native/tui/components/help_popup.rs +++ b/packages/nx/src/native/tui/components/help_popup.rs @@ -91,7 +91,7 @@ impl HelpPopup { let keybindings = vec![ // Misc ("?", "Toggle this popup"), - ("+c", "Quit the TUI"), + ("q or +c", "Quit the TUI"), ("", ""), // Navigation ("↑ or k", "Navigate/scroll task output up"), @@ -106,6 +106,8 @@ impl HelpPopup { ("", "Clear filter"), ("", ""), // Output Controls + ("", "Open and focus terminal for task"), + ("", "Set focus back to task list"), ("", "Quick toggle a single output pane"), ("b", "Toggle task list visibility"), ("1", "Pin task to be shown in output pane 1"), @@ -166,12 +168,7 @@ impl HelpPopup { let mut spans = Vec::new(); // Calculate the total visible length (excluding color codes) - let visible_length = if key_parts.len() > 1 { - key_parts.iter().map(|s| s.len()).sum::() + 2 - // for alignment - } else { - key.len() - }; + let visible_length = key.chars().count(); // Add each key part with the appropriate styling for (i, part) in key_parts.iter().enumerate() { @@ -188,7 +185,7 @@ impl HelpPopup { } // Add padding to align all descriptions - let padding = " ".repeat(11usize.saturating_sub(visible_length)); + let padding = " ".repeat(14usize.saturating_sub(visible_length)); spans.push(Span::raw(padding)); // Add the separator and description diff --git a/packages/nx/src/native/tui/components/help_text.rs b/packages/nx/src/native/tui/components/help_text.rs index a84fc10336..00d21cec15 100644 --- a/packages/nx/src/native/tui/components/help_text.rs +++ b/packages/nx/src/native/tui/components/help_text.rs @@ -36,7 +36,7 @@ impl HelpText { // Show minimal hint let hint = vec![ Span::styled("quit: ", base_style.fg(Color::DarkGray)), - Span::styled("+c", base_style.fg(Color::Cyan)), + Span::styled("q", base_style.fg(Color::Cyan)), Span::styled(" ", base_style.fg(Color::DarkGray)), Span::styled("help: ", base_style.fg(Color::DarkGray)), Span::styled("? ", base_style.fg(Color::Cyan)), @@ -53,7 +53,7 @@ impl HelpText { // Show full shortcuts let shortcuts = vec![ Span::styled("quit: ", base_style.fg(Color::DarkGray)), - Span::styled("+c", base_style.fg(Color::Cyan)), + Span::styled("q", base_style.fg(Color::Cyan)), Span::styled(" ", base_style.fg(Color::DarkGray)), Span::styled("help: ", base_style.fg(Color::DarkGray)), Span::styled("?", base_style.fg(Color::Cyan)), @@ -70,8 +70,8 @@ impl HelpText { Span::styled(" or ", base_style.fg(Color::DarkGray)), Span::styled("2", base_style.fg(Color::Cyan)), Span::styled(" ", base_style.fg(Color::DarkGray)), - Span::styled("focus output: ", base_style.fg(Color::DarkGray)), - Span::styled("", base_style.fg(Color::Cyan)), + Span::styled("show output: ", base_style.fg(Color::DarkGray)), + Span::styled("", base_style.fg(Color::Cyan)), ]; f.render_widget( diff --git a/packages/nx/src/native/tui/components/tasks_list.rs b/packages/nx/src/native/tui/components/tasks_list.rs index c8774a907c..01e7e68309 100644 --- a/packages/nx/src/native/tui/components/tasks_list.rs +++ b/packages/nx/src/native/tui/components/tasks_list.rs @@ -14,7 +14,7 @@ use std::sync::{Arc, Mutex, RwLock}; use std::{any::Any, io}; use vt100_ctt::Parser; -use crate::native::tui::utils::{is_cache_hit, normalize_newlines, sort_task_items}; +use crate::native::tui::utils::{normalize_newlines, sort_task_items}; use crate::native::tui::{ action::Action, app::Focus, components::Component, pty::PtyInstance, utils, }; @@ -385,9 +385,7 @@ impl TasksList { /// If there is existing filter text that isn't persisted, persists it instead. pub fn enter_filter_mode(&mut self) { if !self.filter_text.is_empty() && !self.filter_persisted { - // If we have filter text and it's not persisted, pressing / should persist it - self.filter_persisted = true; - self.filter_mode = false; + self.persist_filter(); } else { // Otherwise enter normal filter mode self.filter_persisted = false; @@ -401,6 +399,11 @@ impl TasksList { self.filter_persisted = false; } + pub fn persist_filter(&mut self) { + self.filter_persisted = true; + self.filter_mode = false; + } + /// Clears the current filter and resets filter-related state. pub fn clear_filter(&mut self) { self.filter_mode = false; @@ -676,6 +679,23 @@ impl TasksList { }; } + pub fn focus_current_task_terminal_pane(&mut self) { + if let Some(task_name) = self.selection_manager.get_selected_task_name() { + // Find which pane contains this task + let pane_idx = self + .pane_tasks + .iter() + .position(|t| t.as_deref() == Some(task_name.as_str())) + .unwrap_or_else(|| { + self.assign_current_task_to_pane(0); + 0 + }); + // Set focus to this pane + self.focus = Focus::MultipleOutput(pane_idx); + self.focused_pane = Some(pane_idx); + } + } + /// Gets the table style based on the current focus state. /// Returns a dimmed style when focus is not on the task list. fn get_table_style(&self) -> Style { @@ -702,6 +722,13 @@ impl TasksList { } } + /// Returns true if the currently focused pane is in interactive mode. + pub fn set_interactive_mode(&mut self, interactive: bool) { + if let Focus::MultipleOutput(pane_idx) = self.focus { + self.terminal_pane_data[pane_idx].set_interactive(interactive); + } + } + /// Returns true if the currently focused pane is in interactive mode. pub fn is_interactive_mode(&self) -> bool { match self.focus { @@ -938,6 +965,16 @@ impl TasksList { task_item.update_status(status); self.sort_tasks(); } + for (i, data) in self.terminal_pane_data.iter_mut().enumerate() { + if self.pane_tasks.as_ref()[i].clone().is_some_and(|id| id == task_id) { + let in_progress = status == TaskStatus::InProgress; + data.can_be_interactive = in_progress; + if !in_progress { + data.set_interactive(false); + } + } + } + } pub fn end_tasks(&mut self, task_results: Vec) { @@ -1732,7 +1769,7 @@ impl Component for TasksList { ) } else { format!( - " -> {} tasks filtered out. Press / to persist, to clear", + " -> {} tasks filtered out. Press to persist, to clear", hidden_tasks ) } @@ -2041,7 +2078,7 @@ impl Component for TasksList { { let mut terminal_pane_data = &mut self.terminal_pane_data[1]; terminal_pane_data.is_continuous = task.continuous; - terminal_pane_data.is_cache_hit = is_cache_hit(task.status); + // terminal_pane_data.is_cache_hit = is_cache_hit(task.status); let mut has_pty = false; if let Some(pty) = self.pty_instances.get(task_name) { @@ -2084,7 +2121,7 @@ impl Component for TasksList { if let Some(task) = self.tasks.iter_mut().find(|t| t.name == *task_name) { let mut terminal_pane_data = &mut self.terminal_pane_data[pane_idx]; terminal_pane_data.is_continuous = task.continuous; - terminal_pane_data.is_cache_hit = is_cache_hit(task.status); + // terminal_pane_data.is_cache_hit = is_cache_hit(task.status); let mut has_pty = false; if let Some(pty) = self.pty_instances.get(task_name) { @@ -2126,7 +2163,7 @@ impl Component for TasksList { { let mut terminal_pane_data = &mut self.terminal_pane_data[pane_idx]; terminal_pane_data.is_continuous = task.continuous; - terminal_pane_data.is_cache_hit = is_cache_hit(task.status); + // terminal_pane_data.is_cache_hit = is_cache_hit(task.status); let mut has_pty = false; if let Some(pty) = self.pty_instances.get(task_name) { diff --git a/packages/nx/src/native/tui/components/terminal_pane.rs b/packages/nx/src/native/tui/components/terminal_pane.rs index b4b7186ed9..00ec3809cf 100644 --- a/packages/nx/src/native/tui/components/terminal_pane.rs +++ b/packages/nx/src/native/tui/components/terminal_pane.rs @@ -21,7 +21,7 @@ pub struct TerminalPaneData { pub pty: Option>, pub is_interactive: bool, pub is_continuous: bool, - pub is_cache_hit: bool, + pub can_be_interactive: bool, } impl TerminalPaneData { @@ -30,7 +30,7 @@ impl TerminalPaneData { pty: None, is_interactive: false, is_continuous: false, - is_cache_hit: false, + can_be_interactive: false, } } @@ -91,20 +91,11 @@ impl TerminalPaneData { } return Ok(()); } - // Handle 'i' to enter interactive mode for non cache hit tasks - KeyCode::Char('i') if !self.is_cache_hit && !self.is_interactive => { + // Handle 'i' to enter interactive mode for in progress tasks + KeyCode::Char('i') if self.can_be_interactive && !self.is_interactive => { self.set_interactive(true); return Ok(()); } - // Handle Ctrl+Z to exit interactive mode - KeyCode::Char('z') - if key.modifiers == KeyModifiers::CONTROL - && !self.is_cache_hit - && self.is_interactive => - { - self.set_interactive(false); - return Ok(()); - } // Only send input to PTY if we're in interactive mode _ if self.is_interactive => match key.code { KeyCode::Char(c) => { @@ -435,7 +426,7 @@ impl<'a> StatefulWidget for TerminalPane<'a> { } // Show interactive/readonly status for focused, non-cache hit, tasks - if state.is_focused && !pty_data.is_cache_hit { + if state.task_status == TaskStatus::InProgress && state.is_focused { // Bottom right status let bottom_text = if self.is_currently_interactive() { Line::from(vec![ diff --git a/packages/nx/src/native/tui/lifecycle.rs b/packages/nx/src/native/tui/lifecycle.rs index 87bf6a7741..f526475c62 100644 --- a/packages/nx/src/native/tui/lifecycle.rs +++ b/packages/nx/src/native/tui/lifecycle.rs @@ -202,11 +202,7 @@ impl AppLifeCycle { // Handle events using our Tui abstraction if let Some(event) = tui.next().await { if let Ok(mut app) = app_mutex.lock() { - if let Ok(true) = app.handle_event(event, &action_tx) { - tui.exit().ok(); - app.call_done_callback(); - break; - } + let _ = app.handle_event(event, &action_tx); // Check if we should quit based on the timer if let Some(quit_time) = app.quit_at { diff --git a/packages/nx/src/native/tui/utils.rs b/packages/nx/src/native/tui/utils.rs index 4fdd9e88d1..de52423e9a 100644 --- a/packages/nx/src/native/tui/utils.rs +++ b/packages/nx/src/native/tui/utils.rs @@ -32,13 +32,6 @@ pub fn normalize_newlines(input: &[u8]) -> Vec { output } -pub fn is_cache_hit(status: TaskStatus) -> bool { - matches!( - status, - TaskStatus::LocalCacheKeptExisting | TaskStatus::LocalCache | TaskStatus::RemoteCache - ) -} - /// Sorts a list of TaskItems with a stable, total ordering. /// /// The sort order is: diff --git a/packages/nx/src/tasks-runner/task-orchestrator.ts b/packages/nx/src/tasks-runner/task-orchestrator.ts index 7cbb10bd88..c08607dac5 100644 --- a/packages/nx/src/tasks-runner/task-orchestrator.ts +++ b/packages/nx/src/tasks-runner/task-orchestrator.ts @@ -680,6 +680,12 @@ export class TaskOrchestrator { this.runningContinuousTasks.set(task.id, runningTask); runningTask.onExit(() => { + if (this.tuiEnabled) { + this.options.lifeCycle.setTaskStatus( + task.id, + NativeTaskStatus.Stopped + ); + } this.runningContinuousTasks.delete(task.id); }); @@ -744,6 +750,9 @@ export class TaskOrchestrator { this.runningContinuousTasks.set(task.id, childProcess); childProcess.onExit(() => { + if (this.tuiEnabled) { + this.options.lifeCycle.setTaskStatus(task.id, NativeTaskStatus.Stopped); + } this.runningTasksService.removeRunningTask(task.id); this.runningContinuousTasks.delete(task.id); });