Skip to main content

agentmux_srv/backend/viewer/
terminal.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The pure pieces of a paired device's terminal feed
5//! (`GET /agentmux/viewer/terminals/:block_id/feed`, the route in
6//! `server::http_viewer_terminal`): where a snapshot of a terminal's output starts,
7//! and how each published append moves the feed's byte cursor.
8//!
9//! A terminal's output is the block's `term` file: the PTY's raw bytes, ANSI
10//! escapes and all, appended in order. Each append is published with the
11//! byte offset it is written at, before it is written, so a feed that
12//! subscribes, then reads the file's size, can place every later event: an
13//! event that ends at or before the cursor was in the snapshot, one that
14//! starts past it means bytes were missed.
15
16/// How much of a terminal's output a snapshot holds, at most.
17pub const SNAPSHOT_TAIL_BYTES: u64 = 256 * 1024;
18/// How far into a tail a snapshot looks for a line start.
19const LINE_START_WINDOW: usize = 64 * 1024;
20
21/// Where a snapshot of `data`, read from byte `offset` of the file, starts:
22/// right after its first newline when it doesn't start the file, so the
23/// device's terminal doesn't open in the middle of an escape sequence or a
24/// character; all of it when it starts the file, or has no newline early on.
25pub fn tail_start(offset: u64, data: &[u8]) -> usize {
26    if offset == 0 {
27        return 0;
28    }
29    let window = &data[..data.len().min(LINE_START_WINDOW)];
30    window.iter().position(|b| *b == b'\n').map_or(0, |i| i + 1)
31}
32
33/// What to do with one published append.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum TermStep {
36    /// Send these bytes, written at `offset`; the cursor has moved past them.
37    Send { offset: u64, data: Vec<u8> },
38    /// Already sent (the snapshot held them).
39    Skip,
40    /// The feed can't continue from here: reset and send a fresh snapshot.
41    Resync(&'static str),
42}
43
44/// Apply one published append, written at `offset`, to a cursor at `next`.
45pub fn apply_term_append(next: &mut u64, offset: Option<u64>, data: &[u8]) -> TermStep {
46    let Some(offset) = offset else {
47        // A writer that didn't say where: nothing to place it by.
48        return TermStep::Resync("offset");
49    };
50    let end = offset + data.len() as u64;
51    if end <= *next {
52        return TermStep::Skip;
53    }
54    if offset > *next {
55        return TermStep::Resync("gap");
56    }
57    let skip = (*next - offset) as usize;
58    let at = *next;
59    *next = end;
60    TermStep::Send { offset: at, data: data[skip..].to_vec() }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66
67    #[test]
68    fn a_tail_starts_after_its_first_newline_unless_it_starts_the_file() {
69        assert_eq!(tail_start(0, b"\x1b[1mbold\nnext"), 0, "the file's start");
70        assert_eq!(tail_start(10, b"[1mbold\nnext\n"), 8, "mid escape: from the next line");
71        assert_eq!(tail_start(10, b"no newline at all"), 0);
72        let mut late = vec![b'x'; LINE_START_WINDOW];
73        late.extend_from_slice(b"\nrest");
74        assert_eq!(tail_start(10, &late), 0, "a newline past the window isn't looked for");
75    }
76
77    #[test]
78    fn appends_continue_the_cursor_and_drop_what_was_sent() {
79        let mut next = 10;
80        assert_eq!(apply_term_append(&mut next, Some(10), b"abc"), TermStep::Send { offset: 10, data: b"abc".to_vec() });
81        assert_eq!(next, 13);
82        assert_eq!(apply_term_append(&mut next, Some(8), b"xyz"), TermStep::Skip, "ends at the cursor");
83        assert_eq!(apply_term_append(&mut next, Some(11), b"cdef"), TermStep::Send { offset: 13, data: b"ef".to_vec() });
84        assert_eq!(next, 15);
85    }
86
87    #[test]
88    fn a_gap_or_an_unplaced_append_resyncs() {
89        let mut next = 10;
90        assert_eq!(apply_term_append(&mut next, Some(11), b"x"), TermStep::Resync("gap"));
91        assert_eq!(apply_term_append(&mut next, None, b"x"), TermStep::Resync("offset"));
92        assert_eq!(next, 10, "nothing moved");
93    }
94}