feat(maestro-tui): status bar + focus highlight + j/k sidebar nav (NAP-287)
ci / rust (push) Successful in 15m39s

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.
This commit is contained in:
2026-05-29 09:01:36 +00:00
parent 1cafa2c2af
commit f27adf5aba
7 changed files with 160 additions and 82 deletions
+6
View File
@@ -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),
+22
View File
@@ -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<Uuid> = 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<Uuid> = 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(),
+5
View File
@@ -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"),
+63 -6
View File
@@ -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::*;
+17 -2
View File
@@ -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);
+47
View File
@@ -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();