fix: drop modifier-only keys before pane forwarding

This commit is contained in:
Ogulcan Celik
2026-04-03 00:30:06 +03:00
parent 544c7ebebb
commit 33ccc1656c
4 changed files with 202 additions and 11 deletions
+118 -5
View File
@@ -5,7 +5,7 @@ use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKin
use crate::input::TerminalKey;
use ratatui::layout::{Direction, Rect};
use tracing::warn;
use tracing::{debug, warn};
use crate::layout::{NavDirection, PaneInfo, SplitBorder};
use crate::selection::Selection;
@@ -33,6 +33,10 @@ use super::App;
// Key handling
// ---------------------------------------------------------------------------
fn is_modifier_only_key(code: &KeyCode) -> bool {
matches!(code, KeyCode::Modifier(_))
}
fn terminal_direct_navigation_action(state: &AppState, key: &KeyEvent) -> Option<NavigateAction> {
let kb = &state.keybinds;
if kb
@@ -344,6 +348,13 @@ impl App {
let key_event = key.as_key_event();
if let Some(action) = terminal_direct_navigation_action(&self.state, &key_event) {
debug!(
code = ?key_event.code,
modifiers = ?key_event.modifiers,
kind = ?key_event.kind,
action = ?action,
"intercepted terminal direct navigation key before forwarding to pane"
);
execute_navigate_action(&mut self.state, action);
return;
}
@@ -353,16 +364,36 @@ impl App {
return;
}
if is_modifier_only_key(&key_event.code) {
debug!(
code = ?key_event.code,
modifiers = ?key_event.modifiers,
kind = ?key_event.kind,
"dropping modifier-only terminal key event instead of forwarding it to pane"
);
return;
}
if let Some(ws) = self.state.active.and_then(|i| self.state.workspaces.get(i)) {
if let Some(rt) = ws.focused_runtime() {
rt.scroll_reset();
let flags = rt
.kitty_keyboard_flags
.load(std::sync::atomic::Ordering::Relaxed);
let bytes = crate::input::encode_terminal_key(
key,
crate::input::KeyboardProtocol::from_kitty_flags(flags),
);
let protocol = crate::input::KeyboardProtocol::from_kitty_flags(flags);
let bytes = crate::input::encode_terminal_key(key, protocol);
if matches!(key_event.code, KeyCode::Esc)
|| key_event.modifiers.contains(crossterm::event::KeyModifiers::ALT)
{
debug!(
code = ?key_event.code,
modifiers = ?key_event.modifiers,
kind = ?key_event.kind,
protocol = ?protocol,
encoded = ?bytes,
"forwarding potentially-ambiguous terminal key to pane"
);
}
if bytes.is_empty() {
if key.kind != crossterm::event::KeyEventKind::Release
&& !matches!(
@@ -1831,6 +1862,16 @@ impl AppState {
self.mode = Mode::Terminal;
return None;
}
if let Some((ws_idx, tab_idx, pane_id)) = self.agent_detail_target_at(mouse.row) {
self.switch_workspace(ws_idx);
if let Some(ws) = self.workspaces.get_mut(ws_idx) {
ws.switch_tab(tab_idx);
ws.layout.focus_pane(pane_id);
}
self.mode = Mode::Terminal;
return None;
}
} else if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() {
let (row, col) = (
mouse.row - info.inner_rect.y,
@@ -2062,6 +2103,59 @@ impl AppState {
None
}
fn agent_detail_target_at(
&self,
row: u16,
) -> Option<(usize, usize, crate::layout::PaneId)> {
if self.sidebar_collapsed {
return None;
}
let content = Rect::new(
self.view.sidebar_rect.x,
self.view.sidebar_rect.y,
self.view.sidebar_rect.width.saturating_sub(1),
self.view.sidebar_rect.height,
);
if content.width == 0 || content.height == 0 {
return None;
}
let total_h = content.height as usize;
let ws_h = (total_h + 1) / 2;
let detail_area = Rect::new(
content.x,
content.y + ws_h as u16,
content.width,
total_h.saturating_sub(ws_h) as u16,
);
if detail_area.height < 4 || row < detail_area.y + 3 || row >= detail_area.y + detail_area.height {
return None;
}
let detail_ws_idx = if matches!(
self.mode,
Mode::Navigate
| Mode::RenameWorkspace
| Mode::Resize
| Mode::ConfirmClose
| Mode::ContextMenu
| Mode::Settings
| Mode::GlobalMenu
| Mode::KeybindHelp
) {
self.selected
} else {
self.active?
};
let ws = self.workspaces.get(detail_ws_idx)?;
let detail_idx = (row - (detail_area.y + 3)) as usize;
let details = ws.pane_details();
let detail = details.get(detail_idx)?;
Some((detail_ws_idx, detail.tab_idx, detail.pane_id))
}
fn screen_rect(&self) -> Rect {
let sidebar = self.view.sidebar_rect;
let terminal = self.view.terminal_area;
@@ -2884,6 +2978,25 @@ mod tests {
assert_eq!(app.state.workspaces[0].display_name(), "a");
}
#[test]
fn clicking_agent_detail_row_switches_to_correct_tab_and_pane() {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("test");
ws.tabs[0].set_custom_name("main".into());
let second_tab = ws.test_add_tab(Some("logs"));
let second_pane = ws.tabs[second_tab].root_pane;
app.state.workspaces = vec![ws];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 14));
assert_eq!(app.state.workspaces[0].active_tab, 1);
assert_eq!(app.state.workspaces[0].tabs[1].layout.focused(), second_pane);
assert_eq!(app.state.mode, Mode::Terminal);
}
#[test]
fn dragging_sidebar_divider_sets_manual_width() {
let mut app = app_for_mouse_test();
+4
View File
@@ -76,6 +76,10 @@ fn flush_incomplete_buffer(buffer: &mut Vec<u8>, tx: &mpsc::Sender<RawInputEvent
}
if buffer.as_slice() == [ESC] {
tracing::warn!(
bytes = ?buffer,
"flushing lone escape after input timeout; if this follows an alt chord or focus switch it may reach the pane as plain esc"
);
let _ = tx.blocking_send(RawInputEvent::Key(TerminalKey::new(
crossterm::event::KeyCode::Esc,
KeyModifiers::empty(),
+9 -2
View File
@@ -294,7 +294,7 @@ fn compute_sidebar_width(app: &AppState) -> u16 {
.sidebar_width
.clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH);
}
let max_line = app
let max_workspace_line = app
.workspaces
.iter()
.enumerate()
@@ -309,7 +309,14 @@ fn compute_sidebar_width(app: &AppState) -> u16 {
})
.max()
.unwrap_or(12);
((max_line as u16) + 2).clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH)
let max_agent_line = app
.workspaces
.iter()
.flat_map(|ws| ws.pane_details().into_iter())
.map(|detail| 3 + detail.label.len() + 1 + state_label(detail.state, detail.seen).len())
.max()
.unwrap_or(0);
((max_workspace_line.max(max_agent_line) as u16) + 2).clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH)
}
/// Collapsed sidebar: pure glance mode.
+71 -4
View File
@@ -192,11 +192,15 @@ impl Tab {
let agent = pane.and_then(|p| p.detected_agent);
let state = pane.map(|p| p.state).unwrap_or(AgentState::Unknown);
let seen = pane.map(|p| p.seen).unwrap_or(true);
let label = agent
let agent_label = agent
.map(|a| agent_name(a).to_string())
.unwrap_or_else(|| "shell".to_string());
PaneDetail {
label,
pane_id: *id,
tab_idx: self.number.saturating_sub(1),
tab_label: self.display_name(),
label: agent_label.clone(),
agent_label,
agent,
state,
seen,
@@ -442,7 +446,17 @@ impl Workspace {
}
pub fn pane_details(&self) -> Vec<PaneDetail> {
self.active_tab().map(Tab::pane_details).unwrap_or_default()
let multi_tab = self.tabs.len() > 1;
self.tabs
.iter()
.flat_map(Tab::pane_details)
.map(|mut detail| {
if multi_tab {
detail.label = format!("{} / {}", detail.tab_label, detail.agent_label);
}
detail
})
.collect()
}
pub fn focused_runtime(&self) -> Option<&PaneRuntime> {
@@ -532,7 +546,11 @@ impl Workspace {
/// Detail info for a single pane, used by the agent detail panel.
pub struct PaneDetail {
pub pane_id: PaneId,
pub tab_idx: usize,
pub tab_label: String,
pub label: String,
pub agent_label: String,
#[allow(dead_code)]
pub agent: Option<Agent>,
pub state: AgentState,
@@ -680,12 +698,36 @@ impl Workspace {
self.register_new_pane(new_id);
new_id
}
pub fn test_add_tab(&mut self, name: Option<&str>) -> usize {
let (events, _) = mpsc::channel(64);
let render_notify = Arc::new(Notify::new());
let render_dirty = Arc::new(AtomicBool::new(false));
let (layout, root_id) = TileLayout::new();
let mut panes = HashMap::new();
panes.insert(root_id, PaneState::new());
let tab = Tab {
custom_name: name.map(str::to_string),
number: self.tabs.len() + 1,
root_pane: root_id,
layout,
panes,
runtimes: HashMap::new(),
zoomed: false,
events,
render_notify,
render_dirty,
};
self.register_new_pane(root_id);
self.tabs.push(tab);
self.tabs.len() - 1
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::detect::AgentState;
use crate::detect::{Agent, AgentState};
#[test]
fn aggregate_state_all_unknown() {
@@ -732,4 +774,29 @@ mod tests {
assert_eq!(state, AgentState::Idle);
assert!(!seen);
}
#[test]
fn pane_details_include_tab_context_when_workspace_has_multiple_tabs() {
let mut ws = Workspace::test_new("test");
ws.tabs[0].set_custom_name("main".into());
let root_pane = ws.tabs[0].root_pane;
ws.tabs[0]
.panes
.get_mut(&root_pane)
.unwrap()
.detected_agent = Some(Agent::Pi);
let tab_idx = ws.test_add_tab(Some("logs"));
let second_root_pane = ws.tabs[tab_idx].root_pane;
ws.tabs[tab_idx]
.panes
.get_mut(&second_root_pane)
.unwrap()
.detected_agent = Some(Agent::Claude);
let details = ws.pane_details();
assert_eq!(details.len(), 2);
assert!(details.iter().any(|detail| detail.label == "main / pi"));
assert!(details.iter().any(|detail| detail.label == "logs / claude"));
}
}