Skip to content

Commit 6f78300

Browse files
authored
feat(core): add hints and status messages to the tui (#33838)
## Current Behavior When users press unhandled keys in the TUI (e.g., pressing `i` on a completed task, or typing in a non-interactive terminal pane), nothing happens and there's no feedback explaining why. Similarly, when users press certain key bindings like `c` to copy output, the action succeeds but there's no visual confirmation. ## Expected Behavior ### Hint Popups for Unhandled Keys Users now see helpful hint popups when pressing keys that don't work in the current context: - Pressing `i`, `c`, or `Ctrl+A` in the dependency view (task hasn't started yet) - Pressing `i` on a task that doesn't support interactive mode - Pressing character keys in a terminal pane that's not in interactive mode The hints explain what's happening and guide users on how to proceed. ### Status Messages for "Invisible" Actions When users perform actions without obvious visual feedback, a status message now appears in the terminal pane's bottom border: - `Output copied` when pressing `c` to copy - `Sent to assistant` when pressing `Ctrl+A` ### Configuration Option Users who prefer not to see hint popups can disable them in `nx.json`: ```json { "tui": { "suppressHints": true } } ```
1 parent a64d1b2 commit 6f78300

11 files changed

Lines changed: 418 additions & 63 deletions

File tree

packages/nx/schemas/nx-schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@
9797
],
9898
"description": "Whether to exit the TUI automatically after all tasks finish. If set to `true`, the TUI will exit immediately. If set to `false` the TUI will not automatically exit. If set to a number, an interruptible countdown popup will be shown for that many seconds before the TUI exits.",
9999
"default": 3
100+
},
101+
"suppressHints": {
102+
"type": "boolean",
103+
"description": "Whether to suppress hint popups that provide guidance for unhandled keys.",
104+
"default": false
100105
}
101106
},
102107
"additionalProperties": false

packages/nx/src/config/nx-json.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -876,6 +876,11 @@ export interface NxJsonConfiguration<T = '*' | string[]> {
876876
* - If set to a number, an interruptible countdown popup will be shown for that many seconds before the TUI exits.
877877
*/
878878
autoExit?: boolean | number;
879+
/**
880+
* Whether to suppress hint popups that provide guidance for unhandled keys.
881+
* Defaults to `false` (hints are shown).
882+
*/
883+
suppressHints?: boolean;
879884
};
880885
}
881886

packages/nx/src/native/index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,7 @@ export interface TuiCliArgs {
498498

499499
export interface TuiConfig {
500500
autoExit?: boolean | number | undefined
501+
suppressHints?: boolean
501502
}
502503

503504
export interface UpdatedWorkspaceFiles {

packages/nx/src/native/tui/action.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,5 @@ pub enum Action {
3636
SendConsoleMessage(String),
3737
ConsoleMessengerAvailable(bool),
3838
EndCommand,
39+
ShowHint(String),
3940
}

packages/nx/src/native/tui/app.rs

Lines changed: 91 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ use super::components::Component;
2929
use super::components::countdown_popup::CountdownPopup;
3030
use super::components::dependency_view::{DependencyView, DependencyViewState};
3131
use super::components::help_popup::HelpPopup;
32+
use super::components::hint_popup::HintPopup;
3233
use super::components::layout_manager::{
3334
LayoutAreas, LayoutManager, PaneArrangement, TaskListVisibility,
3435
};
@@ -46,6 +47,9 @@ use crate::native::ide::nx_console::messaging::NxConsoleMessageConnection;
4647
use crate::native::tui::graph_utils::get_failed_dependencies;
4748
use crate::native::utils::time::current_timestamp_millis;
4849

50+
/// Duration before status messages in terminal panes are automatically cleared
51+
const STATUS_MESSAGE_DURATION: std::time::Duration = std::time::Duration::from_secs(3);
52+
4953
pub struct App {
5054
pub components: Vec<Box<dyn Component>>,
5155
pub quit_at: Option<std::time::Instant>,
@@ -88,6 +92,7 @@ pub enum Focus {
8892
MultipleOutput(usize),
8993
HelpPopup,
9094
CountdownPopup,
95+
HintPopup,
9196
}
9297

9398
impl App {
@@ -121,11 +126,13 @@ impl App {
121126
);
122127
let help_popup = HelpPopup::new();
123128
let countdown_popup = CountdownPopup::new();
129+
let hint_popup = HintPopup::new();
124130

125131
let components: Vec<Box<dyn Component>> = vec![
126132
Box::new(tasks_list),
127133
Box::new(help_popup),
128134
Box::new(countdown_popup),
135+
Box::new(hint_popup),
129136
];
130137

131138
let main_terminal_pane_data = TerminalPaneData::new();
@@ -518,6 +525,22 @@ impl App {
518525
return Ok(false);
519526
}
520527

528+
// If hint popup is open, only ESC dismisses it
529+
if matches!(self.focus, Focus::HintPopup) {
530+
if let Some(hint_popup) = self
531+
.components
532+
.iter_mut()
533+
.find_map(|c| c.as_any_mut().downcast_mut::<HintPopup>())
534+
{
535+
if key.code == KeyCode::Esc {
536+
hint_popup.hide();
537+
self.update_focus(self.previous_focus);
538+
}
539+
// All other keys are consumed while hint popup is visible
540+
}
541+
return Ok(false);
542+
}
543+
521544
if let Some(tasks_list) = self
522545
.components
523546
.iter_mut()
@@ -780,6 +803,9 @@ impl App {
780803
Focus::CountdownPopup => {
781804
// Countdown popup has its own key handling above
782805
}
806+
Focus::HintPopup => {
807+
// Hint popup has its own key handling above
808+
}
783809
}
784810
}
785811
}
@@ -821,6 +847,30 @@ impl App {
821847
messenger.update_running_tasks(&tasks_list.tasks, &self.pty_instances)
822848
})
823849
});
850+
851+
// Auto-dismiss hint popup after duration elapsed
852+
if let Some(hint_popup) = self
853+
.components
854+
.iter_mut()
855+
.find_map(|c| c.as_any_mut().downcast_mut::<HintPopup>())
856+
{
857+
if hint_popup.should_auto_dismiss() {
858+
hint_popup.hide();
859+
// Restore focus if hint popup was focused
860+
if matches!(self.focus, Focus::HintPopup) {
861+
self.update_focus(self.previous_focus);
862+
}
863+
}
864+
}
865+
866+
// Clear expired status messages from terminal panes
867+
for terminal_pane_data in &mut self.terminal_pane_data {
868+
if let Some((_, shown_at)) = &terminal_pane_data.status_message {
869+
if shown_at.elapsed() > STATUS_MESSAGE_DURATION {
870+
terminal_pane_data.status_message = None;
871+
}
872+
}
873+
}
824874
}
825875
// Quit immediately
826876
Action::Quit => {
@@ -1036,18 +1086,29 @@ impl App {
10361086
}
10371087
}
10381088

