diff --git a/src/ui.rs b/src/ui.rs index fc3c8b8..eb447e1 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -14,19 +14,24 @@ use std::time::Duration; struct EmbedUi { term: Option, visible: bool, + /// Keyboard focus is on the claude pane (vs. the feed above it). + /// Directional: ctrl-↓ moves focus into the pane, ctrl-↑ back to the feed. + claude_focused: bool, port: u16, } impl EmbedUi { - /// Pane is visible and the child is still running → it owns plain keys. + /// Pane is visible, child alive, and holds keyboard focus → it gets keys. fn focused(&self) -> bool { - self.visible && self.term.as_ref().is_some_and(|t| !t.exited()) + self.visible + && self.claude_focused + && self.term.as_ref().is_some_and(|t| !t.exited()) } } pub fn run(app: SharedApp, port: u16) -> anyhow::Result<()> { let mut terminal = ratatui::init(); - let mut eui = EmbedUi { term: None, visible: false, port }; + let mut eui = EmbedUi { term: None, visible: false, claude_focused: false, port }; let res = event_loop(&mut terminal, app, &mut eui); ratatui::restore(); res @@ -35,6 +40,7 @@ pub fn run(app: SharedApp, port: u16) -> anyhow::Result<()> { fn toggle_embed(eui: &mut EmbedUi, app: &SharedApp) { if eui.visible { eui.visible = false; + eui.claude_focused = false; // A dead child is dropped on hide so the next toggle respawns. if eui.term.as_ref().is_some_and(|t| t.exited()) { eui.term = None; @@ -42,6 +48,11 @@ fn toggle_embed(eui: &mut EmbedUi, app: &SharedApp) { } return; } + show_embed_pane(eui, app); +} + +/// Show (spawning if needed) the claude pane and give it keyboard focus. +fn show_embed_pane(eui: &mut EmbedUi, app: &SharedApp) { if eui.term.as_ref().is_some_and(|t| t.exited()) { eui.term = None; } @@ -62,6 +73,7 @@ fn toggle_embed(eui: &mut EmbedUi, app: &SharedApp) { } } eui.visible = true; + eui.claude_focused = true; } fn event_loop( @@ -78,21 +90,37 @@ fn event_loop( if k.kind == KeyEventKind::Release { continue; } - // Alt-c toggles the embedded claude pane in every state. - if k.code == KeyCode::Char('c') && k.modifiers.contains(KeyModifiers::ALT) { + // `CT_DEBUG_KEYS=1`: surface every key event in the status bar, + // for diagnosing what the outer terminal actually delivers. + if std::env::var_os("CT_DEBUG_KEYS").is_some() { + app.lock().unwrap().status = + format!("key: {:?} mods={:?} kind={:?}", k.code, k.modifiers, k.kind); + } + // Pane controls, available in every state (alt-keys are out: + // they compose characters on some keyboard layouts): + // F2 show/hide the claude pane + // ctrl-↓ focus the claude pane (showing it if hidden) + // ctrl-↑ focus the feed + let ctrl = k.modifiers.contains(KeyModifiers::CONTROL); + if k.code == KeyCode::F(2) { if k.kind == KeyEventKind::Press { toggle_embed(eui, &app); } continue; } - let embed_focused = eui.focused(); - // Modifier-split routing: while the pane is focused, plain keys - // (incl. ctrl-c!) belong to claude; ALT-keys control the feed. - // The filter popup is modal and keeps its plain keys. - if embed_focused - && !k.modifiers.contains(KeyModifiers::ALT) - && app.lock().unwrap().filter_popup.is_none() - { + if ctrl && k.code == KeyCode::Down { + if k.kind == KeyEventKind::Press { + app.lock().unwrap().filter_popup = None; + show_embed_pane(eui, &app); + } + continue; + } + if ctrl && k.code == KeyCode::Up { + eui.claude_focused = false; + continue; + } + // While the claude pane has focus, everything else belongs to it. + if eui.focused() { if let Some(et) = &eui.term { et.key(k); } @@ -103,10 +131,7 @@ fn event_loop( } let mut a = app.lock().unwrap(); let nsess = a.sessions.len(); - if !embed_focused - && k.code == KeyCode::Char('c') - && k.modifiers.contains(KeyModifiers::CONTROL) - { + if k.code == KeyCode::Char('c') && ctrl { return Ok(()); } // Filter popup captures input while open. @@ -324,6 +349,7 @@ fn draw(f: &mut Frame, app: &SharedApp, eui: &mut EmbedUi) { a.follow = new_follow; // Embedded claude pane + let embed_focused = eui.focused(); if show_embed { let et = eui.term.as_mut().unwrap(); let exited = et.exited(); @@ -333,12 +359,17 @@ fn draw(f: &mut Frame, app: &SharedApp, eui: &mut EmbedUi) { } else { format!(" claude · {id} ") }; - let block = Block::bordered().title(title); + // Bold cyan border = the pane has keyboard focus. + let block = if embed_focused { + Block::bordered().title(title).border_style(Style::new().cyan().bold()) + } else { + Block::bordered().title(title).border_style(Style::new().dark_gray()) + }; let inner = block.inner(embed_area); f.render_widget(block, embed_area); if exited { f.render_widget( - Paragraph::new("\n claude exited — alt-c to close this pane") + Paragraph::new("\n claude exited — F2 to close this pane") .dark_gray(), inner, ); @@ -350,13 +381,14 @@ fn draw(f: &mut Frame, app: &SharedApp, eui: &mut EmbedUi) { } } - let embed_focused = eui.focused(); let keys = if a.filter_popup.is_some() { "space toggle · j/k move · f/esc close" } else if embed_focused { - "alt-c hide claude · alt-q quit · alt-j/k scroll · alt-f filter · alt-n/p session" + "ctrl-↑ feed · F2 hide claude" + } else if show_embed { + "ctrl-↓ claude · q quit · tab session · j/k scroll · f filter · F2 hide" } else { - "q quit · tab session · s sessions · j/k scroll · f filter · g top · G bottom · alt-c claude" + "q quit · tab session · s sessions · j/k scroll · f filter · g top · G bottom · ctrl-↓/F2 claude" }; f.render_widget( Paragraph::new(Line::from(format!(" {} | {keys}", a.status)).dark_gray()),