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;
}
}