aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/input.rs123
-rw-r--r--src/raw_input.rs4
-rw-r--r--src/ui.rs11
-rw-r--r--src/workspace.rs75
4 files changed, 202 insertions, 11 deletions
diff --git a/src/app/input.rs b/src/app/input.rs
index d8ac86c3..3b63f28f 100644
--- a/src/app/input.rs
+++ b/src/app/input.rs
@@ -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;
@@ -2885,6 +2979,25 @@ mod tests {
}
#[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();
diff --git a/src/raw_input.rs b/src/raw_input.rs
index dd61ee5a..77aaecdb 100644
--- a/src/raw_input.rs
+++ b/src/raw_input.rs
@@ -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(),
diff --git a/src/ui.rs b/src/ui.rs
index d729b918..dd344f87 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -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.
diff --git a/src/workspace.rs b/src/workspace.rs
index d10cdfe2..2de109d9 100644
--- a/src/workspace.rs
+++ b/src/workspace.rs
@@ -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"));
+ }
}