Skip to content

Commit 8c1321e

Browse files
committed
preserve scrollback when TUI enters alternate screen
When a TUI app (vim, claude, etc.) enters alternate screen mode, we now save the main screen content. When getting scrollback while in alternate screen, we combine both buffers with a separator line. This means Alt-e in Claude Code now shows: 1. Pre-Claude shell history 2. "--- alternate screen ---" 3. Current Claude Code screen
1 parent d9036b5 commit 8c1321e

1 file changed

Lines changed: 90 additions & 1 deletion

File tree

crates/tap-server/src/scrollback.rs

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,26 @@ const DEFAULT_TERMINAL_ROWS: u16 = 24;
33
const DEFAULT_TERMINAL_COLS: u16 = 80;
44

55
/// A scrollback buffer backed by vt100 terminal emulator.
6+
///
7+
/// Handles alternate screen mode properly - when a TUI app enters alternate
8+
/// screen, we preserve the main screen scrollback and combine it with the
9+
/// alternate screen content when reading.
610
pub struct ScrollbackBuffer {
711
parser: Option<vt100::Parser>,
812
max_lines: usize,
13+
/// Saved main screen content when entering alternate screen mode
14+
saved_main_content: Option<String>,
15+
/// Track previous alternate screen state to detect transitions
16+
was_alternate: bool,
917
}
1018

1119
impl ScrollbackBuffer {
1220
pub const fn new() -> Self {
1321
Self {
1422
parser: None,
1523
max_lines: DEFAULT_SCROLLBACK_LINES,
24+
saved_main_content: None,
25+
was_alternate: false,
1626
}
1727
}
1828

@@ -23,7 +33,38 @@ impl ScrollbackBuffer {
2333
}
2434

2535
pub fn push(&mut self, data: &[u8]) {
36+
// Check if we need to save main screen before processing
37+
// (in case this data switches to alternate screen)
38+
let was_alternate_before = self.parser.as_ref().is_some_and(|p| p.screen().alternate_screen());
39+
2640
self.ensure_parser().process(data);
41+
42+
let is_alternate_now = self.parser.as_ref().is_some_and(|p| p.screen().alternate_screen());
43+
44+
// Detect transition from main -> alternate screen
45+
if !was_alternate_before && is_alternate_now {
46+
// We just entered alternate screen - but the content has already been
47+
// replaced. We need to save BEFORE the switch happens.
48+
// Unfortunately vt100 doesn't give us a hook for this, so we need to
49+
// save content continuously while in main screen mode.
50+
tracing::debug!("entered alternate screen mode");
51+
}
52+
53+
// Detect transition from alternate -> main screen
54+
if was_alternate_before && !is_alternate_now {
55+
// We just exited alternate screen - clear saved content
56+
self.saved_main_content = None;
57+
tracing::debug!("exited alternate screen mode");
58+
}
59+
60+
// If we're in main screen mode, keep saving content for potential alternate switch
61+
if !is_alternate_now {
62+
if let Some(parser) = &self.parser {
63+
self.saved_main_content = Some(parser.screen().contents());
64+
}
65+
}
66+
67+
self.was_alternate = is_alternate_now;
2768
}
2869

2970
pub fn get_lines(&self, count: Option<usize>) -> String {
@@ -32,7 +73,20 @@ impl ScrollbackBuffer {
3273
};
3374

3475
let screen = parser.screen();
35-
let all_contents = screen.contents();
76+
let is_alternate = screen.alternate_screen();
77+
78+
// If in alternate screen and we have saved main content, combine them
79+
let all_contents = if is_alternate {
80+
if let Some(saved) = &self.saved_main_content {
81+
// Combine: saved main scrollback + current alternate screen
82+
let alternate_content = screen.contents();
83+
format!("{saved}\n--- alternate screen ---\n{alternate_content}")
84+
} else {
85+
screen.contents()
86+
}
87+
} else {
88+
screen.contents()
89+
};
3690

3791
match count {
3892
Some(n) => {
@@ -56,6 +110,10 @@ impl ScrollbackBuffer {
56110
)
57111
}
58112

113+
/// Returns true if currently in alternate screen mode
114+
pub fn is_alternate_screen(&self) -> bool {
115+
self.parser.as_ref().is_some_and(|p| p.screen().alternate_screen())
116+
}
59117
}
60118

61119
#[cfg(test)]
@@ -107,4 +165,35 @@ mod tests {
107165
assert!(!content.contains("\x1b[31m"));
108166
assert!(!content.contains("[31m"));
109167
}
168+
169+
#[test]
170+
fn test_alternate_screen_detection() {
171+
let mut buf = ScrollbackBuffer::new();
172+
buf.push(b"main screen content");
173+
assert!(!buf.is_alternate_screen());
174+
175+
// Enter alternate screen mode
176+
buf.push(b"\x1b[?1049h");
177+
assert!(buf.is_alternate_screen());
178+
179+
// Exit alternate screen mode
180+
buf.push(b"\x1b[?1049l");
181+
assert!(!buf.is_alternate_screen());
182+
}
183+
184+
#[test]
185+
fn test_alternate_screen_preserves_main_content() {
186+
let mut buf = ScrollbackBuffer::new();
187+
buf.push(b"line1\r\nline2\r\nline3");
188+
189+
// Enter alternate screen
190+
buf.push(b"\x1b[?1049h");
191+
buf.push(b"alternate content");
192+
193+
// Should contain both main and alternate content
194+
let content = buf.get_lines(None);
195+
assert!(content.contains("line1"));
196+
assert!(content.contains("line2"));
197+
assert!(content.contains("alternate content"));
198+
}
110199
}

0 commit comments

Comments
 (0)