diff --git a/crates/maestro-tui/src/action.rs b/crates/maestro-tui/src/action.rs index c3a3403..b96f7b2 100644 --- a/crates/maestro-tui/src/action.rs +++ b/crates/maestro-tui/src/action.rs @@ -47,13 +47,6 @@ pub enum Action { /// Open the retained-worktrees view (#85). ShowWorktrees, - // Tabs - NewTab, - CloseTab, - NextTab, - PrevTab, - GotoTab(u8), - // Chrome ToggleSidebar, ShowHelp, @@ -71,8 +64,37 @@ pub enum ActionParseError { } impl Action { - /// Parse a canonical action string like `"EnterInsert"`, `"FocusPane(Left)"`, - /// or `"GotoTab(3)"`. + /// Plain-language label for the in-app help menu. Configuration keeps the + /// stable enum spelling; operators should not have to decode it. + pub fn help_label(&self) -> String { + match self { + Action::Noop => "Do nothing".into(), + Action::EnterInsert => "Send keys to session".into(), + Action::LeaveInsert => "Return to sidebar".into(), + Action::FocusSidebar => "Focus sidebar".into(), + Action::FocusPane(direction) => { + format!( + "Focus session pane ({})", + format!("{direction:?}").to_lowercase() + ) + } + Action::SidebarNext => "Select next sidebar item".into(), + Action::SidebarPrev => "Select previous sidebar item".into(), + Action::ToggleSidebarGroup => "Expand or collapse directory".into(), + Action::AttachHighlighted => "Open selected item".into(), + Action::ToggleAttachHighlighted => "Open selected item".into(), + Action::NewDraftPane => "Create a session".into(), + Action::FastForwardSpawn => "Create session with defaults".into(), + Action::KillHighlighted => "Kill selected session".into(), + Action::ShowWorktrees => "Show retained worktrees".into(), + Action::ToggleSidebar => "Show or hide sidebar".into(), + Action::ShowHelp => "Show help".into(), + Action::Quit => "Quit Maestro".into(), + } + } + + /// Parse a canonical action string like `"EnterInsert"` or + /// `"FocusPane(Left)"`. pub fn parse(s: &str) -> Result { let s = s.trim(); match s { @@ -89,10 +111,6 @@ impl Action { "FastForwardSpawn" => return Ok(Action::FastForwardSpawn), "KillHighlighted" => return Ok(Action::KillHighlighted), "ShowWorktrees" => return Ok(Action::ShowWorktrees), - "NewTab" => return Ok(Action::NewTab), - "CloseTab" => return Ok(Action::CloseTab), - "NextTab" => return Ok(Action::NextTab), - "PrevTab" => return Ok(Action::PrevTab), "ToggleSidebar" => return Ok(Action::ToggleSidebar), "ShowHelp" => return Ok(Action::ShowHelp), "Quit" => return Ok(Action::Quit), @@ -113,13 +131,6 @@ impl Action { })?; Ok(Action::FocusPane(dir)) } - "GotoTab" => { - let n: u8 = arg.parse().map_err(|_| ActionParseError::BadArg { - action: name.to_string(), - detail: format!("expected u8 0..=255, got {arg:?}"), - })?; - Ok(Action::GotoTab(n)) - } _ => Err(ActionParseError::Unknown(name.to_string())), } } @@ -139,7 +150,6 @@ impl fmt::Display for Action { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Action::FocusPane(d) => write!(f, "FocusPane({d:?})"), - Action::GotoTab(n) => write!(f, "GotoTab({n})"), other => write!(f, "{other:?}"), } } @@ -156,6 +166,13 @@ mod tests { assert_eq!(Action::parse(" Noop ").unwrap(), Action::Noop); } + #[test] + fn help_labels_are_plain_language() { + assert_eq!(Action::EnterInsert.help_label(), "Send keys to session"); + assert_eq!(Action::AttachHighlighted.help_label(), "Open selected item"); + assert!(!Action::NewDraftPane.help_label().contains("NewDraftPane")); + } + #[test] fn parses_focus_pane_directions() { assert_eq!( @@ -168,12 +185,6 @@ mod tests { ); } - #[test] - fn parses_goto_tab() { - assert_eq!(Action::parse("GotoTab(3)").unwrap(), Action::GotoTab(3)); - assert_eq!(Action::parse("GotoTab(0)").unwrap(), Action::GotoTab(0)); - } - #[test] fn rejects_unknown_action() { let err = Action::parse("Frobnicate").unwrap_err(); @@ -188,22 +199,10 @@ mod tests { ); } - #[test] - fn rejects_non_numeric_tab_arg() { - let err = Action::parse("GotoTab(abc)").unwrap_err(); - assert!(matches!(err, ActionParseError::BadArg { ref action, .. } if action == "GotoTab")); - } - - #[test] - fn rejects_overflow_tab_arg() { - let err = Action::parse("GotoTab(9999)").unwrap_err(); - assert!(matches!(err, ActionParseError::BadArg { .. })); - } - #[test] fn rejects_unclosed_paren() { - let err = Action::parse("GotoTab(3").unwrap_err(); - assert_eq!(err, ActionParseError::Malformed("GotoTab(3".into())); + let err = Action::parse("FocusPane(Left").unwrap_err(); + assert_eq!(err, ActionParseError::Malformed("FocusPane(Left".into())); } /// The fast-forward pair sits next to the draft-pane key (#67). @@ -223,7 +222,6 @@ mod tests { Action::Quit, Action::NewDraftPane, Action::FocusPane(Direction::Right), - Action::GotoTab(7), ] { let s = a.to_string(); assert_eq!(Action::parse(&s).unwrap(), a, "round-trip for {a:?}"); diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index d4cf7bc..87d299e 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -40,7 +40,6 @@ use crate::render::{draw, RegionMap, RenderInputs}; use crate::session_task::{SessionInput, SessionTask}; use crate::sidebar::Sidebar; use crate::state::{AppState, Focus, OverlayKind, PaneRef, SidebarItem}; -use crate::tabs::TabBar; use crate::worktrees::WorktreesView; use crate::TuiError; @@ -361,7 +360,10 @@ impl App { match act { Action::Noop | Action::Quit => {} Action::EnterInsert => self.state.mode = Mode::Insert, - Action::LeaveInsert => self.state.mode = Mode::Normal, + Action::LeaveInsert => { + self.state.mode = Mode::Normal; + self.state.focus = Focus::Sidebar; + } Action::FocusSidebar => self.state.focus = Focus::Sidebar, Action::FocusPane(_) => { // v1 has one pane per tab; geometric focus motion lands @@ -385,15 +387,6 @@ impl App { self.state.focus = Focus::Sidebar; } Action::ToggleSidebarGroup => self.toggle_highlighted_sidebar_group(), - Action::NewTab => self.state.new_tab(), - Action::CloseTab => { - if let Some(sid) = self.state.close_tab() { - self.release_session(sid); - } - } - Action::NextTab => self.state.next_tab(), - Action::PrevTab => self.state.prev_tab(), - Action::GotoTab(n) => self.state.goto_tab(n), Action::ToggleSidebar => self.state.toggle_sidebar(), Action::ShowHelp => { self.help = HelpOverlay { @@ -402,13 +395,24 @@ impl App { self.state.show_overlay(OverlayKind::Help); } Action::NewDraftPane => { + if let Some(sid) = self.state.active_session() { + self.release_session(sid); + } self.state.open_draft_pane(); effects.push(Effect::LoadDraft(self.state.active_tab_idx)); } Action::FastForwardSpawn => effects.push(Effect::FastForward), Action::AttachHighlighted | Action::ToggleAttachHighlighted => { - if let Some(sid) = self.pick_sidebar_target() { - effects.push(Effect::Attach(sid)); + if let Some(SidebarItem::Group(key)) = self.state.sidebar_highlight.clone() { + self.toggle_sidebar_group(key); + } else if let Some(sid) = self.pick_sidebar_target() { + self.state.sidebar_highlight = Some(SidebarItem::Session(sid)); + if self.state.active_session() != Some(sid) { + if let Some(previous) = self.state.active_session() { + self.release_session(previous); + } + effects.push(Effect::Attach(sid)); + } self.state.attach_to_focused(sid); } } @@ -787,11 +791,6 @@ impl App { let resolved = crate::fast_forward::resolve(&discovered, &presets, names.first().cloned(), Utc::now()); if let Some(req) = resolved.build_request() { - // Never eat a pane already showing something: the spawn takes a - // fresh tab, same as opening a draft over a session would. - if self.state.active_tab().pane.is_some() { - self.state.new_tab(); - } self.submit_spawn(req).await; return; } @@ -817,6 +816,9 @@ impl App { // Fill the pane in place with the new session (web parity, // #57) and attach to it right away. let sid = session.id; + if let Some(previous) = self.state.active_session() { + self.release_session(previous); + } self.state.complete_spawn(sid); self.attach(sid); } @@ -897,10 +899,6 @@ impl App { highlight: self.state.sidebar_highlight.clone(), attached, }; - let tab_bar = TabBar { - tab_names: self.state.tabs.iter().map(|t| t.name.clone()).collect(), - active_idx: self.state.active_tab_idx, - }; let active_sid = self.state.active_session(); let pane = active_sid.and_then(|sid| { self.emulators.get(sid).map(|h| Pane { @@ -913,11 +911,21 @@ impl App { Some(PaneRef::Draft(d)) => Some(d), _ => None, }; + let pane_title = match self.state.active_tab().pane.as_ref() { + Some(PaneRef::Session(sid)) => snap + .sessions + .iter() + .find(|session| session.id == *sid) + .map(crate::sidebar::name_for) + .unwrap_or_else(|| "session".into()), + Some(PaneRef::Draft(_)) => "new session".into(), + None => "session".into(), + }; let inputs = RenderInputs { sidebar: &sidebar, home_dir: &self.home_dir, - tab_bar: &tab_bar, + pane_title: &pane_title, pane: pane.as_ref(), state: &self.state, draft, @@ -1030,14 +1038,14 @@ fn render_help_lines(cfg: &TuiConfig) -> Vec { fn section(mode: Mode, label: &str, cfg: &TuiConfig) -> Vec { let mut out = vec![format!("{label} mode"), String::new()]; for (input, action) in cfg.keymap.entries(mode) { - out.push(format!(" {input:<24} {action}")); + out.push(format!(" {input:<24} {}", action.help_label())); } out.push(String::new()); out } let leader = cfg.leader.canonical(); let mut lines = vec![ - format!("Leader: {leader} (Insert → Normal)"), + format!("Leader: {leader} (return to sidebar)"), String::new(), ]; lines.extend(section(Mode::Normal, "Normal", cfg)); @@ -1149,19 +1157,11 @@ mod tests { let mut app = make_app(); assert_eq!(app.state.mode, Mode::Normal); app.dispatch(Action::EnterInsert); + app.state.focus = Focus::Pane; assert_eq!(app.state.mode, Mode::Insert); app.dispatch(Action::LeaveInsert); assert_eq!(app.state.mode, Mode::Normal); - } - - #[test] - fn new_tab_action_extends_tabs() { - let mut app = make_app(); - app.dispatch(Action::NewTab); - app.dispatch(Action::NewTab); - assert_eq!(app.state.tabs.len(), 3); - app.dispatch(Action::CloseTab); - assert_eq!(app.state.tabs.len(), 2); + assert_eq!(app.state.focus, Focus::Sidebar); } #[test] @@ -1171,6 +1171,18 @@ mod tests { assert!(matches!(app.state.overlay(), Some(OverlayKind::Help))); } + #[test] + fn help_uses_plain_language_instead_of_action_names() { + let app = make_app(); + let help = render_help_lines(&app.cfg).join("\n"); + assert!(help.contains("Send keys to session")); + assert!(help.contains("Create a session")); + assert!(help.contains("Expand or collapse directory")); + assert!(!help.contains("EnterInsert")); + assert!(!help.contains("NewDraftPane")); + assert!(!help.contains("ToggleSidebarGroup")); + } + #[test] fn new_draft_pane_opens_a_focused_draft_in_the_empty_tab() { let mut app = make_app(); @@ -1184,21 +1196,19 @@ mod tests { assert!(app.state.overlay().is_none(), "no overlay involved"); } - /// A tab already showing a session gets a new tab for the draft. #[test] - fn new_draft_pane_over_a_session_opens_a_new_tab() { + fn new_draft_pane_over_a_session_reuses_the_pane() { let sid = Uuid::from_u128(10); let mut app = app_with_highlighted_session(sid); app.dispatch(Action::AttachHighlighted); app.dispatch(Action::NewDraftPane); - assert_eq!(app.state.tabs.len(), 2); - assert_eq!(app.state.active_tab_idx, 1); + assert_eq!(app.state.tabs.len(), 1); + assert_eq!(app.state.active_tab_idx, 0); assert!(matches!( app.state.active_tab().pane, Some(PaneRef::Draft(_)) )); - assert_eq!(app.state.tabs[0].session(), Some(sid)); } /// Seed the catalog with one session and highlight it. @@ -1261,10 +1271,11 @@ mod tests { vec![Effect::Attach(sid)] ); assert_eq!(app.state.active_tab().session(), Some(sid)); + assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(sid))); } #[test] - fn group_highlight_is_not_a_session_action_target() { + fn enter_on_a_group_toggles_it_without_targeting_a_session() { use maestro_protocol::CatalogEvent; let sid = Uuid::from_u128(14); @@ -1278,6 +1289,15 @@ mod tests { Some(SidebarItem::Group(PathBuf::from("/repo"))) ); assert!(app.dispatch(Action::AttachHighlighted).is_empty()); + assert!(app + .state + .collapsed_sidebar_groups + .contains(&PathBuf::from("/repo"))); + assert!(app.dispatch(Action::AttachHighlighted).is_empty()); + assert!(!app + .state + .collapsed_sidebar_groups + .contains(&PathBuf::from("/repo"))); assert!(app.dispatch(Action::KillHighlighted).is_empty()); assert!(app.state.overlay().is_none()); } @@ -1309,7 +1329,7 @@ mod tests { kind: MouseKind::Click, region: Region::SidebarRow, col: 3, - row: 2, + row: 1, }; assert!(app.handle_sidebar_mouse(&click)); assert_eq!( @@ -1337,7 +1357,7 @@ mod tests { .handle_event(CtEvent::Mouse(MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), column: 3, - row: 2, + row: 1, modifiers: crossterm::event::KeyModifiers::NONE, })) .await diff --git a/crates/maestro-tui/src/config.rs b/crates/maestro-tui/src/config.rs index 494278c..00e4678 100644 --- a/crates/maestro-tui/src/config.rs +++ b/crates/maestro-tui/src/config.rs @@ -40,7 +40,7 @@ pub enum TuiConfigError { const DEFAULT_NORMAL: &[(&str, &str)] = &[ // Mode ("i", "EnterInsert"), - // Sidebar nav + // Sidebar navigation ("j", "SidebarNext"), ("k", "SidebarPrev"), ("down", "SidebarNext"), @@ -54,10 +54,6 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[ ("S", "FastForwardSpawn"), ("x", "KillHighlighted"), ("w", "ShowWorktrees"), - // Tabs - ("ctrl-t", "NewTab"), - ("g t", "NextTab"), - ("g T", "PrevTab"), // Focus ("ctrl-w h", "FocusSidebar"), // Chrome @@ -66,7 +62,6 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[ ("ctrl-w q", "Quit"), // Mouse ("mouse:dblclick(sidebar-row)", "AttachHighlighted"), - ("mouse:click(tab)", "NextTab"), ]; /// Default Insert-mode bindings. Empty by default — the only Insert binding @@ -186,10 +181,6 @@ mod tests { cfg.keymap.resolve(Mode::Normal, "enter"), Some(Action::AttachHighlighted), ); - assert_eq!( - cfg.keymap.resolve(Mode::Normal, "g t"), - Some(Action::NextTab), - ); assert_eq!( cfg.keymap.resolve(Mode::Normal, "s"), Some(Action::NewDraftPane), diff --git a/crates/maestro-tui/src/draft.rs b/crates/maestro-tui/src/draft.rs index 90fb845..2df7d33 100644 --- a/crates/maestro-tui/src/draft.rs +++ b/crates/maestro-tui/src/draft.rs @@ -43,7 +43,7 @@ use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, W use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::widgets::Paragraph; use ratatui::Frame; use crate::input::InputKey; @@ -1078,9 +1078,7 @@ impl DraftPane { /// cursor placement the same way a session pane hides its cursor when /// unfocused. pub fn render(&self, f: &mut Frame, area: Rect, pane_focused: bool) { - let block = Block::default().title(" draft ").borders(Borders::ALL); - let inner = block.inner(area); - f.render_widget(block, area); + let inner = area; let show_custom = self.is_custom(); let show_raw = self.cwd_raw_visible; @@ -2117,7 +2115,7 @@ mod tests { } /// The terminal cursor must sit right after the typed text in the focused - /// field (gutter is 5 chars, block border takes column 0). + /// field (gutter is 5 chars and the pane has no border). #[test] fn render_places_the_cursor_in_the_focused_text_field() { let mut draft = DraftPane { @@ -2129,10 +2127,7 @@ mod tests { let mut terminal = Terminal::new(backend).unwrap(); terminal.draw(|f| draft.render(f, f.area(), true)).unwrap(); let pos = terminal.get_cursor_position().unwrap(); - assert_eq!( - pos, - ratatui::layout::Position::new(1 + GUTTER as u16 + 2, 2) - ); + assert_eq!(pos, ratatui::layout::Position::new(GUTTER as u16 + 2, 1)); } #[test] diff --git a/crates/maestro-tui/src/input.rs b/crates/maestro-tui/src/input.rs index 137458f..b59d250 100644 --- a/crates/maestro-tui/src/input.rs +++ b/crates/maestro-tui/src/input.rs @@ -63,7 +63,6 @@ pub enum MouseKind { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Region { SidebarRow, - Tab, Pane, Modal, Unknown, @@ -150,7 +149,6 @@ impl InputKey { }; let r = match region { Region::SidebarRow => "sidebar-row", - Region::Tab => "tab", Region::Pane => "pane", Region::Modal => "modal", Region::Unknown => "unknown", @@ -182,7 +180,6 @@ impl InputKey { }; let region = match r { "sidebar-row" => Region::SidebarRow, - "tab" => Region::Tab, "pane" => Region::Pane, "modal" => Region::Modal, "unknown" => Region::Unknown, diff --git a/crates/maestro-tui/src/keymap.rs b/crates/maestro-tui/src/keymap.rs index 2fba5d5..d1a970e 100644 --- a/crates/maestro-tui/src/keymap.rs +++ b/crates/maestro-tui/src/keymap.rs @@ -107,8 +107,8 @@ mod tests { #[test] fn resolves_chord() { - let k = build(&[(Mode::Normal, "g t", "NextTab")]); - assert_eq!(k.resolve(Mode::Normal, "g t"), Some(Action::NextTab)); + let k = build(&[(Mode::Normal, "g h", "FocusSidebar")]); + assert_eq!(k.resolve(Mode::Normal, "g h"), Some(Action::FocusSidebar)); } #[test] @@ -123,11 +123,11 @@ mod tests { #[test] fn known_bindings_sees_everything() { let k = build(&[ - (Mode::Normal, "g t", "NextTab"), + (Mode::Normal, "g h", "FocusSidebar"), (Mode::Normal, "i", "EnterInsert"), ]); let kb = k.known_bindings(Mode::Normal); - assert!(kb.contains("g t")); + assert!(kb.contains("g h")); assert!(kb.contains("i")); assert_eq!(kb.len(), 2); } diff --git a/crates/maestro-tui/src/lib.rs b/crates/maestro-tui/src/lib.rs index 97bc354..177221a 100644 --- a/crates/maestro-tui/src/lib.rs +++ b/crates/maestro-tui/src/lib.rs @@ -20,7 +20,6 @@ pub mod render; pub mod session_task; pub mod sidebar; pub mod state; -pub mod tabs; pub mod terminal; pub mod text_input; pub mod worktrees; diff --git a/crates/maestro-tui/src/overlays.rs b/crates/maestro-tui/src/overlays.rs index 8ac67ca..0febb28 100644 --- a/crates/maestro-tui/src/overlays.rs +++ b/crates/maestro-tui/src/overlays.rs @@ -155,12 +155,12 @@ mod tests { fn help_overlay_lists_lines() { let help = HelpOverlay { lines: vec![ - "i EnterInsert".into(), - "s NewDraftPane".into(), + "i Send keys to session".into(), + "s Create a session".into(), ], }; let rendered = render_to_string(80, 12, |f| help.render(f, f.area())); - assert!(rendered.contains("EnterInsert")); - assert!(rendered.contains("NewDraftPane")); + assert!(rendered.contains("Send keys to session")); + assert!(rendered.contains("Create a session")); } } diff --git a/crates/maestro-tui/src/palette.rs b/crates/maestro-tui/src/palette.rs index 49909e0..5c7ec1d 100644 --- a/crates/maestro-tui/src/palette.rs +++ b/crates/maestro-tui/src/palette.rs @@ -109,6 +109,8 @@ pub struct Palette { /// paint this background too; otherwise its contrast depends on the /// operator's terminal theme. pub chrome_bg: Color, + /// Subtle full-panel background while the sidebar owns focus. + pub sidebar_focus_bg: Color, /// Panel border while that panel holds focus. pub border_focused: Color, /// Panel border while it does not. @@ -152,6 +154,7 @@ impl Palette { error: Color::Rgb(0xff, 0xa8, 0xa3), }, chrome_bg: Color::Rgb(0x1f, 0x23, 0x2b), + sidebar_focus_bg: Color::Rgb(0x28, 0x30, 0x2a), border_focused: Color::Rgb(0x72, 0xd6, 0x87), border_unfocused: Color::Rgb(0x68, 0x73, 0x84), focus_accent: Color::Rgb(0x72, 0xd6, 0x87), @@ -186,6 +189,7 @@ impl Palette { error: Color::Rgb(0xa5, 0x1d, 0x14), }, chrome_bg: Color::Rgb(0xff, 0xff, 0xff), + sidebar_focus_bg: Color::Rgb(0xee, 0xf5, 0xef), border_focused: Color::Rgb(0x21, 0x7a, 0x3c), border_unfocused: Color::Rgb(0x65, 0x73, 0x6b), focus_accent: Color::Rgb(0x21, 0x7a, 0x3c), @@ -220,6 +224,7 @@ impl Palette { error: Color::LightRed, }, chrome_bg: Color::Black, + sidebar_focus_bg: Color::DarkGray, border_focused: Color::LightGreen, border_unfocused: Color::DarkGray, focus_accent: Color::LightGreen, @@ -254,7 +259,7 @@ impl Palette { } /// Every role, including the backgrounds it is painted against. - pub fn roles(&self) -> [(&'static str, Color); 26] { + pub fn roles(&self) -> [(&'static str, Color); 27] { [ ("card.bg", self.card.bg), ("card.fg", self.card.fg), @@ -273,6 +278,7 @@ impl Palette { ("selection.awaiting", self.selection.awaiting), ("selection.error", self.selection.error), ("chrome_bg", self.chrome_bg), + ("sidebar_focus_bg", self.sidebar_focus_bg), ("border_focused", self.border_focused), ("border_unfocused", self.border_unfocused), ("focus_accent", self.focus_accent), @@ -414,7 +420,7 @@ mod tests { #[test] fn every_preset_supplies_every_role() { for (name, p) in Palette::presets() { - assert_eq!(p.roles().len(), 26, "{name} role set is incomplete"); + assert_eq!(p.roles().len(), 27, "{name} role set is incomplete"); } assert_eq!(Palette::named("dark"), Some(Palette::DARK)); assert_eq!(Palette::named("light"), Some(Palette::LIGHT)); @@ -487,6 +493,17 @@ mod tests { "{preset}.{name} contrast {ratio:.2} is below {floor} on chrome_bg" ); } + for (name, fg) in [ + ("card.fg", p.card.fg), + ("card.muted", p.card.muted), + ("group_row", p.group_row), + ] { + let ratio = contrast_ratio(fg, p.sidebar_focus_bg).unwrap(); + assert!( + ratio >= 4.5, + "{preset}.{name} contrast {ratio:.2} is below 4.5 on sidebar_focus_bg" + ); + } } } diff --git a/crates/maestro-tui/src/render.rs b/crates/maestro-tui/src/render.rs index 86d0b04..d0ecb3e 100644 --- a/crates/maestro-tui/src/render.rs +++ b/crates/maestro-tui/src/render.rs @@ -1,11 +1,11 @@ -//! Top-level draw orchestration. Composes sidebar / tab bar / pane / overlays +//! Top-level draw orchestration. Composes sidebar / pane / overlays //! into one frame, and returns a [`RegionMap`] that the input router uses to //! hit-test mouse clicks. use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::Style; +use ratatui::style::Modifier; use ratatui::text::Span; -use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::widgets::Paragraph; use ratatui::Frame; use crate::input::Region; @@ -15,7 +15,6 @@ use crate::palette; use crate::pane::Pane; use crate::sidebar::Sidebar; use crate::state::{AppState, Focus, OverlayKind, SidebarItem}; -use crate::tabs::TabBar; use crate::worktrees::{ReclaimConfirm, WorktreesView}; /// Tagged regions for mouse hit-testing. The input router resolves a mouse @@ -24,7 +23,6 @@ use crate::worktrees::{ReclaimConfirm, WorktreesView}; pub struct RegionMap { pub sidebar: Option, pub sidebar_items: Vec, - pub tab_bar: Option, pub pane: Option, pub modal: Option, } @@ -36,11 +34,6 @@ impl RegionMap { return Region::Modal; } } - if let Some(r) = self.tab_bar { - if inside(r, col, row) { - return Region::Tab; - } - } if let Some(r) = self.sidebar { if inside(r, col, row) { return Region::SidebarRow; @@ -58,7 +51,7 @@ impl RegionMap { /// occupies one inner row; groups occupy one row and visible cards two. pub fn sidebar_item_at(&self, row: u16) -> Option { let area = self.sidebar?; - let mut cursor = area.y.saturating_add(2); + let mut cursor = area.y.saturating_add(1); for item in &self.sidebar_items { let height = match item { SidebarItem::Group(_) => 1, @@ -83,9 +76,9 @@ fn inside(r: Rect, col: u16, row: u16) -> bool { pub struct RenderInputs<'a> { pub sidebar: &'a Sidebar, pub home_dir: &'a str, - pub tab_bar: &'a TabBar, + pub pane_title: &'a str, pub pane: Option<&'a Pane>, - /// Inline spawn form when the active tab's pane is a draft (issue #63). + /// Inline spawn form when the pane is a draft (issue #63). pub draft: Option<&'a crate::draft::DraftPane>, pub state: &'a AppState, pub help: &'a HelpOverlay, @@ -135,33 +128,24 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap { .rendered_items_with_collapsed(&inputs.state.collapsed_sidebar_groups); } - // The pane always owns its full border, so focus changes only recolour - // chrome and never change the rectangles handed to the input router. let rows = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(1), Constraint::Min(0)]) .split(right); - let tab_area = rows[0]; + let title_area = rows[0]; let pane_area = rows[1]; - inputs.tab_bar.render(f, tab_area); - map.tab_bar = Some(tab_area); - 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); + render_title_bar(f, title_area, inputs.pane_title, pane_focused); if let Some(p) = inputs.pane { - p.render(f, pane_inner); + p.render(f, pane_area); } if let Some(d) = inputs.draft { - d.render(f, pane_inner, pane_focused); + d.render(f, pane_area, pane_focused); } - // `RegionMap::pane` is also the PTY resize source. Keep it aligned with - // the cells the emulator actually owns, excluding the persistent border. - map.pane = Some(pane_inner); + // `RegionMap::pane` is also the PTY resize source. The title bar is + // chrome; every remaining cell belongs to the emulator. + map.pane = Some(pane_area); // Status bar — always visible, never eclipsed by overlays so the user can // always see the current mode + key hints. @@ -216,10 +200,15 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap { map } -/// Border style for a panel based on whether it currently has focus. Both -/// forms are palette roles (`border_focused` / `border_unfocused`). -fn focus_style(focused: bool) -> Style { - palette::active().border_style(focused) +fn render_title_bar(f: &mut Frame, area: Rect, title: &str, focused: bool) { + let palette = palette::active(); + let style = if focused { + palette.mode_style(false) + } else { + palette.card.text_style() + } + .add_modifier(Modifier::BOLD); + f.render_widget(Paragraph::new(format!(" {title}")).style(style), area); } /// Render the always-visible status bar at the bottom of the frame. Surfaces @@ -236,7 +225,7 @@ fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) { }; let hint = match state.mode { Mode::Normal => "j/k: nav • enter: attach • s: new draft • ?: help • ^Wq: quit", - Mode::Insert => "^a: back to normal", + Mode::Insert => "^a: return to sidebar", }; let mode_style = palette::active().mode_style(state.mode == Mode::Insert); let line = ratatui::text::Line::from(vec![ @@ -261,22 +250,15 @@ mod tests { height: 20, }), sidebar_items: Vec::new(), - tab_bar: Some(Rect { + pane: Some(Rect { x: 10, y: 0, width: 30, - height: 3, - }), - pane: Some(Rect { - x: 10, - y: 3, - width: 30, - height: 17, + height: 20, }), modal: None, }; assert_eq!(map.region_at(5, 5), Region::SidebarRow); - assert_eq!(map.region_at(15, 1), Region::Tab); assert_eq!(map.region_at(15, 5), Region::Pane); assert_eq!(map.region_at(50, 50), Region::Unknown); } @@ -291,17 +273,11 @@ mod tests { height: 20, }), sidebar_items: Vec::new(), - tab_bar: Some(Rect { + pane: Some(Rect { x: 10, y: 0, width: 30, - height: 3, - }), - pane: Some(Rect { - x: 10, - y: 3, - width: 30, - height: 17, + height: 20, }), modal: Some(Rect { x: 5, @@ -329,10 +305,10 @@ mod tests { sidebar_items: vec![group_a.clone(), card_a.clone(), group_b.clone(), card_b], ..RegionMap::default() }; - assert_eq!(map.sidebar_item_at(2), Some(group_a)); - assert_eq!(map.sidebar_item_at(3), Some(card_a.clone())); - assert_eq!(map.sidebar_item_at(4), Some(card_a)); - assert_eq!(map.sidebar_item_at(5), Some(group_b)); + assert_eq!(map.sidebar_item_at(1), Some(group_a)); + assert_eq!(map.sidebar_item_at(2), Some(card_a.clone())); + assert_eq!(map.sidebar_item_at(3), Some(card_a)); + assert_eq!(map.sidebar_item_at(4), Some(group_b)); let collapsed = RegionMap { sidebar: Some(Rect::new(0, 0, 28, 10)), @@ -344,7 +320,7 @@ mod tests { ..RegionMap::default() }; assert_eq!( - collapsed.sidebar_item_at(3), + collapsed.sidebar_item_at(2), Some(SidebarItem::Group(PathBuf::from("/b"))) ); } @@ -368,15 +344,11 @@ mod tests { highlight: None, attached: HashSet::new(), }; - let tab_bar = TabBar { - tab_names: vec!["1".into()], - active_idx: 0, - }; let help = HelpOverlay { lines: Vec::new() }; let inputs = RenderInputs { sidebar: &sidebar, home_dir: "", - tab_bar: &tab_bar, + pane_title: "new session", pane: None, draft: Some(draft), state: &state, @@ -400,7 +372,7 @@ mod tests { out.push('\n'); } for s in [ - "draft", + "new session", "cmd:", "dir:", "name:", @@ -422,10 +394,6 @@ mod tests { highlight: None, attached: HashSet::new(), }; - let tab_bar = TabBar { - tab_names: vec!["1".into()], - active_idx: 0, - }; let help = HelpOverlay { lines: Vec::new() }; let draw_map = |focus| { let mut state = AppState::new(); @@ -433,7 +401,7 @@ mod tests { let inputs = RenderInputs { sidebar: &sidebar, home_dir: "", - tab_bar: &tab_bar, + pane_title: "session", pane: None, draft: None, state: &state, @@ -450,13 +418,12 @@ mod tests { let sidebar_focus = draw_map(Focus::Sidebar); let pane_focus = draw_map(Focus::Pane); assert_eq!(sidebar_focus.sidebar, pane_focus.sidebar); - assert_eq!(sidebar_focus.tab_bar, pane_focus.tab_bar); assert_eq!(sidebar_focus.pane, pane_focus.pane); - assert_eq!(sidebar_focus.pane, Some(Rect::new(29, 2, 50, 20))); + assert_eq!(sidebar_focus.pane, Some(Rect::new(28, 1, 52, 22))); } #[test] - fn panel_border_colours_follow_focus_without_restyling_the_pane() { + fn panel_focus_uses_backgrounds_without_borders() { use crate::overlays::HelpOverlay; use crate::sidebar::Sidebar; use std::collections::HashSet; @@ -466,10 +433,6 @@ mod tests { highlight: None, attached: HashSet::new(), }; - let tab_bar = TabBar { - tab_names: vec!["1".into()], - active_idx: 0, - }; let help = HelpOverlay { lines: Vec::new() }; let render = |focus| { let mut state = AppState::new(); @@ -477,7 +440,7 @@ mod tests { let inputs = RenderInputs { sidebar: &sidebar, home_dir: "", - tab_bar: &tab_bar, + pane_title: "session", pane: None, draft: None, state: &state, @@ -497,12 +460,11 @@ mod tests { let sidebar_focused = render(Focus::Sidebar); let pane_focused = render(Focus::Pane); let p = palette::active(); - assert_eq!(sidebar_focused[(0, 0)].style().fg, Some(p.border_focused)); - assert_eq!(pane_focused[(0, 0)].style().fg, Some(p.border_unfocused)); - assert_eq!( - sidebar_focused[(28, 1)].style().fg, - Some(p.border_unfocused) - ); - assert_eq!(pane_focused[(28, 1)].style().fg, Some(p.border_focused)); + assert_eq!(sidebar_focused[(0, 0)].style().bg, Some(p.sidebar_focus_bg)); + assert_eq!(pane_focused[(0, 0)].style().bg, Some(p.chrome_bg)); + assert_eq!(sidebar_focused[(28, 0)].style().bg, Some(p.card.bg)); + assert_eq!(pane_focused[(28, 0)].style().bg, Some(p.mode_normal_bg)); + let title: String = (28..80).map(|x| pane_focused[(x, 0)].symbol()).collect(); + assert!(title.contains("session"), "missing title: {title:?}"); } } diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index 61a44b9..9013607 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -13,7 +13,7 @@ use maestro_protocol::{ActivityState, Session, SessionStatus}; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::widgets::{Block, Paragraph}; use ratatui::Frame; use uuid::Uuid; @@ -31,16 +31,17 @@ const META_LINE_AFTER_SECS: i64 = 30; const PLAUSIBLE_SINCE_YEAR: i32 = 2010; const INDENT: &str = " "; const META_INDENT: &str = " "; -/// Spawn-similar and kill, shown on the highlighted card's top-right corner. -const ACTIONS: &str = "⧉ ✕"; /// Cards use the selection surface only while the sidebar owns focus. An /// unfocused highlight remains an edge-marked cursor without claiming the /// keyboard via a filled selection background. fn card_base(highlighted: bool, focused: bool) -> Style { - palette::active() - .surface(highlighted && focused) - .text_style() + let palette = palette::active(); + let mut style = palette.surface(highlighted && focused).text_style(); + if focused && !highlighted { + style = style.bg(palette.sidebar_focus_bg); + } + style } /// Renderable sidebar state. @@ -63,9 +64,8 @@ impl Sidebar { self.render_with_focus(f, area, false); } - /// Render the panel with a persistent border. Focus changes its colour - /// and whether the highlighted card claims the selection surface, never - /// its geometry. + /// Render the panel without a border. Focus is the panel background, so + /// it remains obvious without spending rows or columns on chrome. pub fn render_with_focus(&self, f: &mut Frame, area: Rect, focused: bool) { self.render_with_focus_and_collapsed(f, area, focused, &HashSet::new()); } @@ -88,17 +88,21 @@ impl Sidebar { collapsed: &HashSet, home: &str, ) { - let border_style = palette::active().border_style(focused); - let block = Block::default() - .borders(Borders::ALL) - .border_style(border_style); - let inner = block.inner(area); - f.render_widget(block, area); + let palette = palette::active(); + let panel_bg = if focused { + palette.sidebar_focus_bg + } else { + palette.chrome_bg + }; + f.render_widget(Block::default().style(Style::default().bg(panel_bg)), area); - let width = inner.width as usize; + let width = area.width as usize; let mut lines: Vec = vec![Line::from(Span::styled( "sessions", - Style::default().add_modifier(Modifier::BOLD), + Style::default() + .bg(panel_bg) + .fg(palette.card.fg) + .add_modifier(Modifier::BOLD), ))]; for group in self.groups(collapsed) { lines.push(Line::from(self.group_row(&group, width, focused, home))); @@ -112,7 +116,7 @@ impl Sidebar { } } } - f.render_widget(Paragraph::new(lines), inner); + f.render_widget(Paragraph::new(lines), area); } /// Items in exactly the order and height they occupy on screen. @@ -169,7 +173,14 @@ impl Sidebar { let highlighted = self.highlight == Some(SidebarItem::Group(group.key.clone())); let palette = palette::active(); let surface = palette.surface(highlighted && focused); - let base = Style::default().bg(surface.bg).fg(if highlighted { + let row_bg = if highlighted { + surface.bg + } else if focused { + palette.sidebar_focus_bg + } else { + surface.bg + }; + let base = Style::default().bg(row_bg).fg(if highlighted { surface.fg } else { palette.group_row @@ -189,11 +200,14 @@ impl Sidebar { }, ); let chevron = Span::styled(chevron, base); - let count = Span::styled(count, surface.muted_style()); + let count = Span::styled(count, surface.muted_style().bg(row_bg)); let label_budget = width.saturating_sub(prefix.width() + chevron.width() + count.width()); let mut label = Vec::new(); if let Some(stem) = name.stem { - label.push(Span::styled(format!("{stem}/"), surface.dim_style())); + label.push(Span::styled( + format!("{stem}/"), + surface.dim_style().bg(row_bg), + )); } label.push(Span::styled(name.last, base)); @@ -211,20 +225,8 @@ impl Sidebar { }; let highlighted = self.highlight == Some(SidebarItem::Session(s.id)); let base = card_base(highlighted, focused); - // Line-1 actions are keyboard affordances shown on the highlighted - // row only; they reserve their width so the name truncates with `…` - // before it can ever reach them (docs/SIDEBAR.md). - let actions_width = if highlighted { - ACTIONS.chars().count() - } else { - 0 - }; - let usable = width.saturating_sub(actions_width); let prefix = INDENT.chars().count() + 2; - // Keep one column of separation between a truncated name and the - // actions so the ellipsis never touches them. - let sep = if highlighted { 1 } else { 0 }; - let name_budget = usable.saturating_sub(prefix + sep); + let name_budget = width.saturating_sub(prefix); let edge = highlighted && !focused; let mut spans: Vec> = vec![ @@ -243,14 +245,7 @@ impl Sidebar { .into_iter() .map(|sp| Span::styled(sp.content, base.patch(sp.style))), ); - let used: usize = spans.iter().map(|sp| sp.width()).sum(); - if let Some(fill) = usable.checked_sub(used).filter(|&n| n > 0) { - spans.push(Span::styled(" ".repeat(fill), base)); - } - if highlighted { - spans.push(Span::styled(ACTIONS, base)); - } - spans + pad_to_width(spans, width, base) } /// The adaptive second line: ` [] [] [ | exited] `. @@ -268,7 +263,8 @@ impl Sidebar { base.patch(Style::default().fg(activity_color(s.activity, highlighted, focused))); let muted = palette::active() .surface(highlighted && focused) - .muted_style(); + .muted_style() + .bg(base.bg.unwrap_or(palette::active().chrome_bg)); let edge = highlighted && !focused; let mut spans = vec![ Span::styled( @@ -385,7 +381,7 @@ fn pad_to_width(mut spans: Vec>, width: usize, base: Style) -> Vec } /// `metadata.name` if present, else the command line. -fn name_for(s: &Session) -> String { +pub(crate) fn name_for(s: &Session) -> String { if let Some(serde_json::Value::String(name)) = s.metadata.get("name") { name.clone() } else { @@ -587,10 +583,10 @@ mod tests { attached: Default::default(), }; let all = rows(&draw(&sidebar, 40, 8)); - assert!(all[2].contains("/tmp")); - assert!(all[3].contains("claude")); - assert!(all[4].contains("bash")); - assert!(!all[5].contains("bash")); + assert!(all[1].contains("/tmp")); + assert!(all[2].contains("claude")); + assert!(all[3].contains("bash")); + assert!(!all[4].contains("bash")); } #[test] @@ -680,20 +676,20 @@ mod tests { attached: Default::default(), }; let all = rows(&draw(&sb, 28, 8)); - assert_eq!(content(&all[1]), "sessions"); + assert_eq!(content(&all[0]), "sessions"); assert!(row_containing(&all, "claude").contains('○')); assert!( - all[4].contains('●'), + all[3].contains('●'), "line 2 must carry the dot: {:?}", all[3] ); assert!( - all[4].contains("bash"), + all[3].contains("bash"), "line 2 must carry the command: {:?}", all[3] ); assert!( - !all[4].contains("10s"), + !all[3].contains("10s"), "fresh cards have no duration: {:?}", all[3] ); @@ -1015,12 +1011,12 @@ mod tests { }; let all = rows(&draw(&sb, 40, 6)); assert!( - all[3].contains("friendly name"), + all[2].contains("friendly name"), "name must stay on line 1: {:?}", all[2] ); assert!( - all[4].contains("bash"), + all[3].contains("bash"), "command must be on line 2: {:?}", all[3] ); @@ -1034,14 +1030,14 @@ mod tests { highlight: None, attached: Default::default(), }; - let all = rows(&draw(&sb, 10, 6)); + let all = rows(&draw(&sb, 9, 6)); assert!( - all[4].contains('●'), + all[3].contains('●'), "dot must survive narrow cards: {:?}", all[3] ); assert!( - all[4].contains('…'), + all[3].contains('…'), "command should truncate: {:?}", all[3] ); @@ -1061,8 +1057,8 @@ mod tests { attached: Default::default(), }; let all = rows(&draw(&sb, 29, 6)); - assert!(all[4].contains("⚠ 1 uncommitted file"), "{:?}", all[4]); - let meta = content(&all[4]); + assert!(all[3].contains("⚠ 1 uncommitted file"), "{:?}", all[3]); + let meta = content(&all[3]); assert!(meta.ends_with('…'), "command must truncate: {meta:?}"); } @@ -1080,13 +1076,13 @@ mod tests { attached: Default::default(), }; let all = rows(&draw(&sb, 40, 6)); - assert!(all[4].contains("⚠ 1 uncommitted file"), "{:?}", all[4]); + assert!(all[3].contains("⚠ 1 uncommitted file"), "{:?}", all[3]); assert!( - all[4].contains("bash"), + all[3].contains("bash"), "command must survive: {:?}", all[3] ); - assert!(all[4].contains('…'), "detail must truncate: {:?}", all[4]); + assert!(all[3].contains('…'), "detail must truncate: {:?}", all[3]); } /// Reversed video makes a card unreadable against a background; cards @@ -1114,8 +1110,8 @@ mod tests { } } - /// The highlighted card gets the focus background; every other card gets - /// a card background distinct from the panel's default. + /// The highlighted card gets the selection background; the rest of the + /// focused sidebar shares its subtle panel background. #[test] fn cards_use_explicit_backgrounds_with_a_distinct_focus_background() { let one = session_named(1, "claude"); @@ -1143,13 +1139,13 @@ mod tests { ); assert_eq!( buf[(1, bash_y as u16)].style().bg, - Some(p.card.bg), - "unfocused cards must still sit on the card surface" + Some(p.sidebar_focus_bg), + "ordinary cards must share the focused panel surface" ); - // The panel itself (header row) stays on the terminal default. - assert!( - matches!(buf[(1, 1)].style().bg, None | Some(Color::Reset)), - "panel must keep the terminal default background" + assert_eq!( + buf[(1, 0)].style().bg, + Some(p.sidebar_focus_bg), + "the focus background must cover the whole sidebar" ); } @@ -1204,15 +1200,14 @@ mod tests { for x in 1..27 { assert_eq!( buf[(x, y)].style().bg, - Some(palette::active().card.bg), + Some(palette::active().sidebar_focus_bg), "gap in card background at col {x}" ); } } - /// Line 1's actions live on the highlighted row's top-right corner. #[test] - fn highlighted_row_anchors_the_actions_top_right() { + fn highlighted_rows_do_not_show_action_glyphs() { let sb = Sidebar { sessions: vec![session_named(1, "claude")], highlight: Some(SidebarItem::Session(Uuid::from_u128(1))), @@ -1221,8 +1216,8 @@ mod tests { let all = rows(&draw(&sb, 28, 8)); let line = content(row_containing(&all, "claude")); assert!( - line.ends_with("⧉ ✕"), - "actions must sit at the right edge: {line:?}" + !line.contains('⧉') && !line.contains('✕'), + "keyboard-only actions should not consume card space: {line:?}" ); } @@ -1243,10 +1238,8 @@ mod tests { ); } - /// The name truncates with `…` before it can ever reach the reserved - /// action width (docs/SIDEBAR.md — overlap is a defect). #[test] - fn name_truncates_before_reaching_the_actions() { + fn long_name_uses_the_full_card_width() { let mut s = session_named(1, "claude"); if let Some(serde_json::Value::String(name)) = s.metadata.get_mut("name") { *name = "an-unreasonably-long-session-name".into(); @@ -1258,12 +1251,10 @@ mod tests { }; let all = rows(&draw(&sb, 28, 8)); let line = row_containing(&all, "an-unreasonably"); - let name_end = line.find('…').expect("name must truncate with …"); - let actions_start = line.find('⧉').expect("actions must still render"); - assert!(name_end < actions_start, "name overlaps actions: {line:?}"); + assert!(line.contains('…'), "name must truncate with …: {line:?}"); assert!( - line.split_at(actions_start).0.ends_with(' '), - "no gap before the actions: {line:?}" + !line.contains('⧉') && !line.contains('✕'), + "actions leaked in: {line:?}" ); } } diff --git a/crates/maestro-tui/src/state.rs b/crates/maestro-tui/src/state.rs index 85e4d13..229faf8 100644 --- a/crates/maestro-tui/src/state.rs +++ b/crates/maestro-tui/src/state.rs @@ -1,8 +1,7 @@ //! `AppState` — the top-level mutable state of the running TUI. //! -//! v1 keeps it small on purpose: a list of tabs (each bound to at most one -//! session or a draft form), an active tab index, focus split between sidebar -//! and pane, the current input mode, and an optional overlay (kill confirm, +//! v1 keeps it small on purpose: one pane containing a session or draft form, +//! focus split between sidebar and pane, the current input mode, and an optional overlay (kill confirm, //! help, transient toast). The spawn form is no longer an overlay — an empty //! pane is a draft pane (issue #63). //! @@ -33,7 +32,7 @@ pub enum SidebarItem { } /// What a tab's pane slot holds: a live session, or an inline draft form. -// The draft variant dwarfs `Session(Uuid)`; tabs are moved rarely enough +// The draft variant dwarfs `Session(Uuid)`; the pane is moved rarely enough // that boxing every access costs more than the copy does. #[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, PartialEq)] @@ -143,57 +142,6 @@ impl AppState { &mut self.tabs[self.active_tab_idx] } - /// Append a new tab and switch to it. Sidebar regains focus so the user - /// can pick something to attach. - pub fn new_tab(&mut self) { - let name = format!("{}", self.tabs.len() + 1); - self.tabs.push(Tab { name, pane: None }); - self.active_tab_idx = self.tabs.len() - 1; - self.focus = Focus::Sidebar; - } - - /// Drop the active tab. No-op if it's the only one — there must always - /// be at least one tab. - /// - /// Returns the session that tab was showing, if any, so the caller can - /// release its emulator. Without that the tab's WebSocket task and vt100 - /// parser outlive the tab forever. A draft pane holds no session and - /// releases nothing. - pub fn close_tab(&mut self) -> Option { - if self.tabs.len() <= 1 { - return None; - } - let closed = self.tabs.remove(self.active_tab_idx); - if self.active_tab_idx >= self.tabs.len() { - self.active_tab_idx = self.tabs.len() - 1; - } - let sid = closed.session()?; - // Only release it if no other tab still shows the same session. - let still_shown = self.tabs.iter().any(|t| t.session() == Some(sid)); - (!still_shown).then_some(sid) - } - - pub fn next_tab(&mut self) { - if self.tabs.is_empty() { - return; - } - self.active_tab_idx = (self.active_tab_idx + 1) % self.tabs.len(); - } - - pub fn prev_tab(&mut self) { - if self.tabs.is_empty() { - return; - } - self.active_tab_idx = (self.active_tab_idx + self.tabs.len() - 1) % self.tabs.len(); - } - - pub fn goto_tab(&mut self, n: u8) { - let idx = (n as usize) - .saturating_sub(1) - .min(self.tabs.len().saturating_sub(1)); - self.active_tab_idx = idx; - } - /// Bind a session to the active tab, move focus into the pane, and /// drop straight into Insert mode so keystrokes go to the session /// without an extra `i` press. The leader returns to Normal. @@ -203,14 +151,10 @@ impl AppState { self.mode = Mode::Insert; } - /// Open a draft form in a pane (issue #63): reuse the empty active pane, - /// otherwise open a fresh tab for it — v1 has no split tree, so "split" - /// means a new tab. Focus lands in the pane, Insert mode on, so typing - /// starts immediately, same flow as [`Self::attach_to_focused`]. + /// Replace the pane with a draft form. Focus lands in the pane, Insert + /// mode on, so typing starts immediately, same flow as + /// [`Self::attach_to_focused`]. pub fn open_draft_pane(&mut self) { - if self.active_tab().pane.is_some() { - self.new_tab(); - } self.active_tab_mut().pane = Some(PaneRef::Draft(DraftPane::default())); self.focus = Focus::Pane; self.mode = Mode::Insert; @@ -282,66 +226,6 @@ mod tests { assert!(state.overlay.is_none()); } - #[test] - fn new_tab_extends_and_focuses() { - let mut state = AppState::new(); - state.new_tab(); - assert_eq!(state.tabs.len(), 2); - assert_eq!(state.active_tab_idx, 1); - assert_eq!(state.focus, Focus::Sidebar); - } - - #[test] - fn close_tab_keeps_at_least_one() { - let mut state = AppState::new(); - state.close_tab(); - assert_eq!(state.tabs.len(), 1); - state.new_tab(); - state.close_tab(); - assert_eq!(state.tabs.len(), 1); - } - - #[test] - fn close_tab_advances_when_last_is_closed() { - let mut state = AppState::new(); - state.new_tab(); - state.new_tab(); - assert_eq!(state.active_tab_idx, 2); - state.close_tab(); - assert_eq!(state.tabs.len(), 2); - assert_eq!(state.active_tab_idx, 1); - } - - #[test] - fn next_prev_tab_wraps() { - let mut state = AppState::new(); - state.new_tab(); - state.new_tab(); - state.active_tab_idx = 0; - state.next_tab(); - assert_eq!(state.active_tab_idx, 1); - state.next_tab(); - assert_eq!(state.active_tab_idx, 2); - state.next_tab(); - assert_eq!(state.active_tab_idx, 0); - state.prev_tab(); - assert_eq!(state.active_tab_idx, 2); - } - - #[test] - fn goto_tab_uses_one_based_clamp() { - let mut state = AppState::new(); - state.new_tab(); - state.new_tab(); - state.goto_tab(1); - assert_eq!(state.active_tab_idx, 0); - state.goto_tab(2); - assert_eq!(state.active_tab_idx, 1); - state.goto_tab(99); - // Clamped to last available tab. - assert_eq!(state.active_tab_idx, state.tabs.len() - 1); - } - #[test] fn move_sidebar_highlight_walks_order_and_wraps() { let mut state = AppState::new(); @@ -421,38 +305,6 @@ mod tests { assert_eq!(state.focus, Focus::Pane); } - /// The emulator leak: a closed tab's session must come back so the caller - /// can abort its WebSocket task. - #[test] - fn close_tab_reports_the_session_it_dropped() { - let mut state = AppState::new(); - let sid = Uuid::from_u128(3); - state.new_tab(); - state.active_tab_mut().pane = Some(PaneRef::Session(sid)); - - assert_eq!(state.close_tab(), Some(sid)); - } - - /// ...but not while another tab still shows it. - #[test] - fn close_tab_keeps_a_session_another_tab_still_shows() { - let mut state = AppState::new(); - let sid = Uuid::from_u128(4); - state.active_tab_mut().pane = Some(PaneRef::Session(sid)); - state.new_tab(); - state.active_tab_mut().pane = Some(PaneRef::Session(sid)); - - assert_eq!(state.close_tab(), None); - } - - #[test] - fn close_tab_is_a_no_op_on_the_last_tab() { - let mut state = AppState::new(); - state.active_tab_mut().pane = Some(PaneRef::Session(Uuid::from_u128(5))); - assert_eq!(state.close_tab(), None); - assert_eq!(state.tabs.len(), 1); - } - #[test] fn dismiss_overlay_reports_whether_one_was_showing() { let mut state = AppState::new(); @@ -474,18 +326,14 @@ mod tests { assert_eq!(state.tabs.len(), 1); } - /// A tab already showing a session gets a fresh tab for the draft; v1 - /// has no split tree, so a new tab is the "split". #[test] - fn open_draft_over_a_session_opens_a_new_tab() { + fn open_draft_over_a_session_reuses_the_pane() { let mut state = AppState::new(); state.attach_to_focused(Uuid::from_u128(9)); state.open_draft_pane(); - assert_eq!(state.tabs.len(), 2); - assert_eq!(state.active_tab_idx, 1); + assert_eq!(state.tabs.len(), 1); + assert_eq!(state.active_tab_idx, 0); assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_)))); - // The session tab is untouched. - assert_eq!(state.tabs[0].session(), Some(Uuid::from_u128(9))); } /// Web parity (#57): submitting fills the pane in place. @@ -499,14 +347,4 @@ mod tests { assert_eq!(state.focus, Focus::Pane); assert_eq!(state.mode, Mode::Insert); } - - /// A closed draft tab holds no session, so nothing is released. - #[test] - fn close_tab_reports_nothing_for_a_draft_pane() { - let mut state = AppState::new(); - state.open_draft_pane(); - state.new_tab(); - state.active_tab_idx = 0; - assert_eq!(state.close_tab(), None); - } } diff --git a/crates/maestro-tui/src/tabs.rs b/crates/maestro-tui/src/tabs.rs deleted file mode 100644 index 7fd0156..0000000 --- a/crates/maestro-tui/src/tabs.rs +++ /dev/null @@ -1,61 +0,0 @@ -//! Tab-bar widget — top row of the main area. - -use ratatui::layout::Rect; -use ratatui::style::{Modifier, Style}; -use ratatui::text::Line; -use ratatui::widgets::Tabs; -use ratatui::Frame; - -#[derive(Debug, Clone)] -pub struct TabBar { - pub tab_names: Vec, - pub active_idx: usize, -} - -impl TabBar { - pub fn render(&self, f: &mut Frame, area: Rect) { - let titles: Vec = self - .tab_names - .iter() - .map(|n| Line::from(n.as_str())) - .collect(); - let tabs = Tabs::new(titles) - .select(self.active_idx) - .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); - f.render_widget(tabs, area); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use ratatui::backend::TestBackend; - use ratatui::Terminal; - - fn render_to_string(bar: &TabBar, w: u16, h: u16) -> String { - let backend = TestBackend::new(w, h); - let mut terminal = Terminal::new(backend).unwrap(); - terminal.draw(|f| bar.render(f, f.area())).unwrap(); - let buf = terminal.backend().buffer().clone(); - let mut out = String::new(); - for y in 0..buf.area.height { - for x in 0..buf.area.width { - out.push_str(buf[(x, y)].symbol()); - } - out.push('\n'); - } - out - } - - #[test] - fn renders_three_tab_names() { - let bar = TabBar { - tab_names: vec!["1".into(), "2".into(), "3".into()], - active_idx: 1, - }; - let rendered = render_to_string(&bar, 40, 1); - assert!(rendered.contains("1")); - assert!(rendered.contains("2")); - assert!(rendered.contains("3")); - } -} diff --git a/docs/INSTALL.md b/docs/INSTALL.md index c9101bf..68822b0 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -136,15 +136,13 @@ cover every v1 action — only override what you want to change: [tui] theme = "dark" # dark (default), light, or ansi leader = "ctrl-a" # one byte that escapes Insert mode -chord_timeout_ms = 1000 # how long to hold a partial chord like `g` +chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w` [tui.keymap.normal] "i" = "EnterInsert" -"s" = "OpenSpawnModal" +"s" = "NewDraftPane" "x" = "KillHighlighted" "?" = "ShowHelp" -"ctrl-t" = "NewTab" -"g t" = "NextTab" "ctrl-w h" = "FocusSidebar" "enter" = "AttachHighlighted" diff --git a/docs/SIDEBAR.md b/docs/SIDEBAR.md index f28b3d1..2fc925a 100644 --- a/docs/SIDEBAR.md +++ b/docs/SIDEBAR.md @@ -7,17 +7,15 @@ written here; if it needs to change, change it here first. A card is always two lines: **line 1** and **line 2**. -## Line 1 — name + actions +## Line 1 — name - Left: the session name — `metadata.name` if present, else the command line (same fallback both clients already use). -- Right: two actions anchored to the top-right corner: **spawn similar** - (`⧉`) and **kill** (`✕`). In the TUI these are keyboard affordances shown on - the highlighted row; in the web they are buttons. The glyphs are shared so - muscle memory transfers. -- Actions reserve their fixed width; the name truncates with `…` before it can - ever reach them. **Overlap between the name and the actions is a defect** — - that overlap is what started this milestone. +- In the web client, **spawn similar** (`⧉`) and **kill** (`✕`) remain buttons + anchored on the right. The TUI omits those glyphs because its actions are + keyboard-driven and the icons do not identify their keys. +- The name truncates with `…` at the available width. In the web client it + must stop before the action buttons; in the TUI it may use the full row. Line 1 carries no badges. The `⚠ blocked work` badge (#83) and the `exited` badge move off this row into line 2.