diff --git a/crates/loopal-tui/src/app/mod.rs b/crates/loopal-tui/src/app/mod.rs index a2b1f125..120731c8 100644 --- a/crates/loopal-tui/src/app/mod.rs +++ b/crates/loopal-tui/src/app/mod.rs @@ -44,6 +44,10 @@ pub struct App { pub show_topology: bool, /// Agent panel cursor — Tab cycles through agents. Purely TUI concept. pub focused_agent: Option, + /// Which UI region owns keyboard focus. + pub focus_mode: FocusMode, + /// Scroll offset for the agent panel (index of first visible agent). + pub agent_panel_offset: usize, // === Session Controller (observable + interactive) === pub session: SessionController, @@ -81,6 +85,8 @@ impl App { content_overflows: false, show_topology: false, focused_agent: None, + focus_mode: FocusMode::default(), + agent_panel_offset: 0, session, line_cache: LineCache::new(), } diff --git a/crates/loopal-tui/src/app/types.rs b/crates/loopal-tui/src/app/types.rs index e3248d67..d3299f32 100644 --- a/crates/loopal-tui/src/app/types.rs +++ b/crates/loopal-tui/src/app/types.rs @@ -87,6 +87,19 @@ pub enum SubPage { RewindPicker(RewindPickerState), } +/// Which UI region currently owns keyboard input. +/// +/// Orthogonal to `focused_agent` — mode says "are we navigating agents" +/// while `focused_agent` says "which one is highlighted". +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum FocusMode { + /// Default: typing goes to input field; Up/Down = multiline → scroll → history. + #[default] + Input, + /// Agent panel navigation: Up/Down = navigate agents; Enter = drill in. + AgentPanel, +} + /// State for the rewind turn picker. pub struct RewindPickerState { /// Available turns (most recent first for display). diff --git a/crates/loopal-tui/src/input/actions.rs b/crates/loopal-tui/src/input/actions.rs index d32bbe3c..6b876326 100644 --- a/crates/loopal-tui/src/input/actions.rs +++ b/crates/loopal-tui/src/input/actions.rs @@ -33,12 +33,14 @@ pub enum InputAction { RunCommand(String, Option), /// Sub-page picker confirmed a result SubPageConfirm(SubPageResult), - /// Cycle focus to the next agent in the agents map - FocusNextAgent, - /// Focus the previous agent in the agents map - FocusPrevAgent, - /// Clear agent focus - UnfocusAgent, + /// Enter AgentPanel focus mode (Tab from Input when agents exist) + EnterAgentPanel, + /// Exit AgentPanel focus mode back to Input + ExitAgentPanel, + /// Navigate up within agent panel (with scroll) + AgentPanelUp, + /// Navigate down within agent panel (with scroll) + AgentPanelDown, /// Enter the focused agent's conversation view (drill in) EnterAgentView, /// Return to root/parent view (drill out) diff --git a/crates/loopal-tui/src/input/editing.rs b/crates/loopal-tui/src/input/editing.rs index 5bc67a51..e24ae1bd 100644 --- a/crates/loopal-tui/src/input/editing.rs +++ b/crates/loopal-tui/src/input/editing.rs @@ -1,4 +1,4 @@ -use crate::app::App; +use crate::app::{App, FocusMode}; use super::InputAction; use super::commands::try_execute_slash_command; @@ -44,7 +44,7 @@ pub(super) fn handle_backspace(app: &mut App) -> InputAction { InputAction::None } -/// Ctrl+C: clear input if non-empty, clear focus if set, otherwise interrupt agent. +/// Ctrl+C: clear input → exit AgentPanel → clear focus → interrupt agent. pub(super) fn handle_ctrl_c(app: &mut App) -> InputAction { if !app.input.is_empty() || !app.pending_images.is_empty() { app.input.clear(); @@ -54,6 +54,11 @@ pub(super) fn handle_ctrl_c(app: &mut App) -> InputAction { app.paste_map.clear(); app.autocomplete = None; InputAction::None + } else if app.focus_mode == FocusMode::AgentPanel { + app.focused_agent = None; + app.focus_mode = FocusMode::Input; + app.agent_panel_offset = 0; + InputAction::None } else if app.focused_agent.is_some() { app.focused_agent = None; InputAction::None diff --git a/crates/loopal-tui/src/input/mod.rs b/crates/loopal-tui/src/input/mod.rs index 0918ad29..064bb6a2 100644 --- a/crates/loopal-tui/src/input/mod.rs +++ b/crates/loopal-tui/src/input/mod.rs @@ -12,7 +12,7 @@ pub use actions::*; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use crate::app::App; +use crate::app::{App, FocusMode}; use autocomplete::{handle_autocomplete_key, update_autocomplete}; use editing::{handle_backspace, handle_ctrl_c, handle_enter}; use navigation::{ @@ -45,6 +45,13 @@ fn handle_global_keys(app: &mut App, key: &KeyEvent) -> Option { KeyCode::Char('c') => return Some(handle_ctrl_c(app)), KeyCode::Char('d') => return Some(InputAction::Quit), KeyCode::Char('v') => return Some(InputAction::PasteRequested), + // Ctrl+P/N: mode-aware up/down (agent nav in AgentPanel, history in Input) + KeyCode::Char('p') if app.focus_mode == FocusMode::AgentPanel => { + return Some(InputAction::AgentPanelUp); + } + KeyCode::Char('n') if app.focus_mode == FocusMode::AgentPanel => { + return Some(InputAction::AgentPanelDown); + } KeyCode::Char('p') => return Some(handle_up(app)), KeyCode::Char('n') => return Some(handle_down(app)), _ => {} @@ -62,20 +69,40 @@ fn handle_global_keys(app: &mut App, key: &KeyEvent) -> Option { None } -/// Handle normal input keys (typing, navigation, submit). +/// Handle normal input keys — dispatch by current focus mode. fn handle_normal_key(app: &mut App, key: &KeyEvent) -> InputAction { - // Agent focus mode: when an agent is focused, input is empty, and - // content doesn't need scrolling, Up/Down navigate agents. - // Delete terminates the focused agent. - if app.focused_agent.is_some() && app.input.is_empty() { - let needs_scroll = app.scroll_offset > 0 || app.content_overflows; - match key.code { - KeyCode::Up if !needs_scroll => return InputAction::FocusPrevAgent, - KeyCode::Down if !needs_scroll => return InputAction::FocusNextAgent, - KeyCode::Delete => return InputAction::TerminateFocusedAgent, - _ => {} + match app.focus_mode { + FocusMode::AgentPanel => handle_agent_panel_key(app, key), + FocusMode::Input => handle_input_mode_key(app, key), + } +} + +/// Keys in AgentPanel mode: Up/Down navigate, Enter drills in, Tab/Esc exits. +fn handle_agent_panel_key(app: &mut App, key: &KeyEvent) -> InputAction { + match key.code { + KeyCode::Up => InputAction::AgentPanelUp, + KeyCode::Down => InputAction::AgentPanelDown, + KeyCode::Enter => InputAction::EnterAgentView, + KeyCode::Delete => InputAction::TerminateFocusedAgent, + KeyCode::Tab | KeyCode::Esc => InputAction::ExitAgentPanel, + KeyCode::Char(c) => { + // Auto-switch to Input mode and insert the character + app.focus_mode = FocusMode::Input; + app.input.insert(app.input_cursor, c); + app.input_cursor += c.len_utf8(); + InputAction::None } + KeyCode::Backspace => { + // Auto-switch to Input mode and delete + app.focus_mode = FocusMode::Input; + handle_backspace(app) + } + _ => InputAction::None, } +} + +/// Keys in Input mode: typing, navigation, submit. +fn handle_input_mode_key(app: &mut App, key: &KeyEvent) -> InputAction { match key.code { KeyCode::Enter if key.modifiers.contains(KeyModifiers::SHIFT) => { app.input.insert(app.input_cursor, '\n'); @@ -115,7 +142,7 @@ fn handle_normal_key(app: &mut App, key: &KeyEvent) -> InputAction { } KeyCode::Up => handle_up_key(app), KeyCode::Down => handle_down_key(app), - KeyCode::Tab => InputAction::FocusNextAgent, + KeyCode::Tab => InputAction::EnterAgentPanel, KeyCode::Esc => handle_esc(app), KeyCode::PageUp => { app.scroll_offset = app.scroll_offset.saturating_add(10); diff --git a/crates/loopal-tui/src/input/navigation.rs b/crates/loopal-tui/src/input/navigation.rs index 275f7fb5..01713868 100644 --- a/crates/loopal-tui/src/input/navigation.rs +++ b/crates/loopal-tui/src/input/navigation.rs @@ -4,7 +4,7 @@ use std::time::Instant; use super::InputAction; use super::multiline; -use crate::app::App; +use crate::app::{App, FocusMode}; /// Default wrap width when terminal width is unknown. pub(super) const DEFAULT_WRAP_WIDTH: usize = 80; @@ -79,6 +79,10 @@ pub(super) fn handle_down(app: &mut App) -> InputAction { } pub(super) fn handle_esc(app: &mut App) -> InputAction { + // AgentPanel mode: exit back to Input (don't trigger view exit or rewind) + if app.focus_mode == FocusMode::AgentPanel { + return InputAction::ExitAgentPanel; + } // Priority 1: exit agent view if app.session.lock().active_view != loopal_session::ROOT_AGENT { return InputAction::ExitAgentView; diff --git a/crates/loopal-tui/src/key_dispatch.rs b/crates/loopal-tui/src/key_dispatch.rs index ec7b394d..3434961a 100644 --- a/crates/loopal-tui/src/key_dispatch.rs +++ b/crates/loopal-tui/src/key_dispatch.rs @@ -7,7 +7,7 @@ use crate::event::EventHandler; use crate::input::paste; use crate::input::{InputAction, handle_key}; use crate::key_dispatch_ops::{ - cycle_agent_focus, handle_effect, handle_sub_page_confirm, push_to_inbox, + cycle_agent_focus, enter_agent_panel, handle_effect, handle_sub_page_confirm, push_to_inbox, terminate_focused_agent, }; use crate::views::progress::LineCache; @@ -81,16 +81,20 @@ pub(crate) async fn handle_key_action( handle_sub_page_confirm(app, result).await; false } - InputAction::FocusNextAgent => { - cycle_agent_focus(app, true); + InputAction::EnterAgentPanel => { + enter_agent_panel(app); + false + } + InputAction::ExitAgentPanel => { + app.focus_mode = crate::app::FocusMode::Input; false } - InputAction::FocusPrevAgent => { + InputAction::AgentPanelUp => { cycle_agent_focus(app, false); false } - InputAction::UnfocusAgent => { - app.focused_agent = None; + InputAction::AgentPanelDown => { + cycle_agent_focus(app, true); false } InputAction::TerminateFocusedAgent => { @@ -100,6 +104,7 @@ pub(crate) async fn handle_key_action( InputAction::EnterAgentView => { if let Some(name) = app.focused_agent.clone() { if app.session.enter_agent_view(&name) { + app.focus_mode = crate::app::FocusMode::Input; app.scroll_offset = 0; app.line_cache = LineCache::new(); app.last_esc_time = None; // prevent stale double-ESC rewind diff --git a/crates/loopal-tui/src/key_dispatch_ops.rs b/crates/loopal-tui/src/key_dispatch_ops.rs index 47bea2c1..d186ef00 100644 --- a/crates/loopal-tui/src/key_dispatch_ops.rs +++ b/crates/loopal-tui/src/key_dispatch_ops.rs @@ -4,7 +4,7 @@ use loopal_protocol::UserContent; use loopal_protocol::AgentStatus; -use crate::app::App; +use crate::app::{App, FocusMode}; use crate::command::CommandEffect; use crate::input::SubPageResult; @@ -62,9 +62,36 @@ pub(crate) async fn handle_sub_page_confirm(app: &mut App, result: SubPageResult } } +/// Enter AgentPanel focus mode. No-op if no live agents exist. +pub fn enter_agent_panel(app: &mut App) { + let state = app.session.lock(); + let active = &state.active_view; + let live_keys: Vec = state + .agents + .iter() + .filter(|(k, a)| k.as_str() != active && is_agent_live(&a.observable.status)) + .map(|(k, _)| k.clone()) + .collect(); + drop(state); + if live_keys.is_empty() { + return; + } + app.focus_mode = FocusMode::AgentPanel; + // Re-focus if current focused_agent is None or no longer live + let needs_focus = match &app.focused_agent { + None => true, + Some(name) => !live_keys.contains(name), + }; + if needs_focus { + cycle_agent_focus(app, true); + } +} + +use crate::views::agent_panel::MAX_VISIBLE; + /// Cycle `focused_agent` in the panel. `forward=true` → next, `false` → prev. /// Skips the active_view agent (it's the current conversation). -pub(crate) fn cycle_agent_focus(app: &mut App, forward: bool) { +pub fn cycle_agent_focus(app: &mut App, forward: bool) { let state = app.session.lock(); let active = &state.active_view; let keys: Vec = state @@ -76,6 +103,8 @@ pub(crate) fn cycle_agent_focus(app: &mut App, forward: bool) { drop(state); if keys.is_empty() { app.focused_agent = None; + app.focus_mode = FocusMode::Input; + app.agent_panel_offset = 0; return; } app.focused_agent = Some(match &app.focused_agent { @@ -100,6 +129,12 @@ pub(crate) fn cycle_agent_focus(app: &mut App, forward: bool) { } } }); + // Keep focused agent visible in the scroll window + if let Some(ref focused) = app.focused_agent { + if let Some(idx) = keys.iter().position(|k| k == focused) { + adjust_agent_scroll(app, idx, keys.len()); + } + } } /// Terminate (interrupt) the currently focused agent via Hub. @@ -123,6 +158,34 @@ pub(crate) async fn terminate_focused_agent(app: &mut App) { app.line_cache = crate::views::progress::LineCache::new(); } app.focused_agent = None; + // If no live agents remain, exit AgentPanel mode + let state = app.session.lock(); + let av = state.active_view.clone(); + let has_live = state + .agents + .iter() + .any(|(k, a)| k.as_str() != av && is_agent_live(&a.observable.status)); + drop(state); + if !has_live { + app.focus_mode = FocusMode::Input; + app.agent_panel_offset = 0; + } +} + +/// Ensure the focused agent at `focused_idx` is visible within the scroll window. +fn adjust_agent_scroll(app: &mut App, focused_idx: usize, total: usize) { + if total <= MAX_VISIBLE { + app.agent_panel_offset = 0; + return; + } + if focused_idx < app.agent_panel_offset { + app.agent_panel_offset = focused_idx; + } else if focused_idx >= app.agent_panel_offset + MAX_VISIBLE { + app.agent_panel_offset = focused_idx + 1 - MAX_VISIBLE; + } + app.agent_panel_offset = app + .agent_panel_offset + .min(total.saturating_sub(MAX_VISIBLE)); } fn is_agent_live(status: &AgentStatus) -> bool { diff --git a/crates/loopal-tui/src/lib.rs b/crates/loopal-tui/src/lib.rs index 5f9b3da8..d7730fd4 100644 --- a/crates/loopal-tui/src/lib.rs +++ b/crates/loopal-tui/src/lib.rs @@ -13,3 +13,9 @@ mod tui_loop; pub mod views; pub use tui_loop::{run_tui, run_tui_loop}; + +/// Re-exports of dispatch functions for integration testing. +#[doc(hidden)] +pub mod dispatch_ops { + pub use crate::key_dispatch_ops::{cycle_agent_focus, enter_agent_panel}; +} diff --git a/crates/loopal-tui/src/render.rs b/crates/loopal-tui/src/render.rs index 70e6565d..d9662db5 100644 --- a/crates/loopal-tui/src/render.rs +++ b/crates/loopal-tui/src/render.rs @@ -26,7 +26,7 @@ pub fn draw(f: &mut Frame, app: &mut App) { let layout = FrameLayout::compute( size, breadcrumb_h, - views::agent_panel::panel_height(&state.agents, &state.active_view), + views::agent_panel::panel_height(&state.agents, &state.active_view, app.agent_panel_offset), banner_h, input_h, ); @@ -65,6 +65,7 @@ pub fn draw(f: &mut Frame, app: &mut App) { app.focused_agent.as_deref(), viewing, &state.active_view, + app.agent_panel_offset, layout.agents, ); views::separator::render_separator(f, layout.separator); diff --git a/crates/loopal-tui/src/views/agent_panel.rs b/crates/loopal-tui/src/views/agent_panel.rs index 012473f2..e61f6374 100644 --- a/crates/loopal-tui/src/views/agent_panel.rs +++ b/crates/loopal-tui/src/views/agent_panel.rs @@ -18,11 +18,16 @@ use loopal_session::state::AgentViewState; use super::unified_status::{format_duration, spinner_frame}; /// Maximum visible agent rows before showing overflow. -const MAX_VISIBLE: usize = 5; +/// NOTE: must match `key_dispatch_ops::MAX_VISIBLE` (scroll calculation). +pub const MAX_VISIBLE: usize = 5; /// Compute the height needed for the agent panel. /// Excludes the currently viewed agent — it's the active conversation, not a switchable target. -pub fn panel_height(agents: &IndexMap, active_view: &str) -> u16 { +pub fn panel_height( + agents: &IndexMap, + active_view: &str, + agent_panel_offset: usize, +) -> u16 { let live = agents .iter() .filter(|(name, a)| name.as_str() != active_view && is_live(&a.observable.status)) @@ -31,11 +36,14 @@ pub fn panel_height(agents: &IndexMap, active_view: &str return 0; } let visible = live.min(MAX_VISIBLE); - let overflow = u16::from(live > MAX_VISIBLE); - visible as u16 + overflow + let clamped = agent_panel_offset.min(live.saturating_sub(MAX_VISIBLE)); + let has_above = clamped > 0; + let has_below = live > clamped + MAX_VISIBLE; + let indicators = u16::from(has_above) + u16::from(has_below); + visible as u16 + indicators } -/// Render the agent panel. +/// Render the agent panel with a scrolling viewport. /// `active_view` is excluded from the list (it's the current conversation). pub fn render_agent_panel( f: &mut Frame, @@ -43,6 +51,7 @@ pub fn render_agent_panel( focused: Option<&str>, viewing: Option<&str>, active_view: &str, + agent_panel_offset: usize, area: Rect, ) { if area.height == 0 || agents.is_empty() { @@ -55,20 +64,29 @@ pub fn render_agent_panel( .filter(|(name, a)| name.as_str() != active_view && is_live(&a.observable.status)) .collect(); - let visible = live_agents.len().min(MAX_VISIBLE); - let mut lines: Vec> = live_agents[..visible] - .iter() - .map(|(name, agent)| { - let is_focused = focused == Some(name.as_str()); - let is_viewing = viewing == Some(name.as_str()); - render_agent_line(name, agent, is_focused, is_viewing, max_name) - }) - .collect(); + let total = live_agents.len(); + let offset = agent_panel_offset.min(total.saturating_sub(MAX_VISIBLE)); + let window_end = (offset + MAX_VISIBLE).min(total); + let mut lines: Vec> = Vec::new(); + + if offset > 0 { + lines.push(Line::from(Span::styled( + format!(" \u{2191} {offset} more"), + Style::default().fg(Color::DarkGray), + ))); + } + + for (name, agent) in &live_agents[offset..window_end] { + let is_focused = focused == Some(name.as_str()); + let is_viewing = viewing == Some(name.as_str()); + lines.push(render_agent_line( + name, agent, is_focused, is_viewing, max_name, + )); + } - if live_agents.len() > MAX_VISIBLE { - let extra = live_agents.len() - MAX_VISIBLE; + if window_end < total { lines.push(Line::from(Span::styled( - format!(" +{extra} more agents"), + format!(" \u{2193} {} more", total - window_end), Style::default().fg(Color::DarkGray), ))); } diff --git a/crates/loopal-tui/tests/suite.rs b/crates/loopal-tui/tests/suite.rs index fab5e990..4dfc1530 100644 --- a/crates/loopal-tui/tests/suite.rs +++ b/crates/loopal-tui/tests/suite.rs @@ -15,10 +15,18 @@ mod command_dispatch_test; mod command_edge_test; #[path = "suite/command_test.rs"] mod command_test; +#[path = "suite/cycle_focus_test.rs"] +mod cycle_focus_test; +#[path = "suite/enter_panel_test.rs"] +mod enter_panel_test; #[path = "suite/event_forwarding_test.rs"] mod event_forwarding_test; #[path = "suite/event_test.rs"] mod event_test; +#[path = "suite/focus_mode_test.rs"] +mod focus_mode_test; +#[path = "suite/focus_panel_keys_test.rs"] +mod focus_panel_keys_test; #[path = "suite/init_cmd_test.rs"] mod init_cmd_test; #[path = "suite/input_edge_test.rs"] @@ -45,8 +53,6 @@ mod message_lines_test; mod skill_render_test; #[path = "suite/styled_wrap_test.rs"] mod styled_wrap_test; -#[path = "suite/view_switch_focus_keys_test.rs"] -mod view_switch_focus_keys_test; #[path = "suite/view_switch_panel_lifecycle_test.rs"] mod view_switch_panel_lifecycle_test; #[path = "suite/view_switch_test.rs"] diff --git a/crates/loopal-tui/tests/suite/cycle_focus_test.rs b/crates/loopal-tui/tests/suite/cycle_focus_test.rs new file mode 100644 index 00000000..841f9f4b --- /dev/null +++ b/crates/loopal-tui/tests/suite/cycle_focus_test.rs @@ -0,0 +1,185 @@ +/// Tests for cycle_agent_focus and adjust_agent_scroll (indirect). +use loopal_protocol::{AgentEvent, AgentEventPayload, ControlCommand, UserQuestionResponse}; +use loopal_session::SessionController; +use loopal_tui::app::{App, FocusMode}; +use loopal_tui::dispatch_ops::cycle_agent_focus; + +use tokio::sync::mpsc; + +fn make_app() -> App { + let (control_tx, _) = mpsc::channel::(16); + let (perm_tx, _) = mpsc::channel::(16); + let (question_tx, _) = mpsc::channel::(16); + let session = SessionController::new( + "test-model".into(), + "act".into(), + control_tx, + perm_tx, + question_tx, + Default::default(), + std::sync::Arc::new(tokio::sync::watch::channel(0u64).0), + ); + App::new(session, std::env::temp_dir()) +} + +fn spawn_agent(app: &App, name: &str) { + app.session.handle_event(AgentEvent::named( + name, + AgentEventPayload::SubAgentSpawned { + name: name.to_string(), + agent_id: format!("id-{name}"), + parent: Some("main".into()), + model: Some("test-model".into()), + session_id: None, + }, + )); + app.session + .handle_event(AgentEvent::named(name, AgentEventPayload::Started)); +} + +fn finish_agent(app: &App, name: &str) { + app.session + .handle_event(AgentEvent::named(name, AgentEventPayload::Finished)); +} + +// === Basic cycling === + +#[test] +fn forward_through_agents() { + let mut app = make_app(); + spawn_agent(&app, "a"); + spawn_agent(&app, "b"); + spawn_agent(&app, "c"); + app.focus_mode = FocusMode::AgentPanel; + cycle_agent_focus(&mut app, true); + assert_eq!(app.focused_agent.as_deref(), Some("a")); + cycle_agent_focus(&mut app, true); + assert_eq!(app.focused_agent.as_deref(), Some("b")); + cycle_agent_focus(&mut app, true); + assert_eq!(app.focused_agent.as_deref(), Some("c")); +} + +#[test] +fn forward_wraps_around() { + let mut app = make_app(); + spawn_agent(&app, "a"); + spawn_agent(&app, "b"); + app.focused_agent = Some("b".into()); + cycle_agent_focus(&mut app, true); + assert_eq!(app.focused_agent.as_deref(), Some("a")); +} + +#[test] +fn backward_wraps_around() { + let mut app = make_app(); + spawn_agent(&app, "a"); + spawn_agent(&app, "b"); + app.focused_agent = Some("a".into()); + cycle_agent_focus(&mut app, false); + assert_eq!(app.focused_agent.as_deref(), Some("b")); +} + +#[test] +fn backward_from_none_selects_last() { + let mut app = make_app(); + spawn_agent(&app, "a"); + spawn_agent(&app, "b"); + cycle_agent_focus(&mut app, false); + assert_eq!(app.focused_agent.as_deref(), Some("b")); +} + +// === Stale focus recovery === + +#[test] +fn recovers_from_stale_focused_agent() { + let mut app = make_app(); + spawn_agent(&app, "live"); + spawn_agent(&app, "dead"); + finish_agent(&app, "dead"); + app.focused_agent = Some("dead".into()); + cycle_agent_focus(&mut app, true); + assert_eq!(app.focused_agent.as_deref(), Some("live")); +} + +// === Empty list auto-exits AgentPanel === + +#[test] +fn empty_list_exits_agent_panel() { + let mut app = make_app(); + spawn_agent(&app, "worker"); + finish_agent(&app, "worker"); + app.focused_agent = Some("worker".into()); + app.focus_mode = FocusMode::AgentPanel; + app.agent_panel_offset = 2; + cycle_agent_focus(&mut app, true); + assert!(app.focused_agent.is_none()); + assert_eq!(app.focus_mode, FocusMode::Input); + assert_eq!(app.agent_panel_offset, 0); +} + +// === Scroll offset (indirect via 7 agents) === + +#[test] +fn scroll_follows_focus_downward() { + let mut app = make_app(); + for i in 0..7 { + spawn_agent(&app, &format!("a{i}")); + } + app.focus_mode = FocusMode::AgentPanel; + for _ in 0..7 { + cycle_agent_focus(&mut app, true); + } + assert_eq!(app.focused_agent.as_deref(), Some("a6")); + assert!( + app.agent_panel_offset >= 2, + "got {}", + app.agent_panel_offset, + ); +} + +#[test] +fn scroll_zero_when_few_agents() { + let mut app = make_app(); + spawn_agent(&app, "a"); + spawn_agent(&app, "b"); + spawn_agent(&app, "c"); + for _ in 0..3 { + cycle_agent_focus(&mut app, true); + } + assert_eq!(app.agent_panel_offset, 0); +} + +#[test] +fn scroll_resets_on_wrap_to_first() { + let mut app = make_app(); + for i in 0..7 { + spawn_agent(&app, &format!("a{i}")); + } + app.focused_agent = Some("a6".into()); + app.agent_panel_offset = 2; + cycle_agent_focus(&mut app, true); + assert_eq!(app.focused_agent.as_deref(), Some("a0")); + assert_eq!(app.agent_panel_offset, 0); +} + +#[test] +fn scroll_adjusts_on_backward_past_window() { + let mut app = make_app(); + for i in 0..7 { + spawn_agent(&app, &format!("a{i}")); + } + app.focused_agent = Some("a3".into()); + app.agent_panel_offset = 2; // window: a2..a6 + cycle_agent_focus(&mut app, false); + // a3 → a2, still in window + assert_eq!(app.focused_agent.as_deref(), Some("a2")); + assert_eq!(app.agent_panel_offset, 2); + cycle_agent_focus(&mut app, false); + // a2 → a1, now ABOVE window → offset adjusts + assert_eq!(app.focused_agent.as_deref(), Some("a1")); + assert!( + app.agent_panel_offset <= 1, + "got {}", + app.agent_panel_offset + ); +} diff --git a/crates/loopal-tui/tests/suite/enter_panel_test.rs b/crates/loopal-tui/tests/suite/enter_panel_test.rs new file mode 100644 index 00000000..740ea968 --- /dev/null +++ b/crates/loopal-tui/tests/suite/enter_panel_test.rs @@ -0,0 +1,97 @@ +/// Tests for enter_agent_panel dispatch function. +use loopal_protocol::{AgentEvent, AgentEventPayload, ControlCommand, UserQuestionResponse}; +use loopal_session::SessionController; +use loopal_tui::app::{App, FocusMode}; +use loopal_tui::dispatch_ops::enter_agent_panel; + +use tokio::sync::mpsc; + +fn make_app() -> App { + let (control_tx, _) = mpsc::channel::(16); + let (perm_tx, _) = mpsc::channel::(16); + let (question_tx, _) = mpsc::channel::(16); + let session = SessionController::new( + "test-model".into(), + "act".into(), + control_tx, + perm_tx, + question_tx, + Default::default(), + std::sync::Arc::new(tokio::sync::watch::channel(0u64).0), + ); + App::new(session, std::env::temp_dir()) +} + +fn spawn_agent(app: &App, name: &str) { + app.session.handle_event(AgentEvent::named( + name, + AgentEventPayload::SubAgentSpawned { + name: name.to_string(), + agent_id: format!("id-{name}"), + parent: Some("main".into()), + model: Some("test-model".into()), + session_id: None, + }, + )); + app.session + .handle_event(AgentEvent::named(name, AgentEventPayload::Started)); +} + +fn finish_agent(app: &App, name: &str) { + app.session + .handle_event(AgentEvent::named(name, AgentEventPayload::Finished)); +} + +#[test] +fn noop_without_agents() { + let mut app = make_app(); + enter_agent_panel(&mut app); + assert_eq!(app.focus_mode, FocusMode::Input); + assert!(app.focused_agent.is_none()); +} + +#[test] +fn sets_mode_and_focuses_first() { + let mut app = make_app(); + spawn_agent(&app, "alpha"); + spawn_agent(&app, "beta"); + enter_agent_panel(&mut app); + assert_eq!(app.focus_mode, FocusMode::AgentPanel); + assert_eq!(app.focused_agent.as_deref(), Some("alpha")); +} + +#[test] +fn keeps_existing_live_focus() { + let mut app = make_app(); + spawn_agent(&app, "alpha"); + spawn_agent(&app, "beta"); + app.focused_agent = Some("beta".into()); + enter_agent_panel(&mut app); + assert_eq!(app.focus_mode, FocusMode::AgentPanel); + assert_eq!(app.focused_agent.as_deref(), Some("beta")); +} + +#[test] +fn refocuses_when_focused_agent_is_dead() { + let mut app = make_app(); + spawn_agent(&app, "alive"); + spawn_agent(&app, "dead"); + finish_agent(&app, "dead"); + app.focused_agent = Some("dead".into()); + enter_agent_panel(&mut app); + assert_eq!(app.focus_mode, FocusMode::AgentPanel); + assert_eq!( + app.focused_agent.as_deref(), + Some("alive"), + "should re-focus to a live agent" + ); +} + +#[test] +fn noop_when_only_finished_agents() { + let mut app = make_app(); + spawn_agent(&app, "done"); + finish_agent(&app, "done"); + enter_agent_panel(&mut app); + assert_eq!(app.focus_mode, FocusMode::Input); +} diff --git a/crates/loopal-tui/tests/suite/focus_mode_test.rs b/crates/loopal-tui/tests/suite/focus_mode_test.rs new file mode 100644 index 00000000..b6654f3b --- /dev/null +++ b/crates/loopal-tui/tests/suite/focus_mode_test.rs @@ -0,0 +1,180 @@ +/// Tests for FocusMode transitions: entering/exiting AgentPanel, auto-switch on typing. +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use loopal_protocol::{AgentEvent, AgentEventPayload, ControlCommand, UserQuestionResponse}; +use loopal_session::SessionController; +use loopal_tui::app::{App, FocusMode}; +use loopal_tui::input::{InputAction, handle_key}; + +use tokio::sync::mpsc; + +fn make_app() -> App { + let (control_tx, _) = mpsc::channel::(16); + let (perm_tx, _) = mpsc::channel::(16); + let (question_tx, _) = mpsc::channel::(16); + let session = SessionController::new( + "test-model".into(), + "act".into(), + control_tx, + perm_tx, + question_tx, + Default::default(), + std::sync::Arc::new(tokio::sync::watch::channel(0u64).0), + ); + App::new(session, std::env::temp_dir()) +} + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +fn ctrl(c: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL) +} + +fn spawn_agent(app: &App, name: &str) { + app.session.handle_event(AgentEvent::named( + name, + AgentEventPayload::SubAgentSpawned { + name: name.to_string(), + agent_id: "id".into(), + parent: Some("main".into()), + model: Some("test-model".into()), + session_id: None, + }, + )); + app.session + .handle_event(AgentEvent::named(name, AgentEventPayload::Started)); +} + +// === Default state === + +#[test] +fn default_focus_mode_is_input() { + let app = make_app(); + assert_eq!(app.focus_mode, FocusMode::Input); +} + +// === Tab → enter/exit AgentPanel === + +#[test] +fn tab_returns_enter_agent_panel() { + let mut app = make_app(); + spawn_agent(&app, "worker"); + let action = handle_key(&mut app, key(KeyCode::Tab)); + assert!(matches!(action, InputAction::EnterAgentPanel)); +} + +#[test] +fn tab_without_agents_still_returns_enter_but_mode_unchanged() { + let mut app = make_app(); + let action = handle_key(&mut app, key(KeyCode::Tab)); + assert!(matches!(action, InputAction::EnterAgentPanel)); + assert_eq!(app.focus_mode, FocusMode::Input); +} + +#[test] +fn tab_exits_agent_panel() { + let mut app = make_app(); + spawn_agent(&app, "worker"); + app.focused_agent = Some("worker".into()); + app.focus_mode = FocusMode::AgentPanel; + let action = handle_key(&mut app, key(KeyCode::Tab)); + assert!(matches!(action, InputAction::ExitAgentPanel)); +} + +#[test] +fn esc_exits_agent_panel() { + let mut app = make_app(); + spawn_agent(&app, "worker"); + app.focused_agent = Some("worker".into()); + app.focus_mode = FocusMode::AgentPanel; + let action = handle_key(&mut app, key(KeyCode::Esc)); + assert!(matches!(action, InputAction::ExitAgentPanel)); +} + +#[test] +fn esc_in_agent_panel_takes_priority_over_view_exit() { + let mut app = make_app(); + spawn_agent(&app, "worker"); + app.session.enter_agent_view("worker"); + app.focused_agent = Some("worker".into()); + app.focus_mode = FocusMode::AgentPanel; + let action = handle_key(&mut app, key(KeyCode::Esc)); + assert!(matches!(action, InputAction::ExitAgentPanel)); +} + +// === Char/Backspace auto-switch from AgentPanel → Input === + +#[test] +fn char_in_agent_panel_switches_to_input_and_inserts() { + let mut app = make_app(); + spawn_agent(&app, "worker"); + app.focused_agent = Some("worker".into()); + app.focus_mode = FocusMode::AgentPanel; + let action = handle_key(&mut app, key(KeyCode::Char('x'))); + assert!(matches!(action, InputAction::None)); + assert_eq!(app.focus_mode, FocusMode::Input); + assert_eq!(app.input, "x"); +} + +#[test] +fn backspace_in_agent_panel_switches_to_input() { + let mut app = make_app(); + spawn_agent(&app, "worker"); + app.focused_agent = Some("worker".into()); + app.focus_mode = FocusMode::AgentPanel; + app.input = "hi".into(); + app.input_cursor = 2; + let action = handle_key(&mut app, key(KeyCode::Backspace)); + assert!(matches!(action, InputAction::None)); + assert_eq!(app.focus_mode, FocusMode::Input); + assert_eq!(app.input, "h"); +} + +// === Ctrl+C priority chain === + +#[test] +fn ctrl_c_clears_input_first_even_in_agent_panel() { + let mut app = make_app(); + spawn_agent(&app, "worker"); + app.focused_agent = Some("worker".into()); + app.focus_mode = FocusMode::AgentPanel; + app.input = "text".into(); + handle_key(&mut app, ctrl('c')); + assert!(app.input.is_empty()); + assert!(app.focused_agent.is_some(), "focus not cleared yet"); + assert_eq!(app.focus_mode, FocusMode::AgentPanel, "mode unchanged"); +} + +#[test] +fn ctrl_c_exits_agent_panel_and_clears_focus() { + let mut app = make_app(); + spawn_agent(&app, "worker"); + app.focused_agent = Some("worker".into()); + app.focus_mode = FocusMode::AgentPanel; + app.agent_panel_offset = 3; + handle_key(&mut app, ctrl('c')); + assert!(app.focused_agent.is_none()); + assert_eq!(app.focus_mode, FocusMode::Input); + assert_eq!(app.agent_panel_offset, 0); +} + +#[test] +fn ctrl_c_clears_focus_in_input_mode() { + let mut app = make_app(); + spawn_agent(&app, "worker"); + app.focused_agent = Some("worker".into()); + handle_key(&mut app, ctrl('c')); + assert!(app.focused_agent.is_none()); + assert_eq!(app.focus_mode, FocusMode::Input); +} + +#[test] +fn ctrl_c_noop_when_idle_no_focus_no_input() { + let mut app = make_app(); + app.session + .handle_event(AgentEvent::named("main", AgentEventPayload::AwaitingInput)); + let action = handle_key(&mut app, ctrl('c')); + assert!(matches!(action, InputAction::None)); +} diff --git a/crates/loopal-tui/tests/suite/view_switch_focus_keys_test.rs b/crates/loopal-tui/tests/suite/focus_panel_keys_test.rs similarity index 54% rename from crates/loopal-tui/tests/suite/view_switch_focus_keys_test.rs rename to crates/loopal-tui/tests/suite/focus_panel_keys_test.rs index 8ee65e1c..b03275a3 100644 --- a/crates/loopal-tui/tests/suite/view_switch_focus_keys_test.rs +++ b/crates/loopal-tui/tests/suite/focus_panel_keys_test.rs @@ -1,9 +1,9 @@ -/// Tests for focus-mode key interactions: Ctrl+C, Up/Down scroll priority, Delete terminate. +/// Tests for key behavior within each FocusMode: navigation, Ctrl+P/N, Delete, Enter. use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use loopal_protocol::{AgentEvent, AgentEventPayload, ControlCommand, UserQuestionResponse}; use loopal_session::SessionController; -use loopal_tui::app::App; +use loopal_tui::app::{App, FocusMode}; use loopal_tui::input::{InputAction, handle_key}; use tokio::sync::mpsc; @@ -47,84 +47,89 @@ fn spawn_agent(app: &App, name: &str) { .handle_event(AgentEvent::named(name, AgentEventPayload::Started)); } -// --- Ctrl+C clears focus before interrupt --- +// === Up/Down in AgentPanel === #[test] -fn ctrl_c_clears_focused_agent() { +fn down_in_agent_panel_returns_panel_down() { + let mut app = make_app(); + spawn_agent(&app, "a"); + spawn_agent(&app, "b"); + app.focused_agent = Some("a".into()); + app.focus_mode = FocusMode::AgentPanel; + let action = handle_key(&mut app, key(KeyCode::Down)); + assert!(matches!(action, InputAction::AgentPanelDown)); +} + +#[test] +fn up_in_agent_panel_returns_panel_up() { let mut app = make_app(); spawn_agent(&app, "worker"); app.focused_agent = Some("worker".into()); - let action = handle_key(&mut app, ctrl('c')); - assert!(matches!(action, InputAction::None)); - assert!(app.focused_agent.is_none(), "Ctrl+C should clear focus"); + app.focus_mode = FocusMode::AgentPanel; + let action = handle_key(&mut app, key(KeyCode::Up)); + assert!(matches!(action, InputAction::AgentPanelUp)); } #[test] -fn ctrl_c_noop_after_focus_cleared_when_idle() { +fn up_in_input_mode_ignores_agent_panel() { let mut app = make_app(); - app.session - .handle_event(AgentEvent::named("main", AgentEventPayload::AwaitingInput)); - app.focused_agent = Some("main".into()); - handle_key(&mut app, ctrl('c')); - assert!(app.focused_agent.is_none()); - let action = handle_key(&mut app, ctrl('c')); - assert!(matches!(action, InputAction::None)); + spawn_agent(&app, "worker"); + app.focused_agent = Some("worker".into()); + app.focus_mode = FocusMode::Input; + app.content_overflows = true; + let action = handle_key(&mut app, key(KeyCode::Up)); + assert!(!matches!(action, InputAction::AgentPanelUp)); } -// --- Up/Down respect scroll state --- +// === Ctrl+P/N mode-aware === #[test] -fn up_down_navigate_agents_when_no_scroll_needed() { +fn ctrl_p_in_agent_panel_navigates_up() { let mut app = make_app(); - spawn_agent(&app, "a"); - spawn_agent(&app, "b"); - app.focused_agent = Some("a".into()); - app.content_overflows = false; - app.scroll_offset = 0; - let action = handle_key(&mut app, key(KeyCode::Down)); - assert!(matches!(action, InputAction::FocusNextAgent)); + spawn_agent(&app, "worker"); + app.focused_agent = Some("worker".into()); + app.focus_mode = FocusMode::AgentPanel; + let action = handle_key(&mut app, ctrl('p')); + assert!(matches!(action, InputAction::AgentPanelUp)); } #[test] -fn up_scrolls_content_when_overflow_despite_focus() { +fn ctrl_n_in_agent_panel_navigates_down() { let mut app = make_app(); spawn_agent(&app, "worker"); app.focused_agent = Some("worker".into()); - app.content_overflows = true; - let action = handle_key(&mut app, key(KeyCode::Up)); - assert!( - !matches!(action, InputAction::FocusPrevAgent), - "Up should scroll, not cycle agents when content overflows" - ); + app.focus_mode = FocusMode::AgentPanel; + let action = handle_key(&mut app, ctrl('n')); + assert!(matches!(action, InputAction::AgentPanelDown)); } #[test] -fn up_navigates_agents_only_when_content_fits() { +fn ctrl_p_in_input_mode_does_history() { let mut app = make_app(); - spawn_agent(&app, "worker"); - app.focused_agent = Some("worker".into()); - app.content_overflows = false; - app.scroll_offset = 0; - let action = handle_key(&mut app, key(KeyCode::Up)); - assert!(matches!(action, InputAction::FocusPrevAgent)); + app.focus_mode = FocusMode::Input; + let action = handle_key(&mut app, ctrl('p')); + // Should NOT be AgentPanelUp — it's history/input navigation + assert!(!matches!(action, InputAction::AgentPanelUp)); } -// --- Delete terminates agent --- +// === Delete === #[test] -fn delete_on_focused_agent_returns_terminate() { +fn delete_in_agent_panel_terminates_agent() { let mut app = make_app(); spawn_agent(&app, "worker"); app.focused_agent = Some("worker".into()); + app.focus_mode = FocusMode::AgentPanel; let action = handle_key(&mut app, key(KeyCode::Delete)); assert!(matches!(action, InputAction::TerminateFocusedAgent)); } #[test] -fn delete_with_input_text_deletes_character_not_agent() { +fn delete_in_input_mode_deletes_char() { let mut app = make_app(); spawn_agent(&app, "worker"); app.focused_agent = Some("worker".into()); + app.focus_mode = FocusMode::Input; app.input = "hello".into(); app.input_cursor = 3; let action = handle_key(&mut app, key(KeyCode::Delete)); @@ -132,28 +137,32 @@ fn delete_with_input_text_deletes_character_not_agent() { assert_eq!(app.input, "helo"); } -// --- Down key in focus mode --- +// === Enter === #[test] -fn down_navigates_agents_when_no_scroll() { +fn enter_in_agent_panel_returns_enter_agent_view() { let mut app = make_app(); - spawn_agent(&app, "a"); - spawn_agent(&app, "b"); - app.focused_agent = Some("a".into()); - app.content_overflows = false; - app.scroll_offset = 0; - let action = handle_key(&mut app, key(KeyCode::Down)); - assert!(matches!(action, InputAction::FocusNextAgent)); + spawn_agent(&app, "researcher"); + app.focused_agent = Some("researcher".into()); + app.focus_mode = FocusMode::AgentPanel; + let action = handle_key(&mut app, key(KeyCode::Enter)); + assert!(matches!(action, InputAction::EnterAgentView)); +} + +#[test] +fn enter_in_input_mode_with_focus_also_drills_in() { + let mut app = make_app(); + spawn_agent(&app, "researcher"); + app.focused_agent = Some("researcher".into()); + app.focus_mode = FocusMode::Input; + // Empty input + focused agent → drill in (backward-compat path via editing.rs) + let action = handle_key(&mut app, key(KeyCode::Enter)); + assert!(matches!(action, InputAction::EnterAgentView)); } -// --- Delete does not terminate root --- +// === Root agent guard === #[test] -fn delete_on_root_focus_is_terminate_action_but_dispatch_guards() { - let _app = make_app(); - // Root "main" could theoretically be focused when viewing a sub-agent. - // The input layer returns TerminateFocusedAgent, but key_dispatch_ops - // guards against terminating ROOT_AGENT. Here we verify the guard exists - // by checking that ROOT_AGENT constant equals "main". +fn terminate_guards_root_agent() { assert_eq!(loopal_session::ROOT_AGENT, "main"); } diff --git a/crates/loopal-tui/tests/suite/view_switch_test.rs b/crates/loopal-tui/tests/suite/view_switch_test.rs index e5c0501c..c669570b 100644 --- a/crates/loopal-tui/tests/suite/view_switch_test.rs +++ b/crates/loopal-tui/tests/suite/view_switch_test.rs @@ -53,11 +53,10 @@ fn tab_focuses_first_live_subagent() { let mut app = make_app(); spawn_agent(&app, "researcher"); let action = handle_key(&mut app, key(KeyCode::Tab)); - assert!(matches!(action, InputAction::FocusNextAgent)); + assert!(matches!(action, InputAction::EnterAgentPanel)); // Simulate dispatch loopal_tui::input::handle_key(&mut app, key(KeyCode::Tab)); - // Can't call cycle_agent_focus directly (crate-private), but we can test via handle_key - // Tab returns FocusNextAgent, which key_dispatch handles + // Tab returns EnterAgentPanel, which key_dispatch handles } #[test]