Reset terminal mainline to restore-output xterm

This commit is contained in:
sladro 2026-04-10 12:21:19 +08:00
parent 6fe97e7d8a
commit b460957bcf
25 changed files with 891 additions and 1339 deletions

View File

@ -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.

View File

@ -13,24 +13,14 @@ class AgentSocketClient {
);
}
Map<String, dynamic> buildAttachMessage(String sessionId) => <String, dynamic>{
'type': 'attach',
'sessionId': sessionId,
};
Map<String, dynamic> buildAttachMessage(String sessionId) =>
<String, dynamic>{'type': 'attach', 'sessionId': sessionId};
Map<String, dynamic> buildInputMessage(String input) => <String, dynamic>{
'type': 'input',
'input': input,
};
'type': 'input',
'input': input,
};
Map<String, dynamic> buildResizeMessage(int columns, int rows) =>
<String, dynamic>{
'type': 'resize',
'columns': columns,
'rows': rows,
};
Map<String, dynamic> buildScreenSyncMessage() => <String, dynamic>{
'type': 'screen_sync',
};
<String, dynamic>{'type': 'resize', 'columns': columns, 'rows': rows};
}

View File

@ -22,9 +22,6 @@ import 'repeatable_terminal_key_button.dart';
import 'terminal_interaction_controller.dart';
import 'terminal_restore_payload.dart';
import 'terminal_restore_decision.dart';
import 'terminal_screen_patch.dart';
import 'terminal_screen_state.dart';
import 'terminal_screen_snapshot.dart';
import 'terminal_session_coordinator.dart';
import 'terminal_snapshot.dart';
import 'terminal_snapshot_storage.dart';
@ -154,7 +151,6 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
String? _pendingHistorySeed;
bool _receivedSocketFrame = false;
bool _receivedRestorePayload = false;
bool _receivedScreenSnapshot = false;
bool _historySeeded = false;
bool _awaitingAttachReplayFrame = true;
bool _awaitingReconnectRestore = false;
@ -162,7 +158,6 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
bool _showExpandedControls = false;
_TerminalInputMode _inputMode = _TerminalInputMode.read;
TerminalConnectionState? _lastConnectionState;
TerminalScreenState? _authoritativeScreenState;
@override
void initState() {
@ -179,8 +174,6 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
onFrame: _handleTerminalFrame,
onRestore: _handleRestorePayload,
onHistoryLoaded: _handleHistoryLoaded,
onScreenSnapshot: _handleScreenSnapshot,
onScreenPatch: _handleScreenPatch,
viewportProvider: () => TerminalViewport(
columns: terminal.viewWidth,
rows: terminal.viewHeight,
@ -354,6 +347,12 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
_terminalFocusNode.requestFocus();
}
void _restoreEditFocusIfNeeded() {
if (_inputMode == _TerminalInputMode.edit) {
_terminalFocusNode.requestFocus();
}
}
Future<void> _copySelectedOrVisibleText() async {
final selection = _terminalViewController.selection;
final selectedText = selection == null
@ -397,11 +396,6 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
_receivedSocketFrame = true;
_awaitingReconnectRestore = false;
_cancelHistorySeedTimer();
if (_authoritativeScreenState != null) {
_scheduleSnapshotPersist();
return;
}
terminal.write(frame);
_scheduleSnapshotPersist();
}
@ -412,11 +406,6 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
_receivedRestorePayload = true;
_awaitingReconnectRestore = false;
_cancelHistorySeedTimer();
if (_authoritativeScreenState != null) {
_scheduleSnapshotPersist();
return;
}
final combined = restore.screenText + restore.pendingInput;
if (combined.isEmpty) {
_scheduleSnapshotPersist();
@ -435,43 +424,6 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
_scheduleSnapshotPersist();
}
void _handleScreenSnapshot(TerminalScreenSnapshot snapshot) {
_awaitingAttachReplayFrame = false;
_receivedSocketFrame = true;
_receivedScreenSnapshot = true;
_awaitingReconnectRestore = false;
_cancelHistorySeedTimer();
_authoritativeScreenState = TerminalScreenState.fromSnapshot(snapshot);
final displayText = _authoritativeScreenState!.toDisplayText();
_resetTerminalForSnapshot();
if (displayText.isNotEmpty) {
terminal.write(displayText);
}
_historySeeded = _terminalHasVisibleContent;
_scheduleSnapshotPersist();
}
void _handleScreenPatch(TerminalScreenPatch patch) {
final currentState = _authoritativeScreenState;
if (currentState == null || !currentState.canApplyPatch(patch)) {
return;
}
_awaitingAttachReplayFrame = false;
_receivedSocketFrame = true;
_receivedScreenSnapshot = true;
_awaitingReconnectRestore = false;
_cancelHistorySeedTimer();
_authoritativeScreenState = currentState.applyPatch(patch);
final displayText = _authoritativeScreenState!.toDisplayText();
_resetTerminalForSnapshot();
if (displayText.isNotEmpty) {
terminal.write(displayText);
}
_historySeeded = _terminalHasVisibleContent;
_scheduleSnapshotPersist();
}
void _handleHistoryLoaded(HistoryWindow history) {
final seedText = _buildHistorySeedText(history.lines);
if (seedText.isEmpty) {
@ -490,8 +442,6 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
connectionState == TerminalConnectionState.reconnecting) {
_awaitingAttachReplayFrame = true;
_receivedRestorePayload = false;
_receivedScreenSnapshot = false;
_authoritativeScreenState = null;
if (connectionState == TerminalConnectionState.reconnecting) {
_awaitingReconnectRestore = true;
_receivedSocketFrame = false;
@ -506,11 +456,6 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
}
void _scheduleHistorySeedIfNeeded() {
if (_receivedScreenSnapshot) {
_cancelHistorySeedTimer();
return;
}
if (_receivedRestorePayload) {
_cancelHistorySeedTimer();
return;
@ -575,12 +520,6 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
terminal.notifyListeners();
}
void _resetTerminalForSnapshot() {
terminal.buffer.clear();
terminal.buffer.setCursor(0, 0);
terminal.notifyListeners();
}
bool _shouldSuppressAttachReplay(String frame) {
final normalizedFrame = _trimTrailingNewlines(
_normalizeTerminalText(frame),
@ -1064,12 +1003,6 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
onPressed: () =>
_setInputMode(_TerminalInputMode.edit),
),
Text(
_inputMode == _TerminalInputMode.read
? 'Read mode prevents the terminal from taking focus.'
: 'Edit mode keeps the terminal ready for typing.',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
@ -1228,7 +1161,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
Widget _buildStatusRow(BuildContext context) {
final mode = controller.isFollowingLiveOutput ? 'Live' : 'Scrollback';
final modeLabel = '$mode | ${controller.liveLines.length} lines';
final modeLabel = mode;
return Wrap(
key: const Key('terminal_status_summary'),
spacing: 8,
@ -1254,12 +1187,13 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
icon: _statusIcon,
color: _statusColor(context),
),
Text(
_coordinator.connectionStatus,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
if (_connectionState != TerminalConnectionState.connected)
Text(
_coordinator.connectionStatus,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
],
);
}

View File

@ -1,3 +1,4 @@
/// Experiment-only model for the backend screen protocol.
class TerminalScreenSnapshot {
const TerminalScreenSnapshot({
required this.sessionId,
@ -51,7 +52,38 @@ class TerminalScreenSnapshot {
final buffer = activeBuffer == 'alternate' && alternateBuffer != null
? alternateBuffer!
: primaryBuffer;
return buffer.viewport.map((line) => line.text).join('\n');
final lines = List<String>.filled(rows, '');
for (final line in buffer.viewport) {
if (line.index >= 0 && line.index < lines.length) {
lines[line.index] = line.text;
}
}
return renderTerminalScreenText(
lines: lines,
cursorRow: cursorRow,
cursorColumn: cursorColumn,
cursorVisible: cursorVisible,
);
}
String toReplaySequence() {
final buffer = activeBuffer == 'alternate' && alternateBuffer != null
? alternateBuffer!
: primaryBuffer;
final lines = List<String>.filled(rows, '');
for (final line in buffer.viewport) {
if (line.index >= 0 && line.index < lines.length) {
lines[line.index] = line.text;
}
}
return buildTerminalScreenReplay(
lines: lines,
cursorRow: cursorRow,
cursorColumn: cursorColumn,
cursorVisible: cursorVisible,
);
}
}
@ -87,3 +119,119 @@ class TerminalScreenLine {
);
}
}
String renderTerminalScreenText({
required List<String> lines,
required int cursorRow,
required int cursorColumn,
required bool cursorVisible,
}) {
var lastContentRow = -1;
for (var index = 0; index < lines.length; index += 1) {
if (_trimRight(lines[index]).isNotEmpty) {
lastContentRow = index;
}
}
final lastVisibleRow = _clampLastVisibleRow(
lines.length,
lastContentRow: lastContentRow,
cursorRow: cursorVisible ? cursorRow : -1,
);
if (lastVisibleRow < 0) {
return '';
}
return List<String>.generate(lastVisibleRow + 1, (row) {
final line = lines[row];
final trimmed = _trimRight(line);
if (!cursorVisible || row != cursorRow) {
return trimmed;
}
final targetWidth = cursorColumn.clamp(0, line.length);
if (targetWidth <= trimmed.length) {
return trimmed;
}
return line.substring(0, targetWidth);
}).join('\n');
}
String buildTerminalScreenReplay({
required List<String> lines,
required int cursorRow,
required int cursorColumn,
required bool cursorVisible,
}) {
final lastContentRow = _findLastContentRow(lines);
final lastVisibleRow = _clampLastVisibleRow(
lines.length,
lastContentRow: lastContentRow,
cursorRow: cursorVisible ? cursorRow : -1,
);
final replay = StringBuffer()..write('\u001b[2J\u001b[H');
for (var row = 0; row <= lastVisibleRow; row += 1) {
replay.write('\u001b[');
replay.write(row + 1);
replay.write(';1H\u001b[2K');
final text = _trimRight(lines[row]);
if (text.isNotEmpty) {
replay.write(text);
}
}
if (cursorVisible && lines.isNotEmpty) {
final targetRow = cursorRow.clamp(0, lines.length - 1);
final targetColumn = cursorColumn.clamp(0, lines[targetRow].length);
replay.write('\u001b[');
replay.write(targetRow + 1);
replay.write(';');
replay.write(targetColumn + 1);
replay.write('H');
}
return replay.toString();
}
int _clampLastVisibleRow(
int lineCount, {
required int lastContentRow,
required int cursorRow,
}) {
if (lineCount <= 0) {
return -1;
}
var lastVisibleRow = lastContentRow;
if (cursorRow >= 0) {
lastVisibleRow = lastVisibleRow > cursorRow ? lastVisibleRow : cursorRow;
}
if (lastVisibleRow < 0) {
return -1;
}
return lastVisibleRow >= lineCount ? lineCount - 1 : lastVisibleRow;
}
int _findLastContentRow(List<String> lines) {
for (var index = lines.length - 1; index >= 0; index -= 1) {
if (_trimRight(lines[index]).isNotEmpty) {
return index;
}
}
return -1;
}
String _trimRight(String value) {
var end = value.length;
while (end > 0 && value.codeUnitAt(end - 1) == 0x20) {
end -= 1;
}
return end == value.length ? value : value.substring(0, end);
}

View File

@ -1,6 +1,7 @@
import 'terminal_screen_patch.dart';
import 'terminal_screen_snapshot.dart';
/// Experiment-only state reducer for backend-authored screen snapshots and patches.
class TerminalScreenState {
const TerminalScreenState({
required this.sessionId,
@ -57,12 +58,16 @@ class TerminalScreenState {
}
bool canApplyPatch(TerminalScreenPatch patch) {
return sessionId == patch.sessionId && screenVersion == patch.baseScreenVersion;
return sessionId == patch.sessionId &&
screenVersion == patch.baseScreenVersion;
}
TerminalScreenState applyPatch(TerminalScreenPatch patch) {
final nextPrimaryLines = List<String>.from(primaryLines, growable: false);
final nextAlternateLines = List<String>.from(alternateLines, growable: false);
final nextAlternateLines = List<String>.from(
alternateLines,
growable: false,
);
final targetLines = patch.activeBuffer == 'alternate'
? nextAlternateLines
: nextPrimaryLines;
@ -96,6 +101,21 @@ class TerminalScreenState {
String toDisplayText() {
final lines = activeBuffer == 'alternate' ? alternateLines : primaryLines;
return lines.join('\n');
return renderTerminalScreenText(
lines: lines,
cursorRow: cursorRow,
cursorColumn: cursorColumn,
cursorVisible: cursorVisible,
);
}
String toReplaySequence() {
final lines = activeBuffer == 'alternate' ? alternateLines : primaryLines;
return buildTerminalScreenReplay(
lines: lines,
cursorRow: cursorRow,
cursorColumn: cursorColumn,
cursorVisible: cursorVisible,
);
}
}

View File

@ -9,8 +9,6 @@ import 'terminal_diagnostic_log.dart';
import 'terminal_interaction_controller.dart';
import 'terminal_output_payload.dart';
import 'terminal_restore_payload.dart';
import 'terminal_screen_patch.dart';
import 'terminal_screen_snapshot.dart';
import 'terminal_socket_session.dart';
typedef CancelReconnect = void Function();
@ -40,8 +38,6 @@ class TerminalSessionCoordinator extends ChangeNotifier {
required this.sessionFactory,
required this.onFrame,
required this.onRestore,
this.onScreenSnapshot,
this.onScreenPatch,
required this.viewportProvider,
Uri? baseUri,
this.onHistoryLoaded,
@ -68,8 +64,6 @@ class TerminalSessionCoordinator extends ChangeNotifier {
final TerminalSessionFactory sessionFactory;
final void Function(String frame) onFrame;
final void Function(TerminalRestorePayload restore) onRestore;
final void Function(TerminalScreenSnapshot snapshot)? onScreenSnapshot;
final void Function(TerminalScreenPatch patch)? onScreenPatch;
final TerminalViewport Function() viewportProvider;
final Uri baseUri;
final void Function(HistoryWindow history)? onHistoryLoaded;
@ -93,9 +87,6 @@ class TerminalSessionCoordinator extends ChangeNotifier {
int? _lastSentColumns;
int? _lastSentRows;
int? _lastReceivedSequence;
int? _screenVersion;
bool _screenProtocolActive = false;
bool _screenSyncPending = false;
bool get isLoadingOlderHistory => _isLoadingOlderHistory;
@ -111,9 +102,6 @@ class TerminalSessionCoordinator extends ChangeNotifier {
}
final sessionGeneration = ++_sessionGeneration;
_screenProtocolActive = false;
_screenVersion = null;
_screenSyncPending = false;
if (isReconnect) {
controller.markReconnecting();
@ -156,20 +144,6 @@ class TerminalSessionCoordinator extends ChangeNotifier {
_handleRestore(restore);
},
onScreenSnapshot: (snapshot) {
if (!_isCurrentSession(socketSession, sessionGeneration)) {
return;
}
_handleScreenSnapshot(snapshot);
},
onScreenPatch: (patch) {
if (!_isCurrentSession(socketSession, sessionGeneration)) {
return;
}
_handleScreenPatch(patch);
},
onDisconnected: () {
if (!_isCurrentSession(socketSession, sessionGeneration)) {
return;
@ -315,7 +289,8 @@ class TerminalSessionCoordinator extends ChangeNotifier {
Future<void> _handleOutput(TerminalOutputPayload output) async {
if (output.sequence > 0) {
final lastReceivedSequence = _lastReceivedSequence;
if (lastReceivedSequence != null && output.sequence <= lastReceivedSequence) {
if (lastReceivedSequence != null &&
output.sequence <= lastReceivedSequence) {
diagnosticLog?.add(
'socket.output.stale',
'seq=${output.sequence} last=$lastReceivedSequence',
@ -340,14 +315,6 @@ class TerminalSessionCoordinator extends ChangeNotifier {
}
void _applyOutput(TerminalOutputPayload output) {
if (_screenProtocolActive) {
diagnosticLog?.add('socket.output.compat.skip', 'seq=${output.sequence}');
if (output.sequence > 0) {
_lastReceivedSequence = output.sequence;
}
return;
}
diagnosticLog?.add('socket.frame.rx', output.chunk);
controller.registerIncomingFrame();
controller.applyFrame(output.chunk);
@ -366,53 +333,14 @@ class TerminalSessionCoordinator extends ChangeNotifier {
onRestore(restore);
}
void _handleScreenSnapshot(TerminalScreenSnapshot snapshot) {
diagnosticLog?.add(
'socket.screen_snapshot.rx',
'screenVersion=${snapshot.screenVersion} sourceSeq=${snapshot.sourceSequence}',
);
_screenProtocolActive = true;
_screenVersion = snapshot.screenVersion;
_screenSyncPending = false;
if (snapshot.sourceSequence > 0) {
_lastReceivedSequence = snapshot.sourceSequence;
}
onScreenSnapshot?.call(snapshot);
}
void _handleScreenPatch(TerminalScreenPatch patch) {
diagnosticLog?.add(
'socket.screen_patch.rx',
'base=${patch.baseScreenVersion} next=${patch.screenVersion} sourceSeq=${patch.sourceSequence}',
);
if (_screenVersion != null && patch.baseScreenVersion != _screenVersion) {
_screenProtocolActive = false;
diagnosticLog?.add(
'socket.screen_patch.skip',
'expected=$_screenVersion actual=${patch.baseScreenVersion}',
);
if (!_screenSyncPending) {
_screenSyncPending = true;
_socketSession?.requestScreenSync();
diagnosticLog?.add('socket.screen_sync.request', session.sessionId);
}
return;
}
_screenProtocolActive = true;
_screenVersion = patch.screenVersion;
if (patch.sourceSequence > 0) {
_lastReceivedSequence = patch.sourceSequence;
}
onScreenPatch?.call(patch);
}
Future<void> _loadHistory({bool loadOlder = false}) async {
try {
final payload = await apiClient.getSessionJournal(
session.sessionId,
limit: historyPageSize,
beforeSequence: loadOlder ? controller.historyWindow.oldestSequence : null,
beforeSequence: loadOlder
? controller.historyWindow.oldestSequence
: null,
);
final history = _buildHistoryWindow(
payload,
@ -643,7 +571,8 @@ class TerminalSessionCoordinator extends ChangeNotifier {
? existing?.oldestSequence
: items.first.sequence;
final newestSequence =
existing?.newestSequence ?? (items.isEmpty ? null : items.last.sequence);
existing?.newestSequence ??
(items.isEmpty ? null : items.last.sequence);
return HistoryWindow(
lines: mergedLines,
@ -657,9 +586,8 @@ class TerminalSessionCoordinator extends ChangeNotifier {
final rawItems = (payload['items'] as List?) ?? const <dynamic>[];
return rawItems
.map(
(item) => _JournalItem.fromJson(
Map<String, dynamic>.from(item as Map),
),
(item) =>
_JournalItem.fromJson(Map<String, dynamic>.from(item as Map)),
)
.toList(growable: false);
}

View File

@ -9,8 +9,6 @@ import '../../core/network/agent_socket_client.dart';
import '../sessions/session.dart';
import 'terminal_output_payload.dart';
import 'terminal_restore_payload.dart';
import 'terminal_screen_patch.dart';
import 'terminal_screen_snapshot.dart';
typedef TerminalSocketTransportFactory =
TerminalSocketTransport Function(Uri uri);
@ -61,8 +59,6 @@ class TerminalSocketSession {
Future<void> connect({
required void Function(TerminalOutputPayload output) onOutput,
required void Function(TerminalRestorePayload restore) onRestore,
void Function(TerminalScreenSnapshot snapshot)? onScreenSnapshot,
void Function(TerminalScreenPatch patch)? onScreenPatch,
void Function()? onDisconnected,
}) async {
if (_transport != null || _subscription != null) {
@ -93,14 +89,6 @@ class TerminalSocketSession {
return;
}
if (_handleScreenSnapshotFrame(message, onScreenSnapshot)) {
return;
}
if (_handleScreenPatchFrame(message, onScreenPatch)) {
return;
}
final output = _decodeOutputFrame(message);
if (output != null) {
onOutput(output);
@ -170,20 +158,9 @@ class TerminalSocketSession {
}
try {
transport.send(jsonEncode(socketClient.buildResizeMessage(columns, rows)));
} catch (_) {
_handleTransportClosed(transport);
}
}
void requestScreenSync() {
final transport = _transport;
if (transport == null) {
return;
}
try {
transport.send(jsonEncode(socketClient.buildScreenSyncMessage()));
transport.send(
jsonEncode(socketClient.buildResizeMessage(columns, rows)),
);
} catch (_) {
_handleTransportClosed(transport);
}
@ -227,53 +204,13 @@ class TerminalSocketSession {
return false;
}
bool _handleScreenSnapshotFrame(
String frame,
void Function(TerminalScreenSnapshot snapshot)? onScreenSnapshot,
) {
if (onScreenSnapshot == null) {
return false;
}
try {
final decoded = jsonDecode(frame);
if (decoded is Map && decoded['type'] == 'screen_snapshot') {
onScreenSnapshot(
TerminalScreenSnapshot.fromJson(Map<String, dynamic>.from(decoded)),
);
return true;
}
} catch (_) {}
return false;
}
bool _handleScreenPatchFrame(
String frame,
void Function(TerminalScreenPatch patch)? onScreenPatch,
) {
if (onScreenPatch == null) {
return false;
}
try {
final decoded = jsonDecode(frame);
if (decoded is Map && decoded['type'] == 'screen_patch') {
onScreenPatch(
TerminalScreenPatch.fromJson(Map<String, dynamic>.from(decoded)),
);
return true;
}
} catch (_) {}
return false;
}
TerminalOutputPayload? _decodeOutputFrame(String frame) {
try {
final decoded = jsonDecode(frame);
if (decoded is Map && decoded['type'] == 'output') {
return TerminalOutputPayload.fromJson(Map<String, dynamic>.from(decoded));
return TerminalOutputPayload.fromJson(
Map<String, dynamic>.from(decoded),
);
}
if (decoded is Map && decoded['type'] != null) {

View File

@ -14,35 +14,18 @@ void main() {
test('builds attach message for terminal sessions', () {
final client = AgentSocketClient(Uri.parse('https://host:9443'));
expect(
client.buildAttachMessage('session-123'),
<String, dynamic>{
'type': 'attach',
'sessionId': 'session-123',
},
);
expect(client.buildAttachMessage('session-123'), <String, dynamic>{
'type': 'attach',
'sessionId': 'session-123',
});
});
test('builds input message for terminal input', () {
final client = AgentSocketClient(Uri.parse('https://host:9443'));
expect(
client.buildInputMessage('ls'),
<String, dynamic>{
'type': 'input',
'input': 'ls',
},
);
});
test('builds screen sync message for terminal resync', () {
final client = AgentSocketClient(Uri.parse('https://host:9443'));
expect(
client.buildScreenSyncMessage(),
<String, dynamic>{
'type': 'screen_sync',
},
);
expect(client.buildInputMessage('ls'), <String, dynamic>{
'type': 'input',
'input': 'ls',
});
});
}

View File

@ -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);
},
);
}

View File

@ -7,8 +7,6 @@ import 'package:term_remote_ctl/features/sessions/session.dart';
import 'package:term_remote_ctl/features/terminal/terminal_interaction_controller.dart';
import 'package:term_remote_ctl/features/terminal/terminal_output_payload.dart';
import 'package:term_remote_ctl/features/terminal/terminal_restore_payload.dart';
import 'package:term_remote_ctl/features/terminal/terminal_screen_patch.dart';
import 'package:term_remote_ctl/features/terminal/terminal_screen_snapshot.dart';
import 'package:term_remote_ctl/features/terminal/terminal_session_coordinator.dart';
import 'package:term_remote_ctl/features/terminal/terminal_socket_session.dart';
@ -199,10 +197,12 @@ void main() {
await coordinator.loadOlderHistory();
expect(apiClient.requestedJournalBeforeSequences, [null, 201]);
expect(
controller.historyWindow.lines,
['[output] zero', '[attach]', '[output] one', '[output] two'],
);
expect(controller.historyWindow.lines, [
'[output] zero',
'[attach]',
'[output] one',
'[output] two',
]);
expect(controller.historyWindow.hasMoreAbove, isFalse);
},
);
@ -584,394 +584,6 @@ void main() {
expect(restores, hasLength(1));
expect(restores.single.pendingInput, 't status');
});
test('screen snapshots are forwarded before legacy restore fallback', () async {
final controller = TerminalInteractionController();
final apiClient = _FakeAgentApiClient();
final sessionFactory = _FakeTerminalSessionFactory();
final session = Session(
sessionId: 'abc',
name: 'codex-main',
status: 'idle',
);
final snapshots = <TerminalScreenSnapshot>[];
final restores = <TerminalRestorePayload>[];
final coordinator = TerminalSessionCoordinator(
controller: controller,
apiClient: apiClient,
session: session,
sessionFactory: sessionFactory.create,
onFrame: (_) {},
onRestore: restores.add,
onScreenSnapshot: snapshots.add,
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
);
await coordinator.start();
sessionFactory.createdSessions.single.emitScreenSnapshot(
TerminalScreenSnapshot.fromJson(const <String, dynamic>{
'sessionId': 'abc',
'screenVersion': 4,
'sourceSequence': 3,
'rows': 24,
'columns': 80,
'cursorRow': 0,
'cursorColumn': 7,
'cursorVisible': true,
'activeBuffer': 'primary',
'primaryBuffer': <String, dynamic>{
'viewport': <Map<String, dynamic>>[
<String, dynamic>{'index': 0, 'text': 'PS> git'},
],
},
}),
);
sessionFactory.createdSessions.single.emitRestore(
const TerminalRestorePayload(
sessionId: 'abc',
sequence: 4,
screenText: 'PS> gi',
pendingInput: 't status',
),
);
expect(snapshots, hasLength(1));
expect(snapshots.single.toDisplayText(), 'PS> git');
expect(restores, hasLength(1));
});
test('screen patch is applied when base screen version is continuous', () async {
final controller = TerminalInteractionController();
final apiClient = _FakeAgentApiClient();
final sessionFactory = _FakeTerminalSessionFactory();
final session = Session(
sessionId: 'abc',
name: 'codex-main',
status: 'idle',
);
final patches = <TerminalScreenPatch>[];
final coordinator = TerminalSessionCoordinator(
controller: controller,
apiClient: apiClient,
session: session,
sessionFactory: sessionFactory.create,
onFrame: (_) {},
onRestore: (_) {},
onScreenPatch: patches.add,
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
);
await coordinator.start();
sessionFactory.createdSessions.single.emitScreenSnapshot(
TerminalScreenSnapshot.fromJson(const <String, dynamic>{
'sessionId': 'abc',
'screenVersion': 4,
'sourceSequence': 7,
'rows': 24,
'columns': 80,
'cursorRow': 0,
'cursorColumn': 7,
'cursorVisible': true,
'activeBuffer': 'primary',
'primaryBuffer': <String, dynamic>{
'viewport': <Map<String, dynamic>>[
<String, dynamic>{'index': 0, 'text': 'PS> git'},
],
},
}),
);
sessionFactory.createdSessions.single.emitScreenPatch(
TerminalScreenPatch.fromJson(const <String, dynamic>{
'sessionId': 'abc',
'baseScreenVersion': 4,
'screenVersion': 5,
'sourceSequence': 8,
'rows': 24,
'columns': 80,
'cursorRow': 0,
'cursorColumn': 14,
'cursorVisible': true,
'operations': <Map<String, dynamic>>[
<String, dynamic>{
'type': 'replace_lines',
'startRow': 0,
'lines': <String>['PS> git status'],
},
],
}),
);
expect(patches, hasLength(1));
expect(patches.single.baseScreenVersion, 4);
expect(patches.single.operations.single.lines, ['PS> git status']);
});
test('screen patch is ignored when base screen version does not match', () async {
final controller = TerminalInteractionController();
final apiClient = _FakeAgentApiClient();
final sessionFactory = _FakeTerminalSessionFactory();
final session = Session(
sessionId: 'abc',
name: 'codex-main',
status: 'idle',
);
final patches = <TerminalScreenPatch>[];
final coordinator = TerminalSessionCoordinator(
controller: controller,
apiClient: apiClient,
session: session,
sessionFactory: sessionFactory.create,
onFrame: (_) {},
onRestore: (_) {},
onScreenPatch: patches.add,
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
);
await coordinator.start();
sessionFactory.createdSessions.single.emitScreenSnapshot(
TerminalScreenSnapshot.fromJson(const <String, dynamic>{
'sessionId': 'abc',
'screenVersion': 4,
'sourceSequence': 7,
'rows': 24,
'columns': 80,
'cursorRow': 0,
'cursorColumn': 7,
'cursorVisible': true,
'activeBuffer': 'primary',
'primaryBuffer': <String, dynamic>{
'viewport': <Map<String, dynamic>>[
<String, dynamic>{'index': 0, 'text': 'PS> git'},
],
},
}),
);
sessionFactory.createdSessions.single.emitScreenPatch(
TerminalScreenPatch.fromJson(const <String, dynamic>{
'sessionId': 'abc',
'baseScreenVersion': 3,
'screenVersion': 5,
'sourceSequence': 8,
'rows': 24,
'columns': 80,
'cursorRow': 0,
'cursorColumn': 14,
'cursorVisible': true,
'operations': <Map<String, dynamic>>[
<String, dynamic>{
'type': 'replace_lines',
'startRow': 0,
'lines': <String>['PS> git status'],
},
],
}),
);
expect(patches, isEmpty);
});
test('screen patch mismatch requests a fresh screen snapshot resync', () async {
final controller = TerminalInteractionController();
final apiClient = _FakeAgentApiClient();
final sessionFactory = _FakeTerminalSessionFactory();
final session = Session(
sessionId: 'abc',
name: 'codex-main',
status: 'idle',
);
final patches = <TerminalScreenPatch>[];
final snapshots = <TerminalScreenSnapshot>[];
final coordinator = TerminalSessionCoordinator(
controller: controller,
apiClient: apiClient,
session: session,
sessionFactory: sessionFactory.create,
onFrame: (_) {},
onRestore: (_) {},
onScreenPatch: patches.add,
onScreenSnapshot: snapshots.add,
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
);
await coordinator.start();
sessionFactory.createdSessions.single.emitScreenSnapshot(
TerminalScreenSnapshot.fromJson(const <String, dynamic>{
'sessionId': 'abc',
'screenVersion': 4,
'sourceSequence': 7,
'rows': 24,
'columns': 80,
'cursorRow': 0,
'cursorColumn': 7,
'cursorVisible': true,
'activeBuffer': 'primary',
'primaryBuffer': <String, dynamic>{
'viewport': <Map<String, dynamic>>[
<String, dynamic>{'index': 0, 'text': 'PS> git'},
],
},
}),
);
sessionFactory.createdSessions.single.emitScreenPatch(
TerminalScreenPatch.fromJson(const <String, dynamic>{
'sessionId': 'abc',
'baseScreenVersion': 1,
'screenVersion': 5,
'sourceSequence': 8,
'rows': 24,
'columns': 80,
'cursorRow': 0,
'cursorColumn': 14,
'cursorVisible': true,
'operations': <Map<String, dynamic>>[
<String, dynamic>{
'type': 'replace_lines',
'startRow': 0,
'lines': <String>['PS> git status'],
},
],
}),
);
expect(patches, isEmpty);
expect(sessionFactory.createdSessions.single.screenSyncRequestCount, 1);
sessionFactory.createdSessions.single.emitScreenSnapshot(
TerminalScreenSnapshot.fromJson(const <String, dynamic>{
'sessionId': 'abc',
'screenVersion': 5,
'sourceSequence': 8,
'rows': 24,
'columns': 80,
'cursorRow': 0,
'cursorColumn': 14,
'cursorVisible': true,
'activeBuffer': 'primary',
'primaryBuffer': <String, dynamic>{
'viewport': <Map<String, dynamic>>[
<String, dynamic>{'index': 0, 'text': 'PS> git status'},
],
},
}),
);
expect(snapshots, hasLength(2));
expect(snapshots.last.screenVersion, 5);
});
test('repeated screen patch mismatches only request one resync until a snapshot arrives', () async {
final controller = TerminalInteractionController();
final apiClient = _FakeAgentApiClient();
final sessionFactory = _FakeTerminalSessionFactory();
final session = Session(
sessionId: 'abc',
name: 'codex-main',
status: 'idle',
);
final coordinator = TerminalSessionCoordinator(
controller: controller,
apiClient: apiClient,
session: session,
sessionFactory: sessionFactory.create,
onFrame: (_) {},
onRestore: (_) {},
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
);
await coordinator.start();
sessionFactory.createdSessions.single.emitScreenSnapshot(
TerminalScreenSnapshot.fromJson(const <String, dynamic>{
'sessionId': 'abc',
'screenVersion': 4,
'sourceSequence': 7,
'rows': 24,
'columns': 80,
'cursorRow': 0,
'cursorColumn': 7,
'cursorVisible': true,
'activeBuffer': 'primary',
'primaryBuffer': <String, dynamic>{
'viewport': <Map<String, dynamic>>[
<String, dynamic>{'index': 0, 'text': 'PS> git'},
],
},
}),
);
final mismatchPatch = TerminalScreenPatch.fromJson(const <String, dynamic>{
'sessionId': 'abc',
'baseScreenVersion': 1,
'screenVersion': 5,
'sourceSequence': 8,
'rows': 24,
'columns': 80,
'cursorRow': 0,
'cursorColumn': 14,
'cursorVisible': true,
'operations': <Map<String, dynamic>>[
<String, dynamic>{
'type': 'replace_lines',
'startRow': 0,
'lines': <String>['PS> git status'],
},
],
});
sessionFactory.createdSessions.single.emitScreenPatch(mismatchPatch);
sessionFactory.createdSessions.single.emitScreenPatch(mismatchPatch);
expect(sessionFactory.createdSessions.single.screenSyncRequestCount, 1);
sessionFactory.createdSessions.single.emitScreenSnapshot(
TerminalScreenSnapshot.fromJson(const <String, dynamic>{
'sessionId': 'abc',
'screenVersion': 5,
'sourceSequence': 8,
'rows': 24,
'columns': 80,
'cursorRow': 0,
'cursorColumn': 14,
'cursorVisible': true,
'activeBuffer': 'primary',
'primaryBuffer': <String, dynamic>{
'viewport': <Map<String, dynamic>>[
<String, dynamic>{'index': 0, 'text': 'PS> git status'},
],
},
}),
);
sessionFactory.createdSessions.single.emitScreenPatch(
TerminalScreenPatch.fromJson(const <String, dynamic>{
'sessionId': 'abc',
'baseScreenVersion': 3,
'screenVersion': 6,
'sourceSequence': 9,
'rows': 24,
'columns': 80,
'cursorRow': 0,
'cursorColumn': 18,
'cursorVisible': true,
'operations': <Map<String, dynamic>>[
<String, dynamic>{
'type': 'replace_lines',
'startRow': 0,
'lines': <String>['still mismatched'],
},
],
}),
);
expect(sessionFactory.createdSessions.single.screenSyncRequestCount, 2);
});
}
class _FakeAgentApiClient extends AgentApiClient {
@ -1052,7 +664,6 @@ class _FakeTerminalSocketSession extends TerminalSocketSession {
final bool autoConnect;
final resizeCalls = <List<int>>[];
final sentInputs = <String>[];
int screenSyncRequestCount = 0;
int disposeCount = 0;
Completer<void>? disposeCompleter;
Completer<void> _connectCompleter = Completer<void>();
@ -1061,21 +672,15 @@ class _FakeTerminalSocketSession extends TerminalSocketSession {
void Function()? _onDisconnected;
bool _isDisconnected = false;
void Function(TerminalRestorePayload restore)? _onRestore;
void Function(TerminalScreenSnapshot snapshot)? _onScreenSnapshot;
void Function(TerminalScreenPatch patch)? _onScreenPatch;
@override
Future<void> connect({
required void Function(TerminalOutputPayload output) onOutput,
required void Function(TerminalRestorePayload restore) onRestore,
void Function(TerminalScreenSnapshot snapshot)? onScreenSnapshot,
void Function(TerminalScreenPatch patch)? onScreenPatch,
void Function()? onDisconnected,
}) {
_onOutput = onOutput;
_onRestore = onRestore;
_onScreenSnapshot = onScreenSnapshot;
_onScreenPatch = onScreenPatch;
_onDisconnected = onDisconnected;
if (autoConnect && !_connectCompleter.isCompleted) {
_connectCompleter.complete();
@ -1112,19 +717,6 @@ class _FakeTerminalSocketSession extends TerminalSocketSession {
_onRestore?.call(restore);
}
void emitScreenSnapshot(TerminalScreenSnapshot snapshot) {
_onScreenSnapshot?.call(snapshot);
}
void emitScreenPatch(TerminalScreenPatch patch) {
_onScreenPatch?.call(patch);
}
@override
void requestScreenSync() {
screenSyncRequestCount += 1;
}
void disconnect() {
_isDisconnected = true;
_onDisconnected?.call();

View File

@ -4,8 +4,6 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:term_remote_ctl/core/network/agent_socket_client.dart';
import 'package:term_remote_ctl/features/terminal/terminal_output_payload.dart';
import 'package:term_remote_ctl/features/terminal/terminal_restore_payload.dart';
import 'package:term_remote_ctl/features/terminal/terminal_screen_patch.dart';
import 'package:term_remote_ctl/features/terminal/terminal_screen_snapshot.dart';
import 'package:term_remote_ctl/features/terminal/terminal_socket_session.dart';
void main() {
@ -19,12 +17,11 @@ void main() {
final outputs = <TerminalOutputPayload>[];
var completed = false;
final connectFuture = session.connect(
onOutput: outputs.add,
onRestore: (_) {},
).then((_) {
completed = true;
});
final connectFuture = session
.connect(onOutput: outputs.add, onRestore: (_) {})
.then((_) {
completed = true;
});
await Future<void>.delayed(Duration.zero);
expect(
@ -50,19 +47,25 @@ void main() {
await session.dispose();
});
test('connect fails if the socket closes before attach acknowledgement', () async {
final transport = _FakeTerminalSocketTransport();
final session = TerminalSocketSession(
sessionId: 'session-123',
socketClient: AgentSocketClient(Uri.parse('https://host:9443')),
transportFactory: (_) => transport,
);
test(
'connect fails if the socket closes before attach acknowledgement',
() async {
final transport = _FakeTerminalSocketTransport();
final session = TerminalSocketSession(
sessionId: 'session-123',
socketClient: AgentSocketClient(Uri.parse('https://host:9443')),
transportFactory: (_) => transport,
);
final connectFuture = session.connect(onOutput: (_) {}, onRestore: (_) {});
await transport.close();
final connectFuture = session.connect(
onOutput: (_) {},
onRestore: (_) {},
);
await transport.close();
await expectLater(connectFuture, throwsStateError);
});
await expectLater(connectFuture, throwsStateError);
},
);
test('sendInput serializes the input message', () async {
final transport = _FakeTerminalSocketTransport();
@ -109,29 +112,6 @@ void main() {
await session.dispose();
});
test('requestScreenSync serializes the screen sync message', () async {
final transport = _FakeTerminalSocketTransport();
final session = TerminalSocketSession(
sessionId: 'session-123',
socketClient: AgentSocketClient(Uri.parse('https://host:9443')),
transportFactory: (_) => transport,
);
final connectFuture = session.connect(onOutput: (_) {}, onRestore: (_) {});
await Future<void>.delayed(Duration.zero);
transport.emit('{"type":"attached","sessionId":"session-123"}');
await connectFuture;
session.requestScreenSync();
expect(
transport.sentMessages,
contains('{"type":"screen_sync"}'),
);
await session.dispose();
});
test('connect notifies when an attached socket closes', () async {
final transport = _FakeTerminalSocketTransport();
final session = TerminalSocketSession(
@ -189,11 +169,9 @@ void main() {
);
final outputs = <TerminalOutputPayload>[];
final snapshots = <TerminalScreenSnapshot>[];
final restores = <TerminalRestorePayload>[];
final connectFuture = session.connect(
onOutput: outputs.add,
onScreenSnapshot: snapshots.add,
onRestore: restores.add,
);
await Future<void>.delayed(Duration.zero);
@ -201,9 +179,6 @@ void main() {
transport.emit('{"type":"attached","sessionId":"session-123"}');
await connectFuture;
transport.emit(
'{"type":"screen_snapshot","sessionId":"session-123","screenVersion":4,"sourceSequence":3,"rows":24,"columns":80,"cursorRow":0,"cursorColumn":7,"cursorVisible":true,"activeBuffer":"primary","primaryBuffer":{"viewport":[{"index":0,"text":"PS> git"}]}}',
);
transport.emit(
'{"type":"restore","sessionId":"session-123","sequence":4,"screenText":"PS> gi","pendingInput":"t status"}',
);
@ -212,10 +187,6 @@ void main() {
);
await Future<void>.delayed(Duration.zero);
expect(snapshots, hasLength(1));
expect(snapshots.single.sessionId, 'session-123');
expect(snapshots.single.screenVersion, 4);
expect(snapshots.single.toDisplayText(), 'PS> git');
expect(restores, hasLength(1));
expect(restores.single.sessionId, 'session-123');
expect(restores.single.sequence, 4);
@ -249,44 +220,6 @@ void main() {
expect(outputs, isEmpty);
});
test('connect routes screen patch frames separately from output frames', () async {
final transport = _FakeTerminalSocketTransport();
final session = TerminalSocketSession(
sessionId: 'session-123',
socketClient: AgentSocketClient(Uri.parse('https://host:9443')),
transportFactory: (_) => transport,
);
final outputs = <TerminalOutputPayload>[];
final patches = <TerminalScreenPatch>[];
final connectFuture = session.connect(
onOutput: outputs.add,
onRestore: (_) {},
onScreenPatch: patches.add,
);
await Future<void>.delayed(Duration.zero);
transport.emit('{"type":"attached","sessionId":"session-123"}');
await connectFuture;
transport.emit(
'{"type":"screen_patch","sessionId":"session-123","baseScreenVersion":4,"screenVersion":5,"sourceSequence":8,"rows":24,"columns":80,"cursorRow":0,"cursorColumn":8,"cursorVisible":true,"operations":[{"type":"replace_lines","startRow":0,"lines":["PS> git "]},{"type":"replace_lines","startRow":1,"lines":["status"]}]}',
);
transport.emit(
'{"type":"output","sessionId":"session-123","sequence":8,"chunk":"status"}',
);
await Future<void>.delayed(Duration.zero);
expect(patches, hasLength(1));
expect(patches.single.baseScreenVersion, 4);
expect(patches.single.screenVersion, 5);
expect(patches.single.operations, hasLength(2));
expect(patches.single.operations.first.startRow, 0);
expect(patches.single.operations.first.lines, ['PS> git ']);
expect(outputs, hasLength(1));
expect(outputs.single.chunk, 'status');
});
}
class _FakeTerminalSocketTransport implements TerminalSocketTransport {

View File

@ -9,8 +9,6 @@ import 'package:term_remote_ctl/features/terminal/terminal_diagnostic_log.dart';
import 'package:term_remote_ctl/features/terminal/terminal_interaction_controller.dart';
import 'package:term_remote_ctl/features/terminal/terminal_output_payload.dart';
import 'package:term_remote_ctl/features/terminal/terminal_restore_payload.dart';
import 'package:term_remote_ctl/features/terminal/terminal_screen_patch.dart';
import 'package:term_remote_ctl/features/terminal/terminal_screen_snapshot.dart';
import 'package:term_remote_ctl/features/terminal/terminal_session_coordinator.dart';
import 'package:term_remote_ctl/features/terminal/terminal_socket_session.dart';
@ -120,8 +118,6 @@ class _RecordingTerminalSocketSession extends TerminalSocketSession {
Future<void> connect({
required void Function(TerminalOutputPayload output) onOutput,
required void Function(TerminalRestorePayload restore) onRestore,
void Function(TerminalScreenSnapshot snapshot)? onScreenSnapshot,
void Function(TerminalScreenPatch patch)? onScreenPatch,
void Function()? onDisconnected,
}) async {}

View File

@ -319,144 +319,38 @@ void main() {
expect(terminal.buffer.getText(), contains('PS> git status'));
});
testWidgets('terminal applies backend screen snapshot as current screen', (
tester,
) async {
final transportFactory = _QueuedTerminalSocketTransportFactory(
connectionStartupFrames: const [
[
_StartupFrame('{"type":"attached","sessionId":"session-1"}'),
_StartupFrame(
'{"type":"screen_snapshot","sessionId":"session-1","screenVersion":4,"sourceSequence":3,"rows":24,"columns":80,"cursorRow":0,"cursorColumn":7,"cursorVisible":true,"activeBuffer":"primary","primaryBuffer":{"viewport":[{"index":0,"text":"PS> git"}]}}',
),
testWidgets(
'terminal ignores backend screen snapshots by default and keeps legacy restore flow',
(tester) async {
final transportFactory = _QueuedTerminalSocketTransportFactory(
connectionStartupFrames: const [
[
_StartupFrame('{"type":"attached","sessionId":"session-1"}'),
_StartupFrame(
'{"type":"screen_snapshot","sessionId":"session-1","screenVersion":4,"sourceSequence":3,"rows":24,"columns":80,"cursorRow":0,"cursorColumn":13,"cursorVisible":true,"activeBuffer":"primary","primaryBuffer":{"viewport":[{"index":0,"text":"snapshot-only"}]}}',
),
_StartupFrame(
'{"type":"restore","sessionId":"session-1","sequence":4,"screenText":"restore-path","pendingInput":""}',
),
],
],
],
);
);
await _pumpTerminalPage(
tester,
session: _session('session-1', 'codex-main'),
socketFactory: TerminalSocketSessionFactory(
transportFactory: transportFactory.create,
),
);
await _pumpTerminalPage(
tester,
session: _session('session-1', 'codex-main'),
socketFactory: TerminalSocketSessionFactory(
transportFactory: transportFactory.create,
),
);
final terminal = tester
.widget<TerminalView>(find.byType(TerminalView))
.terminal;
expect(terminal.buffer.getText(), contains('PS> git'));
});
testWidgets('terminal requests screen resync after patch mismatch and applies fresh snapshot', (
tester,
) async {
final transportFactory = _QueuedTerminalSocketTransportFactory(
connectionStartupFrames: [
[
const _StartupFrame('{"type":"attached","sessionId":"session-1"}'),
const _StartupFrame(
'{"type":"screen_snapshot","sessionId":"session-1","screenVersion":4,"sourceSequence":7,"rows":24,"columns":80,"cursorRow":0,"cursorColumn":7,"cursorVisible":true,"activeBuffer":"primary","primaryBuffer":{"viewport":[{"index":0,"text":"PS> git"}]}}',
),
const _StartupFrame(
'{"type":"screen_patch","sessionId":"session-1","baseScreenVersion":1,"screenVersion":5,"sourceSequence":8,"rows":24,"columns":80,"cursorRow":0,"cursorColumn":14,"cursorVisible":true,"operations":[{"type":"replace_lines","startRow":0,"lines":["stale patch"]}]}',
delay: Duration(milliseconds: 20),
),
],
],
onMessageSent: (transport, message) {
if (message == '{"type":"screen_sync"}') {
transport.emit(
'{"type":"screen_snapshot","sessionId":"session-1","screenVersion":5,"sourceSequence":8,"rows":24,"columns":80,"cursorRow":0,"cursorColumn":14,"cursorVisible":true,"activeBuffer":"primary","primaryBuffer":{"viewport":[{"index":0,"text":"PS> git status"}]}}',
);
}
},
);
await _pumpTerminalPage(
tester,
session: _session('session-1', 'codex-main'),
socketFactory: TerminalSocketSessionFactory(
transportFactory: transportFactory.create,
),
);
await tester.pump(const Duration(milliseconds: 40));
expect(
transportFactory.createdTransports.single.sentMessages,
contains('{"type":"screen_sync"}'),
);
final terminal = tester
.widget<TerminalView>(find.byType(TerminalView))
.terminal;
expect(terminal.buffer.getText(), contains('PS> git status'));
expect(terminal.buffer.getText(), isNot(contains('stale patch')));
});
testWidgets('terminal renders alternate buffer when backend marks it active', (
tester,
) async {
final transportFactory = _QueuedTerminalSocketTransportFactory(
connectionStartupFrames: const [
[
_StartupFrame('{"type":"attached","sessionId":"session-1"}'),
_StartupFrame(
'{"type":"screen_snapshot","sessionId":"session-1","screenVersion":4,"sourceSequence":3,"rows":24,"columns":80,"cursorRow":0,"cursorColumn":3,"cursorVisible":true,"activeBuffer":"alternate","primaryBuffer":{"viewport":[{"index":0,"text":"primary shell"}]},"alternateBuffer":{"viewport":[{"index":0,"text":"alt ui"}]}}',
),
],
],
);
await _pumpTerminalPage(
tester,
session: _session('session-1', 'codex-main'),
socketFactory: TerminalSocketSessionFactory(
transportFactory: transportFactory.create,
),
);
final terminal = tester
.widget<TerminalView>(find.byType(TerminalView))
.terminal;
expect(terminal.buffer.getText(), contains('alt ui'));
expect(terminal.buffer.getText(), isNot(contains('primary shell')));
});
testWidgets('terminal switches back to primary when screen patch changes active buffer', (
tester,
) async {
final transportFactory = _QueuedTerminalSocketTransportFactory(
connectionStartupFrames: const [
[
_StartupFrame('{"type":"attached","sessionId":"session-1"}'),
_StartupFrame(
'{"type":"screen_snapshot","sessionId":"session-1","screenVersion":4,"sourceSequence":3,"rows":24,"columns":80,"cursorRow":0,"cursorColumn":3,"cursorVisible":true,"activeBuffer":"alternate","primaryBuffer":{"viewport":[{"index":0,"text":"primary shell"}]},"alternateBuffer":{"viewport":[{"index":0,"text":"alt ui"}]}}',
),
_StartupFrame(
'{"type":"screen_patch","sessionId":"session-1","baseScreenVersion":4,"screenVersion":5,"sourceSequence":4,"rows":24,"columns":80,"cursorRow":0,"cursorColumn":13,"cursorVisible":true,"activeBuffer":"primary","operations":[{"type":"replace_lines","startRow":0,"lines":["primary shell"]}]}',
delay: Duration(milliseconds: 20),
),
],
],
);
await _pumpTerminalPage(
tester,
session: _session('session-1', 'codex-main'),
socketFactory: TerminalSocketSessionFactory(
transportFactory: transportFactory.create,
),
);
await tester.pump(const Duration(milliseconds: 40));
final terminal = tester
.widget<TerminalView>(find.byType(TerminalView))
.terminal;
expect(terminal.buffer.getText(), contains('primary shell'));
expect(terminal.buffer.getText(), isNot(contains('alt ui')));
});
final terminal = tester
.widget<TerminalView>(find.byType(TerminalView))
.terminal;
expect(terminal.buffer.getText(), contains('restore-path'));
expect(terminal.buffer.getText(), isNot(contains('snapshot-only')));
},
);
testWidgets(
'terminal page keeps the command deck above the bottom safe area',
@ -1051,12 +945,12 @@ void main() {
await _openProjectTerminal(tester);
expect(find.text('Scrollback | 2 lines'), findsNothing);
expect(find.text('Scrollback'), findsNothing);
await tester.tap(find.textContaining('Live |'));
await tester.tap(find.text('Live'));
await tester.pumpAndSettle();
expect(find.text('Scrollback | 2 lines'), findsOneWidget);
expect(find.text('Scrollback'), findsOneWidget);
expect(find.text('Load older lines'), findsOneWidget);
await tester.ensureVisible(find.text('Load older lines'));
@ -1202,7 +1096,7 @@ void main() {
expect(terminal.buffer.cursorX, 3);
expect(terminal.buffer.getText(), contains('one\ntwo'));
await tester.tap(find.textContaining('Live |'));
await tester.tap(find.text('Live'));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('Load older lines'));
await tester.tap(find.text('Load older lines'));

View File

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

View File

@ -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();

View File

@ -1,11 +1,8 @@
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Options;
using TermRemoteCtl.Agent.Sessions;
using TermRemoteCtl.Agent.Terminal;
using TermRemoteCtl.Agent.Configuration;
using TermRemoteCtl.Agent.Terminal.Screen;
namespace TermRemoteCtl.Agent.Realtime;
@ -60,58 +57,23 @@ public static class TerminalWebSocketHandler
}
using var sendGate = new SemaphoreSlim(1, 1);
var screenStateGate = new object();
var lastSentScreenSnapshot = registry.GetScreenSnapshot(sessionId);
async Task SendScreenSnapshotAsync(CancellationToken cancellationToken)
{
TerminalScreenSnapshot snapshot;
lock (screenStateGate)
{
snapshot = registry.GetScreenSnapshot(sessionId);
lastSentScreenSnapshot = snapshot;
}
await SendJsonAsync(
socket,
new TerminalScreenSnapshotResponse(
snapshot.SessionId,
snapshot.ScreenVersion,
snapshot.SourceSequence,
snapshot.Rows,
snapshot.Columns,
snapshot.CursorRow,
snapshot.CursorColumn,
snapshot.CursorVisible,
snapshot.ActiveBuffer,
ToBufferResponse(snapshot.PrimaryBuffer),
snapshot.AlternateBuffer is null ? null : ToBufferResponse(snapshot.AlternateBuffer)),
sendGate,
cancellationToken).ConfigureAwait(false);
}
void HandleOutput(object? sender, TerminalOutputEventArgs args)
{
if (string.Equals(args.SessionId, sessionId, StringComparison.Ordinal))
{
TerminalScreenPatch? screenPatch = null;
lock (screenStateGate)
{
var currentSnapshot = registry.GetScreenSnapshot(sessionId);
screenPatch = TerminalScreenPatch.Create(lastSentScreenSnapshot, currentSnapshot);
lastSentScreenSnapshot = currentSnapshot;
}
_ = SendOutputAndPatchAsync(socket, args, screenPatch, sendGate, context.RequestAborted);
_ = SendJsonAsync(
socket,
new TerminalOutputResponse(args.SessionId, args.Sequence, args.Chunk),
sendGate,
context.RequestAborted);
}
}
try
{
await registry.RecordAttachAsync(sessionId, context.RequestAborted).ConfigureAwait(false);
var screenSnapshot = lastSentScreenSnapshot;
var restore = registry.GetRestoreSnapshot(sessionId);
await SendJsonAsync(socket, new TerminalAttachResponse(sessionId), sendGate, context.RequestAborted).ConfigureAwait(false);
await SendScreenSnapshotAsync(context.RequestAborted).ConfigureAwait(false);
await SendJsonAsync(
socket,
new TerminalRestoreResponse(
@ -119,12 +81,12 @@ public static class TerminalWebSocketHandler
restore.Sequence,
restore.ScreenText,
restore.PendingInput,
restore.CursorRow,
restore.CursorColumn),
restore.CursorRow,
restore.CursorColumn),
sendGate,
context.RequestAborted).ConfigureAwait(false);
host.OutputReceived += HandleOutput;
await ReceiveLoopAsync(context, socket, host, registry, diagnostics, sessionId, SendScreenSnapshotAsync).ConfigureAwait(false);
await ReceiveLoopAsync(context, socket, host, registry, diagnostics, sessionId).ConfigureAwait(false);
}
finally
{
@ -145,8 +107,7 @@ public static class TerminalWebSocketHandler
ISessionHost host,
SessionRegistry registry,
ITerminalDiagnosticsSink diagnostics,
string sessionId,
Func<CancellationToken, Task> sendScreenSnapshotAsync)
string sessionId)
{
var buffer = new byte[4096];
@ -178,8 +139,7 @@ public static class TerminalWebSocketHandler
host,
diagnostics,
sessionId,
context.RequestAborted,
sendScreenSnapshotAsync).ConfigureAwait(false);
context.RequestAborted).ConfigureAwait(false);
}
}
@ -189,8 +149,7 @@ public static class TerminalWebSocketHandler
ISessionHost host,
ITerminalDiagnosticsSink diagnostics,
string sessionId,
CancellationToken cancellationToken,
Func<CancellationToken, Task> sendScreenSnapshotAsync)
CancellationToken cancellationToken)
{
TerminalClientMessage? message;
@ -205,8 +164,7 @@ public static class TerminalWebSocketHandler
if (message is null ||
!string.Equals(message.Type, "input", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(message.Type, "resize", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(message.Type, "screen_sync", StringComparison.OrdinalIgnoreCase))
!string.Equals(message.Type, "resize", StringComparison.OrdinalIgnoreCase))
{
if (message is not null && string.Equals(message.Type, "attach", StringComparison.OrdinalIgnoreCase))
{
@ -228,12 +186,6 @@ public static class TerminalWebSocketHandler
return;
}
if (string.Equals(message.Type, "screen_sync", StringComparison.OrdinalIgnoreCase))
{
await sendScreenSnapshotAsync(cancellationToken).ConfigureAwait(false);
return;
}
if (message.Columns is > 0 && message.Rows is > 0)
{
await registry.RecordResizeAsync(
@ -283,76 +235,8 @@ public static class TerminalWebSocketHandler
}
}
private static async Task SendOutputAndPatchAsync(
WebSocket socket,
TerminalOutputEventArgs args,
TerminalScreenPatch? screenPatch,
SemaphoreSlim sendGate,
CancellationToken cancellationToken)
{
await SendJsonAsync(
socket,
new TerminalOutputResponse(args.SessionId, args.Sequence, args.Chunk),
sendGate,
cancellationToken).ConfigureAwait(false);
if (screenPatch is not null)
{
await SendJsonAsync(
socket,
ToScreenPatchResponse(screenPatch),
sendGate,
cancellationToken).ConfigureAwait(false);
}
}
private static TerminalScreenBufferResponse ToBufferResponse(TerminalScreenBufferSnapshot buffer)
{
return new TerminalScreenBufferResponse(
buffer.Viewport.Select(line => new TerminalScreenLineResponse(line.Index, line.Text)).ToArray());
}
private static TerminalScreenPatchResponse ToScreenPatchResponse(TerminalScreenPatch patch)
{
return new TerminalScreenPatchResponse(
patch.SessionId,
patch.BaseScreenVersion,
patch.ScreenVersion,
patch.SourceSequence,
patch.Rows,
patch.Columns,
patch.CursorRow,
patch.CursorColumn,
patch.CursorVisible,
patch.ActiveBuffer,
patch.Operations.Select(operation => new TerminalScreenPatchOperationResponse(
operation.Type,
operation.StartRow,
operation.Lines.ToArray())).ToArray());
}
private sealed record TerminalAttachResponse(string SessionId, string Type = "attached");
private sealed record TerminalScreenSnapshotResponse(
string SessionId,
long ScreenVersion,
long SourceSequence,
int Rows,
int Columns,
int CursorRow,
int CursorColumn,
bool CursorVisible,
string ActiveBuffer,
TerminalScreenBufferResponse PrimaryBuffer,
TerminalScreenBufferResponse? AlternateBuffer,
string Type = "screen_snapshot");
private sealed record TerminalScreenBufferResponse(
IReadOnlyList<TerminalScreenLineResponse> Viewport);
private sealed record TerminalScreenLineResponse(
int Index,
string Text);
private sealed record TerminalRestoreResponse(
string SessionId,
long Sequence,
@ -368,25 +252,6 @@ public static class TerminalWebSocketHandler
string Chunk,
string Type = "output");
private sealed record TerminalScreenPatchResponse(
string SessionId,
long BaseScreenVersion,
long ScreenVersion,
long SourceSequence,
int Rows,
int Columns,
int CursorRow,
int CursorColumn,
bool CursorVisible,
string ActiveBuffer,
IReadOnlyList<TerminalScreenPatchOperationResponse> Operations,
string Type = "screen_patch");
private sealed record TerminalScreenPatchOperationResponse(
string Type,
int StartRow,
IReadOnlyList<string> Lines);
private sealed record TerminalClientMessage(
string Type,
string? SessionId,

View File

@ -9,6 +9,8 @@ namespace TermRemoteCtl.Agent.Sessions;
public sealed class SessionRegistry
{
private const int ReplayCharacterLimit = 262_144;
private const string ScreenProtocolDisabledMessage =
"Backend screen protocol is disabled on the mainline product path.";
private readonly ConcurrentDictionary<string, SessionRecord> _records = new();
private readonly ConcurrentDictionary<string, TerminalRingBuffer> _historyBySession = new();
private readonly ConcurrentDictionary<string, TerminalReplayBuffer> _replayBySession = new();
@ -18,6 +20,7 @@ public sealed class SessionRegistry
private readonly SessionHistoryStore _historyStore;
private readonly SessionIoJournalStore _journalStore;
private readonly int _ringBufferLineLimit;
private readonly bool _enableBackendScreenProtocol;
public SessionRegistry(
SessionHistoryStore historyStore,
@ -27,6 +30,7 @@ public sealed class SessionRegistry
_historyStore = historyStore;
_journalStore = journalStore;
_ringBufferLineLimit = options.Value.RingBufferLineLimit;
_enableBackendScreenProtocol = options.Value.EnableBackendScreenProtocol;
}
public SessionRecord Create(
@ -50,7 +54,10 @@ public sealed class SessionRegistry
_historyBySession[record.SessionId] = new TerminalRingBuffer(_ringBufferLineLimit);
_replayBySession[record.SessionId] = new TerminalReplayBuffer(ReplayCharacterLimit);
_pendingInputEchoBySession[record.SessionId] = new PendingInputEchoTracker();
_screenBySession[record.SessionId] = new TerminalScreenEngine();
if (_enableBackendScreenProtocol)
{
_screenBySession[record.SessionId] = new TerminalScreenEngine();
}
_sequenceBySession[record.SessionId] = 0;
return record;
}
@ -123,7 +130,6 @@ public sealed class SessionRegistry
sessionId,
_ => new PendingInputEchoTracker());
pendingInputEcho.ObserveOutput(chunk);
var screen = _screenBySession.GetOrAdd(sessionId, _ => new TerminalScreenEngine());
var updatedAtUtc = DateTimeOffset.UtcNow;
var ioEvent = new SessionIoEvent(
sessionId,
@ -131,7 +137,11 @@ public sealed class SessionRegistry
"output",
chunk,
updatedAtUtc);
screen.ApplyOutput(chunk, ioEvent.Sequence);
if (_enableBackendScreenProtocol)
{
var screen = _screenBySession.GetOrAdd(sessionId, _ => new TerminalScreenEngine());
screen.ApplyOutput(chunk, ioEvent.Sequence);
}
_records[sessionId] = record with { UpdatedAtUtc = updatedAtUtc };
await _historyStore.AppendAsync(sessionId, chunk, cancellationToken).ConfigureAwait(false);
await _journalStore.AppendAsync(ioEvent, cancellationToken).ConfigureAwait(false);
@ -189,8 +199,11 @@ public sealed class SessionRegistry
"resize",
$"{columns}x{rows}",
updatedAtUtc);
var screen = _screenBySession.GetOrAdd(sessionId, _ => new TerminalScreenEngine());
screen.Resize(columns, rows);
if (_enableBackendScreenProtocol)
{
var screen = _screenBySession.GetOrAdd(sessionId, _ => new TerminalScreenEngine());
screen.Resize(columns, rows);
}
_records[sessionId] = record with { UpdatedAtUtc = updatedAtUtc };
await _journalStore.AppendAsync(ioEvent, cancellationToken).ConfigureAwait(false);
return ioEvent;
@ -303,6 +316,11 @@ public sealed class SessionRegistry
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
if (!_enableBackendScreenProtocol)
{
throw new InvalidOperationException(ScreenProtocolDisabledMessage);
}
if (!_records.ContainsKey(sessionId))
{
throw new KeyNotFoundException($"Session '{sessionId}' was not found.");

View File

@ -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)

View File

@ -5,6 +5,7 @@
"HttpsPort": 0,
"HttpPort": 5067,
"WebSocketFrameFlushMilliseconds": 33,
"RingBufferLineLimit": 4000
"RingBufferLineLimit": 4000,
"EnableBackendScreenProtocol": false
}
}

View File

@ -15,6 +15,7 @@ namespace TermRemoteCtl.Agent.IntegrationTests.Realtime;
public sealed class TerminalWebSocketHandlerTests
{
[Fact]
[Trait("Track", "Mainline")]
public async Task Attach_Streams_Output_And_Forwards_Input()
{
await using var fixture = new TerminalApiFixture();
@ -35,18 +36,6 @@ public sealed class TerminalWebSocketHandlerTests
Assert.Equal("attached", attachedPayload!.Type);
Assert.Equal(session.SessionId, attachedPayload.SessionId);
var screenSnapshotFrame = await ReceiveTextAsync(socket, CancellationToken.None);
var screenSnapshotPayload = JsonSerializer.Deserialize<TerminalScreenSnapshotResponse>(
screenSnapshotFrame,
new JsonSerializerOptions(JsonSerializerDefaults.Web));
Assert.NotNull(screenSnapshotPayload);
Assert.Equal("screen_snapshot", screenSnapshotPayload!.Type);
Assert.Equal(session.SessionId, screenSnapshotPayload.SessionId);
Assert.Equal(24, screenSnapshotPayload.Rows);
Assert.Equal(80, screenSnapshotPayload.Columns);
Assert.Equal("primary", screenSnapshotPayload.ActiveBuffer);
var restoreFrame = await ReceiveTextAsync(socket, CancellationToken.None);
var restorePayload = JsonSerializer.Deserialize<TerminalRestoreResponse>(
restoreFrame,
@ -69,21 +58,6 @@ public sealed class TerminalWebSocketHandlerTests
Assert.Equal(2L, firstOutputPayload.Sequence);
Assert.Equal("abc", firstOutputPayload.Chunk);
var firstPatchFrame = await ReceiveTextAsync(socket, CancellationToken.None);
var firstPatchPayload = JsonSerializer.Deserialize<TerminalScreenPatchResponse>(
firstPatchFrame,
new JsonSerializerOptions(JsonSerializerDefaults.Web));
Assert.NotNull(firstPatchPayload);
Assert.Equal("screen_patch", firstPatchPayload!.Type);
Assert.Equal(session.SessionId, firstPatchPayload.SessionId);
Assert.Equal(0L, firstPatchPayload.BaseScreenVersion);
Assert.Equal(1L, firstPatchPayload.ScreenVersion);
Assert.Equal(2L, firstPatchPayload.SourceSequence);
Assert.Single(firstPatchPayload.Operations);
Assert.Equal("replace_lines", firstPatchPayload.Operations[0].Type);
Assert.Equal(0, firstPatchPayload.Operations[0].StartRow);
Assert.Equal(["abc"], firstPatchPayload.Operations[0].Lines);
var secondOutputFrame = await ReceiveTextAsync(socket, CancellationToken.None);
var secondOutputPayload = JsonSerializer.Deserialize<TerminalOutputResponse>(
secondOutputFrame,
@ -93,18 +67,6 @@ public sealed class TerminalWebSocketHandlerTests
Assert.Equal(3L, secondOutputPayload.Sequence);
Assert.Equal("def", secondOutputPayload.Chunk);
var secondPatchFrame = await ReceiveTextAsync(socket, CancellationToken.None);
var secondPatchPayload = JsonSerializer.Deserialize<TerminalScreenPatchResponse>(
secondPatchFrame,
new JsonSerializerOptions(JsonSerializerDefaults.Web));
Assert.NotNull(secondPatchPayload);
Assert.Equal("screen_patch", secondPatchPayload!.Type);
Assert.Equal(1L, secondPatchPayload.BaseScreenVersion);
Assert.Equal(2L, secondPatchPayload.ScreenVersion);
Assert.Equal(3L, secondPatchPayload.SourceSequence);
Assert.Single(secondPatchPayload.Operations);
Assert.Equal(["abcdef"], secondPatchPayload.Operations[0].Lines);
var inputMessage = JsonSerializer.Serialize(new { type = "input", input = "dir" });
await socket.SendAsync(Encoding.UTF8.GetBytes(inputMessage), WebSocketMessageType.Text, true, CancellationToken.None);
@ -115,6 +77,7 @@ public sealed class TerminalWebSocketHandlerTests
}
[Fact]
[Trait("Track", "Mainline")]
public async Task Attach_Replays_Recent_Output_For_Existing_Session()
{
await using var fixture = new TerminalApiFixture();
@ -135,19 +98,6 @@ public sealed class TerminalWebSocketHandlerTests
Assert.NotNull(attachedPayload);
Assert.Equal("attached", attachedPayload!.Type);
var screenSnapshotFrame = await ReceiveTextAsync(socket, CancellationToken.None);
var screenSnapshotPayload = JsonSerializer.Deserialize<TerminalScreenSnapshotResponse>(
screenSnapshotFrame,
new JsonSerializerOptions(JsonSerializerDefaults.Web));
Assert.NotNull(screenSnapshotPayload);
Assert.Equal("screen_snapshot", screenSnapshotPayload!.Type);
Assert.StartsWith(
"prompt> dir",
screenSnapshotPayload.PrimaryBuffer.Viewport[0].Text,
StringComparison.Ordinal);
Assert.Equal(1L, screenSnapshotPayload.SourceSequence);
var restoreFrame = await ReceiveTextAsync(socket, CancellationToken.None);
var restorePayload = JsonSerializer.Deserialize<TerminalRestoreResponse>(
restoreFrame,
@ -161,6 +111,7 @@ public sealed class TerminalWebSocketHandlerTests
}
[Fact]
[Trait("Track", "Mainline")]
public async Task Attach_Does_Not_Duplicate_Output_Produced_During_Replay_Boundary()
{
await using var fixture = new TerminalApiFixture();
@ -182,18 +133,6 @@ public sealed class TerminalWebSocketHandlerTests
Assert.NotNull(attachedPayload);
Assert.Equal("attached", attachedPayload!.Type);
var screenSnapshotFrame = await ReceiveTextAsync(socket, CancellationToken.None);
var screenSnapshotPayload = JsonSerializer.Deserialize<TerminalScreenSnapshotResponse>(
screenSnapshotFrame,
new JsonSerializerOptions(JsonSerializerDefaults.Web));
Assert.NotNull(screenSnapshotPayload);
Assert.Equal("screen_snapshot", screenSnapshotPayload!.Type);
Assert.StartsWith(
"prompt> dir",
screenSnapshotPayload.PrimaryBuffer.Viewport[0].Text,
StringComparison.Ordinal);
var restoreFrame = await ReceiveTextAsync(socket, CancellationToken.None);
var restorePayload = JsonSerializer.Deserialize<TerminalRestoreResponse>(
restoreFrame,
@ -213,20 +152,11 @@ public sealed class TerminalWebSocketHandlerTests
Assert.Equal(3L, livePayload.Sequence);
Assert.Equal("next> ", livePayload.Chunk);
var patchFrame = await ReceiveTextAsync(socket, CancellationToken.None);
var patchPayload = JsonSerializer.Deserialize<TerminalScreenPatchResponse>(
patchFrame,
new JsonSerializerOptions(JsonSerializerDefaults.Web));
Assert.NotNull(patchPayload);
Assert.Equal("screen_patch", patchPayload!.Type);
Assert.Equal(1L, patchPayload.BaseScreenVersion);
Assert.Equal(2L, patchPayload.ScreenVersion);
Assert.Equal(3L, patchPayload.SourceSequence);
await AssertNoAdditionalTextFrameAsync(socket, TimeSpan.FromMilliseconds(200));
}
[Fact]
[Trait("Track", "Mainline")]
public async Task Reattach_Replays_Visible_User_Input_When_No_Output_Echo_Has_Arrived_Yet()
{
await using var fixture = new TerminalApiFixture();
@ -250,7 +180,6 @@ public sealed class TerminalWebSocketHandlerTests
new Uri($"ws://localhost/ws/terminal?sessionId={session.SessionId}"),
CancellationToken.None);
_ = await ReceiveTextAsync(replaySocket, CancellationToken.None);
_ = await ReceiveTextAsync(replaySocket, CancellationToken.None);
var restoreFrame = await ReceiveTextAsync(replaySocket, CancellationToken.None);
var restorePayload = JsonSerializer.Deserialize<TerminalRestoreResponse>(
@ -264,6 +193,7 @@ public sealed class TerminalWebSocketHandlerTests
}
[Fact]
[Trait("Track", "Mainline")]
public async Task Reattach_Returns_Restore_Payload_With_Pending_Input()
{
await using var fixture = new TerminalApiFixture();
@ -277,7 +207,6 @@ public sealed class TerminalWebSocketHandlerTests
new Uri($"ws://localhost/ws/terminal?sessionId={session.SessionId}"),
CancellationToken.None);
_ = await ReceiveTextAsync(socket, CancellationToken.None);
_ = await ReceiveTextAsync(socket, CancellationToken.None);
var restoreFrame = await ReceiveTextAsync(socket, CancellationToken.None);
@ -286,144 +215,6 @@ public sealed class TerminalWebSocketHandlerTests
Assert.Contains("\"sequence\":2", restoreFrame);
}
[Fact]
public async Task Live_Output_Also_Streams_Authoritative_Screen_Patch()
{
await using var fixture = new TerminalApiFixture();
var registry = fixture.Services.GetRequiredService<SessionRegistry>();
fixture.TerminalHost.Registry = registry;
var session = registry.Create("Shell", DateTimeOffset.UtcNow);
using WebSocket socket = await fixture.Server.CreateWebSocketClient().ConnectAsync(
new Uri($"ws://localhost/ws/terminal?sessionId={session.SessionId}"),
CancellationToken.None);
_ = await ReceiveTextAsync(socket, CancellationToken.None);
_ = await ReceiveTextAsync(socket, CancellationToken.None);
_ = await ReceiveTextAsync(socket, CancellationToken.None);
fixture.TerminalHost.EmitOutput(session.SessionId, "prompt> ");
var outputFrame = await ReceiveTextAsync(socket, CancellationToken.None);
var outputPayload = JsonSerializer.Deserialize<TerminalOutputResponse>(
outputFrame,
new JsonSerializerOptions(JsonSerializerDefaults.Web));
Assert.NotNull(outputPayload);
Assert.Equal("prompt> ", outputPayload!.Chunk);
var patchFrame = await ReceiveTextAsync(socket, CancellationToken.None);
var patchPayload = JsonSerializer.Deserialize<TerminalScreenPatchResponse>(
patchFrame,
new JsonSerializerOptions(JsonSerializerDefaults.Web));
Assert.NotNull(patchPayload);
Assert.Equal("screen_patch", patchPayload!.Type);
Assert.Equal(session.SessionId, patchPayload.SessionId);
Assert.Equal(0L, patchPayload.BaseScreenVersion);
Assert.Equal(1L, patchPayload.ScreenVersion);
Assert.Equal(2L, patchPayload.SourceSequence);
Assert.Single(patchPayload.Operations);
Assert.Equal("replace_lines", patchPayload.Operations[0].Type);
Assert.Equal(0, patchPayload.Operations[0].StartRow);
Assert.Equal(["prompt> "], patchPayload.Operations[0].Lines);
}
[Fact]
public async Task ScreenSync_Request_Returns_Fresh_Screen_Snapshot()
{
await using var fixture = new TerminalApiFixture();
var registry = fixture.Services.GetRequiredService<SessionRegistry>();
fixture.TerminalHost.Registry = registry;
var session = registry.Create("Shell", DateTimeOffset.UtcNow);
using WebSocket socket = await fixture.Server.CreateWebSocketClient().ConnectAsync(
new Uri($"ws://localhost/ws/terminal?sessionId={session.SessionId}"),
CancellationToken.None);
_ = await ReceiveTextAsync(socket, CancellationToken.None);
_ = await ReceiveTextAsync(socket, CancellationToken.None);
_ = await ReceiveTextAsync(socket, CancellationToken.None);
fixture.TerminalHost.EmitOutput(session.SessionId, "prompt> ");
_ = await ReceiveTextAsync(socket, CancellationToken.None);
_ = await ReceiveTextAsync(socket, CancellationToken.None);
var syncMessage = JsonSerializer.Serialize(new { type = "screen_sync" });
await socket.SendAsync(Encoding.UTF8.GetBytes(syncMessage), WebSocketMessageType.Text, true, CancellationToken.None);
var snapshotFrame = await ReceiveTextAsync(socket, CancellationToken.None);
var snapshotPayload = JsonSerializer.Deserialize<TerminalScreenSnapshotResponse>(
snapshotFrame,
new JsonSerializerOptions(JsonSerializerDefaults.Web));
Assert.NotNull(snapshotPayload);
Assert.Equal("screen_snapshot", snapshotPayload!.Type);
Assert.Equal(session.SessionId, snapshotPayload.SessionId);
Assert.Equal(1L, snapshotPayload.ScreenVersion);
Assert.Equal(2L, snapshotPayload.SourceSequence);
Assert.Equal("prompt> ", snapshotPayload.PrimaryBuffer.Viewport[0].Text.TrimEnd() + " ");
}
[Fact]
public async Task Attach_Returns_Alternate_Buffer_When_Alternate_Screen_Is_Active()
{
await using var fixture = new TerminalApiFixture();
var registry = fixture.Services.GetRequiredService<SessionRegistry>();
fixture.TerminalHost.Registry = registry;
var session = registry.Create("Shell", DateTimeOffset.UtcNow);
await registry.RecordOutputAsync(session.SessionId, "primary", CancellationToken.None);
await registry.RecordOutputAsync(session.SessionId, "\u001b[?1049halt", CancellationToken.None);
using WebSocket socket = await fixture.Server.CreateWebSocketClient().ConnectAsync(
new Uri($"ws://localhost/ws/terminal?sessionId={session.SessionId}"),
CancellationToken.None);
_ = await ReceiveTextAsync(socket, CancellationToken.None);
var screenSnapshotFrame = await ReceiveTextAsync(socket, CancellationToken.None);
var screenSnapshotPayload = JsonSerializer.Deserialize<TerminalScreenSnapshotResponse>(
screenSnapshotFrame,
new JsonSerializerOptions(JsonSerializerDefaults.Web));
Assert.NotNull(screenSnapshotPayload);
Assert.Equal("alternate", screenSnapshotPayload!.ActiveBuffer);
Assert.StartsWith("primary", screenSnapshotPayload.PrimaryBuffer.Viewport[0].Text, StringComparison.Ordinal);
Assert.NotNull(screenSnapshotPayload.AlternateBuffer);
Assert.StartsWith("alt", screenSnapshotPayload.AlternateBuffer!.Viewport[0].Text, StringComparison.Ordinal);
}
[Fact]
public async Task Live_Patch_Carries_Alternate_Buffer_Activation()
{
await using var fixture = new TerminalApiFixture();
var registry = fixture.Services.GetRequiredService<SessionRegistry>();
fixture.TerminalHost.Registry = registry;
var session = registry.Create("Shell", DateTimeOffset.UtcNow);
await registry.RecordOutputAsync(session.SessionId, "same", CancellationToken.None);
using WebSocket socket = await fixture.Server.CreateWebSocketClient().ConnectAsync(
new Uri($"ws://localhost/ws/terminal?sessionId={session.SessionId}"),
CancellationToken.None);
_ = await ReceiveTextAsync(socket, CancellationToken.None);
_ = await ReceiveTextAsync(socket, CancellationToken.None);
_ = await ReceiveTextAsync(socket, CancellationToken.None);
fixture.TerminalHost.EmitOutput(session.SessionId, "\u001b[?1049hsame");
_ = await ReceiveTextAsync(socket, CancellationToken.None);
var patchFrame = await ReceiveTextAsync(socket, CancellationToken.None);
var patchPayload = JsonSerializer.Deserialize<TerminalScreenPatchResponse>(
patchFrame,
new JsonSerializerOptions(JsonSerializerDefaults.Web));
Assert.NotNull(patchPayload);
Assert.Equal("screen_patch", patchPayload!.Type);
Assert.Equal("alternate", patchPayload.ActiveBuffer);
Assert.Empty(patchPayload.Operations);
}
private static async Task<string> ReceiveTextAsync(WebSocket socket, CancellationToken cancellationToken)
{
var buffer = new byte[4096];
@ -622,43 +413,4 @@ public sealed class TerminalWebSocketHandlerTests
string Chunk,
string Type);
private sealed record TerminalScreenPatchResponse(
string SessionId,
long BaseScreenVersion,
long ScreenVersion,
long SourceSequence,
int Rows,
int Columns,
int CursorRow,
int CursorColumn,
bool CursorVisible,
string ActiveBuffer,
IReadOnlyList<TerminalScreenPatchOperationResponse> Operations,
string Type);
private sealed record TerminalScreenPatchOperationResponse(
string Type,
int StartRow,
IReadOnlyList<string> Lines);
private sealed record TerminalScreenSnapshotResponse(
string SessionId,
long ScreenVersion,
long SourceSequence,
int Rows,
int Columns,
int CursorRow,
int CursorColumn,
bool CursorVisible,
string ActiveBuffer,
TerminalScreenBufferResponse PrimaryBuffer,
TerminalScreenBufferResponse? AlternateBuffer,
string Type);
private sealed record TerminalScreenBufferResponse(
IReadOnlyList<TerminalScreenLineResponse> Viewport);
private sealed record TerminalScreenLineResponse(
int Index,
string Text);
}

View File

@ -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);

View File

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

View File

@ -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**

View File

@ -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

View File

@ -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.