Replace alt-key scheme with directional focus (ctrl-arrows + F2)

Alt-modified keys never reach the app on keyboard layouts where Alt
composes characters (e.g. dk_mac_fixed: alt-c = ©, alt-q = @), so the
modifier-split model was unusable. New scheme, layout-proof:

- F2          show/hide the claude pane
- ctrl-down   focus the claude pane (spawns/shows it if hidden)
- ctrl-up     focus the feed
- claude focused: every other key goes to claude (bold cyan border)
- feed focused: original plain keybindings unchanged
- CT_DEBUG_KEYS=1 surfaces raw key events in the status bar

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jonas H
2026-06-11 08:36:47 +02:00
parent 60df921dbb
commit 4f9331e274

View File

@@ -14,19 +14,24 @@ use std::time::Duration;
struct EmbedUi {
term: Option<EmbeddedTerm>,
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()),