1039-
// Draw the help popup and countdown popup
1040-
let (first_part, second_part) = self.components.split_at_mut(2);
1041-
let help_popup = first_part[1]
1042-
.as_any_mut()
1043-
.downcast_mut::<HelpPopup>()
1044-
.unwrap();
1045-
let countdown_popup = second_part[0]
1046-
.as_any_mut()
1047-
.downcast_mut::<CountdownPopup>()
1048-
.unwrap();
1049-
let _ = help_popup.draw(f, frame_area);
1050-
let _ = countdown_popup.draw(f, frame_area);
1089+
// Draw the popups (help, countdown, interstitial)
1090+
// Draw each popup sequentially to avoid multiple mutable borrows
1091+
if let Some(help_popup) = self
1092+
.components
1093+
.iter_mut()
1094+
.find_map(|c| c.as_any_mut().downcast_mut::<HelpPopup>())
1095+
{
1096+
let _ = help_popup.draw(f, frame_area);
1097+
}
1098+
if let Some(countdown_popup) = self
1099+
.components
1100+
.iter_mut()
1101+
.find_map(|c| c.as_any_mut().downcast_mut::<CountdownPopup>())
1102+
{
1103+
let _ = countdown_popup.draw(f, frame_area);
1104+
}
1105+
if let Some(hint_popup) = self
1106+
.components
1107+
.iter_mut()
1108+
.find_map(|c| c.as_any_mut().downcast_mut::<HintPopup>())
1109+
{
1110+
let _ = hint_popup.draw(f, frame_area);
1111+
}
10511112
})
10521113
.ok();
10531114
}
@@ -1061,6 +1122,19 @@ impl App {
10611122
Action::EndCommand => {
10621123
self.handle_end_command();
10631124
}
1125+
Action::ShowHint(message) => {
1126+
// Only show hints if not suppressed by config
1127+
if !self.tui_config.suppress_hints {
1128+
if let Some(hint_popup) = self
1129+
.components
1130+
.iter_mut()
1131+
.find_map(|c| c.as_any_mut().downcast_mut::<HintPopup>())
1132+
{
1133+
hint_popup.show(message.clone());
1134+
self.update_focus(Focus::HintPopup);
1135+
}
1136+
}
1137+
}
10641138
_ => {}
10651139
}
10661140

@@ -1265,6 +1339,7 @@ impl App {
12651339
}
12661340
Focus::HelpPopup => Focus::TaskList,
12671341
Focus::CountdownPopup => Focus::TaskList,
1342+
Focus::HintPopup => Focus::TaskList,
12681343
};
12691344

