Skip to content

Commit 926a399

Browse files
committed
fix(tui): snap input follow to bottom
1 parent 2714eca commit 926a399

7 files changed

Lines changed: 128 additions & 4 deletions

File tree

crates/jcode-tui/src/tui/app/input.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -862,6 +862,14 @@ pub(super) fn insert_input_text(app: &mut App, text: &str) {
862862
return;
863863
}
864864

865+
// Composer insertions should behave the same in local and remote modes.
866+
// Unless the user explicitly enabled typing scroll lock, beginning or
867+
// continuing a draft resumes tail-follow before the input height can change.
868+
// Keeping this at the shared insertion boundary also covers paste and
869+
// Shift+Enter, rather than relying on individual key dispatchers to remember
870+
// to reconcile the transcript viewport.
871+
app.follow_chat_bottom_for_typing();
872+
865873
let at_end = app.cursor_pos == app.input.len();
866874

867875
// A habitual space typed after an auto-inserted picker separator would

crates/jcode-tui/src/tui/app/navigation.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1756,6 +1756,7 @@ impl App {
17561756
self.pending_history_anchor = None;
17571757
self.scroll_offset = 0;
17581758
self.auto_scroll_paused = false;
1759+
super::super::ui::request_tail_follow_snap();
17591760
}
17601761

17611762
/// Record an overscroll tick (downward scroll while already pinned to the

