Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 26 additions & 9 deletions MultiplexorApp/lib/services/monitor/monitor_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ class MonitorScreen {
int _selectedIndex = 0;
Duration _range = monitorRanges.first;
bool _forceFull = true;
int _incrementalCharactersSinceFullFrame = 0;
int _lastColumns = -1;
int _lastLines = -1;
final MonitorGeometryStabilizer _geometry = MonitorGeometryStabilizer();
Expand Down Expand Up @@ -344,6 +345,7 @@ class MonitorScreen {
void _resetViewState() {
_last = null;
_forceFull = true;
_incrementalCharactersSinceFullFrame = 0;
_lastColumns = -1;
_lastLines = -1;
_geometry.reset();
Expand Down Expand Up @@ -513,24 +515,39 @@ class MonitorScreen {
_pressedId = _liveId(_pressedId);

final String text = frame.rows.join('\n');
final String patch = renderTerminalPatch(
bool fullFrame = _forceFull;
String patch = renderTerminalPatch(
previous: _last,
next: text,
forceFull: _forceFull,
forceFull: fullFrame,
);
_last = text;
_forceFull = false;
if (patch.isEmpty) {
return;
}
// Raw mode turns OPOST off, so a bare '\n' drops a line without
// returning the carriage and stair-steps the frame. Only the full-frame
// path contains newlines at all — a patch addresses each line by cursor
// position — so that is the only one that needs them expanded.
final String normalized = patch.startsWith(_fullFramePrefix)
String normalized = patch.startsWith(_fullFramePrefix)
? patch.replaceAll('\n', '\r\n')
: patch;
stdout.write(synchronizeTerminalPatch(normalized));
String output = synchronizeTerminalPatch(normalized);
if (!fullFrame &&
terminalFullFrameCheckpointDue(
charactersSinceFullFrame: _incrementalCharactersSinceFullFrame,
nextPatchCharacters: output.length,
)) {
fullFrame = true;
patch = renderTerminalPatch(previous: _last, next: text, forceFull: true);
normalized = patch.replaceAll('\n', '\r\n');
output = synchronizeTerminalPatch(normalized);
}
_last = text;
_forceFull = false;
if (output.isEmpty) {
return;
}
stdout.write(output);
_incrementalCharactersSinceFullFrame = fullFrame
? 0
: _incrementalCharactersSinceFullFrame + output.length;
}

/// Composes [modal]'s card over [base], carrying the instance's latest
Expand Down
17 changes: 17 additions & 0 deletions MultiplexorApp/lib/utils/terminal/frame_patch.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
/// Maximum incremental terminal output emitted between complete frames.
///
/// Periodic complete frames act as replay checkpoints for terminal hosts that
/// detach and later reconstruct their display from a bounded output history.
/// The budget advances only when a visible patch is emitted, so an idle
/// dashboard still writes nothing.
const int terminalFullFrameCheckpointCharacters = 1_000_000;

/// Whether [nextPatchCharacters] would exhaust the incremental output budget.
bool terminalFullFrameCheckpointDue({
required int charactersSinceFullFrame,
required int nextPatchCharacters,
int threshold = terminalFullFrameCheckpointCharacters,
}) =>
nextPatchCharacters > 0 &&
charactersSinceFullFrame + nextPatchCharacters >= threshold;

/// Computes a differential ANSI patch that turns a previously rendered
/// terminal frame into [next], writing only the lines that actually
/// changed. This keeps full-screen dashboard repaints flicker-free by
Expand Down
36 changes: 36 additions & 0 deletions MultiplexorApp/test/frame_patch_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,42 @@ import 'package:multiplexor/utils/terminal/frame_patch.dart';
import 'package:test/test.dart';

void main() {
group('terminalFullFrameCheckpointDue', () {
test('uses a one-million-character default budget', () {
expect(terminalFullFrameCheckpointCharacters, 1_000_000);
});

test('keeps incremental output below the checkpoint budget', () {
expect(
terminalFullFrameCheckpointDue(
charactersSinceFullFrame: 900_000,
nextPatchCharacters: 99_999,
),
isFalse,
);
});

test('requests a checkpoint when the next patch reaches the budget', () {
expect(
terminalFullFrameCheckpointDue(
charactersSinceFullFrame: 900_000,
nextPatchCharacters: 100_000,
),
isTrue,
);
});

test('does not checkpoint an empty idle update', () {
expect(
terminalFullFrameCheckpointDue(
charactersSinceFullFrame: terminalFullFrameCheckpointCharacters,
nextPatchCharacters: 0,
),
isFalse,
);
});
});

group('renderTerminalPatch', () {
test('renders a full clear and the frame content on the first render', () {
const String next = 'line one\nline two';
Expand Down