feat(tui): add Ctrl+1‑5 panel focus shortcuts and UI hints

- Implement `focus_panel` to programmatically switch between panels with validation.
- Add key bindings for `Ctrl+1`‑`Ctrl+5` to focus Files, Chat, Code, Thinking, and Input panels respectively.
- Update pane headers to display focus shortcuts alongside panel labels.
- Extend UI hint strings across panels to include the new focus shortcuts.
- Refactor highlight style handling and introduce a dedicated `highlight_style`.
- Adjust default theme colors to use explicit RGB values for better consistency.
This commit is contained in:
2025-10-15 06:24:57 +02:00
parent 96e0436d43
commit baf49b1e69
3 changed files with 155 additions and 56 deletions

View File

@@ -532,52 +532,52 @@ fn default_dark() -> Theme {
name: "default_dark".to_string(), name: "default_dark".to_string(),
text: Color::White, text: Color::White,
background: Color::Black, background: Color::Black,
focused_panel_border: Color::LightMagenta, focused_panel_border: Color::Rgb(216, 160, 255),
unfocused_panel_border: Color::Rgb(95, 20, 135), unfocused_panel_border: Color::Rgb(137, 82, 204),
focus_beacon_fg: Theme::default_focus_beacon_fg(), focus_beacon_fg: Color::Rgb(248, 229, 255),
focus_beacon_bg: Theme::default_focus_beacon_bg(), focus_beacon_bg: Color::Rgb(38, 10, 58),
unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(), unfocused_beacon_fg: Color::Rgb(130, 130, 130),
pane_header_active: Theme::default_pane_header_active(), pane_header_active: Theme::default_pane_header_active(),
pane_header_inactive: Theme::default_pane_header_inactive(), pane_header_inactive: Color::Rgb(210, 210, 210),
pane_hint_text: Theme::default_pane_hint_text(), pane_hint_text: Color::Rgb(210, 210, 210),
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::Rgb(200, 200, 200),
thinking_panel_title: Color::LightMagenta, thinking_panel_title: Color::Rgb(234, 182, 255),
command_bar_background: Color::Black, command_bar_background: Color::Rgb(10, 10, 10),
status_background: Color::Black, status_background: Color::Rgb(12, 12, 12),
mode_normal: Color::LightBlue, mode_normal: Color::Rgb(117, 200, 255),
mode_editing: Color::LightGreen, mode_editing: Color::Rgb(144, 242, 170),
mode_model_selection: Color::LightYellow, mode_model_selection: Color::Rgb(255, 226, 140),
mode_provider_selection: Color::LightCyan, mode_provider_selection: Color::Rgb(164, 235, 255),
mode_help: Color::LightMagenta, mode_help: Color::Rgb(234, 182, 255),
mode_visual: Color::Magenta, mode_visual: Color::Rgb(255, 170, 255),
mode_command: Color::Yellow, mode_command: Color::Rgb(255, 220, 120),
selection_bg: Color::LightBlue, selection_bg: Color::Rgb(56, 140, 240),
selection_fg: Color::Black, selection_fg: Color::Black,
cursor: Color::Magenta, cursor: Color::Rgb(255, 196, 255),
code_block_background: Color::Rgb(25, 25, 25), code_block_background: Color::Rgb(25, 25, 25),
code_block_border: Color::LightMagenta, code_block_border: Color::Rgb(216, 160, 255),
code_block_text: Color::White, code_block_text: Color::White,
code_block_keyword: Color::Yellow, code_block_keyword: Color::Rgb(255, 220, 120),
code_block_string: Color::LightGreen, code_block_string: Color::Rgb(144, 242, 170),
code_block_comment: Color::Gray, code_block_comment: Color::Rgb(170, 170, 170),
placeholder: Color::DarkGray, placeholder: Color::Rgb(180, 180, 180),
error: Color::Red, error: Color::Red,
info: Color::LightGreen, info: Color::Rgb(144, 242, 170),
agent_thought: Color::LightBlue, agent_thought: Color::Rgb(117, 200, 255),
agent_action: Color::Yellow, agent_action: Color::Rgb(255, 220, 120),
agent_action_input: Color::LightCyan, agent_action_input: Color::Rgb(164, 235, 255),
agent_observation: Color::LightGreen, agent_observation: Color::Rgb(144, 242, 170),
agent_final_answer: Color::Magenta, agent_final_answer: Color::Rgb(255, 170, 255),
agent_badge_running_fg: Color::Black, agent_badge_running_fg: Color::Black,
agent_badge_running_bg: Color::Yellow, agent_badge_running_bg: Color::Yellow,
agent_badge_idle_fg: Color::Black, agent_badge_idle_fg: Color::Black,
agent_badge_idle_bg: Color::Cyan, agent_badge_idle_bg: Color::Cyan,
operating_chat_fg: Color::Black, operating_chat_fg: Color::Black,
operating_chat_bg: Color::Blue, operating_chat_bg: Color::Rgb(117, 200, 255),
operating_code_fg: Color::Black, operating_code_fg: Color::Black,
operating_code_bg: Color::Magenta, operating_code_bg: Color::Rgb(255, 170, 255),
} }
} }

