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:
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user