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")]
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),

View File

@@ -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<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) {
// 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<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 {
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",