Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions crates/loopal-tui/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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,
Expand Down Expand Up @@ -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(),
}
Expand Down
13 changes: 13 additions & 0 deletions crates/loopal-tui/src/app/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
14 changes: 8 additions & 6 deletions crates/loopal-tui/src/input/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,14 @@ pub enum InputAction {
RunCommand(String, Option<String>),
/// 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)
Expand Down
9 changes: 7 additions & 2 deletions crates/loopal-tui/src/input/editing.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::app::App;
use crate::app::{App, FocusMode};

use super::InputAction;
use super::commands::try_execute_slash_command;
Expand Down Expand Up @@ -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();
Expand All @@ -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
Expand Down
53 changes: 40 additions & 13 deletions crates/loopal-tui/src/input/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -45,6 +45,13 @@ fn handle_global_keys(app: &mut App, key: &KeyEvent) -> Option<InputAction> {
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)),
_ => {}
Expand All @@ -62,20 +69,40 @@ fn handle_global_keys(app: &mut App, key: &KeyEvent) -> Option<InputAction> {
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');
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion crates/loopal-tui/src/input/navigation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
17 changes: 11 additions & 6 deletions crates/loopal-tui/src/key_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 => {
Expand All @@ -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
Expand Down
67 changes: 65 additions & 2 deletions crates/loopal-tui/src/key_dispatch_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<String> = 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<String> = state
Expand All @@ -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 {
Expand All @@ -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.
Expand All @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions crates/loopal-tui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
}
3 changes: 2 additions & 1 deletion crates/loopal-tui/src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading