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},
tty::IsTty,
};
use itertools::Itertools;
use napi::bindgen_prelude::*;
use portable_pty::{CommandBuilder, NativePtySystem, PtyPair, PtySize, PtySystem};
use std::io::stdout;

View File

@ -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::<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
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::<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 {
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::<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 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 {

View File

@ -91,7 +91,7 @@ impl HelpPopup {
let keybindings = vec![
// Misc
("?", "Toggle this popup"),
("<ctrl>+c", "Quit the TUI"),
("q or <ctrl>+c", "Quit the TUI"),
("", ""),
// Navigation
("↑ or k", "Navigate/scroll task output up"),
@ -106,6 +106,8 @@ impl HelpPopup {
("<esc>", "Clear filter"),
("", ""),
// Output Controls
("<enter>", "Open and focus terminal for task"),
("<esc>", "Set focus back to task list"),
("<space>", "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::<usize>() + 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

View File

@ -36,7 +36,7 @@ impl HelpText {
// Show minimal hint
let hint = vec![
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("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("<ctrl>+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("<tab>", base_style.fg(Color::Cyan)),
Span::styled("show output: ", base_style.fg(Color::DarkGray)),
Span::styled("<enter>", base_style.fg(Color::Cyan)),
];
f.render_widget(

View File

@ -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<TaskResult>) {
@ -1732,7 +1769,7 @@ impl Component for TasksList {
)
} else {
format!(
" -> {} tasks filtered out. Press / to persist, <esc> to clear",
" -> {} tasks filtered out. Press <enter> to persist, <esc> 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) {

View File

@ -21,7 +21,7 @@ pub struct TerminalPaneData {
pub pty: Option<Arc<PtyInstance>>,
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![

View File

@ -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 {

View File

@ -32,13 +32,6 @@ pub fn normalize_newlines(input: &[u8]) -> Vec<u8> {
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:

View File

@ -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);
});