Skip to main content

par_term_terminal/
scrollback_metadata.rs

1use std::collections::HashMap;
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use par_term_emu_core_rust::shell_integration::ShellIntegrationMarker;
5use par_term_emu_core_rust::terminal::CommandExecution;
6
7// Re-export ScrollbackMark from par-term-config (shared type used by both terminal and renderer)
8pub use par_term_config::ScrollbackMark;
9
10/// Maximum distance (in lines) between the A (PromptStart) and B (CommandStart)
11/// markers of the same prompt. Used both to suppress duplicate prompt_lines
12/// entries (B won't create one if A already recorded one nearby) and to snap
13/// `finish_command` back to the A marker line for multi-line prompts.
14///
15/// Must be large enough to cover the tallest realistic prompt.
16const MAX_PROMPT_HEIGHT: usize = 6;
17
18/// Lightweight snapshot of a completed command taken from the core library.
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct CommandSnapshot {
21    pub id: usize,
22    pub command: Option<String>,
23    pub start_time: u64,
24    pub end_time: Option<u64>,
25    pub exit_code: Option<i32>,
26    pub duration_ms: Option<u64>,
27}
28
29impl CommandSnapshot {
30    pub fn from_core(command: &CommandExecution, id: usize) -> Self {
31        Self {
32            id,
33            command: Some(command.command.clone()),
34            start_time: command.start_time,
35            end_time: command.end_time,
36            exit_code: command.exit_code,
37            duration_ms: command.duration_ms,
38        }
39    }
40}
41
42/// Metadata for displaying timing/command info for a specific line.
43#[derive(Clone, Debug, PartialEq, Eq)]
44pub struct LineMetadata {
45    pub line: usize,
46    pub exit_code: Option<i32>,
47    pub start_time: Option<u64>,
48    pub duration_ms: Option<u64>,
49    pub command: Option<String>,
50}
51
52#[derive(Default)]
53pub struct ScrollbackMetadata {
54    /// Map of prompt/mark line -> command id
55    line_to_command: HashMap<usize, usize>,
56    /// Map of command id -> command info
57    commands: HashMap<usize, CommandSnapshot>,
58    /// Prompt/mark line indices sorted ascending
59    prompt_lines: Vec<usize>,
60    /// Optional timestamp for lines (ms since epoch)
61    line_timestamps: HashMap<usize, u64>,
62    /// Current command start line (absolute)
63    current_command_start: Option<usize>,
64    /// Last marker we processed to avoid duplicate events
65    last_marker: Option<ShellIntegrationMarker>,
66    /// Line where last marker was observed (to de-dupe identical repeats)
67    last_marker_line: Option<usize>,
68    /// Last exit code seen from shell integration (for synthetic finishes)
69    last_exit_code: Option<i32>,
70    /// Line where we last consumed an exit code event
71    last_exit_code_line: Option<usize>,
72    /// Number of commands already recorded (matches command_history.len())
73    last_recorded_history_len: usize,
74    /// Wall-clock start time for the current command (ms since epoch)
75    current_command_start_time_ms: Option<u64>,
76    /// True when a PromptStart (A) marker has been recorded and we're waiting
77    /// for the corresponding CommandStart (B). While set, B/C markers suppress
78    /// prompt_line creation since A already created the entry.
79    prompt_start_pending: bool,
80}
81
82impl ScrollbackMetadata {
83    pub fn new() -> Self {
84        Self::default()
85    }
86
87    /// Reset all metadata, clearing prompt lines, commands, and timestamps.
88    ///
89    /// Called when the scrollback buffer is cleared so stale marks don't persist.
90    pub fn clear(&mut self) {
91        self.line_to_command.clear();
92        self.commands.clear();
93        self.prompt_lines.clear();
94        self.line_timestamps.clear();
95        self.current_command_start = None;
96        self.last_marker = None;
97        self.last_marker_line = None;
98        self.last_exit_code = None;
99        self.last_exit_code_line = None;
100        self.last_recorded_history_len = 0;
101        self.current_command_start_time_ms = None;
102        self.prompt_start_pending = false;
103    }
104
105    /// Apply the latest shell integration marker and update internal metadata.
106    ///
107    /// `absolute_line` is the cursor position (scrollback_len + cursor_row) at the
108    /// time the marker was emitted by the core library. This captures the exact
109    /// position before subsequent output moves the cursor.
110    /// `history_len` is the current command history length from the core library.
111    /// `last_command` should contain the most recent command when `history_len` > 0.
112    pub fn apply_event(
113        &mut self,
114        marker: Option<ShellIntegrationMarker>,
115        absolute_line: usize,
116        history_len: usize,
117        last_command: Option<CommandSnapshot>,
118        last_exit_code: Option<i32>,
119    ) {
120        let last_command_clone = last_command.clone();
121        let repeat_marker =
122            marker == self.last_marker && Some(absolute_line) == self.last_marker_line;
123        let mut finished_command = false;
124
125        match marker {
126            Some(ShellIntegrationMarker::PromptStart) => {
127                if !repeat_marker {
128                    self.record_prompt_line(
129                        absolute_line,
130                        last_command.as_ref().map(|c| c.start_time),
131                    );
132                    self.prompt_start_pending = true;
133                }
134            }
135            Some(ShellIntegrationMarker::CommandStart)
136            | Some(ShellIntegrationMarker::CommandExecuted) => {
137                if !repeat_marker {
138                    // Only record a prompt line if no PromptStart (A) marker has
139                    // already created one for this prompt cycle. Multi-line prompts
140                    // emit A at the top and B at the cursor line — recording both
141                    // creates duplicate marks.  When no A marker was seen (degraded
142                    // shell integration), B is the primary marker and creates the entry.
143                    if !self.prompt_start_pending {
144                        self.record_prompt_line(absolute_line, Some(now_ms()));
145                    }
146                    self.prompt_start_pending = false;
147                }
148                self.current_command_start = Some(absolute_line);
149                self.current_command_start_time_ms = Some(now_ms());
150            }
151            Some(ShellIntegrationMarker::CommandFinished) => {
152                #[allow(clippy::collapsible_if)]
153                if history_len > self.last_recorded_history_len {
154                    if let Some(cmd) = last_command {
155                        let start_line = self.finish_command(absolute_line, cmd);
156                        self.last_recorded_history_len = history_len;
157                        self.last_exit_code_line = Some(start_line);
158                        finished_command = true;
159                    }
160                } else if let Some(exit_code) = last_exit_code {
161                    // Shell reported completion but core history did not advance
162                    // (common when shell integration markers are emitted but
163                    // command history tracking is not wired up). Synthesize a
164                    // minimal snapshot so mark indicators still render.
165                    let start_time = self.current_command_start_time_ms.unwrap_or_else(now_ms);
166                    let end_time = now_ms();
167                    let duration_ms = end_time.saturating_sub(start_time);
168                    let id = self.last_recorded_history_len;
169                    let synthetic = CommandSnapshot {
170                        id,
171                        command: None,
172                        start_time,
173                        end_time: Some(end_time),
174                        exit_code: Some(exit_code),
175                        duration_ms: Some(duration_ms),
176                    };
177                    let start_line = self.finish_command(absolute_line, synthetic);
178                    // Keep ids monotonic to avoid duplicate marks on repeated frames
179                    self.last_recorded_history_len =
180                        self.last_recorded_history_len.saturating_add(1);
181                    self.last_exit_code_line = Some(start_line);
182                    finished_command = true;
183                }
184            }
185            _ => {}
186        }
187
188        // Fallback: if command history advanced but we didn't see a CommandFinished marker,
189        // still record a mark at the current line so users get indicators when shell integration
190        // scripts emit timestamps but markers are missing.
191        if history_len > self.last_recorded_history_len
192            && let Some(ref cmd) = last_command_clone
193        {
194            let start_line = self.finish_command(absolute_line, cmd.clone());
195            self.last_recorded_history_len = history_len;
196            self.last_exit_code_line = Some(start_line);
197            finished_command = true;
198        }
199
200        // Some shells emit the exit code but not a CommandFinished marker. If the exit code
201        // changed or arrived on a new prompt line, synthesize a completion using the latest
202        // marker location so scrollbar marks get colored correctly.
203        if !finished_command && let Some(code) = last_exit_code {
204            let candidate_line = self
205                .current_command_start
206                .or_else(|| self.prompt_lines.last().copied())
207                .unwrap_or(absolute_line);
208
209            let exit_event_is_new = Some(candidate_line) != self.last_exit_code_line
210                || Some(code) != self.last_exit_code;
211
212            if exit_event_is_new {
213                let start_time = self.current_command_start_time_ms.unwrap_or_else(now_ms);
214                let end_time = now_ms();
215                let duration_ms = end_time.saturating_sub(start_time);
216                let id = self.last_recorded_history_len;
217                let synthetic = CommandSnapshot {
218                    id,
219                    command: last_command_clone.as_ref().and_then(|c| c.command.clone()),
220                    start_time,
221                    end_time: Some(end_time),
222                    exit_code: Some(code),
223                    duration_ms: Some(duration_ms),
224                };
225                let start_line = self.finish_command(candidate_line, synthetic);
226                self.last_recorded_history_len = self.last_recorded_history_len.saturating_add(1);
227                self.last_exit_code_line = Some(start_line);
228            }
229        }
230
231        self.last_marker = marker;
232        self.last_marker_line = Some(absolute_line);
233        self.last_exit_code = last_exit_code;
234    }
235
236    /// Produce a list of marks suitable for rendering or navigation.
237    pub fn marks(&self) -> Vec<ScrollbackMark> {
238        let mut marks = Vec::with_capacity(self.prompt_lines.len());
239
240        for line in &self.prompt_lines {
241            let command_id = self.line_to_command.get(line);
242            let (exit_code, start_time, duration_ms, command) = command_id
243                .and_then(|id| self.commands.get(id))
244                .map(|cmd| {
245                    (
246                        cmd.exit_code,
247                        Some(cmd.start_time),
248                        cmd.duration_ms,
249                        cmd.command.clone(),
250                    )
251                })
252                .unwrap_or((None, None, None, None));
253
254            marks.push(ScrollbackMark {
255                line: *line,
256                exit_code,
257                start_time,
258                duration_ms,
259                command,
260                color: None,
261                trigger_id: None,
262            });
263        }
264
265        marks
266    }
267
268    /// Retrieve metadata for a specific absolute line index, if available.
269    pub fn metadata_for_line(&self, line: usize) -> Option<LineMetadata> {
270        let command_id = self.line_to_command.get(&line);
271        let base = command_id
272            .and_then(|id| self.commands.get(id))
273            .map(|cmd| LineMetadata {
274                line,
275                exit_code: cmd.exit_code,
276                start_time: Some(cmd.start_time),
277                duration_ms: cmd.duration_ms,
278                command: cmd.command.clone(),
279            });
280
281        if base.is_some() {
282            return base;
283        }
284
285        self.line_timestamps.get(&line).map(|ts| LineMetadata {
286            line,
287            exit_code: None,
288            start_time: Some(*ts),
289            duration_ms: None,
290            command: None,
291        })
292    }
293
294    /// Find the previous mark (prompt) before the given absolute line.
295    pub fn previous_mark(&self, line: usize) -> Option<usize> {
296        match self.prompt_lines.binary_search(&line) {
297            Ok(idx) => {
298                if idx > 0 {
299                    Some(self.prompt_lines[idx - 1])
300                } else {
301                    None
302                }
303            }
304            Err(idx) => idx
305                .checked_sub(1)
306                .and_then(|i| self.prompt_lines.get(i).copied()),
307        }
308    }
309
310    /// Find the next mark (prompt) after the given absolute line.
311    pub fn next_mark(&self, line: usize) -> Option<usize> {
312        match self.prompt_lines.binary_search(&line) {
313            Ok(idx) => self.prompt_lines.get(idx + 1).copied(),
314            Err(idx) => self.prompt_lines.get(idx).copied(),
315        }
316    }
317
318    /// Set the command text on the mark at or nearest to `target_line`.
319    pub fn set_mark_command_at(&mut self, target_line: usize, command: String) {
320        let line = match self.prompt_lines.binary_search(&target_line) {
321            Ok(_) => Some(target_line),
322            Err(idx) => idx
323                .checked_sub(1)
324                .and_then(|i| self.prompt_lines.get(i).copied()),
325        };
326        if let Some(line) = line
327            && let Some(id) = self.line_to_command.get(&line)
328            && let Some(snapshot) = self.commands.get_mut(id)
329            && snapshot.command.is_none()
330        {
331            snapshot.command = Some(command);
332        }
333    }
334
335    fn record_prompt_line(&mut self, line: usize, timestamp: Option<u64>) {
336        if let Err(pos) = self.prompt_lines.binary_search(&line) {
337            self.prompt_lines.insert(pos, line);
338        }
339        if let Some(ts) = timestamp {
340            self.line_timestamps.entry(line).or_insert(ts);
341        }
342    }
343
344    fn finish_command(&mut self, end_line: usize, command: CommandSnapshot) -> usize {
345        let start_line = self
346            .current_command_start
347            .take()
348            .or_else(|| self.prompt_lines.last().copied())
349            .unwrap_or(end_line);
350
351        // For multi-line prompts: PromptStart (A) fires at the top of the prompt
352        // while CommandStart (B) fires at the cursor line below. Prefer the earlier
353        // PromptStart line so the separator/mark appears at the top of the prompt.
354        let start_line = match self.prompt_lines.binary_search(&start_line) {
355            Ok(_) => start_line, // exact match already in prompt_lines, keep it
356            Err(pos) if pos > 0 => {
357                let prev = self.prompt_lines[pos - 1];
358                if start_line - prev <= MAX_PROMPT_HEIGHT {
359                    prev
360                } else {
361                    start_line
362                }
363            }
364            _ => start_line,
365        };
366
367        self.current_command_start_time_ms = None;
368
369        // Ensure a mark exists even if no prompt marker was recorded.
370        self.record_prompt_line(start_line, Some(command.start_time));
371
372        self.line_to_command.insert(start_line, command.id);
373        let start_time = command.start_time;
374        self.commands.insert(command.id, command);
375        self.line_timestamps.entry(start_line).or_insert(start_time);
376        start_line
377    }
378}
379
380fn now_ms() -> u64 {
381    SystemTime::now()
382        .duration_since(UNIX_EPOCH)
383        .unwrap_or_default()
384        .as_millis() as u64
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    fn snapshot(id: usize, exit_code: i32, start_time: u64, duration_ms: u64) -> CommandSnapshot {
392        CommandSnapshot {
393            id,
394            command: Some(format!("cmd-{id}")),
395            start_time,
396            end_time: Some(start_time + duration_ms),
397            exit_code: Some(exit_code),
398            duration_ms: Some(duration_ms),
399        }
400    }
401
402    #[test]
403    fn records_prompt_and_command() {
404        let mut meta = ScrollbackMetadata::new();
405
406        // absolute_line = 15 (scrollback 10 + cursor 5)
407        meta.apply_event(Some(ShellIntegrationMarker::PromptStart), 15, 0, None, None);
408        meta.apply_event(
409            Some(ShellIntegrationMarker::CommandExecuted),
410            15,
411            0,
412            None,
413            None,
414        );
415        meta.apply_event(
416            Some(ShellIntegrationMarker::CommandFinished),
417            15,
418            1,
419            Some(snapshot(0, 0, 1_000, 500)),
420            None,
421        );
422
423        let marks = meta.marks();
424        assert_eq!(marks.len(), 1);
425        let mark = &marks[0];
426        assert_eq!(mark.line, 15);
427        assert_eq!(mark.exit_code, Some(0));
428        assert_eq!(mark.start_time, Some(1_000));
429    }
430
431    #[test]
432    fn navigation_prev_next() {
433        let mut meta = ScrollbackMetadata::new();
434
435        meta.apply_event(Some(ShellIntegrationMarker::PromptStart), 5, 0, None, None);
436        meta.apply_event(Some(ShellIntegrationMarker::PromptStart), 10, 0, None, None);
437
438        assert_eq!(meta.previous_mark(7), Some(5));
439        assert_eq!(meta.next_mark(5), Some(10));
440    }
441
442    #[test]
443    fn records_when_history_advances_without_marker() {
444        let mut meta = ScrollbackMetadata::new();
445        let cmd = snapshot(0, 1, 2_000, 300);
446
447        // No marker but history length increased, absolute_line = 15
448        meta.apply_event(None, 15, 1, Some(cmd), Some(1));
449
450        let marks = meta.marks();
451        assert_eq!(marks.len(), 1);
452        assert_eq!(marks[0].line, 15);
453        assert_eq!(marks[0].exit_code, Some(1));
454    }
455
456    #[test]
457    fn records_when_exit_code_arrives_without_history() {
458        let mut meta = ScrollbackMetadata::new();
459
460        // Simulate prompt and command start at line 20
461        meta.apply_event(Some(ShellIntegrationMarker::PromptStart), 20, 0, None, None);
462        meta.apply_event(
463            Some(ShellIntegrationMarker::CommandStart),
464            20,
465            0,
466            None,
467            None,
468        );
469
470        // Command finishes, shell sends exit code but core history does not advance
471        meta.apply_event(
472            Some(ShellIntegrationMarker::CommandFinished),
473            23,
474            0,
475            None,
476            Some(42),
477        );
478
479        let marks = meta.marks();
480        assert_eq!(marks.len(), 1);
481        assert_eq!(marks[0].line, 20);
482        assert_eq!(marks[0].exit_code, Some(42));
483        assert!(marks[0].start_time.is_some());
484        assert!(marks[0].duration_ms.is_some());
485    }
486
487    #[test]
488    fn synthesizes_exit_code_without_finished_marker() {
489        let mut meta = ScrollbackMetadata::new();
490
491        meta.apply_event(Some(ShellIntegrationMarker::CommandStart), 0, 0, None, None);
492
493        meta.apply_event(None, 1, 0, None, Some(7));
494
495        let marks = meta.marks();
496        assert_eq!(marks.len(), 1);
497        assert_eq!(marks[0].line, 0);
498        assert_eq!(marks[0].exit_code, Some(7));
499    }
500
501    #[test]
502    fn records_multiple_commands_when_history_missing() {
503        let mut meta = ScrollbackMetadata::new();
504
505        // First command (exit 0) at line 0
506        meta.apply_event(Some(ShellIntegrationMarker::CommandStart), 0, 0, None, None);
507        meta.apply_event(None, 0, 0, None, Some(0));
508
509        // Second command, same exit code but new prompt line at line 10
510        meta.apply_event(
511            Some(ShellIntegrationMarker::CommandStart),
512            10,
513            0,
514            None,
515            None,
516        );
517        meta.apply_event(None, 10, 0, None, Some(0));
518
519        let marks = meta.marks();
520        assert_eq!(marks.len(), 2);
521        assert_eq!(marks[0].exit_code, Some(0));
522        assert_eq!(marks[1].exit_code, Some(0));
523        assert_eq!(marks[1].line, 10);
524    }
525
526    #[test]
527    fn multiline_prompt_mark_at_prompt_start() {
528        let mut meta = ScrollbackMetadata::new();
529
530        // PromptStart (A) at line 10 (top of 2-line prompt)
531        meta.apply_event(Some(ShellIntegrationMarker::PromptStart), 10, 0, None, None);
532        // CommandStart (B) at line 11 (cursor line, bottom of prompt)
533        meta.apply_event(
534            Some(ShellIntegrationMarker::CommandStart),
535            11,
536            0,
537            None,
538            None,
539        );
540        // CommandFinished with exit code at line 14
541        meta.apply_event(
542            Some(ShellIntegrationMarker::CommandFinished),
543            14,
544            1,
545            Some(snapshot(0, 0, 1_000, 500)),
546            None,
547        );
548
549        let marks = meta.marks();
550        // Should have one mark at line 10 (PromptStart), NOT line 11 (CommandStart)
551        assert_eq!(marks.len(), 1);
552        assert_eq!(marks[0].line, 10);
553        assert_eq!(marks[0].exit_code, Some(0));
554    }
555
556    #[test]
557    fn clear_resets_all_state() {
558        let mut meta = ScrollbackMetadata::new();
559
560        meta.apply_event(Some(ShellIntegrationMarker::PromptStart), 15, 0, None, None);
561        meta.apply_event(
562            Some(ShellIntegrationMarker::CommandFinished),
563            15,
564            1,
565            Some(snapshot(0, 0, 1_000, 500)),
566            None,
567        );
568
569        assert_eq!(meta.marks().len(), 1);
570
571        meta.clear();
572
573        assert!(meta.marks().is_empty());
574        assert_eq!(meta.previous_mark(100), None);
575        assert_eq!(meta.next_mark(0), None);
576    }
577
578    /// Single-line prompt: A and B on the same line (e.g. `$ `)
579    #[test]
580    fn single_line_prompt() {
581        let mut meta = ScrollbackMetadata::new();
582
583        // Prompt 1: A and B both at line 0
584        meta.apply_event(Some(ShellIntegrationMarker::PromptStart), 0, 0, None, None);
585        meta.apply_event(Some(ShellIntegrationMarker::CommandStart), 0, 0, None, None);
586
587        // Command finishes, prompt 2 at line 2 (line 0 = prompt, line 1 = output)
588        meta.apply_event(Some(ShellIntegrationMarker::PromptStart), 2, 0, None, None);
589        meta.apply_event(
590            Some(ShellIntegrationMarker::CommandFinished),
591            2,
592            1,
593            Some(snapshot(0, 0, 1_000, 100)),
594            None,
595        );
596        meta.apply_event(Some(ShellIntegrationMarker::CommandStart), 2, 0, None, None);
597
598        let marks = meta.marks();
599        assert_eq!(marks.len(), 2);
600        assert_eq!(marks[0].line, 0);
601        assert_eq!(marks[0].exit_code, Some(0));
602        assert_eq!(marks[1].line, 2);
603    }
604
605    /// 3-line prompt (e.g. git info + path + cursor): A at top, B 2 lines below
606    #[test]
607    fn three_line_prompt() {
608        let mut meta = ScrollbackMetadata::new();
609
610        // Prompt 1: A at line 0, B at line 2 (3-line prompt)
611        meta.apply_event(Some(ShellIntegrationMarker::PromptStart), 0, 0, None, None);
612        meta.apply_event(Some(ShellIntegrationMarker::CommandStart), 2, 0, None, None);
613
614        // Command output on lines 3-5, then prompt 2 at line 6
615        meta.apply_event(Some(ShellIntegrationMarker::PromptStart), 6, 0, None, None);
616        meta.apply_event(
617            Some(ShellIntegrationMarker::CommandFinished),
618            6,
619            1,
620            Some(snapshot(0, 42, 1_000, 200)),
621            None,
622        );
623        meta.apply_event(Some(ShellIntegrationMarker::CommandStart), 8, 0, None, None);
624
625        let marks = meta.marks();
626        assert_eq!(marks.len(), 2);
627        // Command should be associated with line 0 (A marker), not line 2 (B marker)
628        assert_eq!(marks[0].line, 0);
629        assert_eq!(marks[0].exit_code, Some(42));
630        assert_eq!(marks[1].line, 6);
631    }
632
633    /// Tall prompt (6 lines, e.g. starship with many segments): A at top, B far below
634    #[test]
635    fn tall_prompt_mark_at_top() {
636        let mut meta = ScrollbackMetadata::new();
637
638        // Prompt 1: A at line 0, B at line 5 (6-line prompt)
639        meta.apply_event(Some(ShellIntegrationMarker::PromptStart), 0, 0, None, None);
640        meta.apply_event(Some(ShellIntegrationMarker::CommandStart), 5, 0, None, None);
641
642        // Command output on lines 6-8, then prompt 2 at line 9
643        meta.apply_event(Some(ShellIntegrationMarker::PromptStart), 9, 0, None, None);
644        meta.apply_event(
645            Some(ShellIntegrationMarker::CommandFinished),
646            9,
647            1,
648            Some(snapshot(0, 1, 2_000, 300)),
649            None,
650        );
651        meta.apply_event(
652            Some(ShellIntegrationMarker::CommandStart),
653            14,
654            0,
655            None,
656            None,
657        );
658
659        let marks = meta.marks();
660        assert_eq!(marks.len(), 2);
661        // Even with a 6-line prompt, the mark should be at line 0 (A), not line 5 (B)
662        assert_eq!(marks[0].line, 0);
663        assert_eq!(marks[0].exit_code, Some(1));
664        assert_eq!(marks[1].line, 9);
665    }
666
667    /// Multiple consecutive commands with single-line prompts produce separate marks
668    #[test]
669    fn consecutive_single_line_prompts() {
670        let mut meta = ScrollbackMetadata::new();
671
672        // Prompt 1 at line 0
673        meta.apply_event(Some(ShellIntegrationMarker::PromptStart), 0, 0, None, None);
674        meta.apply_event(Some(ShellIntegrationMarker::CommandStart), 0, 0, None, None);
675
676        // Prompt 2 at line 2 (1 line of output)
677        meta.apply_event(Some(ShellIntegrationMarker::PromptStart), 2, 0, None, None);
678        meta.apply_event(
679            Some(ShellIntegrationMarker::CommandFinished),
680            2,
681            1,
682            Some(snapshot(0, 0, 1_000, 100)),
683            None,
684        );
685        meta.apply_event(Some(ShellIntegrationMarker::CommandStart), 2, 0, None, None);
686
687        // Prompt 3 at line 4 (1 line of output)
688        meta.apply_event(Some(ShellIntegrationMarker::PromptStart), 4, 0, None, None);
689        meta.apply_event(
690            Some(ShellIntegrationMarker::CommandFinished),
691            4,
692            2,
693            Some(snapshot(1, 0, 2_000, 100)),
694            None,
695        );
696        meta.apply_event(Some(ShellIntegrationMarker::CommandStart), 4, 0, None, None);
697
698        // Prompt 4 at line 6 (1 line of output)
699        meta.apply_event(Some(ShellIntegrationMarker::PromptStart), 6, 0, None, None);
700        meta.apply_event(
701            Some(ShellIntegrationMarker::CommandFinished),
702            6,
703            3,
704            Some(snapshot(2, 127, 3_000, 100)),
705            None,
706        );
707        meta.apply_event(Some(ShellIntegrationMarker::CommandStart), 6, 0, None, None);
708
709        let marks = meta.marks();
710        assert_eq!(marks.len(), 4, "each prompt should have its own mark");
711        assert_eq!(marks[0].line, 0);
712        assert_eq!(marks[1].line, 2);
713        assert_eq!(marks[2].line, 4);
714        assert_eq!(marks[3].line, 6);
715        assert_eq!(marks[0].exit_code, Some(0));
716        assert_eq!(marks[1].exit_code, Some(0));
717        assert_eq!(marks[2].exit_code, Some(127));
718    }
719}