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