View File

@@ -2604,6 +2604,39 @@ impl ChatApp {
self.focused_panel = order[prev_index]; self.focused_panel = order[prev_index];
} }
pub fn focus_panel(&mut self, target: FocusedPanel) -> bool {
match target {
FocusedPanel::Files => {
if self.file_panel_collapsed {
self.expand_file_panel();
if self.file_panel_collapsed {
return false;
}
}
}
FocusedPanel::Code => {
if !self.should_show_code_view() {
return false;
}
}
FocusedPanel::Thinking => {
if self.current_thinking.is_none() && self.agent_actions.is_none() {
return false;
}
}
FocusedPanel::Chat | FocusedPanel::Input => {}
}
let order = self.focus_sequence();
if !order.contains(&target) {
return false;
}
self.focused_panel = target;
self.ensure_focus_valid();
true
}
/// Sync textarea content to input buffer /// Sync textarea content to input buffer
fn sync_textarea_to_buffer(&mut self) { fn sync_textarea_to_buffer(&mut self) {
let text = self.textarea.lines().join("\n"); let text = self.textarea.lines().join("\n");
@@ -5007,6 +5040,65 @@ impl ChatApp {
}; };
self.status = format!("Focus: {}", panel_name); self.status = format!("Focus: {}", panel_name);
} }
(KeyCode::Char('1'), modifiers)
if modifiers.contains(KeyModifiers::CONTROL)
|| modifiers.contains(KeyModifiers::ALT) =>
{
if self.focus_panel(FocusedPanel::Files) {
self.status = "Focus: Files (Ctrl+1)".to_string();
self.error = None;
} else if self.is_code_mode() {
self.status = "Files panel is collapsed — use :files to reopen"
.to_string();
}
return Ok(AppState::Running);
}
(KeyCode::Char('2'), modifiers)
if modifiers.contains(KeyModifiers::CONTROL)
|| modifiers.contains(KeyModifiers::ALT) =>
{
if self.focus_panel(FocusedPanel::Chat) {
self.status = "Focus: Chat (Ctrl+2)".to_string();
self.error = None;
}
return Ok(AppState::Running);
}
(KeyCode::Char('3'), modifiers)
if modifiers.contains(KeyModifiers::CONTROL)
|| modifiers.contains(KeyModifiers::ALT) =>
{
if self.focus_panel(FocusedPanel::Code) {
self.status = "Focus: Code (Ctrl+3)".to_string();
self.error = None;
} else {
self.status =
"Open a file to focus the code workspace".to_string();
}
return Ok(AppState::Running);
}
(KeyCode::Char('4'), modifiers)
if modifiers.contains(KeyModifiers::CONTROL)
|| modifiers.contains(KeyModifiers::ALT) =>
{
if self.focus_panel(FocusedPanel::Thinking) {
self.status = "Focus: Thinking (Ctrl+4)".to_string();
self.error = None;
} else {
self.status = "No reasoning panel to focus yet".to_string();
}
return Ok(AppState::Running);
}
(KeyCode::Char('5'), modifiers)
if modifiers.contains(KeyModifiers::CONTROL)
|| modifiers.contains(KeyModifiers::ALT) =>
{
if self.focus_panel(FocusedPanel::Input) {
self.status =
"Focus: Input (Ctrl+5) — press i to edit".to_string();
self.error = None;
}
return Ok(AppState::Running);
}
(KeyCode::Char('m'), KeyModifiers::NONE) => { (KeyCode::Char('m'), KeyModifiers::NONE) => {
if let Err(err) = self.show_model_picker().await { if let Err(err) = self.show_model_picker().await {
self.error = Some(err.to_string()); self.error = Some(err.to_string());

View File

@@ -597,7 +597,7 @@ fn render_file_tree(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
} }
title_spans.push(Span::styled( title_spans.push(Span::styled(
" ↩ open · o split↓ · O split→ · t tab · y abs · Y rel · A dir · r ren · m move · d del · . $EDITOR · gh hidden · / fuzzy search", " ↩ open · Ctrl+1 focus · o split↓ · O split→ · t tab · y abs · Y rel · A dir · r ren · m move · d del · . $EDITOR · gh hidden · / fuzzy search",
panel_hint_style(has_focus, &theme), panel_hint_style(has_focus, &theme),
)); ));
@@ -1453,7 +1453,7 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let mut title_spans = panel_title_spans("Chat", true, has_focus, &theme); let mut title_spans = panel_title_spans("Chat", true, has_focus, &theme);
title_spans.push(Span::raw(" ")); title_spans.push(Span::raw(" "));
title_spans.push(Span::styled( title_spans.push(Span::styled(
"PgUp/PgDn scroll · g/G jump · s save", "PgUp/PgDn scroll · g/G jump · s save · Ctrl+2 focus",
panel_hint_style(has_focus, &theme), panel_hint_style(has_focus, &theme),
)); ));
@@ -1568,7 +1568,7 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let mut title_spans = panel_title_spans("💭 Thinking", true, has_focus, &theme); let mut title_spans = panel_title_spans("💭 Thinking", true, has_focus, &theme);
title_spans.push(Span::raw(" ")); title_spans.push(Span::raw(" "));
title_spans.push(Span::styled( title_spans.push(Span::styled(
"Esc close", "Esc close · Ctrl+4 focus",
panel_hint_style(has_focus, &theme), panel_hint_style(has_focus, &theme),
)); ));
@@ -1774,7 +1774,7 @@ fn render_agent_actions(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let mut title_spans = panel_title_spans("🤖 Agent Actions", true, has_focus, &theme); let mut title_spans = panel_title_spans("🤖 Agent Actions", true, has_focus, &theme);
title_spans.push(Span::raw(" ")); title_spans.push(Span::raw(" "));
title_spans.push(Span::styled( title_spans.push(Span::styled(
"Pause ▸ p · Resume ▸ r", "Pause ▸ p · Resume ▸ r · Ctrl+4 focus",
panel_hint_style(has_focus, &theme), panel_hint_style(has_focus, &theme),
)); ));
@@ -1800,16 +1800,22 @@ fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let (label, hint) = match app.mode() { let (label, hint) = match app.mode() {
InputMode::Editing => ( InputMode::Editing => (
"Input", "Input",
Some("Enter send · Shift+Enter newline · Esc normal"), Some("Enter send · Shift+Enter newline · Esc normal · Ctrl+5 focus"),
), ),
InputMode::Visual => ("Visual Select", Some("y yank · d cut · Esc cancel")), InputMode::Visual => (
InputMode::Command => ("Command", Some("Enter run · Esc cancel")), "Visual Select",
Some("y yank · d cut · Esc cancel · Ctrl+5 focus"),
),
InputMode::Command => ("Command", Some("Enter run · Esc cancel · Ctrl+5 focus")),
InputMode::RepoSearch => ( InputMode::RepoSearch => (
"Repo Search", "Repo Search",
Some("Enter run · Alt+Enter scratch · Esc close"), Some("Enter run · Alt+Enter scratch · Esc close · Ctrl+5 focus"),
), ),
InputMode::SymbolSearch => ("Symbol Search", Some("Type @name · Esc close")), InputMode::SymbolSearch => (
_ => ("Input", Some("Press i to start typing")), "Symbol Search",
Some("Type @name · Esc close · Ctrl+5 focus"),
),
_ => ("Input", Some("Press i to start typing · Ctrl+5 focus")),
}; };
let is_active = matches!( let is_active = matches!(
@@ -2019,12 +2025,12 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
owlen_core::mode::Mode::Code => ("CODE", theme.operating_code_fg, theme.operating_code_bg), owlen_core::mode::Mode::Code => ("CODE", theme.operating_code_fg, theme.operating_code_bg),
}; };
let focus_label = match app.focused_panel() { let (focus_label, focus_hint) = match app.focused_panel() {
FocusedPanel::Files => "FILES", FocusedPanel::Files => ("FILES", "Ctrl+1"),
FocusedPanel::Chat => "CHAT", FocusedPanel::Chat => ("CHAT", "Ctrl+2"),
FocusedPanel::Thinking => "THINK", FocusedPanel::Thinking => ("THINK", "Ctrl+4"),
FocusedPanel::Input => "INPUT", FocusedPanel::Input => ("INPUT", "Ctrl+5"),
FocusedPanel::Code => "CODE", FocusedPanel::Code => ("CODE", "Ctrl+3"),
}; };
let mut left_spans = vec![ let mut left_spans = vec![
@@ -2043,7 +2049,7 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
Span::styled( Span::styled(
format!("{}", focus_label), format!("{} · {}", focus_label, focus_hint),
Style::default() Style::default()
.fg(theme.pane_header_active) .fg(theme.pane_header_active)
.add_modifier(Modifier::BOLD | Modifier::ITALIC), .add_modifier(Modifier::BOLD | Modifier::ITALIC),
@@ -2440,7 +2446,7 @@ fn render_code_pane(
if is_active { if is_active {
title_spans.push(Span::raw(" ")); title_spans.push(Span::raw(" "));
title_spans.push(Span::styled( title_spans.push(Span::styled(
"Ctrl+W split · :w save", "Ctrl+W split · :w save · Ctrl+3 focus",
panel_hint_style(has_focus && is_active, theme), panel_hint_style(has_focus && is_active, theme),
)); ));
} }
@@ -2605,6 +2611,11 @@ fn render_provider_selector(frame: &mut Frame<'_>, app: &ChatApp) {
}) })
.collect(); .collect();
let highlight_style = Style::default()
.bg(theme.selection_bg)
.fg(theme.selection_fg)
.add_modifier(Modifier::BOLD);
let list = List::new(items) let list = List::new(items)
.block( .block(
Block::default() Block::default()
@@ -2618,11 +2629,7 @@ fn render_provider_selector(frame: &mut Frame<'_>, app: &ChatApp) {
.border_style(Style::default().fg(theme.unfocused_panel_border)) .border_style(Style::default().fg(theme.unfocused_panel_border))
.style(Style::default().bg(theme.background).fg(theme.text)), .style(Style::default().bg(theme.background).fg(theme.text)),
) )
.highlight_style( .highlight_style(highlight_style)
Style::default()
.fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(""); .highlight_symbol("");
let mut state = ListState::default(); let mut state = ListState::default();