Skip to content

Commit a360ed7

Browse files
fix(tui): replace timing-based scroll debounce with batch detection and absolute position indexing (#85) (#88)
The 30ms async timer debounce had race conditions between ArrowDebounceTimeout and event batch processing, causing scroll/history misfires. The tail-window rendering model drifted during output because window_size depended on scroll_offset, creating a feedback loop. Changes: - Delete scroll_debounce.rs (197 lines) and all timer machinery - Batch detection in tui_loop: ≥2 arrows in one batch = mouse wheel → scroll; 1 arrow = keyboard → history. No timers, no races. - ContentScroll struct encapsulates offset + prev_total + LineCache with reset()/to_bottom()/scroll_up()/scroll_down() methods - Absolute position indexing via slice() replaces tail() feedback loop; offset auto-compensated on content growth while pinned - E2E regression tests for both bugs + unit tests for compensation logic
1 parent 8b5eab1 commit a360ed7

21 files changed

Lines changed: 657 additions & 548 deletions

crates/loopal-tui/src/app/mod.rs

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,14 @@ use loopal_session::SessionController;
1414
use loopal_tool_background::BackgroundTaskStore;
1515

1616
use crate::command::CommandRegistry;
17-
use crate::input::scroll_debounce::ArrowDebounce;
18-
use crate::views::progress::LineCache;
17+
use crate::views::progress::ContentScroll;
1918

2019
/// Main application state — UI-only fields + session controller handle.
2120
pub struct App {
2221
// === UI-only state ===
2322
pub exiting: bool,
2423
pub input: String,
2524
pub input_cursor: usize,
26-
pub scroll_offset: u16,
2725
pub input_history: Vec<String>,
2826
pub history_index: Option<usize>,
2927
/// Images attached to the current input (pending submit).
@@ -50,8 +48,6 @@ pub struct App {
5048
pub focused_bg_task: Option<String>,
5149
/// Which UI region owns keyboard focus.
5250
pub focus_mode: FocusMode,
53-
/// Arrow-key debounce state for mouse-wheel vs keyboard detection.
54-
pub(crate) arrow_debounce: ArrowDebounce,
5551
/// Scroll offset for the agent panel (index of first visible agent).
5652
pub agent_panel_offset: usize,
5753

@@ -63,8 +59,8 @@ pub struct App {
6359
// === Session Controller (observable + interactive) ===
6460
pub session: SessionController,
6561

66-
// === Render optimization ===
67-
pub line_cache: LineCache,
62+
// === Content area scroll + render state ===
63+
pub content_scroll: ContentScroll,
6864
}
6965

7066
impl App {
@@ -82,7 +78,6 @@ impl App {
8278
exiting: false,
8379
input: String::new(),
8480
input_cursor: 0,
85-
scroll_offset: 0,
8681
input_history: Vec::new(),
8782
history_index: None,
8883
pending_images: Vec::new(),
@@ -97,12 +92,11 @@ impl App {
9792
focused_agent: None,
9893
focused_bg_task: None,
9994
focus_mode: FocusMode::default(),
100-
arrow_debounce: ArrowDebounce::default(),
10195
agent_panel_offset: 0,
10296
bg_store: BackgroundTaskStore::new(),
10397
bg_snapshots: Vec::new(),
10498
session,
105-
line_cache: LineCache::new(),
99+
content_scroll: ContentScroll::new(),
106100
}
107101
}
108102

@@ -123,7 +117,7 @@ impl App {
123117
}
124118
self.input_cursor = 0;
125119
self.input_scroll = 0;
126-
self.scroll_offset = 0;
120+
self.content_scroll.to_bottom();
127121
Some(UserContent {
128122
text,
129123
images,

crates/loopal-tui/src/event.rs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@ pub enum AppEvent {
1717
Paste(PasteResult),
1818
/// Tick for periodic UI refresh
1919
Tick,
20-
/// Arrow-key debounce timer expired — flush pending arrow as history
21-
ArrowDebounceTimeout,
2220
}
2321

2422
/// Merges crossterm terminal events with agent events into a single stream.

crates/loopal-tui/src/input/actions.rs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,4 @@ pub enum InputAction {
6464
QuestionCancel,
6565
/// User pressed Ctrl+V — caller should spawn async clipboard read
6666
PasteRequested,
67-
/// Arrow key deferred — start 30 ms debounce timer for scroll detection
68-
StartArrowDebounce,
6967
}

crates/loopal-tui/src/input/mod.rs

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ mod modal;
66
pub(crate) mod multiline;
77
mod navigation;
88
pub(crate) mod paste;
9-
pub(crate) mod scroll_debounce;
109
mod status_page_keys;
1110
mod sub_page;
1211
mod sub_page_rewind;
@@ -21,22 +20,18 @@ use editing::{handle_backspace, handle_ctrl_c, handle_enter};
2120
use navigation::{
2221
DEFAULT_WRAP_WIDTH, handle_down, handle_esc, handle_up, move_cursor_left, move_cursor_right,
2322
};
24-
use scroll_debounce::{ScrollDirection, handle_arrow_with_debounce, resolve_pending_arrow};
2523

2624
/// Process a key event and update the app's input state.
2725
pub fn handle_key(app: &mut App, key: KeyEvent) -> InputAction {
2826
if let Some(action) = modal::handle_modal_keys(app, &key) {
29-
scroll_debounce::discard_pending(app);
3027
return action;
3128
}
3229
if let Some(action) = handle_global_keys(app, &key) {
33-
scroll_debounce::discard_pending(app);
3430
return action;
3531
}
3632
if app.autocomplete.is_some()
3733
&& let Some(action) = handle_autocomplete_key(app, &key)
3834
{
39-
scroll_debounce::discard_pending(app);
4035
return action;
4136
}
4237

@@ -45,14 +40,6 @@ pub fn handle_key(app: &mut App, key: KeyEvent) -> InputAction {
4540
action
4641
}
4742

48-
/// Flush any pending arrow-key debounce as history navigation.
49-
///
50-
/// Called by the event loop when the 30 ms debounce timer expires and by
51-
/// tests to simulate the timeout deterministically.
52-
pub fn resolve_arrow_debounce(app: &mut App) {
53-
scroll_debounce::resolve_pending_arrow(app);
54-
}
55-
5643
/// Handle global shortcuts: Ctrl combos, Shift+Tab.
5744
fn handle_global_keys(app: &mut App, key: &KeyEvent) -> Option<InputAction> {
5845
if key.modifiers.contains(KeyModifiers::CONTROL) {
@@ -123,12 +110,7 @@ fn handle_panel_key(app: &mut App, key: &KeyEvent) -> InputAction {
123110

124111
/// Keys in Input mode: typing, navigation, submit.
125112
fn handle_input_mode_key(app: &mut App, key: &KeyEvent) -> InputAction {
126-
// Flush any pending arrow debounce on non-arrow key input.
127-
if !matches!(key.code, KeyCode::Up | KeyCode::Down) {
128-
resolve_pending_arrow(app);
129-
}
130-
// Auto-scroll to bottom on input interaction (except scroll/panel/escape/arrow keys).
131-
// Arrow keys are exempt because they may become scroll via debounce.
113+
// Auto-scroll to bottom on input interaction (except scroll/panel/escape keys).
132114
if !matches!(
133115
key.code,
134116
KeyCode::PageUp
@@ -138,7 +120,7 @@ fn handle_input_mode_key(app: &mut App, key: &KeyEvent) -> InputAction {
138120
| KeyCode::Up
139121
| KeyCode::Down
140122
) {
141-
app.scroll_offset = 0;
123+
app.content_scroll.to_bottom();
142124
}
143125
match key.code {
144126
KeyCode::Enter if key.modifiers.contains(KeyModifiers::SHIFT) => {
@@ -177,18 +159,16 @@ fn handle_input_mode_key(app: &mut App, key: &KeyEvent) -> InputAction {
177159
multiline::line_end(&app.input, app.input_cursor, DEFAULT_WRAP_WIDTH);
178160
InputAction::None
179161
}
180-
KeyCode::Up | KeyCode::Down => {
181-
let dir = ScrollDirection::from_key(key.code).unwrap();
182-
handle_arrow_with_debounce(app, dir)
183-
}
162+
KeyCode::Up => handle_up(app),
163+
KeyCode::Down => handle_down(app),
184164
KeyCode::Tab => InputAction::EnterPanel,
185165
KeyCode::Esc => handle_esc(app),
186166
KeyCode::PageUp => {
187-
app.scroll_offset = app.scroll_offset.saturating_add(10);
167+
app.content_scroll.scroll_up(10);
188168
InputAction::None
189169
}
190170
KeyCode::PageDown => {
191-
app.scroll_offset = app.scroll_offset.saturating_sub(10);
171+
app.content_scroll.scroll_down(10);
192172
InputAction::None
193173
}
194174
_ => InputAction::None,

crates/loopal-tui/src/input/navigation.rs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ pub(super) fn move_cursor_right(app: &mut App) {
3131

3232
/// Up: multiline navigation first, then history browse.
3333
pub(super) fn handle_up(app: &mut App) -> InputAction {
34-
app.scroll_offset = 0;
3534
if multiline::is_multiline(&app.input, DEFAULT_WRAP_WIDTH)
3635
&& let Some(new_cursor) =
3736
multiline::cursor_up(&app.input, app.input_cursor, DEFAULT_WRAP_WIDTH)
@@ -55,7 +54,6 @@ pub(super) fn handle_up(app: &mut App) -> InputAction {
5554

5655
/// Down: multiline navigation first, then history browse.
5756
pub(super) fn handle_down(app: &mut App) -> InputAction {
58-
app.scroll_offset = 0;
5957
if multiline::is_multiline(&app.input, DEFAULT_WRAP_WIDTH)
6058
&& let Some(new_cursor) =
6159
multiline::cursor_down(&app.input, app.input_cursor, DEFAULT_WRAP_WIDTH)

crates/loopal-tui/src/input/scroll_debounce.rs

Lines changed: 0 additions & 197 deletions
This file was deleted.

0 commit comments

Comments
 (0)