diff --git a/src/app/input.rs b/src/app/input.rs index 3135de07d..4187b62cf 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -381,6 +381,28 @@ impl App { ), "handle_key should not be called in interactive mode" ); + // The agents-pane search box captures the keys it needs (text editing, + // result navigation, open, clear) so printable characters — including + // ones bound globally like `q` or `?` — edit the query instead of + // firing shortcuts. Keys it does not use (Tab, the palette key, …) + // leave search and fall through to normal dispatch. Placed before the + // global handler so the search box wins for the keys it owns. + if self.focus == FocusPane::Left + && self.left_search_active + && self.handle_left_search_key(key)? + { + return Ok(false); + } + // The files-pane search box captures keys the same way while active, so + // typing characters that are also global shortcuts (e.g. `q`) edits the + // query instead of firing the shortcut. Keys it doesn't use (Tab, the + // palette key, …) leave search and fall through to normal dispatch. + if self.focus == FocusPane::Files + && self.files_search_active + && self.handle_files_search_key(key) + { + return Ok(false); + } if self.bindings.lookup(&key, BindingScope::Global) == Some(Action::CloseOverlay) && self.close_top_overlay() { @@ -666,6 +688,7 @@ impl App { Action::OpenWorktreeInEditor => self.open_selected_worktree_in_default_editor()?, Action::ChooseWorktreeEditor => self.open_worktree_editor_picker()?, Action::ToggleProject => self.toggle_collapse_selected_project(), + Action::SearchAgents => self.start_left_search(), Action::InteractAgent => { if self.selected_session().is_some() && self @@ -695,6 +718,227 @@ impl App { Ok(()) } + /// Open the in-pane agent search. Forces the Projects section, clears any + /// prior query, and keeps the cursor on the currently selected agent (the + /// full, unfiltered list is shown until the user types). No-op when the + /// sidebar is collapsed to icons (no room for a search box). + fn start_left_search(&mut self) { + if self.left_collapsed { + return; + } + self.left_section = LeftSection::Projects; + // Remember where the cursor was so Esc can return here even if the + // query ends up matching nothing. + self.left_search_origin_session = self.selected_session().map(|s| s.id.clone()); + self.left_search_active = true; + self.left_search.clear(); + self.rebuild_left_items(); + let open = self.bindings.label_for(Action::FocusAgent); + let clear = self.bindings.label_for(Action::CloseOverlay); + self.set_info(format!( + "Searching agents: type to filter by title, branch, or project. {open} opens the highlighted agent, {clear} clears." + )); + } + + /// Route a key while the agents-pane search box is active. Returns `true` + /// when the search box consumed the key; `false` means the key is a + /// pane-switch/global action — search has been exited and the caller should + /// let normal dispatch handle it. Printable characters edit the query; + /// navigation/confirm/close resolve through the user's bindings. + fn handle_left_search_key(&mut self, key: KeyEvent) -> Result { + // Plain printable characters always edit the query first, so keys that + // are also bound in the Left pane (e.g. vim-style `j`/`k` navigation) + // are typed rather than triggering navigation. + let is_plain_char = matches!(key.code, KeyCode::Char(_)) + && !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT); + if is_plain_char { + self.feed_left_search(key); + return Ok(true); + } + if self.bindings.lookup(&key, BindingScope::Global) == Some(Action::CloseOverlay) { + self.exit_left_search(); + return Ok(true); + } + match self.bindings.lookup(&key, BindingScope::Left) { + Some(Action::MoveDown) => { + if let Some(next) = self.next_selectable_left_item_after(self.selected_left) { + self.selected_left = next; + self.after_left_search_selection_change(); + } + return Ok(true); + } + Some(Action::MoveUp) => { + if let Some(prev) = self.previous_selectable_left_item_before(self.selected_left) { + self.selected_left = prev; + self.after_left_search_selection_change(); + } + return Ok(true); + } + Some(Action::FocusAgent | Action::ExitInteractive) => { + self.open_left_search_selection()?; + return Ok(true); + } + _ => {} + } + // Let the text box handle the remaining editing keys it knows about — + // Backspace/Delete, cursor moves, and word operations (Ctrl/Alt+arrows, + // Alt+Backspace, Alt+b/f, Ctrl-W). The text box itself declines to insert + // Alt/Ctrl character combos, so an unconsumed key falls through below. + if self.feed_left_search(key) { + return Ok(true); + } + // A key bound to a global or Left-pane action (Tab/FocusNext, the palette + // key, or a rebound Left action such as NewAgent on a function key) leaves + // search and runs normally. An unbound key is ignored so a stray keypress + // doesn't silently drop the filter. + if self.bindings.lookup(&key, BindingScope::Global).is_some() + || self.bindings.lookup(&key, BindingScope::Left).is_some() + { + self.exit_left_search(); + return Ok(false); + } + Ok(true) + } + + /// Forward a key to the search text box, re-filtering only when the query + /// text actually changed (so cursor moves don't reset the highlight or + /// rebuild the list). Returns whether the text box consumed the key. + fn feed_left_search(&mut self, key: KeyEvent) -> bool { + let before = self.left_search.text.clone(); + if self.left_search.handle_key(key) { + if self.left_search.text != before { + self.apply_left_search_query(); + } + true + } else { + false + } + } + + /// Re-filter the list after the query changed and move the highlight to the + /// first matching agent. The changed-files pane is intentionally not + /// reloaded here — that happens when the selection settles (Up/Down/Enter) + /// so typing does not run git on every keystroke. + fn apply_left_search_query(&mut self) { + // `rebuild_left_items` re-anchors the selection to the current session + // (for background refreshes); the typing path deliberately overrides that + // with `select_first_left_result` so each keystroke shows the top match. + self.rebuild_left_items(); + self.select_first_left_result(); + self.close_diff_view(); + } + + /// Move the highlight to the first agent row in the current (filtered) list. + fn select_first_left_result(&mut self) { + let first = self + .left_items() + .iter() + .position(|item| matches!(item, LeftItem::Session(_))); + if let Some(idx) = first { + self.selected_left = idx; + } else { + self.ensure_selectable_left_item(); + } + } + + /// Point the left selection at the agent row for `session_id`, if present + /// in the current list. Used to keep the highlight stable across the + /// filtered → full-list transition when search ends. + fn select_left_session_by_id(&mut self, session_id: &str) { + let found = self.left_items().iter().position(|item| match item { + LeftItem::Session(idx) => self.sessions.get(*idx).is_some_and(|s| s.id == session_id), + _ => false, + }); + if let Some(idx) = found { + self.selected_left = idx; + } + } + + /// Expand `session_id`'s project if it is collapsed, so the session is + /// present in the non-search list. Search reveals matches inside collapsed + /// projects, so a result the user opens (or returns to) must survive the + /// rebuild of the collapse-respecting full list. + fn uncollapse_project_for_session(&mut self, session_id: &str) { + if let Some(project_id) = self + .sessions + .iter() + .find(|s| s.id == session_id) + .map(|s| s.project_id.clone()) + { + self.collapsed_projects.remove(&project_id); + } + } + + /// Shared teardown for leaving search: clear state, rebuild the full list, + /// and re-anchor the highlight to `keep_session` if it is still visible. + /// Does NOT change collapse state — cancelling a search must leave the + /// user's collapsed projects exactly as they were. Opening an agent inside a + /// collapsed project un-collapses explicitly via `open_left_search_selection`. + fn teardown_left_search(&mut self, keep_session: Option) { + self.left_search_active = false; + self.left_search.clear(); + self.left_search_origin_session = None; + self.rebuild_left_items(); + if let Some(id) = keep_session { + self.select_left_session_by_id(&id); + } + } + + /// Enter opens the highlighted agent and leaves search mode. + fn open_left_search_selection(&mut self) -> Result<()> { + let Some(session) = self.selected_session().cloned() else { + // No agent highlighted (e.g. nothing matched) — just close search. + self.teardown_left_search(None); + self.set_info("No matching agent to open. Search cleared, showing all agents."); + return Ok(()); + }; + let session_id = session.id.clone(); + let label = session.title.unwrap_or(session.branch_name); + // Search can reveal an agent inside a collapsed project; opening it must + // expand that project so the agent is present in the rebuilt full list. + self.uncollapse_project_for_session(&session_id); + self.teardown_left_search(Some(session_id)); + // activate_selected_left_item reloads the changed-files pane itself, so + // teardown deliberately skips the selection-change side effects here. + let status_before = self.status.message().to_string(); + self.activate_selected_left_item()?; + // If activation set its own status (e.g. reconnecting a stopped agent), + // keep it; otherwise confirm the open so the search prompt doesn't linger. + if self.status.message() == status_before { + self.set_info(format!("Opened agent \"{label}\".")); + } + Ok(()) + } + + /// The agent the highlight should return to when search ends: whichever + /// agent is highlighted in the filtered view, or — when the query matched + /// nothing — the agent selected before search opened. + fn left_search_keep_target(&self) -> Option { + self.selected_session() + .map(|s| s.id.clone()) + .or_else(|| self.left_search_origin_session.clone()) + } + + /// Leave search mode (via Esc or a pane-switch key), restore the full + /// collapse-respecting list, re-anchor the highlight, and announce it. + fn exit_left_search(&mut self) { + let keep = self.left_search_keep_target(); + self.teardown_left_search(keep); + self.set_info("Agent search cleared. Showing all agents."); + self.after_left_search_selection_change(); + } + + /// Shared side effects when the left selection settles during search: close + /// any open diff, reload the changed-files pane, refresh the missing project + /// warning — mirroring normal Up/Down navigation. + fn after_left_search_selection_change(&mut self) { + self.close_diff_view(); + self.reload_changed_files(); + self.update_missing_project_warning(); + } + fn handle_left_terminal_key(&mut self, key: KeyEvent) -> Result<()> { let term_count = self.terminal_items().len(); if let Some(action) = self.bindings.lookup(&key, BindingScope::Left) { @@ -708,12 +952,14 @@ impl App { } else { // Jump back to projects section. self.left_section = LeftSection::Projects; - if let Some(last) = self - .left_items() - .iter() - .enumerate() - .rev() - .find_map(|(idx, item)| item.is_selectable().then_some(idx)) + if let Some(last) = + self.left_items() + .iter() + .enumerate() + .rev() + .find_map(|(idx, item)| { + self.left_item_is_nav_target(*item).then_some(idx) + }) { self.selected_left = last; self.close_diff_view(); @@ -984,33 +1230,63 @@ impl App { } } - fn handle_files_search_key(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Esc => { - self.clear_files_search(); - return; - } - KeyCode::Enter => { - self.files_search_active = false; - return; - } - _ => {} + /// Route a key while the files-pane search box is active. Returns `true` + /// when the box consumed the key; `false` means it is a pane-switch/global + /// action — the box has been left and the caller should let normal dispatch + /// handle it. Mirrors `handle_left_search_key` so the two in-pane searches + /// behave the same (e.g. Tab leaves search rather than being swallowed). + fn handle_files_search_key(&mut self, key: KeyEvent) -> bool { + let is_plain_char = matches!(key.code, KeyCode::Char(_)) + && !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT); + if is_plain_char { + self.feed_files_search(key); + return true; } + // Close via the configurable binding (mirrors handle_left_search_key) + // rather than a hardcoded Esc, so a rebound close key works in both + // searches. + if self.bindings.lookup(&key, BindingScope::Global) == Some(Action::CloseOverlay) { + self.clear_files_search(); + return true; + } + if key.code == KeyCode::Enter { + self.files_search_active = false; + return true; + } + if self.feed_files_search(key) { + return true; + } + // A key bound to a global action (Tab/FocusNext, the palette key, …) + // fully leaves the search box (clearing the query, matching the agent + // search) and runs normally; an unbound key is ignored. + if self.bindings.lookup(&key, BindingScope::Global).is_some() { + self.clear_files_search(); + return false; + } + true + } + + /// Forward a key to the files-search text box, refreshing the match on a + /// change. Returns whether the text box consumed the key. + fn feed_files_search(&mut self, key: KeyEvent) -> bool { if self.files_search.handle_key(key) { let query = self.files_search.text.clone(); let found_match = self.update_files_search(query); if !found_match && self.has_files_search() { self.set_info("No file matches the current search."); } + true + } else { + false } } fn handle_files_key(&mut self, key: KeyEvent) -> Result<()> { - if self.files_search_active { - self.handle_files_search_key(key); - return Ok(()); - } - + // While the files search box is active, keys are intercepted at the top + // of handle_key (and handle_files_search_key clears the flag for keys it + // passes through), so by the time pane dispatch runs the box is inactive. if let Some(action) = self.bindings.lookup(&key, BindingScope::Files) { match action { Action::MoveDown if self.right_section != RightSection::CommitInput => { @@ -5768,6 +6044,40 @@ impl App { } pub(crate) fn handle_mouse(&mut self, mouse: MouseEvent) -> bool { + let status_before = self.status.message().to_string(); + let handled = self.handle_mouse_dispatch(mouse); + // Central enforcement: any mouse interaction that moved focus out of the + // projects list ends the agent search, so a filtered list never lingers + // in (or behind) an unfocused pane. Clicking/scrolling within the + // projects list keeps search active, matching keyboard navigation. The + // dispatch above already updated focus and (for activations) the status + // line, so this tears down without a second reload that would clobber + // that work. + if self.left_search_active + && !(self.focus == FocusPane::Left && self.left_section == LeftSection::Projects) + { + let keep = self.left_search_keep_target(); + self.teardown_left_search(keep); + // Refresh the changed-files pane (and missing-project warning) for the + // restored selection, matching the keyboard exit path. + self.after_left_search_selection_change(); + // Announce the cleared search only if nothing above set a status + // (e.g. opening an agent) — avoids clobbering that message and the + // now-stale "Searching agents…" prompt from search-open. + if self.status.message() == status_before { + self.set_info("Agent search cleared. Showing all agents."); + } + } + // Symmetrically, leaving an active files search fully clears its box + // (text included, not just the active flag) so neither a stale + // interceptor nor a lingering filter survives — matching the agent search. + if self.files_search_active && self.focus != FocusPane::Files { + self.clear_files_search(); + } + handled + } + + fn handle_mouse_dispatch(&mut self, mouse: MouseEvent) -> bool { if !matches!(self.prompt, PromptState::None) { return self.handle_prompt_mouse(mouse); } @@ -6505,6 +6815,9 @@ mod tests { files_index: 0, files_search: TextInput::new(), files_search_active: false, + left_search: TextInput::new(), + left_search_active: false, + left_search_origin_session: None, commit_input: TextInput::new() .with_multiline(4) .with_placeholder("Type your commit message\u{2026}"), @@ -8640,6 +8953,590 @@ not_a_real_action = ["x"] assert!(app.files_search.is_empty()); } + /// Give the default fixture two agents under the single project, each with a + /// distinct searchable title/branch. + fn seed_two_agents(app: &mut App) { + app.sessions[0].title = Some("Fix the parser".to_string()); + app.sessions[0].branch_name = "parser-branch".to_string(); + let mut second = app.sessions[0].clone(); + second.id = "session-2".to_string(); + second.title = Some("Add logging".to_string()); + second.branch_name = "logging-branch".to_string(); + app.sessions.push(second); + app.rebuild_left_items(); + } + + fn type_chars(app: &mut App, text: &str) { + for ch in text.chars() { + app.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)) + .unwrap(); + } + } + + /// Two projects with three agents: session-1 (project-1, matches "alpha"), + /// session-2 (project-1, no match), session-3 (project-2, matches "alpha"). + /// Lets tests exercise filtering and navigation across non-contiguous + /// session-array indices. + fn seed_two_projects(app: &mut App) { + let mut project2 = app.projects[0].clone(); + project2.id = "project-2".to_string(); + project2.name = "second".to_string(); + app.projects.push(project2); + + app.sessions[0].title = Some("alpha one".to_string()); + app.sessions[0].branch_name = "alpha-one".to_string(); + let mut no_match = app.sessions[0].clone(); + no_match.id = "session-2".to_string(); + no_match.title = Some("unrelated".to_string()); + no_match.branch_name = "unrelated-branch".to_string(); + let mut other_project = app.sessions[0].clone(); + other_project.id = "session-3".to_string(); + other_project.project_id = "project-2".to_string(); + other_project.title = Some("alpha three".to_string()); + other_project.branch_name = "alpha-three".to_string(); + app.sessions.push(no_match); + app.sessions.push(other_project); + app.rebuild_left_items(); + } + + #[test] + fn slash_enters_left_search_and_filters_to_matching_agent() { + let mut app = test_app(default_bindings()); + seed_two_agents(&mut app); + app.focus = FocusPane::Left; + + app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)) + .unwrap(); + assert!(app.left_search_active); + + type_chars(&mut app, "parser"); + + assert_eq!(app.left_search.text, "parser"); + // The project header stays; only the matching agent remains. + assert_eq!( + app.left_items(), + &[LeftItem::Project(0), LeftItem::Session(0)] + ); + // Highlight parks on the matching agent, not the header. + assert!(matches!( + app.left_items().get(app.selected_left), + Some(LeftItem::Session(0)) + )); + } + + #[test] + fn left_search_arrows_move_between_agents_skipping_header() { + let mut app = test_app(default_bindings()); + seed_two_agents(&mut app); + app.focus = FocusPane::Left; + + app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)) + .unwrap(); + // The project name "demo" matches, so both agents are listed. + type_chars(&mut app, "demo"); + assert_eq!(app.left_items().len(), 3); + assert!(matches!( + app.left_items().get(app.selected_left), + Some(LeftItem::Session(0)) + )); + + app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)) + .unwrap(); + assert!(matches!( + app.left_items().get(app.selected_left), + Some(LeftItem::Session(1)) + )); + + // Down on the last agent does not wrap. + app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)) + .unwrap(); + assert!(matches!( + app.left_items().get(app.selected_left), + Some(LeftItem::Session(1)) + )); + + // Up returns to the first agent and never lands on the header at index 0. + app.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)) + .unwrap(); + assert!(matches!( + app.left_items().get(app.selected_left), + Some(LeftItem::Session(0)) + )); + app.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)) + .unwrap(); + assert_ne!(app.selected_left, 0); + assert!(matches!( + app.left_items().get(app.selected_left), + Some(LeftItem::Session(0)) + )); + } + + #[test] + fn enter_opens_highlighted_agent_and_exits_left_search() { + let mut app = test_app(default_bindings()); + seed_two_agents(&mut app); + // Avoid spawning a provider on activation: point worktrees at a missing + // path so reconnect short-circuits. + for session in &mut app.sessions { + session.worktree_path = "/nonexistent/dux-test-worktree".to_string(); + } + app.focus = FocusPane::Left; + + app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)) + .unwrap(); + type_chars(&mut app, "parser"); + assert_eq!( + app.selected_session().map(|s| s.id.clone()).as_deref(), + Some("session-1") + ); + + app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + .unwrap(); + + assert!(!app.left_search_active); + assert!(app.left_search.is_empty()); + assert_eq!(app.focus, FocusPane::Center); + // The full list is restored and the highlight stays on the opened agent. + assert_eq!(app.left_items().len(), 3); + assert_eq!( + app.selected_session().map(|s| s.id.clone()).as_deref(), + Some("session-1") + ); + } + + #[test] + fn esc_clears_left_search_and_restores_selection() { + let mut app = test_app(default_bindings()); + seed_two_agents(&mut app); + app.focus = FocusPane::Left; + + app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)) + .unwrap(); + type_chars(&mut app, "logging"); + assert_eq!( + app.selected_session().map(|s| s.id.clone()).as_deref(), + Some("session-2") + ); + + app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) + .unwrap(); + + assert!(!app.left_search_active); + assert!(app.left_search.is_empty()); + // Full list restored; highlight returns to the agent we were on. + assert_eq!(app.left_items().len(), 3); + assert_eq!( + app.selected_session().map(|s| s.id.clone()).as_deref(), + Some("session-2") + ); + } + + #[test] + fn left_search_typed_global_shortcut_chars_edit_query() { + let mut app = test_app(default_bindings()); + seed_two_agents(&mut app); + app.focus = FocusPane::Left; + + app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)) + .unwrap(); + // 'q' is Quit globally and '?' toggles help — while searching they must + // be typed into the query, not fire their shortcuts. + type_chars(&mut app, "q?"); + + assert!(app.left_search_active); + assert_eq!(app.left_search.text, "q?"); + assert!(app.help_scroll.is_none()); + } + + #[test] + fn enter_opens_agent_inside_collapsed_project_after_search() { + let mut app = test_app(default_bindings()); + app.sessions[0].title = Some("Fix the parser".to_string()); + // Avoid spawning a provider on activation. + app.sessions[0].worktree_path = "/nonexistent/dux-test-worktree".to_string(); + // Collapse the project so its agent is hidden from the normal list. + app.collapsed_projects.insert("project-1".to_string()); + app.rebuild_left_items(); + app.focus = FocusPane::Left; + + // Search reveals the agent inside the collapsed project. + app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)) + .unwrap(); + type_chars(&mut app, "parser"); + assert_eq!( + app.selected_session().map(|s| s.id.clone()).as_deref(), + Some("session-1") + ); + + // Enter must un-collapse the project so the right agent is opened. + app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + .unwrap(); + + assert!(!app.left_search_active); + assert!(!app.collapsed_projects.contains("project-1")); + assert_eq!(app.focus, FocusPane::Center); + assert_eq!( + app.selected_session().map(|s| s.id.clone()).as_deref(), + Some("session-1") + ); + } + + #[test] + fn esc_cancel_does_not_uncollapse_a_browsed_project() { + let mut app = test_app(default_bindings()); + app.sessions[0].title = Some("Fix the parser".to_string()); + app.collapsed_projects.insert("project-1".to_string()); + app.rebuild_left_items(); + app.focus = FocusPane::Left; + + // Search reveals the agent inside the collapsed project and highlights it. + app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)) + .unwrap(); + type_chars(&mut app, "parser"); + assert_eq!( + app.selected_session().map(|s| s.id.clone()).as_deref(), + Some("session-1") + ); + + // Esc cancels the search; it must NOT change the user's collapse state. + app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) + .unwrap(); + + assert!(!app.left_search_active); + assert!(app.collapsed_projects.contains("project-1")); + } + + #[test] + fn tab_exits_left_search_and_switches_pane() { + let mut app = test_app(default_bindings()); + seed_two_agents(&mut app); + app.focus = FocusPane::Left; + + app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)) + .unwrap(); + type_chars(&mut app, "parser"); + assert!(app.left_search_active); + + app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)) + .unwrap(); + + // Tab leaves search and moves focus to the next pane instead of being + // swallowed. + assert!(!app.left_search_active); + assert!(app.left_search.is_empty()); + assert_eq!(app.focus, FocusPane::Center); + } + + #[test] + fn esc_after_no_match_restores_pre_search_selection() { + let mut app = test_app(default_bindings()); + seed_two_agents(&mut app); + app.focus = FocusPane::Left; + // Start on the second agent. + app.selected_left = app + .left_items() + .iter() + .position( + |i| matches!(i, LeftItem::Session(idx) if app.sessions[*idx].id == "session-2"), + ) + .unwrap(); + assert_eq!( + app.selected_session().map(|s| s.id.clone()).as_deref(), + Some("session-2") + ); + + app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)) + .unwrap(); + type_chars(&mut app, "zzz-no-match"); + assert!( + app.left_items() + .iter() + .all(|i| !matches!(i, LeftItem::Session(_))) + ); + + app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) + .unwrap(); + + assert!(!app.left_search_active); + assert_eq!( + app.selected_session().map(|s| s.id.clone()).as_deref(), + Some("session-2") + ); + } + + #[test] + fn mouse_selectability_skips_project_header_during_search() { + let mut app = test_app(default_bindings()); + seed_two_agents(&mut app); + app.focus = FocusPane::Left; + + app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)) + .unwrap(); + type_chars(&mut app, "parser"); + + // Filtered list is [Project(0), Session(0)]; the mouse must not be able + // to land on the project header during search (matching keyboard nav). + assert!(matches!( + app.left_items().first(), + Some(LeftItem::Project(_)) + )); + assert!(!app.is_selectable_left_item(0)); + assert!(app.is_selectable_left_item(1)); + } + + #[test] + fn alt_char_does_not_edit_left_search_query() { + let mut app = test_app(default_bindings()); + seed_two_agents(&mut app); + app.focus = FocusPane::Left; + + app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)) + .unwrap(); + type_chars(&mut app, "par"); + app.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT)) + .unwrap(); + + // Alt+x is a shortcut, not text — it must not be appended to the query, + // and (being unbound) it must not drop the user out of search either. + assert_eq!(app.left_search.text, "par"); + assert!(app.left_search_active); + } + + #[test] + fn files_search_typed_q_edits_query_not_quit() { + let mut app = test_app(default_bindings()); + app.focus = FocusPane::Files; + app.right_section = RightSection::Unstaged; + app.unstaged_files = vec![ChangedFile { + path: "src/main.rs".into(), + status: "M".into(), + additions: 1, + deletions: 0, + binary: false, + }]; + + app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)) + .unwrap(); + assert!(app.files_search_active); + type_chars(&mut app, "q"); + + // `q` is Quit globally; while searching files it must edit the query. + assert!(app.files_search_active); + assert_eq!(app.files_search.text, "q"); + assert!(matches!(app.prompt, PromptState::None)); + } + + #[test] + fn word_delete_edits_left_search_query() { + let mut app = test_app(default_bindings()); + seed_two_agents(&mut app); + app.focus = FocusPane::Left; + + app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)) + .unwrap(); + type_chars(&mut app, "foo bar"); + assert_eq!(app.left_search.text, "foo bar"); + + // Alt+Backspace deletes the last word of the query without leaving search. + app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT)) + .unwrap(); + + assert!(app.left_search_active); + assert_eq!(app.left_search.text, "foo "); + } + + #[test] + fn alt_word_nav_moves_cursor_in_left_search_without_typing() { + let mut app = test_app(default_bindings()); + seed_two_agents(&mut app); + app.focus = FocusPane::Left; + + app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)) + .unwrap(); + type_chars(&mut app, "foo bar"); + assert_eq!(app.left_search.cursor, 7); + + // Alt+b is a word-left move handled by the text box: cursor moves to the + // start of "bar", the query text is unchanged, and search stays active. + app.handle_key(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT)) + .unwrap(); + + assert!(app.left_search_active); + assert_eq!(app.left_search.text, "foo bar"); + assert_eq!(app.left_search.cursor, 4); + } + + #[test] + fn clicking_another_pane_clears_left_search() { + let mut app = test_app(default_bindings()); + seed_two_agents(&mut app); + install_mouse_layout(&mut app); + app.focus = FocusPane::Left; + + app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)) + .unwrap(); + type_chars(&mut app, "parser"); + assert!(app.left_search_active); + + // A click in the center pane moves focus away and must clear search. + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 40, 5)); + + assert_eq!(app.focus, FocusPane::Center); + assert!(!app.left_search_active); + assert!(app.left_search.is_empty()); + // The full, unfiltered list is restored (project header + both agents). + assert_eq!(app.left_items().len(), 3); + } + + #[test] + fn left_search_navigates_matching_agents_across_projects() { + let mut app = test_app(default_bindings()); + seed_two_projects(&mut app); + app.focus = FocusPane::Left; + + app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)) + .unwrap(); + type_chars(&mut app, "alpha"); + + // Both matching agents appear under their own project headers; the + // non-matching session-2 is dropped. Note the session indices are the + // global sessions-array indices (0 and 2), not filtered positions. + assert_eq!( + app.left_items(), + &[ + LeftItem::Project(0), + LeftItem::Session(0), + LeftItem::Project(1), + LeftItem::Session(2), + ] + ); + assert_eq!( + app.selected_session().map(|s| s.id.clone()).as_deref(), + Some("session-1") + ); + + // Down skips the second project header and the non-matching agent, + // landing on the matching agent in the other project. + app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)) + .unwrap(); + assert_eq!( + app.selected_session().map(|s| s.id.clone()).as_deref(), + Some("session-3") + ); + + app.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)) + .unwrap(); + assert_eq!( + app.selected_session().map(|s| s.id.clone()).as_deref(), + Some("session-1") + ); + } + + #[test] + fn tab_exits_files_search_and_switches_pane() { + let mut app = test_app(default_bindings()); + app.focus = FocusPane::Files; + app.right_section = RightSection::Unstaged; + app.unstaged_files = vec![ChangedFile { + path: "src/main.rs".into(), + status: "M".into(), + additions: 1, + deletions: 0, + binary: false, + }]; + + app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)) + .unwrap(); + type_chars(&mut app, "main"); + assert!(app.files_search_active); + + // Tab must leave the files search instead of being swallowed, and fully + // clear it (query included) rather than leaving a lingering filter. + app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)) + .unwrap(); + + assert!(!app.files_search_active); + assert!(app.files_search.is_empty()); + assert!(!app.has_files_search()); + assert_ne!(app.focus, FocusPane::Files); + } + + #[test] + fn cancel_in_pane_searches_clears_active_filter() { + let mut app = test_app(default_bindings()); + seed_two_agents(&mut app); + app.focus = FocusPane::Left; + + app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)) + .unwrap(); + type_chars(&mut app, "parser"); + assert!(app.left_search_active); + assert_eq!(app.left_items().len(), 2); // filtered: [Project, Session] + + app.cancel_in_pane_searches(); + + assert!(!app.left_search_active); + assert!(app.left_search.is_empty()); + assert_eq!(app.left_items().len(), 3); // full list restored + } + + #[test] + fn background_rebuild_keeps_search_selection_anchored() { + let mut app = test_app(default_bindings()); + seed_two_agents(&mut app); + app.focus = FocusPane::Left; + + app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)) + .unwrap(); + // "demo" matches the project name, so both agents are listed. + type_chars(&mut app, "demo"); + app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)) + .unwrap(); + assert_eq!( + app.selected_session().map(|s| s.id.clone()).as_deref(), + Some("session-2") + ); + + // A background worker rebuilding the list mid-search must not move the + // highlight off the agent the user navigated to. + app.rebuild_left_items(); + + assert_eq!( + app.selected_session().map(|s| s.id.clone()).as_deref(), + Some("session-2") + ); + } + + #[test] + fn double_click_agent_during_search_opens_and_clears() { + let mut app = test_app(default_bindings()); + seed_two_agents(&mut app); + // Avoid spawning a provider on activation. + for session in &mut app.sessions { + session.worktree_path = "/nonexistent/dux-test-worktree".to_string(); + } + install_mouse_layout(&mut app); + app.focus = FocusPane::Left; + + app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)) + .unwrap(); + type_chars(&mut app, "parser"); // filtered to [Project(0), Session(0)] + + // First click selects the agent row (list index 1) but keeps search; + // the second (double) click opens it and the post-dispatch hook clears. + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 2)); + assert!(app.left_search_active); + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 2)); + + assert!(!app.left_search_active); + assert!(app.left_search.is_empty()); + assert_eq!(app.focus, FocusPane::Center); + assert_eq!( + app.selected_session().map(|s| s.id.clone()).as_deref(), + Some("session-1") + ); + } + #[test] fn enter_opens_selected_file_diff_from_files_pane() { let mut app = test_app(default_bindings()); diff --git a/src/app/mod.rs b/src/app/mod.rs index b1a8c9ce5..56a0a1170 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -67,6 +67,13 @@ pub struct App { pub(crate) files_index: usize, pub(crate) files_search: TextInput, pub(crate) files_search_active: bool, + /// Single-line filter for the projects/agents list. Active state lives in + /// `left_search_active`; an empty query while active shows the full list. + pub(crate) left_search: TextInput, + pub(crate) left_search_active: bool, + /// Agent selected when search opened, used to restore the highlight if the + /// query ends up matching nothing. + pub(crate) left_search_origin_session: Option, pub(crate) commit_input: TextInput, pub(crate) left_width_pct: u16, pub(crate) right_width_pct: u16, @@ -1310,12 +1317,56 @@ impl LeftItem { } } -pub(crate) fn build_left_items( +/// Build the flattened left-pane item list. Module-private so the only +/// production entry point is [`App::rebuild_left_items`], which supplies the +/// active search query (`None` when search is inactive) and re-anchors the +/// selection — calling this directly with `None` while a search is active would +/// silently produce an unfiltered list. +fn build_left_items( projects: &[Project], sessions: &[AgentSession], collapsed_projects: &HashSet, empty_project_separator_min_projects: u16, + search: Option<&str>, ) -> Vec { + // When a search query is active, build a flat filtered view: project + // headers followed by their matching agents, with non-matching and + // agent-less projects dropped entirely. Collapse state and the + // empty-project grouping are ignored so matches are always revealed. + // Matching is case-insensitive; the needle is lowercased here so callers + // do not have to. + let search = search.map(str::trim).filter(|n| !n.is_empty()); + if let Some(needle) = search { + let needle = needle.to_lowercase(); + let needle = needle.as_str(); + let mut items = Vec::new(); + for (project_index, project) in projects.iter().enumerate() { + let project_matches = project.name.to_lowercase().contains(needle); + let matching: Vec = sessions + .iter() + .enumerate() + .filter(|(_, session)| session.project_id == project.id) + .filter(|(_, session)| { + project_matches + || session + .title + .as_deref() + .is_some_and(|t| t.to_lowercase().contains(needle)) + || session.branch_name.to_lowercase().contains(needle) + }) + .map(|(session_index, _)| session_index) + .collect(); + if matching.is_empty() { + continue; + } + items.push(LeftItem::Project(project_index)); + for session_index in matching { + items.push(LeftItem::Session(session_index)); + } + } + return items; + } + let split_empty_projects = empty_project_separator_min_projects > 0 && projects.len() >= usize::from(empty_project_separator_min_projects); let mut items = Vec::new(); @@ -1702,6 +1753,9 @@ impl App { files_index: 0, files_search: TextInput::new(), files_search_active: false, + left_search: TextInput::new(), + left_search_active: false, + left_search_origin_session: None, commit_input: TextInput::new() .with_multiline(4) .with_placeholder("Type your commit message\u{2026}"), @@ -2575,19 +2629,66 @@ impl App { } pub(crate) fn rebuild_left_items(&mut self) { + // While searching, keep the highlight anchored to the same agent across + // rebuilds (e.g. a background branch/PR refresh re-running the filter) + // rather than letting `ensure_selectable_left_item` move it. The typing + // path overrides this afterward via `select_first_left_result`. + let anchor = self + .left_search_active + .then(|| self.selected_session().map(|s| s.id.clone())) + .flatten(); + // `build_left_items` trims, lowercases, and ignores an empty query, so + // the raw text is passed through when search is active. + let search = self + .left_search_active + .then(|| self.left_search.text.clone()); self.left_items_cache = build_left_items( &self.projects, &self.sessions, &self.collapsed_projects, self.config.ui.empty_project_separator_min_projects, + search.as_deref(), ); self.ensure_selectable_left_item(); + if let Some(id) = anchor + && let Some(index) = self.left_items_cache.iter().position(|item| { + matches!(item, LeftItem::Session(si) if self.sessions.get(*si).is_some_and(|s| s.id == id)) + }) + { + self.selected_left = index; + } + } + + /// Whether keyboard or mouse navigation may land the cursor on `item`. Both + /// input paths route selection through this predicate. It matches + /// `LeftItem::is_selectable()`, except that while searching, project headers + /// act as plain context so selection only moves between agent rows. + /// (Deliberate, search-gated state transitions — e.g. re-selecting a project + /// after toggling its collapse — set the cursor directly and are exempt.) + pub(crate) fn left_item_is_nav_target(&self, item: LeftItem) -> bool { + item.is_selectable() && !(self.left_search_active && matches!(item, LeftItem::Project(_))) + } + + /// Drop any active in-pane search (agents and files) without restoring a + /// prior selection. Used when a background event takes over the UI — e.g. an + /// agent launch completes and focus moves to the new agent — so a stale + /// filter or search box never lingers. Rebuilds the list when an agent + /// search was active so it is no longer filtered. + pub(crate) fn cancel_in_pane_searches(&mut self) { + let was_searching = self.left_search_active; + self.left_search_active = false; + self.left_search.clear(); + self.left_search_origin_session = None; + self.clear_files_search(); + if was_searching { + self.rebuild_left_items(); + } } pub(crate) fn is_selectable_left_item(&self, index: usize) -> bool { self.left_items() .get(index) - .is_some_and(|item| item.is_selectable()) + .is_some_and(|item| self.left_item_is_nav_target(*item)) } pub(crate) fn next_selectable_left_item_after(&self, index: usize) -> Option { @@ -2595,7 +2696,7 @@ impl App { .iter() .enumerate() .skip(index.saturating_add(1)) - .find_map(|(idx, item)| item.is_selectable().then_some(idx)) + .find_map(|(idx, item)| self.left_item_is_nav_target(*item).then_some(idx)) } pub(crate) fn previous_selectable_left_item_before(&self, index: usize) -> Option { @@ -2604,7 +2705,7 @@ impl App { .enumerate() .take(index) .rev() - .find_map(|(idx, item)| item.is_selectable().then_some(idx)) + .find_map(|(idx, item)| self.left_item_is_nav_target(*item).then_some(idx)) } pub(crate) fn ensure_selectable_left_item(&mut self) { @@ -2615,7 +2716,7 @@ impl App { if self.selected_left >= self.left_items_cache.len() { self.selected_left = self.left_items_cache.len().saturating_sub(1); } - if self.left_items_cache[self.selected_left].is_selectable() { + if self.left_item_is_nav_target(self.left_items_cache[self.selected_left]) { return; } if let Some(next) = self.next_selectable_left_item_after(self.selected_left) { @@ -3619,7 +3720,7 @@ mod tests { ]; let sessions = vec![test_session("session-1", "project-2", 0)]; - let items = build_left_items(&projects, &sessions, &HashSet::new(), 5); + let items = build_left_items(&projects, &sessions, &HashSet::new(), 5, None); assert_eq!( items, @@ -3633,6 +3734,92 @@ mod tests { ); } + #[test] + fn build_left_items_filters_by_title() { + let projects = vec![test_project("project-1"), test_project("project-2")]; + let mut s0 = test_session("session-1", "project-1", 0); + s0.title = Some("Fix the parser".to_string()); + let mut s1 = test_session("session-2", "project-2", 0); + s1.title = Some("Add logging".to_string()); + let sessions = vec![s0, s1]; + + let items = build_left_items(&projects, &sessions, &HashSet::new(), 0, Some("parser")); + + assert_eq!(items, vec![LeftItem::Project(0), LeftItem::Session(0)]); + } + + #[test] + fn build_left_items_filters_by_branch_name() { + let projects = vec![test_project("project-1")]; + // test_session sets branch_name == id. + let sessions = vec![ + test_session("feature-login", "project-1", 0), + test_session("chore-docs", "project-1", 0), + ]; + + let items = build_left_items(&projects, &sessions, &HashSet::new(), 0, Some("login")); + + assert_eq!(items, vec![LeftItem::Project(0), LeftItem::Session(0)]); + } + + #[test] + fn build_left_items_project_name_match_surfaces_all_agents() { + let projects = vec![test_project("backend"), test_project("frontend")]; + let sessions = vec![ + test_session("alpha", "backend", 0), + test_session("beta", "backend", 0), + test_session("gamma", "frontend", 0), + ]; + + // "backend" matches the project name, so both of its agents appear even + // though their own titles/branches don't contain the needle. The + // unrelated project is dropped. + let items = build_left_items(&projects, &sessions, &HashSet::new(), 0, Some("backend")); + + assert_eq!( + items, + vec![ + LeftItem::Project(0), + LeftItem::Session(0), + LeftItem::Session(1) + ] + ); + } + + #[test] + fn build_left_items_filter_hides_empty_projects_and_ignores_collapse() { + let projects = vec![ + test_project("alpha"), // has a matching agent, but is collapsed + test_project("beta"), // no agents at all + test_project("gamma"), // an agent that does not match + ]; + let mut s0 = test_session("s0", "alpha", 0); + s0.title = Some("login flow".to_string()); + let mut s1 = test_session("s1", "gamma", 0); + s1.title = Some("unrelated".to_string()); + let sessions = vec![s0, s1]; + + let mut collapsed = HashSet::new(); + collapsed.insert("alpha".to_string()); + + let items = build_left_items(&projects, &sessions, &collapsed, 5, Some("login")); + + // alpha is revealed despite being collapsed; the empty project and the + // non-matching project are hidden; no spacer/separator in search view. + assert_eq!(items, vec![LeftItem::Project(0), LeftItem::Session(0)]); + } + + #[test] + fn build_left_items_empty_query_is_unfiltered() { + let projects = vec![test_project("p1")]; + let sessions = vec![test_session("s1", "p1", 0)]; + + let filtered = build_left_items(&projects, &sessions, &HashSet::new(), 0, Some("")); + let unfiltered = build_left_items(&projects, &sessions, &HashSet::new(), 0, None); + + assert_eq!(filtered, unfiltered); + } + #[test] fn build_left_items_splits_empty_projects_at_threshold() { let projects = vec![ @@ -3647,7 +3834,7 @@ mod tests { test_session("session-2", "project-4", 0), ]; - let items = build_left_items(&projects, &sessions, &HashSet::new(), 5); + let items = build_left_items(&projects, &sessions, &HashSet::new(), 5, None); assert_eq!( items, @@ -3680,7 +3867,7 @@ mod tests { test_session("session-3", "project-3", 0), ]; - let items = build_left_items(&projects, &sessions, &HashSet::new(), 5); + let items = build_left_items(&projects, &sessions, &HashSet::new(), 5, None); assert_eq!( items, @@ -3715,7 +3902,7 @@ mod tests { ]; sessions.sort_by_key(|session| std::cmp::Reverse(session.created_at)); - let items = build_left_items(&projects, &sessions, &HashSet::new(), 5); + let items = build_left_items(&projects, &sessions, &HashSet::new(), 5, None); assert_eq!( items, @@ -3745,7 +3932,7 @@ mod tests { ]; let sessions = vec![test_session("session-1", "project-2", 0)]; - let items = build_left_items(&projects, &sessions, &HashSet::new(), 0); + let items = build_left_items(&projects, &sessions, &HashSet::new(), 0, None); assert!(!items.contains(&LeftItem::EmptyProjectsSeparator)); assert!(!items.contains(&LeftItem::EmptyProjectsSpacer)); @@ -3762,7 +3949,7 @@ mod tests { test_project("project-5"), ]; - let items = build_left_items(&projects, &[], &HashSet::new(), 5); + let items = build_left_items(&projects, &[], &HashSet::new(), 5, None); assert_eq!( items, diff --git a/src/app/render.rs b/src/app/render.rs index 9c291d294..c676c18ab 100644 --- a/src/app/render.rs +++ b/src/app/render.rs @@ -821,23 +821,66 @@ impl App { } }) .collect::>(); - self.mouse_layout.left_list = self - .themed_block(&title, projects_focused) - .inner(projects_area); - let mut state = - ListState::default().with_selected(if self.left_section == LeftSection::Projects { - Some(self.selected_left) + // Draw the pane border first so the list (and, when searching, the + // in-pane search box) can be laid out within its inner area. + let projects_block = self.themed_block(&title, projects_focused); + let projects_inner = projects_block.inner(projects_area); + projects_block.render(projects_area, frame.buffer_mut()); + + let show_search = self.left_search_active; + let list_area = if show_search && projects_inner.height >= 1 { + // Use a 2-row bordered box when there is room; fall back to a single + // borderless row in a very short pane so the active filter and its + // query stay visible rather than silently filtering an invisible box + // (at 1 row the query takes the row and the list is squeezed out). + let search_rows = if projects_inner.height >= 3 { 2 } else { 1 }; + let [search_area, list_area] = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(search_rows), Constraint::Min(1)]) + .areas(projects_inner); + let query = render_single_line_cursor_input( + "/ ", + &self.left_search.text, + self.left_search.cursor, + self.theme.input_cursor_fg, + self.theme.input_cursor_bg, + ); + let search_block = if search_rows == 2 { + Block::default() + .borders(Borders::BOTTOM) + .border_style(Style::default().fg(self.theme.border_normal)) } else { - None - }); - StatefulWidget::render( - List::new(items) - .block(self.themed_block(&title, projects_focused)) - .highlight_style(self.theme.selection_style()), - projects_area, - frame.buffer_mut(), - &mut state, - ); + Block::default() + }; + Paragraph::new(query) + .block(search_block) + .render(search_area, frame.buffer_mut()); + list_area + } else { + projects_inner + }; + self.mouse_layout.left_list = list_area; + + if show_search && items.is_empty() { + Paragraph::new(Line::from(Span::styled( + "No matching agents", + Style::default().fg(self.theme.hint_dim_desc_fg), + ))) + .render(list_area, frame.buffer_mut()); + } else { + let mut state = + ListState::default().with_selected(if self.left_section == LeftSection::Projects { + Some(self.selected_left) + } else { + None + }); + StatefulWidget::render( + List::new(items).highlight_style(self.theme.selection_style()), + list_area, + frame.buffer_mut(), + &mut state, + ); + } // Render terminals section if any terminals exist. if let Some(term_area) = terminals_area { @@ -1939,7 +1982,19 @@ impl App { FocusPane::Center => HintContext::Center, FocusPane::Files => HintContext::Files, }; - let hints = self.footer_hints_for(ctx); + let hints = if self.focus == FocusPane::Left && self.left_search_active { + // Search mode owns the keys, so show its affordances instead of the + // normal left-pane hints (which would mislead — `/` and letters now + // edit the query). + vec![ + (self.bindings.label_for(Action::CloseOverlay), "Exit search"), + (self.bindings.label_for(Action::MoveUp), "Up"), + (self.bindings.label_for(Action::MoveDown), "Down"), + (self.bindings.label_for(Action::FocusAgent), "Open agent"), + ] + } else { + self.footer_hints_for(ctx) + }; let [hints_area, status_area] = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(1), Constraint::Min(1)]) diff --git a/src/app/sessions.rs b/src/app/sessions.rs index 80c5597ac..89cfb67a3 100644 --- a/src/app/sessions.rs +++ b/src/app/sessions.rs @@ -964,6 +964,11 @@ impl App { self.resume_fallback_candidates.remove(&session.id); self.clear_companion_terminals_for_session(&session.id); self.sessions.retain(|candidate| candidate.id != session.id); + // Drop a now-dangling agent-search restore target so a later Esc with no + // matches doesn't try to return to a deleted session. + if self.left_search_origin_session.as_deref() == Some(session.id.as_str()) { + self.left_search_origin_session = None; + } self.update_branch_sync_sessions(); let project_still_has_sessions = self .sessions @@ -2724,6 +2729,9 @@ mod tests { files_index: 0, files_search: TextInput::new(), files_search_active: false, + left_search: TextInput::new(), + left_search_active: false, + left_search_origin_session: None, commit_input: TextInput::new() .with_multiline(4) .with_placeholder("Type your commit message\u{2026}"), diff --git a/src/app/text_input.rs b/src/app/text_input.rs index ce1a752f6..f4a7e1552 100644 --- a/src/app/text_input.rs +++ b/src/app/text_input.rs @@ -376,7 +376,9 @@ impl TextInput { /// Handle common text-editing keys. Returns `true` if the key was consumed. /// /// Handled keys: - /// - `Char(c)` (without Ctrl) → insert + /// - `Char(c)` (without Ctrl or Alt) → insert. Alt/Ctrl character combos are + /// treated as shortcuts (see the Alt+b/Alt+f and Ctrl+W arms) and are not + /// inserted as text, so the caller can dispatch them. /// - `Backspace` → delete char backward; `Alt+Backspace` / `Ctrl+W` → delete word backward /// - `Delete` → delete char forward; `Alt+Delete` / `Ctrl+Delete` → delete word forward /// - `Left` / `Right` → move char; `Alt+Left/Right` / `Ctrl+Left/Right` → move word @@ -471,7 +473,11 @@ impl TextInput { self.move_right_word(); true } - KeyCode::Char(c) if !has_ctrl => { + // Insert only un-modified characters. `Alt`/`Ctrl` combinations are + // shortcuts, not text (the word-navigation arms above already claim + // the Alt+b/Alt+f and Ctrl-W cases the editor cares about), so they + // are left for the caller to dispatch rather than typed into the field. + KeyCode::Char(c) if !has_ctrl && !has_alt => { self.insert_char(c); if is_multiline { self.ensure_cursor_visible(); diff --git a/src/app/workers.rs b/src/app/workers.rs index 7ff54317d..9377108fb 100644 --- a/src/app/workers.rs +++ b/src/app/workers.rs @@ -1116,6 +1116,12 @@ impl App { self.agent_launches_in_flight.remove(&session_id); self.last_pty_size = request.pty_size; + // A ready agent takes over the surface (focus moves to it), so leave any + // active in-pane search; otherwise a filtered list or search box would + // linger behind the agent. Rebuilds the (no-longer-filtered) list even on + // the reconnect path, which doesn't rebuild below. + self.cancel_in_pane_searches(); + if matches!(request.kind, AgentLaunchKind::Create { .. }) { self.create_agent_in_flight = false; if let Err(err) = self.session_store.upsert_session(&session) { diff --git a/src/keybindings.rs b/src/keybindings.rs index e468cd7f3..57007537b 100644 --- a/src/keybindings.rs +++ b/src/keybindings.rs @@ -26,6 +26,7 @@ pub enum Action { ReconnectAgent, DeleteSession, DeleteTerminal, + SearchAgents, // Agent pane InteractAgent, ShowTerminal, @@ -220,6 +221,7 @@ impl Action { Action::ReconnectAgent => "reconnect_agent", Action::DeleteSession => "delete_session", Action::DeleteTerminal => "delete_terminal", + Action::SearchAgents => "search_agents", Action::InteractAgent => "interact_agent", Action::ShowTerminal => "show_terminal", Action::ExitInteractive => "exit_interactive", @@ -329,6 +331,9 @@ impl Action { Action::ReconnectAgent => "Restart the CLI for the selected agent.", Action::DeleteSession => "Delete the selected session and worktree.", Action::DeleteTerminal => "Delete the selected companion terminal.", + Action::SearchAgents => { + "Filter agents in the projects pane by title, branch, or project name." + } Action::InteractAgent => "Start a prompt turn for the agent.", Action::ShowTerminal => { "Open the first companion terminal for the selected agent, or launch a new one if none exists." @@ -445,7 +450,8 @@ impl Action { | Action::InteractAgent | Action::ReconnectAgent | Action::DeleteSession - | Action::DeleteTerminal => Some("Projects pane"), + | Action::DeleteTerminal + | Action::SearchAgents => Some("Projects pane"), Action::NewAgentFromPr => None, Action::ExitInteractive | Action::OpenMacroBar @@ -956,6 +962,23 @@ pub const BINDING_DEFS: &[BindingDef] = &[ description: "Delete the selected companion terminal", }), }, + BindingDef { + action: Action::SearchAgents, + default_keys: &[KeyCombination::one_key( + KeyCode::Char('/'), + KeyModifiers::NONE, + )], + scopes: &[BindingScope::Left], + help: Some(HelpEntry { + section: "Projects pane", + description: "Search agents", + }), + hint_contexts: &[ + (HintContext::LeftProject, "Search"), + (HintContext::LeftSession, "Search"), + ], + palette: None, + }, // ── Agent pane ──────────────────────────────────────────────── BindingDef { action: Action::InteractAgent, @@ -2730,9 +2753,20 @@ mod tests { assert!(actions_in_defs.contains(&Action::ExitPathEditorOnProjectAdd)); assert!(actions_in_defs.contains(&Action::SearchFiles)); assert!(actions_in_defs.contains(&Action::SearchNext)); + assert!(actions_in_defs.contains(&Action::SearchAgents)); assert!(actions_in_defs.contains(&Action::ForceRedraw)); } + #[test] + fn left_scope_resolves_slash_to_search_agents() { + let bindings = default_bindings(); + let slash = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE); + assert_eq!( + bindings.lookup(&slash, BindingScope::Left), + Some(Action::SearchAgents) + ); + } + #[test] fn files_scope_resolves_slash_to_search_toggle() { let bindings = default_bindings();