diff --git a/README.md b/README.md index 13ed1d1..5a78317 100644 --- a/README.md +++ b/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. diff --git a/apps/mobile_app/lib/core/network/agent_socket_client.dart b/apps/mobile_app/lib/core/network/agent_socket_client.dart index 82eb082..e2be05e 100644 --- a/apps/mobile_app/lib/core/network/agent_socket_client.dart +++ b/apps/mobile_app/lib/core/network/agent_socket_client.dart @@ -13,24 +13,14 @@ class AgentSocketClient { ); } - Map buildAttachMessage(String sessionId) => { - 'type': 'attach', - 'sessionId': sessionId, - }; + Map buildAttachMessage(String sessionId) => + {'type': 'attach', 'sessionId': sessionId}; Map buildInputMessage(String input) => { - 'type': 'input', - 'input': input, - }; + 'type': 'input', + 'input': input, + }; Map buildResizeMessage(int columns, int rows) => - { - 'type': 'resize', - 'columns': columns, - 'rows': rows, - }; - - Map buildScreenSyncMessage() => { - 'type': 'screen_sync', - }; + {'type': 'resize', 'columns': columns, 'rows': rows}; } diff --git a/apps/mobile_app/lib/features/terminal/terminal_page.dart b/apps/mobile_app/lib/features/terminal/terminal_page.dart index eea852d..38c7e7d 100644 --- a/apps/mobile_app/lib/features/terminal/terminal_page.dart +++ b/apps/mobile_app/lib/features/terminal/terminal_page.dart @@ -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 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 bool _showExpandedControls = false; _TerminalInputMode _inputMode = _TerminalInputMode.read; TerminalConnectionState? _lastConnectionState; - TerminalScreenState? _authoritativeScreenState; @override void initState() { @@ -179,8 +174,6 @@ class _TerminalPageState extends ConsumerState 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 _terminalFocusNode.requestFocus(); } + void _restoreEditFocusIfNeeded() { + if (_inputMode == _TerminalInputMode.edit) { + _terminalFocusNode.requestFocus(); + } + } + Future _copySelectedOrVisibleText() async { final selection = _terminalViewController.selection; final selectedText = selection == null @@ -397,11 +396,6 @@ class _TerminalPageState extends ConsumerState _receivedSocketFrame = true; _awaitingReconnectRestore = false; _cancelHistorySeedTimer(); - if (_authoritativeScreenState != null) { - _scheduleSnapshotPersist(); - return; - } - terminal.write(frame); _scheduleSnapshotPersist(); } @@ -412,11 +406,6 @@ class _TerminalPageState extends ConsumerState _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 _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 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 } void _scheduleHistorySeedIfNeeded() { - if (_receivedScreenSnapshot) { - _cancelHistorySeedTimer(); - return; - } - if (_receivedRestorePayload) { _cancelHistorySeedTimer(); return; @@ -575,12 +520,6 @@ class _TerminalPageState extends ConsumerState 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 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 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 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, + ), ], ); } diff --git a/apps/mobile_app/lib/features/terminal/terminal_screen_snapshot.dart b/apps/mobile_app/lib/features/terminal/terminal_screen_snapshot.dart index b41cb70..665f164 100644 --- a/apps/mobile_app/lib/features/terminal/terminal_screen_snapshot.dart +++ b/apps/mobile_app/lib/features/terminal/terminal_screen_snapshot.dart @@ -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.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.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 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.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 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 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); +} diff --git a/apps/mobile_app/lib/features/terminal/terminal_screen_state.dart b/apps/mobile_app/lib/features/terminal/terminal_screen_state.dart index 112f8dc..4307d88 100644 --- a/apps/mobile_app/lib/features/terminal/terminal_screen_state.dart +++ b/apps/mobile_app/lib/features/terminal/terminal_screen_state.dart @@ -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.from(primaryLines, growable: false); - final nextAlternateLines = List.from(alternateLines, growable: false); + final nextAlternateLines = List.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, + ); } } diff --git a/apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart b/apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart index 5eba66f..99dd211 100644 --- a/apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart +++ b/apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart @@ -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 _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 _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 []; return rawItems .map( - (item) => _JournalItem.fromJson( - Map.from(item as Map), - ), + (item) => + _JournalItem.fromJson(Map.from(item as Map)), ) .toList(growable: false); } diff --git a/apps/mobile_app/lib/features/terminal/terminal_socket_session.dart b/apps/mobile_app/lib/features/terminal/terminal_socket_session.dart index cfdca13..5827151 100644 --- a/apps/mobile_app/lib/features/terminal/terminal_socket_session.dart +++ b/apps/mobile_app/lib/features/terminal/terminal_socket_session.dart @@ -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 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.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.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.from(decoded)); + return TerminalOutputPayload.fromJson( + Map.from(decoded), + ); } if (decoded is Map && decoded['type'] != null) { diff --git a/apps/mobile_app/test/core/network/agent_socket_client_test.dart b/apps/mobile_app/test/core/network/agent_socket_client_test.dart index 49e4e66..a1a9625 100644 --- a/apps/mobile_app/test/core/network/agent_socket_client_test.dart +++ b/apps/mobile_app/test/core/network/agent_socket_client_test.dart @@ -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'), - { - 'type': 'attach', - 'sessionId': 'session-123', - }, - ); + expect(client.buildAttachMessage('session-123'), { + 'type': 'attach', + 'sessionId': 'session-123', + }); }); test('builds input message for terminal input', () { final client = AgentSocketClient(Uri.parse('https://host:9443')); - expect( - client.buildInputMessage('ls'), - { - 'type': 'input', - 'input': 'ls', - }, - ); - }); - - test('builds screen sync message for terminal resync', () { - final client = AgentSocketClient(Uri.parse('https://host:9443')); - - expect( - client.buildScreenSyncMessage(), - { - 'type': 'screen_sync', - }, - ); + expect(client.buildInputMessage('ls'), { + 'type': 'input', + 'input': 'ls', + }); }); } diff --git a/apps/mobile_app/test/features/terminal/terminal_screen_state_test.dart b/apps/mobile_app/test/features/terminal/terminal_screen_state_test.dart new file mode 100644 index 0000000..539a37c --- /dev/null +++ b/apps/mobile_app/test/features/terminal/terminal_screen_state_test.dart @@ -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); + }, + ); +} diff --git a/apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart b/apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart index 10ea968..58e510a 100644 --- a/apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart +++ b/apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart @@ -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 = []; - final restores = []; - 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 { - 'sessionId': 'abc', - 'screenVersion': 4, - 'sourceSequence': 3, - 'rows': 24, - 'columns': 80, - 'cursorRow': 0, - 'cursorColumn': 7, - 'cursorVisible': true, - 'activeBuffer': 'primary', - 'primaryBuffer': { - 'viewport': >[ - {'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 = []; - 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 { - 'sessionId': 'abc', - 'screenVersion': 4, - 'sourceSequence': 7, - 'rows': 24, - 'columns': 80, - 'cursorRow': 0, - 'cursorColumn': 7, - 'cursorVisible': true, - 'activeBuffer': 'primary', - 'primaryBuffer': { - 'viewport': >[ - {'index': 0, 'text': 'PS> git'}, - ], - }, - }), - ); - - sessionFactory.createdSessions.single.emitScreenPatch( - TerminalScreenPatch.fromJson(const { - 'sessionId': 'abc', - 'baseScreenVersion': 4, - 'screenVersion': 5, - 'sourceSequence': 8, - 'rows': 24, - 'columns': 80, - 'cursorRow': 0, - 'cursorColumn': 14, - 'cursorVisible': true, - 'operations': >[ - { - 'type': 'replace_lines', - 'startRow': 0, - 'lines': ['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 = []; - 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 { - 'sessionId': 'abc', - 'screenVersion': 4, - 'sourceSequence': 7, - 'rows': 24, - 'columns': 80, - 'cursorRow': 0, - 'cursorColumn': 7, - 'cursorVisible': true, - 'activeBuffer': 'primary', - 'primaryBuffer': { - 'viewport': >[ - {'index': 0, 'text': 'PS> git'}, - ], - }, - }), - ); - - sessionFactory.createdSessions.single.emitScreenPatch( - TerminalScreenPatch.fromJson(const { - 'sessionId': 'abc', - 'baseScreenVersion': 3, - 'screenVersion': 5, - 'sourceSequence': 8, - 'rows': 24, - 'columns': 80, - 'cursorRow': 0, - 'cursorColumn': 14, - 'cursorVisible': true, - 'operations': >[ - { - 'type': 'replace_lines', - 'startRow': 0, - 'lines': ['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 = []; - final snapshots = []; - 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 { - 'sessionId': 'abc', - 'screenVersion': 4, - 'sourceSequence': 7, - 'rows': 24, - 'columns': 80, - 'cursorRow': 0, - 'cursorColumn': 7, - 'cursorVisible': true, - 'activeBuffer': 'primary', - 'primaryBuffer': { - 'viewport': >[ - {'index': 0, 'text': 'PS> git'}, - ], - }, - }), - ); - - sessionFactory.createdSessions.single.emitScreenPatch( - TerminalScreenPatch.fromJson(const { - 'sessionId': 'abc', - 'baseScreenVersion': 1, - 'screenVersion': 5, - 'sourceSequence': 8, - 'rows': 24, - 'columns': 80, - 'cursorRow': 0, - 'cursorColumn': 14, - 'cursorVisible': true, - 'operations': >[ - { - 'type': 'replace_lines', - 'startRow': 0, - 'lines': ['PS> git status'], - }, - ], - }), - ); - - expect(patches, isEmpty); - expect(sessionFactory.createdSessions.single.screenSyncRequestCount, 1); - - sessionFactory.createdSessions.single.emitScreenSnapshot( - TerminalScreenSnapshot.fromJson(const { - 'sessionId': 'abc', - 'screenVersion': 5, - 'sourceSequence': 8, - 'rows': 24, - 'columns': 80, - 'cursorRow': 0, - 'cursorColumn': 14, - 'cursorVisible': true, - 'activeBuffer': 'primary', - 'primaryBuffer': { - 'viewport': >[ - {'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 { - 'sessionId': 'abc', - 'screenVersion': 4, - 'sourceSequence': 7, - 'rows': 24, - 'columns': 80, - 'cursorRow': 0, - 'cursorColumn': 7, - 'cursorVisible': true, - 'activeBuffer': 'primary', - 'primaryBuffer': { - 'viewport': >[ - {'index': 0, 'text': 'PS> git'}, - ], - }, - }), - ); - - final mismatchPatch = TerminalScreenPatch.fromJson(const { - 'sessionId': 'abc', - 'baseScreenVersion': 1, - 'screenVersion': 5, - 'sourceSequence': 8, - 'rows': 24, - 'columns': 80, - 'cursorRow': 0, - 'cursorColumn': 14, - 'cursorVisible': true, - 'operations': >[ - { - 'type': 'replace_lines', - 'startRow': 0, - 'lines': ['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 { - 'sessionId': 'abc', - 'screenVersion': 5, - 'sourceSequence': 8, - 'rows': 24, - 'columns': 80, - 'cursorRow': 0, - 'cursorColumn': 14, - 'cursorVisible': true, - 'activeBuffer': 'primary', - 'primaryBuffer': { - 'viewport': >[ - {'index': 0, 'text': 'PS> git status'}, - ], - }, - }), - ); - - sessionFactory.createdSessions.single.emitScreenPatch( - TerminalScreenPatch.fromJson(const { - 'sessionId': 'abc', - 'baseScreenVersion': 3, - 'screenVersion': 6, - 'sourceSequence': 9, - 'rows': 24, - 'columns': 80, - 'cursorRow': 0, - 'cursorColumn': 18, - 'cursorVisible': true, - 'operations': >[ - { - 'type': 'replace_lines', - 'startRow': 0, - 'lines': ['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 = >[]; final sentInputs = []; - int screenSyncRequestCount = 0; int disposeCount = 0; Completer? disposeCompleter; Completer _connectCompleter = Completer(); @@ -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 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(); diff --git a/apps/mobile_app/test/features/terminal/terminal_socket_session_test.dart b/apps/mobile_app/test/features/terminal/terminal_socket_session_test.dart index 6dcd715..aab9405 100644 --- a/apps/mobile_app/test/features/terminal/terminal_socket_session_test.dart +++ b/apps/mobile_app/test/features/terminal/terminal_socket_session_test.dart @@ -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 = []; 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.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.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 = []; - final snapshots = []; final restores = []; final connectFuture = session.connect( onOutput: outputs.add, - onScreenSnapshot: snapshots.add, onRestore: restores.add, ); await Future.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.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 = []; - final patches = []; - final connectFuture = session.connect( - onOutput: outputs.add, - onRestore: (_) {}, - onScreenPatch: patches.add, - ); - await Future.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.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 { diff --git a/apps/mobile_app/test/terminal_session_coordinator_diagnostics_test.dart b/apps/mobile_app/test/terminal_session_coordinator_diagnostics_test.dart index 7719872..b873c5a 100644 --- a/apps/mobile_app/test/terminal_session_coordinator_diagnostics_test.dart +++ b/apps/mobile_app/test/terminal_session_coordinator_diagnostics_test.dart @@ -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 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 {} diff --git a/apps/mobile_app/test/widget_test.dart b/apps/mobile_app/test/widget_test.dart index 6e6d789..f92d139 100644 --- a/apps/mobile_app/test/widget_test.dart +++ b/apps/mobile_app/test/widget_test.dart @@ -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(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(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(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(find.byType(TerminalView)) - .terminal; - expect(terminal.buffer.getText(), contains('primary shell')); - expect(terminal.buffer.getText(), isNot(contains('alt ui'))); - }); + final terminal = tester + .widget(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')); diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptions.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptions.cs index 644fa0d..0ad789c 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptions.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptions.cs @@ -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; diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptionsServiceCollectionExtensions.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptionsServiceCollectionExtensions.cs index 2e773b1..43fb133 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptionsServiceCollectionExtensions.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptionsServiceCollectionExtensions.cs @@ -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(); diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs index b94f24e..36e1e32 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs @@ -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 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 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 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 Operations, - string Type = "screen_patch"); - - private sealed record TerminalScreenPatchOperationResponse( - string Type, - int StartRow, - IReadOnlyList Lines); - private sealed record TerminalClientMessage( string Type, string? SessionId, diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs index 92e22fe..ea5f1a3 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs @@ -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 _records = new(); private readonly ConcurrentDictionary _historyBySession = new(); private readonly ConcurrentDictionary _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."); diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/Screen/TerminalScreenEngine.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/Screen/TerminalScreenEngine.cs index 5448648..b785b30 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/Screen/TerminalScreenEngine.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/Screen/TerminalScreenEngine.cs @@ -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) diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/appsettings.json b/apps/windows_agent/src/TermRemoteCtl.Agent/appsettings.json index 44f0042..5f9baa0 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/appsettings.json +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/appsettings.json @@ -5,6 +5,7 @@ "HttpsPort": 0, "HttpPort": 5067, "WebSocketFrameFlushMilliseconds": 33, - "RingBufferLineLimit": 4000 + "RingBufferLineLimit": 4000, + "EnableBackendScreenProtocol": false } } diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs index d542d0a..e0318e9 100644 --- a/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs @@ -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( - 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( 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( - 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( 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( - 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( - 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( 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( - 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( 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( - 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( @@ -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(); - 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( - outputFrame, - new JsonSerializerOptions(JsonSerializerDefaults.Web)); - Assert.NotNull(outputPayload); - Assert.Equal("prompt> ", outputPayload!.Chunk); - - var patchFrame = await ReceiveTextAsync(socket, CancellationToken.None); - var patchPayload = JsonSerializer.Deserialize( - 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(); - 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( - 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(); - 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( - 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(); - 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( - 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 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 Operations, - string Type); - - private sealed record TerminalScreenPatchOperationResponse( - string Type, - int StartRow, - IReadOnlyList 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 Viewport); - - private sealed record TerminalScreenLineResponse( - int Index, - string Text); } diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs index cda9351..108bccd 100644 --- a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs @@ -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); diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/TerminalScreenEngineTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/TerminalScreenEngineTests.cs index 161494e..256c63f 100644 --- a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/TerminalScreenEngineTests.cs +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/TerminalScreenEngineTests.cs @@ -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); + } } diff --git a/docs/superpowers/plans/2026-04-10-terminal-truth-source-reset.md b/docs/superpowers/plans/2026-04-10-terminal-truth-source-reset.md new file mode 100644 index 0000000..d297c9b --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-terminal-truth-source-reset.md @@ -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** diff --git a/docs/superpowers/specs/2026-04-10-terminal-truth-source-reset-design.md b/docs/superpowers/specs/2026-04-10-terminal-truth-source-reset-design.md new file mode 100644 index 0000000..f0e02ec --- /dev/null +++ b/docs/superpowers/specs/2026-04-10-terminal-truth-source-reset-design.md @@ -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 diff --git a/docs/testing/manual-smoke-checklist.md b/docs/testing/manual-smoke-checklist.md index 337fbcb..6d50ef9 100644 --- a/docs/testing/manual-smoke-checklist.md +++ b/docs/testing/manual-smoke-checklist.md @@ -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.