feat(ui): introduce focus beacon and unified panel styling helpers

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.
This commit is contained in:
2025-10-12 21:37:34 +02:00
parent 33ad3797a1
commit f413a63c5a
2 changed files with 390 additions and 137 deletions

View File

@@ -36,6 +36,42 @@ pub struct Theme {
#[serde(serialize_with = "serialize_color")] #[serde(serialize_with = "serialize_color")]
pub unfocused_panel_border: 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 /// Color for user message role indicator
#[serde(deserialize_with = "deserialize_color")] #[serde(deserialize_with = "deserialize_color")]
#[serde(serialize_with = "serialize_color")] #[serde(serialize_with = "serialize_color")]
@@ -313,6 +349,30 @@ impl Theme {
Color::Cyan 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 { const fn default_operating_chat_fg() -> Color {
Color::Black Color::Black
} }
@@ -474,6 +534,12 @@ fn default_dark() -> Theme {
background: Color::Black, background: Color::Black,
focused_panel_border: Color::LightMagenta, focused_panel_border: Color::LightMagenta,
unfocused_panel_border: Color::Rgb(95, 20, 135), 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, user_message_role: Color::LightBlue,
assistant_message_role: Color::Yellow, assistant_message_role: Color::Yellow,
tool_output: Color::Gray, tool_output: Color::Gray,
@@ -523,6 +589,12 @@ fn default_light() -> Theme {
background: Color::White, background: Color::White,
focused_panel_border: Color::Rgb(74, 144, 226), focused_panel_border: Color::Rgb(74, 144, 226),
unfocused_panel_border: Color::Rgb(221, 221, 221), 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), user_message_role: Color::Rgb(0, 85, 164),
assistant_message_role: Color::Rgb(142, 68, 173), assistant_message_role: Color::Rgb(142, 68, 173),
tool_output: Color::Gray, tool_output: Color::Gray,
@@ -572,7 +644,13 @@ fn gruvbox() -> Theme {
background: Color::Rgb(40, 40, 40), // #282828 background: Color::Rgb(40, 40, 40), // #282828
focused_panel_border: Color::Rgb(254, 128, 25), // #fe8019 (orange) focused_panel_border: Color::Rgb(254, 128, 25), // #fe8019 (orange)
unfocused_panel_border: Color::Rgb(124, 111, 100), // #7c6f64 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) assistant_message_role: Color::Rgb(131, 165, 152), // #83a598 (blue)
tool_output: Color::Rgb(146, 131, 116), tool_output: Color::Rgb(146, 131, 116),
thinking_panel_title: Color::Rgb(211, 134, 155), // #d3869b (purple) thinking_panel_title: Color::Rgb(211, 134, 155), // #d3869b (purple)
@@ -617,11 +695,17 @@ fn gruvbox() -> Theme {
fn dracula() -> Theme { fn dracula() -> Theme {
Theme { Theme {
name: "dracula".to_string(), name: "dracula".to_string(),
text: Color::Rgb(248, 248, 242), // #f8f8f2 text: Color::Rgb(248, 248, 242), // #f8f8f2
background: Color::Rgb(40, 42, 54), // #282a36 background: Color::Rgb(40, 42, 54), // #282a36
focused_panel_border: Color::Rgb(255, 121, 198), // #ff79c6 (pink) focused_panel_border: Color::Rgb(255, 121, 198), // #ff79c6 (pink)
unfocused_panel_border: Color::Rgb(68, 71, 90), // #44475a unfocused_panel_border: Color::Rgb(68, 71, 90), // #44475a
user_message_role: Color::Rgb(139, 233, 253), // #8be9fd (cyan) 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) assistant_message_role: Color::Rgb(255, 121, 198), // #ff79c6 (pink)
tool_output: Color::Rgb(98, 114, 164), tool_output: Color::Rgb(98, 114, 164),
thinking_panel_title: Color::Rgb(189, 147, 249), // #bd93f9 (purple) 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) background: Color::Rgb(0, 43, 54), // #002b36 (base03)
focused_panel_border: Color::Rgb(38, 139, 210), // #268bd2 (blue) focused_panel_border: Color::Rgb(38, 139, 210), // #268bd2 (blue)
unfocused_panel_border: Color::Rgb(7, 54, 66), // #073642 (base02) 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) user_message_role: Color::Rgb(42, 161, 152), // #2aa198 (cyan)
assistant_message_role: Color::Rgb(203, 75, 22), // #cb4b16 (orange) assistant_message_role: Color::Rgb(203, 75, 22), // #cb4b16 (orange)
tool_output: Color::Rgb(101, 123, 131), tool_output: Color::Rgb(101, 123, 131),
@@ -719,6 +809,12 @@ fn midnight_ocean() -> Theme {
background: Color::Rgb(13, 17, 23), background: Color::Rgb(13, 17, 23),
focused_panel_border: Color::Rgb(88, 166, 255), focused_panel_border: Color::Rgb(88, 166, 255),
unfocused_panel_border: Color::Rgb(48, 54, 61), 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), user_message_role: Color::Rgb(121, 192, 255),
assistant_message_role: Color::Rgb(137, 221, 255), assistant_message_role: Color::Rgb(137, 221, 255),
tool_output: Color::Rgb(84, 110, 122), tool_output: Color::Rgb(84, 110, 122),
@@ -764,11 +860,17 @@ fn midnight_ocean() -> Theme {
fn rose_pine() -> Theme { fn rose_pine() -> Theme {
Theme { Theme {
name: "rose-pine".to_string(), name: "rose-pine".to_string(),
text: Color::Rgb(224, 222, 244), // #e0def4 text: Color::Rgb(224, 222, 244), // #e0def4
background: Color::Rgb(25, 23, 36), // #191724 background: Color::Rgb(25, 23, 36), // #191724
focused_panel_border: Color::Rgb(235, 111, 146), // #eb6f92 (love) focused_panel_border: Color::Rgb(235, 111, 146), // #eb6f92 (love)
unfocused_panel_border: Color::Rgb(38, 35, 58), // #26233a unfocused_panel_border: Color::Rgb(38, 35, 58), // #26233a
user_message_role: Color::Rgb(49, 116, 143), // #31748f (foam) 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) assistant_message_role: Color::Rgb(156, 207, 216), // #9ccfd8 (foam light)
tool_output: Color::Rgb(110, 106, 134), tool_output: Color::Rgb(110, 106, 134),
thinking_panel_title: Color::Rgb(196, 167, 231), // #c4a7e7 (iris) thinking_panel_title: Color::Rgb(196, 167, 231), // #c4a7e7 (iris)
@@ -813,11 +915,17 @@ fn rose_pine() -> Theme {
fn monokai() -> Theme { fn monokai() -> Theme {
Theme { Theme {
name: "monokai".to_string(), name: "monokai".to_string(),
text: Color::Rgb(248, 248, 242), // #f8f8f2 text: Color::Rgb(248, 248, 242), // #f8f8f2
background: Color::Rgb(39, 40, 34), // #272822 background: Color::Rgb(39, 40, 34), // #272822
focused_panel_border: Color::Rgb(249, 38, 114), // #f92672 (pink) focused_panel_border: Color::Rgb(249, 38, 114), // #f92672 (pink)
unfocused_panel_border: Color::Rgb(117, 113, 94), // #75715e unfocused_panel_border: Color::Rgb(117, 113, 94), // #75715e
user_message_role: Color::Rgb(102, 217, 239), // #66d9ef (cyan) 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) assistant_message_role: Color::Rgb(174, 129, 255), // #ae81ff (purple)
tool_output: Color::Rgb(117, 113, 94), tool_output: Color::Rgb(117, 113, 94),
thinking_panel_title: Color::Rgb(230, 219, 116), // #e6db74 (yellow) thinking_panel_title: Color::Rgb(230, 219, 116), // #e6db74 (yellow)
@@ -862,11 +970,17 @@ fn monokai() -> Theme {
fn material_dark() -> Theme { fn material_dark() -> Theme {
Theme { Theme {
name: "material-dark".to_string(), name: "material-dark".to_string(),
text: Color::Rgb(238, 255, 255), // #eeffff text: Color::Rgb(238, 255, 255), // #eeffff
background: Color::Rgb(38, 50, 56), // #263238 background: Color::Rgb(38, 50, 56), // #263238
focused_panel_border: Color::Rgb(128, 203, 196), // #80cbc4 (cyan) focused_panel_border: Color::Rgb(128, 203, 196), // #80cbc4 (cyan)
unfocused_panel_border: Color::Rgb(84, 110, 122), // #546e7a unfocused_panel_border: Color::Rgb(84, 110, 122), // #546e7a
user_message_role: Color::Rgb(130, 170, 255), // #82aaff (blue) 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) assistant_message_role: Color::Rgb(199, 146, 234), // #c792ea (purple)
tool_output: Color::Rgb(84, 110, 122), tool_output: Color::Rgb(84, 110, 122),
thinking_panel_title: Color::Rgb(255, 203, 107), // #ffcb6b (yellow) thinking_panel_title: Color::Rgb(255, 203, 107), // #ffcb6b (yellow)
@@ -915,6 +1029,12 @@ fn material_light() -> Theme {
background: Color::Rgb(236, 239, 241), background: Color::Rgb(236, 239, 241),
focused_panel_border: Color::Rgb(0, 150, 136), focused_panel_border: Color::Rgb(0, 150, 136),
unfocused_panel_border: Color::Rgb(176, 190, 197), 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), user_message_role: Color::Rgb(68, 138, 255),
assistant_message_role: Color::Rgb(124, 77, 255), assistant_message_role: Color::Rgb(124, 77, 255),
tool_output: Color::Rgb(144, 164, 174), tool_output: Color::Rgb(144, 164, 174),
@@ -964,6 +1084,12 @@ fn grayscale_high_contrast() -> Theme {
background: Color::Black, background: Color::Black,
focused_panel_border: Color::White, focused_panel_border: Color::White,
unfocused_panel_border: Color::Rgb(76, 76, 76), 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), user_message_role: Color::Rgb(240, 240, 240),
assistant_message_role: Color::Rgb(214, 214, 214), assistant_message_role: Color::Rgb(214, 214, 214),
tool_output: Color::Rgb(189, 189, 189), tool_output: Color::Rgb(189, 189, 189),

View File

@@ -21,6 +21,152 @@ use owlen_core::ui::{FocusedPanel, InputMode, RoleLabelDisplay};
const PRIVACY_TAB_INDEX: usize = HELP_TAB_COUNT - 1; 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<String>,
is_active: bool,
is_focused: bool,
theme: &Theme,
) -> Vec<Span<'static>> {
let mut spans: Vec<Span<'static>> = 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) { pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
// Update thinking content from last message // Update thinking content from last message
app.update_thinking_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(), tree.show_hidden(),
) )
}; };
let mut title_spans = vec![Span::styled( let mut title_spans =
format!("Files ▸ {}", repo_name), panel_title_spans(format!("Files ▸ {}", repo_name), true, has_focus, &theme);
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
)];
if !filter_query.is_empty() { if !filter_query.is_empty() {
let mode_label = match filter_mode { let mode_label = match filter_mode {
FileFilterMode::Glob => "glob", FileFilterMode::Glob => "glob",
FileFilterMode::Fuzzy => "fuzzy", FileFilterMode::Fuzzy => "fuzzy",
}; };
title_spans.push(Span::raw(" "));
title_spans.push(Span::styled( title_spans.push(Span::styled(
format!(" {}:{}", mode_label, filter_query), format!("{}:{}", mode_label, filter_query),
Style::default().fg(theme.info), Style::default().fg(theme.info),
)); ));
} }
if show_hidden { if show_hidden {
title_spans.push(Span::raw(" "));
title_spans.push(Span::styled( title_spans.push(Span::styled(
" hidden:on", "hidden:on",
Style::default() Style::default()
.fg(theme.placeholder) .fg(theme.pane_hint_text)
.add_modifier(Modifier::ITALIC), .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( 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", " ↩ 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() let block = Block::default()
.title(Line::from(title_spans)) .title(Line::from(title_spans))
.borders(Borders::ALL) .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 inner = block.inner(area);
let viewport_height = inner.height as usize; let viewport_height = inner.height as usize;
@@ -289,9 +420,15 @@ fn render_file_tree(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
); );
} else { } else {
for (offset, entry) in entries[start..end].iter().enumerate() { 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 node = &tree.nodes()[entry.index];
let indent_level = entry.depth.saturating_sub(1); let indent_level = entry.depth.saturating_sub(1);
let mut spans: Vec<Span<'_>> = Vec::new(); let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(focus_beacon_span(is_selected, has_focus, &theme));
spans.push(Span::raw(" "));
if indent_level > 0 { if indent_level > 0 {
spans.push(Span::raw(" ".repeat(indent_level))); 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)); spans.push(Span::styled(node.name.clone(), name_style));
let absolute_idx = start + offset;
let mut line_style = Style::default(); 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); line_style = line_style.bg(theme.selection_bg).fg(theme.selection_fg);
} else if !has_focus { } else if !has_focus {
line_style = line_style.fg(theme.text).add_modifier(Modifier::DIM); 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) { fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let theme = app.theme().clone(); let theme = app.theme().clone();
let has_focus = matches!(app.focused_panel(), FocusedPanel::Chat);
// Calculate viewport dimensions for autoscroll calculations // Calculate viewport dimensions for autoscroll calculations
let viewport_height = area.height.saturating_sub(2) as usize; // subtract borders 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; let scroll_position = app.scroll().min(u16::MAX as usize) as u16;
// Highlight border if this panel is focused let mut title_spans = panel_title_spans("Chat", true, has_focus, &theme);
let border_color = if matches!(app.focused_panel(), FocusedPanel::Chat) { title_spans.push(Span::raw(" "));
theme.focused_panel_border title_spans.push(Span::styled(
} else { "PgUp/PgDn scroll · g/G jump · s save",
theme.unfocused_panel_border 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) let paragraph = Paragraph::new(lines)
.style(Style::default().bg(theme.background).fg(theme.text)) .style(Style::default().bg(theme.background).fg(theme.text))
.block( .block(chat_block)
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.style(Style::default().bg(theme.background).fg(theme.text)),
)
.scroll((scroll_position, 0)); .scroll((scroll_position, 0));
frame.render_widget(paragraph, area); 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); thinking_scroll.on_viewport(viewport_height);
let scroll_position = app.thinking_scroll_position().min(u16::MAX as usize) as u16; let scroll_position = app.thinking_scroll_position().min(u16::MAX as usize) as u16;
let has_focus = matches!(app.focused_panel(), FocusedPanel::Thinking);
// Highlight border if this panel is focused let mut title_spans = panel_title_spans("💭 Thinking", true, has_focus, &theme);
let border_color = if matches!(app.focused_panel(), FocusedPanel::Thinking) { title_spans.push(Span::raw(" "));
theme.focused_panel_border title_spans.push(Span::styled(
} else { "Esc close",
theme.unfocused_panel_border panel_hint_style(has_focus, &theme),
}; ));
let paragraph = Paragraph::new(lines) let paragraph = Paragraph::new(lines)
.style(Style::default().bg(theme.background)) .style(Style::default().bg(theme.background))
.block( .block(
Block::default() Block::default()
.title(Span::styled( .title(Line::from(title_spans))
" 💭 Thinking ",
Style::default()
.fg(theme.thinking_panel_title)
.add_modifier(Modifier::ITALIC),
))
.borders(Borders::ALL) .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)), .style(Style::default().bg(theme.background).fg(theme.text)),
) )
.scroll((scroll_position, 0)) .scroll((scroll_position, 0))
@@ -1162,10 +1295,7 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
frame.render_widget(paragraph, area); frame.render_widget(paragraph, area);
// Render cursor if Thinking panel is focused and in Normal mode // Render cursor if Thinking panel is focused and in Normal mode
if app.cursor_should_be_visible() if app.cursor_should_be_visible() && has_focus && matches!(app.mode(), InputMode::Normal) {
&& matches!(app.focused_panel(), FocusedPanel::Thinking)
&& matches!(app.mode(), InputMode::Normal)
{
let cursor = app.thinking_cursor(); let cursor = app.thinking_cursor();
let cursor_row = cursor.0; let cursor_row = cursor.0;
let cursor_col = cursor.1; 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) // Color-coded: THOUGHT (blue), ACTION (yellow), OBSERVATION (green)
fn render_agent_actions(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { fn render_agent_actions(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let theme = app.theme().clone(); let theme = app.theme().clone();
let has_focus = matches!(app.focused_panel(), FocusedPanel::Thinking);
if let Some(actions) = app.agent_actions().cloned() { if let Some(actions) = app.agent_actions().cloned() {
let viewport_height = area.height.saturating_sub(2) as usize; // subtract borders 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 mut title_spans = panel_title_spans("🤖 Agent Actions", true, has_focus, &theme);
let border_color = if matches!(app.focused_panel(), FocusedPanel::Thinking) { title_spans.push(Span::raw(" "));
// Reuse the same focus logic; could add a dedicated enum variant later. title_spans.push(Span::styled(
theme.focused_panel_border "Pause ▸ p · Resume ▸ r",
} else { panel_hint_style(has_focus, &theme),
theme.unfocused_panel_border ));
};
let paragraph = Paragraph::new(lines) let paragraph = Paragraph::new(lines)
.style(Style::default().bg(theme.background)) .style(Style::default().bg(theme.background))
.block( .block(
Block::default() Block::default()
.title(Span::styled( .title(Line::from(title_spans))
" 🤖 Agent Actions ",
Style::default()
.fg(theme.thinking_panel_title)
.add_modifier(Modifier::ITALIC),
))
.borders(Borders::ALL) .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)), .style(Style::default().bg(theme.background).fg(theme.text)),
) )
.wrap(Wrap { trim: false }); .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) { fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let theme = app.theme().clone(); let theme = app.theme().clone();
let title = match app.mode() { let has_focus = matches!(app.focused_panel(), FocusedPanel::Input);
InputMode::Editing => " Input (Enter=send · Ctrl+J=newline · Esc=exit input mode) ", let (label, hint) = match app.mode() {
InputMode::Visual => " Visual Mode (y=yank · d=cut · Esc=cancel) ", InputMode::Editing => (
InputMode::Command => " Command Mode (Enter=execute · Esc=cancel) ", "Input",
InputMode::RepoSearch => " Repo Search (Enter=run · Alt+Enter=scratch · Esc=close) ", Some("Enter send · Shift+Enter newline · Esc normal"),
InputMode::SymbolSearch => " Symbol Search (Ctrl+P then @) ", ),
_ => " Input (Press 'i' to start typing) ", 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 is_active = matches!(
let border_color = if matches!(app.focused_panel(), FocusedPanel::Input) { app.mode(),
theme.focused_panel_border InputMode::Editing
} else { | InputMode::Visual
theme.unfocused_panel_border | 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() let input_block = Block::default()
.title(Span::styled( .title(Line::from(title_spans))
title,
Style::default()
.fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL) .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)); .style(Style::default().bg(theme.background).fg(theme.text));
if matches!(app.mode(), InputMode::Editing) { if matches!(app.mode(), InputMode::Editing) {
@@ -1595,8 +1733,8 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
Span::styled( Span::styled(
format!("{}", focus_label), format!("{}", focus_label),
Style::default() Style::default()
.fg(theme.info) .fg(theme.pane_header_active)
.add_modifier(Modifier::ITALIC), .add_modifier(Modifier::BOLD | Modifier::ITALIC),
), ),
]; ];
@@ -1919,26 +2057,13 @@ fn render_code_pane(
}) })
.unwrap_or_else(|| pane.title.clone()); .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 { if is_active {
title_style = Style::default() title_spans.push(Span::raw(" "));
.fg(if has_focus { title_spans.push(Span::styled(
theme.focused_panel_border "Ctrl+W split · :w save",
} else { panel_hint_style(has_focus && is_active, theme),
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);
} }
let paragraph = Paragraph::new(lines) let paragraph = Paragraph::new(lines)
@@ -1946,8 +2071,8 @@ fn render_code_pane(
.block( .block(
Block::default() Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(border_style) .border_style(panel_border_style(is_active, has_focus && is_active, theme))
.title(Span::styled(title, title_style)), .title(Line::from(title_spans)),
) )
.scroll((scroll_position, 0)) .scroll((scroll_position, 0))
.wrap(Wrap { trim: false }); .wrap(Wrap { trim: false });
@@ -2616,7 +2741,9 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
)]), )]),
Line::from(" Tab → cycle panels forward"), Line::from(" Tab → cycle panels forward"),
Line::from(" Shift+Tab → cycle panels backward"), 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(""),
Line::from(vec![Span::styled( Line::from(vec![Span::styled(
"CURSOR MOVEMENT", "CURSOR MOVEMENT",