feat(core): add enter and q keybindings (#30786)

<!-- 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 -->

The TUI is missing some standard keybindings:

Q to quit
Enter to show task output

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

Q now triggers a confirmation to exit. Because Q is close to 1, users
might often accidentally hit Q so this gives them a chance to cancel the
exit.
Enter will show the task output.

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

Fixes #
This commit is contained in:
Jason Jean 2025-04-28 10:40:34 -04:00 committed by GitHub
parent 85bc540a15
commit 2c8aba2fc2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 160 additions and 95 deletions

View File

@ -5,7 +5,6 @@ use crossterm::{
terminal::{disable_raw_mode, enable_raw_mode}, terminal::{disable_raw_mode, enable_raw_mode},
tty::IsTty, tty::IsTty,
}; };
use itertools::Itertools;
use napi::bindgen_prelude::*; use napi::bindgen_prelude::*;
use portable_pty::{CommandBuilder, NativePtySystem, PtyPair, PtySize, PtySystem}; use portable_pty::{CommandBuilder, NativePtySystem, PtyPair, PtySize, PtySystem};
use std::io::stdout; use std::io::stdout;

View File

@ -10,6 +10,7 @@ use ratatui::widgets::Paragraph;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::UnboundedSender;
use tracing::debug; use tracing::debug;
use tracing::trace;
use crate::native::pseudo_terminal::pseudo_terminal::{ParserArc, WriterArc}; use crate::native::pseudo_terminal::pseudo_terminal::{ParserArc, WriterArc};
use crate::native::tasks::types::{Task, TaskResult}; use crate::native::tasks::types::{Task, TaskResult};
@ -154,6 +155,10 @@ impl App {
return; return;
} }
self.begin_exit_countdown()
}
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() {
@ -241,20 +246,29 @@ impl App {
// Record that the user has interacted with the app // Record that the user has interacted with the app
self.user_has_interacted = true; 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 // Get tasks list component to check interactive mode before handling '?' key
if let Some(tasks_list) = self if let Some(tasks_list) = self
.components .components
.iter_mut() .iter_mut()
.find_map(|c| c.as_any_mut().downcast_mut::<TasksList>()) .find_map(|c| c.as_any_mut().downcast_mut::<TasksList>())
{ {
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 // Only handle '?' key if we're not in interactive mode and the countdown popup is not open
if matches!(key.code, KeyCode::Char('?')) if matches!(key.code, KeyCode::Char('?'))
&& !tasks_list.is_interactive_mode() && !tasks_list.is_interactive_mode()
@ -286,18 +300,26 @@ impl App {
.iter_mut() .iter_mut()
.find_map(|c| c.as_any_mut().downcast_mut::<CountdownPopup>()) .find_map(|c| c.as_any_mut().downcast_mut::<CountdownPopup>())
{ {
if !countdown_popup.is_scrollable() {
countdown_popup.cancel_countdown();
self.quit_at = None;
self.focus = self.previous_focus;
return Ok(false);
}
match key.code { 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(); countdown_popup.scroll_up();
return Ok(false); return Ok(false);
} }
KeyCode::Down | KeyCode::Char('j') => { KeyCode::Down | KeyCode::Char('j')
if countdown_popup.is_scrollable() =>
{
countdown_popup.scroll_down(); countdown_popup.scroll_down();
return Ok(false); return Ok(false);
} }
@ -312,6 +334,21 @@ impl App {
return Ok(false); return Ok(false);
} }
if let Some(tasks_list) = self
.components
.iter_mut()
.find_map(|c| c.as_any_mut().downcast_mut::<TasksList>())
{
// 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 shortcuts popup is open, handle its keyboard events
if matches!(self.focus, Focus::HelpPopup) { if matches!(self.focus, Focus::HelpPopup) {
match key.code { match key.code {
@ -375,37 +412,36 @@ impl App {
match tasks_list.get_focus() { match tasks_list.get_focus() {
Focus::MultipleOutput(_) => { Focus::MultipleOutput(_) => {
if tasks_list.is_interactive_mode() { // Handle navigation and special actions
// Send all other keys to the task list (and ultimately through the terminal pane to the PTY) match key.code {
tasks_list.handle_key_event(key).ok(); KeyCode::Tab => {
} else { tasks_list.focus_next();
// Handle navigation and special actions self.focus = tasks_list.get_focus();
match key.code { }
KeyCode::Tab => { KeyCode::BackTab => {
tasks_list.focus_next(); tasks_list.focus_previous();
self.focus = tasks_list.get_focus(); self.focus = tasks_list.get_focus();
} }
KeyCode::BackTab => { KeyCode::Esc => {
tasks_list.focus_previous(); tasks_list.set_focus(Focus::TaskList);
self.focus = tasks_list.get_focus(); self.focus = Focus::TaskList;
} }
// Add our new shortcuts here // Add our new shortcuts here
KeyCode::Char('c') => { KeyCode::Char('c') => {
tasks_list.handle_key_event(key).ok(); tasks_list.handle_key_event(key).ok();
} }
KeyCode::Char('u') | KeyCode::Char('d') KeyCode::Char('u') | KeyCode::Char('d')
if key.modifiers.contains(KeyModifiers::CONTROL) => if key.modifiers.contains(KeyModifiers::CONTROL) =>
{ {
tasks_list.handle_key_event(key).ok(); tasks_list.handle_key_event(key).ok();
} }
KeyCode::Char('b') => { KeyCode::Char('b') => {
tasks_list.toggle_task_list(); tasks_list.toggle_task_list();
self.focus = tasks_list.get_focus(); self.focus = tasks_list.get_focus();
} }
_ => { _ => {
// Forward other keys for interactivity, scrolling (j/k) etc // Forward other keys for interactivity, scrolling (j/k) etc
tasks_list.handle_key_event(key).ok(); tasks_list.handle_key_event(key).ok();
}
} }
} }
return Ok(false); return Ok(false);
@ -462,7 +498,7 @@ impl App {
match c { match c {
'/' => { '/' => {
if tasks_list.filter_mode { if tasks_list.filter_mode {
tasks_list.exit_filter_mode(); tasks_list.persist_filter();
} else { } else {
tasks_list.enter_filter_mode(); tasks_list.enter_filter_mode();
} }
@ -509,6 +545,13 @@ impl App {
self.focus = tasks_list.get_focus(); 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 { Focus::MultipleOutput(_idx) => match key.code {

View File

@ -91,7 +91,7 @@ impl HelpPopup {
let keybindings = vec![ let keybindings = vec![
// Misc // Misc
("?", "Toggle this popup"), ("?", "Toggle this popup"),
("<ctrl>+c", "Quit the TUI"), ("q or <ctrl>+c", "Quit the TUI"),
("", ""), ("", ""),
// Navigation // Navigation
("↑ or k", "Navigate/scroll task output up"), ("↑ or k", "Navigate/scroll task output up"),
@ -106,6 +106,8 @@ impl HelpPopup {
("<esc>", "Clear filter"), ("<esc>", "Clear filter"),
("", ""), ("", ""),
// Output Controls // Output Controls
("<enter>", "Open and focus terminal for task"),
("<esc>", "Set focus back to task list"),
("<space>", "Quick toggle a single output pane"), ("<space>", "Quick toggle a single output pane"),
("b", "Toggle task list visibility"), ("b", "Toggle task list visibility"),
("1", "Pin task to be shown in output pane 1"), ("1", "Pin task to be shown in output pane 1"),
@ -166,12 +168,7 @@ impl HelpPopup {
let mut spans = Vec::new(); let mut spans = Vec::new();
// Calculate the total visible length (excluding color codes) // Calculate the total visible length (excluding color codes)
let visible_length = if key_parts.len() > 1 { let visible_length = key.chars().count();
key_parts.iter().map(|s| s.len()).sum::<usize>() + 2
// for alignment
} else {
key.len()
};
// Add each key part with the appropriate styling // Add each key part with the appropriate styling
for (i, part) in key_parts.iter().enumerate() { for (i, part) in key_parts.iter().enumerate() {
@ -188,7 +185,7 @@ impl HelpPopup {
} }
// Add padding to align all descriptions // 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)); spans.push(Span::raw(padding));
// Add the separator and description // Add the separator and description

View File

@ -36,7 +36,7 @@ impl HelpText {
// Show minimal hint // Show minimal hint
let hint = vec![ let hint = vec![
Span::styled("quit: ", base_style.fg(Color::DarkGray)), Span::styled("quit: ", base_style.fg(Color::DarkGray)),
Span::styled("<ctrl>+c", base_style.fg(Color::Cyan)), Span::styled("q", base_style.fg(Color::Cyan)),
Span::styled(" ", base_style.fg(Color::DarkGray)), Span::styled(" ", base_style.fg(Color::DarkGray)),
Span::styled("help: ", base_style.fg(Color::DarkGray)), Span::styled("help: ", base_style.fg(Color::DarkGray)),
Span::styled("? ", base_style.fg(Color::Cyan)), Span::styled("? ", base_style.fg(Color::Cyan)),
@ -53,7 +53,7 @@ impl HelpText {
// Show full shortcuts // Show full shortcuts
let shortcuts = vec![ let shortcuts = vec![
Span::styled("quit: ", base_style.fg(Color::DarkGray)), Span::styled("quit: ", base_style.fg(Color::DarkGray)),
Span::styled("<ctrl>+c", base_style.fg(Color::Cyan)), Span::styled("q", base_style.fg(Color::Cyan)),
Span::styled(" ", base_style.fg(Color::DarkGray)), Span::styled(" ", base_style.fg(Color::DarkGray)),
Span::styled("help: ", base_style.fg(Color::DarkGray)), Span::styled("help: ", base_style.fg(Color::DarkGray)),
Span::styled("?", base_style.fg(Color::Cyan)), Span::styled("?", base_style.fg(Color::Cyan)),
@ -70,8 +70,8 @@ impl HelpText {
Span::styled(" or ", base_style.fg(Color::DarkGray)), Span::styled(" or ", base_style.fg(Color::DarkGray)),
Span::styled("2", base_style.fg(Color::Cyan)), Span::styled("2", base_style.fg(Color::Cyan)),
Span::styled(" ", base_style.fg(Color::DarkGray)), Span::styled(" ", base_style.fg(Color::DarkGray)),
Span::styled("focus output: ", base_style.fg(Color::DarkGray)), Span::styled("show output: ", base_style.fg(Color::DarkGray)),
Span::styled("<tab>", base_style.fg(Color::Cyan)), Span::styled("<enter>", base_style.fg(Color::Cyan)),
]; ];
f.render_widget( f.render_widget(

View File

@ -14,7 +14,7 @@ use std::sync::{Arc, Mutex, RwLock};
use std::{any::Any, io}; use std::{any::Any, io};
use vt100_ctt::Parser; 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::{ use crate::native::tui::{
action::Action, app::Focus, components::Component, pty::PtyInstance, utils, 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. /// If there is existing filter text that isn't persisted, persists it instead.
pub fn enter_filter_mode(&mut self) { pub fn enter_filter_mode(&mut self) {
if !self.filter_text.is_empty() && !self.filter_persisted { if !self.filter_text.is_empty() && !self.filter_persisted {
// If we have filter text and it's not persisted, pressing / should persist it self.persist_filter();
self.filter_persisted = true;
self.filter_mode = false;
} else { } else {
// Otherwise enter normal filter mode // Otherwise enter normal filter mode
self.filter_persisted = false; self.filter_persisted = false;
@ -401,6 +399,11 @@ impl TasksList {
self.filter_persisted = false; 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. /// Clears the current filter and resets filter-related state.
pub fn clear_filter(&mut self) { pub fn clear_filter(&mut self) {
self.filter_mode = false; 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. /// Gets the table style based on the current focus state.
/// Returns a dimmed style when focus is not on the task list. /// Returns a dimmed style when focus is not on the task list.
fn get_table_style(&self) -> Style { 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. /// Returns true if the currently focused pane is in interactive mode.
pub fn is_interactive_mode(&self) -> bool { pub fn is_interactive_mode(&self) -> bool {
match self.focus { match self.focus {
@ -938,6 +965,16 @@ impl TasksList {
task_item.update_status(status); task_item.update_status(status);
self.sort_tasks(); 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<TaskResult>) { pub fn end_tasks(&mut self, task_results: Vec<TaskResult>) {
@ -1732,7 +1769,7 @@ impl Component for TasksList {
) )
} else { } else {
format!( format!(
" -> {} tasks filtered out. Press / to persist, <esc> to clear", " -> {} tasks filtered out. Press <enter> to persist, <esc> to clear",
hidden_tasks hidden_tasks
) )
} }
@ -2041,7 +2078,7 @@ impl Component for TasksList {
{ {
let mut terminal_pane_data = &mut self.terminal_pane_data[1]; let mut terminal_pane_data = &mut self.terminal_pane_data[1];
terminal_pane_data.is_continuous = task.continuous; 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; let mut has_pty = false;
if let Some(pty) = self.pty_instances.get(task_name) { 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) { 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]; let mut terminal_pane_data = &mut self.terminal_pane_data[pane_idx];
terminal_pane_data.is_continuous = task.continuous; 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; let mut has_pty = false;
if let Some(pty) = self.pty_instances.get(task_name) { 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]; let mut terminal_pane_data = &mut self.terminal_pane_data[pane_idx];
terminal_pane_data.is_continuous = task.continuous; 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; let mut has_pty = false;
if let Some(pty) = self.pty_instances.get(task_name) { if let Some(pty) = self.pty_instances.get(task_name) {

View File

@ -21,7 +21,7 @@ pub struct TerminalPaneData {
pub pty: Option<Arc<PtyInstance>>, pub pty: Option<Arc<PtyInstance>>,
pub is_interactive: bool, pub is_interactive: bool,
pub is_continuous: bool, pub is_continuous: bool,
pub is_cache_hit: bool, pub can_be_interactive: bool,
} }
impl TerminalPaneData { impl TerminalPaneData {
@ -30,7 +30,7 @@ impl TerminalPaneData {
pty: None, pty: None,
is_interactive: false, is_interactive: false,
is_continuous: false, is_continuous: false,
is_cache_hit: false, can_be_interactive: false,
} }
} }
@ -91,20 +91,11 @@ impl TerminalPaneData {
} }
return Ok(()); return Ok(());
} }
// Handle 'i' to enter interactive mode for non cache hit tasks // Handle 'i' to enter interactive mode for in progress tasks
KeyCode::Char('i') if !self.is_cache_hit && !self.is_interactive => { KeyCode::Char('i') if self.can_be_interactive && !self.is_interactive => {
self.set_interactive(true); self.set_interactive(true);
return Ok(()); 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 // Only send input to PTY if we're in interactive mode
_ if self.is_interactive => match key.code { _ if self.is_interactive => match key.code {
KeyCode::Char(c) => { KeyCode::Char(c) => {
@ -435,7 +426,7 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
} }
// Show interactive/readonly status for focused, non-cache hit, tasks // 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 // 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

@ -202,11 +202,7 @@ impl AppLifeCycle {
// Handle events using our Tui abstraction // Handle events using our Tui abstraction
if let Some(event) = tui.next().await { if let Some(event) = tui.next().await {
if let Ok(mut app) = app_mutex.lock() { if let Ok(mut app) = app_mutex.lock() {
if let Ok(true) = app.handle_event(event, &action_tx) { let _ = app.handle_event(event, &action_tx);
tui.exit().ok();
app.call_done_callback();
break;
}
// Check if we should quit based on the timer // Check if we should quit based on the timer
if let Some(quit_time) = app.quit_at { if let Some(quit_time) = app.quit_at {

View File

@ -32,13 +32,6 @@ pub fn normalize_newlines(input: &[u8]) -> Vec<u8> {
output 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. /// Sorts a list of TaskItems with a stable, total ordering.
/// ///
/// The sort order is: /// The sort order is:

View File

@ -680,6 +680,12 @@ export class TaskOrchestrator {
this.runningContinuousTasks.set(task.id, runningTask); this.runningContinuousTasks.set(task.id, runningTask);
runningTask.onExit(() => { runningTask.onExit(() => {
if (this.tuiEnabled) {
this.options.lifeCycle.setTaskStatus(
task.id,
NativeTaskStatus.Stopped
);
}
this.runningContinuousTasks.delete(task.id); this.runningContinuousTasks.delete(task.id);
}); });
@ -744,6 +750,9 @@ export class TaskOrchestrator {
this.runningContinuousTasks.set(task.id, childProcess); this.runningContinuousTasks.set(task.id, childProcess);
childProcess.onExit(() => { childProcess.onExit(() => {
if (this.tuiEnabled) {
this.options.lifeCycle.setTaskStatus(task.id, NativeTaskStatus.Stopped);
}
this.runningTasksService.removeRunningTask(task.id); this.runningTasksService.removeRunningTask(task.id);
this.runningContinuousTasks.delete(task.id); this.runningContinuousTasks.delete(task.id);
}); });