diff --git a/src-tauri/src/core/recording/mod.rs b/src-tauri/src/core/recording/mod.rs index 8f408b00..884a0168 100644 --- a/src-tauri/src/core/recording/mod.rs +++ b/src-tauri/src/core/recording/mod.rs @@ -4,7 +4,6 @@ use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet, VecDeque}; use std::fs::{self, File, OpenOptions}; use std::io::{BufWriter, Write}; -use std::mem; use std::path::{Component, Path, PathBuf}; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::mpsc::{self, SyncSender, TrySendError}; diff --git a/src-tauri/src/core/recording/search.rs b/src-tauri/src/core/recording/search.rs index 756b3803..a6850218 100644 --- a/src-tauri/src/core/recording/search.rs +++ b/src-tauri/src/core/recording/search.rs @@ -118,31 +118,34 @@ fn context_records( #[cfg(test)] fn strip_terminal_control_sequences(text: &str) -> String { - let replayed = replay_terminal_output(text, "", 0); + let mut pending = Vec::new(); + let mut cursor = 0; + let replayed = replay_terminal_output(text, &mut pending, &mut cursor); let mut out = String::with_capacity(text.len()); for line in replayed.lines { out.push_str(&line); out.push('\n'); } - out.push_str(&replayed.tail); + out.extend(pending); out } struct TerminalReplayResult { lines: Vec, - tail: String, - cursor: usize, + // Echo matching must stop at the first artificial line boundary. + first_forced_split: Option, } fn replay_terminal_output( text: &str, - initial_line: &str, - initial_cursor: usize, + line: &mut Vec, + cursor: &mut usize, ) -> TerminalReplayResult { let bytes = text.as_bytes(); - let mut line = initial_line.chars().collect::>(); - let mut cursor = initial_cursor.min(line.len()); - let mut lines = Vec::new(); + let mut replayed = TerminalReplayResult { + lines: Vec::new(), + first_forced_split: None, + }; let mut i = 0; while i < bytes.len() { @@ -163,9 +166,9 @@ fn replay_terminal_output( apply_csi_sequence( &text[params_start..i - 1], b, - &mut line, - &mut cursor, - &mut lines, + line, + cursor, + &mut replayed.lines, ); break; } @@ -201,28 +204,26 @@ fn replay_terminal_output( } } b'\r' => { - cursor = 0; + *cursor = 0; i += 1; } b'\n' => { - lines.push(line.iter().collect()); - line.clear(); - cursor = 0; + replayed.lines.push(take_replay_line(line, cursor)); i += 1; } b'\t' => { - write_terminal_char(&mut line, &mut cursor, '\t'); + write_terminal_char(line, cursor, '\t', &mut replayed); i += 1; } b'\x08' => { - cursor = cursor.saturating_sub(1); + *cursor = cursor.saturating_sub(1); i += 1; } b if b.is_ascii_control() => { i += 1; } b if b.is_ascii() => { - write_terminal_char(&mut line, &mut cursor, b as char); + write_terminal_char(line, cursor, b as char, &mut replayed); i += 1; } _ => { @@ -233,24 +234,36 @@ fn replay_terminal_output( let Some(ch) = text[i..].chars().next() else { break; }; - write_terminal_char(&mut line, &mut cursor, ch); + write_terminal_char(line, cursor, ch, &mut replayed); i += ch.len_utf8(); } } } - TerminalReplayResult { - lines, - tail: line.iter().collect(), - cursor, - } + replayed } const MAX_REPLAY_CURSOR_COLUMNS: usize = 4096; +// Transcript segments are bounded even when a TUI never sends a newline. +const MAX_REPLAY_LINE_CHARS: usize = 16 * 1024; -fn write_terminal_char(line: &mut Vec, cursor: &mut usize, ch: char) { - while *cursor > line.len() { - line.push(' '); +fn take_replay_line(line: &mut Vec, cursor: &mut usize) -> String { + *cursor = 0; + line.drain(..).collect() +} + +fn write_terminal_char( + line: &mut Vec, + cursor: &mut usize, + ch: char, + replayed: &mut TerminalReplayResult, +) { + if *cursor >= MAX_REPLAY_LINE_CHARS { + replayed.first_forced_split.get_or_insert(replayed.lines.len()); + replayed.lines.push(take_replay_line(line, cursor)); + } + if *cursor > line.len() { + line.resize(*cursor, ' '); } if *cursor == line.len() { @@ -270,12 +283,9 @@ fn apply_csi_sequence( ) { match final_byte { b'B' | b'E' => { - let count = csi_param(params, 0, 1).max(1); - for _ in 0..count { - lines.push(line.iter().collect()); - line.clear(); - } - *cursor = 0; + // Empty transcript records are discarded; do not expand cursor + // movement into a parameter-sized collection of blank rows. + lines.push(take_replay_line(line, cursor)); } b'C' => { let count = csi_param(params, 0, 1); @@ -316,10 +326,10 @@ fn apply_csi_sequence( } } b'@' => { - let count = csi_param(params, 0, 1); - for _ in 0..count { - line.insert((*cursor).min(line.len()), ' '); - } + let count = csi_param(params, 0, 1) + .min(MAX_REPLAY_LINE_CHARS.saturating_sub(line.len())); + let start = (*cursor).min(line.len()); + line.splice(start..start, std::iter::repeat_n(' ', count)); } _ => {} } diff --git a/src-tauri/src/core/recording/session_capture.rs b/src-tauri/src/core/recording/session_capture.rs index 136165d4..0bab3858 100644 --- a/src-tauri/src/core/recording/session_capture.rs +++ b/src-tauri/src/core/recording/session_capture.rs @@ -2,7 +2,7 @@ struct TranscriptState { records: VecDeque, record_bytes: usize, memory_limit_bytes: usize, - output_buffer: String, + output_buffer: Vec, output_cursor: usize, submitted_line_echo: Option, next_line_id: u64, @@ -14,7 +14,7 @@ impl TranscriptState { records: VecDeque::new(), record_bytes: 0, memory_limit_bytes, - output_buffer: String::new(), + output_buffer: Vec::new(), output_cursor: 0, submitted_line_echo: None, next_line_id: 1, @@ -50,14 +50,23 @@ impl TranscriptState { return Vec::new(); } - let replayed = replay_terminal_output(data, &self.output_buffer, self.output_cursor); - self.output_buffer = replayed.tail; - self.output_cursor = replayed.cursor; - + let replayed = replay_terminal_output( + data, + &mut self.output_buffer, + &mut self.output_cursor, + ); + let first_forced_split = replayed.first_forced_split; replayed .lines .into_iter() - .filter_map(|line| self.append_output_line(line)) + .enumerate() + .filter_map(|(index, line)| { + if Some(index) == first_forced_split { + // A segment ending in the command is not proof of an echo. + self.submitted_line_echo = None; + } + self.append_output_line(line) + }) .collect() } @@ -93,11 +102,8 @@ impl TranscriptState { fn flush_output_lines(&mut self, flush_partial: bool) -> Vec { let mut flushed = Vec::new(); - if flush_partial && !self.output_buffer.is_empty() { - let tail = mem::take(&mut self.output_buffer) - .trim_end_matches('\r') - .to_string(); - self.output_cursor = 0; + if flush_partial { + let tail = take_replay_line(&mut self.output_buffer, &mut self.output_cursor); if let Some(record) = self.append_output_line(tail) { flushed.push(record); } @@ -137,14 +143,7 @@ impl TranscriptState { } fn flush_output_before_command(&mut self) -> Vec { - if self.output_buffer.is_empty() { - return Vec::new(); - } - - let tail = mem::take(&mut self.output_buffer) - .trim_end_matches('\r') - .to_string(); - self.output_cursor = 0; + let tail = take_replay_line(&mut self.output_buffer, &mut self.output_cursor); if looks_like_prompt_tail(&tail) { return Vec::new(); diff --git a/src-tauri/src/core/recording/tests.rs b/src-tauri/src/core/recording/tests.rs index 2fc910d3..e0ec0399 100644 --- a/src-tauri/src/core/recording/tests.rs +++ b/src-tauri/src/core/recording/tests.rs @@ -120,6 +120,220 @@ mod tests { assert!(!cleaned.contains("CoderKangMode")); } + #[test] + fn segments_long_output_in_one_chunk_without_losing_unicode() { + let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES); + let output = "你🙂".repeat(super::MAX_REPLAY_LINE_CHARS + 3); + + let mut records = transcript.write_output(&output); + + assert_eq!(records.len(), 2); + assert!(records.iter().all(|record| { + record.data.chars().count() == super::MAX_REPLAY_LINE_CHARS + })); + records.extend(transcript.finish()); + let restored: String = records.iter().map(|record| record.data.as_str()).collect(); + assert_eq!(restored, output); + } + + #[test] + fn segments_long_output_across_chunks_with_bounded_pending_line() { + let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES); + let chunk = "x".repeat(257); + let mut records = Vec::new(); + + for _ in 0..256 { + records.extend(transcript.write_output(&chunk)); + assert!(transcript.output_buffer.len() <= super::MAX_REPLAY_LINE_CHARS); + } + records.extend(transcript.finish()); + + assert!(records.iter().all(|record| { + record.data.chars().count() <= super::MAX_REPLAY_LINE_CHARS + })); + let restored: String = records.iter().map(|record| record.data.as_str()).collect(); + assert_eq!(restored, chunk.repeat(256)); + } + + #[test] + fn keeps_full_pending_line_editable_until_it_needs_another_segment() { + let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES); + let full_line = "x".repeat(super::MAX_REPLAY_LINE_CHARS); + + assert!(transcript.write_output(&full_line).is_empty()); + assert!(transcript.write_output("\rZ").is_empty()); + let edited = transcript.write_output("\n"); + assert_eq!(edited.len(), 1); + assert_eq!(edited[0].data, format!("Z{}", &full_line[1..])); + + assert!(transcript.write_output(&full_line).is_empty()); + let segments = transcript.write_output("!"); + assert_eq!(segments.len(), 1); + assert_eq!(segments[0].data, full_line); + assert_eq!(transcript.finish()[0].data, "!"); + } + + #[test] + fn preserves_split_output_ending_with_a_pending_command() { + let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES); + transcript.record_command("pwd".to_string()); + let output = format!("{}pwdTAIL", "x".repeat(super::MAX_REPLAY_LINE_CHARS - 3)); + + let records = transcript.write_output(&format!("{output}\n")); + + assert_eq!(records.len(), 2); + let restored: String = records.iter().map(|record| record.data.as_str()).collect(); + assert_eq!(restored, output); + } + + #[test] + fn filters_complete_command_echo_before_later_forced_splits() { + let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES); + transcript.record_command("pwd".to_string()); + let output = format!("{}tail", "x".repeat(super::MAX_REPLAY_LINE_CHARS)); + + let records = transcript.write_output(&format!("pwd\n{output}\n")); + + assert_eq!(records.len(), 2); + let restored: String = records.iter().map(|record| record.data.as_str()).collect(); + assert_eq!(restored, output); + } + + #[test] + fn reuses_pending_line_storage_across_chunks_and_flushes() { + let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES); + transcript.output_buffer.reserve(128); + let allocation = transcript.output_buffer.as_ptr(); + let capacity = transcript.output_buffer.capacity(); + + for _ in 0..64 { + assert!(transcript.write_output("x").is_empty()); + assert_eq!(transcript.output_buffer.as_ptr(), allocation); + } + assert_eq!(transcript.write_output("\n")[0].data, "x".repeat(64)); + assert_eq!(transcript.output_buffer.capacity(), capacity); + transcript.write_output("next"); + assert_eq!(transcript.output_buffer.as_ptr(), allocation); + } + + #[test] + fn preserves_cursor_position_between_output_chunks() { + let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES); + transcript.write_output("abc"); + transcript.write_output("\x1b[8G"); + + let records = transcript.write_output("X\n"); + + assert_eq!(records.len(), 1); + assert_eq!(records[0].data, "abc X"); + } + + #[test] + fn resets_cursor_when_flushing_an_empty_pending_line() { + let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES); + transcript.write_output("\x1b[8G"); + assert!(transcript.finish().is_empty()); + assert_eq!(transcript.write_output("first\n")[0].data, "first"); + + transcript.write_output("\x1b[8G"); + transcript.snapshot_records(); + assert_eq!(transcript.write_output("second\n")[0].data, "second"); + + transcript.write_output("\x1b[8G"); + transcript.record_command("echo third".to_string()); + assert_eq!(transcript.write_output("third\n")[0].data, "third"); + } + + #[test] + fn bounds_csi_insert_expansion_without_losing_existing_text() { + let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES); + transcript.write_output("tail"); + let insert = format!("\r\x1b[{}@", usize::MAX); + + assert!(transcript.write_output(&insert).is_empty()); + assert_eq!(transcript.output_buffer.len(), super::MAX_REPLAY_LINE_CHARS); + assert!(transcript.write_output(&insert).is_empty()); + assert_eq!(transcript.output_buffer.len(), super::MAX_REPLAY_LINE_CHARS); + + let records = transcript.write_output("ok\nnext\n"); + assert_eq!(records.len(), 2); + assert!(records[0].data.starts_with("ok")); + assert!(records[0].data.ends_with("tail")); + assert_eq!(records[0].data.chars().count(), super::MAX_REPLAY_LINE_CHARS); + assert_eq!(records[1].data, "next"); + } + + #[test] + fn bounds_csi_vertical_movement_without_expanding_empty_rows() { + for final_byte in ['B', 'E'] { + let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES); + let output = format!("before\x1b[{}{final_byte}after\n", usize::MAX); + + let records = transcript.write_output(&output); + + assert_eq!(records.len(), 2); + assert_eq!(records[0].data, "before"); + assert_eq!(records[1].data, "after"); + assert!(transcript.output_buffer.is_empty()); + } + } + + #[test] + fn bounds_csi_horizontal_movement_and_deletion() { + let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES); + transcript.write_output(&format!("\x1b[{}C", usize::MAX)); + transcript.write_output("X"); + assert_eq!(transcript.output_buffer.len(), super::MAX_REPLAY_CURSOR_COLUMNS + 1); + + let records = transcript.write_output(&format!( + "\x1b[{}D\x1b[{}Pdone\n", usize::MAX, usize::MAX + )); + assert_eq!(records.len(), 1); + assert_eq!(records[0].data, "done"); + } + + #[test] + fn bounds_repeated_tui_redraws_without_linefeeds() { + let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES); + let text = "x".repeat(200); + let chunk = format!("\x1b[1;1H\x1b[32m{text}\x1b[0m"); + let mut recorded_chars = 0; + + for _ in 0..2048 { + for record in transcript.write_output(&chunk) { + assert!(record.data.len() <= super::MAX_REPLAY_LINE_CHARS); + recorded_chars += record.data.len(); + } + assert!(transcript.output_buffer.len() <= super::MAX_REPLAY_LINE_CHARS); + } + for record in transcript.finish() { + recorded_chars += record.data.len(); + } + assert_eq!(recorded_chars, text.len() * 2048); + } + + #[test] + fn searches_output_after_long_unterminated_text() { + let manager = RecordingManager::new(); + manager.write_output("s1", &"x".repeat(super::MAX_REPLAY_LINE_CHARS * 3)); + manager.write_output("s1", "tail-sentinel\n"); + + let result = manager.search_history(super::TerminalHistorySearchRequest { + session_id: "s1".to_string(), + query: "tail-sentinel".to_string(), + case_sensitive: true, + regex: false, + whole_word: false, + limit: None, + context_before: None, + context_after: None, + max_lines: None, + }).unwrap(); + + assert_eq!(result.total, 1); + assert_eq!(result.results[0].preview, "tail-sentinel"); + } + #[test] fn consumes_matching_echo_prefix() { let mut prefix = "ps -ef".to_string(); @@ -571,13 +785,16 @@ mod tests { let path = manager .start_with_profile(session_id, context, profile, None) .unwrap(); - manager.write_raw_output(session_id, b"\x1b[31mERROR\x1b[0m\n"); - manager.write_output(session_id, "\x1b[31mERROR\x1b[0m\n"); + let output = format!( + "\x1b[31mERROR\x1b[0m\n{}", + "x".repeat(super::MAX_REPLAY_LINE_CHARS * 2 + 3), + ); + manager.write_raw_output(session_id, output.as_bytes()); + manager.write_output(session_id, &output); manager.stop(session_id).unwrap(); let raw = fs::read(&path).unwrap(); - assert!(raw.windows(5).any(|window| window == b"ERROR")); - assert!(raw.contains(&0x1b)); + assert_eq!(raw, output.as_bytes()); let transcript_path = unique_path("raw-transcript"); manager