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:
parent
85bc540a15
commit
2c8aba2fc2
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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![
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user