crates/jcode-tui/src/tui/app/remote.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1831,7 +1831,6 @@ fn handle_disconnected_key_internal(
18311831

18321832
if let Some(text) = text_input.or_else(|| input::text_input_for_key(code, modifiers)) {
18331833
input::handle_text_input(app, &text);
1834-
app.follow_chat_bottom_for_typing();
18351834
return Ok(());
18361835
}
18371836

crates/jcode-tui/src/tui/app/remote/key_handling.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ use crate::tui::core;
55

66
pub(in crate::tui::app) fn handle_remote_char_input(app: &mut App, c: char) {
77
input::handle_text_input(app, &c.to_string());
8-
app.follow_chat_bottom_for_typing();
98
}
109

1110
pub(in crate::tui::app) async fn send_interleave_now(
@@ -782,7 +781,6 @@ async fn handle_remote_key_internal(
782781

783782
if code == KeyCode::Enter && modifiers.intersects(KeyModifiers::SHIFT | KeyModifiers::ALT) {
784783
input::insert_input_text(app, "\n");
785-
app.follow_chat_bottom_for_typing();
786784
return Ok(());
787785
}
788786

@@ -794,7 +792,6 @@ async fn handle_remote_key_internal(
794792

795793
if let Some(text) = text_input.or_else(|| input::text_input_for_key(code, modifiers)) {
796794
input::handle_text_input(app, &text);
797-
app.follow_chat_bottom_for_typing();
798795
return Ok(());
799796
}
800797

crates/jcode-tui/src/tui/app/tests/scroll_copy_01/part_01.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,53 @@ fn test_remote_typing_resumes_bottom_follow_mode() {
665665
);
666666
}
667667

668+
#[test]
669+
fn test_local_typing_resumes_bottom_follow_mode() {
670+
let mut app = create_test_app();
671+
app.scroll_offset = 7;
672+
app.auto_scroll_paused = true;
673+
674+
app.handle_key(KeyCode::Char('x'), KeyModifiers::empty())
675+
.unwrap();
676+
677+
assert_eq!(app.input, "x");
678+
assert_eq!(app.cursor_pos, 1);
679+
assert_eq!(app.scroll_offset, 0);
680+
assert!(
681+
!app.auto_scroll_paused,
682+
"local typing should follow newest content just like remote typing"
683+
);
684+
}
685+
686+
#[test]
687+
fn test_local_typing_snaps_rendered_viewport_to_bottom_in_one_frame() {
688+
let _lock = scroll_render_test_lock();
689+
crate::tui::ui::clear_test_render_state_for_tests();
690+
691+
let (mut app, mut terminal) = create_scroll_test_app(50, 12, 0, 32);
692+
let _ = render_and_snap(&app, &mut terminal);
693+
let max_scroll = crate::tui::ui::last_max_scroll();
694+
assert!(max_scroll > 8, "expected a long transcript, got {max_scroll}");
695+
696+
app.auto_scroll_paused = true;
697+
app.scroll_offset = max_scroll - 8;
698+
let _ = render_and_snap(&app, &mut terminal);
699+
assert_eq!(
700+
crate::tui::ui::last_resolved_chat_scroll(),
701+
max_scroll - 8
702+
);
703+
704+
app.handle_key(KeyCode::Char('x'), KeyModifiers::empty())
705+
.unwrap();
706+
let _ = render_and_snap(&app, &mut terminal);
707+
708+
assert_eq!(
709+
crate::tui::ui::last_resolved_chat_scroll(),
710+
crate::tui::ui::last_max_scroll(),
711+
"typing should explicitly snap to the exact transcript tail, not use content catch-up"
712+
);
713+
}
714+
668715
#[test]
669716
fn test_remote_shift_slash_preserves_layout_translated_slash() {
670717
let mut app = create_test_app();
@@ -851,6 +898,26 @@ fn test_remote_typing_scroll_lock_preserves_scroll_position() {
851898
);
852899
}
853900

901+
#[test]
902+
fn test_local_typing_scroll_lock_preserves_scroll_position() {
903+
let mut app = create_test_app();
904+
app.scroll_offset = 7;
905+
app.auto_scroll_paused = true;
906+
907+
app.handle_key(KeyCode::Char('s'), KeyModifiers::ALT)
908+
.unwrap();
909+
app.handle_key(KeyCode::Char('x'), KeyModifiers::empty())
910+
.unwrap();
911+
912+
assert_eq!(app.input, "x");
913+
assert_eq!(app.cursor_pos, 1);
914+
assert_eq!(app.scroll_offset, 7);
915+
assert!(
916+
app.auto_scroll_paused,
917+
"typing scroll lock should preserve local paused scroll state"
918+
);
919+
}
920+
854921
#[test]
855922
fn test_remote_typing_scroll_lock_can_be_toggled_back_off() {
856923
let mut app = create_test_app();

crates/jcode-tui/src/tui/ui.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,13 @@ static LAST_RESOLVED_CHAT_SCROLL: AtomicUsize = AtomicUsize::new(0);
200200
#[cfg(not(test))]
201201
static TAIL_CATCHUP_ACTIVE: std::sync::atomic::AtomicBool =
202202
std::sync::atomic::AtomicBool::new(false);
203+
/// Set by explicit user actions that resume bottom-follow (typing, End,
204+
/// submitting a prompt). The next renderer pass consumes this request and snaps
205+
/// directly to the tail instead of mistaking the large offset change for a
206+
/// newly-appended content block that should use catch-up animation.
207+
#[cfg(not(test))]
208+
static TAIL_FOLLOW_SNAP_PENDING: std::sync::atomic::AtomicBool =
209+
std::sync::atomic::AtomicBool::new(false);
203210
/// Wrapped line indices where each user prompt starts (updated each render frame).
204211
/// Used by prompt-jump keybindings (Ctrl+5..9, Ctrl+[/]) for accurate positioning.
205212
#[cfg(not(test))]
@@ -215,6 +222,7 @@ thread_local! {
215222
static TEST_LAST_TOTAL_WRAPPED_LINES: Cell<usize> = const { Cell::new(0) };
216223
static TEST_LAST_RESOLVED_CHAT_SCROLL: Cell<usize> = const { Cell::new(0) };
217224
static TEST_TAIL_CATCHUP_ACTIVE: Cell<bool> = const { Cell::new(false) };
225+
static TEST_TAIL_FOLLOW_SNAP_PENDING: Cell<bool> = const { Cell::new(false) };
218226
static TEST_LAST_USER_PROMPT_POSITIONS: RefCell<Vec<usize>> = const { RefCell::new(Vec::new()) };
219227
static TEST_LAST_LAYOUT: RefCell<Option<LayoutSnapshot>> = const { RefCell::new(None) };
220228
static TEST_LAST_STATUS_AREA: RefCell<Option<Rect>> = const { RefCell::new(None) };
@@ -463,6 +471,34 @@ pub(crate) fn set_tail_catchup_active(active: bool) {
463471
}
464472
}
465473

474+
/// Request that the next tail-follow render land at the exact bottom.
475+
///
476+
/// This is reserved for explicit navigation or composer actions. Automatic
477+
/// transcript growth does not set it, so large committed blocks still use the
478+
/// bounded catch-up animation.
479+
pub(crate) fn request_tail_follow_snap() {
480+
#[cfg(test)]
481+
{
482+
TEST_TAIL_FOLLOW_SNAP_PENDING.with(|cell| cell.set(true));
483+
return;
484+
}
485+
#[cfg(not(test))]
486+
{
487+
TAIL_FOLLOW_SNAP_PENDING.store(true, Ordering::Relaxed);
488+
}
489+
}
490+
491+
pub(crate) fn take_tail_follow_snap_request() -> bool {
492+
#[cfg(test)]
493+
{
494+
return TEST_TAIL_FOLLOW_SNAP_PENDING.with(|cell| cell.replace(false));
495+
}
496+
#[cfg(not(test))]
497+
{
498+
TAIL_FOLLOW_SNAP_PENDING.swap(false, Ordering::Relaxed)
499+
}
500+
}
501+
466502
pub(super) fn hash_text_for_cache(text: &str) -> u64 {
467503
let mut hasher = DefaultHasher::new();
468504
text.hash(&mut hasher);
@@ -1365,6 +1401,7 @@ pub(crate) fn clear_test_render_state_for_tests() {
13651401
set_last_diff_pane_max_scroll(0);
13661402
set_last_total_wrapped_lines(0);
13671403
set_last_resolved_chat_scroll(0);
1404+
TEST_TAIL_FOLLOW_SNAP_PENDING.with(|cell| cell.set(false));
13681405
update_user_prompt_positions(&[]);
13691406
// Flicker events recorded by sibling tests add a "⚠ flicker detected"
13701407
// notification line to subsequent renders, shifting every layout-sensitive

crates/jcode-tui/src/tui/ui_viewport.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ const TAIL_CATCHUP_MAX_STEP: usize = 3;
5858
/// frame's resolved position by a bounded step so the new content slides into
5959
/// view. Disabled on tiers without decorative animations.
6060
fn resolve_tail_follow_scroll(max_scroll: usize, viewport_height: usize) -> usize {
61+
if super::take_tail_follow_snap_request() {
62+
super::set_tail_catchup_active(false);
63+
return max_scroll;
64+
}
6165
if !crate::perf::tui_policy().enable_decorative_animations {
6266
super::set_tail_catchup_active(false);
6367
return max_scroll;
@@ -1323,6 +1327,17 @@ mod tests {
13231327
assert!(!crate::tui::ui::tail_catchup_active());
13241328
}
13251329

1330+
#[test]
1331+
fn explicit_tail_follow_request_skips_content_catch_up_animation() {
1332+
crate::tui::ui::set_last_resolved_chat_scroll(40);
1333+
crate::tui::ui::request_tail_follow_snap();
1334+
1335+
let scroll = super::resolve_tail_follow_scroll(80, 30);
1336+
1337+
assert_eq!(scroll, 80);
1338+
assert!(!crate::tui::ui::tail_catchup_active());
1339+
}
1340+
13261341
#[test]
13271342
fn tail_follow_caps_lag_to_one_viewport() {
13281343
// A huge append (way beyond a screen) starts at most one viewport

0 commit comments

Comments
 (0)