From f413a63c5a945112bca01624ae67c0e79b8b5f52 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sun, 12 Oct 2025 21:37:34 +0200 Subject: [PATCH] feat(ui): introduce focus beacon and unified panel styling helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `focus_beacon_span`, `panel_title_spans`, `panel_hint_style`, and `panel_border_style` utilities to centralize panel header, hint, border, and beacon rendering. Integrate these helpers across all UI panels (files, chat, thinking, agent actions, input, status bar) and update help text. Extend `Theme` with new color fields for beacons, pane headers, and hint text, providing defaults for all built‑in themes. Include comprehensive unit tests for the new styling functions. --- crates/owlen-core/src/theme.rs | 168 +++++++++++++-- crates/owlen-tui/src/ui.rs | 359 ++++++++++++++++++++++----------- 2 files changed, 390 insertions(+), 137 deletions(-) diff --git a/crates/owlen-core/src/theme.rs b/crates/owlen-core/src/theme.rs index 76f2eaf..410e0ac 100644 --- a/crates/owlen-core/src/theme.rs +++ b/crates/owlen-core/src/theme.rs @@ -36,6 +36,42 @@ pub struct Theme { #[serde(serialize_with = "serialize_color")] pub unfocused_panel_border: Color, + /// Foreground color for the active pane beacon (`▌`) + #[serde(default = "Theme::default_focus_beacon_fg")] + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub focus_beacon_fg: Color, + + /// Background color for the active pane beacon (`▌`) + #[serde(default = "Theme::default_focus_beacon_bg")] + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub focus_beacon_bg: Color, + + /// Foreground color for the inactive pane beacon (`▌`) + #[serde(default = "Theme::default_unfocused_beacon_fg")] + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub unfocused_beacon_fg: Color, + + /// Title color for active pane headers + #[serde(default = "Theme::default_pane_header_active")] + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub pane_header_active: Color, + + /// Title color for inactive pane headers + #[serde(default = "Theme::default_pane_header_inactive")] + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub pane_header_inactive: Color, + + /// Hint text color used within pane headers + #[serde(default = "Theme::default_pane_hint_text")] + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub pane_hint_text: Color, + /// Color for user message role indicator #[serde(deserialize_with = "deserialize_color")] #[serde(serialize_with = "serialize_color")] @@ -313,6 +349,30 @@ impl Theme { Color::Cyan } + const fn default_focus_beacon_fg() -> Color { + Color::LightMagenta + } + + const fn default_focus_beacon_bg() -> Color { + Color::Black + } + + const fn default_unfocused_beacon_fg() -> Color { + Color::DarkGray + } + + const fn default_pane_header_active() -> Color { + Color::White + } + + const fn default_pane_header_inactive() -> Color { + Color::Gray + } + + const fn default_pane_hint_text() -> Color { + Color::DarkGray + } + const fn default_operating_chat_fg() -> Color { Color::Black } @@ -474,6 +534,12 @@ fn default_dark() -> Theme { background: Color::Black, focused_panel_border: Color::LightMagenta, unfocused_panel_border: Color::Rgb(95, 20, 135), + focus_beacon_fg: Theme::default_focus_beacon_fg(), + focus_beacon_bg: Theme::default_focus_beacon_bg(), + unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(), + pane_header_active: Theme::default_pane_header_active(), + pane_header_inactive: Theme::default_pane_header_inactive(), + pane_hint_text: Theme::default_pane_hint_text(), user_message_role: Color::LightBlue, assistant_message_role: Color::Yellow, tool_output: Color::Gray, @@ -523,6 +589,12 @@ fn default_light() -> Theme { background: Color::White, focused_panel_border: Color::Rgb(74, 144, 226), unfocused_panel_border: Color::Rgb(221, 221, 221), + focus_beacon_fg: Theme::default_focus_beacon_fg(), + focus_beacon_bg: Theme::default_focus_beacon_bg(), + unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(), + pane_header_active: Theme::default_pane_header_active(), + pane_header_inactive: Theme::default_pane_header_inactive(), + pane_hint_text: Theme::default_pane_hint_text(), user_message_role: Color::Rgb(0, 85, 164), assistant_message_role: Color::Rgb(142, 68, 173), tool_output: Color::Gray, @@ -572,7 +644,13 @@ fn gruvbox() -> Theme { background: Color::Rgb(40, 40, 40), // #282828 focused_panel_border: Color::Rgb(254, 128, 25), // #fe8019 (orange) unfocused_panel_border: Color::Rgb(124, 111, 100), // #7c6f64 - user_message_role: Color::Rgb(184, 187, 38), // #b8bb26 (green) + focus_beacon_fg: Theme::default_focus_beacon_fg(), + focus_beacon_bg: Theme::default_focus_beacon_bg(), + unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(), + pane_header_active: Theme::default_pane_header_active(), + pane_header_inactive: Theme::default_pane_header_inactive(), + pane_hint_text: Theme::default_pane_hint_text(), + user_message_role: Color::Rgb(184, 187, 38), // #b8bb26 (green) assistant_message_role: Color::Rgb(131, 165, 152), // #83a598 (blue) tool_output: Color::Rgb(146, 131, 116), thinking_panel_title: Color::Rgb(211, 134, 155), // #d3869b (purple) @@ -617,11 +695,17 @@ fn gruvbox() -> Theme { fn dracula() -> Theme { Theme { name: "dracula".to_string(), - text: Color::Rgb(248, 248, 242), // #f8f8f2 - background: Color::Rgb(40, 42, 54), // #282a36 - focused_panel_border: Color::Rgb(255, 121, 198), // #ff79c6 (pink) - unfocused_panel_border: Color::Rgb(68, 71, 90), // #44475a - user_message_role: Color::Rgb(139, 233, 253), // #8be9fd (cyan) + text: Color::Rgb(248, 248, 242), // #f8f8f2 + background: Color::Rgb(40, 42, 54), // #282a36 + focused_panel_border: Color::Rgb(255, 121, 198), // #ff79c6 (pink) + unfocused_panel_border: Color::Rgb(68, 71, 90), // #44475a + focus_beacon_fg: Theme::default_focus_beacon_fg(), + focus_beacon_bg: Theme::default_focus_beacon_bg(), + unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(), + pane_header_active: Theme::default_pane_header_active(), + pane_header_inactive: Theme::default_pane_header_inactive(), + pane_hint_text: Theme::default_pane_hint_text(), + user_message_role: Color::Rgb(139, 233, 253), // #8be9fd (cyan) assistant_message_role: Color::Rgb(255, 121, 198), // #ff79c6 (pink) tool_output: Color::Rgb(98, 114, 164), thinking_panel_title: Color::Rgb(189, 147, 249), // #bd93f9 (purple) @@ -670,6 +754,12 @@ fn solarized() -> Theme { background: Color::Rgb(0, 43, 54), // #002b36 (base03) focused_panel_border: Color::Rgb(38, 139, 210), // #268bd2 (blue) unfocused_panel_border: Color::Rgb(7, 54, 66), // #073642 (base02) + focus_beacon_fg: Theme::default_focus_beacon_fg(), + focus_beacon_bg: Theme::default_focus_beacon_bg(), + unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(), + pane_header_active: Theme::default_pane_header_active(), + pane_header_inactive: Theme::default_pane_header_inactive(), + pane_hint_text: Theme::default_pane_hint_text(), user_message_role: Color::Rgb(42, 161, 152), // #2aa198 (cyan) assistant_message_role: Color::Rgb(203, 75, 22), // #cb4b16 (orange) tool_output: Color::Rgb(101, 123, 131), @@ -719,6 +809,12 @@ fn midnight_ocean() -> Theme { background: Color::Rgb(13, 17, 23), focused_panel_border: Color::Rgb(88, 166, 255), unfocused_panel_border: Color::Rgb(48, 54, 61), + focus_beacon_fg: Theme::default_focus_beacon_fg(), + focus_beacon_bg: Theme::default_focus_beacon_bg(), + unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(), + pane_header_active: Theme::default_pane_header_active(), + pane_header_inactive: Theme::default_pane_header_inactive(), + pane_hint_text: Theme::default_pane_hint_text(), user_message_role: Color::Rgb(121, 192, 255), assistant_message_role: Color::Rgb(137, 221, 255), tool_output: Color::Rgb(84, 110, 122), @@ -764,11 +860,17 @@ fn midnight_ocean() -> Theme { fn rose_pine() -> Theme { Theme { name: "rose-pine".to_string(), - text: Color::Rgb(224, 222, 244), // #e0def4 - background: Color::Rgb(25, 23, 36), // #191724 - focused_panel_border: Color::Rgb(235, 111, 146), // #eb6f92 (love) - unfocused_panel_border: Color::Rgb(38, 35, 58), // #26233a - user_message_role: Color::Rgb(49, 116, 143), // #31748f (foam) + text: Color::Rgb(224, 222, 244), // #e0def4 + background: Color::Rgb(25, 23, 36), // #191724 + focused_panel_border: Color::Rgb(235, 111, 146), // #eb6f92 (love) + unfocused_panel_border: Color::Rgb(38, 35, 58), // #26233a + focus_beacon_fg: Theme::default_focus_beacon_fg(), + focus_beacon_bg: Theme::default_focus_beacon_bg(), + unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(), + pane_header_active: Theme::default_pane_header_active(), + pane_header_inactive: Theme::default_pane_header_inactive(), + pane_hint_text: Theme::default_pane_hint_text(), + user_message_role: Color::Rgb(49, 116, 143), // #31748f (foam) assistant_message_role: Color::Rgb(156, 207, 216), // #9ccfd8 (foam light) tool_output: Color::Rgb(110, 106, 134), thinking_panel_title: Color::Rgb(196, 167, 231), // #c4a7e7 (iris) @@ -813,11 +915,17 @@ fn rose_pine() -> Theme { fn monokai() -> Theme { Theme { name: "monokai".to_string(), - text: Color::Rgb(248, 248, 242), // #f8f8f2 - background: Color::Rgb(39, 40, 34), // #272822 - focused_panel_border: Color::Rgb(249, 38, 114), // #f92672 (pink) - unfocused_panel_border: Color::Rgb(117, 113, 94), // #75715e - user_message_role: Color::Rgb(102, 217, 239), // #66d9ef (cyan) + text: Color::Rgb(248, 248, 242), // #f8f8f2 + background: Color::Rgb(39, 40, 34), // #272822 + focused_panel_border: Color::Rgb(249, 38, 114), // #f92672 (pink) + unfocused_panel_border: Color::Rgb(117, 113, 94), // #75715e + focus_beacon_fg: Theme::default_focus_beacon_fg(), + focus_beacon_bg: Theme::default_focus_beacon_bg(), + unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(), + pane_header_active: Theme::default_pane_header_active(), + pane_header_inactive: Theme::default_pane_header_inactive(), + pane_hint_text: Theme::default_pane_hint_text(), + user_message_role: Color::Rgb(102, 217, 239), // #66d9ef (cyan) assistant_message_role: Color::Rgb(174, 129, 255), // #ae81ff (purple) tool_output: Color::Rgb(117, 113, 94), thinking_panel_title: Color::Rgb(230, 219, 116), // #e6db74 (yellow) @@ -862,11 +970,17 @@ fn monokai() -> Theme { fn material_dark() -> Theme { Theme { name: "material-dark".to_string(), - text: Color::Rgb(238, 255, 255), // #eeffff - background: Color::Rgb(38, 50, 56), // #263238 - focused_panel_border: Color::Rgb(128, 203, 196), // #80cbc4 (cyan) - unfocused_panel_border: Color::Rgb(84, 110, 122), // #546e7a - user_message_role: Color::Rgb(130, 170, 255), // #82aaff (blue) + text: Color::Rgb(238, 255, 255), // #eeffff + background: Color::Rgb(38, 50, 56), // #263238 + focused_panel_border: Color::Rgb(128, 203, 196), // #80cbc4 (cyan) + unfocused_panel_border: Color::Rgb(84, 110, 122), // #546e7a + focus_beacon_fg: Theme::default_focus_beacon_fg(), + focus_beacon_bg: Theme::default_focus_beacon_bg(), + unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(), + pane_header_active: Theme::default_pane_header_active(), + pane_header_inactive: Theme::default_pane_header_inactive(), + pane_hint_text: Theme::default_pane_hint_text(), + user_message_role: Color::Rgb(130, 170, 255), // #82aaff (blue) assistant_message_role: Color::Rgb(199, 146, 234), // #c792ea (purple) tool_output: Color::Rgb(84, 110, 122), thinking_panel_title: Color::Rgb(255, 203, 107), // #ffcb6b (yellow) @@ -915,6 +1029,12 @@ fn material_light() -> Theme { background: Color::Rgb(236, 239, 241), focused_panel_border: Color::Rgb(0, 150, 136), unfocused_panel_border: Color::Rgb(176, 190, 197), + focus_beacon_fg: Theme::default_focus_beacon_fg(), + focus_beacon_bg: Theme::default_focus_beacon_bg(), + unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(), + pane_header_active: Theme::default_pane_header_active(), + pane_header_inactive: Theme::default_pane_header_inactive(), + pane_hint_text: Theme::default_pane_hint_text(), user_message_role: Color::Rgb(68, 138, 255), assistant_message_role: Color::Rgb(124, 77, 255), tool_output: Color::Rgb(144, 164, 174), @@ -964,6 +1084,12 @@ fn grayscale_high_contrast() -> Theme { background: Color::Black, focused_panel_border: Color::White, unfocused_panel_border: Color::Rgb(76, 76, 76), + focus_beacon_fg: Theme::default_focus_beacon_fg(), + focus_beacon_bg: Theme::default_focus_beacon_bg(), + unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(), + pane_header_active: Theme::default_pane_header_active(), + pane_header_inactive: Theme::default_pane_header_inactive(), + pane_hint_text: Theme::default_pane_hint_text(), user_message_role: Color::Rgb(240, 240, 240), assistant_message_role: Color::Rgb(214, 214, 214), tool_output: Color::Rgb(189, 189, 189), diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index 1955c98..21f19ac 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -21,6 +21,152 @@ use owlen_core::ui::{FocusedPanel, InputMode, RoleLabelDisplay}; const PRIVACY_TAB_INDEX: usize = HELP_TAB_COUNT - 1; +fn focus_beacon_span(is_active: bool, is_focused: bool, theme: &Theme) -> Span<'static> { + if !is_active { + return Span::styled(" ", Style::default().fg(theme.unfocused_beacon_fg)); + } + + if is_focused { + Span::styled( + "▌", + Style::default() + .fg(theme.focus_beacon_fg) + .bg(theme.focus_beacon_bg), + ) + } else { + Span::styled("▌", Style::default().fg(theme.unfocused_beacon_fg)) + } +} + +fn panel_title_spans( + label: impl Into, + is_active: bool, + is_focused: bool, + theme: &Theme, +) -> Vec> { + let mut spans: Vec> = Vec::new(); + spans.push(focus_beacon_span(is_active, is_focused, theme)); + spans.push(Span::raw(" ")); + + let mut label_style = Style::default().fg(theme.pane_header_inactive); + if is_active { + label_style = Style::default() + .fg(theme.pane_header_active) + .add_modifier(Modifier::BOLD); + if !is_focused { + label_style = label_style.add_modifier(Modifier::DIM); + } + } else { + label_style = label_style.add_modifier(Modifier::DIM); + } + + spans.push(Span::styled(label.into(), label_style)); + spans +} + +fn panel_hint_style(is_focused: bool, theme: &Theme) -> Style { + let mut style = Style::default().fg(theme.pane_hint_text); + if !is_focused { + style = style.add_modifier(Modifier::DIM); + } + style +} + +fn panel_border_style(is_active: bool, is_focused: bool, theme: &Theme) -> Style { + if is_active && is_focused { + Style::default().fg(theme.focused_panel_border) + } else if is_active { + Style::default().fg(theme.unfocused_panel_border) + } else { + Style::default() + .fg(theme.unfocused_panel_border) + .add_modifier(Modifier::DIM) + } +} + +#[cfg(test)] +mod focus_tests { + use super::*; + use ratatui::style::{Modifier, Style}; + + fn theme() -> Theme { + Theme::default() + } + + #[test] + fn beacon_blank_when_inactive() { + let theme = theme(); + let span = focus_beacon_span(false, false, &theme); + assert_eq!(span.content.as_ref(), " "); + assert_eq!(span.style.fg, Some(theme.unfocused_beacon_fg)); + assert_eq!(span.style.bg, None); + } + + #[test] + fn beacon_highlighted_when_active_and_focused() { + let theme = theme(); + let span = focus_beacon_span(true, true, &theme); + assert_eq!(span.content.as_ref(), "▌"); + assert_eq!(span.style.fg, Some(theme.focus_beacon_fg)); + assert_eq!(span.style.bg, Some(theme.focus_beacon_bg)); + } + + #[test] + fn panel_title_spans_apply_active_styles() { + let theme = theme(); + let spans = panel_title_spans("Chat", true, true, &theme); + assert_eq!(spans[0].content.as_ref(), "▌"); + assert_eq!( + spans[2].style, + Style::default() + .fg(theme.pane_header_active) + .add_modifier(Modifier::BOLD) + ); + } + + #[test] + fn panel_title_spans_dim_when_unfocused() { + let theme = theme(); + let spans = panel_title_spans("Chat", true, false, &theme); + assert_eq!(spans[0].content.as_ref(), "▌"); + assert_eq!( + spans[2].style, + Style::default() + .fg(theme.pane_header_active) + .add_modifier(Modifier::BOLD) + .add_modifier(Modifier::DIM) + ); + } + + #[test] + fn panel_hint_style_dims_when_inactive() { + let theme = theme(); + let style = panel_hint_style(false, &theme); + assert_eq!(style.fg, Some(theme.pane_hint_text)); + assert!(style.add_modifier.contains(Modifier::DIM)); + } + + #[test] + fn panel_hint_style_keeps_highlights_when_focused() { + let theme = theme(); + let style = panel_hint_style(true, &theme); + assert_eq!(style.fg, Some(theme.pane_hint_text)); + assert!(style.add_modifier.is_empty()); + } + + #[test] + fn border_style_matches_focus_state() { + let theme = theme(); + let focused = panel_border_style(true, true, &theme); + let active_unfocused = panel_border_style(true, false, &theme); + let inactive = panel_border_style(false, false, &theme); + assert_eq!(focused.fg, Some(theme.focused_panel_border)); + assert_eq!(active_unfocused.fg, Some(theme.unfocused_panel_border)); + assert_eq!(inactive.fg, Some(theme.unfocused_panel_border)); + assert!(inactive.add_modifier.contains(Modifier::DIM)); + } +} + pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { // Update thinking content from last message app.update_thinking_from_last_message(); @@ -190,56 +336,41 @@ fn render_file_tree(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { tree.show_hidden(), ) }; - let mut title_spans = vec![Span::styled( - format!("Files ▸ {}", repo_name), - Style::default().fg(theme.text).add_modifier(Modifier::BOLD), - )]; + let mut title_spans = + panel_title_spans(format!("Files ▸ {}", repo_name), true, has_focus, &theme); if !filter_query.is_empty() { let mode_label = match filter_mode { FileFilterMode::Glob => "glob", FileFilterMode::Fuzzy => "fuzzy", }; + title_spans.push(Span::raw(" ")); title_spans.push(Span::styled( - format!(" {}:{}", mode_label, filter_query), + format!("{}:{}", mode_label, filter_query), Style::default().fg(theme.info), )); } if show_hidden { + title_spans.push(Span::raw(" ")); title_spans.push(Span::styled( - " hidden:on", + "hidden:on", Style::default() - .fg(theme.placeholder) + .fg(theme.pane_hint_text) .add_modifier(Modifier::ITALIC), )); } - let hint_style = if has_focus { - Style::default() - .fg(theme.placeholder) - .add_modifier(Modifier::DIM) - } else { - Style::default() - .fg(theme.unfocused_panel_border) - .add_modifier(Modifier::DIM) - }; - title_spans.push(Span::styled( " ↩ open · o split↓ · O split→ · t tab · y abs · Y rel · a file · A dir · r ren · m move · d del · . $EDITOR · gh hidden · / mode", - hint_style, + panel_hint_style(has_focus, &theme), )); - let border_color = if has_focus { - theme.focused_panel_border - } else { - theme.unfocused_panel_border - }; - let block = Block::default() .title(Line::from(title_spans)) .borders(Borders::ALL) - .border_style(Style::default().fg(border_color)); + .border_style(panel_border_style(true, has_focus, &theme)) + .style(Style::default().bg(theme.background).fg(theme.text)); let inner = block.inner(area); let viewport_height = inner.height as usize; @@ -289,9 +420,15 @@ fn render_file_tree(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { ); } else { for (offset, entry) in entries[start..end].iter().enumerate() { + let absolute_idx = start + offset; + let is_selected = absolute_idx == tree.cursor(); let node = &tree.nodes()[entry.index]; let indent_level = entry.depth.saturating_sub(1); - let mut spans: Vec> = Vec::new(); + let mut spans: Vec> = Vec::new(); + + spans.push(focus_beacon_span(is_selected, has_focus, &theme)); + spans.push(Span::raw(" ")); + if indent_level > 0 { spans.push(Span::raw(" ".repeat(indent_level))); } @@ -341,9 +478,8 @@ fn render_file_tree(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { spans.push(Span::styled(node.name.clone(), name_style)); - let absolute_idx = start + offset; let mut line_style = Style::default(); - if absolute_idx == tree.cursor() { + if is_selected { line_style = line_style.bg(theme.selection_bg).fg(theme.selection_fg); } else if !has_focus { line_style = line_style.fg(theme.text).add_modifier(Modifier::DIM); @@ -888,6 +1024,7 @@ fn char_to_byte_index(s: &str, char_idx: usize) -> usize { fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { let theme = app.theme().clone(); + let has_focus = matches!(app.focused_panel(), FocusedPanel::Chat); // Calculate viewport dimensions for autoscroll calculations let viewport_height = area.height.saturating_sub(2) as usize; // subtract borders @@ -1022,21 +1159,22 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { let scroll_position = app.scroll().min(u16::MAX as usize) as u16; - // Highlight border if this panel is focused - let border_color = if matches!(app.focused_panel(), FocusedPanel::Chat) { - theme.focused_panel_border - } else { - theme.unfocused_panel_border - }; + let mut title_spans = panel_title_spans("Chat", true, has_focus, &theme); + title_spans.push(Span::raw(" ")); + title_spans.push(Span::styled( + "PgUp/PgDn scroll · g/G jump · s save", + panel_hint_style(has_focus, &theme), + )); + + let chat_block = Block::default() + .borders(Borders::ALL) + .border_style(panel_border_style(true, has_focus, &theme)) + .style(Style::default().bg(theme.background).fg(theme.text)) + .title(Line::from(title_spans)); let paragraph = Paragraph::new(lines) .style(Style::default().bg(theme.background).fg(theme.text)) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(border_color)) - .style(Style::default().bg(theme.background).fg(theme.text)), - ) + .block(chat_block) .scroll((scroll_position, 0)); frame.render_widget(paragraph, area); @@ -1134,26 +1272,21 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { thinking_scroll.on_viewport(viewport_height); let scroll_position = app.thinking_scroll_position().min(u16::MAX as usize) as u16; - - // Highlight border if this panel is focused - let border_color = if matches!(app.focused_panel(), FocusedPanel::Thinking) { - theme.focused_panel_border - } else { - theme.unfocused_panel_border - }; + let has_focus = matches!(app.focused_panel(), FocusedPanel::Thinking); + let mut title_spans = panel_title_spans("💭 Thinking", true, has_focus, &theme); + title_spans.push(Span::raw(" ")); + title_spans.push(Span::styled( + "Esc close", + panel_hint_style(has_focus, &theme), + )); let paragraph = Paragraph::new(lines) .style(Style::default().bg(theme.background)) .block( Block::default() - .title(Span::styled( - " 💭 Thinking ", - Style::default() - .fg(theme.thinking_panel_title) - .add_modifier(Modifier::ITALIC), - )) + .title(Line::from(title_spans)) .borders(Borders::ALL) - .border_style(Style::default().fg(border_color)) + .border_style(panel_border_style(true, has_focus, &theme)) .style(Style::default().bg(theme.background).fg(theme.text)), ) .scroll((scroll_position, 0)) @@ -1162,10 +1295,7 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { frame.render_widget(paragraph, area); // Render cursor if Thinking panel is focused and in Normal mode - if app.cursor_should_be_visible() - && matches!(app.focused_panel(), FocusedPanel::Thinking) - && matches!(app.mode(), InputMode::Normal) - { + if app.cursor_should_be_visible() && has_focus && matches!(app.mode(), InputMode::Normal) { let cursor = app.thinking_cursor(); let cursor_row = cursor.0; let cursor_col = cursor.1; @@ -1195,6 +1325,7 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { // Color-coded: THOUGHT (blue), ACTION (yellow), OBSERVATION (green) fn render_agent_actions(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { let theme = app.theme().clone(); + let has_focus = matches!(app.focused_panel(), FocusedPanel::Thinking); if let Some(actions) = app.agent_actions().cloned() { let viewport_height = area.height.saturating_sub(2) as usize; // subtract borders @@ -1348,26 +1479,20 @@ fn render_agent_actions(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { } } - // Highlight border if this panel is focused - let border_color = if matches!(app.focused_panel(), FocusedPanel::Thinking) { - // Reuse the same focus logic; could add a dedicated enum variant later. - theme.focused_panel_border - } else { - theme.unfocused_panel_border - }; + let mut title_spans = panel_title_spans("🤖 Agent Actions", true, has_focus, &theme); + title_spans.push(Span::raw(" ")); + title_spans.push(Span::styled( + "Pause ▸ p · Resume ▸ r", + panel_hint_style(has_focus, &theme), + )); let paragraph = Paragraph::new(lines) .style(Style::default().bg(theme.background)) .block( Block::default() - .title(Span::styled( - " 🤖 Agent Actions ", - Style::default() - .fg(theme.thinking_panel_title) - .add_modifier(Modifier::ITALIC), - )) + .title(Line::from(title_spans)) .borders(Borders::ALL) - .border_style(Style::default().fg(border_color)) + .border_style(panel_border_style(true, has_focus, &theme)) .style(Style::default().bg(theme.background).fg(theme.text)), ) .wrap(Wrap { trim: false }); @@ -1379,31 +1504,44 @@ fn render_agent_actions(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { let theme = app.theme().clone(); - let title = match app.mode() { - InputMode::Editing => " Input (Enter=send · Ctrl+J=newline · Esc=exit input mode) ", - InputMode::Visual => " Visual Mode (y=yank · d=cut · Esc=cancel) ", - InputMode::Command => " Command Mode (Enter=execute · Esc=cancel) ", - InputMode::RepoSearch => " Repo Search (Enter=run · Alt+Enter=scratch · Esc=close) ", - InputMode::SymbolSearch => " Symbol Search (Ctrl+P then @) ", - _ => " Input (Press 'i' to start typing) ", + let has_focus = matches!(app.focused_panel(), FocusedPanel::Input); + let (label, hint) = match app.mode() { + InputMode::Editing => ( + "Input", + Some("Enter send · Shift+Enter newline · Esc normal"), + ), + InputMode::Visual => ("Visual Select", Some("y yank · d cut · Esc cancel")), + InputMode::Command => ("Command", Some("Enter run · Esc cancel")), + InputMode::RepoSearch => ( + "Repo Search", + Some("Enter run · Alt+Enter scratch · Esc close"), + ), + InputMode::SymbolSearch => ("Symbol Search", Some("Type @name · Esc close")), + _ => ("Input", Some("Press i to start typing")), }; - // Highlight border if this panel is focused - let border_color = if matches!(app.focused_panel(), FocusedPanel::Input) { - theme.focused_panel_border - } else { - theme.unfocused_panel_border - }; + let is_active = matches!( + app.mode(), + InputMode::Editing + | InputMode::Visual + | InputMode::Command + | InputMode::RepoSearch + | InputMode::SymbolSearch + ); + + let mut title_spans = panel_title_spans(label, is_active, has_focus, &theme); + if let Some(hint_text) = hint { + title_spans.push(Span::raw(" ")); + title_spans.push(Span::styled( + hint_text.to_string(), + panel_hint_style(has_focus, &theme), + )); + } let input_block = Block::default() - .title(Span::styled( - title, - Style::default() - .fg(theme.focused_panel_border) - .add_modifier(Modifier::BOLD), - )) + .title(Line::from(title_spans)) .borders(Borders::ALL) - .border_style(Style::default().fg(border_color)) + .border_style(panel_border_style(is_active, has_focus, &theme)) .style(Style::default().bg(theme.background).fg(theme.text)); if matches!(app.mode(), InputMode::Editing) { @@ -1595,8 +1733,8 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { Span::styled( format!(" │ {}", focus_label), Style::default() - .fg(theme.info) - .add_modifier(Modifier::ITALIC), + .fg(theme.pane_header_active) + .add_modifier(Modifier::BOLD | Modifier::ITALIC), ), ]; @@ -1919,26 +2057,13 @@ fn render_code_pane( }) .unwrap_or_else(|| pane.title.clone()); - let mut title_style = Style::default().fg(theme.placeholder); + let mut title_spans = panel_title_spans(title, is_active, has_focus && is_active, theme); if is_active { - title_style = Style::default() - .fg(if has_focus { - theme.focused_panel_border - } else { - theme.unfocused_panel_border - }) - .add_modifier(Modifier::BOLD); - } else { - title_style = title_style.add_modifier(Modifier::DIM); - } - - let mut border_style = Style::default().fg(theme.unfocused_panel_border); - if is_active && has_focus { - border_style = Style::default().fg(theme.focused_panel_border); - } else if is_active { - border_style = border_style.fg(theme.unfocused_panel_border); - } else { - border_style = border_style.add_modifier(Modifier::DIM); + title_spans.push(Span::raw(" ")); + title_spans.push(Span::styled( + "Ctrl+W split · :w save", + panel_hint_style(has_focus && is_active, theme), + )); } let paragraph = Paragraph::new(lines) @@ -1946,8 +2071,8 @@ fn render_code_pane( .block( Block::default() .borders(Borders::ALL) - .border_style(border_style) - .title(Span::styled(title, title_style)), + .border_style(panel_border_style(is_active, has_focus && is_active, theme)) + .title(Line::from(title_spans)), ) .scroll((scroll_position, 0)) .wrap(Wrap { trim: false }); @@ -2616,7 +2741,9 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { )]), Line::from(" Tab → cycle panels forward"), Line::from(" Shift+Tab → cycle panels backward"), - Line::from(" (Panels: Chat, Thinking, Input)"), + Line::from(" (Panels: Files, Chat, Thinking, Actions, Input, Code)"), + Line::from(" ▌ beacon marks the active entry; bright when the pane has focus"), + Line::from(" Status bar highlights MODE, CONTEXT, and current FOCUS target"), Line::from(""), Line::from(vec![Span::styled( "CURSOR MOVEMENT",