TUI usability improvements#294
Conversation
Press 'm' to cycle Peak Memory sort (desc → asc → off) and 'p' to cycle Peak CPU % sort. Column headers show ↓ / ↑ indicators when active. Also renames the Results column from "Avg CPU %" to "Peak CPU %" since it has always read peak_cpu_percent, not avg_cpu_percent. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… UX, scoped help
- Jobs tab sorting via 1/2/3 (cycles ID/Name/Status: desc → asc → off)
with arrow indicators in column headers
- g / G jump to top / bottom of the focused table (Workflows or any
Detail pane)
- Preserve selection across workflow refresh and Jobs / Results sort
cycles instead of snapping back to row 0
- Show active filter ("filter: column~value") in every table title in
magenta when a filter is set
- '=' applies a filter using the selected row's primary column value
(Workflows → User, Jobs / Results / Scheduled Nodes → Status,
Events → Event Type, Files → Name, Compute Nodes → Hostname)
- Scope the ? help popup to the active pane / detail tab via
HelpContext, showing only the relevant section plus globals
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR improves TUI usability by adding context-aware help, visible filter state in table titles, quick navigation, and table sorting for Jobs/Results.
Changes:
- Add
HelpContextand render context-specific help content in the help popup. - Show active filter state in table titles and add
=shortcut to filter by the currently selected row. - Add jump-to-top/bottom keys (
g/G) and add sorting controls + indicators for Jobs (1/2/3) and Results (m/p).
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| src/tui/ui.rs | Adds help-context mapping, filter suffix display in titles, and sort indicators in headers. |
| src/tui/components.rs | Reworks help popup to show context-specific keybindings via HelpContext. |
| src/tui/app.rs | Adds sort state + sorting/selection restore logic, jump-to-top/bottom, and filter-by-row behavior. |
| src/tui.rs | Wires new keybindings for jumping, filter-by-row, and sorting. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- refresh_workflows: clamp stale selection index after refresh and clear selection on empty list, instead of leaving an out-of-bounds index - restore_results_selection / restore_jobs_selection: explicitly select None when the list becomes empty so a stale index can't be used by later actions - JobsSort id ordering: keep None last in both directions to match the None-last convention used by the Results sort and the status sort - JobsSort name ordering: use sort_by_cached_key so we only allocate the lowercased key once per row instead of per comparison Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- filter_by_current_row: don't mutate filter_target before validating that column_name resolves; restore the prior target on the early return path so a bad mapping can't leave the app inconsistent - ResultsSort PeakCpu*: use f64::total_cmp instead of partial_cmp + unwrap_or(Equal). The latter violates the strict-weak-ordering required by sort_by when NaN is present and can scramble unrelated rows - help_context_for: while a popup is open, focus is Focus::Popup and the prior focus is stashed in previous_focus. Read previous_focus so the help popup keeps showing the pane-specific section instead of collapsing to the generic Other view Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Previously c always cancelled the selected job in the Jobs detail tab, which meant a filter applied to the Jobs tab couldn't be cleared without leaving the tab. Now c uniformly clears the active filter on whatever pane has focus, and C cancels the selected job when on the Jobs detail tab (and otherwise cancels the selected workflow, matching its existing role). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Add g/G jump-to-top/bottom and PgUp/PgDn rows to Basic Navigation - Note that ? help is now context-aware (scoped to current pane / tab) - Update Job Management table: cancel is now C (was c) and add row for 1/2/3 column sort - New "Filtering and Sorting" section covering f / = / c keys, the filter-suffix shown in table titles, and the Jobs / Results sort shortcuts (with the sticky-selection behavior) - New "Results Tab" section listing the columns including the renamed Peak CPU % column Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pressing 'e' from either the Workflows pane or any Detail tab switches to the Events detail tab for the highlighted workflow and opens its live Server-Sent Events stream. Saves the 4-keystroke "Enter, Tab, Tab, Tab" walk to reach Events from the default Summary landing tab. The shortcut is special-cased rather than generalizing to "a letter per tab" because Events has the unique side effect of starting an SSE connection, and a generic scheme would conflict with existing keys (r=refresh, s=submit, f=filter, ...). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| pub fn cycle_results_sort_peak_memory(&mut self) { | ||
| let prev_id = self.selected_result_id(); | ||
| self.results_sort = self.results_sort.cycle_peak_memory(); | ||
| self.apply_results_sort(); | ||
| self.restore_results_selection(prev_id); | ||
| } | ||
|
|
||
| pub fn cycle_results_sort_peak_cpu(&mut self) { | ||
| let prev_id = self.selected_result_id(); | ||
| self.results_sort = self.results_sort.cycle_peak_cpu(); | ||
| self.apply_results_sort(); |
There was a problem hiding this comment.
ResultsSort::None currently leaves the vector in whatever order it had from the previous sort, so cycling to “off” doesn’t actually turn sorting off (it only hides the indicator). This conflicts with the documented/help behavior of cycling ↓ ↑ off. Consider making the None state actively restore the natural (unsorted) order for the current view, e.g., by rebuilding self.results from the canonical source (results_all + current filter) when sort becomes None, then re-applying selection restoration.
| pub fn cycle_results_sort_peak_memory(&mut self) { | |
| let prev_id = self.selected_result_id(); | |
| self.results_sort = self.results_sort.cycle_peak_memory(); | |
| self.apply_results_sort(); | |
| self.restore_results_selection(prev_id); | |
| } | |
| pub fn cycle_results_sort_peak_cpu(&mut self) { | |
| let prev_id = self.selected_result_id(); | |
| self.results_sort = self.results_sort.cycle_peak_cpu(); | |
| self.apply_results_sort(); | |
| fn restore_unsorted_results_order(&mut self) { | |
| let mut visible_counts: HashMap<Option<i64>, usize> = HashMap::new(); | |
| for result in &self.results { | |
| *visible_counts.entry(result.id).or_insert(0) += 1; | |
| } | |
| self.results = self | |
| .results_all | |
| .iter() | |
| .filter_map(|result| { | |
| let remaining = visible_counts.get_mut(&result.id)?; | |
| if *remaining == 0 { | |
| return None; | |
| } | |
| *remaining -= 1; | |
| Some(result.clone()) | |
| }) | |
| .collect(); | |
| } | |
| pub fn cycle_results_sort_peak_memory(&mut self) { | |
| let prev_id = self.selected_result_id(); | |
| self.results_sort = self.results_sort.cycle_peak_memory(); | |
| if matches!(self.results_sort, ResultsSort::None) { | |
| self.restore_unsorted_results_order(); | |
| } else { | |
| self.apply_results_sort(); | |
| } | |
| self.restore_results_selection(prev_id); | |
| } | |
| pub fn cycle_results_sort_peak_cpu(&mut self) { | |
| let prev_id = self.selected_result_id(); | |
| self.results_sort = self.results_sort.cycle_peak_cpu(); | |
| if matches!(self.results_sort, ResultsSort::None) { | |
| self.restore_unsorted_results_order(); | |
| } else { | |
| self.apply_results_sort(); | |
| } |
| /// Sort `self.jobs` in-place based on `self.jobs_sort`. Stable for None. | ||
| pub fn apply_jobs_sort(&mut self) { | ||
| match self.jobs_sort { | ||
| JobsSort::None => {} | ||
| // Sort missing IDs last in either direction (server-assigned IDs | ||
| // should always be present, but match the None-last convention | ||
| // used by the Results / Status sorts for consistency). | ||
| JobsSort::IdDesc => self | ||
| .jobs | ||
| .sort_by_key(|j| (j.id.is_none(), std::cmp::Reverse(j.id.unwrap_or(i64::MIN)))), | ||
| JobsSort::IdAsc => self | ||
| .jobs | ||
| .sort_by_key(|j| (j.id.is_none(), j.id.unwrap_or(i64::MAX))), | ||
| // Cache lowercased keys so we don't allocate on every comparison. | ||
| JobsSort::NameDesc => self | ||
| .jobs | ||
| .sort_by_cached_key(|j| std::cmp::Reverse(j.name.to_lowercase())), | ||
| JobsSort::NameAsc => self.jobs.sort_by_cached_key(|j| j.name.to_lowercase()), | ||
| JobsSort::StatusDesc => self.jobs.sort_by(|a, b| { | ||
| let ka = a.status.map(|s| s as u8).unwrap_or(u8::MAX); | ||
| let kb = b.status.map(|s| s as u8).unwrap_or(u8::MAX); | ||
| kb.cmp(&ka) | ||
| }), | ||
| JobsSort::StatusAsc => self.jobs.sort_by(|a, b| { | ||
| let ka = a.status.map(|s| s as u8).unwrap_or(u8::MAX); | ||
| let kb = b.status.map(|s| s as u8).unwrap_or(u8::MAX); | ||
| ka.cmp(&kb) | ||
| }), | ||
| } | ||
| } | ||
|
|
||
| pub fn cycle_jobs_sort_id(&mut self) { | ||
| let prev_id = self.selected_job_id(); | ||
| self.jobs_sort = self.jobs_sort.cycle_id(); | ||
| self.apply_jobs_sort(); | ||
| self.restore_jobs_selection(prev_id); | ||
| } | ||
|
|
||
| pub fn cycle_jobs_sort_name(&mut self) { | ||
| let prev_id = self.selected_job_id(); | ||
| self.jobs_sort = self.jobs_sort.cycle_name(); | ||
| self.apply_jobs_sort(); | ||
| self.restore_jobs_selection(prev_id); | ||
| } | ||
|
|
||
| pub fn cycle_jobs_sort_status(&mut self) { | ||
| let prev_id = self.selected_job_id(); | ||
| self.jobs_sort = self.jobs_sort.cycle_status(); | ||
| self.apply_jobs_sort(); | ||
| self.restore_jobs_selection(prev_id); | ||
| } |
There was a problem hiding this comment.
Same issue as Results sorting: when JobsSort::None is selected, the table remains in the previously-sorted order, so cycling to “off” doesn’t actually revert to the natural order (it only removes the arrow indicator). To match the UI/docs (cycles ↓ ↑ off), consider restoring the unsorted order when switching to None (e.g., rebuild self.jobs from jobs_all + current filter, then re-apply selection restoration). Also, the doc comment Stable for None is misleading in the current behavior—either restore natural order for None or update the comment/help/docs to reflect that None means “keep current order.”
No description provided.