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(),
text: Color::White,
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(),
focused_panel_border: Color::Rgb(216, 160, 255),
unfocused_panel_border: Color::Rgb(137, 82, 204),
focus_beacon_fg: Color::Rgb(248, 229, 255),
focus_beacon_bg: Color::Rgb(38, 10, 58),
unfocused_beacon_fg: Color::Rgb(130, 130, 130),
pane_header_active: Theme::default_pane_header_active(),
pane_header_inactive: Theme::default_pane_header_inactive(),
pane_hint_text: Theme::default_pane_hint_text(),
pane_header_inactive: Color::Rgb(210, 210, 210),
pane_hint_text: Color::Rgb(210, 210, 210),
user_message_role: Color::LightBlue,
assistant_message_role: Color::Yellow,
tool_output: Color::Gray,
thinking_panel_title: Color::LightMagenta,
command_bar_background: Color::Black,
status_background: Color::Black,
mode_normal: Color::LightBlue,
mode_editing: Color::LightGreen,
mode_model_selection: Color::LightYellow,
mode_provider_selection: Color::LightCyan,
mode_help: Color::LightMagenta,
mode_visual: Color::Magenta,
mode_command: Color::Yellow,
selection_bg: Color::LightBlue,
tool_output: Color::Rgb(200, 200, 200),
thinking_panel_title: Color::Rgb(234, 182, 255),
command_bar_background: Color::Rgb(10, 10, 10),
status_background: Color::Rgb(12, 12, 12),
mode_normal: Color::Rgb(117, 200, 255),
mode_editing: Color::Rgb(144, 242, 170),
mode_model_selection: Color::Rgb(255, 226, 140),
mode_provider_selection: Color::Rgb(164, 235, 255),
mode_help: Color::Rgb(234, 182, 255),
mode_visual: Color::Rgb(255, 170, 255),
mode_command: Color::Rgb(255, 220, 120),
selection_bg: Color::Rgb(56, 140, 240),
selection_fg: Color::Black,
cursor: Color::Magenta,
cursor: Color::Rgb(255, 196, 255),
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_keyword: Color::Yellow,
code_block_string: Color::LightGreen,
code_block_comment: Color::Gray,
placeholder: Color::DarkGray,
code_block_keyword: Color::Rgb(255, 220, 120),
code_block_string: Color::Rgb(144, 242, 170),
code_block_comment: Color::Rgb(170, 170, 170),
placeholder: Color::Rgb(180, 180, 180),
error: Color::Red,
info: Color::LightGreen,
agent_thought: Color::LightBlue,
agent_action: Color::Yellow,
agent_action_input: Color::LightCyan,
agent_observation: Color::LightGreen,
agent_final_answer: Color::Magenta,
info: Color::Rgb(144, 242, 170),
agent_thought: Color::Rgb(117, 200, 255),
agent_action: Color::Rgb(255, 220, 120),
agent_action_input: Color::Rgb(164, 235, 255),
agent_observation: Color::Rgb(144, 242, 170),
agent_final_answer: Color::Rgb(255, 170, 255),
agent_badge_running_fg: Color::Black,
agent_badge_running_bg: Color::Yellow,
agent_badge_idle_fg: Color::Black,
agent_badge_idle_bg: Color::Cyan,
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_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];
}
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
fn sync_textarea_to_buffer(&mut self) {
let text = self.textarea.lines().join("\n");
@@ -5007,6 +5040,65 @@ impl ChatApp {
};
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) => {
if let Err(err) = self.show_model_picker().await {
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(
" ↩ 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),
));
@@ -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);
title_spans.push(Span::raw(" "));
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),
));
@@ -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);
title_spans.push(Span::raw(" "));
title_spans.push(Span::styled(
"Esc close",
"Esc close · Ctrl+4 focus",
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);
title_spans.push(Span::raw(" "));
title_spans.push(Span::styled(
"Pause ▸ p · Resume ▸ r",
"Pause ▸ p · Resume ▸ r · Ctrl+4 focus",
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() {
InputMode::Editing => (
"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::Command => ("Command", Some("Enter run · Esc cancel")),
InputMode::Visual => (
"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 => (
"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")),
_ => ("Input", Some("Press i to start typing")),
InputMode::SymbolSearch => (
"Symbol Search",
Some("Type @name · Esc close · Ctrl+5 focus"),
),
_ => ("Input", Some("Press i to start typing · Ctrl+5 focus")),
};
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),
};
let focus_label = match app.focused_panel() {
FocusedPanel::Files => "FILES",
FocusedPanel::Chat => "CHAT",
FocusedPanel::Thinking => "THINK",
FocusedPanel::Input => "INPUT",
FocusedPanel::Code => "CODE",
let (focus_label, focus_hint) = match app.focused_panel() {
FocusedPanel::Files => ("FILES", "Ctrl+1"),
FocusedPanel::Chat => ("CHAT", "Ctrl+2"),
FocusedPanel::Thinking => ("THINK", "Ctrl+4"),
FocusedPanel::Input => ("INPUT", "Ctrl+5"),
FocusedPanel::Code => ("CODE", "Ctrl+3"),
};
let mut left_spans = vec![
@@ -2043,7 +2049,7 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{}", focus_label),
format!("{} · {}", focus_label, focus_hint),
Style::default()
.fg(theme.pane_header_active)
.add_modifier(Modifier::BOLD | Modifier::ITALIC),
@@ -2440,7 +2446,7 @@ fn render_code_pane(
if is_active {
title_spans.push(Span::raw(" "));
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),
));
}
@@ -2605,6 +2611,11 @@ fn render_provider_selector(frame: &mut Frame<'_>, app: &ChatApp) {
})
.collect();
let highlight_style = Style::default()
.bg(theme.selection_bg)
.fg(theme.selection_fg)
.add_modifier(Modifier::BOLD);
let list = List::new(items)
.block(
Block::default()
@@ -2618,11 +2629,7 @@ fn render_provider_selector(frame: &mut Frame<'_>, app: &ChatApp) {
.border_style(Style::default().fg(theme.unfocused_panel_border))
.style(Style::default().bg(theme.background).fg(theme.text)),
)
.highlight_style(
Style::default()
.fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD),
)
.highlight_style(highlight_style)
.highlight_symbol("");
let mut state = ListState::default();