diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-24 04:00:13 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-24 04:00:13 +0300 |
| commit | c0fb777ed7c7950c6a2f397113c1842c2e679306 (patch) | |
| tree | 56a1b58314e292433946c1eed5c025abb832595f /src | |
| parent | e608a75159fc5cca70485d83ffb8ee681c9da385 (diff) | |
fix: report truncated pane reads
refs #1717
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/api/agents.rs | 6 | ||||
| -rw-r--r-- | src/app/api/panes.rs | 28 | ||||
| -rw-r--r-- | src/app/api_helpers.rs | 83 | ||||
| -rw-r--r-- | src/pane.rs | 20 | ||||
| -rw-r--r-- | src/pane/terminal.rs | 136 | ||||
| -rw-r--r-- | src/pane/terminal/windows_recent_fallback.rs | 50 | ||||
| -rw-r--r-- | src/terminal/runtime.rs | 25 |
7 files changed, 248 insertions, 100 deletions
diff --git a/src/app/api/agents.rs b/src/app/api/agents.rs index ffb9cb63..f1977ce4 100644 --- a/src/app/api/agents.rs +++ b/src/app/api/agents.rs @@ -117,7 +117,7 @@ impl App { else { return agent_not_found(id, ¶ms.target); }; - let text = crate::app::api_helpers::read_terminal_snapshot( + let snapshot = crate::app::api_helpers::read_terminal_snapshot( pane, params.source, params.format, @@ -137,9 +137,9 @@ impl App { .unwrap(), source: params.source, format: params.format, - text, + text: snapshot.text, revision: 0, - truncated: false, + truncated: snapshot.truncated, }, }, ) diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 43206d1e..253fd627 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -1179,7 +1179,7 @@ impl App { else { return pane_not_found(id, ¶ms.pane_id); }; - let text = crate::app::api_helpers::read_terminal_snapshot( + let snapshot = crate::app::api_helpers::read_terminal_snapshot( pane, params.source, params.format, @@ -1195,9 +1195,9 @@ impl App { tab_id: self.public_tab_id(ws_idx, tab_idx).unwrap(), source: params.source, format: params.format, - text, + text: snapshot.text, revision: 0, - truncated: false, + truncated: snapshot.truncated, }, }, ) @@ -2002,6 +2002,28 @@ mod tests { } #[tokio::test] + async fn api_pane_read_reports_when_older_rows_are_omitted() { + let (mut app, public_pane_id, _pane_id) = app_with_scrollback_runtime(); + + let response = app.handle_pane_read( + "req".into(), + PaneReadParams { + pane_id: public_pane_id, + source: crate::api::schema::ReadSource::Recent, + lines: Some(2), + format: crate::api::schema::ReadFormat::Text, + strip_ansi: true, + }, + ); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneRead { read } = success.result else { + panic!("expected pane read response"); + }; + assert!(read.text.contains("line 19")); + assert!(read.truncated); + } + + #[tokio::test] async fn api_pane_send_keys_preserves_legacy_control_c_aliases() { let (mut app, pane_id, mut rx) = app_with_send_key_runtime(3); diff --git a/src/app/api_helpers.rs b/src/app/api_helpers.rs index 6c393dac..e0091b39 100644 --- a/src/app/api_helpers.rs +++ b/src/app/api_helpers.rs @@ -106,50 +106,77 @@ pub(super) fn read_terminal_snapshot( source: crate::api::schema::ReadSource, format: crate::api::schema::ReadFormat, lines: Option<u32>, -) -> String { +) -> crate::pane::TerminalReadSnapshot { use crate::api::schema::{ReadFormat, ReadSource}; - let requested_lines = lines.unwrap_or(80).min(1000) as usize; - let text = match (format, source) { - (ReadFormat::Text, ReadSource::Visible) => terminal.visible_text(), - (ReadFormat::Text, ReadSource::Recent) => terminal.recent_text(requested_lines), + let line_limit = lines.map(|lines| lines.min(1000) as usize); + let recent_lines = line_limit.unwrap_or(80); + match (format, source) { + (ReadFormat::Text, ReadSource::Visible) => { + limit_snapshot_lines(terminal.visible_text(), line_limit) + } + (ReadFormat::Text, ReadSource::Recent) => terminal.recent_text_snapshot(recent_lines), (ReadFormat::Text, ReadSource::RecentUnwrapped) => { - terminal.recent_unwrapped_text(requested_lines) + terminal.recent_unwrapped_text_snapshot(recent_lines) + } + (ReadFormat::Text, ReadSource::Detection) => { + limit_snapshot_lines(terminal.detection_text(), line_limit) + } + (ReadFormat::Ansi, ReadSource::Visible) => { + limit_snapshot_lines(terminal.visible_ansi(), line_limit) } - (ReadFormat::Text, ReadSource::Detection) => terminal.detection_text(), - (ReadFormat::Ansi, ReadSource::Visible) => terminal.visible_ansi(), - (ReadFormat::Ansi, ReadSource::Recent) => terminal.recent_ansi(requested_lines), + (ReadFormat::Ansi, ReadSource::Recent) => terminal.recent_ansi_snapshot(recent_lines), (ReadFormat::Ansi, ReadSource::RecentUnwrapped) => { - terminal.recent_unwrapped_ansi(requested_lines) + terminal.recent_unwrapped_ansi_snapshot(recent_lines) + } + (ReadFormat::Ansi, ReadSource::Detection) => { + limit_snapshot_lines(terminal.detection_text(), line_limit) } - (ReadFormat::Ansi, ReadSource::Detection) => terminal.detection_text(), - }; - - if lines.is_some() && matches!(source, ReadSource::Visible | ReadSource::Detection) { - last_lines(&text, requested_lines) - } else { - text } } -fn last_lines(text: &str, count: usize) -> String { - if text.is_empty() || count == 0 { - return String::new(); - } +fn limit_snapshot_lines(text: String, limit: Option<usize>) -> crate::pane::TerminalReadSnapshot { + let Some(limit) = limit else { + return crate::pane::TerminalReadSnapshot { + text, + truncated: false, + }; + }; let lines: Vec<_> = text.split_inclusive('\n').collect(); - lines[lines.len().saturating_sub(count)..].concat() + crate::pane::TerminalReadSnapshot { + text: lines[lines.len().saturating_sub(limit)..].concat(), + truncated: lines.len() > limit, + } } #[cfg(test)] mod read_snapshot_tests { - use super::last_lines; + use super::limit_snapshot_lines; + + #[test] + fn line_limit_preserves_endings_and_reports_omitted_lines() { + let snapshot = limit_snapshot_lines("one\ntwø\n三\n".into(), Some(2)); + assert_eq!(snapshot.text, "twø\n三\n"); + assert!(snapshot.truncated); + + let snapshot = limit_snapshot_lines("one\ntwo\nthree".into(), Some(1)); + assert_eq!(snapshot.text, "three"); + assert!(snapshot.truncated); + + let snapshot = limit_snapshot_lines("one\ntwo".into(), Some(0)); + assert_eq!(snapshot.text, ""); + assert!(snapshot.truncated); + + let snapshot = limit_snapshot_lines("".into(), Some(2)); + assert_eq!(snapshot.text, ""); + assert!(!snapshot.truncated); + } #[test] - fn last_lines_preserves_real_line_endings_and_unicode() { - assert_eq!(last_lines("one\ntwø\n三\n", 2), "twø\n三\n"); - assert_eq!(last_lines("one\ntwo\nthree", 1), "three"); - assert_eq!(last_lines("one\ntwo", 0), ""); - assert_eq!(last_lines("", 2), ""); + fn omitted_line_limit_returns_the_complete_snapshot() { + let snapshot = limit_snapshot_lines("one\ntwo\n".into(), None); + assert_eq!(snapshot.text, "one\ntwo\n"); + assert!(!snapshot.truncated); } } diff --git a/src/pane.rs b/src/pane.rs index ea6a13fd..45f4f720 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -41,8 +41,8 @@ use self::agent_detection::{ }; use self::terminal::{GhosttyPaneTerminal, PaneTerminal}; pub(crate) use self::terminal::{ - TerminalDirtyPatch, TerminalDirtyPatchOutcome, TerminalTextMatch, TerminalTextPoint, - TerminalWordMotion, + TerminalDirtyPatch, TerminalDirtyPatchOutcome, TerminalReadSnapshot, TerminalTextMatch, + TerminalTextPoint, TerminalWordMotion, }; pub use self::{ state::PaneState, @@ -2552,18 +2552,26 @@ impl PaneRuntime { self.terminal.recent_text(lines) } - pub fn recent_ansi(&self, lines: usize) -> String { - self.terminal.recent_ansi(lines) + pub(crate) fn recent_text_snapshot(&self, lines: usize) -> TerminalReadSnapshot { + self.terminal.recent_text_snapshot(lines) } - pub fn recent_unwrapped_text(&self, lines: usize) -> String { - self.terminal.recent_unwrapped_text(lines) + pub(crate) fn recent_ansi_snapshot(&self, lines: usize) -> TerminalReadSnapshot { + self.terminal.recent_ansi_snapshot(lines) + } + + pub(crate) fn recent_unwrapped_text_snapshot(&self, lines: usize) -> TerminalReadSnapshot { + self.terminal.recent_unwrapped_text_snapshot(lines) } pub fn recent_unwrapped_ansi(&self, lines: usize) -> String { self.terminal.recent_unwrapped_ansi(lines) } + pub(crate) fn recent_unwrapped_ansi_snapshot(&self, lines: usize) -> TerminalReadSnapshot { + self.terminal.recent_unwrapped_ansi_snapshot(lines) + } + pub fn snapshot_history(&self) -> Option<String> { let ansi = self.recent_unwrapped_ansi(usize::MAX); (!ansi.trim().is_empty()).then_some(ansi) diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 81c57ef9..d5070b9b 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -141,6 +141,12 @@ pub(crate) struct ProcessBytesResult { pub terminal_responses: Vec<Bytes>, } +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(crate) struct TerminalReadSnapshot { + pub text: String, + pub truncated: bool, +} + pub(crate) struct GhosttyPaneTerminal { pub core: Mutex<GhosttyPaneCore>, key_encoder: Mutex<crate::ghostty::KeyEncoder>, @@ -387,18 +393,26 @@ impl PaneTerminal { self.ghostty.recent_text(lines) } - pub fn recent_ansi(&self, lines: usize) -> String { - self.ghostty.recent_ansi(lines) + pub(crate) fn recent_text_snapshot(&self, lines: usize) -> TerminalReadSnapshot { + self.ghostty.recent_text_snapshot(lines) } - pub fn recent_unwrapped_text(&self, lines: usize) -> String { - self.ghostty.recent_unwrapped_text(lines) + pub(crate) fn recent_ansi_snapshot(&self, lines: usize) -> TerminalReadSnapshot { + self.ghostty.recent_ansi_snapshot(lines) + } + + pub(crate) fn recent_unwrapped_text_snapshot(&self, lines: usize) -> TerminalReadSnapshot { + self.ghostty.recent_unwrapped_text_snapshot(lines) } pub fn recent_unwrapped_ansi(&self, lines: usize) -> String { self.ghostty.recent_unwrapped_ansi(lines) } + pub(crate) fn recent_unwrapped_ansi_snapshot(&self, lines: usize) -> TerminalReadSnapshot { + self.ghostty.recent_unwrapped_ansi_snapshot(lines) + } + pub fn extract_selection(&self, selection: &crate::selection::Selection) -> Option<String> { self.ghostty.extract_selection(selection) } @@ -1708,34 +1722,52 @@ impl GhosttyPaneTerminal { } pub fn recent_text(&self, lines: usize) -> String { + self.recent_text_snapshot(lines).text + } + + pub(crate) fn recent_text_snapshot(&self, lines: usize) -> TerminalReadSnapshot { self.core .lock() .ok() - .and_then(|core| ghostty_recent_text(&core, lines).ok()) + .and_then(|core| ghostty_recent_text_snapshot(&core, lines).ok()) .unwrap_or_default() } + #[cfg(test)] pub fn recent_ansi(&self, lines: usize) -> String { + self.recent_ansi_snapshot(lines).text + } + + pub(crate) fn recent_ansi_snapshot(&self, lines: usize) -> TerminalReadSnapshot { self.core .lock() .ok() - .and_then(|core| ghostty_recent_ansi(&core, lines, false).ok()) + .and_then(|core| ghostty_recent_ansi_snapshot(&core, lines, false).ok()) .unwrap_or_default() } + #[cfg(test)] pub fn recent_unwrapped_text(&self, lines: usize) -> String { + self.recent_unwrapped_text_snapshot(lines).text + } + + pub(crate) fn recent_unwrapped_text_snapshot(&self, lines: usize) -> TerminalReadSnapshot { self.core .lock() .ok() - .and_then(|core| ghostty_recent_text_unwrapped(&core, lines).ok()) + .and_then(|core| ghostty_recent_text_unwrapped_snapshot(&core, lines).ok()) .unwrap_or_default() } pub fn recent_unwrapped_ansi(&self, lines: usize) -> String { + self.recent_unwrapped_ansi_snapshot(lines).text + } + + pub(crate) fn recent_unwrapped_ansi_snapshot(&self, lines: usize) -> TerminalReadSnapshot { self.core .lock() .ok() - .and_then(|core| ghostty_recent_ansi(&core, lines, true).ok()) + .and_then(|core| ghostty_recent_ansi_snapshot(&core, lines, true).ok()) .unwrap_or_default() } @@ -2289,34 +2321,23 @@ fn ghostty_recent_text( core: &GhosttyPaneCore, lines: usize, ) -> Result<String, crate::ghostty::Error> { + ghostty_recent_text_snapshot(core, lines).map(|snapshot| snapshot.text) +} + +fn ghostty_recent_text_snapshot( + core: &GhosttyPaneCore, + lines: usize, +) -> Result<TerminalReadSnapshot, crate::ghostty::Error> { let text = ghostty_recent_text_for_terminal(&core.terminal, lines)?; - #[cfg(windows)] - { - if text.trim().is_empty() { - let fallback = windows_recent_fallback::recent_text(core, lines, false); - if !fallback.trim().is_empty() { - return Ok(fallback); - } - } - } - Ok(text) + Ok(finish_recent_snapshot(core, text, lines, false)) } -fn ghostty_recent_text_unwrapped( +fn ghostty_recent_text_unwrapped_snapshot( core: &GhosttyPaneCore, lines: usize, -) -> Result<String, crate::ghostty::Error> { +) -> Result<TerminalReadSnapshot, crate::ghostty::Error> { let text = ghostty_recent_text_unwrapped_for_terminal(&core.terminal, lines)?; - #[cfg(windows)] - { - if text.trim().is_empty() { - let fallback = windows_recent_fallback::recent_text(core, lines, true); - if !fallback.trim().is_empty() { - return Ok(fallback); - } - } - } - Ok(text) + Ok(finish_recent_snapshot(core, text, lines, true)) } fn ghostty_recent_ansi( @@ -2324,17 +2345,42 @@ fn ghostty_recent_ansi( lines: usize, unwrap: bool, ) -> Result<String, crate::ghostty::Error> { - let ansi = ghostty_recent_ansi_for_terminal(&core.terminal, lines, unwrap)?; + ghostty_recent_ansi_snapshot(core, lines, unwrap).map(|snapshot| snapshot.text) +} + +fn ghostty_recent_ansi_snapshot( + core: &GhosttyPaneCore, + lines: usize, + unwrap: bool, +) -> Result<TerminalReadSnapshot, crate::ghostty::Error> { + let text = ghostty_recent_ansi_for_terminal(&core.terminal, lines, unwrap)?; + Ok(finish_recent_snapshot(core, text, lines, unwrap)) +} + +fn finish_recent_snapshot( + core: &GhosttyPaneCore, + text: String, + lines: usize, + unwrap: bool, +) -> TerminalReadSnapshot { + #[cfg(not(windows))] + let _ = unwrap; #[cfg(windows)] - { - if ansi.trim().is_empty() { - let fallback = windows_recent_fallback::recent_text(core, lines, unwrap); - if !fallback.trim().is_empty() { - return Ok(fallback); - } + if text.trim().is_empty() { + let fallback = windows_recent_fallback::recent_text(core, lines, unwrap); + if !fallback.text.trim().is_empty() { + return fallback; } } - Ok(ansi) + + // Recent read limits are measured in rendered rows, including blank or styled rows. + TerminalReadSnapshot { + text, + truncated: core + .terminal + .total_rows() + .is_ok_and(|total_rows| total_rows > lines), + } } fn ghostty_recent_text_for_terminal( @@ -4342,6 +4388,20 @@ mod tests { } #[test] + fn recent_snapshots_report_omitted_rendered_rows() { + let (tx, _rx) = mpsc::channel(4); + let mut terminal = crate::ghostty::Terminal::new(20, 3, 100).unwrap(); + terminal.write(b"one\r\ntwo\r\nthree\r\nfour"); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + + assert!(pane.recent_text_snapshot(2).truncated); + assert!(pane.recent_ansi_snapshot(2).truncated); + assert!(pane.recent_unwrapped_text_snapshot(2).truncated); + assert!(pane.recent_unwrapped_ansi_snapshot(2).truncated); + assert!(!pane.recent_text_snapshot(100).truncated); + } + + #[test] fn plain_text_reads_skip_wide_character_spacer_cells() { let (tx, _rx) = mpsc::channel(4); let mut terminal = crate::ghostty::Terminal::new(40, 3, 100).unwrap(); diff --git a/src/pane/terminal/windows_recent_fallback.rs b/src/pane/terminal/windows_recent_fallback.rs index c9a02f67..caa3b9f7 100644 --- a/src/pane/terminal/windows_recent_fallback.rs +++ b/src/pane/terminal/windows_recent_fallback.rs @@ -1,4 +1,4 @@ -use super::{ghostty_line_from_cells, GhosttyPaneCore}; +use super::{ghostty_line_from_cells, GhosttyPaneCore, TerminalReadSnapshot}; const CACHE_LINES: usize = 2000; @@ -44,9 +44,13 @@ pub(super) fn update(core: &mut GhosttyPaneCore) { core.recent_fallback.usable = true; } -pub(super) fn recent_text(core: &GhosttyPaneCore, lines: usize, unwrap: bool) -> String { +pub(super) fn recent_text( + core: &GhosttyPaneCore, + lines: usize, + unwrap: bool, +) -> TerminalReadSnapshot { if !primary_screen_active(core) { - return String::new(); + return TerminalReadSnapshot::default(); } if unwrap { unwrapped_text(core, lines) @@ -62,9 +66,9 @@ fn primary_screen_active(core: &GhosttyPaneCore) -> bool { ) } -fn wrapped_text(core: &GhosttyPaneCore, lines: usize) -> String { +fn wrapped_text(core: &GhosttyPaneCore, lines: usize) -> TerminalReadSnapshot { if !core.recent_fallback.usable { - return String::new(); + return TerminalReadSnapshot::default(); } let rows: Vec<&str> = core .recent_fallback @@ -72,16 +76,24 @@ fn wrapped_text(core: &GhosttyPaneCore, lines: usize) -> String { .iter() .map(|line| line.text.as_str()) .collect(); - cache_text(&rows, lines) + TerminalReadSnapshot { + text: cache_text(&rows, lines), + truncated: rows.len() > lines, + } } -fn unwrapped_text(core: &GhosttyPaneCore, lines: usize) -> String { +fn unwrapped_text(core: &GhosttyPaneCore, lines: usize) -> TerminalReadSnapshot { if !core.recent_fallback.usable { - return String::new(); + return TerminalReadSnapshot::default(); } - let unwrapped = unwrap_render_lines(&core.recent_fallback.rows); + let rendered_rows = &core.recent_fallback.rows; + let start = rendered_rows.len().saturating_sub(lines); + let unwrapped = unwrap_render_lines(&rendered_rows[start..]); let rows: Vec<&str> = unwrapped.iter().map(String::as_str).collect(); - cache_text(&rows, lines) + TerminalReadSnapshot { + text: cache_text(&rows, rows.len()), + truncated: rendered_rows.len() > lines, + } } fn viewport_is_at_bottom(core: &GhosttyPaneCore) -> bool { @@ -298,8 +310,8 @@ mod tests { pane.process_pty_bytes(pane_id, 0, b"new output\r\n", &tx); let core = pane.core.lock().unwrap(); - assert_eq!(recent_text(&core, 3, false), ""); - assert_eq!(recent_text(&core, 3, true), ""); + assert_eq!(recent_text(&core, 3, false).text, ""); + assert_eq!(recent_text(&core, 3, true).text, ""); } #[test] @@ -311,12 +323,12 @@ mod tests { pane.seed_history_ansi("seeded history\r\n"); let core = pane.core.lock().unwrap(); - assert!(recent_text(&core, 3, false).contains("seeded history")); - assert!(recent_text(&core, 3, true).contains("seeded history")); + assert!(recent_text(&core, 3, false).text.contains("seeded history")); + assert!(recent_text(&core, 3, true).text.contains("seeded history")); } #[test] - fn recent_ansi_unwrapped_uses_unwrapped_render_cache() { + fn recent_ansi_unwrapped_limits_rendered_rows_before_unwrapping() { let (tx, _rx) = mpsc::channel(4); let terminal = crate::ghostty::Terminal::new(40, 3, 1024).unwrap(); let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); @@ -324,13 +336,17 @@ mod tests { { let mut core = pane.core.lock().unwrap(); core.recent_fallback.rows = vec![ + rendered_line("older", false, false), rendered_line("wrapped", true, false), rendered_line("rows", false, true), + rendered_line("last", false, false), ]; core.recent_fallback.usable = true; } - assert_eq!(pane.recent_ansi(2), "wrapped\nrows\n"); - assert_eq!(pane.recent_unwrapped_ansi(2), "wrappedrows\n"); + let snapshot = pane.recent_unwrapped_ansi_snapshot(3); + assert_eq!(snapshot.text, "wrappedrows\nlast\n"); + assert!(snapshot.truncated); + assert_eq!(pane.recent_ansi(3), "wrapped\nrows\nlast\n"); } } diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index 0241f984..cd6f4211 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -334,16 +334,31 @@ impl TerminalRuntime { self.0.recent_text(lines) } - pub fn recent_ansi(&self, lines: usize) -> String { - self.0.recent_ansi(lines) + pub(crate) fn recent_text_snapshot(&self, lines: usize) -> crate::pane::TerminalReadSnapshot { + self.0.recent_text_snapshot(lines) } + pub(crate) fn recent_ansi_snapshot(&self, lines: usize) -> crate::pane::TerminalReadSnapshot { + self.0.recent_ansi_snapshot(lines) + } + + #[cfg(test)] pub fn recent_unwrapped_text(&self, lines: usize) -> String { - self.0.recent_unwrapped_text(lines) + self.0.recent_unwrapped_text_snapshot(lines).text } - pub fn recent_unwrapped_ansi(&self, lines: usize) -> String { - self.0.recent_unwrapped_ansi(lines) + pub(crate) fn recent_unwrapped_text_snapshot( + &self, + lines: usize, + ) -> crate::pane::TerminalReadSnapshot { + self.0.recent_unwrapped_text_snapshot(lines) + } + + pub(crate) fn recent_unwrapped_ansi_snapshot( + &self, + lines: usize, + ) -> crate::pane::TerminalReadSnapshot { + self.0.recent_unwrapped_ansi_snapshot(lines) } pub fn snapshot_history(&self) -> Option<String> { |
