mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-26 00:01:14 +00:00
fix: drop modifier-only keys before pane forwarding
This commit is contained in:
+118
-5
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user