Implement backend-authoritative terminal screen sync

This commit is contained in:
sladro 2026-04-07 20:48:16 +08:00
parent e79148e9a3
commit 6fe97e7d8a
40 changed files with 4756 additions and 219 deletions

View File

@ -29,6 +29,25 @@ class AgentApiClient {
.replace(queryParameters: <String, String>{'lineCount': '$lineCount'});
}
Uri sessionJournalUri(
String sessionId, {
int limit = 200,
int? beforeSequence,
int? afterSequence,
}) {
final queryParameters = <String, String>{'limit': '$limit'};
if (beforeSequence != null) {
queryParameters['beforeSeq'] = '$beforeSequence';
}
if (afterSequence != null) {
queryParameters['afterSeq'] = '$afterSequence';
}
return baseUri
.resolve('/api/sessions/$sessionId/journal')
.replace(queryParameters: queryParameters);
}
Future<List<Map<String, dynamic>>> listSessions() async {
final response = await _dio.getUri(sessionsUri);
return _readJsonList(response.data, 'sessions');
@ -114,6 +133,23 @@ class AgentApiClient {
return _readJsonMap(response.data, 'session history');
}
Future<Map<String, dynamic>> getSessionJournal(
String sessionId, {
int limit = 200,
int? beforeSequence,
int? afterSequence,
}) async {
final response = await _dio.getUri(
sessionJournalUri(
sessionId,
limit: limit,
beforeSequence: beforeSequence,
afterSequence: afterSequence,
),
);
return _readJsonMap(response.data, 'session journal');
}
Future<void> redeemPairingCode({
required String code,
required String deviceName,

View File

@ -29,4 +29,8 @@ class AgentSocketClient {
'columns': columns,
'rows': rows,
};
Map<String, dynamic> buildScreenSyncMessage() => <String, dynamic>{
'type': 'screen_sync',
};
}

View File

@ -2,8 +2,12 @@ class HistoryWindow {
const HistoryWindow({
required this.lines,
required this.hasMoreAbove,
this.oldestSequence,
this.newestSequence,
});
final List<String> lines;
final bool hasMoreAbove;
final int? oldestSequence;
final int? newestSequence;
}

View File

@ -0,0 +1,19 @@
class TerminalOutputPayload {
const TerminalOutputPayload({
required this.sessionId,
required this.sequence,
required this.chunk,
});
final String sessionId;
final int sequence;
final String chunk;
factory TerminalOutputPayload.fromJson(Map<String, dynamic> json) {
return TerminalOutputPayload(
sessionId: json['sessionId'] as String,
sequence: (json['sequence'] as num?)?.toInt() ?? 0,
chunk: (json['chunk'] as String?) ?? '',
);
}
}

View File

@ -22,6 +22,9 @@ 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';
@ -151,6 +154,7 @@ 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;
@ -158,6 +162,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
bool _showExpandedControls = false;
_TerminalInputMode _inputMode = _TerminalInputMode.read;
TerminalConnectionState? _lastConnectionState;
TerminalScreenState? _authoritativeScreenState;
@override
void initState() {
@ -174,6 +179,8 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
onFrame: _handleTerminalFrame,
onRestore: _handleRestorePayload,
onHistoryLoaded: _handleHistoryLoaded,
onScreenSnapshot: _handleScreenSnapshot,
onScreenPatch: _handleScreenPatch,
viewportProvider: () => TerminalViewport(
columns: terminal.viewWidth,
rows: terminal.viewHeight,
@ -390,6 +397,11 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
_receivedSocketFrame = true;
_awaitingReconnectRestore = false;
_cancelHistorySeedTimer();
if (_authoritativeScreenState != null) {
_scheduleSnapshotPersist();
return;
}
terminal.write(frame);
_scheduleSnapshotPersist();
}
@ -400,6 +412,11 @@ 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();
@ -418,13 +435,51 @@ 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) {
if (history.lines.isEmpty) {
final seedText = _buildHistorySeedText(history.lines);
if (seedText.isEmpty) {
_pendingHistorySeed = null;
return;
}
_pendingHistorySeed = history.lines.join('\r\n');
_pendingHistorySeed = seedText;
_scheduleHistorySeedIfNeeded();
}
@ -435,6 +490,8 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
connectionState == TerminalConnectionState.reconnecting) {
_awaitingAttachReplayFrame = true;
_receivedRestorePayload = false;
_receivedScreenSnapshot = false;
_authoritativeScreenState = null;
if (connectionState == TerminalConnectionState.reconnecting) {
_awaitingReconnectRestore = true;
_receivedSocketFrame = false;
@ -449,6 +506,11 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
}
void _scheduleHistorySeedIfNeeded() {
if (_receivedScreenSnapshot) {
_cancelHistorySeedTimer();
return;
}
if (_receivedRestorePayload) {
_cancelHistorySeedTimer();
return;
@ -513,6 +575,12 @@ 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),
@ -553,6 +621,17 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
return end == text.length ? text : text.substring(0, end);
}
static String _buildHistorySeedText(List<String> lines) {
final outputLines = <String>[];
for (final line in lines) {
if (line.startsWith('[output] ')) {
outputLines.add(line.substring('[output] '.length));
}
}
return outputLines.join('\r\n');
}
static String _normalizeTerminalKeyboardInput(String input) {
if (!input.contains('\n')) {
return input;

View File

@ -0,0 +1,71 @@
class TerminalScreenPatch {
const TerminalScreenPatch({
required this.sessionId,
required this.baseScreenVersion,
required this.screenVersion,
required this.sourceSequence,
required this.rows,
required this.columns,
required this.cursorRow,
required this.cursorColumn,
required this.cursorVisible,
required this.activeBuffer,
required this.operations,
});
final String sessionId;
final int baseScreenVersion;
final int screenVersion;
final int sourceSequence;
final int rows;
final int columns;
final int cursorRow;
final int cursorColumn;
final bool cursorVisible;
final String activeBuffer;
final List<TerminalScreenPatchOperation> operations;
factory TerminalScreenPatch.fromJson(Map<String, dynamic> json) {
final rawOperations = (json['operations'] as List?) ?? const <dynamic>[];
return TerminalScreenPatch(
sessionId: json['sessionId'] as String,
baseScreenVersion: (json['baseScreenVersion'] as num?)?.toInt() ?? 0,
screenVersion: (json['screenVersion'] as num?)?.toInt() ?? 0,
sourceSequence: (json['sourceSequence'] as num?)?.toInt() ?? 0,
rows: (json['rows'] as num?)?.toInt() ?? 0,
columns: (json['columns'] as num?)?.toInt() ?? 0,
cursorRow: (json['cursorRow'] as num?)?.toInt() ?? 0,
cursorColumn: (json['cursorColumn'] as num?)?.toInt() ?? 0,
cursorVisible: json['cursorVisible'] as bool? ?? true,
activeBuffer: (json['activeBuffer'] as String?) ?? 'primary',
operations: rawOperations
.map(
(item) => TerminalScreenPatchOperation.fromJson(
Map<String, dynamic>.from(item as Map),
),
)
.toList(growable: false),
);
}
}
class TerminalScreenPatchOperation {
const TerminalScreenPatchOperation({
required this.type,
required this.startRow,
required this.lines,
});
final String type;
final int startRow;
final List<String> lines;
factory TerminalScreenPatchOperation.fromJson(Map<String, dynamic> json) {
final rawLines = (json['lines'] as List?) ?? const <dynamic>[];
return TerminalScreenPatchOperation(
type: (json['type'] as String?) ?? '',
startRow: (json['startRow'] as num?)?.toInt() ?? 0,
lines: rawLines.map((item) => item as String? ?? '').toList(growable: false),
);
}
}

View File

@ -0,0 +1,89 @@
class TerminalScreenSnapshot {
const TerminalScreenSnapshot({
required this.sessionId,
required this.screenVersion,
required this.sourceSequence,
required this.rows,
required this.columns,
required this.cursorRow,
required this.cursorColumn,
required this.cursorVisible,
required this.activeBuffer,
required this.primaryBuffer,
this.alternateBuffer,
});
final String sessionId;
final int screenVersion;
final int sourceSequence;
final int rows;
final int columns;
final int cursorRow;
final int cursorColumn;
final bool cursorVisible;
final String activeBuffer;
final TerminalScreenBuffer primaryBuffer;
final TerminalScreenBuffer? alternateBuffer;
factory TerminalScreenSnapshot.fromJson(Map<String, dynamic> json) {
return TerminalScreenSnapshot(
sessionId: json['sessionId'] as String,
screenVersion: (json['screenVersion'] as num?)?.toInt() ?? 0,
sourceSequence: (json['sourceSequence'] as num?)?.toInt() ?? 0,
rows: (json['rows'] as num?)?.toInt() ?? 0,
columns: (json['columns'] as num?)?.toInt() ?? 0,
cursorRow: (json['cursorRow'] as num?)?.toInt() ?? 0,
cursorColumn: (json['cursorColumn'] as num?)?.toInt() ?? 0,
cursorVisible: json['cursorVisible'] as bool? ?? true,
activeBuffer: (json['activeBuffer'] as String?) ?? 'primary',
primaryBuffer: TerminalScreenBuffer.fromJson(
Map<String, dynamic>.from((json['primaryBuffer'] as Map?) ?? const {}),
),
alternateBuffer: json['alternateBuffer'] is Map
? TerminalScreenBuffer.fromJson(
Map<String, dynamic>.from(json['alternateBuffer'] as Map),
)
: null,
);
}
String toDisplayText() {
final buffer = activeBuffer == 'alternate' && alternateBuffer != null
? alternateBuffer!
: primaryBuffer;
return buffer.viewport.map((line) => line.text).join('\n');
}
}
class TerminalScreenBuffer {
const TerminalScreenBuffer({required this.viewport});
final List<TerminalScreenLine> viewport;
factory TerminalScreenBuffer.fromJson(Map<String, dynamic> json) {
final rawViewport = (json['viewport'] as List?) ?? const <dynamic>[];
return TerminalScreenBuffer(
viewport: rawViewport
.map(
(item) => TerminalScreenLine.fromJson(
Map<String, dynamic>.from(item as Map),
),
)
.toList(growable: false),
);
}
}
class TerminalScreenLine {
const TerminalScreenLine({required this.index, required this.text});
final int index;
final String text;
factory TerminalScreenLine.fromJson(Map<String, dynamic> json) {
return TerminalScreenLine(
index: (json['index'] as num?)?.toInt() ?? 0,
text: (json['text'] as String?) ?? '',
);
}
}

View File

@ -0,0 +1,101 @@
import 'terminal_screen_patch.dart';
import 'terminal_screen_snapshot.dart';
class TerminalScreenState {
const TerminalScreenState({
required this.sessionId,
required this.screenVersion,
required this.sourceSequence,
required this.rows,
required this.columns,
required this.cursorRow,
required this.cursorColumn,
required this.cursorVisible,
required this.activeBuffer,
required this.primaryLines,
required this.alternateLines,
});
final String sessionId;
final int screenVersion;
final int sourceSequence;
final int rows;
final int columns;
final int cursorRow;
final int cursorColumn;
final bool cursorVisible;
final String activeBuffer;
final List<String> primaryLines;
final List<String> alternateLines;
factory TerminalScreenState.fromSnapshot(TerminalScreenSnapshot snapshot) {
List<String> materialize(TerminalScreenBuffer? buffer) {
final lines = List<String>.filled(snapshot.rows, '');
final viewport = buffer?.viewport ?? const <TerminalScreenLine>[];
for (final line in viewport) {
if (line.index >= 0 && line.index < lines.length) {
lines[line.index] = line.text;
}
}
return lines;
}
return TerminalScreenState(
sessionId: snapshot.sessionId,
screenVersion: snapshot.screenVersion,
sourceSequence: snapshot.sourceSequence,
rows: snapshot.rows,
columns: snapshot.columns,
cursorRow: snapshot.cursorRow,
cursorColumn: snapshot.cursorColumn,
cursorVisible: snapshot.cursorVisible,
activeBuffer: snapshot.activeBuffer,
primaryLines: materialize(snapshot.primaryBuffer),
alternateLines: materialize(snapshot.alternateBuffer),
);
}
bool canApplyPatch(TerminalScreenPatch patch) {
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 targetLines = patch.activeBuffer == 'alternate'
? nextAlternateLines
: nextPrimaryLines;
for (final operation in patch.operations) {
if (operation.type != 'replace_lines') {
continue;
}
for (var index = 0; index < operation.lines.length; index += 1) {
final row = operation.startRow + index;
if (row >= 0 && row < targetLines.length) {
targetLines[row] = operation.lines[index];
}
}
}
return TerminalScreenState(
sessionId: sessionId,
screenVersion: patch.screenVersion,
sourceSequence: patch.sourceSequence,
rows: patch.rows,
columns: patch.columns,
cursorRow: patch.cursorRow,
cursorColumn: patch.cursorColumn,
cursorVisible: patch.cursorVisible,
activeBuffer: patch.activeBuffer,
primaryLines: nextPrimaryLines,
alternateLines: nextAlternateLines,
);
}
String toDisplayText() {
final lines = activeBuffer == 'alternate' ? alternateLines : primaryLines;
return lines.join('\n');
}
}

View File

@ -4,10 +4,13 @@ import 'package:flutter/foundation.dart';
import '../../core/network/agent_api_client.dart';
import '../sessions/session.dart';
import 'terminal_diagnostic_log.dart';
import 'history_window.dart';
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();
@ -37,6 +40,8 @@ class TerminalSessionCoordinator extends ChangeNotifier {
required this.sessionFactory,
required this.onFrame,
required this.onRestore,
this.onScreenSnapshot,
this.onScreenPatch,
required this.viewportProvider,
Uri? baseUri,
this.onHistoryLoaded,
@ -54,7 +59,7 @@ class TerminalSessionCoordinator extends ChangeNotifier {
);
static const Duration reconnectDelay = Duration(seconds: 1);
static const Duration resizeDebounceDelay = Duration(milliseconds: 120);
static const int initialHistoryLineCount = 200;
static const int historyPageSize = 200;
static const int pendingInputCharacterLimit = 2048;
final TerminalInteractionController controller;
@ -63,6 +68,8 @@ 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;
@ -73,7 +80,6 @@ class TerminalSessionCoordinator extends ChangeNotifier {
TerminalSocketSession? _socketSession;
CancelReconnect? _cancelReconnect;
CancelResize? _cancelResize;
int _historyLineCount = initialHistoryLineCount;
bool _isLoadingOlderHistory = false;
bool _isDisposed = false;
bool _connectionAttemptInProgress = false;
@ -86,6 +92,10 @@ class TerminalSessionCoordinator extends ChangeNotifier {
int? _pendingResizeRows;
int? _lastSentColumns;
int? _lastSentRows;
int? _lastReceivedSequence;
int? _screenVersion;
bool _screenProtocolActive = false;
bool _screenSyncPending = false;
bool get isLoadingOlderHistory => _isLoadingOlderHistory;
@ -101,6 +111,9 @@ class TerminalSessionCoordinator extends ChangeNotifier {
}
final sessionGeneration = ++_sessionGeneration;
_screenProtocolActive = false;
_screenVersion = null;
_screenSyncPending = false;
if (isReconnect) {
controller.markReconnecting();
@ -129,12 +142,12 @@ class TerminalSessionCoordinator extends ChangeNotifier {
try {
await socketSession.connect(
onFrame: (chunk) {
onOutput: (output) {
if (!_isCurrentSession(socketSession, sessionGeneration)) {
return;
}
_handleFrame(chunk);
unawaited(_handleOutput(output));
},
onRestore: (restore) {
if (!_isCurrentSession(socketSession, sessionGeneration)) {
@ -143,6 +156,20 @@ 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;
@ -242,12 +269,14 @@ class TerminalSessionCoordinator extends ChangeNotifier {
}
_isLoadingOlderHistory = true;
_historyLineCount += initialHistoryLineCount;
diagnosticLog?.add('history.load.older', 'lineCount=$_historyLineCount');
diagnosticLog?.add(
'history.load.older',
'beforeSeq=${controller.historyWindow.oldestSequence}',
);
notifyListeners();
try {
await _loadHistory();
await _loadHistory(loadOlder: true);
} finally {
_isLoadingOlderHistory = false;
notifyListeners();
@ -283,11 +312,49 @@ class TerminalSessionCoordinator extends ChangeNotifier {
await _closeActiveSession();
}
void _handleFrame(String chunk) {
diagnosticLog?.add('socket.frame.rx', chunk);
Future<void> _handleOutput(TerminalOutputPayload output) async {
if (output.sequence > 0) {
final lastReceivedSequence = _lastReceivedSequence;
if (lastReceivedSequence != null && output.sequence <= lastReceivedSequence) {
diagnosticLog?.add(
'socket.output.stale',
'seq=${output.sequence} last=$lastReceivedSequence',
);
return;
}
if (lastReceivedSequence != null &&
output.sequence > lastReceivedSequence + 1) {
diagnosticLog?.add(
'socket.output.gap',
'expected=${lastReceivedSequence + 1} actual=${output.sequence}',
);
await _recoverMissingOutput(afterSequence: lastReceivedSequence);
if (output.sequence <= (_lastReceivedSequence ?? 0)) {
return;
}
}
}
_applyOutput(output);
}
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(chunk);
onFrame(chunk);
controller.applyFrame(output.chunk);
if (output.sequence > 0) {
_lastReceivedSequence = output.sequence;
}
onFrame(output.chunk);
}
void _handleRestore(TerminalRestorePayload restore) {
@ -295,30 +362,112 @@ class TerminalSessionCoordinator extends ChangeNotifier {
'socket.restore.rx',
'sequence=${restore.sequence} pending=${restore.pendingInput.length}',
);
_lastReceivedSequence = restore.sequence;
onRestore(restore);
}
Future<void> _loadHistory() async {
try {
final payload = await apiClient.getSessionHistory(
session.sessionId,
lineCount: _historyLineCount,
void _handleScreenSnapshot(TerminalScreenSnapshot snapshot) {
diagnosticLog?.add(
'socket.screen_snapshot.rx',
'screenVersion=${snapshot.screenVersion} sourceSeq=${snapshot.sourceSequence}',
);
final history = HistoryWindow(
lines: ((payload['lines'] as List?) ?? const <dynamic>[])
.map((line) => line.toString())
.toList(growable: false),
hasMoreAbove: payload['hasMoreAbove'] == true,
_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,
);
final history = _buildHistoryWindow(
payload,
existing: loadOlder ? controller.historyWindow : null,
);
controller.loadHistory(history);
onHistoryLoaded?.call(history);
diagnosticLog?.add(
'history.loaded',
'${history.lines.length} lines, more=${history.hasMoreAbove}',
'${history.lines.length} lines, more=${history.hasMoreAbove}, newest=${history.newestSequence}',
);
} catch (_) {}
}
Future<void> _recoverMissingOutput({required int afterSequence}) async {
try {
var cursor = afterSequence;
while (true) {
final payload = await apiClient.getSessionJournal(
session.sessionId,
limit: historyPageSize,
afterSequence: cursor,
);
final items = _readJournalItems(payload);
if (items.isEmpty) {
break;
}
for (final item in items) {
if (item.sequence <= cursor) {
continue;
}
if (item.kind == 'output') {
_applyOutput(
TerminalOutputPayload(
sessionId: item.sessionId,
sequence: item.sequence,
chunk: item.payload,
),
);
} else {
_lastReceivedSequence = item.sequence;
}
cursor = item.sequence;
}
if (payload['hasMoreAfter'] != true) {
break;
}
}
} catch (_) {}
}
void _scheduleReconnect() {
_cancelPendingReconnect();
_cancelPendingResize();
@ -480,4 +629,97 @@ class TerminalSessionCoordinator extends ChangeNotifier {
sessionGeneration == _sessionGeneration &&
identical(_socketSession, socketSession);
}
HistoryWindow _buildHistoryWindow(
Map<String, dynamic> payload, {
HistoryWindow? existing,
}) {
final items = _readJournalItems(payload);
final lines = _renderHistoryLines(items);
final mergedLines = existing == null
? lines
: <String>[...lines, ...existing.lines];
final oldestSequence = items.isEmpty
? existing?.oldestSequence
: items.first.sequence;
final newestSequence =
existing?.newestSequence ?? (items.isEmpty ? null : items.last.sequence);
return HistoryWindow(
lines: mergedLines,
hasMoreAbove: payload['hasMoreBefore'] == true,
oldestSequence: oldestSequence,
newestSequence: newestSequence,
);
}
List<_JournalItem> _readJournalItems(Map<String, dynamic> payload) {
final rawItems = (payload['items'] as List?) ?? const <dynamic>[];
return rawItems
.map(
(item) => _JournalItem.fromJson(
Map<String, dynamic>.from(item as Map),
),
)
.toList(growable: false);
}
List<String> _renderHistoryLines(List<_JournalItem> items) {
final lines = <String>[];
for (final item in items) {
switch (item.kind) {
case 'output':
lines.addAll(
_splitTerminalLines(item.payload).map((line) => '[output] $line'),
);
case 'input':
lines.add('[input] ${_compactControlText(item.payload)}');
case 'resize':
lines.add('[resize] ${item.payload}');
case 'attach':
lines.add('[attach]');
case 'detach':
lines.add('[detach]');
default:
lines.add('[${item.kind}] ${item.payload}');
}
}
return lines;
}
static List<String> _splitTerminalLines(String payload) {
final normalized = payload.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
final parts = normalized.split('\n');
if (parts.isNotEmpty && parts.last.isEmpty) {
return parts.sublist(0, parts.length - 1);
}
return parts;
}
static String _compactControlText(String payload) {
return payload.replaceAll('\r', r'\r').replaceAll('\n', r'\n');
}
}
class _JournalItem {
const _JournalItem({
required this.sessionId,
required this.sequence,
required this.kind,
required this.payload,
});
final String sessionId;
final int sequence;
final String kind;
final String payload;
factory _JournalItem.fromJson(Map<String, dynamic> json) {
return _JournalItem(
sessionId: json['sessionId'] as String,
sequence: (json['sequence'] as num?)?.toInt() ?? 0,
kind: (json['kind'] as String?) ?? 'unknown',
payload: (json['payload'] as String?) ?? '',
);
}
}

View File

@ -7,7 +7,10 @@ import 'package:web_socket_channel/web_socket_channel.dart';
import '../../core/network/agent_api_client.dart';
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);
@ -56,8 +59,10 @@ class TerminalSocketSession {
bool _isAttached = false;
Future<void> connect({
required void Function(String frame) onFrame,
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) {
@ -88,7 +93,18 @@ class TerminalSocketSession {
return;
}
onFrame(message);
if (_handleScreenSnapshotFrame(message, onScreenSnapshot)) {
return;
}
if (_handleScreenPatchFrame(message, onScreenPatch)) {
return;
}
final output = _decodeOutputFrame(message);
if (output != null) {
onOutput(output);
}
},
onError: (error, stackTrace) {
final wasAttached = _isAttached;
@ -160,6 +176,19 @@ class TerminalSocketSession {
}
}
void requestScreenSync() {
final transport = _transport;
if (transport == null) {
return;
}
try {
transport.send(jsonEncode(socketClient.buildScreenSyncMessage()));
} catch (_) {
_handleTransportClosed(transport);
}
}
Future<void> dispose() async {
final subscription = _subscription;
final transport = _transport;
@ -198,6 +227,67 @@ 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));
}
if (decoded is Map && decoded['type'] != null) {
return null;
}
} catch (_) {}
return TerminalOutputPayload(
sessionId: sessionId,
sequence: 0,
chunk: frame,
);
}
void _handleTransportClosed(TerminalSocketTransport? transport) {
if (transport == null) {
_subscription = null;

View File

@ -143,6 +143,42 @@ void main() {
expect(history['hasMoreAbove'], isTrue);
});
test('fetches session journal from the journal endpoint', () async {
final adapter = _FakeHttpClientAdapter(
jsonEncode({
'sessionId': 'abc',
'items': [
{
'sessionId': 'abc',
'sequence': 11,
'kind': 'input',
'payload': 'git status',
'timestampUtc': '2026-04-07T03:20:01Z',
},
],
'hasMoreBefore': true,
'hasMoreAfter': false,
'currentSequence': 11,
}),
);
final dio = Dio()..httpClientAdapter = adapter;
final client = AgentApiClient(Uri.parse('https://host:9443'), dio: dio);
final journal = await client.getSessionJournal(
'abc',
beforeSequence: 20,
limit: 50,
);
expect(adapter.lastOptions?.method, 'GET');
expect(
adapter.lastOptions?.uri.toString(),
'https://host:9443/api/sessions/abc/journal?limit=50&beforeSeq=20',
);
expect(journal['sessionId'], 'abc');
expect((journal['items'] as List).single['kind'], 'input');
});
test('applies explicit default request timeouts', () async {
final adapter = _FakeHttpClientAdapter(jsonEncode(const <dynamic>[]));
final dio = Dio()..httpClientAdapter = adapter;

View File

@ -34,4 +34,15 @@ void main() {
},
);
});
test('builds screen sync message for terminal resync', () {
final client = AgentSocketClient(Uri.parse('https://host:9443'));
expect(
client.buildScreenSyncMessage(),
<String, dynamic>{
'type': 'screen_sync',
},
);
});
}

View File

@ -252,14 +252,33 @@ class _FakeAgentApiClient extends AgentApiClient {
_FakeAgentApiClient() : super(Uri.parse('http://100.81.30.82:5067'));
@override
Future<Map<String, dynamic>> getSessionHistory(
Future<Map<String, dynamic>> getSessionJournal(
String sessionId, {
int lineCount = 200,
int limit = 200,
int? beforeSequence,
int? afterSequence,
}) async {
return <String, dynamic>{
'sessionId': sessionId,
'lines': <String>['one', 'two'],
'hasMoreAbove': true,
'items': <Map<String, dynamic>>[
<String, dynamic>{
'sessionId': sessionId,
'sequence': 1,
'kind': 'output',
'payload': 'one\r\n',
'timestampUtc': '2026-04-07T03:20:01Z',
},
<String, dynamic>{
'sessionId': sessionId,
'sequence': 2,
'kind': 'output',
'payload': 'two\r\n',
'timestampUtc': '2026-04-07T03:20:02Z',
},
],
'hasMoreBefore': true,
'hasMoreAfter': false,
'currentSequence': 2,
};
}
}

View File

@ -5,7 +5,10 @@ import 'package:term_remote_ctl/core/network/agent_api_client.dart';
import 'package:term_remote_ctl/core/network/agent_socket_client.dart';
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';
@ -125,20 +128,54 @@ void main() {
);
test(
'loadOlderHistory increases the requested history window size',
'loadOlderHistory pages backward with beforeSequence instead of expanding lineCount',
() async {
final controller = TerminalInteractionController();
final apiClient = _FakeAgentApiClient(
responses: [
journalResponses: [
<String, dynamic>{
'sessionId': 'abc',
'lines': <String>['one', 'two'],
'hasMoreAbove': true,
'items': [
{
'sessionId': 'abc',
'sequence': 201,
'kind': 'output',
'payload': 'one\r\n',
'timestampUtc': '2026-04-07T03:20:01Z',
},
{
'sessionId': 'abc',
'sequence': 202,
'kind': 'output',
'payload': 'two\r\n',
'timestampUtc': '2026-04-07T03:20:02Z',
},
],
'hasMoreBefore': true,
'hasMoreAfter': false,
'currentSequence': 202,
},
<String, dynamic>{
'sessionId': 'abc',
'lines': <String>['zero', 'one', 'two'],
'hasMoreAbove': false,
'items': [
{
'sessionId': 'abc',
'sequence': 199,
'kind': 'output',
'payload': 'zero\r\n',
'timestampUtc': '2026-04-07T03:19:59Z',
},
{
'sessionId': 'abc',
'sequence': 200,
'kind': 'attach',
'payload': '',
'timestampUtc': '2026-04-07T03:20:00Z',
},
],
'hasMoreBefore': false,
'hasMoreAfter': true,
'currentSequence': 202,
},
],
);
@ -161,12 +198,186 @@ void main() {
await coordinator.start();
await coordinator.loadOlderHistory();
expect(apiClient.requestedLineCounts, [200, 400]);
expect(controller.historyWindow.lines, ['zero', 'one', 'two']);
expect(apiClient.requestedJournalBeforeSequences, [null, 201]);
expect(
controller.historyWindow.lines,
['[output] zero', '[attach]', '[output] one', '[output] two'],
);
expect(controller.historyWindow.hasMoreAbove, isFalse);
},
);
test(
'sequence gaps trigger authoritative journal fetch before applying newer output',
() async {
final controller = TerminalInteractionController();
final apiClient = _FakeAgentApiClient(
journalResponses: [
<String, dynamic>{
'sessionId': 'abc',
'items': const <Map<String, dynamic>>[],
'hasMoreBefore': false,
'hasMoreAfter': false,
'currentSequence': 0,
},
<String, dynamic>{
'sessionId': 'abc',
'items': [
{
'sessionId': 'abc',
'sequence': 6,
'kind': 'output',
'payload': 'missed-output',
'timestampUtc': '2026-04-07T03:20:06Z',
},
{
'sessionId': 'abc',
'sequence': 7,
'kind': 'output',
'payload': 'live-output',
'timestampUtc': '2026-04-07T03:20:07Z',
},
],
'hasMoreBefore': true,
'hasMoreAfter': false,
'currentSequence': 7,
},
],
);
final sessionFactory = _FakeTerminalSessionFactory();
final session = Session(
sessionId: 'abc',
name: 'codex-main',
status: 'idle',
);
final receivedFrames = <String>[];
final coordinator = TerminalSessionCoordinator(
controller: controller,
apiClient: apiClient,
session: session,
sessionFactory: sessionFactory.create,
onFrame: receivedFrames.add,
onRestore: (_) {},
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
);
await coordinator.start();
sessionFactory.createdSessions.single.emitRestore(
const TerminalRestorePayload(
sessionId: 'abc',
sequence: 5,
screenText: 'PS> ',
pendingInput: '',
),
);
sessionFactory.createdSessions.single.emitOutput(
const TerminalOutputPayload(
sessionId: 'abc',
sequence: 7,
chunk: 'live-output',
),
);
await Future<void>.delayed(Duration.zero);
expect(apiClient.requestedJournalAfterSequences, [5]);
expect(receivedFrames, ['missed-output', 'live-output']);
},
);
test(
'sequence gaps fetch multiple journal pages until the missing range is closed',
() async {
final controller = TerminalInteractionController();
final apiClient = _FakeAgentApiClient(
journalResponses: [
<String, dynamic>{
'sessionId': 'abc',
'items': const <Map<String, dynamic>>[],
'hasMoreBefore': false,
'hasMoreAfter': false,
'currentSequence': 0,
},
<String, dynamic>{
'sessionId': 'abc',
'items': [
{
'sessionId': 'abc',
'sequence': 6,
'kind': 'output',
'payload': 'gap-1',
'timestampUtc': '2026-04-07T03:20:06Z',
},
{
'sessionId': 'abc',
'sequence': 7,
'kind': 'output',
'payload': 'gap-2',
'timestampUtc': '2026-04-07T03:20:07Z',
},
],
'hasMoreBefore': true,
'hasMoreAfter': true,
'currentSequence': 9,
},
<String, dynamic>{
'sessionId': 'abc',
'items': [
{
'sessionId': 'abc',
'sequence': 8,
'kind': 'output',
'payload': 'gap-3',
'timestampUtc': '2026-04-07T03:20:08Z',
},
],
'hasMoreBefore': true,
'hasMoreAfter': false,
'currentSequence': 9,
},
],
);
final sessionFactory = _FakeTerminalSessionFactory();
final session = Session(
sessionId: 'abc',
name: 'codex-main',
status: 'idle',
);
final receivedFrames = <String>[];
final coordinator = TerminalSessionCoordinator(
controller: controller,
apiClient: apiClient,
session: session,
sessionFactory: sessionFactory.create,
onFrame: receivedFrames.add,
onRestore: (_) {},
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
);
await coordinator.start();
sessionFactory.createdSessions.single.emitRestore(
const TerminalRestorePayload(
sessionId: 'abc',
sequence: 5,
screenText: 'PS> ',
pendingInput: '',
),
);
sessionFactory.createdSessions.single.emitOutput(
const TerminalOutputPayload(
sessionId: 'abc',
sequence: 9,
chunk: 'live-output',
),
);
await Future<void>.delayed(Duration.zero);
expect(apiClient.requestedJournalAfterSequences, [5, 7]);
expect(receivedFrames, ['gap-1', 'gap-2', 'gap-3', 'live-output']);
},
);
test('suspendForBackground closes the active socket session', () async {
final controller = TerminalInteractionController();
final apiClient = _FakeAgentApiClient();
@ -220,7 +431,13 @@ void main() {
await coordinator.start();
controller.enterScrollback();
sessionFactory.createdSessions.single.emitFrame('next-line');
sessionFactory.createdSessions.single.emitOutput(
const TerminalOutputPayload(
sessionId: 'abc',
sequence: 1,
chunk: 'next-line',
),
);
expect(receivedFrames, ['next-line']);
expect(controller.hasPendingLiveOutput, isTrue);
@ -314,8 +531,20 @@ void main() {
expect(sessionFactory.createdSessions, hasLength(2));
final secondSession = sessionFactory.createdSessions.last;
firstSession.emitFrame('stale-frame');
secondSession.emitFrame('fresh-frame');
firstSession.emitOutput(
const TerminalOutputPayload(
sessionId: 'abc',
sequence: 1,
chunk: 'stale-frame',
),
);
secondSession.emitOutput(
const TerminalOutputPayload(
sessionId: 'abc',
sequence: 1,
chunk: 'fresh-frame',
),
);
expect(receivedFrames, ['fresh-frame']);
},
@ -355,33 +584,444 @@ 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 {
_FakeAgentApiClient({List<Map<String, dynamic>>? responses})
: _responses =
responses ??
_FakeAgentApiClient({List<Map<String, dynamic>>? journalResponses})
: _journalResponses =
journalResponses ??
[
<String, dynamic>{
'sessionId': 'abc',
'lines': <String>['one', 'two'],
'hasMoreAbove': true,
'items': [
{
'sessionId': 'abc',
'sequence': 1,
'kind': 'output',
'payload': 'one\r\n',
'timestampUtc': '2026-04-07T03:20:01Z',
},
{
'sessionId': 'abc',
'sequence': 2,
'kind': 'output',
'payload': 'two\r\n',
'timestampUtc': '2026-04-07T03:20:02Z',
},
],
'hasMoreBefore': true,
'hasMoreAfter': false,
'currentSequence': 2,
},
],
super(Uri.parse('https://host:9443'));
final List<Map<String, dynamic>> _responses;
final requestedLineCounts = <int>[];
final List<Map<String, dynamic>> _journalResponses;
final requestedJournalBeforeSequences = <int?>[];
final requestedJournalAfterSequences = <int?>[];
var _index = 0;
@override
Future<Map<String, dynamic>> getSessionHistory(
Future<Map<String, dynamic>> getSessionJournal(
String sessionId, {
int lineCount = 200,
int limit = 200,
int? beforeSequence,
int? afterSequence,
}) async {
requestedLineCounts.add(lineCount);
final response = _responses[_index];
if (_index < _responses.length - 1) {
requestedJournalBeforeSequences.add(beforeSequence);
if (afterSequence != null) {
requestedJournalAfterSequences.add(afterSequence);
}
final response = _journalResponses[_index];
if (_index < _journalResponses.length - 1) {
_index += 1;
}
@ -412,22 +1052,30 @@ 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>();
void Function(String frame)? _onFrame;
void Function(TerminalOutputPayload output)? _onOutput;
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(String frame) onFrame,
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,
}) {
_onFrame = onFrame;
_onOutput = onOutput;
_onRestore = onRestore;
_onScreenSnapshot = onScreenSnapshot;
_onScreenPatch = onScreenPatch;
_onDisconnected = onDisconnected;
if (autoConnect && !_connectCompleter.isCompleted) {
_connectCompleter.complete();
@ -456,14 +1104,27 @@ class _FakeTerminalSocketSession extends TerminalSocketSession {
}
}
void emitFrame(String frame) {
_onFrame?.call(frame);
void emitOutput(TerminalOutputPayload output) {
_onOutput?.call(output);
}
void emitRestore(TerminalRestorePayload restore) {
_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

@ -2,7 +2,10 @@ import 'dart:async';
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() {
@ -14,10 +17,10 @@ void main() {
transportFactory: (_) => transport,
);
final frames = <String>[];
final outputs = <TerminalOutputPayload>[];
var completed = false;
final connectFuture = session.connect(
onFrame: frames.add,
onOutput: outputs.add,
onRestore: (_) {},
).then((_) {
completed = true;
@ -35,11 +38,14 @@ void main() {
await connectFuture;
expect(completed, isTrue);
expect(frames, isEmpty);
expect(outputs, isEmpty);
transport.emit('abc');
transport.emit(
'{"type":"output","sessionId":"session-123","sequence":3,"chunk":"abc"}',
);
await Future<void>.delayed(Duration.zero);
expect(frames, ['abc']);
expect(outputs.single.sequence, 3);
expect(outputs.single.chunk, 'abc');
await session.dispose();
});
@ -52,7 +58,7 @@ void main() {
transportFactory: (_) => transport,
);
final connectFuture = session.connect(onFrame: (_) {}, onRestore: (_) {});
final connectFuture = session.connect(onOutput: (_) {}, onRestore: (_) {});
await transport.close();
await expectLater(connectFuture, throwsStateError);
@ -66,7 +72,7 @@ void main() {
transportFactory: (_) => transport,
);
final connectFuture = session.connect(onFrame: (_) {}, onRestore: (_) {});
final connectFuture = session.connect(onOutput: (_) {}, onRestore: (_) {});
await Future<void>.delayed(Duration.zero);
transport.emit('{"type":"attached","sessionId":"session-123"}');
await connectFuture;
@ -88,7 +94,7 @@ void main() {
transportFactory: (_) => transport,
);
final connectFuture = session.connect(onFrame: (_) {}, onRestore: (_) {});
final connectFuture = session.connect(onOutput: (_) {}, onRestore: (_) {});
await Future<void>.delayed(Duration.zero);
transport.emit('{"type":"attached","sessionId":"session-123"}');
await connectFuture;
@ -103,6 +109,29 @@ 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(
@ -113,7 +142,7 @@ void main() {
var disconnectCount = 0;
final connectFuture = session.connect(
onFrame: (_) {},
onOutput: (_) {},
onRestore: (_) {},
onDisconnected: () {
disconnectCount += 1;
@ -137,7 +166,7 @@ void main() {
transportFactory: (_) => transport,
);
final connectFuture = session.connect(onFrame: (_) {}, onRestore: (_) {});
final connectFuture = session.connect(onOutput: (_) {}, onRestore: (_) {});
await Future<void>.delayed(Duration.zero);
transport.emit('{"type":"attached","sessionId":"session-123"}');
await connectFuture;
@ -159,10 +188,12 @@ void main() {
transportFactory: (_) => transport,
);
final frames = <String>[];
final outputs = <TerminalOutputPayload>[];
final snapshots = <TerminalScreenSnapshot>[];
final restores = <TerminalRestorePayload>[];
final connectFuture = session.connect(
onFrame: frames.add,
onOutput: outputs.add,
onScreenSnapshot: snapshots.add,
onRestore: restores.add,
);
await Future<void>.delayed(Duration.zero);
@ -170,18 +201,91 @@ 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"}',
);
transport.emit('live-output');
transport.emit(
'{"type":"output","sessionId":"session-123","sequence":5,"chunk":"live-output"}',
);
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);
expect(restores.single.screenText, 'PS> gi');
expect(restores.single.pendingInput, 't status');
expect(frames, ['live-output']);
expect(outputs, hasLength(1));
expect(outputs.single.sequence, 5);
expect(outputs.single.chunk, 'live-output');
});
test('connect ignores unknown json control frames', () async {
final transport = _FakeTerminalSocketTransport();
final session = TerminalSocketSession(
sessionId: 'session-123',
socketClient: AgentSocketClient(Uri.parse('https://host:9443')),
transportFactory: (_) => transport,
);
final outputs = <TerminalOutputPayload>[];
final connectFuture = session.connect(
onOutput: outputs.add,
onRestore: (_) {},
);
await Future<void>.delayed(Duration.zero);
transport.emit('{"type":"attached","sessionId":"session-123"}');
await connectFuture;
transport.emit('{"type":"heartbeat","sessionId":"session-123"}');
await Future<void>.delayed(Duration.zero);
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');
});
}

View File

@ -7,7 +7,10 @@ import 'package:term_remote_ctl/features/sessions/session.dart';
import 'package:term_remote_ctl/features/terminal/history_window.dart';
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';
@ -88,14 +91,18 @@ class _FakeAgentApiClient extends AgentApiClient {
_FakeAgentApiClient() : super(Uri.parse('https://host.example:9443'));
@override
Future<Map<String, dynamic>> getSessionHistory(
Future<Map<String, dynamic>> getSessionJournal(
String sessionId, {
int lineCount = 200,
int limit = 200,
int? beforeSequence,
int? afterSequence,
}) async {
return <String, dynamic>{
'sessionId': sessionId,
'lines': const <String>[],
'hasMoreAbove': false,
'items': const <Map<String, dynamic>>[],
'hasMoreBefore': false,
'hasMoreAfter': false,
'currentSequence': 0,
};
}
}
@ -111,8 +118,10 @@ class _RecordingTerminalSocketSession extends TerminalSocketSession {
@override
Future<void> connect({
required void Function(String frame) onFrame,
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,6 +319,145 @@ 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"}]}}',
),
],
],
);
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')));
});
testWidgets(
'terminal page keeps the command deck above the bottom safe area',
(tester) async {
@ -858,13 +997,47 @@ void main() {
responses: [
<String, dynamic>{
'sessionId': 'session-1',
'lines': <String>['one', 'two'],
'hasMoreAbove': true,
'items': <Map<String, dynamic>>[
<String, dynamic>{
'sessionId': 'session-1',
'sequence': 201,
'kind': 'output',
'payload': 'one\r\n',
'timestampUtc': '2026-04-07T03:20:01Z',
},
<String, dynamic>{
'sessionId': 'session-1',
'lines': <String>['zero', 'one', 'two'],
'hasMoreAbove': false,
'sequence': 202,
'kind': 'output',
'payload': 'two\r\n',
'timestampUtc': '2026-04-07T03:20:02Z',
},
],
'hasMoreBefore': true,
'hasMoreAfter': false,
'currentSequence': 202,
},
<String, dynamic>{
'sessionId': 'session-1',
'items': <Map<String, dynamic>>[
<String, dynamic>{
'sessionId': 'session-1',
'sequence': 199,
'kind': 'output',
'payload': 'zero\r\n',
'timestampUtc': '2026-04-07T03:19:59Z',
},
<String, dynamic>{
'sessionId': 'session-1',
'sequence': 200,
'kind': 'attach',
'payload': '',
'timestampUtc': '2026-04-07T03:20:00Z',
},
],
'hasMoreBefore': false,
'hasMoreAfter': true,
'currentSequence': 202,
},
],
);
@ -880,7 +1053,7 @@ void main() {
expect(find.text('Scrollback | 2 lines'), findsNothing);
await tester.tap(find.text('Live | 2 lines'));
await tester.tap(find.textContaining('Live |'));
await tester.pumpAndSettle();
expect(find.text('Scrollback | 2 lines'), findsOneWidget);
@ -890,8 +1063,8 @@ void main() {
await tester.tap(find.text('Load older lines'));
await tester.pumpAndSettle();
expect(apiClient.requestedLineCounts, [200, 400]);
expect(find.text('3 lines loaded'), findsOneWidget);
expect(apiClient.requestedLineCounts, [null, 201]);
expect(find.text('4 lines loaded'), findsOneWidget);
},
);
@ -958,13 +1131,47 @@ void main() {
responses: [
<String, dynamic>{
'sessionId': 'session-1',
'lines': <String>['one', 'two'],
'hasMoreAbove': true,
'items': <Map<String, dynamic>>[
<String, dynamic>{
'sessionId': 'session-1',
'sequence': 201,
'kind': 'output',
'payload': 'one\r\n',
'timestampUtc': '2026-04-07T03:20:01Z',
},
<String, dynamic>{
'sessionId': 'session-1',
'lines': <String>['zero', 'one', 'two'],
'hasMoreAbove': false,
'sequence': 202,
'kind': 'output',
'payload': 'two\r\n',
'timestampUtc': '2026-04-07T03:20:02Z',
},
],
'hasMoreBefore': true,
'hasMoreAfter': false,
'currentSequence': 202,
},
<String, dynamic>{
'sessionId': 'session-1',
'items': <Map<String, dynamic>>[
<String, dynamic>{
'sessionId': 'session-1',
'sequence': 199,
'kind': 'output',
'payload': 'zero\r\n',
'timestampUtc': '2026-04-07T03:19:59Z',
},
<String, dynamic>{
'sessionId': 'session-1',
'sequence': 200,
'kind': 'attach',
'payload': '',
'timestampUtc': '2026-04-07T03:20:00Z',
},
],
'hasMoreBefore': false,
'hasMoreAfter': true,
'currentSequence': 202,
},
],
);
@ -1745,14 +1952,33 @@ class _FakeAgentApiClient extends AgentApiClient {
_FakeAgentApiClient() : super(Uri.parse('http://100.81.30.82:5067'));
@override
Future<Map<String, dynamic>> getSessionHistory(
Future<Map<String, dynamic>> getSessionJournal(
String sessionId, {
int lineCount = 200,
int limit = 200,
int? beforeSequence,
int? afterSequence,
}) async {
return <String, dynamic>{
'sessionId': sessionId,
'lines': <String>['one', 'two'],
'hasMoreAbove': true,
'items': <Map<String, dynamic>>[
<String, dynamic>{
'sessionId': sessionId,
'sequence': 1,
'kind': 'output',
'payload': 'one\r\n',
'timestampUtc': '2026-04-07T03:20:01Z',
},
<String, dynamic>{
'sessionId': sessionId,
'sequence': 2,
'kind': 'output',
'payload': 'two\r\n',
'timestampUtc': '2026-04-07T03:20:02Z',
},
],
'hasMoreBefore': true,
'hasMoreAfter': false,
'currentSequence': 2,
};
}
}
@ -1763,15 +1989,18 @@ class _SequencedHistoryAgentApiClient extends _FakeAgentApiClient {
}) : _responses = responses;
final List<Map<String, dynamic>> _responses;
final requestedLineCounts = <int>[];
final requestedBeforeSequences = <int?>[];
List<int?> get requestedLineCounts => requestedBeforeSequences;
int _index = 0;
@override
Future<Map<String, dynamic>> getSessionHistory(
Future<Map<String, dynamic>> getSessionJournal(
String sessionId, {
int lineCount = 200,
int limit = 200,
int? beforeSequence,
int? afterSequence,
}) async {
requestedLineCounts.add(lineCount);
requestedBeforeSequences.add(beforeSequence);
final response = _responses[_index];
if (_index < _responses.length - 1) {
_index += 1;
@ -1786,6 +2015,7 @@ class _FakeTerminalSocketTransport implements TerminalSocketTransport {
this.autoAttach = false,
this.startupFrames = const <String>[],
this.scheduledStartupFrames = const <_StartupFrame>[],
this.onMessageSent,
}) {
final framesToEmit = scheduledStartupFrames.isNotEmpty
? scheduledStartupFrames
@ -1803,6 +2033,8 @@ class _FakeTerminalSocketTransport implements TerminalSocketTransport {
final bool autoAttach;
final List<String> startupFrames;
final List<_StartupFrame> scheduledStartupFrames;
final void Function(_FakeTerminalSocketTransport transport, String message)?
onMessageSent;
final _incoming = StreamController<dynamic>.broadcast();
final sentMessages = <String>[];
final List<Timer> _frameTimers = <Timer>[];
@ -1813,6 +2045,7 @@ class _FakeTerminalSocketTransport implements TerminalSocketTransport {
@override
void send(String message) {
sentMessages.add(message);
onMessageSent?.call(this, message);
}
@override
@ -1855,10 +2088,13 @@ class _QueuedTerminalSocketTransportFactory {
_QueuedTerminalSocketTransportFactory({
this.startupFrames = const <String>[],
this.connectionStartupFrames = const <List<_StartupFrame>>[],
this.onMessageSent,
});
final List<String> startupFrames;
final List<List<_StartupFrame>> connectionStartupFrames;
final void Function(_FakeTerminalSocketTransport transport, String message)?
onMessageSent;
final createdTransports = <_FakeTerminalSocketTransport>[];
int createCount = 0;
@ -1870,6 +2106,7 @@ class _QueuedTerminalSocketTransportFactory {
autoAttach: startupFrames.isEmpty && scheduledFrames.isEmpty,
startupFrames: startupFrames,
scheduledStartupFrames: scheduledFrames,
onMessageSent: onMessageSent,
);
createdTransports.add(transport);
createCount += 1;

View File

@ -14,14 +14,48 @@ public static class SessionEndpoints
var group = endpoints.MapGroup("/api/sessions");
group.MapGet(string.Empty, (SessionRegistry registry) => Results.Ok(registry.List()));
group.MapGet("/{sessionId}/history", (
group.MapGet("/{sessionId}/history", async (
string sessionId,
int? lineCount,
SessionRegistry registry) =>
SessionRegistry registry,
CancellationToken cancellationToken) =>
{
try
{
return Results.Ok(registry.GetHistory(sessionId, lineCount ?? 200));
var history = await registry.GetHistoryAsync(
sessionId,
lineCount ?? 200,
cancellationToken).ConfigureAwait(false);
return Results.Ok(history);
}
catch (KeyNotFoundException)
{
return Results.NotFound();
}
});
group.MapGet("/{sessionId}/journal", async (
string sessionId,
long? beforeSeq,
long? afterSeq,
int? limit,
SessionRegistry registry,
CancellationToken cancellationToken) =>
{
if (beforeSeq.HasValue && afterSeq.HasValue)
{
return Results.BadRequest(new { error = "invalid_cursor" });
}
try
{
var page = await registry.GetJournalAsync(
sessionId,
beforeSeq,
afterSeq,
limit ?? 200,
cancellationToken).ConfigureAwait(false);
return Results.Ok(page);
}
catch (KeyNotFoundException)
{

View File

@ -9,6 +9,7 @@ public sealed class SessionIoJournalStore
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
private static readonly UTF8Encoding Utf8WithoutBom = new(false);
private readonly string _sessionRoot;
@ -26,10 +27,116 @@ public sealed class SessionIoJournalStore
var filePath = Path.Combine(_sessionRoot, $"{ioEvent.SessionId}.io.jsonl");
var line = JsonSerializer.Serialize(ioEvent, SerializerOptions) + Environment.NewLine;
await File.AppendAllTextAsync(
await File.AppendAllTextAsync(filePath, line, Utf8WithoutBom, cancellationToken).ConfigureAwait(false);
}
public async Task<SessionJournalPage> ReadAsync(
string sessionId,
long? beforeSequence,
long? afterSequence,
int limit,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(limit);
if (beforeSequence.HasValue && afterSequence.HasValue)
{
throw new ArgumentException("beforeSequence and afterSequence cannot both be specified.");
}
var items = await ReadAllAsync(sessionId, cancellationToken).ConfigureAwait(false);
var currentSequence = items.Count == 0 ? 0 : items[^1].Sequence;
if (afterSequence.HasValue)
{
var newerItems = items
.Where(item => item.Sequence > afterSequence.Value)
.ToList();
var pageItems = newerItems.Take(limit).ToArray();
return new SessionJournalPage(
sessionId,
pageItems,
HasMoreBefore: afterSequence.Value > 0,
HasMoreAfter: newerItems.Count > pageItems.Length,
CurrentSequence: currentSequence);
}
if (beforeSequence.HasValue)
{
var olderItems = items
.Where(item => item.Sequence < beforeSequence.Value)
.ToList();
var skipCount = Math.Max(0, olderItems.Count - limit);
var pageItems = olderItems.Skip(skipCount).ToArray();
return new SessionJournalPage(
sessionId,
pageItems,
HasMoreBefore: skipCount > 0,
HasMoreAfter: beforeSequence.Value <= currentSequence,
CurrentSequence: currentSequence);
}
var latestSkipCount = Math.Max(0, items.Count - limit);
var latestItems = items.Skip(latestSkipCount).ToArray();
return new SessionJournalPage(
sessionId,
latestItems,
HasMoreBefore: latestSkipCount > 0,
HasMoreAfter: false,
CurrentSequence: currentSequence);
}
public Task DeleteAsync(string sessionId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
cancellationToken.ThrowIfCancellationRequested();
var filePath = Path.Combine(_sessionRoot, $"{sessionId}.io.jsonl");
if (File.Exists(filePath))
{
File.Delete(filePath);
}
return Task.CompletedTask;
}
private async Task<List<SessionIoEvent>> ReadAllAsync(string sessionId, CancellationToken cancellationToken)
{
var filePath = Path.Combine(_sessionRoot, $"{sessionId}.io.jsonl");
if (!File.Exists(filePath))
{
return [];
}
var items = new List<SessionIoEvent>();
await using var stream = new FileStream(
filePath,
line,
new UTF8Encoding(false),
cancellationToken).ConfigureAwait(false);
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite,
4096,
FileOptions.Asynchronous);
using var reader = new StreamReader(stream, Utf8WithoutBom, detectEncodingFromByteOrderMarks: false);
while (!reader.EndOfStream)
{
cancellationToken.ThrowIfCancellationRequested();
var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
var item = JsonSerializer.Deserialize<SessionIoEvent>(line, SerializerOptions);
if (item is not null)
{
items.Add(item);
}
}
items.Sort(static (left, right) => left.Sequence.CompareTo(right.Sequence));
return items;
}
}

View File

@ -0,0 +1,8 @@
namespace TermRemoteCtl.Agent.History;
public sealed record SessionJournalPage(
string SessionId,
IReadOnlyList<SessionIoEvent> Items,
bool HasMoreBefore,
bool HasMoreAfter,
long CurrentSequence);

View File

@ -23,7 +23,12 @@ builder.Services.AddSingleton(serviceProvider =>
builder.Services.AddSingleton<ProjectRegistry>();
builder.Services.AddSingleton<SessionRegistry>();
builder.Services.AddSingleton<IConPtySessionFactory, ConPtySessionFactory>();
builder.Services.AddSingleton<ISessionHost, PowerShellSessionHost>();
builder.Services.AddSingleton<ISessionHost>(serviceProvider =>
{
return new PowerShellSessionHost(
serviceProvider.GetRequiredService<IConPtySessionFactory>(),
serviceProvider.GetRequiredService<SessionRegistry>());
});
builder.Services.AddSingleton<ITerminalDiagnosticsSink, LoggingTerminalDiagnosticsSink>();
builder.Services.AddSingleton(serviceProvider =>
{

View File

@ -1,12 +1,11 @@
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using TermRemoteCtl.Agent.Configuration;
using TermRemoteCtl.Agent.History;
using TermRemoteCtl.Agent.Sessions;
using TermRemoteCtl.Agent.Terminal;
using TermRemoteCtl.Agent.Configuration;
using TermRemoteCtl.Agent.Terminal.Screen;
namespace TermRemoteCtl.Agent.Realtime;
@ -44,9 +43,7 @@ public static class TerminalWebSocketHandler
}
var host = context.RequestServices.GetRequiredService<ISessionHost>();
var journalStore = context.RequestServices.GetRequiredService<SessionIoJournalStore>();
var diagnostics = context.RequestServices.GetRequiredService<ITerminalDiagnosticsSink>();
var options = context.RequestServices.GetRequiredService<IOptions<AgentOptions>>().Value;
using var socket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false);
try
{
@ -63,30 +60,58 @@ public static class TerminalWebSocketHandler
}
using var sendGate = new SemaphoreSlim(1, 1);
await using var batcher = new TerminalFrameBatcher(
TimeSpan.FromMilliseconds(options.WebSocketFrameFlushMilliseconds),
chunk => SendTextAsync(socket, chunk, sendGate, context.RequestAborted));
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))
{
batcher.Append(args.Chunk);
TerminalScreenPatch? screenPatch = null;
lock (screenStateGate)
{
var currentSnapshot = registry.GetScreenSnapshot(sessionId);
screenPatch = TerminalScreenPatch.Create(lastSentScreenSnapshot, currentSnapshot);
lastSentScreenSnapshot = currentSnapshot;
}
_ = SendOutputAndPatchAsync(socket, args, screenPatch, sendGate, context.RequestAborted);
}
}
try
{
await journalStore.AppendAsync(
new SessionIoEvent(
sessionId,
registry.NextSequence(sessionId),
"attach",
string.Empty,
DateTimeOffset.UtcNow),
context.RequestAborted).ConfigureAwait(false);
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(
@ -99,21 +124,14 @@ public static class TerminalWebSocketHandler
sendGate,
context.RequestAborted).ConfigureAwait(false);
host.OutputReceived += HandleOutput;
await ReceiveLoopAsync(context, socket, host, journalStore, diagnostics, sessionId).ConfigureAwait(false);
await ReceiveLoopAsync(context, socket, host, registry, diagnostics, sessionId, SendScreenSnapshotAsync).ConfigureAwait(false);
}
finally
{
host.OutputReceived -= HandleOutput;
try
{
await journalStore.AppendAsync(
new SessionIoEvent(
sessionId,
registry.NextSequence(sessionId),
"detach",
string.Empty,
DateTimeOffset.UtcNow),
CancellationToken.None).ConfigureAwait(false);
await registry.RecordDetachAsync(sessionId, CancellationToken.None).ConfigureAwait(false);
}
catch
{
@ -125,9 +143,10 @@ public static class TerminalWebSocketHandler
HttpContext context,
WebSocket socket,
ISessionHost host,
SessionIoJournalStore journalStore,
SessionRegistry registry,
ITerminalDiagnosticsSink diagnostics,
string sessionId)
string sessionId,
Func<CancellationToken, Task> sendScreenSnapshotAsync)
{
var buffer = new byte[4096];
@ -155,12 +174,12 @@ public static class TerminalWebSocketHandler
await HandleClientMessageAsync(
Encoding.UTF8.GetString(message.ToArray()),
context.RequestServices.GetRequiredService<SessionRegistry>(),
registry,
host,
journalStore,
diagnostics,
sessionId,
context.RequestAborted).ConfigureAwait(false);
context.RequestAborted,
sendScreenSnapshotAsync).ConfigureAwait(false);
}
}
@ -168,10 +187,10 @@ public static class TerminalWebSocketHandler
string payload,
SessionRegistry registry,
ISessionHost host,
SessionIoJournalStore journalStore,
ITerminalDiagnosticsSink diagnostics,
string sessionId,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
Func<CancellationToken, Task> sendScreenSnapshotAsync)
{
TerminalClientMessage? message;
@ -184,7 +203,10 @@ public static class TerminalWebSocketHandler
return;
}
if (message is null || !string.Equals(message.Type, "input", StringComparison.OrdinalIgnoreCase) && !string.Equals(message.Type, "resize", StringComparison.OrdinalIgnoreCase))
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))
{
if (message is not null && string.Equals(message.Type, "attach", StringComparison.OrdinalIgnoreCase))
{
@ -198,15 +220,7 @@ public static class TerminalWebSocketHandler
{
if (!string.IsNullOrEmpty(message.Input))
{
registry.RecordInputEcho(sessionId, message.Input);
await journalStore.AppendAsync(
new SessionIoEvent(
sessionId,
registry.NextSequence(sessionId),
"input",
message.Input,
DateTimeOffset.UtcNow),
cancellationToken).ConfigureAwait(false);
await registry.RecordInputAsync(sessionId, message.Input, cancellationToken).ConfigureAwait(false);
diagnostics.Record("backend.input.received", sessionId, SanitizeDiagnosticText(message.Input));
await host.WriteInputAsync(sessionId, message.Input, cancellationToken).ConfigureAwait(false);
}
@ -214,15 +228,18 @@ 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 journalStore.AppendAsync(
new SessionIoEvent(
await registry.RecordResizeAsync(
sessionId,
registry.NextSequence(sessionId),
"resize",
$"{message.Columns.Value}x{message.Rows.Value}",
DateTimeOffset.UtcNow),
message.Columns.Value,
message.Rows.Value,
cancellationToken).ConfigureAwait(false);
await host.ResizeAsync(sessionId, message.Columns.Value, message.Rows.Value, cancellationToken).ConfigureAwait(false);
}
@ -243,16 +260,6 @@ public static class TerminalWebSocketHandler
await SendAsync(socket, json, sendGate, cancellationToken).ConfigureAwait(false);
}
private static async Task SendTextAsync(
WebSocket socket,
string chunk,
SemaphoreSlim sendGate,
CancellationToken cancellationToken)
{
var bytes = Encoding.UTF8.GetBytes(chunk);
await SendAsync(socket, bytes, sendGate, cancellationToken).ConfigureAwait(false);
}
private static async Task SendAsync(
WebSocket socket,
byte[] payload,
@ -276,8 +283,76 @@ 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,
@ -287,6 +362,31 @@ public static class TerminalWebSocketHandler
int? CursorColumn,
string Type = "restore");
private sealed record TerminalOutputResponse(
string SessionId,
long Sequence,
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

@ -2,6 +2,7 @@ using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
using TermRemoteCtl.Agent.Configuration;
using TermRemoteCtl.Agent.History;
using TermRemoteCtl.Agent.Terminal.Screen;
namespace TermRemoteCtl.Agent.Sessions;
@ -12,13 +13,19 @@ public sealed class SessionRegistry
private readonly ConcurrentDictionary<string, TerminalRingBuffer> _historyBySession = new();
private readonly ConcurrentDictionary<string, TerminalReplayBuffer> _replayBySession = new();
private readonly ConcurrentDictionary<string, PendingInputEchoTracker> _pendingInputEchoBySession = new();
private readonly ConcurrentDictionary<string, TerminalScreenEngine> _screenBySession = new();
private readonly ConcurrentDictionary<string, long> _sequenceBySession = new();
private readonly SessionHistoryStore _historyStore;
private readonly SessionIoJournalStore _journalStore;
private readonly int _ringBufferLineLimit;
public SessionRegistry(SessionHistoryStore historyStore, IOptions<AgentOptions> options)
public SessionRegistry(
SessionHistoryStore historyStore,
SessionIoJournalStore journalStore,
IOptions<AgentOptions> options)
{
_historyStore = historyStore;
_journalStore = journalStore;
_ringBufferLineLimit = options.Value.RingBufferLineLimit;
}
@ -43,6 +50,7 @@ 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();
_sequenceBySession[record.SessionId] = 0;
return record;
}
@ -86,6 +94,14 @@ public sealed class SessionRegistry
string sessionId,
string chunk,
CancellationToken cancellationToken)
{
_ = await RecordOutputAsync(sessionId, chunk, cancellationToken).ConfigureAwait(false);
}
public async Task<SessionIoEvent> RecordOutputAsync(
string sessionId,
string chunk,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
ArgumentNullException.ThrowIfNull(chunk);
@ -107,12 +123,25 @@ public sealed class SessionRegistry
sessionId,
_ => new PendingInputEchoTracker());
pendingInputEcho.ObserveOutput(chunk);
NextSequence(sessionId);
_records[sessionId] = record with { UpdatedAtUtc = DateTimeOffset.UtcNow };
var screen = _screenBySession.GetOrAdd(sessionId, _ => new TerminalScreenEngine());
var updatedAtUtc = DateTimeOffset.UtcNow;
var ioEvent = new SessionIoEvent(
sessionId,
AdvanceSequence(sessionId),
"output",
chunk,
updatedAtUtc);
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);
return ioEvent;
}
public void RecordInputEcho(string sessionId, string input)
public async Task<SessionIoEvent> RecordInputAsync(
string sessionId,
string input,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
ArgumentNullException.ThrowIfNull(input);
@ -126,11 +155,61 @@ public sealed class SessionRegistry
sessionId,
_ => new PendingInputEchoTracker());
pendingInputEcho.Record(input);
NextSequence(sessionId);
_records[sessionId] = record with { UpdatedAtUtc = DateTimeOffset.UtcNow };
var updatedAtUtc = DateTimeOffset.UtcNow;
var ioEvent = new SessionIoEvent(
sessionId,
AdvanceSequence(sessionId),
"input",
input,
updatedAtUtc);
_records[sessionId] = record with { UpdatedAtUtc = updatedAtUtc };
await _journalStore.AppendAsync(ioEvent, cancellationToken).ConfigureAwait(false);
return ioEvent;
}
public SessionHistorySnapshot GetHistory(string sessionId, int lineCount)
public async Task<SessionIoEvent> RecordResizeAsync(
string sessionId,
int columns,
int rows,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(columns);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(rows);
if (!_records.TryGetValue(sessionId, out var record))
{
throw new KeyNotFoundException($"Session '{sessionId}' was not found.");
}
var updatedAtUtc = DateTimeOffset.UtcNow;
var ioEvent = new SessionIoEvent(
sessionId,
AdvanceSequence(sessionId),
"resize",
$"{columns}x{rows}",
updatedAtUtc);
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;
}
public async Task<SessionIoEvent> RecordAttachAsync(string sessionId, CancellationToken cancellationToken)
{
return await RecordLifecycleAsync(sessionId, "attach", cancellationToken).ConfigureAwait(false);
}
public async Task<SessionIoEvent> RecordDetachAsync(string sessionId, CancellationToken cancellationToken)
{
return await RecordLifecycleAsync(sessionId, "detach", cancellationToken).ConfigureAwait(false);
}
public async Task<SessionHistorySnapshot> GetHistoryAsync(
string sessionId,
int lineCount,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(lineCount);
@ -140,11 +219,17 @@ public sealed class SessionRegistry
throw new KeyNotFoundException($"Session '{sessionId}' was not found.");
}
var history = _historyBySession.GetOrAdd(
var journalPage = await _journalStore.ReadAsync(
sessionId,
_ => new TerminalRingBuffer(_ringBufferLineLimit));
var lines = history.GetSnapshotLines();
var skipCount = Math.Max(0, lines.Count - lineCount);
beforeSequence: null,
afterSequence: null,
limit: int.MaxValue,
cancellationToken).ConfigureAwait(false);
var lines = journalPage.Items
.Where(static item => string.Equals(item.Kind, "output", StringComparison.Ordinal))
.SelectMany(static item => SplitOutputLines(item.Payload))
.ToArray();
var skipCount = Math.Max(0, lines.Length - lineCount);
return new SessionHistorySnapshot(
sessionId,
@ -152,6 +237,24 @@ public sealed class SessionRegistry
skipCount > 0);
}
public Task<SessionJournalPage> GetJournalAsync(
string sessionId,
long? beforeSequence,
long? afterSequence,
int limit,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(limit);
if (!_records.ContainsKey(sessionId))
{
throw new KeyNotFoundException($"Session '{sessionId}' was not found.");
}
return _journalStore.ReadAsync(sessionId, beforeSequence, afterSequence, limit, cancellationToken);
}
public string GetReplaySnapshot(string sessionId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
@ -185,7 +288,7 @@ public sealed class SessionRegistry
var pendingInputEcho = _pendingInputEchoBySession.GetOrAdd(
sessionId,
_ => new PendingInputEchoTracker());
var sequence = _sequenceBySession.GetOrAdd(sessionId, 1);
var sequence = GetCurrentSequence(sessionId);
return new SessionRestoreSnapshot(
sessionId,
@ -196,7 +299,7 @@ public sealed class SessionRegistry
null);
}
public long NextSequence(string sessionId)
public TerminalScreenSnapshot GetScreenSnapshot(string sessionId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
@ -205,7 +308,20 @@ public sealed class SessionRegistry
throw new KeyNotFoundException($"Session '{sessionId}' was not found.");
}
return _sequenceBySession.AddOrUpdate(sessionId, 1, static (_, current) => current + 1);
var screen = _screenBySession.GetOrAdd(sessionId, _ => new TerminalScreenEngine());
return screen.CreateSnapshot(sessionId);
}
public long GetCurrentSequence(string sessionId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
if (!_records.ContainsKey(sessionId))
{
throw new KeyNotFoundException($"Session '{sessionId}' was not found.");
}
return _sequenceBySession.GetOrAdd(sessionId, 0);
}
public async Task DeleteAsync(string sessionId, CancellationToken cancellationToken)
@ -220,7 +336,49 @@ public sealed class SessionRegistry
_historyBySession.TryRemove(sessionId, out _);
_replayBySession.TryRemove(sessionId, out _);
_pendingInputEchoBySession.TryRemove(sessionId, out _);
_screenBySession.TryRemove(sessionId, out _);
_sequenceBySession.TryRemove(sessionId, out _);
await _historyStore.DeleteAsync(sessionId, cancellationToken).ConfigureAwait(false);
await _journalStore.DeleteAsync(sessionId, cancellationToken).ConfigureAwait(false);
}
private async Task<SessionIoEvent> RecordLifecycleAsync(
string sessionId,
string kind,
CancellationToken cancellationToken)
{
if (!_records.TryGetValue(sessionId, out var record))
{
throw new KeyNotFoundException($"Session '{sessionId}' was not found.");
}
var updatedAtUtc = DateTimeOffset.UtcNow;
var ioEvent = new SessionIoEvent(
sessionId,
AdvanceSequence(sessionId),
kind,
string.Empty,
updatedAtUtc);
_records[sessionId] = record with { UpdatedAtUtc = updatedAtUtc };
await _journalStore.AppendAsync(ioEvent, cancellationToken).ConfigureAwait(false);
return ioEvent;
}
private long AdvanceSequence(string sessionId)
{
return _sequenceBySession.AddOrUpdate(sessionId, 1, static (_, current) => current + 1);
}
private static IEnumerable<string> SplitOutputLines(string payload)
{
var normalized = payload.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace('\r', '\n');
var parts = normalized.Split('\n');
if (parts.Length > 0 && parts[^1].Length == 0)
{
return parts.Take(parts.Length - 1);
}
return parts;
}
}

View File

@ -15,14 +15,17 @@ public interface ISessionHost
public sealed class TerminalOutputEventArgs : EventArgs
{
public TerminalOutputEventArgs(string sessionId, string chunk)
public TerminalOutputEventArgs(string sessionId, string chunk, long sequence = 0)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
Chunk = chunk ?? throw new ArgumentNullException(nameof(chunk));
SessionId = sessionId;
Sequence = sequence;
}
public string SessionId { get; }
public string Chunk { get; }
public long Sequence { get; }
}

View File

@ -1,6 +1,6 @@
using System.Collections.Concurrent;
using System.Runtime.Versioning;
using TermRemoteCtl.Agent.History;
using System.Threading.Channels;
using TermRemoteCtl.Agent.Sessions;
namespace TermRemoteCtl.Agent.Terminal;
@ -10,17 +10,23 @@ internal sealed class PowerShellSessionHost : ISessionHost, IAsyncDisposable
{
private readonly IConPtySessionFactory _sessionFactory;
private readonly SessionRegistry _sessionRegistry;
private readonly SessionIoJournalStore _journalStore;
private readonly ConcurrentDictionary<string, IConPtySession> _sessions = new(StringComparer.Ordinal);
private readonly Channel<(string SessionId, string Chunk)> _outputChannel = Channel.CreateUnbounded<(string SessionId, string Chunk)>(
new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false,
});
private readonly CancellationTokenSource _disposeCancellation = new();
private readonly Task _outputPump;
public PowerShellSessionHost(
IConPtySessionFactory sessionFactory,
SessionRegistry sessionRegistry,
SessionIoJournalStore journalStore)
SessionRegistry sessionRegistry)
{
_sessionFactory = sessionFactory;
_sessionRegistry = sessionRegistry;
_journalStore = journalStore;
_outputPump = Task.Run(() => PumpOutputAsync(_disposeCancellation.Token));
}
public event EventHandler<TerminalOutputEventArgs>? OutputReceived;
@ -112,6 +118,9 @@ internal sealed class PowerShellSessionHost : ISessionHost, IAsyncDisposable
public async ValueTask DisposeAsync()
{
_outputChannel.Writer.TryComplete();
_disposeCancellation.Cancel();
foreach (var session in _sessions.Values)
{
session.OutputReceived -= HandleSessionOutput;
@ -119,19 +128,35 @@ internal sealed class PowerShellSessionHost : ISessionHost, IAsyncDisposable
}
_sessions.Clear();
try
{
await _outputPump.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
}
_disposeCancellation.Dispose();
}
private void HandleSessionOutput(object? sender, TerminalOutputEventArgs args)
{
_ = _sessionRegistry.AppendOutputAsync(args.SessionId, args.Chunk, CancellationToken.None);
_ = _journalStore.AppendAsync(
new SessionIoEvent(
args.SessionId,
_sessionRegistry.NextSequence(args.SessionId),
"output",
args.Chunk,
DateTimeOffset.UtcNow),
CancellationToken.None);
OutputReceived?.Invoke(this, args);
_outputChannel.Writer.TryWrite((args.SessionId, args.Chunk));
}
private async Task PublishOutputAsync(string sessionId, string chunk)
{
var ioEvent = await _sessionRegistry.RecordOutputAsync(
sessionId,
chunk,
CancellationToken.None).ConfigureAwait(false);
OutputReceived?.Invoke(this, new TerminalOutputEventArgs(sessionId, chunk, ioEvent.Sequence));
}
private async Task PumpOutputAsync(CancellationToken cancellationToken)
{
await foreach (var output in _outputChannel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
await PublishOutputAsync(output.SessionId, output.Chunk).ConfigureAwait(false);
}
}
}

View File

@ -0,0 +1,354 @@
using System.Text;
namespace TermRemoteCtl.Agent.Terminal.Screen;
public sealed class TerminalScreenEngine
{
private readonly object _gate = new();
private char[,] _primaryCells;
private char[,] _alternateCells;
private int _rows;
private int _columns;
private int _primaryCursorRow;
private int _primaryCursorColumn;
private int _alternateCursorRow;
private int _alternateCursorColumn;
private bool _alternateActive;
private long _screenVersion;
private long _sourceSequence;
public TerminalScreenEngine(int rows = 24, int cols = 80)
{
_rows = Math.Max(1, rows);
_columns = Math.Max(1, cols);
_primaryCells = CreateCells(_rows, _columns);
_alternateCells = CreateCells(_rows, _columns);
}
public void Resize(int columns, int rows)
{
lock (_gate)
{
var nextRows = Math.Max(1, rows);
var nextColumns = Math.Max(1, columns);
if (nextRows == _rows && nextColumns == _columns)
{
return;
}
var nextPrimaryCells = ResizeCells(_primaryCells, _rows, _columns, nextRows, nextColumns);
var nextAlternateCells = ResizeCells(_alternateCells, _rows, _columns, nextRows, nextColumns);
_rows = nextRows;
_columns = nextColumns;
_primaryCells = nextPrimaryCells;
_alternateCells = nextAlternateCells;
_primaryCursorRow = Math.Min(_primaryCursorRow, _rows - 1);
_primaryCursorColumn = Math.Min(_primaryCursorColumn, _columns - 1);
_alternateCursorRow = Math.Min(_alternateCursorRow, _rows - 1);
_alternateCursorColumn = Math.Min(_alternateCursorColumn, _columns - 1);
_screenVersion += 1;
}
}
public void ApplyOutput(string chunk, long sourceSequence)
{
ArgumentNullException.ThrowIfNull(chunk);
lock (_gate)
{
var changed = false;
for (var index = 0; index < chunk.Length; index += 1)
{
var current = chunk[index];
if (current == '\u001b')
{
changed |= TryApplyEscape(chunk, ref index);
continue;
}
switch (current)
{
case '\r':
SetCursor(column: 0);
changed = true;
break;
case '\n':
AdvanceLine();
changed = true;
break;
case '\b':
if (GetCursorColumn() > 0)
{
SetCursor(column: GetCursorColumn() - 1);
changed = true;
}
break;
default:
if (!char.IsControl(current))
{
WriteCharacter(current);
changed = true;
}
break;
}
}
_sourceSequence = Math.Max(_sourceSequence, sourceSequence);
if (changed)
{
_screenVersion += 1;
}
}
}
public TerminalScreenSnapshot CreateSnapshot(string sessionId)
{
lock (_gate)
{
var lines = new List<TerminalScreenLineSnapshot>(_rows);
for (var row = 0; row < _rows; row += 1)
{
lines.Add(new TerminalScreenLineSnapshot(row, BuildLine(_primaryCells, row)));
}
var alternateLines = new List<TerminalScreenLineSnapshot>(_rows);
for (var row = 0; row < _rows; row += 1)
{
alternateLines.Add(new TerminalScreenLineSnapshot(row, BuildLine(_alternateCells, row)));
}
return new TerminalScreenSnapshot(
sessionId,
_screenVersion,
_sourceSequence,
_rows,
_columns,
ActiveCursorRow,
ActiveCursorColumn,
CursorVisible: true,
ActiveBuffer: _alternateActive ? "alternate" : "primary",
PrimaryBuffer: new TerminalScreenBufferSnapshot(lines),
AlternateBuffer: new TerminalScreenBufferSnapshot(alternateLines));
}
}
private int ActiveCursorRow
=> _alternateActive ? _alternateCursorRow : _primaryCursorRow;
private int ActiveCursorColumn
=> _alternateActive ? _alternateCursorColumn : _primaryCursorColumn;
private char[,] CurrentCells
=> _alternateActive ? _alternateCells : _primaryCells;
private static char[,] CreateCells(int rows, int columns)
{
var cells = new char[rows, columns];
for (var row = 0; row < rows; row += 1)
{
for (var column = 0; column < columns; column += 1)
{
cells[row, column] = ' ';
}
}
return cells;
}
private static char[,] ResizeCells(char[,] source, int sourceRows, int sourceColumns, int targetRows, int targetColumns)
{
var target = CreateCells(targetRows, targetColumns);
var copyRows = Math.Min(sourceRows, targetRows);
var copyColumns = Math.Min(sourceColumns, targetColumns);
for (var row = 0; row < copyRows; row += 1)
{
for (var column = 0; column < copyColumns; column += 1)
{
target[row, column] = source[row, column];
}
}
return target;
}
private string BuildLine(char[,] cells, int row)
{
var builder = new StringBuilder(_columns);
for (var column = 0; column < _columns; column += 1)
{
builder.Append(cells[row, column]);
}
return builder.ToString();
}
private void WriteCharacter(char value)
{
if (GetCursorColumn() >= _columns)
{
AdvanceLine();
}
CurrentCells[GetCursorRow(), GetCursorColumn()] = value;
SetCursor(column: GetCursorColumn() + 1);
if (GetCursorColumn() >= _columns)
{
AdvanceLine();
}
}
private void AdvanceLine()
{
var nextRow = GetCursorRow();
if (nextRow < _rows - 1)
{
nextRow += 1;
}
SetCursor(nextRow, 0);
}
private bool TryApplyEscape(string chunk, ref int index)
{
if (index + 1 >= chunk.Length || chunk[index + 1] != '[')
{
return false;
}
var commandIndex = index + 2;
var parameters = new StringBuilder();
while (commandIndex < chunk.Length)
{
var current = chunk[commandIndex];
if (current >= '@' && current <= '~')
{
index = commandIndex;
return ApplyCsi(parameters.ToString(), current);
}
parameters.Append(current);
commandIndex += 1;
}
index = chunk.Length - 1;
return false;
}
private bool ApplyCsi(string parameterText, char command)
{
switch (command)
{
case 'H':
case 'f':
ApplyCursorPosition(parameterText);
return true;
case 'C':
SetCursor(column: Math.Min(_columns - 1, GetCursorColumn() + ParseSingle(parameterText, 1)));
return true;
case 'D':
SetCursor(column: Math.Max(0, GetCursorColumn() - ParseSingle(parameterText, 1)));
return true;
case 'K':
ClearLineFromCursor();
return true;
case 'J':
if (ParseSingle(parameterText, 0) == 2)
{
ClearAll();
return true;
}
break;
case 'h':
if (IsAlternateBufferEnable(parameterText))
{
_alternateActive = true;
_alternateCells = CreateCells(_rows, _columns);
_alternateCursorRow = 0;
_alternateCursorColumn = 0;
return true;
}
break;
case 'l':
if (IsAlternateBufferDisable(parameterText))
{
_alternateActive = false;
return true;
}
break;
}
return false;
}
private void ApplyCursorPosition(string parameterText)
{
var parts = parameterText.Split(';');
var row = parts.Length > 0 ? ParseSingle(parts[0], 1) : 1;
var column = parts.Length > 1 ? ParseSingle(parts[1], 1) : 1;
SetCursor(
Math.Clamp(row - 1, 0, _rows - 1),
Math.Clamp(column - 1, 0, _columns - 1));
}
private void ClearLineFromCursor()
{
for (var column = GetCursorColumn(); column < _columns; column += 1)
{
CurrentCells[GetCursorRow(), column] = ' ';
}
}
private void ClearAll()
{
for (var row = 0; row < _rows; row += 1)
{
for (var column = 0; column < _columns; column += 1)
{
CurrentCells[row, column] = ' ';
}
}
SetCursor(0, 0);
}
private int GetCursorRow()
{
return _alternateActive ? _alternateCursorRow : _primaryCursorRow;
}
private int GetCursorColumn()
{
return _alternateActive ? _alternateCursorColumn : _primaryCursorColumn;
}
private void SetCursor(int? row = null, int? column = null)
{
if (_alternateActive)
{
_alternateCursorRow = row ?? _alternateCursorRow;
_alternateCursorColumn = column ?? _alternateCursorColumn;
return;
}
_primaryCursorRow = row ?? _primaryCursorRow;
_primaryCursorColumn = column ?? _primaryCursorColumn;
}
private static int ParseSingle(string value, int fallback)
{
return int.TryParse(value, out var parsed) && parsed > 0 ? parsed : fallback;
}
private static bool IsAlternateBufferEnable(string parameterText)
{
return string.Equals(parameterText, "?1049", StringComparison.Ordinal) ||
string.Equals(parameterText, "?1047", StringComparison.Ordinal) ||
string.Equals(parameterText, "?47", StringComparison.Ordinal);
}
private static bool IsAlternateBufferDisable(string parameterText)
{
return IsAlternateBufferEnable(parameterText);
}
}

View File

@ -0,0 +1,129 @@
namespace TermRemoteCtl.Agent.Terminal.Screen;
public sealed record TerminalScreenPatch(
string SessionId,
long BaseScreenVersion,
long ScreenVersion,
long SourceSequence,
int Rows,
int Columns,
int CursorRow,
int CursorColumn,
bool CursorVisible,
string ActiveBuffer,
IReadOnlyList<TerminalScreenPatchOperation> Operations)
{
public static TerminalScreenPatch? Create(
TerminalScreenSnapshot previous,
TerminalScreenSnapshot current)
{
ArgumentNullException.ThrowIfNull(previous);
ArgumentNullException.ThrowIfNull(current);
var operations = BuildOperations(previous, current);
if (operations.Count == 0 &&
string.Equals(previous.ActiveBuffer, current.ActiveBuffer, StringComparison.Ordinal) &&
previous.CursorRow == current.CursorRow &&
previous.CursorColumn == current.CursorColumn &&
previous.CursorVisible == current.CursorVisible &&
previous.Rows == current.Rows &&
previous.Columns == current.Columns)
{
return null;
}
return new TerminalScreenPatch(
current.SessionId,
previous.ScreenVersion,
current.ScreenVersion,
current.SourceSequence,
current.Rows,
current.Columns,
current.CursorRow,
current.CursorColumn,
current.CursorVisible,
current.ActiveBuffer,
operations);
}
private static IReadOnlyList<TerminalScreenPatchOperation> BuildOperations(
TerminalScreenSnapshot previous,
TerminalScreenSnapshot current)
{
var previousLines = GetActiveViewport(previous);
var currentLines = GetActiveViewport(current);
var maxRows = Math.Max(previousLines.Count, currentLines.Count);
var operations = new List<TerminalScreenPatchOperation>();
List<string>? pendingLines = null;
var pendingStart = 0;
for (var row = 0; row < maxRows; row += 1)
{
var before = row < previousLines.Count
? NormalizeLine(previousLines[row].Text, row, previous.CursorRow, previous.CursorColumn)
: string.Empty;
var after = row < currentLines.Count
? NormalizeLine(currentLines[row].Text, row, current.CursorRow, current.CursorColumn)
: string.Empty;
var changed = !string.Equals(before, after, StringComparison.Ordinal);
if (!changed)
{
FlushPending(operations, pendingLines, pendingStart);
pendingLines = null;
continue;
}
if (pendingLines is null)
{
pendingStart = row;
pendingLines = new List<string>();
}
pendingLines.Add(after);
}
FlushPending(operations, pendingLines, pendingStart);
return operations;
}
private static IReadOnlyList<TerminalScreenLineSnapshot> GetActiveViewport(TerminalScreenSnapshot snapshot)
{
return string.Equals(snapshot.ActiveBuffer, "alternate", StringComparison.Ordinal)
? snapshot.AlternateBuffer?.Viewport ?? []
: snapshot.PrimaryBuffer.Viewport;
}
private static void FlushPending(
ICollection<TerminalScreenPatchOperation> operations,
List<string>? pendingLines,
int pendingStart)
{
if (pendingLines is null || pendingLines.Count == 0)
{
return;
}
operations.Add(new TerminalScreenPatchOperation(
"replace_lines",
pendingStart,
pendingLines.ToArray()));
}
private static string NormalizeLine(string text, int row, int cursorRow, int cursorColumn)
{
var trimmed = text.TrimEnd(' ');
if (row != cursorRow)
{
return trimmed;
}
var visibleLength = Math.Max(trimmed.Length, Math.Clamp(cursorColumn, 0, text.Length));
return text[..visibleLength];
}
}
public sealed record TerminalScreenPatchOperation(
string Type,
int StartRow,
IReadOnlyList<string> Lines);

View File

@ -0,0 +1,21 @@
namespace TermRemoteCtl.Agent.Terminal.Screen;
public sealed record TerminalScreenSnapshot(
string SessionId,
long ScreenVersion,
long SourceSequence,
int Rows,
int Columns,
int CursorRow,
int CursorColumn,
bool CursorVisible,
string ActiveBuffer,
TerminalScreenBufferSnapshot PrimaryBuffer,
TerminalScreenBufferSnapshot? AlternateBuffer);
public sealed record TerminalScreenBufferSnapshot(
IReadOnlyList<TerminalScreenLineSnapshot> Viewport);
public sealed record TerminalScreenLineSnapshot(
int Index,
string Text);

View File

@ -32,6 +32,7 @@ public sealed class TerminalSmokeCheckTests
Assert.NotNull(attachedPayload);
Assert.Equal("attached", attachedPayload!.Type);
Assert.Equal(session.SessionId, attachedPayload.SessionId);
_ = await fixture.ReceiveTextAsync(socket, TimeSpan.FromSeconds(20));
await fixture.SendTextAsync(socket, JsonSerializer.Serialize(new { type = "input", input = "Write-Output smoke\r" }));
var output = await fixture.ReceiveTextContainingAsync(socket, "smoke", TimeSpan.FromSeconds(20));
@ -61,6 +62,7 @@ public sealed class TerminalSmokeCheckTests
using (var firstSocket = await fixture.ConnectTerminalAsync(session.SessionId))
{
_ = await fixture.ReceiveTextAsync(firstSocket, TimeSpan.FromSeconds(20));
_ = await fixture.ReceiveTextAsync(firstSocket, TimeSpan.FromSeconds(20));
await fixture.SendTextAsync(firstSocket, JsonSerializer.Serialize(new { type = "input", input = "Write-Output first\r" }));
_ = await fixture.ReceiveTextContainingAsync(firstSocket, "first", TimeSpan.FromSeconds(20));
@ -72,6 +74,7 @@ public sealed class TerminalSmokeCheckTests
Assert.NotNull(attachedPayload);
Assert.Equal("attached", attachedPayload!.Type);
Assert.Equal(session.SessionId, attachedPayload.SessionId);
_ = await fixture.ReceiveTextAsync(secondSocket, TimeSpan.FromSeconds(20));
await fixture.SendTextAsync(secondSocket, JsonSerializer.Serialize(new { type = "input", input = "Write-Output second\r" }));
var output = await fixture.ReceiveTextContainingAsync(secondSocket, "second", TimeSpan.FromSeconds(20));

View File

@ -19,6 +19,7 @@ public sealed class TerminalWebSocketHandlerTests
{
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(
@ -34,6 +35,18 @@ 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,
@ -42,12 +55,55 @@ public sealed class TerminalWebSocketHandlerTests
Assert.NotNull(restorePayload);
Assert.Equal("restore", restorePayload!.Type);
Assert.Equal(session.SessionId, restorePayload.SessionId);
Assert.Equal(1L, restorePayload.Sequence);
fixture.TerminalHost.EmitOutput(session.SessionId, "abc");
fixture.TerminalHost.EmitOutput(session.SessionId, "def");
var outputFrame = await ReceiveTextAsync(socket, CancellationToken.None);
Assert.Equal("abcdef", outputFrame);
var firstOutputFrame = await ReceiveTextAsync(socket, CancellationToken.None);
var firstOutputPayload = JsonSerializer.Deserialize<TerminalOutputResponse>(
firstOutputFrame,
new JsonSerializerOptions(JsonSerializerDefaults.Web));
Assert.NotNull(firstOutputPayload);
Assert.Equal("output", firstOutputPayload!.Type);
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,
new JsonSerializerOptions(JsonSerializerDefaults.Web));
Assert.NotNull(secondOutputPayload);
Assert.Equal("output", secondOutputPayload!.Type);
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);
@ -63,6 +119,7 @@ public sealed class TerminalWebSocketHandlerTests
{
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.AppendOutputAsync(session.SessionId, "prompt> dir\r\nnext> ", CancellationToken.None);
@ -78,6 +135,19 @@ 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,
@ -87,6 +157,7 @@ public sealed class TerminalWebSocketHandlerTests
Assert.Equal("restore", restorePayload!.Type);
Assert.Equal("prompt> dir\r\nnext> ", restorePayload.ScreenText);
Assert.Equal(string.Empty, restorePayload.PendingInput);
Assert.Equal(2L, restorePayload.Sequence);
}
[Fact]
@ -111,6 +182,18 @@ 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,
@ -119,9 +202,26 @@ public sealed class TerminalWebSocketHandlerTests
Assert.NotNull(restorePayload);
Assert.Equal("prompt> dir\r\n", restorePayload!.ScreenText);
Assert.Equal(string.Empty, restorePayload.PendingInput);
Assert.Equal(2L, restorePayload.Sequence);
var liveFrame = await ReceiveTextAsync(socket, CancellationToken.None);
Assert.Equal("next> ", liveFrame);
var livePayload = JsonSerializer.Deserialize<TerminalOutputResponse>(
liveFrame,
new JsonSerializerOptions(JsonSerializerDefaults.Web));
Assert.NotNull(livePayload);
Assert.Equal("output", livePayload!.Type);
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));
}
@ -131,6 +231,7 @@ public sealed class TerminalWebSocketHandlerTests
{
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(
@ -149,6 +250,7 @@ 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>(
@ -158,6 +260,7 @@ public sealed class TerminalWebSocketHandlerTests
Assert.NotNull(restorePayload);
Assert.Equal("dir", restorePayload!.PendingInput);
Assert.Equal(string.Empty, restorePayload.ScreenText);
Assert.Equal(4L, restorePayload.Sequence);
}
[Fact]
@ -165,19 +268,160 @@ public sealed class TerminalWebSocketHandlerTests
{
await using var fixture = new TerminalApiFixture();
var registry = fixture.Services.GetRequiredService<SessionRegistry>();
fixture.TerminalHost.Registry = registry;
var session = registry.Create("Shell", DateTimeOffset.UtcNow);
registry.RecordInputEcho(session.SessionId, "dir");
await registry.RecordInputAsync(session.SessionId, "dir", 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);
var restoreFrame = await ReceiveTextAsync(socket, CancellationToken.None);
Assert.Contains("\"type\":\"restore\"", restoreFrame);
Assert.Contains("\"pendingInput\":\"dir\"", restoreFrame);
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)
@ -354,8 +598,10 @@ public sealed class TerminalWebSocketHandlerTests
public void EmitOutput(string sessionId, string chunk)
{
Registry?.AppendOutputAsync(sessionId, chunk, CancellationToken.None).GetAwaiter().GetResult();
_outputReceived?.Invoke(this, new TerminalOutputEventArgs(sessionId, chunk));
var ioEvent = Registry?.RecordOutputAsync(sessionId, chunk, CancellationToken.None).GetAwaiter().GetResult();
_outputReceived?.Invoke(
this,
new TerminalOutputEventArgs(sessionId, chunk, ioEvent?.Sequence ?? 0));
}
}
@ -369,4 +615,50 @@ public sealed class TerminalWebSocketHandlerTests
int? CursorRow,
int? CursorColumn,
string Type);
private sealed record TerminalOutputResponse(
string SessionId,
long Sequence,
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

@ -56,7 +56,13 @@ public sealed class SessionFlowTests
var replayedSessions = await client.GetFromJsonAsync<List<SessionResponse>>("/api/sessions", JsonOptions);
Assert.NotNull(replayedSessions);
Assert.Contains(replayedSessions!, session => session.SessionId == initialSnapshot.SessionId && session.Name == initialSnapshot.Name && session.Status == initialSnapshot.Status && session.CreatedAtUtc == initialSnapshot.CreatedAtUtc && session.UpdatedAtUtc == initialSnapshot.UpdatedAtUtc);
Assert.Contains(
replayedSessions!,
session => session.SessionId == initialSnapshot.SessionId &&
session.Name == initialSnapshot.Name &&
session.Status == initialSnapshot.Status &&
session.CreatedAtUtc == initialSnapshot.CreatedAtUtc &&
session.UpdatedAtUtc >= initialSnapshot.UpdatedAtUtc);
Assert.Equal(1, fixture.SessionHost.StartCountFor(initialSnapshot.SessionId));
}

View File

@ -33,6 +33,55 @@ public sealed class SessionHistoryApiTests
Assert.True(response.HasMoreAbove);
}
[Fact]
public async Task GetJournal_Returns_Persistent_Ordered_Items_With_Input_And_Output()
{
await using var fixture = new AgentFixture();
using var client = fixture.CreateClient();
var registry = fixture.Services.GetRequiredService<SessionRegistry>();
var session = registry.Create("codex-main", DateTimeOffset.UtcNow);
await registry.RecordAttachAsync(session.SessionId, CancellationToken.None);
await registry.RecordInputAsync(session.SessionId, "git status\r", CancellationToken.None);
await registry.RecordOutputAsync(session.SessionId, "PS> git status\r\n", CancellationToken.None);
var response = await client.GetFromJsonAsync<SessionJournalResponse>(
$"/api/sessions/{session.SessionId}/journal?limit=10",
JsonOptions);
Assert.NotNull(response);
Assert.Equal(session.SessionId, response!.SessionId);
Assert.Equal(["attach", "input", "output"], response.Items.Select(item => item.Kind).ToArray());
Assert.Equal([1L, 2L, 3L], response.Items.Select(item => item.Sequence).ToArray());
Assert.Equal(3L, response.CurrentSequence);
Assert.False(response.HasMoreBefore);
Assert.False(response.HasMoreAfter);
}
[Fact]
public async Task GetJournal_Can_Page_Older_Items_With_BeforeSequence()
{
await using var fixture = new AgentFixture();
using var client = fixture.CreateClient();
var registry = fixture.Services.GetRequiredService<SessionRegistry>();
var session = registry.Create("codex-main", DateTimeOffset.UtcNow);
await registry.RecordInputAsync(session.SessionId, "first", CancellationToken.None);
await registry.RecordOutputAsync(session.SessionId, "first\r\n", CancellationToken.None);
await registry.RecordInputAsync(session.SessionId, "second", CancellationToken.None);
await registry.RecordOutputAsync(session.SessionId, "second\r\n", CancellationToken.None);
var response = await client.GetFromJsonAsync<SessionJournalResponse>(
$"/api/sessions/{session.SessionId}/journal?beforeSeq=4&limit=2",
JsonOptions);
Assert.NotNull(response);
Assert.Equal([2L, 3L], response!.Items.Select(item => item.Sequence).ToArray());
Assert.True(response.HasMoreBefore);
Assert.True(response.HasMoreAfter);
Assert.Equal(4L, response.CurrentSequence);
}
private sealed class AgentFixture : WebApplicationFactory<Program>
{
private readonly string _dataRoot = Path.Combine(Path.GetTempPath(), "TermRemoteCtl.Tests", Guid.NewGuid().ToString("N"));
@ -64,4 +113,18 @@ public sealed class SessionHistoryApiTests
}
private sealed record SessionHistoryResponse(string SessionId, IReadOnlyList<string> Lines, bool HasMoreAbove);
private sealed record SessionJournalResponse(
string SessionId,
IReadOnlyList<SessionJournalItemResponse> Items,
bool HasMoreBefore,
bool HasMoreAfter,
long CurrentSequence);
private sealed record SessionJournalItemResponse(
string SessionId,
long Sequence,
string Kind,
string Payload,
DateTimeOffset TimestampUtc);
}

View File

@ -47,7 +47,7 @@ public class SessionRegistryTests
await registry.AppendOutputAsync(session.SessionId, "one\ntwo\nthree\n", CancellationToken.None);
var history = registry.GetHistory(session.SessionId, 2);
var history = await registry.GetHistoryAsync(session.SessionId, 2, CancellationToken.None);
Assert.Equal(["two", "three"], history.Lines);
Assert.True(history.HasMoreAbove);
@ -105,13 +105,64 @@ public class SessionRegistryTests
}
[Fact]
public void RecordInputEcho_Includes_Visible_User_Input_In_Replay_Snapshot()
public async Task GetJournalAsync_Returns_Durable_Events_In_Sequence_Order()
{
using var harness = SessionRegistryHarness.Create();
var session = harness.Registry.Create("Shell", DateTimeOffset.UtcNow);
await harness.Registry.RecordAttachAsync(session.SessionId, CancellationToken.None);
await harness.Registry.RecordInputAsync(session.SessionId, "git status\r", CancellationToken.None);
await harness.Registry.RecordOutputAsync(session.SessionId, "PS> git status\r\n", CancellationToken.None);
await harness.Registry.RecordResizeAsync(session.SessionId, 120, 32, CancellationToken.None);
await harness.Registry.RecordDetachAsync(session.SessionId, CancellationToken.None);
var page = await harness.Registry.GetJournalAsync(
session.SessionId,
beforeSequence: null,
afterSequence: null,
limit: 10,
CancellationToken.None);
Assert.Equal(session.SessionId, page.SessionId);
Assert.Equal(["attach", "input", "output", "resize", "detach"], page.Items.Select(item => item.Kind).ToArray());
Assert.Equal([1L, 2L, 3L, 4L, 5L], page.Items.Select(item => item.Sequence).ToArray());
Assert.Equal(5L, page.CurrentSequence);
Assert.False(page.HasMoreBefore);
Assert.False(page.HasMoreAfter);
}
[Fact]
public async Task GetJournalAsync_Can_Page_Backward_From_A_Sequence_Cursor()
{
using var harness = SessionRegistryHarness.Create();
var session = harness.Registry.Create("Shell", DateTimeOffset.UtcNow);
await harness.Registry.RecordInputAsync(session.SessionId, "first", CancellationToken.None);
await harness.Registry.RecordOutputAsync(session.SessionId, "first\r\n", CancellationToken.None);
await harness.Registry.RecordInputAsync(session.SessionId, "second", CancellationToken.None);
await harness.Registry.RecordOutputAsync(session.SessionId, "second\r\n", CancellationToken.None);
var page = await harness.Registry.GetJournalAsync(
session.SessionId,
beforeSequence: 4,
afterSequence: null,
limit: 2,
CancellationToken.None);
Assert.Equal([2L, 3L], page.Items.Select(item => item.Sequence).ToArray());
Assert.Equal(["output", "input"], page.Items.Select(item => item.Kind).ToArray());
Assert.True(page.HasMoreBefore);
Assert.True(page.HasMoreAfter);
}
[Fact]
public async Task RecordInputEcho_Includes_Visible_User_Input_In_Replay_Snapshot()
{
using var harness = SessionRegistryHarness.Create();
var registry = harness.Registry;
var session = registry.Create("Shell", DateTimeOffset.UtcNow);
registry.RecordInputEcho(session.SessionId, "dir");
await registry.RecordInputAsync(session.SessionId, "dir", CancellationToken.None);
var replay = registry.GetReplaySnapshot(session.SessionId);
@ -125,7 +176,7 @@ public class SessionRegistryTests
var registry = harness.Registry;
var session = registry.Create("Shell", DateTimeOffset.UtcNow);
registry.RecordInputEcho(session.SessionId, "dir\r");
await registry.RecordInputAsync(session.SessionId, "dir\r", CancellationToken.None);
await registry.AppendOutputAsync(session.SessionId, "prompt> dir\r\nnext> ", CancellationToken.None);
var replay = registry.GetReplaySnapshot(session.SessionId);
@ -140,7 +191,7 @@ public class SessionRegistryTests
var registry = harness.Registry;
var session = registry.Create("Shell", DateTimeOffset.UtcNow);
registry.RecordInputEcho(session.SessionId, "dir\r");
await registry.RecordInputAsync(session.SessionId, "dir\r", CancellationToken.None);
await registry.AppendOutputAsync(session.SessionId, "prompt> d", CancellationToken.None);
await registry.AppendOutputAsync(session.SessionId, "ir\r\nnext> ", CancellationToken.None);
@ -150,12 +201,12 @@ public class SessionRegistryTests
}
[Fact]
public void GetRestoreSnapshot_Includes_Pending_Visible_Input()
public async Task GetRestoreSnapshot_Includes_Pending_Visible_Input()
{
using var harness = SessionRegistryHarness.Create();
var session = harness.Registry.Create("Shell", DateTimeOffset.UtcNow);
harness.Registry.RecordInputEcho(session.SessionId, "git status");
await harness.Registry.RecordInputAsync(session.SessionId, "git status", CancellationToken.None);
var snapshot = harness.Registry.GetRestoreSnapshot(session.SessionId);
@ -170,7 +221,7 @@ public class SessionRegistryTests
using var harness = SessionRegistryHarness.Create();
var session = harness.Registry.Create("Shell", DateTimeOffset.UtcNow);
harness.Registry.RecordInputEcho(session.SessionId, "dir\r");
await harness.Registry.RecordInputAsync(session.SessionId, "dir\r", CancellationToken.None);
await harness.Registry.AppendOutputAsync(session.SessionId, "PS> dir\r\nnext> ", CancellationToken.None);
var snapshot = harness.Registry.GetRestoreSnapshot(session.SessionId);
@ -179,6 +230,32 @@ public class SessionRegistryTests
Assert.Equal(string.Empty, snapshot.PendingInput);
}
[Fact]
public async Task GetScreenSnapshot_Returns_Authoritative_Primary_Buffer_State()
{
using var harness = SessionRegistryHarness.Create();
var session = harness.Registry.Create("Shell", DateTimeOffset.UtcNow);
await harness.Registry.RecordResizeAsync(session.SessionId, 40, 10, CancellationToken.None);
await harness.Registry.RecordOutputAsync(session.SessionId, "first line\r\nsecond line", CancellationToken.None);
await harness.Registry.RecordOutputAsync(session.SessionId, "\u001b[2J\u001b[Hprompt> ", CancellationToken.None);
var snapshot = harness.Registry.GetScreenSnapshot(session.SessionId);
Assert.Equal(session.SessionId, snapshot.SessionId);
Assert.Equal(10, snapshot.Rows);
Assert.Equal(40, snapshot.Columns);
Assert.Equal(0, snapshot.CursorRow);
Assert.Equal(8, snapshot.CursorColumn);
Assert.Equal(3L, snapshot.SourceSequence);
Assert.True(snapshot.ScreenVersion > 0);
Assert.Equal("primary", snapshot.ActiveBuffer);
Assert.StartsWith("prompt> ", snapshot.PrimaryBuffer.Viewport[0].Text, StringComparison.Ordinal);
Assert.DoesNotContain(
snapshot.PrimaryBuffer.Viewport,
line => line.Text.Contains("first line", StringComparison.Ordinal));
}
[Fact]
public async Task Delete_Removes_Session_Record_And_History_Log()
{
@ -192,7 +269,7 @@ public class SessionRegistryTests
Assert.Empty(registry.List());
Assert.False(registry.TryGet(session.SessionId, out _));
Assert.False(File.Exists(Path.Combine(harness.DataRoot, "sessions", $"{session.SessionId}.log")));
Assert.Throws<KeyNotFoundException>(() => registry.GetHistory(session.SessionId, 10));
await Assert.ThrowsAsync<KeyNotFoundException>(() => registry.GetHistoryAsync(session.SessionId, 10, CancellationToken.None));
}
private sealed class SessionRegistryHarness : IDisposable
@ -218,7 +295,8 @@ public class SessionRegistryTests
RingBufferLineLimit = lineLimit,
});
var historyStore = new SessionHistoryStore(dataRoot);
var registry = new SessionRegistry(historyStore, options);
var journalStore = new SessionIoJournalStore(dataRoot);
var registry = new SessionRegistry(historyStore, journalStore, options);
return new SessionRegistryHarness(dataRoot, registry);
}

View File

@ -76,11 +76,11 @@ public class ConPtySessionFactoryTests
DataRoot = dataRoot,
RingBufferLineLimit = 4000,
});
var registry = new SessionRegistry(new SessionHistoryStore(dataRoot), options);
var host = new PowerShellSessionHost(
new ConPtySessionFactory(),
registry,
new SessionIoJournalStore(dataRoot));
var registry = new SessionRegistry(
new SessionHistoryStore(dataRoot),
new SessionIoJournalStore(dataRoot),
options);
var host = new PowerShellSessionHost(new ConPtySessionFactory(), registry);
return new HostHarness(dataRoot, registry, host);
}

View File

@ -46,16 +46,51 @@ public class PowerShellSessionHostTests
using var harness = HostHarness.Create(factory, lineLimit: 3);
var session = harness.Registry.Create("Shell", DateTimeOffset.UtcNow);
await using var host = harness.Host;
using var received = new ManualResetEventSlim(false);
host.OutputReceived += (_, _) => received.Set();
await host.StartAsync(session.SessionId, CancellationToken.None);
factory.Session.EmitOutput(session.SessionId, "one\ntwo\nthree\n");
Assert.True(received.Wait(TimeSpan.FromSeconds(2)));
var history = harness.Registry.GetHistory(session.SessionId, 2);
var history = await harness.Registry.GetHistoryAsync(session.SessionId, 2, CancellationToken.None);
Assert.Equal(["two", "three"], history.Lines);
Assert.True(history.HasMoreAbove);
}
[Fact]
public async Task Session_Output_Events_Are_Published_In_Order()
{
var factory = new FakeConPtySessionFactory();
using var harness = HostHarness.Create(factory);
var session = harness.Registry.Create("Shell", DateTimeOffset.UtcNow);
await using var host = harness.Host;
var outputs = new List<TerminalOutputEventArgs>();
using var received = new ManualResetEventSlim(false);
host.OutputReceived += (_, args) =>
{
lock (outputs)
{
outputs.Add(args);
if (outputs.Count == 2)
{
received.Set();
}
}
};
await host.StartAsync(session.SessionId, CancellationToken.None);
factory.Session.EmitOutput(session.SessionId, "first");
factory.Session.EmitOutput(session.SessionId, "second");
Assert.True(received.Wait(TimeSpan.FromSeconds(2)));
Assert.Equal(["first", "second"], outputs.Select(static output => output.Chunk).ToArray());
Assert.Equal([1L, 2L], outputs.Select(static output => output.Sequence).ToArray());
}
[Fact]
public async Task StartAsync_Recreates_Unhealthy_Existing_Session()
{
@ -100,8 +135,11 @@ public class PowerShellSessionHostTests
DataRoot = dataRoot,
RingBufferLineLimit = lineLimit,
});
var registry = new SessionRegistry(new SessionHistoryStore(dataRoot), options);
var host = new PowerShellSessionHost(factory, registry, new SessionIoJournalStore(dataRoot));
var registry = new SessionRegistry(
new SessionHistoryStore(dataRoot),
new SessionIoJournalStore(dataRoot),
options);
var host = new PowerShellSessionHost(factory, registry);
return new HostHarness(dataRoot, registry, host);
}

View File

@ -0,0 +1,93 @@
using TermRemoteCtl.Agent.Terminal.Screen;
namespace TermRemoteCtl.Agent.Tests.Terminal;
public sealed class TerminalScreenEngineTests
{
[Fact]
public void ApplyOutput_Clear_Removes_Stale_Lines_From_Visible_Snapshot()
{
var engine = new TerminalScreenEngine(rows: 4, cols: 20);
engine.ApplyOutput("first line\r\nsecond line", sourceSequence: 1);
engine.ApplyOutput("\u001b[2J\u001b[Hprompt> ", sourceSequence: 2);
var snapshot = engine.CreateSnapshot("session-1");
Assert.Equal(2L, snapshot.SourceSequence);
Assert.Equal(2L, snapshot.ScreenVersion);
Assert.StartsWith("prompt> ", snapshot.PrimaryBuffer.Viewport[0].Text, StringComparison.Ordinal);
Assert.DoesNotContain(
snapshot.PrimaryBuffer.Viewport,
line => line.Text.Contains("first line", StringComparison.Ordinal));
Assert.Equal(0, snapshot.CursorRow);
Assert.Equal(8, snapshot.CursorColumn);
}
[Fact]
public void ApplyOutput_Cursor_Movement_And_Insert_Produces_Final_Visible_Line()
{
var engine = new TerminalScreenEngine(rows: 4, cols: 40);
engine.ApplyOutput("PS> gst", sourceSequence: 1);
engine.ApplyOutput("\u001b[2D", sourceSequence: 2);
engine.ApplyOutput("it", sourceSequence: 3);
var snapshot = engine.CreateSnapshot("session-1");
Assert.StartsWith("PS> git", snapshot.PrimaryBuffer.Viewport[0].Text, StringComparison.Ordinal);
Assert.Equal(0, snapshot.CursorRow);
Assert.Equal(7, snapshot.CursorColumn);
Assert.Equal(3L, snapshot.SourceSequence);
Assert.Equal(3L, snapshot.ScreenVersion);
}
[Fact]
public void ApplyOutput_Alternate_Buffer_Preserves_Primary_And_Restores_On_Exit()
{
var engine = new TerminalScreenEngine(rows: 4, cols: 20);
engine.ApplyOutput("primary view", sourceSequence: 1);
engine.ApplyOutput("\u001b[?1049halt view", sourceSequence: 2);
var alternateSnapshot = engine.CreateSnapshot("session-1");
Assert.Equal("alternate", alternateSnapshot.ActiveBuffer);
Assert.StartsWith("alt view", alternateSnapshot.AlternateBuffer!.Viewport[0].Text, StringComparison.Ordinal);
Assert.StartsWith("primary view", alternateSnapshot.PrimaryBuffer.Viewport[0].Text, StringComparison.Ordinal);
engine.ApplyOutput("\u001b[?1049l", sourceSequence: 3);
var restoredSnapshot = engine.CreateSnapshot("session-1");
Assert.Equal("primary", restoredSnapshot.ActiveBuffer);
Assert.StartsWith("primary view", restoredSnapshot.PrimaryBuffer.Viewport[0].Text, StringComparison.Ordinal);
Assert.StartsWith("alt view", restoredSnapshot.AlternateBuffer!.Viewport[0].Text, StringComparison.Ordinal);
}
[Theory]
[InlineData("\u001b[?1047h", "\u001b[?1047l")]
[InlineData("\u001b[?47h", "\u001b[?47l")]
public void ApplyOutput_Alternate_Buffer_Compatibility_Modes_Switch_Buffers(
string enterSequence,
string exitSequence)
{
var engine = new TerminalScreenEngine(rows: 4, cols: 20);
engine.ApplyOutput("primary", sourceSequence: 1);
engine.ApplyOutput($"{enterSequence}alt", sourceSequence: 2);
var alternateSnapshot = engine.CreateSnapshot("session-1");
Assert.Equal("alternate", alternateSnapshot.ActiveBuffer);
Assert.StartsWith("alt", alternateSnapshot.AlternateBuffer!.Viewport[0].Text, StringComparison.Ordinal);
Assert.StartsWith("primary", alternateSnapshot.PrimaryBuffer.Viewport[0].Text, StringComparison.Ordinal);
engine.ApplyOutput(exitSequence, sourceSequence: 3);
var restoredSnapshot = engine.CreateSnapshot("session-1");
Assert.Equal("primary", restoredSnapshot.ActiveBuffer);
Assert.StartsWith("primary", restoredSnapshot.PrimaryBuffer.Viewport[0].Text, StringComparison.Ordinal);
}
}

View File

@ -0,0 +1,96 @@
# Backend-Authoritative Terminal History 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:** Replace recent-memory terminal history with a durable backend journal/history path and sequence-aware reconnect baseline, while keeping local mobile snapshots as a speed-only cache.
**Architecture:** Persist semantic terminal journal events as the official backend history source, expose sequence-ordered read APIs, and make websocket restore and live frames sequence-aware so the mobile client can recover from known backend baselines instead of assembling truth from local guesses.
**Tech Stack:** ASP.NET Core minimal APIs, C# record models, JSON Lines journal storage, Flutter, Dio, websocket_channel, Flutter widget/unit tests.
---
### Task 1: Add failing backend tests for authoritative journal reads
**Files:**
- Modify: `apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs`
- Modify: `apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/SessionHistoryApiTests.cs`
- Test: `apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs`
- Test: `apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/SessionHistoryApiTests.cs`
- [ ] **Step 1: Write failing tests for persistent journal reads**
- [ ] **Step 2: Run `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/TermRemoteCtl.Agent.Tests.csproj --filter SessionRegistryTests` and verify the new tests fail for the expected missing journal-read behavior**
- [ ] **Step 3: Run `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/TermRemoteCtl.Agent.IntegrationTests.csproj --filter SessionHistoryApiTests` and verify API tests fail because `/history` still reads ring buffer data**
### Task 2: Implement backend journal reader and formal history models
**Files:**
- Create: `apps/windows_agent/src/TermRemoteCtl.Agent/History/SessionJournalModels.cs`
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/History/SessionIoJournalStore.cs`
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/History/SessionHistoryStore.cs`
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs`
- [ ] **Step 1: Add journal item and journal page models with clear sequence semantics**
- [ ] **Step 2: Extend `SessionIoJournalStore` with UTF-8 JSONL read APIs that can return ordered pages by sequence**
- [ ] **Step 3: Replace the current `/history` backing logic in `SessionRegistry` so it derives user-visible history from the persistent journal instead of only reading `TerminalRingBuffer`**
- [ ] **Step 4: Run backend unit tests and make them pass with the minimal implementation**
### Task 3: Add failing backend tests for sequence-aware restore and live stream semantics
**Files:**
- Modify: `apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs`
- Test: `apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs`
- [ ] **Step 1: Write failing websocket tests that assert restore responses expose a backend baseline sequence and live messages carry newer sequence values**
- [ ] **Step 2: Run `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/TermRemoteCtl.Agent.IntegrationTests.csproj --filter TerminalWebSocketHandlerTests` and verify the new tests fail before implementation**
### Task 4: Implement backend sequence-aware restore and live frames
**Files:**
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs`
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/PowerShellSessionHost.cs`
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRestoreSnapshot.cs`
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs`
- [ ] **Step 1: Change restore payload generation so `sequence` means a real backend restore baseline**
- [ ] **Step 2: Wrap live websocket output in sequence-aware message envelopes**
- [ ] **Step 3: Ensure input, output, resize, attach, and detach advance a single consistent session sequence**
- [ ] **Step 4: Run backend websocket tests and make them pass**
### Task 5: Add failing frontend tests for authoritative history and sequence-aware recovery
**Files:**
- Modify: `apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart`
- Modify: `apps/mobile_app/test/features/terminal/terminal_socket_session_test.dart`
- Modify: `apps/mobile_app/test/widget_test.dart`
- Test: `apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart`
- Test: `apps/mobile_app/test/features/terminal/terminal_socket_session_test.dart`
- [ ] **Step 1: Write failing tests that expect the mobile client to consume sequence-aware restore/live messages**
- [ ] **Step 2: Write failing tests that expect authoritative history loading to use backend history items rather than output-only line assumptions**
- [ ] **Step 3: Run `C:\\tools\\flutter\\bin\\flutter.bat test test/features/terminal/terminal_session_coordinator_test.dart test/features/terminal/terminal_socket_session_test.dart` and verify the new tests fail for the expected contract mismatch**
### Task 6: Implement frontend integration while demoting local snapshot to acceleration only
**Files:**
- Modify: `apps/mobile_app/lib/core/network/agent_api_client.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/lib/features/terminal/terminal_restore_payload.dart`
- Modify: `apps/mobile_app/lib/features/terminal/terminal_page.dart`
- Modify: `apps/mobile_app/lib/features/terminal/history_window.dart`
- [ ] **Step 1: Update the API client and coordinator to read the new backend history payload and preserve ordering**
- [ ] **Step 2: Update websocket parsing for sequence-aware live envelopes and restore baselines**
- [ ] **Step 3: Keep local snapshot restore as an early render optimization, but make backend restore and journal data authoritative whenever they arrive**
- [ ] **Step 4: Run targeted Flutter tests and make them pass**
### Task 7: Verify end-to-end behavior and document phase boundaries
**Files:**
- Modify: `docs/superpowers/specs/2026-04-07-backend-authoritative-terminal-history-design.md`
- [ ] **Step 1: Run `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/TermRemoteCtl.Agent.Tests.csproj`**
- [ ] **Step 2: Run `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/TermRemoteCtl.Agent.IntegrationTests.csproj`**
- [ ] **Step 3: Run `C:\\tools\\flutter\\bin\\flutter.bat test test/features/terminal/terminal_session_coordinator_test.dart test/features/terminal/terminal_socket_session_test.dart test/widget_test.dart` from `apps/mobile_app`**
- [ ] **Step 4: Summarize what phase 1 solved and what remains phase 2, especially full TUI screen recovery**

View File

@ -0,0 +1,278 @@
# Backend-Authoritative Terminal Screen Recovery 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:** Replace reconnect-time text approximation with backend-authoritative terminal screen recovery while preserving the journal as the history authority and keeping local snapshots as provisional acceleration only.
**Architecture:** Add a backend terminal screen engine that consumes ConPTY output into authoritative screen state, expose that state over `screen_snapshot` and `screen_patch` websocket messages, and update the Flutter terminal flow so visible recovery follows backend screen versions instead of text replay heuristics. Deliver this in two phases: 2A for primary-buffer shell correctness and 2B for alternate-screen or fullscreen TUI fidelity.
**Tech Stack:** ASP.NET Core, C#, ConPTY integration, JSON websocket protocol, Flutter, xterm package, unit tests, integration tests, widget tests.
---
## Delivery Rule
Do not begin implementation until the tests for the target slice exist and fail for the expected reason.
## File Structure And Responsibility
Backend files expected for 2A:
- Create: `apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/Screen/TerminalScreenState.cs`
- Create: `apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/Screen/TerminalScreenBuffer.cs`
- Create: `apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/Screen/TerminalCell.cs`
- Create: `apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/Screen/TerminalScreenSnapshot.cs`
- Create: `apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/Screen/TerminalScreenPatch.cs`
- Create: `apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/Screen/TerminalScreenEngine.cs`
- Create: `apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/Screen/TerminalScreenProtocolModels.cs`
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/PowerShellSessionHost.cs`
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs`
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRestoreSnapshot.cs`
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs`
Frontend files expected for 2A:
- Create: `apps/mobile_app/lib/features/terminal/terminal_screen_snapshot.dart`
- Create: `apps/mobile_app/lib/features/terminal/terminal_screen_patch.dart`
- Create: `apps/mobile_app/lib/features/terminal/terminal_screen_state.dart`
- Modify: `apps/mobile_app/lib/features/terminal/terminal_socket_session.dart`
- Modify: `apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart`
- Modify: `apps/mobile_app/lib/features/terminal/terminal_page.dart`
- Modify: `apps/mobile_app/lib/features/terminal/terminal_restore_payload.dart`
- Modify: `apps/mobile_app/lib/features/terminal/terminal_restore_decision.dart`
- Modify: `apps/mobile_app/lib/features/terminal/terminal_snapshot.dart`
Expected test files:
- Modify: `apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/PowerShellSessionHostTests.cs`
- Modify: `apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs`
- Modify: `apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs`
- Modify: `apps/mobile_app/test/features/terminal/terminal_socket_session_test.dart`
- Modify: `apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart`
- Modify: `apps/mobile_app/test/widget_test.dart`
## 2A Scope
2A must solve:
- shell prompt recovery from backend screen snapshot
- `clear` and erase semantics in the primary buffer
- cursor movement and line editing recovery
- backend-owned screen versioning
- frontend provisional local snapshot replacement
2A must not claim:
- alternate screen fidelity
- fullscreen curses fidelity
- complete style fidelity beyond what is needed for future compatibility
### Task 1: Lock The Contract With Backend Unit Tests
**Files:**
- Create: `apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/TerminalScreenEngineTests.cs`
- Modify: `apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs`
- [ ] **Step 1: Add a failing test for clear semantics**
Verify that applying output containing clear or erase operations results in a snapshot whose visible lines no longer include stale replay text.
- [ ] **Step 2: Add a failing test for cursor-addressed line editing**
Verify that a prompt plus left-arrow editing plus inserted characters produces the correct final visible line and cursor position in screen state.
- [ ] **Step 3: Add a failing test for screen version monotonicity**
Verify that each visible screen mutation increments `screenVersion`, while a no-op does not silently create inconsistent versions.
- [ ] **Step 4: Add a failing test for registry snapshot exposure**
Verify that `SessionRegistry` can return a screen snapshot contract with non-null rows, cols, cursor, and `sourceSequence`.
- [ ] **Step 5: Run backend unit tests and confirm failure**
Run:
`dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/TermRemoteCtl.Agent.Tests.csproj --filter "TerminalScreenEngineTests|SessionRegistryTests"`
### Task 2: Build The 2A Backend Screen Engine
**Files:**
- Create: `apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/Screen/TerminalScreenState.cs`
- Create: `apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/Screen/TerminalScreenBuffer.cs`
- Create: `apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/Screen/TerminalCell.cs`
- Create: `apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/Screen/TerminalScreenSnapshot.cs`
- Create: `apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/Screen/TerminalScreenEngine.cs`
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs`
- [ ] **Step 1: Add the screen-state domain model**
Model rows, cols, cursor, active buffer, primary buffer lines, bounded scrollback metadata, `screenVersion`, and `sourceSequence`.
- [ ] **Step 2: Implement ordered VT application for the 2A subset**
Support printable characters, CR, LF, backspace, clear or erase operations, and cursor movements needed for ordinary shells and line editing.
- [ ] **Step 3: Store one screen engine per session in the registry**
Update output and resize recording so authoritative screen state is advanced in lockstep with journal sequence progression.
- [ ] **Step 4: Expose authoritative screen snapshot retrieval**
Add a new registry API that returns `TerminalScreenSnapshot` instead of replay text as the primary reconnect state.
- [ ] **Step 5: Re-run backend unit tests until they pass**
Run:
`dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/TermRemoteCtl.Agent.Tests.csproj --filter "TerminalScreenEngineTests|SessionRegistryTests"`
### Task 3: Lock The 2A Websocket Contract With Integration Tests
**Files:**
- Modify: `apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs`
- Modify: `apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/SessionFlowTests.cs`
- [ ] **Step 1: Add a failing websocket attach test for `screen_snapshot`**
Verify attach returns `attached` followed by `screen_snapshot`, and that the snapshot carries `screenVersion`, `sourceSequence`, rows, cols, and cursor metadata.
- [ ] **Step 2: Add a failing reconnect test for clear recovery**
Verify reconnect after a clear-style command returns a visible snapshot without stale lines from before the clear.
- [ ] **Step 3: Add a failing test for version mismatch handling**
Verify that when a client misses continuity, the server or protocol path can recover by delivering a fresh snapshot instead of relying on replayed output.
- [ ] **Step 4: Run backend integration tests and confirm failure**
Run:
`dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/TermRemoteCtl.Agent.IntegrationTests.csproj --filter "TerminalWebSocketHandlerTests|SessionFlowTests"`
### Task 4: Implement The 2A Websocket Screen Protocol
**Files:**
- Create: `apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/Screen/TerminalScreenProtocolModels.cs`
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs`
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/PowerShellSessionHost.cs`
- [ ] **Step 1: Add `screen_snapshot` response models**
Include `screenVersion`, `sourceSequence`, size, cursor, active buffer, and visible line payloads.
- [ ] **Step 2: Add `screen_patch` response models**
Include `baseScreenVersion`, `screenVersion`, `sourceSequence`, and patch operations for changed rows in 2A.
- [ ] **Step 3: Emit `screen_snapshot` after attach**
Keep legacy `restore` available behind compatibility logic, but make `screen_snapshot` the preferred reconnect truth for new clients.
- [ ] **Step 4: Emit `screen_patch` on live updates**
Publish state changes from the backend screen engine, not just raw output chunks.
- [ ] **Step 5: Re-run backend integration tests until they pass**
Run:
`dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/TermRemoteCtl.Agent.IntegrationTests.csproj --filter "TerminalWebSocketHandlerTests|SessionFlowTests"`
## Frontend Testing Strategy Before Frontend Code
The frontend must stop proving that text replay looks plausible and start proving that backend screen state wins.
### Task 5: Add Failing Frontend Tests For Snapshot-Driven Recovery
**Files:**
- Modify: `apps/mobile_app/test/features/terminal/terminal_socket_session_test.dart`
- Modify: `apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart`
- Modify: `apps/mobile_app/test/widget_test.dart`
- [ ] **Step 1: Add a failing socket test for `screen_snapshot` parsing**
Verify the socket session can parse `screen_snapshot` and `screen_patch` frames separately from legacy `restore` and `output`.
- [ ] **Step 2: Add a failing coordinator test for version continuity**
Verify the coordinator applies patches only when `baseScreenVersion` matches local state and otherwise requests or waits for a fresh snapshot.
- [ ] **Step 3: Add a failing widget test for provisional local snapshot replacement**
Verify a local text snapshot paints immediately but is replaced by backend snapshot content without calling restore-text comparison heuristics.
- [ ] **Step 4: Add a failing widget test for clear recovery**
Verify reconnect after a clear-style screen snapshot does not preserve stale local text.
- [ ] **Step 5: Run Flutter tests and confirm failure**
Run from `apps/mobile_app`:
`C:\tools\flutter\bin\flutter.bat test test/features/terminal/terminal_socket_session_test.dart test/features/terminal/terminal_session_coordinator_test.dart test/widget_test.dart`
### Task 6: Implement Frontend Snapshot And Patch Consumption
**Files:**
- Create: `apps/mobile_app/lib/features/terminal/terminal_screen_snapshot.dart`
- Create: `apps/mobile_app/lib/features/terminal/terminal_screen_patch.dart`
- Create: `apps/mobile_app/lib/features/terminal/terminal_screen_state.dart`
- Modify: `apps/mobile_app/lib/features/terminal/terminal_socket_session.dart`
- Modify: `apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart`
- Modify: `apps/mobile_app/lib/features/terminal/terminal_page.dart`
- Modify: `apps/mobile_app/lib/features/terminal/terminal_restore_decision.dart`
- Modify: `apps/mobile_app/lib/features/terminal/terminal_snapshot.dart`
- [ ] **Step 1: Add screen snapshot and patch models**
Parse backend snapshot, version, cursor, buffer, and line payloads without collapsing them into a single restore string.
- [ ] **Step 2: Update socket session callbacks**
Deliver authoritative screen events separately from compatibility restore frames.
- [ ] **Step 3: Update coordinator state machine**
Track local `screenVersion`, apply patches only on continuity, and request or expect a fresh snapshot on mismatch.
- [ ] **Step 4: Update `TerminalPage` rendering flow**
Paint local snapshot only as provisional content, then clear and rebuild from backend screen snapshot when it arrives.
- [ ] **Step 5: Keep legacy restore only as fallback**
Use legacy `restore` only when the backend screen protocol is unavailable, not when both exist.
- [ ] **Step 6: Re-run Flutter tests until they pass**
Run from `apps/mobile_app`:
`C:\tools\flutter\bin\flutter.bat test test/features/terminal/terminal_socket_session_test.dart test/features/terminal/terminal_session_coordinator_test.dart test/widget_test.dart`
## 2B Scope
2B extends the same architecture to alternate screen and fullscreen programs.
### Task 7: Lock Alternate-Screen Behavior With Tests
**Files:**
- Modify: `apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/TerminalScreenEngineTests.cs`
- Modify: `apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs`
- Modify: `apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart`
- Modify: `apps/mobile_app/test/widget_test.dart`
- [ ] **Step 1: Add a failing backend unit test for primary versus alternate buffer isolation**
Verify entering alternate screen does not destroy primary screen content and leaving alternate screen restores primary view.
- [ ] **Step 2: Add failing integration tests for fullscreen reconnect**
Verify reconnect during `less` or `vim`-style alternate-screen content returns the alternate buffer as active state.
- [ ] **Step 3: Add failing frontend tests for alternate buffer snapshot consumption**
Verify the client renders whichever buffer the backend marks active, without replay-based inference.
- [ ] **Step 4: Run targeted backend and Flutter tests and confirm failure**
### Task 8: Implement 2B Alternate Screen And TUI Fidelity
**Files:**
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/Screen/TerminalScreenEngine.cs`
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/Screen/TerminalScreenSnapshot.cs`
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/Screen/TerminalScreenPatch.cs`
- Modify: `apps/mobile_app/lib/features/terminal/terminal_screen_snapshot.dart`
- Modify: `apps/mobile_app/lib/features/terminal/terminal_screen_patch.dart`
- Modify: `apps/mobile_app/lib/features/terminal/terminal_page.dart`
- [ ] **Step 1: Add alternate buffer state and switching semantics**
- [ ] **Step 2: Add richer erase, mode, and redraw operations needed by fullscreen TUIs**
- [ ] **Step 3: Extend snapshot and patch payloads without changing the top-level message family**
- [ ] **Step 4: Re-run targeted backend and Flutter tests until they pass**
## Final Verification
### Task 9: Validate End-To-End Behavior And Document Residual Risks
**Files:**
- Modify: `docs/superpowers/specs/2026-04-07-backend-authoritative-terminal-screen-recovery-design.md`
- Modify: `docs/testing/manual-smoke-checklist.md`
- [ ] **Step 1: Run backend unit tests**
`dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/TermRemoteCtl.Agent.Tests.csproj`
- [ ] **Step 2: Run backend integration tests**
`dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/TermRemoteCtl.Agent.IntegrationTests.csproj`
- [ ] **Step 3: Run Flutter tests**
Run from `apps/mobile_app`:
`C:\tools\flutter\bin\flutter.bat test`
- [ ] **Step 4: Execute manual smoke scenarios**
Cover prompt editing, clear, reconnect during output, reconnect after resize, provisional local snapshot replacement, and fullscreen alternate-screen recovery.
- [ ] **Step 5: Summarize what 2A solved and what still belongs to 2B**
Confirm that ordinary shell recovery is authoritative after 2A and that fullscreen TUI fidelity remains a tracked extension instead of a hidden regression.

View File

@ -0,0 +1,221 @@
# Backend-Authoritative Terminal History Design
## Goal
Fix the root cause behind incomplete terminal history and untrustworthy reconnect recovery by making the Windows agent the authoritative source for terminal history and recovery baselines.
## Problem Summary
The current product has three different mechanisms that all look like "history", but they mean different things:
- the in-memory ring buffer powers `/api/sessions/{sessionId}/history`
- the replay buffer powers websocket restore
- the local mobile snapshot powers instant same-device return
That split creates a false impression of recovery. Users sometimes see content after reconnect, but the system cannot prove that the content is complete or correct.
From a business perspective, this is why users do not trust the session after reconnect:
- older terminal output disappears because the API only reads recent in-memory lines
- typed commands are not part of formal history, so what the user did is not fully traceable
- reconnect restore is based on recent replay text, not on durable authoritative state
- the mobile app can render a local snapshot before the backend answers, which improves speed but hides backend correctness gaps
## Root Cause
### 1. The history API reads the wrong source
`/api/sessions/{sessionId}/history` currently calls `SessionRegistry.GetHistory()`.
That method reads `TerminalRingBuffer`, which is:
- memory only
- output only
- line based
- capped by `RingBufferLineLimit`
So the API is not a history API in the product sense. It is only a recent scrollback cache.
### 2. The backend already stores richer data, but does not expose it
The agent also writes:
- `{sessionId}.log` for output text
- `{sessionId}.io.jsonl` for semantic events like `input`, `output`, `resize`, `attach`, and `detach`
The `.io.jsonl` file is much closer to the needed journal, but it is write-only today. There is no formal read API, no pagination contract, and no frontend integration using it as the source of truth.
### 3. Reconnect restore is not authoritative
The websocket attach path returns `GetRestoreSnapshot()`, which is built from:
- `TerminalReplayBuffer` for recent output characters
- `PendingInputEchoTracker` for visible input not yet echoed
This helps ordinary shell reconnect, but it is still only a bounded approximation. It is not durable history and it is not a reliable recovery baseline for gap detection.
### 4. The frontend mixes acceleration and correctness
The mobile app currently combines:
- local snapshot
- `/history` seed
- websocket `restore`
- live websocket frames
That means the UI can show something plausible even when the backend has not provided a complete authoritative answer. This is good for responsiveness, but wrong as the correctness model.
## Principles
- Full history must come from a durable backend journal.
- Reconnect must be anchored to a backend-defined sequence baseline.
- Frontend local snapshot is allowed only as an acceleration layer.
- "Current visible screen" and "complete session history" are different products and must stay separate.
## Recommended Architecture
### A. Formal Journal Model
Introduce a backend journal model as the official terminal history source.
Each journal event includes:
- `sessionId`
- `seq`
- `timestamp`
- `kind`
- `payload`
Required kinds for phase 1:
- `input`
- `output`
- `resize`
- `attach`
- `detach`
This journal is append-only, durable, and readable.
### B. Formal History API
Expose a new history API backed by the persistent journal, not by the ring buffer.
The API must:
- return stable ordering by sequence
- support cursor-based or sequence-based pagination
- return both input and output events
- support reading older and newer slices without ambiguity
Proposed shape:
`GET /api/sessions/{sessionId}/journal?afterSeq=1200&limit=200`
Response:
```json
{
"sessionId": "session-123",
"items": [
{
"seq": 1201,
"timestamp": "2026-04-07T03:20:01.0000000+00:00",
"kind": "input",
"payload": "git status"
}
],
"nextAfterSeq": 1400,
"hasMore": true,
"lastSeq": 2450
}
```
For backward compatibility, the existing `/history` endpoint can remain temporarily, but it should be redefined to read the persistent journal and return a history window derived from journal events instead of the ring buffer.
### C. Restore Baseline
Reconnect is a different concern from full history.
Phase 1 restore should return:
- the backend restore text used for shell-oriented recovery
- `seq` representing the latest journaled state included in the restore baseline
- optional metadata to support future cursor or screen state upgrades
Proposed websocket restore payload:
```json
{
"type": "restore",
"sessionId": "session-123",
"sequence": 2450,
"screenText": "PS C:\\repo> git status\r\n",
"pendingInput": ""
}
```
Semantics:
- `sequence` means "this restore includes everything up to seq 2450"
- future live frames must carry their own `seq`
- the client can detect missing ranges and fetch them from the journal API
### D. Live Stream Semantics
The live websocket stream should become sequence-aware.
Phase 1 does not need a full screen emulator, but it does need:
- each live output frame tagged with `seq`
- restore payload carrying a clear baseline `seq`
- the client able to tell whether it missed events during reconnect
This turns reconnect from "best effort replay" into "recover from baseline, then continue from a known sequence".
### E. Frontend Contract
The mobile app should be refactored around two separate paths:
#### Full History
- load from backend journal API
- render authoritative terminal history view from backend data
- stop pretending that `/history` seed plus replay text is complete history
#### Current Screen Recovery
- optionally render local snapshot immediately for same-device speed
- replace or reconcile with backend restore once it arrives
- if backend restore or journal proves the local snapshot was incomplete, backend wins
## Compatibility Strategy
Phase 1 should keep the existing ring buffer and replay buffer only as internal compatibility layers:
- ring buffer may still support a small recent scrollback cache if useful for performance
- replay buffer may still help compute shell-oriented restore text
But neither should remain the formal source of truth.
## Scope For This Phase
### Must Solve Now
- backend durable journal becomes readable
- frontend history uses backend authoritative data
- restore payload exposes a clear sequence baseline
- reconnect no longer depends on frontend guesswork for correctness
### Explicitly Deferred To Phase 2
- full VT/TUI screen emulation
- authoritative cursor and alternate-screen recovery
- server-side rendered terminal state for fullscreen TUIs
## Acceptance Criteria
- terminal history includes both user input and system output
- history reads from durable journal data, not from transient memory buffers
- history pagination is stable and sequence-ordered
- websocket restore provides a backend-owned sequence baseline
- the mobile app treats backend data as truth and local snapshot as cache
- reconnect correctness is no longer gated on frontend heuristics alone

View File

@ -0,0 +1,613 @@
# Backend-Authoritative Terminal Screen Recovery Design
## Goal
Make the backend the authoritative source for current terminal screen recovery, not just for durable history. After reconnect, the frontend should recover the current screen from backend-owned screen state, instead of rebuilding it by replaying recent text, history lines, or local snapshots.
## Why Phase 1 Was Necessary But Not Sufficient
Phase 1 fixed the history truth problem:
- durable journal is now the authority for session history
- history fetches already use backend journal APIs
- websocket restore and live output already expose sequence values
- local snapshot has already been demoted to a same-device acceleration layer
But Phase 1 did not solve the screen truth problem.
Today the product still treats "current screen" as a text approximation:
- backend restore snapshot is built from recent replay text plus pending visible input
- frontend restore applies that data by writing text back into the terminal widget
- frontend still compares local text and restore text to decide whether to keep or replace the visible state
That means the system now has backend-authoritative history, but it still does not have backend-authoritative current screen state.
## Current Root Cause
### Business View
Users do not care whether the system can replay some recent bytes. They care whether the reconnected screen is the same screen they would have seen if the socket had never dropped.
The current implementation cannot promise that. It can only promise that the UI will show text that looks plausible for line-oriented shells.
### Technical View
The current backend restore path is not a screen model.
`SessionRegistry.GetRestoreSnapshot()` currently returns:
- `ScreenText`: `TerminalReplayBuffer.GetSnapshot()`
- `PendingInput`: `PendingInputEchoTracker.GetVisibleSuffix()`
- `CursorRow` and `CursorColumn`: always `null`
That is not enough to represent a terminal screen. It is only enough to represent a recent stream fragment plus a guessed unacknowledged input suffix.
There is also no server-side terminal emulator in the current output path:
- ConPTY emits raw VT-oriented output
- `PowerShellSessionHost` forwards raw chunks into `SessionRegistry.RecordOutputAsync()`
- `SessionRegistry` stores output in a replay buffer, a ring buffer, the history store, and the journal
- websocket restore sends replay text back to the client
At no point does the backend interpret VT control sequences into a canonical screen buffer.
### Why `screenText + pendingInput` Works For Simple Shells
It works when the shell behaves like an append-only transcript:
- prompt prints text
- command input appears visibly at the end
- command output mostly appends new lines
- latest visible state is close to the latest emitted text
In that narrow case, replaying recent text and appending pending input often reconstructs something close enough.
### Why It Fails For Real Terminal State
The model breaks as soon as visible state is no longer equal to "recent text suffix".
Examples:
- `clear`: visible screen becomes empty or mostly empty, but replay text still contains old output
- cursor movement: the latest bytes may redraw earlier cells without appending new lines
- line editing: the visible command line is the result of cursor moves, inserts, deletes, and redraws, not a plain suffix
- alternate screen: fullscreen programs render into a separate buffer that should replace the primary view without destroying primary scrollback
- `vim`, `less`, `top`, `htop`: these programs continuously repaint arbitrary cells and rely on mode changes, cursor addressing, and screen erasure
The current system stores output bytes, not resulting cells. So it cannot answer the question "what is on the screen now?" with authority.
## Evidence In Current Code
### Backend
Current backend restore is still replay-oriented:
- `apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs`
- `apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRestoreSnapshot.cs`
- `apps/windows_agent/src/TermRemoteCtl.Agent/History/PendingInputEchoTracker.cs`
- `apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs`
- `apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/PowerShellSessionHost.cs`
Observed boundaries:
- ConPTY output is forwarded as raw chunk events
- journal persists semantic IO events, not screen state
- restore payload exposes text, pending input, and nullable cursor fields
- there is no primary buffer, alternate buffer, cursor visibility, erase state, or style model
### Frontend
Current frontend restore is still text reconstruction:
- `apps/mobile_app/lib/features/terminal/terminal_page.dart`
- `apps/mobile_app/lib/features/terminal/terminal_restore_payload.dart`
- `apps/mobile_app/lib/features/terminal/terminal_restore_decision.dart`
- `apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart`
- `apps/mobile_app/lib/features/terminal/terminal_snapshot.dart`
Observed boundaries:
- `_handleRestorePayload()` concatenates `screenText + pendingInput`
- the page decides whether to keep or replace the current buffer by comparing normalized text
- local snapshot stores only `bufferText`
- same terminal widget still consumes live frames as text writes
So even after Phase 1, the frontend is still being asked to infer visual truth from text.
## What Must Change In Phase 2
History and screen must stay separate products.
### Journal Responsibility
Journal remains the durable record of what happened:
- append-only
- auditable
- sequence-ordered
- used for history and gap recovery
Journal is not responsible for answering "what is currently visible on row 17, column 42?".
### Screen State Responsibility
A new backend screen-state subsystem must answer the current-screen question:
- what buffer is active now
- what cells are visible now
- where the cursor is
- which terminal modes affect rendering
- what version of screen state the client has
That subsystem must be derived from terminal output and input semantics, not from frontend guesses.
## Required Backend Screen State Model
At minimum, the backend screen state should carry:
- `rows`
- `cols`
- `cursorRow`
- `cursorCol`
- `cursorVisible`
- `activeBuffer`: `primary` or `alternate`
- `primaryBuffer`
- `alternateBuffer`
- `screenVersion`
- `sourceSequence`
- `modes`
- `pendingInputEcho`
For 2A, `primaryBuffer` can initially be a row-major cell matrix for the active viewport plus bounded scrollback metadata.
For 2B, the model must fully support:
- alternate-screen preservation
- full-screen repaint behavior
- richer terminal modes and attributes
### Cell Model
To avoid repaint ambiguity, the snapshot should not be plain text. It should be a screen model.
Each line should support:
- cell text
- width information for wide characters
- style attributes needed to preserve visible meaning
- line wrapping metadata
Each cell should be able to hold:
- character or grapheme
- foreground and background color
- bold, italic, underline, inverse, dim, strikethrough
This may feel larger than the current need, but skipping it would block 2B and force a protocol rewrite later.
### Mode State
The backend must also track terminal state that affects interpretation:
- cursor visibility
- insert or replace mode if needed by emulator
- origin mode
- auto-wrap
- active character attributes
- active screen buffer
- clear and erase effects as applied to the current buffers
If the backend does not own those semantics, it still does not own the current screen.
## Do We Need A Backend Terminal Emulator?
Yes.
Not because the frontend cannot render text, but because only a terminal emulator can turn VT control sequences into authoritative screen state.
### Why A Replay Buffer Is Not Enough
A replay buffer only answers:
- what bytes arrived recently
It does not answer:
- what cells those bytes produced after cursor moves, erases, wraps, inserts, and buffer switches
### Emulator Responsibility Boundary
The backend terminal emulator should do exactly this:
- consume ConPTY output stream in order
- apply resize and mode semantics
- maintain primary and alternate screen buffers
- expose authoritative snapshots and incremental updates
It should not replace:
- durable journal storage
- business history APIs
- frontend presentation concerns
So the clean split is:
- journal: what happened
- emulator plus screen state: what the screen currently is
- frontend widget: how to draw the authoritative screen
## Recommended Phase Split
The scope is large enough that it should be split, but the split must preserve the final architecture.
### 2A: Ordinary Shell Authoritative Screen State
Target:
- ordinary shell prompts
- `clear`
- cursor movement
- line editing
- erase operations inside the primary buffer
2A introduces:
- server-side terminal emulator for primary buffer
- authoritative screen snapshot on attach or reconnect
- screen versioning
- optional incremental updates for primary-buffer changes
2A explicitly does not claim full fidelity for:
- alternate screen
- fullscreen TUIs
- advanced curses applications
### 2B: Alternate Screen And Fullscreen TUI Recovery
Target:
- alternate screen
- fullscreen redraw loops
- `vim`
- `less`
- `top`
- `htop`
- richer style fidelity
2B extends the same contract, not a new one:
- same screen snapshot envelope
- same screen version concept
- same source sequence semantics
- same frontend "backend wins" rule
This split works because 2A already introduces the right authority boundary: the backend owns screen state, and the frontend consumes snapshots and patches instead of text guesses.
## Protocol Design
### Attach Or Reconnect Handshake
Attach should become an explicit state negotiation, not a best-effort replay.
Recommended order:
1. client sends `attach`
2. server returns `attached`
3. server returns authoritative `screen_snapshot`
4. server streams live `screen_patch` and `output_event` messages
`output_event` remains useful for diagnostics, journal-linked UX, and compatibility, but it is no longer the rendering truth.
### Snapshot Payload
Recommended 2A payload:
```json
{
"type": "screen_snapshot",
"sessionId": "session-123",
"screenVersion": 18,
"sourceSequence": 2450,
"rows": 30,
"cols": 120,
"cursor": {
"row": 29,
"col": 17,
"visible": true
},
"activeBuffer": "primary",
"buffers": {
"primary": {
"viewport": [
{
"index": 0,
"text": "PS C:\\repo> git status",
"cells": []
}
],
"scrollbackLines": 400
},
"alternate": null
},
"modes": {
"appCursorKeys": false,
"autoWrap": true
},
"pendingInputEcho": "",
"fullSnapshot": true
}
```
Key semantics:
- `screenVersion`: monotonically increasing state version owned by the screen engine
- `sourceSequence`: highest journal sequence reflected in this screen state
- `screenVersion` and `sourceSequence` are different on purpose
`sourceSequence` answers:
- how much journaled IO has been incorporated
`screenVersion` answers:
- which exact screen image the client is holding
### Live Sync Payload
Recommended 2A live payload:
```json
{
"type": "screen_patch",
"sessionId": "session-123",
"baseScreenVersion": 18,
"screenVersion": 19,
"sourceSequence": 2451,
"rows": 30,
"cols": 120,
"ops": [
{
"kind": "replace_lines",
"startRow": 29,
"lines": [
{
"index": 29,
"text": "PS C:\\repo> git status"
}
]
}
]
}
```
Why patch instead of raw text:
- patch expresses resulting state changes
- text only expresses transport bytes and leaves interpretation to the client
### Compatibility Stream
To avoid breaking tooling too early, the server may still emit:
```json
{
"type": "output",
"sessionId": "session-123",
"sequence": 2451,
"chunk": "..."
}
```
But its contract changes:
- `output` is no longer used by the terminal widget as rendering truth
- `output` is only for compatibility, diagnostics, and journal-linked behaviors
### Gap Detection Rules
The client should treat screen state as authoritative only when patch continuity is intact.
Rules:
- if `baseScreenVersion` matches local `screenVersion`, apply patch
- if it does not match, request or wait for a fresh `screen_snapshot`
- if `sourceSequence` jumps ahead unexpectedly, history or diagnostics can fetch journal gaps, but the visible screen should still be refreshed from backend screen state
This avoids replaying missed text to heal visible state.
## Frontend Consumption Model
### Can The Widget Keep Eating Text Streams?
Not as the correctness path.
A text-stream-only widget assumes that the thing being sent is an appendable transcript. That assumption is exactly what breaks for cursor-addressed terminals.
So in Phase 2:
- text stream may remain as a compatibility feed
- visible state recovery must come from `screen_snapshot` and `screen_patch`
### Frontend Restore Flow
Recommended reconnect flow:
1. optionally paint local snapshot immediately if available
2. mark it as provisional
3. wait for backend `screen_snapshot`
4. replace provisional state with backend snapshot unconditionally
5. continue with backend `screen_patch`
No content comparison heuristic should survive that point.
### Local Snapshot Role
Local snapshot remains useful only for:
- same-device instant paint
- hiding short reconnect latency
Local snapshot must no longer:
- win against backend snapshot because it looks richer
- be merged into backend snapshot
- be used to infer missing screen state
If backend snapshot arrives and differs, backend wins.
### What To Do With Existing Client Heuristics
Keep, demote, or replace:
- keep `pending input` buffering for outbound user input during reconnect
- keep history window logic for journal browsing
- demote local snapshot to provisional paint only
- demote replay buffer and pending-input restore to 2A compatibility path only while screen engine is being introduced
- replace `decideTerminalRestore()` with explicit backend-authoritative snapshot application
- replace text-based restore seeding with snapshot or patch consumption
## Data Structures To Keep Or Retire
### Keep
- journal store and sequence model
- pending outbound input buffering in the mobile coordinator
- durable history APIs
- diagnostics around attach, reconnect, and gaps
### Compatibility Only
- `TerminalReplayBuffer`
- `PendingInputEchoTracker`
- `screenText` and `pendingInput` restore payload
- local `TerminalSnapshot.bufferText`
These can remain during migration, but only as a fallback path while the screen engine is rolled out.
### Replace
- frontend restore-by-writing-text
- restore-vs-local text comparison
- using live raw output as the source of visible state correctness
## Migration Strategy
### 2A Protocol Boundary
Introduce new message types without deleting old ones:
- `screen_snapshot`
- `screen_patch`
Keep existing:
- `restore`
- `output`
Frontend preference order:
1. `screen_snapshot` plus `screen_patch`
2. legacy `restore` plus `output` only if screen protocol is unavailable
This lets 2A ship incrementally without blocking current users.
### 2B Protocol Boundary
2B should extend payload fidelity, not replace message families.
Allowed 2B changes:
- non-null alternate buffer content
- richer cell attributes
- more patch operation kinds
- fuller mode-state modeling
Not allowed in 2B:
- replacing `screen_snapshot` with a different top-level concept
- going back to frontend replay-based restore
## Testing Strategy
### Backend Unit Tests
Add focused tests around the future screen engine:
- VT clear or erase updates visible cells instead of preserving replay text
- cursor movement rewrites existing cells without creating false appended lines
- line editing operations update cursor position and line cells correctly
- resize preserves or reflows state according to chosen emulator semantics
- screen version increments on every visible state change
- source sequence only advances when journaled events are incorporated
- primary and alternate buffers stay isolated
Likely locations:
- `apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/`
- `apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/`
### Backend Integration Tests
Drive the full ConPTY-to-websocket path:
- attach returns `screen_snapshot`, not only legacy `restore`
- reconnect after `clear` shows cleared screen, not old replay text
- reconnect after cursor-addressed prompt redraw shows correct prompt line
- reconnect during alternate-screen app returns active alternate buffer in 2B
- patch version mismatch triggers fresh snapshot instead of silent drift
Likely locations:
- `apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs`
- `apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/SessionFlowTests.cs`
### Frontend Tests
The frontend must prove it no longer guesses:
- provisional local snapshot is overwritten when backend snapshot arrives
- snapshot apply does not use text comparison heuristics
- patch base-version mismatch forces snapshot refresh
- legacy restore path is only used when screen protocol is unavailable
- history browsing remains journal-backed and separate from current-screen rendering
Likely locations:
- `apps/mobile_app/test/features/terminal/terminal_socket_session_test.dart`
- `apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart`
- `apps/mobile_app/test/widget_test.dart`
### Manual Verification
Required smoke matrix:
- ordinary PowerShell prompt with partially typed command
- `cls` or `clear`
- left-arrow editing in a long command
- command that rewrites status text in place
- fullscreen `less`
- fullscreen `vim`
- `top` or equivalent repaint-heavy TUI
- reconnect during active output
- reconnect after resize
- background app, resume app, verify backend snapshot replaces provisional local state
## Recommended Implementation Order
1. Define the screen-state domain model and websocket protocol without removing legacy restore.
2. Introduce a backend terminal emulator and authoritative primary-buffer snapshot for 2A.
3. Update frontend coordinator and page to consume screen snapshots and patches as the rendering truth.
4. Leave legacy restore behind a compatibility path.
5. Extend to alternate screen and fullscreen TUI fidelity in 2B.
## Acceptance Criteria
Phase 2 is complete only when these are true:
- backend can answer "what is on screen now" without asking the frontend to infer it
- reconnect visible state comes from backend screen snapshot, not replayed text
- frontend local snapshot is provisional only
- journal remains the authority for full history, not current screen
- 2A does not block 2B because the authority boundary and protocol family stay the same