Reset terminal mainline to restore-output xterm
This commit is contained in:
parent
6fe97e7d8a
commit
b460957bcf
10
README.md
10
README.md
@ -24,3 +24,13 @@ TermRemoteCtl is a personal remote coding controller for one Windows workstation
|
||||
- `flutter test apps/mobile_app/test`
|
||||
- `flutter test apps/mobile_app/integration_test`
|
||||
- The Flutter `integration_test` suite may still require a supported local runtime target or device on the machine running it.
|
||||
|
||||
## Terminal Truth Source
|
||||
|
||||
The stable product path uses one rendering truth source:
|
||||
|
||||
- backend `restore` plus `output` provide the durable terminal data
|
||||
- frontend `xterm` is the only visible renderer on the mainline
|
||||
- local mobile snapshots are provisional cache only
|
||||
|
||||
Backend `screen_snapshot`, `screen_patch`, and `screen_sync` are experiment-only paths. They must stay behind an explicit opt-in switch and must not drive the default product terminal.
|
||||
|
||||
@ -13,24 +13,14 @@ class AgentSocketClient {
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> buildAttachMessage(String sessionId) => <String, dynamic>{
|
||||
'type': 'attach',
|
||||
'sessionId': sessionId,
|
||||
};
|
||||
Map<String, dynamic> buildAttachMessage(String sessionId) =>
|
||||
<String, dynamic>{'type': 'attach', 'sessionId': sessionId};
|
||||
|
||||
Map<String, dynamic> buildInputMessage(String input) => <String, dynamic>{
|
||||
'type': 'input',
|
||||
'input': input,
|
||||
};
|
||||
'type': 'input',
|
||||
'input': input,
|
||||
};
|
||||
|
||||
Map<String, dynamic> buildResizeMessage(int columns, int rows) =>
|
||||
<String, dynamic>{
|
||||
'type': 'resize',
|
||||
'columns': columns,
|
||||
'rows': rows,
|
||||
};
|
||||
|
||||
Map<String, dynamic> buildScreenSyncMessage() => <String, dynamic>{
|
||||
'type': 'screen_sync',
|
||||
};
|
||||
<String, dynamic>{'type': 'resize', 'columns': columns, 'rows': rows};
|
||||
}
|
||||
|
||||
@ -22,9 +22,6 @@ import 'repeatable_terminal_key_button.dart';
|
||||
import 'terminal_interaction_controller.dart';
|
||||
import 'terminal_restore_payload.dart';
|
||||
import 'terminal_restore_decision.dart';
|
||||
import 'terminal_screen_patch.dart';
|
||||
import 'terminal_screen_state.dart';
|
||||
import 'terminal_screen_snapshot.dart';
|
||||
import 'terminal_session_coordinator.dart';
|
||||
import 'terminal_snapshot.dart';
|
||||
import 'terminal_snapshot_storage.dart';
|
||||
@ -154,7 +151,6 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
String? _pendingHistorySeed;
|
||||
bool _receivedSocketFrame = false;
|
||||
bool _receivedRestorePayload = false;
|
||||
bool _receivedScreenSnapshot = false;
|
||||
bool _historySeeded = false;
|
||||
bool _awaitingAttachReplayFrame = true;
|
||||
bool _awaitingReconnectRestore = false;
|
||||
@ -162,7 +158,6 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
bool _showExpandedControls = false;
|
||||
_TerminalInputMode _inputMode = _TerminalInputMode.read;
|
||||
TerminalConnectionState? _lastConnectionState;
|
||||
TerminalScreenState? _authoritativeScreenState;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -179,8 +174,6 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
onFrame: _handleTerminalFrame,
|
||||
onRestore: _handleRestorePayload,
|
||||
onHistoryLoaded: _handleHistoryLoaded,
|
||||
onScreenSnapshot: _handleScreenSnapshot,
|
||||
onScreenPatch: _handleScreenPatch,
|
||||
viewportProvider: () => TerminalViewport(
|
||||
columns: terminal.viewWidth,
|
||||
rows: terminal.viewHeight,
|
||||
@ -354,6 +347,12 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
_terminalFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
void _restoreEditFocusIfNeeded() {
|
||||
if (_inputMode == _TerminalInputMode.edit) {
|
||||
_terminalFocusNode.requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _copySelectedOrVisibleText() async {
|
||||
final selection = _terminalViewController.selection;
|
||||
final selectedText = selection == null
|
||||
@ -397,11 +396,6 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
_receivedSocketFrame = true;
|
||||
_awaitingReconnectRestore = false;
|
||||
_cancelHistorySeedTimer();
|
||||
if (_authoritativeScreenState != null) {
|
||||
_scheduleSnapshotPersist();
|
||||
return;
|
||||
}
|
||||
|
||||
terminal.write(frame);
|
||||
_scheduleSnapshotPersist();
|
||||
}
|
||||
@ -412,11 +406,6 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
_receivedRestorePayload = true;
|
||||
_awaitingReconnectRestore = false;
|
||||
_cancelHistorySeedTimer();
|
||||
if (_authoritativeScreenState != null) {
|
||||
_scheduleSnapshotPersist();
|
||||
return;
|
||||
}
|
||||
|
||||
final combined = restore.screenText + restore.pendingInput;
|
||||
if (combined.isEmpty) {
|
||||
_scheduleSnapshotPersist();
|
||||
@ -435,43 +424,6 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
_scheduleSnapshotPersist();
|
||||
}
|
||||
|
||||
void _handleScreenSnapshot(TerminalScreenSnapshot snapshot) {
|
||||
_awaitingAttachReplayFrame = false;
|
||||
_receivedSocketFrame = true;
|
||||
_receivedScreenSnapshot = true;
|
||||
_awaitingReconnectRestore = false;
|
||||
_cancelHistorySeedTimer();
|
||||
_authoritativeScreenState = TerminalScreenState.fromSnapshot(snapshot);
|
||||
final displayText = _authoritativeScreenState!.toDisplayText();
|
||||
_resetTerminalForSnapshot();
|
||||
if (displayText.isNotEmpty) {
|
||||
terminal.write(displayText);
|
||||
}
|
||||
_historySeeded = _terminalHasVisibleContent;
|
||||
_scheduleSnapshotPersist();
|
||||
}
|
||||
|
||||
void _handleScreenPatch(TerminalScreenPatch patch) {
|
||||
final currentState = _authoritativeScreenState;
|
||||
if (currentState == null || !currentState.canApplyPatch(patch)) {
|
||||
return;
|
||||
}
|
||||
|
||||
_awaitingAttachReplayFrame = false;
|
||||
_receivedSocketFrame = true;
|
||||
_receivedScreenSnapshot = true;
|
||||
_awaitingReconnectRestore = false;
|
||||
_cancelHistorySeedTimer();
|
||||
_authoritativeScreenState = currentState.applyPatch(patch);
|
||||
final displayText = _authoritativeScreenState!.toDisplayText();
|
||||
_resetTerminalForSnapshot();
|
||||
if (displayText.isNotEmpty) {
|
||||
terminal.write(displayText);
|
||||
}
|
||||
_historySeeded = _terminalHasVisibleContent;
|
||||
_scheduleSnapshotPersist();
|
||||
}
|
||||
|
||||
void _handleHistoryLoaded(HistoryWindow history) {
|
||||
final seedText = _buildHistorySeedText(history.lines);
|
||||
if (seedText.isEmpty) {
|
||||
@ -490,8 +442,6 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
connectionState == TerminalConnectionState.reconnecting) {
|
||||
_awaitingAttachReplayFrame = true;
|
||||
_receivedRestorePayload = false;
|
||||
_receivedScreenSnapshot = false;
|
||||
_authoritativeScreenState = null;
|
||||
if (connectionState == TerminalConnectionState.reconnecting) {
|
||||
_awaitingReconnectRestore = true;
|
||||
_receivedSocketFrame = false;
|
||||
@ -506,11 +456,6 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
}
|
||||
|
||||
void _scheduleHistorySeedIfNeeded() {
|
||||
if (_receivedScreenSnapshot) {
|
||||
_cancelHistorySeedTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_receivedRestorePayload) {
|
||||
_cancelHistorySeedTimer();
|
||||
return;
|
||||
@ -575,12 +520,6 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
terminal.notifyListeners();
|
||||
}
|
||||
|
||||
void _resetTerminalForSnapshot() {
|
||||
terminal.buffer.clear();
|
||||
terminal.buffer.setCursor(0, 0);
|
||||
terminal.notifyListeners();
|
||||
}
|
||||
|
||||
bool _shouldSuppressAttachReplay(String frame) {
|
||||
final normalizedFrame = _trimTrailingNewlines(
|
||||
_normalizeTerminalText(frame),
|
||||
@ -1064,12 +1003,6 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
onPressed: () =>
|
||||
_setInputMode(_TerminalInputMode.edit),
|
||||
),
|
||||
Text(
|
||||
_inputMode == _TerminalInputMode.read
|
||||
? 'Read mode prevents the terminal from taking focus.'
|
||||
: 'Edit mode keeps the terminal ready for typing.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -1228,7 +1161,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
|
||||
Widget _buildStatusRow(BuildContext context) {
|
||||
final mode = controller.isFollowingLiveOutput ? 'Live' : 'Scrollback';
|
||||
final modeLabel = '$mode | ${controller.liveLines.length} lines';
|
||||
final modeLabel = mode;
|
||||
return Wrap(
|
||||
key: const Key('terminal_status_summary'),
|
||||
spacing: 8,
|
||||
@ -1254,12 +1187,13 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
icon: _statusIcon,
|
||||
color: _statusColor(context),
|
||||
),
|
||||
Text(
|
||||
_coordinator.connectionStatus,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
if (_connectionState != TerminalConnectionState.connected)
|
||||
Text(
|
||||
_coordinator.connectionStatus,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
/// Experiment-only model for the backend screen protocol.
|
||||
class TerminalScreenSnapshot {
|
||||
const TerminalScreenSnapshot({
|
||||
required this.sessionId,
|
||||
@ -51,7 +52,38 @@ class TerminalScreenSnapshot {
|
||||
final buffer = activeBuffer == 'alternate' && alternateBuffer != null
|
||||
? alternateBuffer!
|
||||
: primaryBuffer;
|
||||
return buffer.viewport.map((line) => line.text).join('\n');
|
||||
final lines = List<String>.filled(rows, '');
|
||||
for (final line in buffer.viewport) {
|
||||
if (line.index >= 0 && line.index < lines.length) {
|
||||
lines[line.index] = line.text;
|
||||
}
|
||||
}
|
||||
|
||||
return renderTerminalScreenText(
|
||||
lines: lines,
|
||||
cursorRow: cursorRow,
|
||||
cursorColumn: cursorColumn,
|
||||
cursorVisible: cursorVisible,
|
||||
);
|
||||
}
|
||||
|
||||
String toReplaySequence() {
|
||||
final buffer = activeBuffer == 'alternate' && alternateBuffer != null
|
||||
? alternateBuffer!
|
||||
: primaryBuffer;
|
||||
final lines = List<String>.filled(rows, '');
|
||||
for (final line in buffer.viewport) {
|
||||
if (line.index >= 0 && line.index < lines.length) {
|
||||
lines[line.index] = line.text;
|
||||
}
|
||||
}
|
||||
|
||||
return buildTerminalScreenReplay(
|
||||
lines: lines,
|
||||
cursorRow: cursorRow,
|
||||
cursorColumn: cursorColumn,
|
||||
cursorVisible: cursorVisible,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -87,3 +119,119 @@ class TerminalScreenLine {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String renderTerminalScreenText({
|
||||
required List<String> lines,
|
||||
required int cursorRow,
|
||||
required int cursorColumn,
|
||||
required bool cursorVisible,
|
||||
}) {
|
||||
var lastContentRow = -1;
|
||||
for (var index = 0; index < lines.length; index += 1) {
|
||||
if (_trimRight(lines[index]).isNotEmpty) {
|
||||
lastContentRow = index;
|
||||
}
|
||||
}
|
||||
|
||||
final lastVisibleRow = _clampLastVisibleRow(
|
||||
lines.length,
|
||||
lastContentRow: lastContentRow,
|
||||
cursorRow: cursorVisible ? cursorRow : -1,
|
||||
);
|
||||
if (lastVisibleRow < 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return List<String>.generate(lastVisibleRow + 1, (row) {
|
||||
final line = lines[row];
|
||||
final trimmed = _trimRight(line);
|
||||
if (!cursorVisible || row != cursorRow) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
final targetWidth = cursorColumn.clamp(0, line.length);
|
||||
if (targetWidth <= trimmed.length) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return line.substring(0, targetWidth);
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
String buildTerminalScreenReplay({
|
||||
required List<String> lines,
|
||||
required int cursorRow,
|
||||
required int cursorColumn,
|
||||
required bool cursorVisible,
|
||||
}) {
|
||||
final lastContentRow = _findLastContentRow(lines);
|
||||
final lastVisibleRow = _clampLastVisibleRow(
|
||||
lines.length,
|
||||
lastContentRow: lastContentRow,
|
||||
cursorRow: cursorVisible ? cursorRow : -1,
|
||||
);
|
||||
final replay = StringBuffer()..write('\u001b[2J\u001b[H');
|
||||
|
||||
for (var row = 0; row <= lastVisibleRow; row += 1) {
|
||||
replay.write('\u001b[');
|
||||
replay.write(row + 1);
|
||||
replay.write(';1H\u001b[2K');
|
||||
|
||||
final text = _trimRight(lines[row]);
|
||||
if (text.isNotEmpty) {
|
||||
replay.write(text);
|
||||
}
|
||||
}
|
||||
|
||||
if (cursorVisible && lines.isNotEmpty) {
|
||||
final targetRow = cursorRow.clamp(0, lines.length - 1);
|
||||
final targetColumn = cursorColumn.clamp(0, lines[targetRow].length);
|
||||
replay.write('\u001b[');
|
||||
replay.write(targetRow + 1);
|
||||
replay.write(';');
|
||||
replay.write(targetColumn + 1);
|
||||
replay.write('H');
|
||||
}
|
||||
|
||||
return replay.toString();
|
||||
}
|
||||
|
||||
int _clampLastVisibleRow(
|
||||
int lineCount, {
|
||||
required int lastContentRow,
|
||||
required int cursorRow,
|
||||
}) {
|
||||
if (lineCount <= 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
var lastVisibleRow = lastContentRow;
|
||||
if (cursorRow >= 0) {
|
||||
lastVisibleRow = lastVisibleRow > cursorRow ? lastVisibleRow : cursorRow;
|
||||
}
|
||||
|
||||
if (lastVisibleRow < 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return lastVisibleRow >= lineCount ? lineCount - 1 : lastVisibleRow;
|
||||
}
|
||||
|
||||
int _findLastContentRow(List<String> lines) {
|
||||
for (var index = lines.length - 1; index >= 0; index -= 1) {
|
||||
if (_trimRight(lines[index]).isNotEmpty) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
String _trimRight(String value) {
|
||||
var end = value.length;
|
||||
while (end > 0 && value.codeUnitAt(end - 1) == 0x20) {
|
||||
end -= 1;
|
||||
}
|
||||
|
||||
return end == value.length ? value : value.substring(0, end);
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'terminal_screen_patch.dart';
|
||||
import 'terminal_screen_snapshot.dart';
|
||||
|
||||
/// Experiment-only state reducer for backend-authored screen snapshots and patches.
|
||||
class TerminalScreenState {
|
||||
const TerminalScreenState({
|
||||
required this.sessionId,
|
||||
@ -57,12 +58,16 @@ class TerminalScreenState {
|
||||
}
|
||||
|
||||
bool canApplyPatch(TerminalScreenPatch patch) {
|
||||
return sessionId == patch.sessionId && screenVersion == patch.baseScreenVersion;
|
||||
return sessionId == patch.sessionId &&
|
||||
screenVersion == patch.baseScreenVersion;
|
||||
}
|
||||
|
||||
TerminalScreenState applyPatch(TerminalScreenPatch patch) {
|
||||
final nextPrimaryLines = List<String>.from(primaryLines, growable: false);
|
||||
final nextAlternateLines = List<String>.from(alternateLines, growable: false);
|
||||
final nextAlternateLines = List<String>.from(
|
||||
alternateLines,
|
||||
growable: false,
|
||||
);
|
||||
final targetLines = patch.activeBuffer == 'alternate'
|
||||
? nextAlternateLines
|
||||
: nextPrimaryLines;
|
||||
@ -96,6 +101,21 @@ class TerminalScreenState {
|
||||
|
||||
String toDisplayText() {
|
||||
final lines = activeBuffer == 'alternate' ? alternateLines : primaryLines;
|
||||
return lines.join('\n');
|
||||
return renderTerminalScreenText(
|
||||
lines: lines,
|
||||
cursorRow: cursorRow,
|
||||
cursorColumn: cursorColumn,
|
||||
cursorVisible: cursorVisible,
|
||||
);
|
||||
}
|
||||
|
||||
String toReplaySequence() {
|
||||
final lines = activeBuffer == 'alternate' ? alternateLines : primaryLines;
|
||||
return buildTerminalScreenReplay(
|
||||
lines: lines,
|
||||
cursorRow: cursorRow,
|
||||
cursorColumn: cursorColumn,
|
||||
cursorVisible: cursorVisible,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,8 +9,6 @@ import 'terminal_diagnostic_log.dart';
|
||||
import 'terminal_interaction_controller.dart';
|
||||
import 'terminal_output_payload.dart';
|
||||
import 'terminal_restore_payload.dart';
|
||||
import 'terminal_screen_patch.dart';
|
||||
import 'terminal_screen_snapshot.dart';
|
||||
import 'terminal_socket_session.dart';
|
||||
|
||||
typedef CancelReconnect = void Function();
|
||||
@ -40,8 +38,6 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
required this.sessionFactory,
|
||||
required this.onFrame,
|
||||
required this.onRestore,
|
||||
this.onScreenSnapshot,
|
||||
this.onScreenPatch,
|
||||
required this.viewportProvider,
|
||||
Uri? baseUri,
|
||||
this.onHistoryLoaded,
|
||||
@ -68,8 +64,6 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
final TerminalSessionFactory sessionFactory;
|
||||
final void Function(String frame) onFrame;
|
||||
final void Function(TerminalRestorePayload restore) onRestore;
|
||||
final void Function(TerminalScreenSnapshot snapshot)? onScreenSnapshot;
|
||||
final void Function(TerminalScreenPatch patch)? onScreenPatch;
|
||||
final TerminalViewport Function() viewportProvider;
|
||||
final Uri baseUri;
|
||||
final void Function(HistoryWindow history)? onHistoryLoaded;
|
||||
@ -93,9 +87,6 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
int? _lastSentColumns;
|
||||
int? _lastSentRows;
|
||||
int? _lastReceivedSequence;
|
||||
int? _screenVersion;
|
||||
bool _screenProtocolActive = false;
|
||||
bool _screenSyncPending = false;
|
||||
|
||||
bool get isLoadingOlderHistory => _isLoadingOlderHistory;
|
||||
|
||||
@ -111,9 +102,6 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
}
|
||||
|
||||
final sessionGeneration = ++_sessionGeneration;
|
||||
_screenProtocolActive = false;
|
||||
_screenVersion = null;
|
||||
_screenSyncPending = false;
|
||||
|
||||
if (isReconnect) {
|
||||
controller.markReconnecting();
|
||||
@ -156,20 +144,6 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
|
||||
_handleRestore(restore);
|
||||
},
|
||||
onScreenSnapshot: (snapshot) {
|
||||
if (!_isCurrentSession(socketSession, sessionGeneration)) {
|
||||
return;
|
||||
}
|
||||
|
||||
_handleScreenSnapshot(snapshot);
|
||||
},
|
||||
onScreenPatch: (patch) {
|
||||
if (!_isCurrentSession(socketSession, sessionGeneration)) {
|
||||
return;
|
||||
}
|
||||
|
||||
_handleScreenPatch(patch);
|
||||
},
|
||||
onDisconnected: () {
|
||||
if (!_isCurrentSession(socketSession, sessionGeneration)) {
|
||||
return;
|
||||
@ -315,7 +289,8 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
Future<void> _handleOutput(TerminalOutputPayload output) async {
|
||||
if (output.sequence > 0) {
|
||||
final lastReceivedSequence = _lastReceivedSequence;
|
||||
if (lastReceivedSequence != null && output.sequence <= lastReceivedSequence) {
|
||||
if (lastReceivedSequence != null &&
|
||||
output.sequence <= lastReceivedSequence) {
|
||||
diagnosticLog?.add(
|
||||
'socket.output.stale',
|
||||
'seq=${output.sequence} last=$lastReceivedSequence',
|
||||
@ -340,14 +315,6 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void _applyOutput(TerminalOutputPayload output) {
|
||||
if (_screenProtocolActive) {
|
||||
diagnosticLog?.add('socket.output.compat.skip', 'seq=${output.sequence}');
|
||||
if (output.sequence > 0) {
|
||||
_lastReceivedSequence = output.sequence;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
diagnosticLog?.add('socket.frame.rx', output.chunk);
|
||||
controller.registerIncomingFrame();
|
||||
controller.applyFrame(output.chunk);
|
||||
@ -366,53 +333,14 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
onRestore(restore);
|
||||
}
|
||||
|
||||
void _handleScreenSnapshot(TerminalScreenSnapshot snapshot) {
|
||||
diagnosticLog?.add(
|
||||
'socket.screen_snapshot.rx',
|
||||
'screenVersion=${snapshot.screenVersion} sourceSeq=${snapshot.sourceSequence}',
|
||||
);
|
||||
_screenProtocolActive = true;
|
||||
_screenVersion = snapshot.screenVersion;
|
||||
_screenSyncPending = false;
|
||||
if (snapshot.sourceSequence > 0) {
|
||||
_lastReceivedSequence = snapshot.sourceSequence;
|
||||
}
|
||||
onScreenSnapshot?.call(snapshot);
|
||||
}
|
||||
|
||||
void _handleScreenPatch(TerminalScreenPatch patch) {
|
||||
diagnosticLog?.add(
|
||||
'socket.screen_patch.rx',
|
||||
'base=${patch.baseScreenVersion} next=${patch.screenVersion} sourceSeq=${patch.sourceSequence}',
|
||||
);
|
||||
if (_screenVersion != null && patch.baseScreenVersion != _screenVersion) {
|
||||
_screenProtocolActive = false;
|
||||
diagnosticLog?.add(
|
||||
'socket.screen_patch.skip',
|
||||
'expected=$_screenVersion actual=${patch.baseScreenVersion}',
|
||||
);
|
||||
if (!_screenSyncPending) {
|
||||
_screenSyncPending = true;
|
||||
_socketSession?.requestScreenSync();
|
||||
diagnosticLog?.add('socket.screen_sync.request', session.sessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_screenProtocolActive = true;
|
||||
_screenVersion = patch.screenVersion;
|
||||
if (patch.sourceSequence > 0) {
|
||||
_lastReceivedSequence = patch.sourceSequence;
|
||||
}
|
||||
onScreenPatch?.call(patch);
|
||||
}
|
||||
|
||||
Future<void> _loadHistory({bool loadOlder = false}) async {
|
||||
try {
|
||||
final payload = await apiClient.getSessionJournal(
|
||||
session.sessionId,
|
||||
limit: historyPageSize,
|
||||
beforeSequence: loadOlder ? controller.historyWindow.oldestSequence : null,
|
||||
beforeSequence: loadOlder
|
||||
? controller.historyWindow.oldestSequence
|
||||
: null,
|
||||
);
|
||||
final history = _buildHistoryWindow(
|
||||
payload,
|
||||
@ -643,7 +571,8 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
? existing?.oldestSequence
|
||||
: items.first.sequence;
|
||||
final newestSequence =
|
||||
existing?.newestSequence ?? (items.isEmpty ? null : items.last.sequence);
|
||||
existing?.newestSequence ??
|
||||
(items.isEmpty ? null : items.last.sequence);
|
||||
|
||||
return HistoryWindow(
|
||||
lines: mergedLines,
|
||||
@ -657,9 +586,8 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
final rawItems = (payload['items'] as List?) ?? const <dynamic>[];
|
||||
return rawItems
|
||||
.map(
|
||||
(item) => _JournalItem.fromJson(
|
||||
Map<String, dynamic>.from(item as Map),
|
||||
),
|
||||
(item) =>
|
||||
_JournalItem.fromJson(Map<String, dynamic>.from(item as Map)),
|
||||
)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
@ -9,8 +9,6 @@ import '../../core/network/agent_socket_client.dart';
|
||||
import '../sessions/session.dart';
|
||||
import 'terminal_output_payload.dart';
|
||||
import 'terminal_restore_payload.dart';
|
||||
import 'terminal_screen_patch.dart';
|
||||
import 'terminal_screen_snapshot.dart';
|
||||
|
||||
typedef TerminalSocketTransportFactory =
|
||||
TerminalSocketTransport Function(Uri uri);
|
||||
@ -61,8 +59,6 @@ class TerminalSocketSession {
|
||||
Future<void> connect({
|
||||
required void Function(TerminalOutputPayload output) onOutput,
|
||||
required void Function(TerminalRestorePayload restore) onRestore,
|
||||
void Function(TerminalScreenSnapshot snapshot)? onScreenSnapshot,
|
||||
void Function(TerminalScreenPatch patch)? onScreenPatch,
|
||||
void Function()? onDisconnected,
|
||||
}) async {
|
||||
if (_transport != null || _subscription != null) {
|
||||
@ -93,14 +89,6 @@ class TerminalSocketSession {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_handleScreenSnapshotFrame(message, onScreenSnapshot)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_handleScreenPatchFrame(message, onScreenPatch)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final output = _decodeOutputFrame(message);
|
||||
if (output != null) {
|
||||
onOutput(output);
|
||||
@ -170,20 +158,9 @@ class TerminalSocketSession {
|
||||
}
|
||||
|
||||
try {
|
||||
transport.send(jsonEncode(socketClient.buildResizeMessage(columns, rows)));
|
||||
} catch (_) {
|
||||
_handleTransportClosed(transport);
|
||||
}
|
||||
}
|
||||
|
||||
void requestScreenSync() {
|
||||
final transport = _transport;
|
||||
if (transport == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
transport.send(jsonEncode(socketClient.buildScreenSyncMessage()));
|
||||
transport.send(
|
||||
jsonEncode(socketClient.buildResizeMessage(columns, rows)),
|
||||
);
|
||||
} catch (_) {
|
||||
_handleTransportClosed(transport);
|
||||
}
|
||||
@ -227,53 +204,13 @@ class TerminalSocketSession {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _handleScreenSnapshotFrame(
|
||||
String frame,
|
||||
void Function(TerminalScreenSnapshot snapshot)? onScreenSnapshot,
|
||||
) {
|
||||
if (onScreenSnapshot == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(frame);
|
||||
if (decoded is Map && decoded['type'] == 'screen_snapshot') {
|
||||
onScreenSnapshot(
|
||||
TerminalScreenSnapshot.fromJson(Map<String, dynamic>.from(decoded)),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _handleScreenPatchFrame(
|
||||
String frame,
|
||||
void Function(TerminalScreenPatch patch)? onScreenPatch,
|
||||
) {
|
||||
if (onScreenPatch == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(frame);
|
||||
if (decoded is Map && decoded['type'] == 'screen_patch') {
|
||||
onScreenPatch(
|
||||
TerminalScreenPatch.fromJson(Map<String, dynamic>.from(decoded)),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
TerminalOutputPayload? _decodeOutputFrame(String frame) {
|
||||
try {
|
||||
final decoded = jsonDecode(frame);
|
||||
if (decoded is Map && decoded['type'] == 'output') {
|
||||
return TerminalOutputPayload.fromJson(Map<String, dynamic>.from(decoded));
|
||||
return TerminalOutputPayload.fromJson(
|
||||
Map<String, dynamic>.from(decoded),
|
||||
);
|
||||
}
|
||||
|
||||
if (decoded is Map && decoded['type'] != null) {
|
||||
|
||||
@ -14,35 +14,18 @@ void main() {
|
||||
test('builds attach message for terminal sessions', () {
|
||||
final client = AgentSocketClient(Uri.parse('https://host:9443'));
|
||||
|
||||
expect(
|
||||
client.buildAttachMessage('session-123'),
|
||||
<String, dynamic>{
|
||||
'type': 'attach',
|
||||
'sessionId': 'session-123',
|
||||
},
|
||||
);
|
||||
expect(client.buildAttachMessage('session-123'), <String, dynamic>{
|
||||
'type': 'attach',
|
||||
'sessionId': 'session-123',
|
||||
});
|
||||
});
|
||||
|
||||
test('builds input message for terminal input', () {
|
||||
final client = AgentSocketClient(Uri.parse('https://host:9443'));
|
||||
|
||||
expect(
|
||||
client.buildInputMessage('ls'),
|
||||
<String, dynamic>{
|
||||
'type': 'input',
|
||||
'input': 'ls',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('builds screen sync message for terminal resync', () {
|
||||
final client = AgentSocketClient(Uri.parse('https://host:9443'));
|
||||
|
||||
expect(
|
||||
client.buildScreenSyncMessage(),
|
||||
<String, dynamic>{
|
||||
'type': 'screen_sync',
|
||||
},
|
||||
);
|
||||
expect(client.buildInputMessage('ls'), <String, dynamic>{
|
||||
'type': 'input',
|
||||
'input': 'ls',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -0,0 +1,89 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:term_remote_ctl/features/terminal/terminal_screen_snapshot.dart';
|
||||
import 'package:term_remote_ctl/features/terminal/terminal_screen_state.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'experiment display text trims trailing blank rows beyond the cursor',
|
||||
() {
|
||||
final state = TerminalScreenState.fromSnapshot(
|
||||
const TerminalScreenSnapshot(
|
||||
sessionId: 'session-1',
|
||||
screenVersion: 4,
|
||||
sourceSequence: 10,
|
||||
rows: 24,
|
||||
columns: 80,
|
||||
cursorRow: 0,
|
||||
cursorColumn: 7,
|
||||
cursorVisible: true,
|
||||
activeBuffer: 'primary',
|
||||
primaryBuffer: TerminalScreenBuffer(
|
||||
viewport: [TerminalScreenLine(index: 0, text: 'PS> git')],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(state.toDisplayText(), 'PS> git');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'experiment display text preserves blank rows that still contain the cursor',
|
||||
() {
|
||||
final state = TerminalScreenState.fromSnapshot(
|
||||
const TerminalScreenSnapshot(
|
||||
sessionId: 'session-1',
|
||||
screenVersion: 4,
|
||||
sourceSequence: 10,
|
||||
rows: 24,
|
||||
columns: 80,
|
||||
cursorRow: 1,
|
||||
cursorColumn: 0,
|
||||
cursorVisible: true,
|
||||
activeBuffer: 'primary',
|
||||
primaryBuffer: TerminalScreenBuffer(
|
||||
viewport: [TerminalScreenLine(index: 0, text: 'build finished')],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(state.toDisplayText(), 'build finished\n');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'experiment replay sequence restores the cursor to the backend screen position',
|
||||
() {
|
||||
final state = TerminalScreenState.fromSnapshot(
|
||||
const TerminalScreenSnapshot(
|
||||
sessionId: 'session-1',
|
||||
screenVersion: 4,
|
||||
sourceSequence: 10,
|
||||
rows: 4,
|
||||
columns: 12,
|
||||
cursorRow: 2,
|
||||
cursorColumn: 3,
|
||||
cursorVisible: true,
|
||||
activeBuffer: 'primary',
|
||||
primaryBuffer: TerminalScreenBuffer(
|
||||
viewport: [
|
||||
TerminalScreenLine(index: 0, text: 'header'),
|
||||
TerminalScreenLine(index: 2, text: 'pwd'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
final terminal = Terminal(maxLines: 1000);
|
||||
|
||||
terminal.resize(12, 4);
|
||||
terminal.write('stale-output');
|
||||
terminal.write(state.toReplaySequence());
|
||||
|
||||
expect(terminal.buffer.getText(), contains('header'));
|
||||
expect(terminal.buffer.getText(), contains('\n\npwd'));
|
||||
expect(terminal.buffer.cursorY, 2);
|
||||
expect(terminal.buffer.cursorX, 3);
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -7,8 +7,6 @@ import 'package:term_remote_ctl/features/sessions/session.dart';
|
||||
import 'package:term_remote_ctl/features/terminal/terminal_interaction_controller.dart';
|
||||
import 'package:term_remote_ctl/features/terminal/terminal_output_payload.dart';
|
||||
import 'package:term_remote_ctl/features/terminal/terminal_restore_payload.dart';
|
||||
import 'package:term_remote_ctl/features/terminal/terminal_screen_patch.dart';
|
||||
import 'package:term_remote_ctl/features/terminal/terminal_screen_snapshot.dart';
|
||||
import 'package:term_remote_ctl/features/terminal/terminal_session_coordinator.dart';
|
||||
import 'package:term_remote_ctl/features/terminal/terminal_socket_session.dart';
|
||||
|
||||
@ -199,10 +197,12 @@ void main() {
|
||||
await coordinator.loadOlderHistory();
|
||||
|
||||
expect(apiClient.requestedJournalBeforeSequences, [null, 201]);
|
||||
expect(
|
||||
controller.historyWindow.lines,
|
||||
['[output] zero', '[attach]', '[output] one', '[output] two'],
|
||||
);
|
||||
expect(controller.historyWindow.lines, [
|
||||
'[output] zero',
|
||||
'[attach]',
|
||||
'[output] one',
|
||||
'[output] two',
|
||||
]);
|
||||
expect(controller.historyWindow.hasMoreAbove, isFalse);
|
||||
},
|
||||
);
|
||||
@ -584,394 +584,6 @@ void main() {
|
||||
expect(restores, hasLength(1));
|
||||
expect(restores.single.pendingInput, 't status');
|
||||
});
|
||||
|
||||
test('screen snapshots are forwarded before legacy restore fallback', () async {
|
||||
final controller = TerminalInteractionController();
|
||||
final apiClient = _FakeAgentApiClient();
|
||||
final sessionFactory = _FakeTerminalSessionFactory();
|
||||
final session = Session(
|
||||
sessionId: 'abc',
|
||||
name: 'codex-main',
|
||||
status: 'idle',
|
||||
);
|
||||
final snapshots = <TerminalScreenSnapshot>[];
|
||||
final restores = <TerminalRestorePayload>[];
|
||||
final coordinator = TerminalSessionCoordinator(
|
||||
controller: controller,
|
||||
apiClient: apiClient,
|
||||
session: session,
|
||||
sessionFactory: sessionFactory.create,
|
||||
onFrame: (_) {},
|
||||
onRestore: restores.add,
|
||||
onScreenSnapshot: snapshots.add,
|
||||
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
|
||||
);
|
||||
|
||||
await coordinator.start();
|
||||
|
||||
sessionFactory.createdSessions.single.emitScreenSnapshot(
|
||||
TerminalScreenSnapshot.fromJson(const <String, dynamic>{
|
||||
'sessionId': 'abc',
|
||||
'screenVersion': 4,
|
||||
'sourceSequence': 3,
|
||||
'rows': 24,
|
||||
'columns': 80,
|
||||
'cursorRow': 0,
|
||||
'cursorColumn': 7,
|
||||
'cursorVisible': true,
|
||||
'activeBuffer': 'primary',
|
||||
'primaryBuffer': <String, dynamic>{
|
||||
'viewport': <Map<String, dynamic>>[
|
||||
<String, dynamic>{'index': 0, 'text': 'PS> git'},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
sessionFactory.createdSessions.single.emitRestore(
|
||||
const TerminalRestorePayload(
|
||||
sessionId: 'abc',
|
||||
sequence: 4,
|
||||
screenText: 'PS> gi',
|
||||
pendingInput: 't status',
|
||||
),
|
||||
);
|
||||
|
||||
expect(snapshots, hasLength(1));
|
||||
expect(snapshots.single.toDisplayText(), 'PS> git');
|
||||
expect(restores, hasLength(1));
|
||||
});
|
||||
|
||||
test('screen patch is applied when base screen version is continuous', () async {
|
||||
final controller = TerminalInteractionController();
|
||||
final apiClient = _FakeAgentApiClient();
|
||||
final sessionFactory = _FakeTerminalSessionFactory();
|
||||
final session = Session(
|
||||
sessionId: 'abc',
|
||||
name: 'codex-main',
|
||||
status: 'idle',
|
||||
);
|
||||
final patches = <TerminalScreenPatch>[];
|
||||
final coordinator = TerminalSessionCoordinator(
|
||||
controller: controller,
|
||||
apiClient: apiClient,
|
||||
session: session,
|
||||
sessionFactory: sessionFactory.create,
|
||||
onFrame: (_) {},
|
||||
onRestore: (_) {},
|
||||
onScreenPatch: patches.add,
|
||||
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
|
||||
);
|
||||
|
||||
await coordinator.start();
|
||||
|
||||
sessionFactory.createdSessions.single.emitScreenSnapshot(
|
||||
TerminalScreenSnapshot.fromJson(const <String, dynamic>{
|
||||
'sessionId': 'abc',
|
||||
'screenVersion': 4,
|
||||
'sourceSequence': 7,
|
||||
'rows': 24,
|
||||
'columns': 80,
|
||||
'cursorRow': 0,
|
||||
'cursorColumn': 7,
|
||||
'cursorVisible': true,
|
||||
'activeBuffer': 'primary',
|
||||
'primaryBuffer': <String, dynamic>{
|
||||
'viewport': <Map<String, dynamic>>[
|
||||
<String, dynamic>{'index': 0, 'text': 'PS> git'},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
sessionFactory.createdSessions.single.emitScreenPatch(
|
||||
TerminalScreenPatch.fromJson(const <String, dynamic>{
|
||||
'sessionId': 'abc',
|
||||
'baseScreenVersion': 4,
|
||||
'screenVersion': 5,
|
||||
'sourceSequence': 8,
|
||||
'rows': 24,
|
||||
'columns': 80,
|
||||
'cursorRow': 0,
|
||||
'cursorColumn': 14,
|
||||
'cursorVisible': true,
|
||||
'operations': <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'type': 'replace_lines',
|
||||
'startRow': 0,
|
||||
'lines': <String>['PS> git status'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(patches, hasLength(1));
|
||||
expect(patches.single.baseScreenVersion, 4);
|
||||
expect(patches.single.operations.single.lines, ['PS> git status']);
|
||||
});
|
||||
|
||||
test('screen patch is ignored when base screen version does not match', () async {
|
||||
final controller = TerminalInteractionController();
|
||||
final apiClient = _FakeAgentApiClient();
|
||||
final sessionFactory = _FakeTerminalSessionFactory();
|
||||
final session = Session(
|
||||
sessionId: 'abc',
|
||||
name: 'codex-main',
|
||||
status: 'idle',
|
||||
);
|
||||
final patches = <TerminalScreenPatch>[];
|
||||
final coordinator = TerminalSessionCoordinator(
|
||||
controller: controller,
|
||||
apiClient: apiClient,
|
||||
session: session,
|
||||
sessionFactory: sessionFactory.create,
|
||||
onFrame: (_) {},
|
||||
onRestore: (_) {},
|
||||
onScreenPatch: patches.add,
|
||||
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
|
||||
);
|
||||
|
||||
await coordinator.start();
|
||||
|
||||
sessionFactory.createdSessions.single.emitScreenSnapshot(
|
||||
TerminalScreenSnapshot.fromJson(const <String, dynamic>{
|
||||
'sessionId': 'abc',
|
||||
'screenVersion': 4,
|
||||
'sourceSequence': 7,
|
||||
'rows': 24,
|
||||
'columns': 80,
|
||||
'cursorRow': 0,
|
||||
'cursorColumn': 7,
|
||||
'cursorVisible': true,
|
||||
'activeBuffer': 'primary',
|
||||
'primaryBuffer': <String, dynamic>{
|
||||
'viewport': <Map<String, dynamic>>[
|
||||
<String, dynamic>{'index': 0, 'text': 'PS> git'},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
sessionFactory.createdSessions.single.emitScreenPatch(
|
||||
TerminalScreenPatch.fromJson(const <String, dynamic>{
|
||||
'sessionId': 'abc',
|
||||
'baseScreenVersion': 3,
|
||||
'screenVersion': 5,
|
||||
'sourceSequence': 8,
|
||||
'rows': 24,
|
||||
'columns': 80,
|
||||
'cursorRow': 0,
|
||||
'cursorColumn': 14,
|
||||
'cursorVisible': true,
|
||||
'operations': <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'type': 'replace_lines',
|
||||
'startRow': 0,
|
||||
'lines': <String>['PS> git status'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(patches, isEmpty);
|
||||
});
|
||||
|
||||
test('screen patch mismatch requests a fresh screen snapshot resync', () async {
|
||||
final controller = TerminalInteractionController();
|
||||
final apiClient = _FakeAgentApiClient();
|
||||
final sessionFactory = _FakeTerminalSessionFactory();
|
||||
final session = Session(
|
||||
sessionId: 'abc',
|
||||
name: 'codex-main',
|
||||
status: 'idle',
|
||||
);
|
||||
final patches = <TerminalScreenPatch>[];
|
||||
final snapshots = <TerminalScreenSnapshot>[];
|
||||
final coordinator = TerminalSessionCoordinator(
|
||||
controller: controller,
|
||||
apiClient: apiClient,
|
||||
session: session,
|
||||
sessionFactory: sessionFactory.create,
|
||||
onFrame: (_) {},
|
||||
onRestore: (_) {},
|
||||
onScreenPatch: patches.add,
|
||||
onScreenSnapshot: snapshots.add,
|
||||
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
|
||||
);
|
||||
|
||||
await coordinator.start();
|
||||
|
||||
sessionFactory.createdSessions.single.emitScreenSnapshot(
|
||||
TerminalScreenSnapshot.fromJson(const <String, dynamic>{
|
||||
'sessionId': 'abc',
|
||||
'screenVersion': 4,
|
||||
'sourceSequence': 7,
|
||||
'rows': 24,
|
||||
'columns': 80,
|
||||
'cursorRow': 0,
|
||||
'cursorColumn': 7,
|
||||
'cursorVisible': true,
|
||||
'activeBuffer': 'primary',
|
||||
'primaryBuffer': <String, dynamic>{
|
||||
'viewport': <Map<String, dynamic>>[
|
||||
<String, dynamic>{'index': 0, 'text': 'PS> git'},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
sessionFactory.createdSessions.single.emitScreenPatch(
|
||||
TerminalScreenPatch.fromJson(const <String, dynamic>{
|
||||
'sessionId': 'abc',
|
||||
'baseScreenVersion': 1,
|
||||
'screenVersion': 5,
|
||||
'sourceSequence': 8,
|
||||
'rows': 24,
|
||||
'columns': 80,
|
||||
'cursorRow': 0,
|
||||
'cursorColumn': 14,
|
||||
'cursorVisible': true,
|
||||
'operations': <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'type': 'replace_lines',
|
||||
'startRow': 0,
|
||||
'lines': <String>['PS> git status'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(patches, isEmpty);
|
||||
expect(sessionFactory.createdSessions.single.screenSyncRequestCount, 1);
|
||||
|
||||
sessionFactory.createdSessions.single.emitScreenSnapshot(
|
||||
TerminalScreenSnapshot.fromJson(const <String, dynamic>{
|
||||
'sessionId': 'abc',
|
||||
'screenVersion': 5,
|
||||
'sourceSequence': 8,
|
||||
'rows': 24,
|
||||
'columns': 80,
|
||||
'cursorRow': 0,
|
||||
'cursorColumn': 14,
|
||||
'cursorVisible': true,
|
||||
'activeBuffer': 'primary',
|
||||
'primaryBuffer': <String, dynamic>{
|
||||
'viewport': <Map<String, dynamic>>[
|
||||
<String, dynamic>{'index': 0, 'text': 'PS> git status'},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(snapshots, hasLength(2));
|
||||
expect(snapshots.last.screenVersion, 5);
|
||||
});
|
||||
|
||||
test('repeated screen patch mismatches only request one resync until a snapshot arrives', () async {
|
||||
final controller = TerminalInteractionController();
|
||||
final apiClient = _FakeAgentApiClient();
|
||||
final sessionFactory = _FakeTerminalSessionFactory();
|
||||
final session = Session(
|
||||
sessionId: 'abc',
|
||||
name: 'codex-main',
|
||||
status: 'idle',
|
||||
);
|
||||
final coordinator = TerminalSessionCoordinator(
|
||||
controller: controller,
|
||||
apiClient: apiClient,
|
||||
session: session,
|
||||
sessionFactory: sessionFactory.create,
|
||||
onFrame: (_) {},
|
||||
onRestore: (_) {},
|
||||
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
|
||||
);
|
||||
|
||||
await coordinator.start();
|
||||
|
||||
sessionFactory.createdSessions.single.emitScreenSnapshot(
|
||||
TerminalScreenSnapshot.fromJson(const <String, dynamic>{
|
||||
'sessionId': 'abc',
|
||||
'screenVersion': 4,
|
||||
'sourceSequence': 7,
|
||||
'rows': 24,
|
||||
'columns': 80,
|
||||
'cursorRow': 0,
|
||||
'cursorColumn': 7,
|
||||
'cursorVisible': true,
|
||||
'activeBuffer': 'primary',
|
||||
'primaryBuffer': <String, dynamic>{
|
||||
'viewport': <Map<String, dynamic>>[
|
||||
<String, dynamic>{'index': 0, 'text': 'PS> git'},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
final mismatchPatch = TerminalScreenPatch.fromJson(const <String, dynamic>{
|
||||
'sessionId': 'abc',
|
||||
'baseScreenVersion': 1,
|
||||
'screenVersion': 5,
|
||||
'sourceSequence': 8,
|
||||
'rows': 24,
|
||||
'columns': 80,
|
||||
'cursorRow': 0,
|
||||
'cursorColumn': 14,
|
||||
'cursorVisible': true,
|
||||
'operations': <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'type': 'replace_lines',
|
||||
'startRow': 0,
|
||||
'lines': <String>['PS> git status'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
sessionFactory.createdSessions.single.emitScreenPatch(mismatchPatch);
|
||||
sessionFactory.createdSessions.single.emitScreenPatch(mismatchPatch);
|
||||
|
||||
expect(sessionFactory.createdSessions.single.screenSyncRequestCount, 1);
|
||||
|
||||
sessionFactory.createdSessions.single.emitScreenSnapshot(
|
||||
TerminalScreenSnapshot.fromJson(const <String, dynamic>{
|
||||
'sessionId': 'abc',
|
||||
'screenVersion': 5,
|
||||
'sourceSequence': 8,
|
||||
'rows': 24,
|
||||
'columns': 80,
|
||||
'cursorRow': 0,
|
||||
'cursorColumn': 14,
|
||||
'cursorVisible': true,
|
||||
'activeBuffer': 'primary',
|
||||
'primaryBuffer': <String, dynamic>{
|
||||
'viewport': <Map<String, dynamic>>[
|
||||
<String, dynamic>{'index': 0, 'text': 'PS> git status'},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
sessionFactory.createdSessions.single.emitScreenPatch(
|
||||
TerminalScreenPatch.fromJson(const <String, dynamic>{
|
||||
'sessionId': 'abc',
|
||||
'baseScreenVersion': 3,
|
||||
'screenVersion': 6,
|
||||
'sourceSequence': 9,
|
||||
'rows': 24,
|
||||
'columns': 80,
|
||||
'cursorRow': 0,
|
||||
'cursorColumn': 18,
|
||||
'cursorVisible': true,
|
||||
'operations': <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'type': 'replace_lines',
|
||||
'startRow': 0,
|
||||
'lines': <String>['still mismatched'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sessionFactory.createdSessions.single.screenSyncRequestCount, 2);
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeAgentApiClient extends AgentApiClient {
|
||||
@ -1052,7 +664,6 @@ class _FakeTerminalSocketSession extends TerminalSocketSession {
|
||||
final bool autoConnect;
|
||||
final resizeCalls = <List<int>>[];
|
||||
final sentInputs = <String>[];
|
||||
int screenSyncRequestCount = 0;
|
||||
int disposeCount = 0;
|
||||
Completer<void>? disposeCompleter;
|
||||
Completer<void> _connectCompleter = Completer<void>();
|
||||
@ -1061,21 +672,15 @@ class _FakeTerminalSocketSession extends TerminalSocketSession {
|
||||
void Function()? _onDisconnected;
|
||||
bool _isDisconnected = false;
|
||||
void Function(TerminalRestorePayload restore)? _onRestore;
|
||||
void Function(TerminalScreenSnapshot snapshot)? _onScreenSnapshot;
|
||||
void Function(TerminalScreenPatch patch)? _onScreenPatch;
|
||||
|
||||
@override
|
||||
Future<void> connect({
|
||||
required void Function(TerminalOutputPayload output) onOutput,
|
||||
required void Function(TerminalRestorePayload restore) onRestore,
|
||||
void Function(TerminalScreenSnapshot snapshot)? onScreenSnapshot,
|
||||
void Function(TerminalScreenPatch patch)? onScreenPatch,
|
||||
void Function()? onDisconnected,
|
||||
}) {
|
||||
_onOutput = onOutput;
|
||||
_onRestore = onRestore;
|
||||
_onScreenSnapshot = onScreenSnapshot;
|
||||
_onScreenPatch = onScreenPatch;
|
||||
_onDisconnected = onDisconnected;
|
||||
if (autoConnect && !_connectCompleter.isCompleted) {
|
||||
_connectCompleter.complete();
|
||||
@ -1112,19 +717,6 @@ class _FakeTerminalSocketSession extends TerminalSocketSession {
|
||||
_onRestore?.call(restore);
|
||||
}
|
||||
|
||||
void emitScreenSnapshot(TerminalScreenSnapshot snapshot) {
|
||||
_onScreenSnapshot?.call(snapshot);
|
||||
}
|
||||
|
||||
void emitScreenPatch(TerminalScreenPatch patch) {
|
||||
_onScreenPatch?.call(patch);
|
||||
}
|
||||
|
||||
@override
|
||||
void requestScreenSync() {
|
||||
screenSyncRequestCount += 1;
|
||||
}
|
||||
|
||||
void disconnect() {
|
||||
_isDisconnected = true;
|
||||
_onDisconnected?.call();
|
||||
|
||||
@ -4,8 +4,6 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:term_remote_ctl/core/network/agent_socket_client.dart';
|
||||
import 'package:term_remote_ctl/features/terminal/terminal_output_payload.dart';
|
||||
import 'package:term_remote_ctl/features/terminal/terminal_restore_payload.dart';
|
||||
import 'package:term_remote_ctl/features/terminal/terminal_screen_patch.dart';
|
||||
import 'package:term_remote_ctl/features/terminal/terminal_screen_snapshot.dart';
|
||||
import 'package:term_remote_ctl/features/terminal/terminal_socket_session.dart';
|
||||
|
||||
void main() {
|
||||
@ -19,12 +17,11 @@ void main() {
|
||||
|
||||
final outputs = <TerminalOutputPayload>[];
|
||||
var completed = false;
|
||||
final connectFuture = session.connect(
|
||||
onOutput: outputs.add,
|
||||
onRestore: (_) {},
|
||||
).then((_) {
|
||||
completed = true;
|
||||
});
|
||||
final connectFuture = session
|
||||
.connect(onOutput: outputs.add, onRestore: (_) {})
|
||||
.then((_) {
|
||||
completed = true;
|
||||
});
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(
|
||||
@ -50,19 +47,25 @@ void main() {
|
||||
await session.dispose();
|
||||
});
|
||||
|
||||
test('connect fails if the socket closes before attach acknowledgement', () async {
|
||||
final transport = _FakeTerminalSocketTransport();
|
||||
final session = TerminalSocketSession(
|
||||
sessionId: 'session-123',
|
||||
socketClient: AgentSocketClient(Uri.parse('https://host:9443')),
|
||||
transportFactory: (_) => transport,
|
||||
);
|
||||
test(
|
||||
'connect fails if the socket closes before attach acknowledgement',
|
||||
() async {
|
||||
final transport = _FakeTerminalSocketTransport();
|
||||
final session = TerminalSocketSession(
|
||||
sessionId: 'session-123',
|
||||
socketClient: AgentSocketClient(Uri.parse('https://host:9443')),
|
||||
transportFactory: (_) => transport,
|
||||
);
|
||||
|
||||
final connectFuture = session.connect(onOutput: (_) {}, onRestore: (_) {});
|
||||
await transport.close();
|
||||
final connectFuture = session.connect(
|
||||
onOutput: (_) {},
|
||||
onRestore: (_) {},
|
||||
);
|
||||
await transport.close();
|
||||
|
||||
await expectLater(connectFuture, throwsStateError);
|
||||
});
|
||||
await expectLater(connectFuture, throwsStateError);
|
||||
},
|
||||
);
|
||||
|
||||
test('sendInput serializes the input message', () async {
|
||||
final transport = _FakeTerminalSocketTransport();
|
||||
@ -109,29 +112,6 @@ void main() {
|
||||
await session.dispose();
|
||||
});
|
||||
|
||||
test('requestScreenSync serializes the screen sync message', () async {
|
||||
final transport = _FakeTerminalSocketTransport();
|
||||
final session = TerminalSocketSession(
|
||||
sessionId: 'session-123',
|
||||
socketClient: AgentSocketClient(Uri.parse('https://host:9443')),
|
||||
transportFactory: (_) => transport,
|
||||
);
|
||||
|
||||
final connectFuture = session.connect(onOutput: (_) {}, onRestore: (_) {});
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
transport.emit('{"type":"attached","sessionId":"session-123"}');
|
||||
await connectFuture;
|
||||
|
||||
session.requestScreenSync();
|
||||
|
||||
expect(
|
||||
transport.sentMessages,
|
||||
contains('{"type":"screen_sync"}'),
|
||||
);
|
||||
|
||||
await session.dispose();
|
||||
});
|
||||
|
||||
test('connect notifies when an attached socket closes', () async {
|
||||
final transport = _FakeTerminalSocketTransport();
|
||||
final session = TerminalSocketSession(
|
||||
@ -189,11 +169,9 @@ void main() {
|
||||
);
|
||||
|
||||
final outputs = <TerminalOutputPayload>[];
|
||||
final snapshots = <TerminalScreenSnapshot>[];
|
||||
final restores = <TerminalRestorePayload>[];
|
||||
final connectFuture = session.connect(
|
||||
onOutput: outputs.add,
|
||||
onScreenSnapshot: snapshots.add,
|
||||
onRestore: restores.add,
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
@ -201,9 +179,6 @@ void main() {
|
||||
transport.emit('{"type":"attached","sessionId":"session-123"}');
|
||||
await connectFuture;
|
||||
|
||||
transport.emit(
|
||||
'{"type":"screen_snapshot","sessionId":"session-123","screenVersion":4,"sourceSequence":3,"rows":24,"columns":80,"cursorRow":0,"cursorColumn":7,"cursorVisible":true,"activeBuffer":"primary","primaryBuffer":{"viewport":[{"index":0,"text":"PS> git"}]}}',
|
||||
);
|
||||
transport.emit(
|
||||
'{"type":"restore","sessionId":"session-123","sequence":4,"screenText":"PS> gi","pendingInput":"t status"}',
|
||||
);
|
||||
@ -212,10 +187,6 @@ void main() {
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(snapshots, hasLength(1));
|
||||
expect(snapshots.single.sessionId, 'session-123');
|
||||
expect(snapshots.single.screenVersion, 4);
|
||||
expect(snapshots.single.toDisplayText(), 'PS> git');
|
||||
expect(restores, hasLength(1));
|
||||
expect(restores.single.sessionId, 'session-123');
|
||||
expect(restores.single.sequence, 4);
|
||||
@ -249,44 +220,6 @@ void main() {
|
||||
|
||||
expect(outputs, isEmpty);
|
||||
});
|
||||
|
||||
test('connect routes screen patch frames separately from output frames', () async {
|
||||
final transport = _FakeTerminalSocketTransport();
|
||||
final session = TerminalSocketSession(
|
||||
sessionId: 'session-123',
|
||||
socketClient: AgentSocketClient(Uri.parse('https://host:9443')),
|
||||
transportFactory: (_) => transport,
|
||||
);
|
||||
|
||||
final outputs = <TerminalOutputPayload>[];
|
||||
final patches = <TerminalScreenPatch>[];
|
||||
final connectFuture = session.connect(
|
||||
onOutput: outputs.add,
|
||||
onRestore: (_) {},
|
||||
onScreenPatch: patches.add,
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
transport.emit('{"type":"attached","sessionId":"session-123"}');
|
||||
await connectFuture;
|
||||
|
||||
transport.emit(
|
||||
'{"type":"screen_patch","sessionId":"session-123","baseScreenVersion":4,"screenVersion":5,"sourceSequence":8,"rows":24,"columns":80,"cursorRow":0,"cursorColumn":8,"cursorVisible":true,"operations":[{"type":"replace_lines","startRow":0,"lines":["PS> git "]},{"type":"replace_lines","startRow":1,"lines":["status"]}]}',
|
||||
);
|
||||
transport.emit(
|
||||
'{"type":"output","sessionId":"session-123","sequence":8,"chunk":"status"}',
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(patches, hasLength(1));
|
||||
expect(patches.single.baseScreenVersion, 4);
|
||||
expect(patches.single.screenVersion, 5);
|
||||
expect(patches.single.operations, hasLength(2));
|
||||
expect(patches.single.operations.first.startRow, 0);
|
||||
expect(patches.single.operations.first.lines, ['PS> git ']);
|
||||
expect(outputs, hasLength(1));
|
||||
expect(outputs.single.chunk, 'status');
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeTerminalSocketTransport implements TerminalSocketTransport {
|
||||
|
||||
@ -9,8 +9,6 @@ import 'package:term_remote_ctl/features/terminal/terminal_diagnostic_log.dart';
|
||||
import 'package:term_remote_ctl/features/terminal/terminal_interaction_controller.dart';
|
||||
import 'package:term_remote_ctl/features/terminal/terminal_output_payload.dart';
|
||||
import 'package:term_remote_ctl/features/terminal/terminal_restore_payload.dart';
|
||||
import 'package:term_remote_ctl/features/terminal/terminal_screen_patch.dart';
|
||||
import 'package:term_remote_ctl/features/terminal/terminal_screen_snapshot.dart';
|
||||
import 'package:term_remote_ctl/features/terminal/terminal_session_coordinator.dart';
|
||||
import 'package:term_remote_ctl/features/terminal/terminal_socket_session.dart';
|
||||
|
||||
@ -120,8 +118,6 @@ class _RecordingTerminalSocketSession extends TerminalSocketSession {
|
||||
Future<void> connect({
|
||||
required void Function(TerminalOutputPayload output) onOutput,
|
||||
required void Function(TerminalRestorePayload restore) onRestore,
|
||||
void Function(TerminalScreenSnapshot snapshot)? onScreenSnapshot,
|
||||
void Function(TerminalScreenPatch patch)? onScreenPatch,
|
||||
void Function()? onDisconnected,
|
||||
}) async {}
|
||||
|
||||
|
||||
@ -319,144 +319,38 @@ void main() {
|
||||
expect(terminal.buffer.getText(), contains('PS> git status'));
|
||||
});
|
||||
|
||||
testWidgets('terminal applies backend screen snapshot as current screen', (
|
||||
tester,
|
||||
) async {
|
||||
final transportFactory = _QueuedTerminalSocketTransportFactory(
|
||||
connectionStartupFrames: const [
|
||||
[
|
||||
_StartupFrame('{"type":"attached","sessionId":"session-1"}'),
|
||||
_StartupFrame(
|
||||
'{"type":"screen_snapshot","sessionId":"session-1","screenVersion":4,"sourceSequence":3,"rows":24,"columns":80,"cursorRow":0,"cursorColumn":7,"cursorVisible":true,"activeBuffer":"primary","primaryBuffer":{"viewport":[{"index":0,"text":"PS> git"}]}}',
|
||||
),
|
||||
testWidgets(
|
||||
'terminal ignores backend screen snapshots by default and keeps legacy restore flow',
|
||||
(tester) async {
|
||||
final transportFactory = _QueuedTerminalSocketTransportFactory(
|
||||
connectionStartupFrames: const [
|
||||
[
|
||||
_StartupFrame('{"type":"attached","sessionId":"session-1"}'),
|
||||
_StartupFrame(
|
||||
'{"type":"screen_snapshot","sessionId":"session-1","screenVersion":4,"sourceSequence":3,"rows":24,"columns":80,"cursorRow":0,"cursorColumn":13,"cursorVisible":true,"activeBuffer":"primary","primaryBuffer":{"viewport":[{"index":0,"text":"snapshot-only"}]}}',
|
||||
),
|
||||
_StartupFrame(
|
||||
'{"type":"restore","sessionId":"session-1","sequence":4,"screenText":"restore-path","pendingInput":""}',
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
);
|
||||
|
||||
await _pumpTerminalPage(
|
||||
tester,
|
||||
session: _session('session-1', 'codex-main'),
|
||||
socketFactory: TerminalSocketSessionFactory(
|
||||
transportFactory: transportFactory.create,
|
||||
),
|
||||
);
|
||||
await _pumpTerminalPage(
|
||||
tester,
|
||||
session: _session('session-1', 'codex-main'),
|
||||
socketFactory: TerminalSocketSessionFactory(
|
||||
transportFactory: transportFactory.create,
|
||||
),
|
||||
);
|
||||
|
||||
final terminal = tester
|
||||
.widget<TerminalView>(find.byType(TerminalView))
|
||||
.terminal;
|
||||
expect(terminal.buffer.getText(), contains('PS> git'));
|
||||
});
|
||||
|
||||
testWidgets('terminal requests screen resync after patch mismatch and applies fresh snapshot', (
|
||||
tester,
|
||||
) async {
|
||||
final transportFactory = _QueuedTerminalSocketTransportFactory(
|
||||
connectionStartupFrames: [
|
||||
[
|
||||
const _StartupFrame('{"type":"attached","sessionId":"session-1"}'),
|
||||
const _StartupFrame(
|
||||
'{"type":"screen_snapshot","sessionId":"session-1","screenVersion":4,"sourceSequence":7,"rows":24,"columns":80,"cursorRow":0,"cursorColumn":7,"cursorVisible":true,"activeBuffer":"primary","primaryBuffer":{"viewport":[{"index":0,"text":"PS> git"}]}}',
|
||||
),
|
||||
const _StartupFrame(
|
||||
'{"type":"screen_patch","sessionId":"session-1","baseScreenVersion":1,"screenVersion":5,"sourceSequence":8,"rows":24,"columns":80,"cursorRow":0,"cursorColumn":14,"cursorVisible":true,"operations":[{"type":"replace_lines","startRow":0,"lines":["stale patch"]}]}',
|
||||
delay: Duration(milliseconds: 20),
|
||||
),
|
||||
],
|
||||
],
|
||||
onMessageSent: (transport, message) {
|
||||
if (message == '{"type":"screen_sync"}') {
|
||||
transport.emit(
|
||||
'{"type":"screen_snapshot","sessionId":"session-1","screenVersion":5,"sourceSequence":8,"rows":24,"columns":80,"cursorRow":0,"cursorColumn":14,"cursorVisible":true,"activeBuffer":"primary","primaryBuffer":{"viewport":[{"index":0,"text":"PS> git status"}]}}',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await _pumpTerminalPage(
|
||||
tester,
|
||||
session: _session('session-1', 'codex-main'),
|
||||
socketFactory: TerminalSocketSessionFactory(
|
||||
transportFactory: transportFactory.create,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 40));
|
||||
|
||||
expect(
|
||||
transportFactory.createdTransports.single.sentMessages,
|
||||
contains('{"type":"screen_sync"}'),
|
||||
);
|
||||
|
||||
final terminal = tester
|
||||
.widget<TerminalView>(find.byType(TerminalView))
|
||||
.terminal;
|
||||
expect(terminal.buffer.getText(), contains('PS> git status'));
|
||||
expect(terminal.buffer.getText(), isNot(contains('stale patch')));
|
||||
});
|
||||
|
||||
testWidgets('terminal renders alternate buffer when backend marks it active', (
|
||||
tester,
|
||||
) async {
|
||||
final transportFactory = _QueuedTerminalSocketTransportFactory(
|
||||
connectionStartupFrames: const [
|
||||
[
|
||||
_StartupFrame('{"type":"attached","sessionId":"session-1"}'),
|
||||
_StartupFrame(
|
||||
'{"type":"screen_snapshot","sessionId":"session-1","screenVersion":4,"sourceSequence":3,"rows":24,"columns":80,"cursorRow":0,"cursorColumn":3,"cursorVisible":true,"activeBuffer":"alternate","primaryBuffer":{"viewport":[{"index":0,"text":"primary shell"}]},"alternateBuffer":{"viewport":[{"index":0,"text":"alt ui"}]}}',
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
await _pumpTerminalPage(
|
||||
tester,
|
||||
session: _session('session-1', 'codex-main'),
|
||||
socketFactory: TerminalSocketSessionFactory(
|
||||
transportFactory: transportFactory.create,
|
||||
),
|
||||
);
|
||||
|
||||
final terminal = tester
|
||||
.widget<TerminalView>(find.byType(TerminalView))
|
||||
.terminal;
|
||||
expect(terminal.buffer.getText(), contains('alt ui'));
|
||||
expect(terminal.buffer.getText(), isNot(contains('primary shell')));
|
||||
});
|
||||
|
||||
testWidgets('terminal switches back to primary when screen patch changes active buffer', (
|
||||
tester,
|
||||
) async {
|
||||
final transportFactory = _QueuedTerminalSocketTransportFactory(
|
||||
connectionStartupFrames: const [
|
||||
[
|
||||
_StartupFrame('{"type":"attached","sessionId":"session-1"}'),
|
||||
_StartupFrame(
|
||||
'{"type":"screen_snapshot","sessionId":"session-1","screenVersion":4,"sourceSequence":3,"rows":24,"columns":80,"cursorRow":0,"cursorColumn":3,"cursorVisible":true,"activeBuffer":"alternate","primaryBuffer":{"viewport":[{"index":0,"text":"primary shell"}]},"alternateBuffer":{"viewport":[{"index":0,"text":"alt ui"}]}}',
|
||||
),
|
||||
_StartupFrame(
|
||||
'{"type":"screen_patch","sessionId":"session-1","baseScreenVersion":4,"screenVersion":5,"sourceSequence":4,"rows":24,"columns":80,"cursorRow":0,"cursorColumn":13,"cursorVisible":true,"activeBuffer":"primary","operations":[{"type":"replace_lines","startRow":0,"lines":["primary shell"]}]}',
|
||||
delay: Duration(milliseconds: 20),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
await _pumpTerminalPage(
|
||||
tester,
|
||||
session: _session('session-1', 'codex-main'),
|
||||
socketFactory: TerminalSocketSessionFactory(
|
||||
transportFactory: transportFactory.create,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 40));
|
||||
|
||||
final terminal = tester
|
||||
.widget<TerminalView>(find.byType(TerminalView))
|
||||
.terminal;
|
||||
expect(terminal.buffer.getText(), contains('primary shell'));
|
||||
expect(terminal.buffer.getText(), isNot(contains('alt ui')));
|
||||
});
|
||||
final terminal = tester
|
||||
.widget<TerminalView>(find.byType(TerminalView))
|
||||
.terminal;
|
||||
expect(terminal.buffer.getText(), contains('restore-path'));
|
||||
expect(terminal.buffer.getText(), isNot(contains('snapshot-only')));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'terminal page keeps the command deck above the bottom safe area',
|
||||
@ -1051,12 +945,12 @@ void main() {
|
||||
|
||||
await _openProjectTerminal(tester);
|
||||
|
||||
expect(find.text('Scrollback | 2 lines'), findsNothing);
|
||||
expect(find.text('Scrollback'), findsNothing);
|
||||
|
||||
await tester.tap(find.textContaining('Live |'));
|
||||
await tester.tap(find.text('Live'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Scrollback | 2 lines'), findsOneWidget);
|
||||
expect(find.text('Scrollback'), findsOneWidget);
|
||||
expect(find.text('Load older lines'), findsOneWidget);
|
||||
|
||||
await tester.ensureVisible(find.text('Load older lines'));
|
||||
@ -1202,7 +1096,7 @@ void main() {
|
||||
expect(terminal.buffer.cursorX, 3);
|
||||
expect(terminal.buffer.getText(), contains('one\ntwo'));
|
||||
|
||||
await tester.tap(find.textContaining('Live |'));
|
||||
await tester.tap(find.text('Live'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.ensureVisible(find.text('Load older lines'));
|
||||
await tester.tap(find.text('Load older lines'));
|
||||
|
||||
@ -18,6 +18,8 @@ public sealed class AgentOptions
|
||||
|
||||
public int SessionJournalRetentionDays { get; set; } = 7;
|
||||
|
||||
public bool EnableBackendScreenProtocol { get; set; }
|
||||
|
||||
public bool HasHttpsEndpoint => HttpsPort > 0;
|
||||
|
||||
public bool HasHttpEndpoint => HttpPort > 0;
|
||||
|
||||
@ -21,6 +21,7 @@ public static class AgentOptionsServiceCollectionExtensions
|
||||
options.HttpPort = effectiveOptions.HttpPort;
|
||||
options.WebSocketFrameFlushMilliseconds = effectiveOptions.WebSocketFrameFlushMilliseconds;
|
||||
options.RingBufferLineLimit = effectiveOptions.RingBufferLineLimit;
|
||||
options.EnableBackendScreenProtocol = effectiveOptions.EnableBackendScreenProtocol;
|
||||
})
|
||||
.ValidateOnStart();
|
||||
|
||||
|
||||
@ -1,11 +1,8 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TermRemoteCtl.Agent.Sessions;
|
||||
using TermRemoteCtl.Agent.Terminal;
|
||||
using TermRemoteCtl.Agent.Configuration;
|
||||
using TermRemoteCtl.Agent.Terminal.Screen;
|
||||
|
||||
namespace TermRemoteCtl.Agent.Realtime;
|
||||
|
||||
@ -60,58 +57,23 @@ public static class TerminalWebSocketHandler
|
||||
}
|
||||
|
||||
using var sendGate = new SemaphoreSlim(1, 1);
|
||||
var screenStateGate = new object();
|
||||
var lastSentScreenSnapshot = registry.GetScreenSnapshot(sessionId);
|
||||
async Task SendScreenSnapshotAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
TerminalScreenSnapshot snapshot;
|
||||
lock (screenStateGate)
|
||||
{
|
||||
snapshot = registry.GetScreenSnapshot(sessionId);
|
||||
lastSentScreenSnapshot = snapshot;
|
||||
}
|
||||
|
||||
await SendJsonAsync(
|
||||
socket,
|
||||
new TerminalScreenSnapshotResponse(
|
||||
snapshot.SessionId,
|
||||
snapshot.ScreenVersion,
|
||||
snapshot.SourceSequence,
|
||||
snapshot.Rows,
|
||||
snapshot.Columns,
|
||||
snapshot.CursorRow,
|
||||
snapshot.CursorColumn,
|
||||
snapshot.CursorVisible,
|
||||
snapshot.ActiveBuffer,
|
||||
ToBufferResponse(snapshot.PrimaryBuffer),
|
||||
snapshot.AlternateBuffer is null ? null : ToBufferResponse(snapshot.AlternateBuffer)),
|
||||
sendGate,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
void HandleOutput(object? sender, TerminalOutputEventArgs args)
|
||||
{
|
||||
if (string.Equals(args.SessionId, sessionId, StringComparison.Ordinal))
|
||||
{
|
||||
TerminalScreenPatch? screenPatch = null;
|
||||
lock (screenStateGate)
|
||||
{
|
||||
var currentSnapshot = registry.GetScreenSnapshot(sessionId);
|
||||
screenPatch = TerminalScreenPatch.Create(lastSentScreenSnapshot, currentSnapshot);
|
||||
lastSentScreenSnapshot = currentSnapshot;
|
||||
}
|
||||
|
||||
_ = SendOutputAndPatchAsync(socket, args, screenPatch, sendGate, context.RequestAborted);
|
||||
_ = SendJsonAsync(
|
||||
socket,
|
||||
new TerminalOutputResponse(args.SessionId, args.Sequence, args.Chunk),
|
||||
sendGate,
|
||||
context.RequestAborted);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await registry.RecordAttachAsync(sessionId, context.RequestAborted).ConfigureAwait(false);
|
||||
var screenSnapshot = lastSentScreenSnapshot;
|
||||
var restore = registry.GetRestoreSnapshot(sessionId);
|
||||
await SendJsonAsync(socket, new TerminalAttachResponse(sessionId), sendGate, context.RequestAborted).ConfigureAwait(false);
|
||||
await SendScreenSnapshotAsync(context.RequestAborted).ConfigureAwait(false);
|
||||
await SendJsonAsync(
|
||||
socket,
|
||||
new TerminalRestoreResponse(
|
||||
@ -119,12 +81,12 @@ public static class TerminalWebSocketHandler
|
||||
restore.Sequence,
|
||||
restore.ScreenText,
|
||||
restore.PendingInput,
|
||||
restore.CursorRow,
|
||||
restore.CursorColumn),
|
||||
restore.CursorRow,
|
||||
restore.CursorColumn),
|
||||
sendGate,
|
||||
context.RequestAborted).ConfigureAwait(false);
|
||||
host.OutputReceived += HandleOutput;
|
||||
await ReceiveLoopAsync(context, socket, host, registry, diagnostics, sessionId, SendScreenSnapshotAsync).ConfigureAwait(false);
|
||||
await ReceiveLoopAsync(context, socket, host, registry, diagnostics, sessionId).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@ -145,8 +107,7 @@ public static class TerminalWebSocketHandler
|
||||
ISessionHost host,
|
||||
SessionRegistry registry,
|
||||
ITerminalDiagnosticsSink diagnostics,
|
||||
string sessionId,
|
||||
Func<CancellationToken, Task> sendScreenSnapshotAsync)
|
||||
string sessionId)
|
||||
{
|
||||
var buffer = new byte[4096];
|
||||
|
||||
@ -178,8 +139,7 @@ public static class TerminalWebSocketHandler
|
||||
host,
|
||||
diagnostics,
|
||||
sessionId,
|
||||
context.RequestAborted,
|
||||
sendScreenSnapshotAsync).ConfigureAwait(false);
|
||||
context.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@ -189,8 +149,7 @@ public static class TerminalWebSocketHandler
|
||||
ISessionHost host,
|
||||
ITerminalDiagnosticsSink diagnostics,
|
||||
string sessionId,
|
||||
CancellationToken cancellationToken,
|
||||
Func<CancellationToken, Task> sendScreenSnapshotAsync)
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
TerminalClientMessage? message;
|
||||
|
||||
@ -205,8 +164,7 @@ public static class TerminalWebSocketHandler
|
||||
|
||||
if (message is null ||
|
||||
!string.Equals(message.Type, "input", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(message.Type, "resize", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(message.Type, "screen_sync", StringComparison.OrdinalIgnoreCase))
|
||||
!string.Equals(message.Type, "resize", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (message is not null && string.Equals(message.Type, "attach", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@ -228,12 +186,6 @@ public static class TerminalWebSocketHandler
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Equals(message.Type, "screen_sync", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await sendScreenSnapshotAsync(cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.Columns is > 0 && message.Rows is > 0)
|
||||
{
|
||||
await registry.RecordResizeAsync(
|
||||
@ -283,76 +235,8 @@ public static class TerminalWebSocketHandler
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SendOutputAndPatchAsync(
|
||||
WebSocket socket,
|
||||
TerminalOutputEventArgs args,
|
||||
TerminalScreenPatch? screenPatch,
|
||||
SemaphoreSlim sendGate,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await SendJsonAsync(
|
||||
socket,
|
||||
new TerminalOutputResponse(args.SessionId, args.Sequence, args.Chunk),
|
||||
sendGate,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (screenPatch is not null)
|
||||
{
|
||||
await SendJsonAsync(
|
||||
socket,
|
||||
ToScreenPatchResponse(screenPatch),
|
||||
sendGate,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static TerminalScreenBufferResponse ToBufferResponse(TerminalScreenBufferSnapshot buffer)
|
||||
{
|
||||
return new TerminalScreenBufferResponse(
|
||||
buffer.Viewport.Select(line => new TerminalScreenLineResponse(line.Index, line.Text)).ToArray());
|
||||
}
|
||||
|
||||
private static TerminalScreenPatchResponse ToScreenPatchResponse(TerminalScreenPatch patch)
|
||||
{
|
||||
return new TerminalScreenPatchResponse(
|
||||
patch.SessionId,
|
||||
patch.BaseScreenVersion,
|
||||
patch.ScreenVersion,
|
||||
patch.SourceSequence,
|
||||
patch.Rows,
|
||||
patch.Columns,
|
||||
patch.CursorRow,
|
||||
patch.CursorColumn,
|
||||
patch.CursorVisible,
|
||||
patch.ActiveBuffer,
|
||||
patch.Operations.Select(operation => new TerminalScreenPatchOperationResponse(
|
||||
operation.Type,
|
||||
operation.StartRow,
|
||||
operation.Lines.ToArray())).ToArray());
|
||||
}
|
||||
|
||||
private sealed record TerminalAttachResponse(string SessionId, string Type = "attached");
|
||||
|
||||
private sealed record TerminalScreenSnapshotResponse(
|
||||
string SessionId,
|
||||
long ScreenVersion,
|
||||
long SourceSequence,
|
||||
int Rows,
|
||||
int Columns,
|
||||
int CursorRow,
|
||||
int CursorColumn,
|
||||
bool CursorVisible,
|
||||
string ActiveBuffer,
|
||||
TerminalScreenBufferResponse PrimaryBuffer,
|
||||
TerminalScreenBufferResponse? AlternateBuffer,
|
||||
string Type = "screen_snapshot");
|
||||
|
||||
private sealed record TerminalScreenBufferResponse(
|
||||
IReadOnlyList<TerminalScreenLineResponse> Viewport);
|
||||
|
||||
private sealed record TerminalScreenLineResponse(
|
||||
int Index,
|
||||
string Text);
|
||||
|
||||
private sealed record TerminalRestoreResponse(
|
||||
string SessionId,
|
||||
long Sequence,
|
||||
@ -368,25 +252,6 @@ public static class TerminalWebSocketHandler
|
||||
string Chunk,
|
||||
string Type = "output");
|
||||
|
||||
private sealed record TerminalScreenPatchResponse(
|
||||
string SessionId,
|
||||
long BaseScreenVersion,
|
||||
long ScreenVersion,
|
||||
long SourceSequence,
|
||||
int Rows,
|
||||
int Columns,
|
||||
int CursorRow,
|
||||
int CursorColumn,
|
||||
bool CursorVisible,
|
||||
string ActiveBuffer,
|
||||
IReadOnlyList<TerminalScreenPatchOperationResponse> Operations,
|
||||
string Type = "screen_patch");
|
||||
|
||||
private sealed record TerminalScreenPatchOperationResponse(
|
||||
string Type,
|
||||
int StartRow,
|
||||
IReadOnlyList<string> Lines);
|
||||
|
||||
private sealed record TerminalClientMessage(
|
||||
string Type,
|
||||
string? SessionId,
|
||||
|
||||
@ -9,6 +9,8 @@ namespace TermRemoteCtl.Agent.Sessions;
|
||||
public sealed class SessionRegistry
|
||||
{
|
||||
private const int ReplayCharacterLimit = 262_144;
|
||||
private const string ScreenProtocolDisabledMessage =
|
||||
"Backend screen protocol is disabled on the mainline product path.";
|
||||
private readonly ConcurrentDictionary<string, SessionRecord> _records = new();
|
||||
private readonly ConcurrentDictionary<string, TerminalRingBuffer> _historyBySession = new();
|
||||
private readonly ConcurrentDictionary<string, TerminalReplayBuffer> _replayBySession = new();
|
||||
@ -18,6 +20,7 @@ public sealed class SessionRegistry
|
||||
private readonly SessionHistoryStore _historyStore;
|
||||
private readonly SessionIoJournalStore _journalStore;
|
||||
private readonly int _ringBufferLineLimit;
|
||||
private readonly bool _enableBackendScreenProtocol;
|
||||
|
||||
public SessionRegistry(
|
||||
SessionHistoryStore historyStore,
|
||||
@ -27,6 +30,7 @@ public sealed class SessionRegistry
|
||||
_historyStore = historyStore;
|
||||
_journalStore = journalStore;
|
||||
_ringBufferLineLimit = options.Value.RingBufferLineLimit;
|
||||
_enableBackendScreenProtocol = options.Value.EnableBackendScreenProtocol;
|
||||
}
|
||||
|
||||
public SessionRecord Create(
|
||||
@ -50,7 +54,10 @@ public sealed class SessionRegistry
|
||||
_historyBySession[record.SessionId] = new TerminalRingBuffer(_ringBufferLineLimit);
|
||||
_replayBySession[record.SessionId] = new TerminalReplayBuffer(ReplayCharacterLimit);
|
||||
_pendingInputEchoBySession[record.SessionId] = new PendingInputEchoTracker();
|
||||
_screenBySession[record.SessionId] = new TerminalScreenEngine();
|
||||
if (_enableBackendScreenProtocol)
|
||||
{
|
||||
_screenBySession[record.SessionId] = new TerminalScreenEngine();
|
||||
}
|
||||
_sequenceBySession[record.SessionId] = 0;
|
||||
return record;
|
||||
}
|
||||
@ -123,7 +130,6 @@ public sealed class SessionRegistry
|
||||
sessionId,
|
||||
_ => new PendingInputEchoTracker());
|
||||
pendingInputEcho.ObserveOutput(chunk);
|
||||
var screen = _screenBySession.GetOrAdd(sessionId, _ => new TerminalScreenEngine());
|
||||
var updatedAtUtc = DateTimeOffset.UtcNow;
|
||||
var ioEvent = new SessionIoEvent(
|
||||
sessionId,
|
||||
@ -131,7 +137,11 @@ public sealed class SessionRegistry
|
||||
"output",
|
||||
chunk,
|
||||
updatedAtUtc);
|
||||
screen.ApplyOutput(chunk, ioEvent.Sequence);
|
||||
if (_enableBackendScreenProtocol)
|
||||
{
|
||||
var screen = _screenBySession.GetOrAdd(sessionId, _ => new TerminalScreenEngine());
|
||||
screen.ApplyOutput(chunk, ioEvent.Sequence);
|
||||
}
|
||||
_records[sessionId] = record with { UpdatedAtUtc = updatedAtUtc };
|
||||
await _historyStore.AppendAsync(sessionId, chunk, cancellationToken).ConfigureAwait(false);
|
||||
await _journalStore.AppendAsync(ioEvent, cancellationToken).ConfigureAwait(false);
|
||||
@ -189,8 +199,11 @@ public sealed class SessionRegistry
|
||||
"resize",
|
||||
$"{columns}x{rows}",
|
||||
updatedAtUtc);
|
||||
var screen = _screenBySession.GetOrAdd(sessionId, _ => new TerminalScreenEngine());
|
||||
screen.Resize(columns, rows);
|
||||
if (_enableBackendScreenProtocol)
|
||||
{
|
||||
var screen = _screenBySession.GetOrAdd(sessionId, _ => new TerminalScreenEngine());
|
||||
screen.Resize(columns, rows);
|
||||
}
|
||||
_records[sessionId] = record with { UpdatedAtUtc = updatedAtUtc };
|
||||
await _journalStore.AppendAsync(ioEvent, cancellationToken).ConfigureAwait(false);
|
||||
return ioEvent;
|
||||
@ -303,6 +316,11 @@ public sealed class SessionRegistry
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
|
||||
|
||||
if (!_enableBackendScreenProtocol)
|
||||
{
|
||||
throw new InvalidOperationException(ScreenProtocolDisabledMessage);
|
||||
}
|
||||
|
||||
if (!_records.ContainsKey(sessionId))
|
||||
{
|
||||
throw new KeyNotFoundException($"Session '{sessionId}' was not found.");
|
||||
|
||||
@ -211,7 +211,17 @@ public sealed class TerminalScreenEngine
|
||||
|
||||
private bool TryApplyEscape(string chunk, ref int index)
|
||||
{
|
||||
if (index + 1 >= chunk.Length || chunk[index + 1] != '[')
|
||||
if (index + 1 >= chunk.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (chunk[index + 1] == ']')
|
||||
{
|
||||
return TryConsumeOperatingSystemCommand(chunk, ref index);
|
||||
}
|
||||
|
||||
if (chunk[index + 1] != '[')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@ -235,6 +245,33 @@ public sealed class TerminalScreenEngine
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryConsumeOperatingSystemCommand(string chunk, ref int index)
|
||||
{
|
||||
var cursor = index + 2;
|
||||
while (cursor < chunk.Length)
|
||||
{
|
||||
var current = chunk[cursor];
|
||||
if (current == '\u0007')
|
||||
{
|
||||
index = cursor;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (current == '\u001b' &&
|
||||
cursor + 1 < chunk.Length &&
|
||||
chunk[cursor + 1] == '\\')
|
||||
{
|
||||
index = cursor + 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
index = chunk.Length - 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool ApplyCsi(string parameterText, char command)
|
||||
{
|
||||
switch (command)
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
"HttpsPort": 0,
|
||||
"HttpPort": 5067,
|
||||
"WebSocketFrameFlushMilliseconds": 33,
|
||||
"RingBufferLineLimit": 4000
|
||||
"RingBufferLineLimit": 4000,
|
||||
"EnableBackendScreenProtocol": false
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ namespace TermRemoteCtl.Agent.IntegrationTests.Realtime;
|
||||
public sealed class TerminalWebSocketHandlerTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Track", "Mainline")]
|
||||
public async Task Attach_Streams_Output_And_Forwards_Input()
|
||||
{
|
||||
await using var fixture = new TerminalApiFixture();
|
||||
@ -35,18 +36,6 @@ public sealed class TerminalWebSocketHandlerTests
|
||||
Assert.Equal("attached", attachedPayload!.Type);
|
||||
Assert.Equal(session.SessionId, attachedPayload.SessionId);
|
||||
|
||||
var screenSnapshotFrame = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
var screenSnapshotPayload = JsonSerializer.Deserialize<TerminalScreenSnapshotResponse>(
|
||||
screenSnapshotFrame,
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
|
||||
Assert.NotNull(screenSnapshotPayload);
|
||||
Assert.Equal("screen_snapshot", screenSnapshotPayload!.Type);
|
||||
Assert.Equal(session.SessionId, screenSnapshotPayload.SessionId);
|
||||
Assert.Equal(24, screenSnapshotPayload.Rows);
|
||||
Assert.Equal(80, screenSnapshotPayload.Columns);
|
||||
Assert.Equal("primary", screenSnapshotPayload.ActiveBuffer);
|
||||
|
||||
var restoreFrame = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
var restorePayload = JsonSerializer.Deserialize<TerminalRestoreResponse>(
|
||||
restoreFrame,
|
||||
@ -69,21 +58,6 @@ public sealed class TerminalWebSocketHandlerTests
|
||||
Assert.Equal(2L, firstOutputPayload.Sequence);
|
||||
Assert.Equal("abc", firstOutputPayload.Chunk);
|
||||
|
||||
var firstPatchFrame = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
var firstPatchPayload = JsonSerializer.Deserialize<TerminalScreenPatchResponse>(
|
||||
firstPatchFrame,
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
Assert.NotNull(firstPatchPayload);
|
||||
Assert.Equal("screen_patch", firstPatchPayload!.Type);
|
||||
Assert.Equal(session.SessionId, firstPatchPayload.SessionId);
|
||||
Assert.Equal(0L, firstPatchPayload.BaseScreenVersion);
|
||||
Assert.Equal(1L, firstPatchPayload.ScreenVersion);
|
||||
Assert.Equal(2L, firstPatchPayload.SourceSequence);
|
||||
Assert.Single(firstPatchPayload.Operations);
|
||||
Assert.Equal("replace_lines", firstPatchPayload.Operations[0].Type);
|
||||
Assert.Equal(0, firstPatchPayload.Operations[0].StartRow);
|
||||
Assert.Equal(["abc"], firstPatchPayload.Operations[0].Lines);
|
||||
|
||||
var secondOutputFrame = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
var secondOutputPayload = JsonSerializer.Deserialize<TerminalOutputResponse>(
|
||||
secondOutputFrame,
|
||||
@ -93,18 +67,6 @@ public sealed class TerminalWebSocketHandlerTests
|
||||
Assert.Equal(3L, secondOutputPayload.Sequence);
|
||||
Assert.Equal("def", secondOutputPayload.Chunk);
|
||||
|
||||
var secondPatchFrame = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
var secondPatchPayload = JsonSerializer.Deserialize<TerminalScreenPatchResponse>(
|
||||
secondPatchFrame,
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
Assert.NotNull(secondPatchPayload);
|
||||
Assert.Equal("screen_patch", secondPatchPayload!.Type);
|
||||
Assert.Equal(1L, secondPatchPayload.BaseScreenVersion);
|
||||
Assert.Equal(2L, secondPatchPayload.ScreenVersion);
|
||||
Assert.Equal(3L, secondPatchPayload.SourceSequence);
|
||||
Assert.Single(secondPatchPayload.Operations);
|
||||
Assert.Equal(["abcdef"], secondPatchPayload.Operations[0].Lines);
|
||||
|
||||
var inputMessage = JsonSerializer.Serialize(new { type = "input", input = "dir" });
|
||||
await socket.SendAsync(Encoding.UTF8.GetBytes(inputMessage), WebSocketMessageType.Text, true, CancellationToken.None);
|
||||
|
||||
@ -115,6 +77,7 @@ public sealed class TerminalWebSocketHandlerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Track", "Mainline")]
|
||||
public async Task Attach_Replays_Recent_Output_For_Existing_Session()
|
||||
{
|
||||
await using var fixture = new TerminalApiFixture();
|
||||
@ -135,19 +98,6 @@ public sealed class TerminalWebSocketHandlerTests
|
||||
Assert.NotNull(attachedPayload);
|
||||
Assert.Equal("attached", attachedPayload!.Type);
|
||||
|
||||
var screenSnapshotFrame = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
var screenSnapshotPayload = JsonSerializer.Deserialize<TerminalScreenSnapshotResponse>(
|
||||
screenSnapshotFrame,
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
|
||||
Assert.NotNull(screenSnapshotPayload);
|
||||
Assert.Equal("screen_snapshot", screenSnapshotPayload!.Type);
|
||||
Assert.StartsWith(
|
||||
"prompt> dir",
|
||||
screenSnapshotPayload.PrimaryBuffer.Viewport[0].Text,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Equal(1L, screenSnapshotPayload.SourceSequence);
|
||||
|
||||
var restoreFrame = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
var restorePayload = JsonSerializer.Deserialize<TerminalRestoreResponse>(
|
||||
restoreFrame,
|
||||
@ -161,6 +111,7 @@ public sealed class TerminalWebSocketHandlerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Track", "Mainline")]
|
||||
public async Task Attach_Does_Not_Duplicate_Output_Produced_During_Replay_Boundary()
|
||||
{
|
||||
await using var fixture = new TerminalApiFixture();
|
||||
@ -182,18 +133,6 @@ public sealed class TerminalWebSocketHandlerTests
|
||||
Assert.NotNull(attachedPayload);
|
||||
Assert.Equal("attached", attachedPayload!.Type);
|
||||
|
||||
var screenSnapshotFrame = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
var screenSnapshotPayload = JsonSerializer.Deserialize<TerminalScreenSnapshotResponse>(
|
||||
screenSnapshotFrame,
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
|
||||
Assert.NotNull(screenSnapshotPayload);
|
||||
Assert.Equal("screen_snapshot", screenSnapshotPayload!.Type);
|
||||
Assert.StartsWith(
|
||||
"prompt> dir",
|
||||
screenSnapshotPayload.PrimaryBuffer.Viewport[0].Text,
|
||||
StringComparison.Ordinal);
|
||||
|
||||
var restoreFrame = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
var restorePayload = JsonSerializer.Deserialize<TerminalRestoreResponse>(
|
||||
restoreFrame,
|
||||
@ -213,20 +152,11 @@ public sealed class TerminalWebSocketHandlerTests
|
||||
Assert.Equal(3L, livePayload.Sequence);
|
||||
Assert.Equal("next> ", livePayload.Chunk);
|
||||
|
||||
var patchFrame = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
var patchPayload = JsonSerializer.Deserialize<TerminalScreenPatchResponse>(
|
||||
patchFrame,
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
Assert.NotNull(patchPayload);
|
||||
Assert.Equal("screen_patch", patchPayload!.Type);
|
||||
Assert.Equal(1L, patchPayload.BaseScreenVersion);
|
||||
Assert.Equal(2L, patchPayload.ScreenVersion);
|
||||
Assert.Equal(3L, patchPayload.SourceSequence);
|
||||
|
||||
await AssertNoAdditionalTextFrameAsync(socket, TimeSpan.FromMilliseconds(200));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Track", "Mainline")]
|
||||
public async Task Reattach_Replays_Visible_User_Input_When_No_Output_Echo_Has_Arrived_Yet()
|
||||
{
|
||||
await using var fixture = new TerminalApiFixture();
|
||||
@ -250,7 +180,6 @@ public sealed class TerminalWebSocketHandlerTests
|
||||
new Uri($"ws://localhost/ws/terminal?sessionId={session.SessionId}"),
|
||||
CancellationToken.None);
|
||||
|
||||
_ = await ReceiveTextAsync(replaySocket, CancellationToken.None);
|
||||
_ = await ReceiveTextAsync(replaySocket, CancellationToken.None);
|
||||
var restoreFrame = await ReceiveTextAsync(replaySocket, CancellationToken.None);
|
||||
var restorePayload = JsonSerializer.Deserialize<TerminalRestoreResponse>(
|
||||
@ -264,6 +193,7 @@ public sealed class TerminalWebSocketHandlerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Track", "Mainline")]
|
||||
public async Task Reattach_Returns_Restore_Payload_With_Pending_Input()
|
||||
{
|
||||
await using var fixture = new TerminalApiFixture();
|
||||
@ -277,7 +207,6 @@ public sealed class TerminalWebSocketHandlerTests
|
||||
new Uri($"ws://localhost/ws/terminal?sessionId={session.SessionId}"),
|
||||
CancellationToken.None);
|
||||
|
||||
_ = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
_ = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
var restoreFrame = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
|
||||
@ -286,144 +215,6 @@ public sealed class TerminalWebSocketHandlerTests
|
||||
Assert.Contains("\"sequence\":2", restoreFrame);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Live_Output_Also_Streams_Authoritative_Screen_Patch()
|
||||
{
|
||||
await using var fixture = new TerminalApiFixture();
|
||||
var registry = fixture.Services.GetRequiredService<SessionRegistry>();
|
||||
fixture.TerminalHost.Registry = registry;
|
||||
var session = registry.Create("Shell", DateTimeOffset.UtcNow);
|
||||
|
||||
using WebSocket socket = await fixture.Server.CreateWebSocketClient().ConnectAsync(
|
||||
new Uri($"ws://localhost/ws/terminal?sessionId={session.SessionId}"),
|
||||
CancellationToken.None);
|
||||
|
||||
_ = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
_ = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
_ = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
|
||||
fixture.TerminalHost.EmitOutput(session.SessionId, "prompt> ");
|
||||
|
||||
var outputFrame = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
var outputPayload = JsonSerializer.Deserialize<TerminalOutputResponse>(
|
||||
outputFrame,
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
Assert.NotNull(outputPayload);
|
||||
Assert.Equal("prompt> ", outputPayload!.Chunk);
|
||||
|
||||
var patchFrame = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
var patchPayload = JsonSerializer.Deserialize<TerminalScreenPatchResponse>(
|
||||
patchFrame,
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
|
||||
Assert.NotNull(patchPayload);
|
||||
Assert.Equal("screen_patch", patchPayload!.Type);
|
||||
Assert.Equal(session.SessionId, patchPayload.SessionId);
|
||||
Assert.Equal(0L, patchPayload.BaseScreenVersion);
|
||||
Assert.Equal(1L, patchPayload.ScreenVersion);
|
||||
Assert.Equal(2L, patchPayload.SourceSequence);
|
||||
Assert.Single(patchPayload.Operations);
|
||||
Assert.Equal("replace_lines", patchPayload.Operations[0].Type);
|
||||
Assert.Equal(0, patchPayload.Operations[0].StartRow);
|
||||
Assert.Equal(["prompt> "], patchPayload.Operations[0].Lines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScreenSync_Request_Returns_Fresh_Screen_Snapshot()
|
||||
{
|
||||
await using var fixture = new TerminalApiFixture();
|
||||
var registry = fixture.Services.GetRequiredService<SessionRegistry>();
|
||||
fixture.TerminalHost.Registry = registry;
|
||||
var session = registry.Create("Shell", DateTimeOffset.UtcNow);
|
||||
|
||||
using WebSocket socket = await fixture.Server.CreateWebSocketClient().ConnectAsync(
|
||||
new Uri($"ws://localhost/ws/terminal?sessionId={session.SessionId}"),
|
||||
CancellationToken.None);
|
||||
|
||||
_ = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
_ = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
_ = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
|
||||
fixture.TerminalHost.EmitOutput(session.SessionId, "prompt> ");
|
||||
_ = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
_ = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
|
||||
var syncMessage = JsonSerializer.Serialize(new { type = "screen_sync" });
|
||||
await socket.SendAsync(Encoding.UTF8.GetBytes(syncMessage), WebSocketMessageType.Text, true, CancellationToken.None);
|
||||
|
||||
var snapshotFrame = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
var snapshotPayload = JsonSerializer.Deserialize<TerminalScreenSnapshotResponse>(
|
||||
snapshotFrame,
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
|
||||
Assert.NotNull(snapshotPayload);
|
||||
Assert.Equal("screen_snapshot", snapshotPayload!.Type);
|
||||
Assert.Equal(session.SessionId, snapshotPayload.SessionId);
|
||||
Assert.Equal(1L, snapshotPayload.ScreenVersion);
|
||||
Assert.Equal(2L, snapshotPayload.SourceSequence);
|
||||
Assert.Equal("prompt> ", snapshotPayload.PrimaryBuffer.Viewport[0].Text.TrimEnd() + " ");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Attach_Returns_Alternate_Buffer_When_Alternate_Screen_Is_Active()
|
||||
{
|
||||
await using var fixture = new TerminalApiFixture();
|
||||
var registry = fixture.Services.GetRequiredService<SessionRegistry>();
|
||||
fixture.TerminalHost.Registry = registry;
|
||||
var session = registry.Create("Shell", DateTimeOffset.UtcNow);
|
||||
|
||||
await registry.RecordOutputAsync(session.SessionId, "primary", CancellationToken.None);
|
||||
await registry.RecordOutputAsync(session.SessionId, "\u001b[?1049halt", CancellationToken.None);
|
||||
|
||||
using WebSocket socket = await fixture.Server.CreateWebSocketClient().ConnectAsync(
|
||||
new Uri($"ws://localhost/ws/terminal?sessionId={session.SessionId}"),
|
||||
CancellationToken.None);
|
||||
|
||||
_ = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
var screenSnapshotFrame = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
var screenSnapshotPayload = JsonSerializer.Deserialize<TerminalScreenSnapshotResponse>(
|
||||
screenSnapshotFrame,
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
|
||||
Assert.NotNull(screenSnapshotPayload);
|
||||
Assert.Equal("alternate", screenSnapshotPayload!.ActiveBuffer);
|
||||
Assert.StartsWith("primary", screenSnapshotPayload.PrimaryBuffer.Viewport[0].Text, StringComparison.Ordinal);
|
||||
Assert.NotNull(screenSnapshotPayload.AlternateBuffer);
|
||||
Assert.StartsWith("alt", screenSnapshotPayload.AlternateBuffer!.Viewport[0].Text, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Live_Patch_Carries_Alternate_Buffer_Activation()
|
||||
{
|
||||
await using var fixture = new TerminalApiFixture();
|
||||
var registry = fixture.Services.GetRequiredService<SessionRegistry>();
|
||||
fixture.TerminalHost.Registry = registry;
|
||||
var session = registry.Create("Shell", DateTimeOffset.UtcNow);
|
||||
|
||||
await registry.RecordOutputAsync(session.SessionId, "same", CancellationToken.None);
|
||||
|
||||
using WebSocket socket = await fixture.Server.CreateWebSocketClient().ConnectAsync(
|
||||
new Uri($"ws://localhost/ws/terminal?sessionId={session.SessionId}"),
|
||||
CancellationToken.None);
|
||||
|
||||
_ = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
_ = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
_ = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
|
||||
fixture.TerminalHost.EmitOutput(session.SessionId, "\u001b[?1049hsame");
|
||||
|
||||
_ = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
var patchFrame = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
var patchPayload = JsonSerializer.Deserialize<TerminalScreenPatchResponse>(
|
||||
patchFrame,
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
|
||||
Assert.NotNull(patchPayload);
|
||||
Assert.Equal("screen_patch", patchPayload!.Type);
|
||||
Assert.Equal("alternate", patchPayload.ActiveBuffer);
|
||||
Assert.Empty(patchPayload.Operations);
|
||||
}
|
||||
|
||||
private static async Task<string> ReceiveTextAsync(WebSocket socket, CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = new byte[4096];
|
||||
@ -622,43 +413,4 @@ public sealed class TerminalWebSocketHandlerTests
|
||||
string Chunk,
|
||||
string Type);
|
||||
|
||||
private sealed record TerminalScreenPatchResponse(
|
||||
string SessionId,
|
||||
long BaseScreenVersion,
|
||||
long ScreenVersion,
|
||||
long SourceSequence,
|
||||
int Rows,
|
||||
int Columns,
|
||||
int CursorRow,
|
||||
int CursorColumn,
|
||||
bool CursorVisible,
|
||||
string ActiveBuffer,
|
||||
IReadOnlyList<TerminalScreenPatchOperationResponse> Operations,
|
||||
string Type);
|
||||
|
||||
private sealed record TerminalScreenPatchOperationResponse(
|
||||
string Type,
|
||||
int StartRow,
|
||||
IReadOnlyList<string> Lines);
|
||||
|
||||
private sealed record TerminalScreenSnapshotResponse(
|
||||
string SessionId,
|
||||
long ScreenVersion,
|
||||
long SourceSequence,
|
||||
int Rows,
|
||||
int Columns,
|
||||
int CursorRow,
|
||||
int CursorColumn,
|
||||
bool CursorVisible,
|
||||
string ActiveBuffer,
|
||||
TerminalScreenBufferResponse PrimaryBuffer,
|
||||
TerminalScreenBufferResponse? AlternateBuffer,
|
||||
string Type);
|
||||
|
||||
private sealed record TerminalScreenBufferResponse(
|
||||
IReadOnlyList<TerminalScreenLineResponse> Viewport);
|
||||
|
||||
private sealed record TerminalScreenLineResponse(
|
||||
int Index,
|
||||
string Text);
|
||||
}
|
||||
|
||||
@ -231,9 +231,9 @@ public class SessionRegistryTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScreenSnapshot_Returns_Authoritative_Primary_Buffer_State()
|
||||
public async Task Experiment_GetScreenSnapshot_Returns_Authoritative_Primary_Buffer_State()
|
||||
{
|
||||
using var harness = SessionRegistryHarness.Create();
|
||||
using var harness = SessionRegistryHarness.Create(enableBackendScreenProtocol: true);
|
||||
var session = harness.Registry.Create("Shell", DateTimeOffset.UtcNow);
|
||||
|
||||
await harness.Registry.RecordResizeAsync(session.SessionId, 40, 10, CancellationToken.None);
|
||||
@ -284,7 +284,9 @@ public class SessionRegistryTests
|
||||
|
||||
public SessionRegistry Registry { get; }
|
||||
|
||||
public static SessionRegistryHarness Create(int lineLimit = 4000)
|
||||
public static SessionRegistryHarness Create(
|
||||
int lineLimit = 4000,
|
||||
bool enableBackendScreenProtocol = false)
|
||||
{
|
||||
var dataRoot = Path.Combine(Path.GetTempPath(), "TermRemoteCtl.Tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dataRoot);
|
||||
@ -293,6 +295,7 @@ public class SessionRegistryTests
|
||||
{
|
||||
DataRoot = dataRoot,
|
||||
RingBufferLineLimit = lineLimit,
|
||||
EnableBackendScreenProtocol = enableBackendScreenProtocol,
|
||||
});
|
||||
var historyStore = new SessionHistoryStore(dataRoot);
|
||||
var journalStore = new SessionIoJournalStore(dataRoot);
|
||||
|
||||
@ -90,4 +90,46 @@ public sealed class TerminalScreenEngineTests
|
||||
Assert.Equal("primary", restoredSnapshot.ActiveBuffer);
|
||||
Assert.StartsWith("primary", restoredSnapshot.PrimaryBuffer.Viewport[0].Text, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyOutput_Osc_Window_Title_Does_Not_Leak_Into_Visible_Screen()
|
||||
{
|
||||
var engine = new TerminalScreenEngine(rows: 4, cols: 80);
|
||||
|
||||
engine.ApplyOutput("\u001b]0;C:\\WINDOWS\\system32\\WindowsPowerShell\\v1.0\\powershell.exe\u0007", sourceSequence: 1);
|
||||
engine.ApplyOutput("PS D:\\App\\python\\MLplatform> pwd", sourceSequence: 2);
|
||||
|
||||
var snapshot = engine.CreateSnapshot("session-1");
|
||||
|
||||
Assert.DoesNotContain(
|
||||
snapshot.PrimaryBuffer.Viewport,
|
||||
line => line.Text.Contains("]0;", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(
|
||||
snapshot.PrimaryBuffer.Viewport,
|
||||
line => line.Text.Contains("powershell.exe", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.StartsWith(
|
||||
"PS D:\\App\\python\\MLplatform> pwd",
|
||||
snapshot.PrimaryBuffer.Viewport[0].Text,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Equal(0, snapshot.CursorRow);
|
||||
Assert.Equal(32, snapshot.CursorColumn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyOutput_Osc_Window_Title_With_String_Terminator_Does_Not_Leak_Into_Visible_Screen()
|
||||
{
|
||||
var engine = new TerminalScreenEngine(rows: 4, cols: 80);
|
||||
|
||||
engine.ApplyOutput("\u001b]0;PowerShell Title\u001b\\", sourceSequence: 1);
|
||||
engine.ApplyOutput("prompt> ", sourceSequence: 2);
|
||||
|
||||
var snapshot = engine.CreateSnapshot("session-1");
|
||||
|
||||
Assert.DoesNotContain(
|
||||
snapshot.PrimaryBuffer.Viewport,
|
||||
line => line.Text.Contains("PowerShell Title", StringComparison.Ordinal));
|
||||
Assert.StartsWith("prompt> ", snapshot.PrimaryBuffer.Viewport[0].Text, StringComparison.Ordinal);
|
||||
Assert.Equal(0, snapshot.CursorRow);
|
||||
Assert.Equal(8, snapshot.CursorColumn);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,99 @@
|
||||
# Terminal Truth-Source Reset Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Reset the terminal architecture so the mainline uses a single rendering truth source, while isolating backend-authoritative screen recovery into an explicit experiment path.
|
||||
|
||||
**Architecture:** Keep backend journal, restore, and output as the durable data path; keep frontend `xterm` as the visible rendering path; remove backend screen protocol from the default runtime path; quarantine the backend screen engine and related protocol into an experiment track.
|
||||
|
||||
**Tech Stack:** ASP.NET Core minimal APIs, C#, Flutter, xterm, websocket_channel, Flutter widget tests, .NET unit tests, Markdown specs and plans.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Lock The Mainline Runtime Boundary With Failing Frontend Tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/mobile_app/test/widget_test.dart`
|
||||
- Modify: `apps/mobile_app/test/features/terminal/terminal_page_input_test.dart`
|
||||
- Test: `apps/mobile_app/test/widget_test.dart`
|
||||
- Test: `apps/mobile_app/test/features/terminal/terminal_page_input_test.dart`
|
||||
|
||||
- [ ] **Step 1: Add a failing widget test that proves `TerminalPage` ignores backend `screen_snapshot` by default and still renders legacy `restore` content**
|
||||
- [ ] **Step 2: Add or update a widget test that proves backend screen protocol only affects rendering when explicitly enabled**
|
||||
- [ ] **Step 3: Run `C:\\tools\\flutter\\bin\\flutter.bat test test/widget_test.dart --plain-name "screen snapshot"` from `apps/mobile_app` and confirm the new expectations fail before the implementation change**
|
||||
- [ ] **Step 4: Run `C:\\tools\\flutter\\bin\\flutter.bat test test/features/terminal/terminal_page_input_test.dart` to verify the terminal input baseline remains intact before refactoring**
|
||||
|
||||
### Task 2: Remove Backend Screen Protocol From The Default Mobile Path
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/mobile_app/lib/features/terminal/terminal_page.dart`
|
||||
- Modify: `apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart`
|
||||
- Modify: `apps/mobile_app/lib/features/terminal/terminal_socket_session.dart`
|
||||
- Modify: `apps/mobile_app/test/widget_test.dart`
|
||||
|
||||
- [ ] **Step 1: Add an explicit runtime flag on `TerminalPage` and `TerminalSessionCoordinator` so backend screen protocol is disabled on the default app path**
|
||||
- [ ] **Step 2: Ensure the default runtime path subscribes only to `restore` and `output` as rendering truth**
|
||||
- [ ] **Step 3: Keep screen protocol callbacks available only for explicit experiment wiring**
|
||||
- [ ] **Step 4: Re-run `C:\\tools\\flutter\\bin\\flutter.bat test test/widget_test.dart --plain-name "screen snapshot"` and confirm the default-path tests pass**
|
||||
- [ ] **Step 5: Re-run `C:\\tools\\flutter\\bin\\flutter.bat test test/features/terminal/terminal_page_input_test.dart` and confirm input behavior still passes**
|
||||
|
||||
### Task 3: Strip Mainline Rendering Dependency On Screen Models
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/mobile_app/lib/features/terminal/terminal_page.dart`
|
||||
- Modify: `apps/mobile_app/lib/features/terminal/terminal_screen_snapshot.dart`
|
||||
- Modify: `apps/mobile_app/lib/features/terminal/terminal_screen_state.dart`
|
||||
- Modify: `apps/mobile_app/test/features/terminal/terminal_screen_state_test.dart`
|
||||
|
||||
- [ ] **Step 1: Remove any mainline rendering assumptions that convert backend screen models into replay text for `xterm`**
|
||||
- [ ] **Step 2: Keep screen-model helpers only if they are clearly marked as experiment-only and are no longer part of the default runtime path**
|
||||
- [ ] **Step 3: Drop or relocate tests that currently validate screen-model replay as mainline terminal behavior**
|
||||
- [ ] **Step 4: Run the targeted Flutter tests that still belong to the mainline and confirm no mainline test depends on backend screen snapshot replay**
|
||||
|
||||
### Task 4: Simplify Backend Mainline Websocket Contract
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs`
|
||||
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs`
|
||||
- Modify: `apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs`
|
||||
|
||||
- [ ] **Step 1: Add failing backend tests that define the stable mainline websocket contract as `attached + restore + output`**
|
||||
- [ ] **Step 2: Remove default runtime emission of `screen_snapshot`, `screen_patch`, and `screen_sync` from the mainline websocket flow, or gate them behind an explicit experiment switch**
|
||||
- [ ] **Step 3: Keep sequence-aware `restore` and `output` semantics intact**
|
||||
- [ ] **Step 4: Run `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/TermRemoteCtl.Agent.IntegrationTests.csproj --filter "TerminalWebSocketHandlerTests"` and verify the stable contract passes**
|
||||
|
||||
### Task 5: Isolate Backend Screen Engine Into An Experiment Track
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/Screen/TerminalScreenEngine.cs`
|
||||
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/Screen/TerminalScreenPatch.cs`
|
||||
- Modify: `apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/TerminalScreenEngineTests.cs`
|
||||
- Modify: `docs/superpowers/specs/2026-04-10-terminal-truth-source-reset-design.md`
|
||||
|
||||
- [ ] **Step 1: Stop treating `TerminalScreenEngine` as a production-ready mainline dependency**
|
||||
- [ ] **Step 2: Rename, relocate, or clearly annotate screen-engine code and tests as experiment-only**
|
||||
- [ ] **Step 3: Keep the experiment test suite runnable in isolation, but do not let it define default product behavior**
|
||||
- [ ] **Step 4: Run `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/TermRemoteCtl.Agent.Tests.csproj --filter "TerminalScreenEngineTests"` and verify the experiment suite remains green in isolation**
|
||||
|
||||
### Task 6: Clean Up Docs And Developer Guidance
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/superpowers/specs/2026-04-10-terminal-truth-source-reset-design.md`
|
||||
- Modify: `docs/testing/manual-smoke-checklist.md`
|
||||
- Modify: `README.md`
|
||||
|
||||
- [ ] **Step 1: Document that the stable product path is `restore/output + xterm`, not backend screen snapshots**
|
||||
- [ ] **Step 2: Document that backend-authoritative screen recovery is an experiment branch concern**
|
||||
- [ ] **Step 3: Update manual smoke checks to focus the mainline on stable shell input, reconnect, resize, and repeated command execution**
|
||||
- [ ] **Step 4: Add a short note in `README.md` or a developer-facing doc explaining the single-truth-source rule**
|
||||
|
||||
### Task 7: Final Verification
|
||||
|
||||
**Files:**
|
||||
- Verify only
|
||||
|
||||
- [ ] **Step 1: Run `C:\\tools\\flutter\\bin\\flutter.bat test test/widget_test.dart test/features/terminal/terminal_page_input_test.dart` from `apps/mobile_app`**
|
||||
- [ ] **Step 2: Run `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/TermRemoteCtl.Agent.IntegrationTests.csproj --filter "TerminalWebSocketHandlerTests"`**
|
||||
- [ ] **Step 3: Run `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/TermRemoteCtl.Agent.Tests.csproj --filter "TerminalScreenEngineTests"` only as experiment verification, not as a mainline gate**
|
||||
- [ ] **Step 4: Perform a manual smoke check with `pwd -> ls -> pwd`, reconnect once, resize once, and verify the prompt remains usable**
|
||||
- [ ] **Step 5: Summarize the final boundary in release notes or task output: mainline is stable-shell-first, experiment branch is backend-screen-first**
|
||||
@ -0,0 +1,275 @@
|
||||
# Terminal Truth-Source Reset Design
|
||||
|
||||
## Goal
|
||||
|
||||
Stop the terminal from drifting into unusable state by resetting the architecture to a single rendering truth source on the mainline, while isolating backend-authoritative screen recovery into an explicit experiment branch.
|
||||
|
||||
## Decision
|
||||
|
||||
The mainline must use:
|
||||
|
||||
- frontend `xterm` as the only terminal rendering truth
|
||||
- backend journal and websocket output as the only terminal data truth
|
||||
- local mobile snapshot as a speed-only cache
|
||||
|
||||
The mainline must not use:
|
||||
|
||||
- backend `screen_snapshot` as a rendering truth
|
||||
- backend `screen_patch` as a rendering truth
|
||||
- frontend replay of backend screen models back into `xterm`
|
||||
|
||||
The backend-authoritative screen protocol stays in the repository only as an experiment and must not remain on the default product path.
|
||||
|
||||
## Why This Reset Is Necessary
|
||||
|
||||
The current system mixes two incompatible terminal architectures:
|
||||
|
||||
1. `xterm`-driven rendering
|
||||
2. backend screen-model-driven rendering
|
||||
|
||||
Each one can work if it is the only truth source. They break when both are live at the same time.
|
||||
|
||||
Today the project has all of the following active or partially active:
|
||||
|
||||
- backend raw output journal
|
||||
- backend replay buffer
|
||||
- backend pending input echo tracker
|
||||
- backend screen engine
|
||||
- websocket `restore`
|
||||
- websocket `output`
|
||||
- websocket `screen_snapshot`
|
||||
- websocket `screen_patch`
|
||||
- frontend local snapshot
|
||||
- frontend `xterm`
|
||||
|
||||
That creates multiple answers to the same business question:
|
||||
|
||||
`What should the user see on screen right now?`
|
||||
|
||||
As soon as reconnect, resize, delayed echo, PowerShell control sequences, or prompt line editing show up, the answers diverge.
|
||||
|
||||
## Root Cause
|
||||
|
||||
### Business View
|
||||
|
||||
Users do not care whether reconnect shows something plausible. They care whether the terminal remains stable:
|
||||
|
||||
- the prompt stays readable
|
||||
- typed input appears where expected
|
||||
- later commands do not corrupt earlier output
|
||||
- reconnect does not make the session less trustworthy
|
||||
|
||||
The current design fails that because the product lets two different models of the terminal race to control the same screen.
|
||||
|
||||
### Technical View
|
||||
|
||||
The current backend screen engine is not strong enough to own rendering truth:
|
||||
|
||||
- it only supports a narrow subset of escape semantics
|
||||
- it does not model scroll behavior correctly for a real shell stream
|
||||
- it does not model PowerShell line editing, prompt redraw, or table layout with enough fidelity
|
||||
- it still leaks some terminal control semantics into visible text unless patched case by case
|
||||
|
||||
At the same time, the frontend is already using `xterm`, which is itself a terminal interpreter. Feeding backend screen state back into `xterm` means the system interprets terminal state twice.
|
||||
|
||||
That is the structural reason the output gets more chaotic over time.
|
||||
|
||||
## Mainline Architecture
|
||||
|
||||
### Rendering Truth
|
||||
|
||||
Mainline rendering truth is:
|
||||
|
||||
- websocket `output` frames
|
||||
- websocket `restore`
|
||||
- local provisional snapshot until backend restore arrives
|
||||
- `xterm` buffer as the actual visible state
|
||||
|
||||
This means:
|
||||
|
||||
- the backend does not send a screen model that the frontend treats as authoritative
|
||||
- the frontend does not try to reconstruct a backend screen model on top of `xterm`
|
||||
- reconnect correctness is limited to shell-oriented restore, not full terminal-state recovery
|
||||
|
||||
That limitation is acceptable on the mainline because stability is more important than partial fidelity masquerading as correctness.
|
||||
|
||||
### History Truth
|
||||
|
||||
Mainline history truth remains:
|
||||
|
||||
- backend journal and sequence model
|
||||
- backend history API built from durable journal data
|
||||
|
||||
This stays because it is already aligned with the right authority boundary:
|
||||
|
||||
- journal answers `what happened`
|
||||
- `xterm` answers `what is visible now`
|
||||
|
||||
### Local Snapshot Role
|
||||
|
||||
Local mobile snapshot remains allowed only as:
|
||||
|
||||
- a same-device startup acceleration layer
|
||||
- a provisional paint while waiting for backend restore
|
||||
|
||||
It must never override backend restore correctness.
|
||||
|
||||
## What Stays On The Mainline
|
||||
|
||||
### Backend
|
||||
|
||||
Keep:
|
||||
|
||||
- `SessionIoJournalStore`
|
||||
- `SessionHistoryStore`
|
||||
- durable session journal APIs
|
||||
- sequence-aware `restore`
|
||||
- sequence-aware `output`
|
||||
- `PendingInputEchoTracker` only as part of shell-oriented restore
|
||||
- `TerminalReplayBuffer` only as part of shell-oriented restore
|
||||
|
||||
Keep these files on the product path:
|
||||
|
||||
- `apps/windows_agent/src/TermRemoteCtl.Agent/History/SessionIoJournalStore.cs`
|
||||
- `apps/windows_agent/src/TermRemoteCtl.Agent/History/SessionHistoryStore.cs`
|
||||
- `apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs`
|
||||
- `apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs`
|
||||
- `apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRestoreSnapshot.cs`
|
||||
|
||||
### Frontend
|
||||
|
||||
Keep:
|
||||
|
||||
- `TerminalSocketSession` attach, restore, output handling
|
||||
- `TerminalSessionCoordinator` reconnect, pending input buffering, history browsing
|
||||
- `TerminalPage` live output rendering through `xterm`
|
||||
- local snapshot storage and restore
|
||||
|
||||
Keep these files on the product path:
|
||||
|
||||
- `apps/mobile_app/lib/features/terminal/terminal_socket_session.dart`
|
||||
- `apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart`
|
||||
- `apps/mobile_app/lib/features/terminal/terminal_page.dart`
|
||||
- `apps/mobile_app/lib/features/terminal/terminal_snapshot.dart`
|
||||
- `apps/mobile_app/lib/features/terminal/terminal_snapshot_storage.dart`
|
||||
|
||||
## What Must Be Removed From The Mainline
|
||||
|
||||
These behaviors must be removed from default runtime behavior:
|
||||
|
||||
- frontend rendering based on `screen_snapshot`
|
||||
- frontend rendering based on `screen_patch`
|
||||
- frontend conversion of backend screen models into escape replay strings
|
||||
- backend attach contract that expects the client to consume both `restore` and `screen_snapshot` as active truths
|
||||
- backend live contract that expects the client to consume both `output` and `screen_patch` as active truths
|
||||
|
||||
These files or code paths should no longer participate in the default product path:
|
||||
|
||||
- `apps/mobile_app/lib/features/terminal/terminal_screen_snapshot.dart`
|
||||
- `apps/mobile_app/lib/features/terminal/terminal_screen_patch.dart`
|
||||
- `apps/mobile_app/lib/features/terminal/terminal_screen_state.dart`
|
||||
- screen-protocol branches inside `terminal_page.dart`
|
||||
- screen-protocol branches inside `terminal_session_coordinator.dart`
|
||||
- screen-protocol parsing that is wired into the default runtime path inside `terminal_socket_session.dart`
|
||||
|
||||
## What Moves To The Experiment Branch
|
||||
|
||||
The backend-authoritative screen effort should continue only behind an explicit experiment boundary.
|
||||
|
||||
That experiment branch owns:
|
||||
|
||||
- `TerminalScreenEngine`
|
||||
- `TerminalScreenSnapshot`
|
||||
- `TerminalScreenPatch`
|
||||
- websocket `screen_snapshot`
|
||||
- websocket `screen_patch`
|
||||
- websocket `screen_sync`
|
||||
- frontend screen snapshot and patch models
|
||||
- frontend screen snapshot and patch application
|
||||
|
||||
These files belong to the experiment track:
|
||||
|
||||
- `apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/Screen/TerminalScreenEngine.cs`
|
||||
- `apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/Screen/TerminalScreenSnapshot.cs`
|
||||
- `apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/Screen/TerminalScreenPatch.cs`
|
||||
- `apps/mobile_app/lib/features/terminal/terminal_screen_snapshot.dart`
|
||||
- `apps/mobile_app/lib/features/terminal/terminal_screen_patch.dart`
|
||||
- `apps/mobile_app/lib/features/terminal/terminal_screen_state.dart`
|
||||
|
||||
The experiment is valid only if it eventually reaches this architecture:
|
||||
|
||||
- backend owns a real terminal emulator
|
||||
- frontend does not feed screen models back into `xterm`
|
||||
- frontend renders screen state directly from backend screen data
|
||||
|
||||
If that condition is not met, the experiment should not merge back into mainline.
|
||||
|
||||
## Runtime Boundary
|
||||
|
||||
There must be an explicit runtime boundary between:
|
||||
|
||||
- stable mainline behavior
|
||||
- experimental screen-protocol behavior
|
||||
|
||||
Recommended rule:
|
||||
|
||||
- mainline build defaults `enableBackendScreenProtocol = false`
|
||||
- experiment branch may enable it by explicit wiring
|
||||
- no hidden fallback should silently re-enable screen protocol in production paths
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Mainline Tests
|
||||
|
||||
Mainline tests should prove:
|
||||
|
||||
- `pwd -> ls -> pwd` remains stable
|
||||
- reconnect keeps input working
|
||||
- resize does not corrupt future input
|
||||
- output remains append-oriented and readable through `xterm`
|
||||
- local snapshot is provisional and restore remains authoritative
|
||||
|
||||
Mainline tests must not assert backend screen snapshot correctness.
|
||||
|
||||
### Experiment Tests
|
||||
|
||||
Experiment tests should prove:
|
||||
|
||||
- scroll semantics
|
||||
- cursor-addressed redraw
|
||||
- PowerShell title or control sequences
|
||||
- prompt editing
|
||||
- alternate buffer behavior
|
||||
- screen version continuity
|
||||
|
||||
These tests should not block mainline unless the experiment is being promoted.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Phase 1: Mainline Reset
|
||||
|
||||
- disable screen protocol on the default mobile path
|
||||
- stop using backend screen models to drive visible terminal state
|
||||
- keep journal, restore, output, and local snapshot semantics intact
|
||||
- remove or quarantine tests that assume backend screen protocol is part of default production behavior
|
||||
|
||||
### Phase 2: Codebase Cleanup
|
||||
|
||||
- remove dead mainline branches for screen protocol
|
||||
- move screen protocol files and tests under an explicit experiment area or keep them feature-gated with clear naming
|
||||
- update docs so the team cannot mistake the experiment for the product path
|
||||
|
||||
### Phase 3: Experiment Continuation
|
||||
|
||||
- continue backend-authoritative screen work in isolation
|
||||
- do not merge until the frontend rendering model also changes
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
This reset is complete when:
|
||||
|
||||
- the default app path no longer consumes `screen_snapshot` or `screen_patch`
|
||||
- terminal rendering uses only one truth source on the mainline
|
||||
- repeated shell commands do not progressively corrupt the visible prompt
|
||||
- reconnect and resize no longer activate competing rendering paths
|
||||
- the repository documents that backend-authoritative screen recovery is experimental, not production truth
|
||||
@ -3,11 +3,14 @@
|
||||
1. Start the Windows agent on the primary Windows machine.
|
||||
2. Pair the iPhone app with a fresh one-time pairing code.
|
||||
3. Create `codex-main` and `cloud-code` sessions.
|
||||
4. Start a noisy command in `codex-main`.
|
||||
5. Background the app for one minute.
|
||||
6. Reopen the app and confirm the same session is still alive.
|
||||
7. Scroll upward and confirm older history loads.
|
||||
8. Trigger one preset command and confirm it appears in the terminal.
|
||||
9. Terminate one session and confirm only that session exits.
|
||||
10. Type a partial command, background the app, reopen it, and confirm the typed command is still visible.
|
||||
11. Execute a command, reconnect during output, and confirm the command is not duplicated after restore.
|
||||
4. In `codex-main`, run `pwd`, then `ls`, then `pwd` again and confirm the prompt stays readable.
|
||||
5. Start a noisy command in `codex-main` and confirm live output keeps appending in the terminal view.
|
||||
6. Background the app for one minute.
|
||||
7. Reopen the app and confirm the same session is still alive and input still works.
|
||||
8. Type a partial command, background the app, reopen it, and confirm the typed command is still visible after restore.
|
||||
9. Resize the terminal once and confirm later input still appears in the expected prompt position.
|
||||
10. Scroll upward and confirm older history loads without corrupting the live terminal.
|
||||
11. Trigger one preset command and confirm it appears in the terminal.
|
||||
12. Terminate one session and confirm only that session exits.
|
||||
13. Execute a command, reconnect during output, and confirm the command is not duplicated after restore.
|
||||
14. Confirm the mainline terminal stays driven by `restore + output + xterm`; do not use backend `screen_snapshot` or `screen_patch` as the visible truth.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user