aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-03-31 20:49:05 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-03-31 20:49:05 +0300
commit41e009f9b7b46d335d41b365e41f9ad57d571ecb (patch)
tree42b3be90c336ad6d4487e9f86abc56ebb40ba210 /src
parentf826fb78dcac89b4ba23e399ac5d141459c14ae9 (diff)
fix alt-screen scrollback handling
Diffstat (limited to 'src')
-rw-r--r--src/app/input.rs66
-rw-r--r--src/app/state.rs1
-rw-r--r--src/pane.rs56
-rw-r--r--src/ui.rs271
4 files changed, 324 insertions, 70 deletions
diff --git a/src/app/input.rs b/src/app/input.rs
index 9827d4d2..05667ab9 100644
--- a/src/app/input.rs
+++ b/src/app/input.rs
@@ -10,6 +10,12 @@ use tracing::warn;
use crate::layout::{NavDirection, PaneInfo, SplitBorder};
use crate::selection::Selection;
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum ScrollbarClickTarget {
+ Thumb { grab_row_offset: u16 },
+ Track { offset_from_bottom: usize },
+}
+
use super::state::{
key_matches, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget, Mode,
};
@@ -926,14 +932,23 @@ impl AppState {
return None;
}
- if let Some((pane_id, offset_from_bottom)) =
+ if let Some((pane_id, target)) =
self.scrollbar_target_at(mouse.column, mouse.row)
{
self.focus_pane(pane_id);
- self.set_pane_scroll_offset(pane_id, offset_from_bottom);
- self.drag = Some(DragState {
- target: DragTarget::PaneScrollbar { pane_id },
- });
+ match target {
+ ScrollbarClickTarget::Thumb { grab_row_offset } => {
+ self.drag = Some(DragState {
+ target: DragTarget::PaneScrollbar {
+ pane_id,
+ grab_row_offset,
+ },
+ });
+ }
+ ScrollbarClickTarget::Track { offset_from_bottom } => {
+ self.set_pane_scroll_offset(pane_id, offset_from_bottom);
+ }
+ }
if self.mode != Mode::Terminal {
self.mode = Mode::Terminal;
}
@@ -1022,10 +1037,15 @@ impl AppState {
ws.layout.set_ratio_at(&path, ratio);
}
}
- DragTarget::PaneScrollbar { pane_id } => {
- if let Some(offset_from_bottom) =
- self.scrollbar_offset_for_pane_row(*pane_id, mouse.row)
- {
+ DragTarget::PaneScrollbar {
+ pane_id,
+ grab_row_offset,
+ } => {
+ if let Some(offset_from_bottom) = self.scrollbar_offset_for_pane_row(
+ *pane_id,
+ mouse.row,
+ *grab_row_offset,
+ ) {
self.set_pane_scroll_offset(*pane_id, offset_from_bottom);
}
}
@@ -1331,7 +1351,11 @@ impl AppState {
}
}
- fn scrollbar_target_at(&self, col: u16, row: u16) -> Option<(crate::layout::PaneId, usize)> {
+ fn scrollbar_target_at(
+ &self,
+ col: u16,
+ row: u16,
+ ) -> Option<(crate::layout::PaneId, ScrollbarClickTarget)> {
let ws = self.active.and_then(|i| self.workspaces.get(i))?;
let info = self.view.pane_infos.iter().find(|info| {
crate::ui::pane_scrollbar_rect(info).is_some_and(|track| {
@@ -1347,16 +1371,23 @@ impl AppState {
return None;
}
let track = crate::ui::pane_scrollbar_rect(info)?;
- Some((
- info.id,
- crate::ui::scrollbar_offset_from_row(metrics, track, row),
- ))
+ if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) {
+ Some((info.id, ScrollbarClickTarget::Thumb { grab_row_offset }))
+ } else {
+ Some((
+ info.id,
+ ScrollbarClickTarget::Track {
+ offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row),
+ },
+ ))
+ }
}
fn scrollbar_offset_for_pane_row(
&self,
pane_id: crate::layout::PaneId,
row: u16,
+ grab_row_offset: u16,
) -> Option<usize> {
let ws = self.active.and_then(|i| self.workspaces.get(i))?;
let info = self
@@ -1370,7 +1401,12 @@ impl AppState {
if metrics.max_offset_from_bottom == 0 {
return None;
}
- Some(crate::ui::scrollbar_offset_from_row(metrics, track, row))
+ Some(crate::ui::scrollbar_offset_from_drag_row(
+ metrics,
+ track,
+ row,
+ grab_row_offset,
+ ))
}
}
diff --git a/src/app/state.rs b/src/app/state.rs
index caedb419..cd45bbb6 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -397,6 +397,7 @@ pub(crate) enum DragTarget {
},
PaneScrollbar {
pane_id: crate::layout::PaneId,
+ grab_row_offset: u16,
},
SidebarDivider,
}
diff --git a/src/pane.rs b/src/pane.rs
index c6b8e175..f25c6a42 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -100,6 +100,12 @@ pub struct ScrollMetrics {
pub viewport_rows: usize,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ScrollState {
+ pub metrics: ScrollMetrics,
+ pub alternate_screen: bool,
+}
+
impl Drop for PaneRuntime {
fn drop(&mut self) {
// Abort detection task immediately.
@@ -124,7 +130,7 @@ fn max_scrollback(parser: &mut vt100::Parser<PtyResponses>) -> usize {
max_scrollback
}
-fn recent_text_from_parser(parser: &mut vt100::Parser<PtyResponses>, lines: usize) -> String {
+fn parser_rows(parser: &mut vt100::Parser<PtyResponses>, lines: usize) -> Vec<String> {
let max_scrollback = max_scrollback(parser);
let screen = parser.screen_mut();
let original_scrollback = screen.scrollback();
@@ -150,7 +156,10 @@ fn recent_text_from_parser(parser: &mut vt100::Parser<PtyResponses>, lines: usiz
screen.set_scrollback(original_scrollback);
rows.extend(visible_rows);
trim_trailing_blank_rows(&mut rows);
+ rows
+}
+fn recent_text_from_rows(rows: &[String], lines: usize) -> String {
let start = rows.len().saturating_sub(lines);
let text = rows[start..].join("\n");
if text.is_empty() {
@@ -160,6 +169,11 @@ fn recent_text_from_parser(parser: &mut vt100::Parser<PtyResponses>, lines: usiz
}
}
+fn recent_text_from_parser(parser: &mut vt100::Parser<PtyResponses>, lines: usize) -> String {
+ let rows = parser_rows(parser, lines);
+ recent_text_from_rows(&rows, lines)
+}
+
fn wait_for_processes_to_exit(pids: &[u32], timeout: std::time::Duration) -> bool {
let deadline = std::time::Instant::now() + timeout;
loop {
@@ -324,6 +338,7 @@ impl PaneRuntime {
Ok(n) => {
if let Ok(mut p) = parser.write() {
p.process(&buf[..n]);
+
// Snapshot live screen content for detection.
// Always reads at scrollback 0 (current view),
// without touching the user's scroll position.
@@ -557,20 +572,27 @@ impl PaneRuntime {
}
}
- pub fn scroll_metrics(&self) -> Option<ScrollMetrics> {
+ pub fn scroll_state(&self) -> Option<ScrollState> {
let Ok(mut parser) = self.parser.write() else {
return None;
};
let max_offset_from_bottom = max_scrollback(&mut parser);
let screen = parser.screen();
let (viewport_rows, _) = screen.size();
- Some(ScrollMetrics {
- offset_from_bottom: screen.scrollback(),
- max_offset_from_bottom,
- viewport_rows: viewport_rows as usize,
+ Some(ScrollState {
+ metrics: ScrollMetrics {
+ offset_from_bottom: screen.scrollback(),
+ max_offset_from_bottom,
+ viewport_rows: viewport_rows as usize,
+ },
+ alternate_screen: screen.alternate_screen(),
})
}
+ pub fn scroll_metrics(&self) -> Option<ScrollMetrics> {
+ self.scroll_state().map(|state| state.metrics)
+ }
+
pub fn visible_text(&self) -> String {
let Ok(content) = self.screen_content.read() else {
return String::new();
@@ -637,6 +659,28 @@ mod tests {
}
#[test]
+ fn alternate_screen_accumulates_its_own_scrollback() {
+ let responses = PtyResponses::new();
+ let mut parser = vt100::Parser::new_with_callbacks(2, 10, 100, responses);
+ parser.process(b"\x1b[?1049h1\r\n2\r\n3");
+
+ assert!(parser.screen().alternate_screen());
+ assert_eq!(max_scrollback(&mut parser), 1);
+ assert_eq!(recent_text_from_parser(&mut parser, 3), "1\n2\n3\n");
+ }
+
+ #[test]
+ fn top_anchored_scroll_regions_feed_scrollback() {
+ let responses = PtyResponses::new();
+ let mut parser = vt100::Parser::new_with_callbacks(5, 10, 100, responses);
+ parser.process(b"\x1b[?1049h1\r\n2\r\n3\r\n4\r\n5");
+ parser.process(b"\x1b[1;3r\x1b[3;1H\r\nX");
+
+ assert!(parser.screen().alternate_screen());
+ assert_eq!(max_scrollback(&mut parser), 1);
+ }
+
+ #[test]
fn claude_working_is_sticky_for_short_gap() {
let now = std::time::Instant::now();
let mut last_working = None;
diff --git a/src/ui.rs b/src/ui.rs
index 64564487..df9216a1 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -1,11 +1,8 @@
use ratatui::{
- layout::{Constraint, Layout, Margin, Rect},
+ layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
- widgets::{
- Block, Borders, Clear, List, ListItem, ListState, Paragraph, Scrollbar,
- ScrollbarOrientation, ScrollbarState, Tabs,
- },
+ widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Tabs},
Frame,
};
use tui_term::widget::PseudoTerminal;
@@ -124,10 +121,14 @@ fn compute_pane_infos(app: &AppState, area: Rect) -> Vec<PaneInfo> {
let mut inner_rect = area;
let mut scrollbar_rect = None;
if let Some(rt) = ws.runtimes.get(&focused_id) {
- if rt
- .scroll_metrics()
- .is_some_and(|metrics| metrics.max_offset_from_bottom > 0 && area.width > 1)
- {
+ let detected_agent = ws
+ .panes
+ .get(&focused_id)
+ .and_then(|pane| pane.detected_agent);
+ if rt.scroll_state().is_some_and(|state| {
+ should_show_scrollbar(state.metrics, state.alternate_screen, detected_agent)
+ && area.width > 1
+ }) {
inner_rect.width = inner_rect.width.saturating_sub(1);
scrollbar_rect = Some(Rect::new(
area.x + area.width.saturating_sub(1),
@@ -167,10 +168,11 @@ fn compute_pane_infos(app: &AppState, area: Rect) -> Vec<PaneInfo> {
let mut inner_rect = pane_inner;
let mut scrollbar_rect = None;
if let Some(rt) = ws.runtimes.get(&info.id) {
- if rt
- .scroll_metrics()
- .is_some_and(|metrics| metrics.max_offset_from_bottom > 0 && pane_inner.width > 1)
- {
+ let detected_agent = ws.panes.get(&info.id).and_then(|pane| pane.detected_agent);
+ if rt.scroll_state().is_some_and(|state| {
+ should_show_scrollbar(state.metrics, state.alternate_screen, detected_agent)
+ && pane_inner.width > 1
+ }) {
inner_rect.width = inner_rect.width.saturating_sub(1);
scrollbar_rect = Some(Rect::new(
pane_inner.x + pane_inner.width.saturating_sub(1),
@@ -633,7 +635,9 @@ fn render_panes(app: &AppState, frame: &mut Frame, area: Rect) {
// Draw terminal content
if let Ok(parser) = rt.parser.read() {
- let pt = PseudoTerminal::new(parser.screen());
+ let show_cursor = parser.screen().scrollback() == 0;
+ let pt = PseudoTerminal::new(parser.screen())
+ .cursor(tui_term::widget::Cursor::default().visibility(show_cursor));
frame.render_widget(pt, info.inner_rect);
}
render_pane_scrollbar(app, frame, info, rt);
@@ -695,25 +699,125 @@ pub(crate) fn pane_scrollbar_rect(info: &PaneInfo) -> Option<Rect> {
info.scrollbar_rect
}
-pub(crate) fn scrollbar_offset_from_row(
+fn should_show_scrollbar(
+ metrics: crate::pane::ScrollMetrics,
+ alternate_screen: bool,
+ detected_agent: Option<crate::detect::Agent>,
+) -> bool {
+ if metrics.max_offset_from_bottom == 0 {
+ return false;
+ }
+
+ if !alternate_screen {
+ return true;
+ }
+
+ metrics.offset_from_bottom > 0 || detected_agent.is_some()
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) struct ScrollbarThumb {
+ pub top: u16,
+ pub len: u16,
+}
+
+pub(crate) fn scrollbar_thumb(
+ metrics: crate::pane::ScrollMetrics,
+ track: Rect,
+) -> Option<ScrollbarThumb> {
+ if metrics.max_offset_from_bottom == 0 || track.height == 0 {
+ return None;
+ }
+
+ let track_height = track.height as usize;
+ let total_rows = metrics.max_offset_from_bottom + metrics.viewport_rows;
+ if total_rows == 0 {
+ return None;
+ }
+
+ let thumb_len = ((metrics.viewport_rows * track_height) as f32 / total_rows as f32)
+ .round()
+ .max(1.0)
+ .min(track_height as f32) as usize;
+ let max_thumb_top = track_height.saturating_sub(thumb_len);
+ let scrolled_from_top = metrics
+ .max_offset_from_bottom
+ .saturating_sub(metrics.offset_from_bottom);
+ let thumb_top = if max_thumb_top == 0 || metrics.max_offset_from_bottom == 0 {
+ 0
+ } else {
+ ((scrolled_from_top * max_thumb_top) as f32 / metrics.max_offset_from_bottom as f32)
+ .round()
+ .clamp(0.0, max_thumb_top as f32) as usize
+ };
+
+ Some(ScrollbarThumb {
+ top: track.y + thumb_top as u16,
+ len: thumb_len as u16,
+ })
+}
+
+pub(crate) fn scrollbar_thumb_grab_offset(
metrics: crate::pane::ScrollMetrics,
track: Rect,
row: u16,
+) -> Option<u16> {
+ let thumb = scrollbar_thumb(metrics, track)?;
+ (row >= thumb.top && row < thumb.top + thumb.len).then_some(row - thumb.top)
+}
+
+fn scrollbar_offset_from_thumb_top(
+ metrics: crate::pane::ScrollMetrics,
+ track: Rect,
+ thumb_top: usize,
) -> usize {
- if metrics.max_offset_from_bottom == 0 || track.height <= 1 {
- return metrics.max_offset_from_bottom;
+ if metrics.max_offset_from_bottom == 0 {
+ return 0;
}
- let clamped_row = row.clamp(track.y, track.y + track.height.saturating_sub(1));
- let row_offset = clamped_row.saturating_sub(track.y) as f32;
- let max_row = track.height.saturating_sub(1) as f32;
- let ratio = if max_row == 0.0 {
- 0.0
- } else {
- row_offset / max_row
+ let thumb_len = scrollbar_thumb(metrics, track)
+ .map(|thumb| thumb.len as usize)
+ .unwrap_or(1);
+ let max_thumb_top = track.height as usize - thumb_len.min(track.height as usize);
+ if max_thumb_top == 0 {
+ return 0;
+ }
+
+ let desired_top = thumb_top.min(max_thumb_top);
+ let scrolled_from_top = ((desired_top * metrics.max_offset_from_bottom) as f32
+ / max_thumb_top as f32)
+ .round() as usize;
+ metrics
+ .max_offset_from_bottom
+ .saturating_sub(scrolled_from_top)
+}
+
+pub(crate) fn scrollbar_offset_from_row(
+ metrics: crate::pane::ScrollMetrics,
+ track: Rect,
+ row: u16,
+) -> usize {
+ let thumb = match scrollbar_thumb(metrics, track) {
+ Some(thumb) => thumb,
+ None => return 0,
};
- let top_position = (ratio * metrics.max_offset_from_bottom as f32).round() as usize;
- metrics.max_offset_from_bottom.saturating_sub(top_position)
+ let clamped_row = row.clamp(track.y, track.y + track.height.saturating_sub(1));
+ let row_offset = clamped_row.saturating_sub(track.y) as usize;
+ let thumb_center = (thumb.len as usize) / 2;
+ let desired_top = row_offset.saturating_sub(thumb_center);
+ scrollbar_offset_from_thumb_top(metrics, track, desired_top)
+}
+
+pub(crate) fn scrollbar_offset_from_drag_row(
+ metrics: crate::pane::ScrollMetrics,
+ track: Rect,
+ row: u16,
+ grab_row_offset: u16,
+) -> usize {
+ let clamped_row = row.clamp(track.y, track.y + track.height.saturating_sub(1));
+ let row_offset = clamped_row.saturating_sub(track.y) as usize;
+ let desired_top = row_offset.saturating_sub(grab_row_offset as usize);
+ scrollbar_offset_from_thumb_top(metrics, track, desired_top)
}
fn render_pane_scrollbar(
@@ -732,34 +836,27 @@ fn render_pane_scrollbar(
return;
}
- let content_length = metrics.max_offset_from_bottom + metrics.viewport_rows;
- let position = metrics
- .max_offset_from_bottom
- .saturating_sub(metrics.offset_from_bottom);
- let mut scrollbar_state = ScrollbarState::new(content_length)
- .position(position)
- .viewport_content_length(metrics.viewport_rows);
+ let Some(thumb) = scrollbar_thumb(metrics, track) else {
+ return;
+ };
+
let (track_color, thumb_color, thumb_symbol) = if info.is_focused {
(app.palette.overlay0, app.palette.overlay1, "▐")
} else {
(app.palette.surface_dim, app.palette.overlay0, "▕")
};
- let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
- .begin_symbol(None)
- .end_symbol(None)
- .track_symbol(Some("▕"))
- .track_style(Style::default().fg(track_color))
- .thumb_symbol(thumb_symbol)
- .thumb_style(Style::default().fg(thumb_color));
-
- frame.render_stateful_widget(
- scrollbar,
- track.inner(Margin {
- vertical: 0,
- horizontal: 0,
- }),
- &mut scrollbar_state,
- );
+
+ let buf = frame.buffer_mut();
+ for y in track.y..track.y + track.height {
+ let cell = &mut buf[(track.x, y)];
+ cell.set_symbol("▕");
+ cell.set_style(Style::default().fg(track_color));
+ }
+ for y in thumb.top..thumb.top + thumb.len {
+ let cell = &mut buf[(track.x, y)];
+ cell.set_symbol(thumb_symbol);
+ cell.set_style(Style::default().fg(thumb_color));
+ }
}
fn render_selection_highlight(
@@ -1748,6 +1845,67 @@ mod tests {
}
#[test]
+ fn alternate_screen_scrollbar_stays_hidden_for_unidentified_live_bottom() {
+ let metrics = crate::pane::ScrollMetrics {
+ offset_from_bottom: 0,
+ max_offset_from_bottom: 20,
+ viewport_rows: 5,
+ };
+
+ assert!(!should_show_scrollbar(metrics, true, None));
+ }
+
+ #[test]
+ fn alternate_screen_scrollbar_shows_for_agents_at_live_bottom() {
+ let metrics = crate::pane::ScrollMetrics {
+ offset_from_bottom: 0,
+ max_offset_from_bottom: 20,
+ viewport_rows: 5,
+ };
+
+ assert!(should_show_scrollbar(
+ metrics,
+ true,
+ Some(crate::detect::Agent::Codex)
+ ));
+ }
+
+ #[test]
+ fn alternate_screen_scrollbar_shows_when_user_scrolled_up() {
+ let metrics = crate::pane::ScrollMetrics {
+ offset_from_bottom: 3,
+ max_offset_from_bottom: 20,
+ viewport_rows: 5,
+ };
+
+ assert!(should_show_scrollbar(metrics, true, None));
+ }
+
+ #[test]
+ fn normal_screen_scrollbar_shows_with_scrollback() {
+ let metrics = crate::pane::ScrollMetrics {
+ offset_from_bottom: 0,
+ max_offset_from_bottom: 20,
+ viewport_rows: 5,
+ };
+
+ assert!(should_show_scrollbar(metrics, false, None));
+ }
+
+ #[test]
+ fn scrollbar_thumb_reaches_bottom_when_scrolled_to_bottom() {
+ let metrics = crate::pane::ScrollMetrics {
+ offset_from_bottom: 0,
+ max_offset_from_bottom: 20,
+ viewport_rows: 5,
+ };
+ let track = Rect::new(9, 4, 1, 5);
+
+ let thumb = scrollbar_thumb(metrics, track).expect("thumb");
+ assert_eq!(thumb.top + thumb.len, track.y + track.height);
+ }
+
+ #[test]
fn scrollbar_offset_mapping_hits_top_middle_and_bottom() {
let metrics = crate::pane::ScrollMetrics {
offset_from_bottom: 0,
@@ -1760,4 +1918,19 @@ mod tests {
assert_eq!(scrollbar_offset_from_row(metrics, track, 6), 10);
assert_eq!(scrollbar_offset_from_row(metrics, track, 8), 0);
}
+
+ #[test]
+ fn dragging_from_current_thumb_row_preserves_offset() {
+ let metrics = crate::pane::ScrollMetrics {
+ offset_from_bottom: 7,
+ max_offset_from_bottom: 20,
+ viewport_rows: 5,
+ };
+ let track = Rect::new(9, 4, 1, 8);
+ let thumb = scrollbar_thumb(metrics, track).expect("thumb");
+ let row = thumb.top + thumb.len / 2;
+ let grab = scrollbar_thumb_grab_offset(metrics, track, row).expect("grab");
+
+ assert_eq!(scrollbar_offset_from_drag_row(metrics, track, row, grab), 7);
+ }
}