From f27adf5abab8a1c6c11f1ffb4ec76f827f78cf16 Mon Sep 17 00:00:00 2001 From: naps62-yolo Date: Fri, 29 May 2026 09:01:36 +0000 Subject: [PATCH] feat(maestro-tui): status bar + focus highlight + j/k sidebar nav (NAP-287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First-use UX fix: the TUI rendered the daemon list but felt frozen because mode and focus were invisible, and the sidebar had no nav keys, so `Enter`/`AttachHighlighted` had nothing to target. - `render::draw` now reserves the bottom row for a status bar that always shows `[NORMAL]`/`[INSERT]` (cyan/yellow chip), the focused panel (sidebar / pane), and the most useful key hints (`j/k: nav • enter: attach • s: spawn • ?: help • ^Wq: quit`). Status bar is never eclipsed by overlays. - `Sidebar::render_with_focus` and the pane block now draw their border in cyan when focused, in the default colour otherwise. - New `Action::SidebarNext` / `SidebarPrev`, bound to `j`/`k` and `down`/`up` in the shipped Normal-mode defaults. Dispatch walks the catalog's session list, calling `AppState::move_sidebar_highlight` (wraps both ends). Workspace clippy + 85 unit tests + 3 integration tests green. --- Cargo.lock | 74 ------------------------------- crates/maestro-tui/src/action.rs | 6 +++ crates/maestro-tui/src/app.rs | 22 +++++++++ crates/maestro-tui/src/config.rs | 5 +++ crates/maestro-tui/src/render.rs | 69 +++++++++++++++++++++++++--- crates/maestro-tui/src/sidebar.rs | 19 +++++++- crates/maestro-tui/src/state.rs | 47 ++++++++++++++++++++ 7 files changed, 160 insertions(+), 82 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d737895..17e9488 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1239,7 +1239,6 @@ dependencies = [ "maestro-protocol", "rand 0.8.6", "reqwest", - "rust-embed", "serde", "serde_json", "tempfile", @@ -1749,40 +1748,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rust-embed" -version = "8.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" -dependencies = [ - "rust-embed-impl", - "rust-embed-utils", - "walkdir", -] - -[[package]] -name = "rust-embed-impl" -version = "8.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" -dependencies = [ - "proc-macro2", - "quote", - "rust-embed-utils", - "syn", - "walkdir", -] - -[[package]] -name = "rust-embed-utils" -version = "8.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" -dependencies = [ - "sha2", - "walkdir", -] - [[package]] name = "rustc-hash" version = "2.1.2" @@ -1887,15 +1852,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - [[package]] name = "scopeguard" version = "1.2.0" @@ -1994,17 +1950,6 @@ dependencies = [ "digest", ] -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "sharded-slab" version = "0.1.7" @@ -2697,16 +2642,6 @@ dependencies = [ "quote", ] -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - [[package]] name = "want" version = "0.3.1" @@ -2896,15 +2831,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/crates/maestro-tui/src/action.rs b/crates/maestro-tui/src/action.rs index 5fa209f..4ebf0e6 100644 --- a/crates/maestro-tui/src/action.rs +++ b/crates/maestro-tui/src/action.rs @@ -31,6 +31,10 @@ pub enum Action { FocusSidebar, FocusPane(Direction), + // Sidebar highlight movement + SidebarNext, + SidebarPrev, + // Catalog actions on the highlighted sidebar row AttachHighlighted, ToggleAttachHighlighted, @@ -70,6 +74,8 @@ impl Action { "EnterInsert" => return Ok(Action::EnterInsert), "LeaveInsert" => return Ok(Action::LeaveInsert), "FocusSidebar" => return Ok(Action::FocusSidebar), + "SidebarNext" => return Ok(Action::SidebarNext), + "SidebarPrev" => return Ok(Action::SidebarPrev), "AttachHighlighted" => return Ok(Action::AttachHighlighted), "ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted), "OpenSpawnModal" => return Ok(Action::OpenSpawnModal), diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 22dde8d..27af434 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -191,6 +191,28 @@ impl App { // either in the sidebar or back in the pane. self.state.focus = Focus::Pane; } + Action::SidebarNext => { + let order: Vec = self + .catalog + .snapshot() + .sessions + .iter() + .map(|s| s.id) + .collect(); + self.state.move_sidebar_highlight(&order, 1); + self.state.focus = Focus::Sidebar; + } + Action::SidebarPrev => { + let order: Vec = self + .catalog + .snapshot() + .sessions + .iter() + .map(|s| s.id) + .collect(); + self.state.move_sidebar_highlight(&order, -1); + self.state.focus = Focus::Sidebar; + } Action::NewTab => self.state.new_tab(), Action::CloseTab => self.state.close_tab(), Action::NextTab => self.state.next_tab(), diff --git a/crates/maestro-tui/src/config.rs b/crates/maestro-tui/src/config.rs index b5fd3db..e4e7c9f 100644 --- a/crates/maestro-tui/src/config.rs +++ b/crates/maestro-tui/src/config.rs @@ -38,6 +38,11 @@ pub enum TuiConfigError { const DEFAULT_NORMAL: &[(&str, &str)] = &[ // Mode ("i", "EnterInsert"), + // Sidebar nav + ("j", "SidebarNext"), + ("k", "SidebarPrev"), + ("down", "SidebarNext"), + ("up", "SidebarPrev"), // Catalog actions ("enter", "AttachHighlighted"), ("s", "OpenSpawnModal"), diff --git a/crates/maestro-tui/src/render.rs b/crates/maestro-tui/src/render.rs index 72c93d3..645b9ad 100644 --- a/crates/maestro-tui/src/render.rs +++ b/crates/maestro-tui/src/render.rs @@ -3,14 +3,17 @@ //! hit-test mouse clicks. use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::widgets::{Block, Borders}; +use ratatui::style::{Color, Style}; +use ratatui::text::Span; +use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::Frame; use crate::input::Region; +use crate::keymap::Mode; use crate::overlays::{HelpOverlay, KillConfirm, SpawnModal, Toast}; use crate::pane::Pane; use crate::sidebar::Sidebar; -use crate::state::{AppState, OverlayKind}; +use crate::state::{AppState, Focus, OverlayKind}; use crate::tabs::TabBar; /// Tagged regions for mouse hit-testing. The input router resolves a mouse @@ -71,22 +74,32 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap { let mut map = RegionMap::default(); let area = f.area(); + // Split the screen into top (everything) + 1-row status bar at the bottom. + let outer = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(1)]) + .split(area); + let body_area = outer[0]; + let status_area = outer[1]; + let cols = if inputs.state.sidebar_visible { Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Length(28), Constraint::Min(0)]) - .split(area) + .split(body_area) } else { Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Length(0), Constraint::Min(0)]) - .split(area) + .split(body_area) }; let sidebar_area = cols[0]; let right = cols[1]; if inputs.state.sidebar_visible { - inputs.sidebar.render(f, sidebar_area); + inputs + .sidebar + .render_with_focus(f, sidebar_area, inputs.state.focus == Focus::Sidebar); map.sidebar = Some(sidebar_area); } @@ -100,7 +113,10 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap { inputs.tab_bar.render(f, tab_area); map.tab_bar = Some(tab_area); - let pane_block = Block::default().borders(Borders::ALL); + let pane_focused = inputs.state.focus == Focus::Pane; + let pane_block = Block::default() + .borders(Borders::ALL) + .border_style(focus_style(pane_focused)); let pane_inner = pane_block.inner(pane_area); f.render_widget(pane_block, pane_area); if let Some(p) = inputs.pane { @@ -108,6 +124,10 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap { } map.pane = Some(pane_inner); + // Status bar — always visible, never eclipsed by overlays so the user can + // always see the current mode + key hints. + render_status_bar(f, status_area, inputs.state); + match &inputs.state.overlay { Some(OverlayKind::Spawn) => { inputs.spawn.render(f, area); @@ -134,6 +154,43 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap { map } +/// Border style for a panel based on whether it currently has focus. +/// Focused = cyan; unfocused = the terminal default. +fn focus_style(focused: bool) -> Style { + if focused { + Style::default().fg(Color::Cyan) + } else { + Style::default() + } +} + +/// Render the always-visible status bar at the bottom of the frame. Surfaces +/// mode + focused panel + the most useful key hints so the user is never +/// guessing what state they're in. +fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) { + let mode_label = match state.mode { + Mode::Normal => "[NORMAL]", + Mode::Insert => "[INSERT]", + }; + let focus_label = match state.focus { + Focus::Sidebar => "sidebar", + Focus::Pane => "pane", + }; + let hint = match state.mode { + Mode::Normal => "j/k: nav • enter: attach • s: spawn • ?: help • ^Wq: quit", + Mode::Insert => "^a: back to normal", + }; + let mode_style = match state.mode { + Mode::Normal => Style::default().fg(Color::Black).bg(Color::Cyan), + Mode::Insert => Style::default().fg(Color::Black).bg(Color::Yellow), + }; + let line = ratatui::text::Line::from(vec![ + Span::styled(format!(" {mode_label} "), mode_style), + Span::raw(format!(" {focus_label} • {hint}")), + ]); + f.render_widget(Paragraph::new(line), area); +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index afc5df4..fdc2f4a 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -6,7 +6,7 @@ use std::collections::HashSet; use maestro_protocol::{DaemonInfo, Session, SessionStatus}; use ratatui::layout::Rect; -use ratatui::style::{Modifier, Style}; +use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::Frame; @@ -23,7 +23,22 @@ pub struct Sidebar { impl Sidebar { pub fn render(&self, f: &mut Frame, area: Rect) { - let block = Block::default().title(" daemons ").borders(Borders::ALL); + self.render_with_focus(f, area, false); + } + + /// Same as [`Sidebar::render`] but draws the border in the focus colour + /// when `focused`. Used by the top-level orchestrator to surface which + /// panel currently consumes navigation keys. + pub fn render_with_focus(&self, f: &mut Frame, area: Rect, focused: bool) { + let border_style = if focused { + Style::default().fg(Color::Cyan) + } else { + Style::default() + }; + let block = Block::default() + .title(" daemons ") + .borders(Borders::ALL) + .border_style(border_style); let inner = block.inner(area); f.render_widget(block, area); diff --git a/crates/maestro-tui/src/state.rs b/crates/maestro-tui/src/state.rs index 580159b..92f1211 100644 --- a/crates/maestro-tui/src/state.rs +++ b/crates/maestro-tui/src/state.rs @@ -120,6 +120,22 @@ impl AppState { self.focus = Focus::Pane; } + /// Move the sidebar highlight by `delta` (positive = down) within the + /// given ordered list of selectable session ids. Wraps. No-op on empty. + pub fn move_sidebar_highlight(&mut self, order: &[Uuid], delta: i32) { + if order.is_empty() { + return; + } + let len = order.len() as i32; + let cur = self + .sidebar_highlight + .and_then(|id| order.iter().position(|x| *x == id)) + .map(|i| i as i32) + .unwrap_or(-1); + let next = (cur + delta).rem_euclid(len) as usize; + self.sidebar_highlight = Some(order[next]); + } + /// Toggle sidebar visibility. pub fn toggle_sidebar(&mut self) { self.sidebar_visible = !self.sidebar_visible; @@ -204,6 +220,37 @@ mod tests { assert_eq!(state.active_tab_idx, state.tabs.len() - 1); } + #[test] + fn move_sidebar_highlight_walks_order_and_wraps() { + let mut state = AppState::new(); + let a = Uuid::from_u128(1); + let b = Uuid::from_u128(2); + let c = Uuid::from_u128(3); + let order = vec![a, b, c]; + + // No prior highlight + delta +1 lands on first item. + state.move_sidebar_highlight(&order, 1); + assert_eq!(state.sidebar_highlight, Some(a)); + + state.move_sidebar_highlight(&order, 1); + assert_eq!(state.sidebar_highlight, Some(b)); + state.move_sidebar_highlight(&order, 1); + assert_eq!(state.sidebar_highlight, Some(c)); + // Wraps forward. + state.move_sidebar_highlight(&order, 1); + assert_eq!(state.sidebar_highlight, Some(a)); + // Wraps backward. + state.move_sidebar_highlight(&order, -1); + assert_eq!(state.sidebar_highlight, Some(c)); + } + + #[test] + fn move_sidebar_highlight_is_noop_on_empty_order() { + let mut state = AppState::new(); + state.move_sidebar_highlight(&[], 1); + assert_eq!(state.sidebar_highlight, None); + } + #[test] fn attach_binds_to_active_tab_and_focuses_pane() { let mut state = AppState::new();