12701345
self.update_focus(focus);
@@ -1330,6 +1405,7 @@ impl App {
13301405
}
13311406
Focus::HelpPopup => Focus::TaskList,
13321407
Focus::CountdownPopup => Focus::TaskList,
1408+
Focus::HintPopup => Focus::TaskList,
13331409
};
13341410

13351411
self.update_focus(focus);
@@ -1479,10 +1555,11 @@ impl App {
14791555
if matches!(task_status, TaskStatus::NotStarted | TaskStatus::Skipped) {
14801556
// Task is pending - handle keys in dependency view
14811557
if let Some(dep_state) = &mut self.dependency_view_states[pane_idx] {
1482-
if dep_state.handle_key_event(key) {
1483-
return Ok(()); // Key was handled by dependency view
1558+
if let Some(action) = dep_state.handle_key_event(key) {
1559+
self.dispatch_action(action);
14841560
}
14851561
}
1562+
return Ok(());
14861563
} else {
14871564
// Task is running/completed - handle keys in terminal pane
14881565
let terminal_pane_data = &mut self.terminal_pane_data[pane_idx];

packages/nx/src/native/tui/components.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ pub mod countdown_popup;
1313
pub mod dependency_view;
1414
pub mod help_popup;
1515
pub mod help_text;
16+
pub mod hint_popup;
1617
pub mod layout_manager;
1718
pub mod task_selection_manager;
1819
pub mod tasks_list;

packages/nx/src/native/tui/components/dependency_view.rs

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::collections::HashMap;
22

33
use crate::native::tasks::types::TaskGraph;
4+
use crate::native::tui::action::Action;
45
use crate::native::tui::components::tasks_list::TaskStatus;
56
use crate::native::tui::graph_utils::{get_dependency_chain_failures, is_task_continuous};
67
use crate::native::tui::status_icons;
@@ -108,8 +109,9 @@ impl DependencyViewState {
108109
(pane_area.height as usize).saturating_sub(4) // 2 for borders, 2 for padding
109110
}
110111

111-
/// Handle key event with automatic viewport height calculation from stored pane area
112-
pub fn handle_key_event(&mut self, key: crossterm::event::KeyEvent) -> bool {
112+
/// Handle key event with automatic viewport height calculation from stored pane area.
113+
/// Returns Some(Action) if an action should be dispatched, None otherwise.
114+
pub fn handle_key_event(&mut self, key: crossterm::event::KeyEvent) -> Option<Action> {
113115
let viewport_height = Self::calculate_viewport_height(self.pane_area);
114116
self.handle_key_event_with_viewport(key, viewport_height)
115117
}
@@ -118,7 +120,7 @@ impl DependencyViewState {
118120
&mut self,
119121
key: crossterm::event::KeyEvent,
120122
viewport_height: usize,
121-
) -> bool {
123+
) -> Option<Action> {
122124
use crossterm::event::{KeyCode, KeyModifiers};
123125

124126
// Only handle keys if there's actually content to scroll
@@ -130,40 +132,41 @@ impl DependencyViewState {
130132
KeyCode::Up | KeyCode::Char('k') => {
131133
if has_scrollable_content && self.scroll_offset > 0 {
132134
self.scroll_up();
133-
true
134-
} else {
135-
false
136135
}
136+
None
137137
}
138138
KeyCode::Down | KeyCode::Char('j') => {
139139
if has_scrollable_content && self.scroll_offset < max_scroll {
140140
self.scroll_down(viewport_height);
141-
true
142-
} else {
143-
false
144141
}
142+
None
145143
}
146144
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
147145
if has_scrollable_content && self.scroll_offset > 0 {
148146
for _ in 0..12 {
149147
self.scroll_up();
150148
}
151-
true
152-
} else {
153-
false
154149
}
150+
None
155151
}
156152
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
157153
if has_scrollable_content && self.scroll_offset < max_scroll {
158154
for _ in 0..12 {
159155
self.scroll_down(viewport_height);
160156
}
161-
true
162-
} else {
163-
false
164157
}
158+
None
159+
}
160+
// Show hint for keys that users might expect to work but don't in dependency view
161+
KeyCode::Char('i') | KeyCode::Char('c') => Some(Action::ShowHint(
162+
"This task hasn't started yet. Keyboard shortcuts will be available once the task begins running.".to_string(),
163+
)),
164+
KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
165+
Some(Action::ShowHint(
166+
"This task hasn't started yet. Keyboard shortcuts will be available once the task begins running.".to_string(),
167+
))
165168
}
166-
_ => false,
169+
_ => None,
167170
}
168171
}
169172
}

0 commit comments

Comments
 (0)