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
4 changes: 0 additions & 4 deletions crates/loopal-tui/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,6 @@ pub struct App {
pub input_scroll: usize,
/// Paste placeholder → original content map for large paste folding.
pub paste_map: HashMap<String, String>,
/// Whether the content area overflows the viewport (set by render pass).
/// Used by input handler to decide Up/Down = scroll vs history navigation.
pub content_overflows: bool,
/// Whether the topology overlay is visible (toggled by /topology).
pub show_topology: bool,
/// Agent panel cursor — Tab cycles through agents. Purely TUI concept.
Expand Down Expand Up @@ -91,7 +88,6 @@ impl App {
last_esc_time: None,
input_scroll: 0,
paste_map: HashMap::new(),
content_overflows: false,
show_topology: true,
focused_agent: None,
focused_bg_task: None,
Expand Down
46 changes: 9 additions & 37 deletions crates/loopal-tui/src/input/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,13 @@ fn handle_panel_key(app: &mut App, key: &KeyEvent) -> InputAction {

/// Keys in Input mode: typing, navigation, submit.
fn handle_input_mode_key(app: &mut App, key: &KeyEvent) -> InputAction {
// Auto-scroll to bottom on input interaction (except scroll/panel/escape keys)
if !matches!(
key.code,
KeyCode::PageUp | KeyCode::PageDown | KeyCode::Tab | KeyCode::Esc
) {
app.scroll_offset = 0;
}
match key.code {
KeyCode::Enter if key.modifiers.contains(KeyModifiers::SHIFT) => {
app.input.insert(app.input_cursor, '\n');
Expand Down Expand Up @@ -145,8 +152,8 @@ fn handle_input_mode_key(app: &mut App, key: &KeyEvent) -> InputAction {
multiline::line_end(&app.input, app.input_cursor, DEFAULT_WRAP_WIDTH);
InputAction::None
}
KeyCode::Up => handle_up_key(app),
KeyCode::Down => handle_down_key(app),
KeyCode::Up => handle_up(app),
KeyCode::Down => handle_down(app),
KeyCode::Tab => InputAction::EnterPanel,
KeyCode::Esc => handle_esc(app),
KeyCode::PageUp => {
Expand All @@ -160,38 +167,3 @@ fn handle_input_mode_key(app: &mut App, key: &KeyEvent) -> InputAction {
_ => InputAction::None,
}
}

/// Up key: multiline nav → scroll → history.
fn handle_up_key(app: &mut App) -> InputAction {
if multiline::is_multiline(&app.input, DEFAULT_WRAP_WIDTH)
&& let Some(pos) = multiline::cursor_up(&app.input, app.input_cursor, DEFAULT_WRAP_WIDTH)
{
app.input_cursor = pos;
InputAction::None
} else if app.scroll_offset > 0
|| !app.session.lock().active_conversation().agent_idle
|| app.content_overflows
{
app.scroll_offset = app.scroll_offset.saturating_add(1);
InputAction::None
} else {
handle_up(app)
}
}

/// Down key: multiline nav → scroll → history.
fn handle_down_key(app: &mut App) -> InputAction {
if multiline::is_multiline(&app.input, DEFAULT_WRAP_WIDTH)
&& let Some(pos) = multiline::cursor_down(&app.input, app.input_cursor, DEFAULT_WRAP_WIDTH)
{
app.input_cursor = pos;
InputAction::None
} else if app.scroll_offset > 0 {
app.scroll_offset = app.scroll_offset.saturating_sub(1);
InputAction::None
} else if app.content_overflows {
InputAction::None
} else {
handle_down(app)
}
}
2 changes: 2 additions & 0 deletions crates/loopal-tui/src/input/navigation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ pub(super) fn move_cursor_right(app: &mut App) {

/// Up: multiline navigation first, then history browse.
pub(super) fn handle_up(app: &mut App) -> InputAction {
app.scroll_offset = 0;
if multiline::is_multiline(&app.input, DEFAULT_WRAP_WIDTH)
&& let Some(new_cursor) =
multiline::cursor_up(&app.input, app.input_cursor, DEFAULT_WRAP_WIDTH)
Expand All @@ -56,6 +57,7 @@ pub(super) fn handle_up(app: &mut App) -> InputAction {

/// Down: multiline navigation first, then history browse.
pub(super) fn handle_down(app: &mut App) -> InputAction {
app.scroll_offset = 0;
if multiline::is_multiline(&app.input, DEFAULT_WRAP_WIDTH)
&& let Some(new_cursor) =
multiline::cursor_down(&app.input, app.input_cursor, DEFAULT_WRAP_WIDTH)
Expand Down
2 changes: 1 addition & 1 deletion crates/loopal-tui/src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ pub fn draw(f: &mut Frame, app: &mut App) {
if breadcrumb_h > 0 {
views::breadcrumb::render_breadcrumb(f, &state.active_view, layout.breadcrumb);
}
app.content_overflows = views::progress::render_progress(
views::progress::render_progress(
f,
&state,
app.scroll_offset,
Expand Down
11 changes: 2 additions & 9 deletions crates/loopal-tui/src/views/progress/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,16 @@ pub use line_cache::LineCache;
pub use message_lines::{message_to_lines, streaming_to_lines};

/// Render the content area — no border, no title, content fills the area.
///
/// Returns `true` when total content exceeds the visible viewport height.
/// The caller stores this flag so the input handler can decide whether
/// Up/Down should scroll content or navigate input history.
pub fn render_progress(
f: &mut Frame,
state: &SessionState,
scroll_offset: u16,
line_cache: &mut LineCache,
area: Rect,
) -> bool {
) {
let visible_h = area.height as usize;
if visible_h == 0 {
return false;
return;
}

let conv = state.active_conversation();
Expand All @@ -51,7 +47,6 @@ pub fn render_progress(
let cached_tail = line_cache.tail(window_size);

// Build the render lines: cached tail + thinking + streaming
let overflows = line_cache.total_lines() + streaming.len() + thinking_lines.len() > visible_h;
let mut lines = Vec::with_capacity(cached_tail.len() + thinking_lines.len() + streaming.len());
lines.extend_from_slice(cached_tail);
lines.extend(thinking_lines);
Expand All @@ -65,6 +60,4 @@ pub fn render_progress(

let paragraph = Paragraph::new(lines).scroll((scroll_row, 0));
f.render_widget(paragraph, area);

overflows
}
2 changes: 2 additions & 0 deletions crates/loopal-tui/tests/suite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ mod focus_panel_keys_test;
mod init_cmd_test;
#[path = "suite/input_edge_test.rs"]
mod input_edge_test;
#[path = "suite/input_scroll_edge_test.rs"]
mod input_scroll_edge_test;
#[path = "suite/input_scroll_test.rs"]
mod input_scroll_test;
#[path = "suite/input_test.rs"]
Expand Down
1 change: 0 additions & 1 deletion crates/loopal-tui/tests/suite/focus_panel_keys_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@ fn up_in_input_mode_ignores_agent_panel() {
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::PanelUp));
}
Expand Down
134 changes: 134 additions & 0 deletions crates/loopal-tui/tests/suite/input_scroll_edge_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/// Edge-case tests for Up/Down key scroll reset, agent-busy behavior,
/// Ctrl+P/N scroll reset, and multiline boundary fall-through to history.
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};

use loopal_protocol::ControlCommand;
use loopal_protocol::UserQuestionResponse;
use loopal_session::SessionController;
use loopal_tui::app::App;
use loopal_tui::input::handle_key;
use tokio::sync::mpsc;

fn make_app() -> App {
let (control_tx, _) = mpsc::channel::<ControlCommand>(16);
let (perm_tx, _) = mpsc::channel::<bool>(16);
let (question_tx, _) = mpsc::channel::<UserQuestionResponse>(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)
}

// --- Auto-reset scroll: per-key verification ---

#[test]
fn test_backspace_resets_scroll_offset() {
let mut app = make_app();
app.input = "ab".into();
app.input_cursor = 2;
app.scroll_offset = 4;
handle_key(&mut app, key(KeyCode::Backspace));
assert_eq!(app.scroll_offset, 0, "Backspace should reset scroll");
assert_eq!(app.input, "a");
}

#[test]
fn test_cursor_move_resets_scroll_offset() {
let mut app = make_app();
app.input = "hello".into();
app.input_cursor = 3;
app.scroll_offset = 6;
handle_key(&mut app, key(KeyCode::Left));
assert_eq!(app.scroll_offset, 0, "Left should reset scroll");
assert_eq!(app.input_cursor, 2);
}

#[test]
fn test_tab_preserves_scroll_offset() {
let mut app = make_app();
app.scroll_offset = 7;
handle_key(&mut app, key(KeyCode::Tab));
assert_eq!(app.scroll_offset, 7, "Tab should not reset scroll");
}

#[test]
fn test_esc_preserves_scroll_offset() {
let mut app = make_app();
app.scroll_offset = 3;
handle_key(&mut app, key(KeyCode::Esc));
assert_eq!(app.scroll_offset, 3, "Esc should not reset scroll");
}

// --- Agent busy: Up/Down still navigates history ---

#[test]
fn test_up_navigates_history_when_agent_busy() {
let mut app = make_app();
app.session.lock().active_conversation_mut().agent_idle = false;
app.input_history.push("prev".into());
handle_key(&mut app, key(KeyCode::Up));
assert_eq!(
app.input, "prev",
"Up should navigate history even when agent busy"
);
}

// --- Ctrl+P/N resets scroll offset ---

#[test]
fn test_ctrl_p_resets_scroll_offset() {
let mut app = make_app();
app.scroll_offset = 10;
app.input_history.push("cmd".into());
handle_key(&mut app, ctrl('p'));
assert_eq!(app.scroll_offset, 0, "Ctrl+P should reset scroll");
assert_eq!(app.input, "cmd");
}

// --- Multiline boundary: fall through to history ---

#[test]
fn test_up_at_first_line_falls_through_to_history() {
let mut app = make_app();
app.input = "line1\nline2".into();
app.input_cursor = 2; // middle of line1 (already at first visual line)
app.input_history.push("old command".into());
handle_key(&mut app, key(KeyCode::Up));
assert_eq!(
app.input, "old command",
"Up at first line should fall through to history"
);
}

#[test]
fn test_down_at_last_line_falls_through_to_history() {
let mut app = make_app();
// First enter history via Up
app.input_history.push("first".into());
app.input_history.push("second".into());
handle_key(&mut app, key(KeyCode::Up)); // "second"
handle_key(&mut app, key(KeyCode::Up)); // "first"
// Now set multiline content and cursor at last line
app.input = "line1\nline2".into();
app.input_cursor = app.input.len(); // end of line2
handle_key(&mut app, key(KeyCode::Down));
// cursor_down returns None (at last line), falls through to history forward
assert_eq!(
app.input, "second",
"Down at last line should fall through to history"
);
}
Loading
Loading