namespace DodoSSH.Client.Terminal; /// What one session's output has most recently looked like. /// The last bytes the remote wrote, oldest first. /// /// Whether the remote has switched to the alternate screen — vi, less, htop — and not yet switched back. /// public sealed record RecentOutput(byte[] Tail, bool AlternateScreen); /// /// The last kilobyte a session's remote wrote, and whether a full-screen program is running. /// /// /// /// This exists so that the phone's command history only keeps what the remote echoed. A terminal is /// one input stream, and the client cannot tell a shell prompt from a sudo password prompt by what is /// typed into it — only by whether the characters came back. A password typed with echo off leaves the /// prompt line exactly as it was, and that is the test the history applies before it keeps anything. See /// TypedCommandHistory in the shell. /// /// /// Kept beside the pump's read loop, and costing that loop almost nothing. Each read copies at most /// bytes into a ring and runs one vectorised search over the read for the /// alternate-screen switches, with no allocation. Locked rather than lock-free /// because it is written from the read loop's thread and read from the UI thread on Enter, and an Enter is /// rare enough that a lock is the honest price. /// /// /// Nothing here outlives the session or leaves this process. It is the same bytes the renderer is shown, /// held for as long as it takes for the next kilobyte to push them out. /// /// public sealed class OutputTail { /// How much output is kept. /// /// A prompt and a long command line with room to spare. A line longer than this is not recorded at all, /// which is the safe direction to fail in. /// public const int Capacity = 1024; /// The longest switch sequence, less one: how far a match can straddle two reads. private const int Overlap = 7; private readonly Lock gate = new(); private readonly byte[] ring = new byte[Capacity]; private int start; private int count; private bool alternateScreen; /// Takes a read's worth of output. public void Append(ReadOnlySpan output) { if (output.IsEmpty) { return; } lock (gate) { // The seam first — the end of what is already held and the start of the new bytes — so a switch // split across two reads is still seen whole; then the whole read, in which a later switch wins. // All of it rather than its tail: a program that switches screens and then paints sixty // kilobytes in the same read is the ordinary case, not the odd one. var carried = Math.Min(count, Overlap); Span seam = stackalloc byte[carried + Math.Min(output.Length, Overlap)]; CopyLast(seam[..carried]); output[..(seam.Length - carried)].CopyTo(seam[carried..]); alternateScreen = ScanForScreenSwitch(output, ScanForScreenSwitch(seam, alternateScreen)); foreach (var b in output.Length > Capacity ? output[^Capacity..] : output) { ring[(start + count) % Capacity] = b; if (count < Capacity) { count++; } else { start = (start + 1) % Capacity; } } } } /// A copy of what is held, oldest byte first. public RecentOutput Snapshot() { lock (gate) { var copy = new byte[count]; CopyLast(copy); return new RecentOutput(copy, alternateScreen); } } /// Copies the last destination.Length held bytes. Called under the lock. private void CopyLast(Span destination) { var offset = count - destination.Length; for (var i = 0; i < destination.Length; i++) { destination[i] = ring[(start + offset + i) % Capacity]; } } /// /// Whether the screen is alternate after this output, given whether it was before. /// /// /// The three DEC private modes programs use for it — 1049, which every modern one sends, and the older /// 47 and 1047 — set with h and reset with l. The last switch in the window wins. /// private static bool ScanForScreenSwitch(ReadOnlySpan window, bool current) { ReadOnlySpan prefix = [0x1B, (byte)'[', (byte)'?']; var state = current; var at = 0; while (at < window.Length) { var found = window[at..].IndexOf(prefix); if (found < 0) { break; } var digits = at + found + prefix.Length; var end = digits; while (end < window.Length && window[end] is >= (byte)'0' and <= (byte)'9') { end++; } if (end < window.Length && window[end] is (byte)'h' or (byte)'l') { var mode = window[digits..end]; if (mode.SequenceEqual("1049"u8) || mode.SequenceEqual("1047"u8) || mode.SequenceEqual("47"u8)) { state = window[end] == (byte)'h'; } } at = digits; } return state; } }