Compare commits

..

No commits in common. "master" and "codex/terminal-reconnect-stability" have entirely different histories.

89 changed files with 1656 additions and 10154 deletions

1
.gitignore vendored
View File

@ -50,7 +50,6 @@ Thumbs.db
data/
certs/
.superpowers/
.codex/
.worktrees/
work/*.log
work/dotnet-*/

View File

@ -24,13 +24,3 @@ TermRemoteCtl is a personal remote coding controller for one Windows workstation
- `flutter test apps/mobile_app/test`
- `flutter test apps/mobile_app/integration_test`
- The Flutter `integration_test` suite may still require a supported local runtime target or device on the machine running it.
## Terminal Truth Source
The stable product path uses one rendering truth source:
- backend `restore` plus `output` provide the durable terminal data
- frontend `xterm` is the only visible renderer on the mainline
- local mobile snapshots are provisional cache only
Backend `screen_snapshot`, `screen_patch`, and `screen_sync` are experiment-only paths. They must stay behind an explicit opt-in switch and must not drive the default product terminal.

View File

@ -1,2 +1 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"

View File

@ -1,2 +0,0 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"
#include "Generated.xcconfig"

View File

@ -1,2 +1 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"

View File

@ -1,40 +0,0 @@
platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end

View File

@ -1,16 +0,0 @@
PODS:
- Flutter (1.0.0)
DEPENDENCIES:
- Flutter (from `Flutter`)
EXTERNAL SOURCES:
Flutter:
:path: Flutter
SPEC CHECKSUMS:
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
PODFILE CHECKSUM: 3c41d3a6488201d7e0d12c3265a22101f620ec8e
COCOAPODS: 1.16.2

View File

@ -75,7 +75,7 @@
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"

View File

@ -4,7 +4,4 @@
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>

View File

@ -29,25 +29,6 @@ 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');
@ -133,23 +114,6 @@ 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

@ -1,44 +0,0 @@
import 'dart:io';
class AgentBaseUriStorage {
AgentBaseUriStorage({Future<File> Function()? storageFileLoader})
: _storageFileLoader = storageFileLoader ?? _defaultStorageFile;
static const String storageFileName = 'agent_base_uri_v1.txt';
final Future<File> Function() _storageFileLoader;
Future<Uri?> read() async {
final storageFile = await _storageFileLoader();
if (!await storageFile.exists()) {
return null;
}
try {
final raw = await storageFile.readAsString();
final parsed = Uri.tryParse(raw.trim());
if (parsed == null || parsed.scheme.isEmpty || parsed.host.isEmpty) {
return null;
}
return parsed;
} catch (_) {
return null;
}
}
Future<void> write(Uri uri) async {
try {
final storageFile = await _storageFileLoader();
await storageFile.parent.create(recursive: true);
await storageFile.writeAsString(uri.toString(), flush: true);
} catch (_) {}
}
static Future<File> _defaultStorageFile() async {
final rootDirectory = Directory.systemTemp.parent;
final appSupportDirectory = Directory(
'${rootDirectory.path}/Library/Application Support',
);
return File('${appSupportDirectory.path}/$storageFileName');
}
}

View File

@ -1,36 +1,21 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'agent_base_uri_storage.dart';
import 'agent_api_client.dart';
import '../../features/projects/project.dart';
import '../../features/projects/project_repository.dart';
import '../../features/sessions/session_repository.dart';
import '../../features/sessions/session.dart';
import '../../features/terminal/terminal_snapshot_storage.dart';
final agentBaseUriStorageProvider = Provider<AgentBaseUriStorage>((ref) {
return AgentBaseUriStorage();
});
final agentBaseUriProvider = StateProvider<Uri>((ref) {
return Uri.parse('http://100.81.30.82:5067');
});
final terminalSnapshotStorageProvider = Provider<TerminalSnapshotStorage>((
ref,
) {
return TerminalSnapshotStorage();
});
final agentApiClientProvider = Provider<AgentApiClient>((ref) {
return AgentApiClient(ref.watch(agentBaseUriProvider));
});
final sessionRepositoryProvider = Provider<SessionRepository>((ref) {
return SessionRepository(
ref.watch(agentApiClientProvider),
snapshotStorage: ref.watch(terminalSnapshotStorageProvider),
);
return SessionRepository(ref.watch(agentApiClientProvider));
});
final projectRepositoryProvider = Provider<ProjectRepository>((ref) {

View File

@ -13,16 +13,20 @@ class AgentSocketClient {
);
}
Map<String, dynamic> buildAttachMessage(String sessionId) =>
<String, dynamic>{'type': 'attach', 'sessionId': sessionId};
Map<String, dynamic> buildAttachMessage(String sessionId) => <String, dynamic>{
'type': 'attach',
'sessionId': sessionId,
};
Map<String, dynamic> buildInputMessage(String input, {String? inputId}) =>
<String, dynamic>{
Map<String, dynamic> buildInputMessage(String input) => <String, dynamic>{
'type': 'input',
'input': input,
if (inputId != null && inputId.isNotEmpty) 'inputId': inputId,
};
Map<String, dynamic> buildResizeMessage(int columns, int rows) =>
<String, dynamic>{'type': 'resize', 'columns': columns, 'rows': rows};
<String, dynamic>{
'type': 'resize',
'columns': columns,
'rows': rows,
};
}

View File

@ -1,38 +1,29 @@
import 'dart:convert';
import 'dart:io';
import 'package:shared_preferences/shared_preferences.dart';
import 'preset_command.dart';
class PresetRepository {
PresetRepository({Future<File> Function()? storageFileLoader})
: _storageFileLoader = storageFileLoader ?? _defaultStorageFile;
PresetRepository({
Future<SharedPreferences> Function()? sharedPreferencesLoader,
}) : _sharedPreferencesLoader =
sharedPreferencesLoader ?? SharedPreferences.getInstance;
static const String storageFileName = 'preset_commands_v1.json';
static const String storageKey = 'preset_commands_v1';
final Future<File> Function() _storageFileLoader;
final Future<SharedPreferences> Function() _sharedPreferencesLoader;
Future<List<PresetCommand>> listPresets() async {
final storageFile = await _storageFileLoader();
if (!await storageFile.exists()) {
return const <PresetCommand>[];
}
try {
final raw = await storageFile.readAsString();
final decoded = jsonDecode(raw);
if (decoded is! List) {
return const <PresetCommand>[];
}
return decoded
.whereType<Map>()
.map((item) => Map<String, dynamic>.from(item))
.map(PresetCommand.fromJson)
.where((preset) => preset.id.isNotEmpty)
.toList(growable: false);
} catch (_) {
return const <PresetCommand>[];
}
final sharedPreferences = await _sharedPreferencesLoader();
final storedItems =
sharedPreferences.getStringList(storageKey) ?? const <String>[];
return storedItems
.map((item) => jsonDecode(item))
.whereType<Map<String, dynamic>>()
.map(PresetCommand.fromJson)
.where((preset) => preset.id.isNotEmpty)
.toList(growable: false);
}
Future<PresetCommand> savePreset(PresetCommand preset) async {
@ -56,21 +47,10 @@ class PresetRepository {
}
Future<void> _persistPresets(List<PresetCommand> presets) async {
final storageFile = await _storageFileLoader();
await storageFile.parent.create(recursive: true);
await storageFile.writeAsString(
jsonEncode(
presets.map((preset) => preset.toJson()).toList(growable: false),
),
flush: true,
final sharedPreferences = await _sharedPreferencesLoader();
await sharedPreferences.setStringList(
storageKey,
presets.map((preset) => jsonEncode(preset.toJson())).toList(),
);
}
static Future<File> _defaultStorageFile() async {
final rootDirectory = Directory.systemTemp.parent;
final appSupportDirectory = Directory(
'${rootDirectory.path}/Library/Application Support',
);
return File('${appSupportDirectory.path}/$storageFileName');
}
}

View File

@ -204,9 +204,6 @@ class _ProjectDetailPageState extends ConsumerState<ProjectDetailPage> {
await ref
.read(projectRepositoryProvider)
.deleteProject(project.projectId);
await ref
.read(terminalSnapshotStorageProvider)
.deleteByProjectId(project.projectId);
ref.invalidate(projectsProvider);
ref.invalidate(sessionsProvider);
if (!mounted) {

View File

@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dart:async';
import '../../app/app_theme.dart';
import '../../app/ui_shell.dart';
@ -31,7 +30,6 @@ class _ProjectListPageState extends ConsumerState<ProjectListPage> {
_agentUrlController = TextEditingController(
text: ref.read(agentBaseUriProvider).toString(),
);
unawaited(_restoreAgentUrl());
}
@override
@ -42,8 +40,9 @@ class _ProjectListPageState extends ConsumerState<ProjectListPage> {
Future<void> _reloadProjects() async {
_detailFutures.clear();
ref.invalidate(projectsProvider);
try {
await ref.refresh(projectsProvider.future);
await ref.read(projectsProvider.future);
} catch (error) {
if (!mounted) {
return;
@ -53,26 +52,6 @@ class _ProjectListPageState extends ConsumerState<ProjectListPage> {
}
}
Future<void> _restoreAgentUrl() async {
final restoredUri = await ref.read(agentBaseUriStorageProvider).read();
if (!mounted || restoredUri == null) {
return;
}
final currentUri = ref.read(agentBaseUriProvider);
if (currentUri == restoredUri) {
if (_agentUrlController.text != restoredUri.toString()) {
_agentUrlController.text = restoredUri.toString();
}
await _reloadProjects();
return;
}
ref.read(agentBaseUriProvider.notifier).state = restoredUri;
_agentUrlController.text = restoredUri.toString();
await _reloadProjects();
}
void _showMessage(String message) {
ScaffoldMessenger.of(
context,
@ -89,11 +68,11 @@ class _ProjectListPageState extends ConsumerState<ProjectListPage> {
}
final current = ref.read(agentBaseUriProvider);
if (current != parsedUri) {
ref.read(agentBaseUriProvider.notifier).state = parsedUri;
if (current == parsedUri) {
return;
}
await ref.read(agentBaseUriStorageProvider).write(parsedUri);
ref.read(agentBaseUriProvider.notifier).state = parsedUri;
ref.invalidate(sessionsProvider);
await _reloadProjects();
}
@ -220,9 +199,6 @@ class _ProjectListPageState extends ConsumerState<ProjectListPage> {
await ref
.read(projectRepositoryProvider)
.deleteProject(project.projectId);
await ref
.read(terminalSnapshotStorageProvider)
.deleteByProjectId(project.projectId);
ref.invalidate(sessionsProvider);
await _reloadProjects();
} catch (error) {

View File

@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dart:async';
import '../../app/app_theme.dart';
import '../../app/ui_shell.dart';
@ -24,7 +23,6 @@ class _SessionListPageState extends ConsumerState<SessionListPage> {
_agentUrlController = TextEditingController(
text: ref.read(agentBaseUriProvider).toString(),
);
unawaited(_restoreAgentUrl());
}
@override
@ -43,25 +41,6 @@ class _SessionListPageState extends ConsumerState<SessionListPage> {
await _reloadSessions();
}
Future<void> _restoreAgentUrl() async {
final restoredUri = await ref.read(agentBaseUriStorageProvider).read();
if (!mounted || restoredUri == null) {
return;
}
final currentUri = ref.read(agentBaseUriProvider);
if (currentUri == restoredUri) {
if (_agentUrlController.text != restoredUri.toString()) {
_agentUrlController.text = restoredUri.toString();
}
return;
}
ref.read(agentBaseUriProvider.notifier).state = restoredUri;
_agentUrlController.text = restoredUri.toString();
await _reloadSessions();
}
Future<void> _createSession() async {
final repository = ref.read(sessionRepositoryProvider);
var sessionNameInput = '';
@ -180,16 +159,18 @@ class _SessionListPageState extends ConsumerState<SessionListPage> {
}
final current = ref.read(agentBaseUriProvider);
if (current != parsedUri) {
ref.read(agentBaseUriProvider.notifier).state = parsedUri;
if (current == parsedUri) {
return;
}
await ref.read(agentBaseUriStorageProvider).write(parsedUri);
ref.read(agentBaseUriProvider.notifier).state = parsedUri;
await _reloadSessions();
}
Future<void> _reloadSessions() async {
ref.invalidate(sessionsProvider);
try {
await ref.refresh(sessionsProvider.future);
await ref.read(sessionsProvider.future);
} catch (error) {
if (!mounted) {
return;

View File

@ -1,21 +1,14 @@
import 'package:term_remote_ctl/core/network/agent_api_client.dart';
import 'package:term_remote_ctl/features/sessions/session.dart';
import 'package:term_remote_ctl/features/terminal/terminal_snapshot_storage.dart';
class SessionRepository {
SessionRepository(this._client, {TerminalSnapshotStorage? snapshotStorage})
: _snapshotStorage = snapshotStorage;
SessionRepository(this._client);
final AgentApiClient _client;
final TerminalSnapshotStorage? _snapshotStorage;
Future<List<Session>> listSessions() async {
final sessions = await _client.listSessions();
final mapped = sessions.map(Session.fromJson).toList(growable: false);
await _snapshotStorage?.pruneToSessionIds(
mapped.map((session) => session.sessionId).toSet(),
);
return mapped;
return sessions.map(Session.fromJson).toList(growable: false);
}
Future<Session> createSession({
@ -31,8 +24,7 @@ class SessionRepository {
return Session.fromJson(session);
}
Future<void> deleteSession(String sessionId) async {
await _client.deleteSession(sessionId);
await _snapshotStorage?.delete(sessionId);
Future<void> deleteSession(String sessionId) {
return _client.deleteSession(sessionId);
}
}

View File

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

View File

@ -5,15 +5,13 @@ import 'package:flutter/material.dart';
class RepeatableTerminalKeyButton extends StatefulWidget {
const RepeatableTerminalKeyButton({
super.key,
required this.label,
required this.onPressed,
this.label,
this.icon,
this.enabled = true,
this.repeatable = false,
}) : assert(label != null || icon != null);
});
final String? label;
final IconData? icon;
final String label;
final VoidCallback onPressed;
final bool enabled;
final bool repeatable;
@ -63,15 +61,11 @@ class _RepeatableTerminalKeyButtonState
backgroundColor: const Color(0xFF151A20),
side: const BorderSide(color: Color(0xFF3F3428)),
minimumSize: const Size(0, 34),
padding: EdgeInsets.symmetric(
horizontal: widget.label == null ? 8 : 10,
),
padding: const EdgeInsets.symmetric(horizontal: 10),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
child: widget.icon != null
? Icon(widget.icon, size: 18)
: Text(widget.label!),
child: Text(widget.label),
),
);
}

View File

@ -0,0 +1,227 @@
import 'dart:async';
import 'package:flutter/material.dart';
enum TerminalInputMode { buffered, direct }
typedef TerminalBufferedSubmit = FutureOr<void> Function(String command);
typedef TerminalDirectInputSink = void Function(String input);
class TerminalInputController extends ChangeNotifier {
TerminalInputController({
required TerminalBufferedSubmit onBufferedSubmit,
required TerminalDirectInputSink onDirectInput,
}) : _onBufferedSubmit = onBufferedSubmit,
_onDirectInput = onDirectInput {
focusNode.addListener(notifyListeners);
textController.addListener(_handleTextChanged);
_applyEditingValue(_bufferedEditingValue);
}
static const String _directInputSentinel = ' ';
static const int _directInputSelectionOffset = 2;
final FocusNode focusNode = FocusNode();
final TextEditingController textController = TextEditingController();
final TerminalBufferedSubmit _onBufferedSubmit;
final TerminalDirectInputSink _onDirectInput;
TerminalInputMode _mode = TerminalInputMode.buffered;
String _bufferedDraft = '';
String? _composingText;
bool _isApplyingValue = false;
bool _isDisposed = false;
TerminalInputMode get mode => _mode;
bool get isDirectInputEnabled => _mode == TerminalInputMode.direct;
String? get composingText => _composingText;
String get hintText => isDirectInputEnabled
? 'Direct mode: keys send immediately'
: 'Buffered mode: type a command and send';
Future<void> submit() async {
if (isDirectInputEnabled) {
_composingText = null;
_onDirectInput('\r');
_restoreDirectEditingValue();
notifyListeners();
return;
}
final command = _bufferedDraft;
if (command.trim().isEmpty) {
return;
}
await _onBufferedSubmit(command);
_bufferedDraft = '';
_applyEditingValue(_bufferedEditingValue);
notifyListeners();
}
void toggleMode() {
setMode(
isDirectInputEnabled
? TerminalInputMode.buffered
: TerminalInputMode.direct,
);
}
void setMode(TerminalInputMode mode) {
if (_mode == mode) {
if (mode == TerminalInputMode.direct) {
_restoreDirectEditingValue();
}
return;
}
_mode = mode;
_composingText = null;
_applyEditingValue(
mode == TerminalInputMode.direct
? _directEditingValue
: _bufferedEditingValue,
);
notifyListeners();
}
void requestFocus() {
focusNode.requestFocus();
}
void syncDirectSelection() {
if (!isDirectInputEnabled) {
return;
}
_restoreDirectEditingValue();
}
@override
void dispose() {
_isDisposed = true;
focusNode.removeListener(notifyListeners);
textController.removeListener(_handleTextChanged);
focusNode.dispose();
textController.dispose();
super.dispose();
}
void _handleTextChanged() {
if (_isApplyingValue) {
return;
}
final value = textController.value;
if (!isDirectInputEnabled) {
_bufferedDraft = value.text;
final nextComposing = value.composing.isCollapsed
? null
: value.composing.textInside(value.text);
if (_composingText != nextComposing) {
_composingText = nextComposing;
notifyListeners();
}
return;
}
if (!value.composing.isCollapsed) {
final nextComposing = value.composing.textInside(value.text);
if (_composingText != nextComposing) {
_composingText = nextComposing;
notifyListeners();
}
return;
}
var didChange = false;
if (_composingText != null) {
_composingText = null;
didChange = true;
}
final backspaceCount = _detectBackspaceCount(value.text);
if (backspaceCount > 0) {
for (var index = 0; index < backspaceCount; index += 1) {
_onDirectInput('\x7f');
}
_restoreDirectEditingValue();
if (didChange) {
notifyListeners();
}
return;
}
final insertedText = _extractInsertedText(value.text);
if (insertedText.isNotEmpty) {
_onDirectInput(insertedText);
_restoreDirectEditingValue();
if (didChange) {
notifyListeners();
}
return;
}
if (value.text != _directInputSentinel ||
value.selection.baseOffset != _directInputSelectionOffset ||
value.selection.extentOffset != _directInputSelectionOffset) {
_restoreDirectEditingValue();
if (didChange) {
notifyListeners();
}
return;
}
if (didChange) {
notifyListeners();
}
}
int _detectBackspaceCount(String text) {
if (text.length >= _directInputSentinel.length) {
return 0;
}
return _directInputSentinel.length - text.length;
}
String _extractInsertedText(String text) {
if (text.isEmpty || text == _directInputSentinel) {
return '';
}
if (text.startsWith(_directInputSentinel)) {
return text.substring(_directInputSentinel.length);
}
return text;
}
void _restoreDirectEditingValue() {
_applyEditingValue(_directEditingValue);
}
void _applyEditingValue(TextEditingValue value) {
if (_isDisposed) {
return;
}
_isApplyingValue = true;
textController.value = value;
_isApplyingValue = false;
}
TextEditingValue get _bufferedEditingValue => TextEditingValue(
text: _bufferedDraft,
selection: TextSelection.collapsed(offset: _bufferedDraft.length),
);
TextEditingValue get _directEditingValue => const TextEditingValue(
text: _directInputSentinel,
selection: TextSelection.collapsed(offset: _directInputSelectionOffset),
);
}

View File

@ -1,19 +0,0 @@
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?) ?? '',
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +0,0 @@
enum TerminalRestoreDecision { keepLocal, replaceWithRestore }
TerminalRestoreDecision decideTerminalRestore({
required String currentText,
required String restoreText,
}) {
return TerminalRestoreDecision.replaceWithRestore;
}

View File

@ -1,36 +0,0 @@
import 'terminal_screen_snapshot.dart';
class TerminalRestorePayload {
const TerminalRestorePayload({
required this.sessionId,
required this.sequence,
required this.screenText,
required this.pendingInput,
this.screenSnapshot,
});
final String sessionId;
final int sequence;
final String screenText;
final String pendingInput;
final TerminalScreenSnapshot? screenSnapshot;
String buildReplayFrame() {
final screenReplay = screenSnapshot?.toReplaySequence() ?? screenText;
return '$screenReplay$pendingInput';
}
factory TerminalRestorePayload.fromJson(Map<String, dynamic> json) {
return TerminalRestorePayload(
sessionId: json['sessionId'] as String,
sequence: json['sequence'] as int,
screenText: (json['screenText'] as String?) ?? '',
pendingInput: (json['pendingInput'] as String?) ?? '',
screenSnapshot: json['screenSnapshot'] is Map
? TerminalScreenSnapshot.fromJson(
Map<String, dynamic>.from(json['screenSnapshot'] as Map),
)
: null,
);
}
}

View File

@ -1,71 +0,0 @@
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

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

View File

@ -1,121 +0,0 @@
import 'terminal_screen_patch.dart';
import 'terminal_screen_snapshot.dart';
/// Experiment-only state reducer for backend-authored screen snapshots and patches.
class TerminalScreenState {
const TerminalScreenState({
required this.sessionId,
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 renderTerminalScreenText(
lines: lines,
cursorRow: cursorRow,
cursorColumn: cursorColumn,
cursorVisible: cursorVisible,
);
}
String toReplaySequence() {
final lines = activeBuffer == 'alternate' ? alternateLines : primaryLines;
return buildTerminalScreenReplay(
lines: lines,
cursorRow: cursorRow,
cursorColumn: cursorColumn,
cursorVisible: cursorVisible,
);
}
}

View File

@ -4,19 +4,14 @@ import 'package:flutter/foundation.dart';
import '../../core/network/agent_api_client.dart';
import '../sessions/session.dart';
import 'history_window.dart';
import 'terminal_diagnostic_log.dart';
import 'history_window.dart';
import 'terminal_interaction_controller.dart';
import 'terminal_output_payload.dart';
import 'terminal_restore_payload.dart';
import 'terminal_socket_session.dart';
typedef CancelReconnect = void Function();
typedef ReconnectScheduler =
CancelReconnect Function(Duration delay, Future<void> Function() callback);
typedef CancelResize = void Function();
typedef ResizeScheduler =
CancelResize Function(Duration delay, void Function() callback);
typedef TerminalSessionFactory =
TerminalSocketSession Function({
required Uri baseUri,
@ -31,25 +26,19 @@ class TerminalViewport {
}
class TerminalSessionCoordinator extends ChangeNotifier {
static int _inputDispatchInstanceCounter = 0;
TerminalSessionCoordinator({
required this.controller,
required this.apiClient,
required this.session,
required this.sessionFactory,
required this.onFrame,
required this.onRestore,
required this.viewportProvider,
Uri? baseUri,
this.onHistoryLoaded,
this.diagnosticLog,
ReconnectScheduler? reconnectScheduler,
ResizeScheduler? resizeScheduler,
}) : baseUri = baseUri ?? _defaultBaseUri,
_reconnectScheduler = reconnectScheduler ?? _defaultReconnectScheduler,
_resizeScheduler = resizeScheduler ?? _defaultResizeScheduler,
_inputDispatchScope = _buildInputDispatchScope(session.sessionId);
_reconnectScheduler = reconnectScheduler ?? _defaultReconnectScheduler;
static final Uri _defaultBaseUri = Uri(
scheme: 'https',
@ -57,9 +46,7 @@ class TerminalSessionCoordinator extends ChangeNotifier {
port: 9443,
);
static const Duration reconnectDelay = Duration(seconds: 1);
static const Duration resizeDebounceDelay = Duration(milliseconds: 120);
static const int historyPageSize = 200;
static const int recentRecoveryOutputLineTarget = 1000;
static const int initialHistoryLineCount = 200;
static const int pendingInputCharacterLimit = 2048;
final TerminalInteractionController controller;
@ -67,64 +54,35 @@ class TerminalSessionCoordinator extends ChangeNotifier {
final Session session;
final TerminalSessionFactory sessionFactory;
final void Function(String frame) onFrame;
final void Function(TerminalRestorePayload restore) onRestore;
final TerminalViewport Function() viewportProvider;
final Uri baseUri;
final void Function(HistoryWindow history)? onHistoryLoaded;
final TerminalDiagnosticLog? diagnosticLog;
final ReconnectScheduler _reconnectScheduler;
final ResizeScheduler _resizeScheduler;
final String _inputDispatchScope;
TerminalSocketSession? _socketSession;
CancelReconnect? _cancelReconnect;
CancelResize? _cancelResize;
int _historyLineCount = initialHistoryLineCount;
bool _isLoadingOlderHistory = false;
bool _isDisposed = false;
bool _connectionAttemptInProgress = false;
bool _reconnectPending = false;
String _connectionStatus = 'Connecting...';
final List<_PendingInputDispatch> _pendingInputs = <_PendingInputDispatch>[];
final List<String> _pendingInputs = <String>[];
int _pendingInputCharacterCount = 0;
int _sessionGeneration = 0;
int? _pendingResizeColumns;
int? _pendingResizeRows;
int? _lastSentColumns;
int? _lastSentRows;
int? _lastReceivedSequence;
int? _recoveryGapBaselineSequence;
bool _isBackendResizeEnabled = true;
int _nextInputId = 0;
bool get isLoadingOlderHistory => _isLoadingOlderHistory;
String get connectionStatus => _connectionStatus;
int? get lastReceivedSequence => _lastReceivedSequence;
void setBackendResizeEnabled(bool enabled) {
if (_isBackendResizeEnabled == enabled) {
return;
}
_isBackendResizeEnabled = enabled;
if (enabled) {
_flushPendingResize();
}
}
Future<void> start({bool isReconnect = false}) async {
_cancelPendingReconnect();
_cancelPendingResize();
_reconnectPending = false;
if (_isDisposed) {
return;
}
final sessionGeneration = ++_sessionGeneration;
_recoveryGapBaselineSequence = isReconnect ? _lastReceivedSequence : null;
if (isReconnect) {
controller.markReconnecting();
_connectionStatus = 'Reconnecting to ${session.name}...';
@ -152,29 +110,9 @@ class TerminalSessionCoordinator extends ChangeNotifier {
try {
await socketSession.connect(
onOutput: (output) {
if (!_isCurrentSession(socketSession, sessionGeneration)) {
return;
}
unawaited(_handleOutput(output));
},
onRestore: (restore) {
if (!_isCurrentSession(socketSession, sessionGeneration)) {
return;
}
_handleRestore(restore);
},
onInputAck: (inputId) {
if (!_isCurrentSession(socketSession, sessionGeneration)) {
return;
}
_handleInputAck(inputId);
},
onFrame: _handleFrame,
onDisconnected: () {
if (!_isCurrentSession(socketSession, sessionGeneration)) {
if (_isDisposed || !identical(_socketSession, socketSession)) {
return;
}
@ -182,19 +120,23 @@ class TerminalSessionCoordinator extends ChangeNotifier {
},
);
if (!_isCurrentSession(socketSession, sessionGeneration)) {
if (_isDisposed || !identical(_socketSession, socketSession)) {
return;
}
final viewport = viewportProvider();
_sendResize(socketSession, viewport.columns, viewport.rows);
socketSession.sendResize(viewport.columns, viewport.rows);
diagnosticLog?.add(
'socket.resize.send',
'${viewport.columns}x${viewport.rows}',
);
_flushPendingInputs(socketSession);
controller.markConnected();
_connectionStatus = 'Attached to ${session.name}';
diagnosticLog?.add('socket.attach.ack', session.sessionId);
notifyListeners();
} catch (error) {
if (!_isCurrentSession(socketSession, sessionGeneration)) {
if (_isDisposed || !identical(_socketSession, socketSession)) {
return;
}
@ -204,29 +146,20 @@ class TerminalSessionCoordinator extends ChangeNotifier {
notifyListeners();
_scheduleReconnect();
} finally {
if (sessionGeneration == _sessionGeneration) {
_connectionAttemptInProgress = false;
}
_connectionAttemptInProgress = false;
}
}
void handleTerminalResize(int columns, int rows) {
diagnosticLog?.add('ui.terminal.resize', '${columns}x${rows}');
if (columns <= 0 || rows <= 0 || _socketSession == null) {
return;
}
_pendingResizeColumns = columns;
_pendingResizeRows = rows;
_cancelResize?.call();
_cancelResize = _resizeScheduler(resizeDebounceDelay, _flushPendingResize);
_socketSession?.sendResize(columns, rows);
}
void sendInput(String input) {
final socketSession = _socketSession;
if (socketSession == null) {
if (_shouldBufferInput(input)) {
_enqueuePendingInput(input);
_bufferInput(input);
diagnosticLog?.add(
'socket.input.buffer',
'reason=no-session input=${_formatInputForDiagnostics(input)}',
@ -241,37 +174,27 @@ class TerminalSessionCoordinator extends ChangeNotifier {
return;
}
final pendingInput = _enqueuePendingInput(input);
if (pendingInput == null) {
diagnosticLog?.add('socket.input.skip', 'reason=empty-input');
return;
}
final result = socketSession.sendInput(
input,
inputId: pendingInput.inputId,
);
final result = socketSession.sendInput(input);
switch (result) {
case TerminalSocketDispatchResult.sent:
diagnosticLog?.add(
'socket.input.tx',
'id=${pendingInput.inputId} ${_formatInputForDiagnostics(input)}',
_formatInputForDiagnostics(input),
);
case TerminalSocketDispatchResult.noTransport:
if (_shouldBufferInput(input)) {
_bufferInput(input);
diagnosticLog?.add(
'socket.input.buffer',
'reason=no-transport id=${pendingInput.inputId} input=${_formatInputForDiagnostics(input)}',
'reason=no-transport input=${_formatInputForDiagnostics(input)}',
);
} else {
_removePendingInputById(pendingInput.inputId);
diagnosticLog?.add(
'socket.input.skip',
'reason=no-transport id=${pendingInput.inputId} input=${_formatInputForDiagnostics(input)}',
'reason=no-transport input=${_formatInputForDiagnostics(input)}',
);
}
case TerminalSocketDispatchResult.emptyInput:
_removePendingInputById(pendingInput.inputId);
diagnosticLog?.add('socket.input.skip', 'reason=empty-input');
}
}
@ -282,14 +205,12 @@ class TerminalSessionCoordinator extends ChangeNotifier {
}
_isLoadingOlderHistory = true;
diagnosticLog?.add(
'history.load.older',
'beforeSeq=${controller.historyWindow.oldestSequence}',
);
_historyLineCount += initialHistoryLineCount;
diagnosticLog?.add('history.load.older', 'lineCount=$_historyLineCount');
notifyListeners();
try {
await _loadHistory(loadOlder: true);
await _loadHistory();
} finally {
_isLoadingOlderHistory = false;
notifyListeners();
@ -303,7 +224,6 @@ class TerminalSessionCoordinator extends ChangeNotifier {
Future<void> suspendForBackground() async {
_cancelPendingReconnect();
_cancelPendingResize();
if (_isDisposed) {
return;
@ -319,202 +239,41 @@ class TerminalSessionCoordinator extends ChangeNotifier {
Future<void> close() async {
_isDisposed = true;
_cancelPendingReconnect();
_cancelPendingResize();
_pendingInputs.clear();
_pendingInputCharacterCount = 0;
await _closeActiveSession();
}
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) {
diagnosticLog?.add('socket.frame.rx', output.chunk);
void _handleFrame(String chunk) {
diagnosticLog?.add('socket.frame.rx', chunk);
controller.registerIncomingFrame();
controller.applyFrame(output.chunk);
if (output.sequence > 0) {
_lastReceivedSequence = output.sequence;
}
onFrame(output.chunk);
controller.applyFrame(chunk);
onFrame(chunk);
}
void _handleRestore(TerminalRestorePayload restore) {
diagnosticLog?.add(
'socket.restore.rx',
'sequence=${restore.sequence} pending=${restore.pendingInput.length}',
);
final recoveryGapBaselineSequence = _recoveryGapBaselineSequence;
_lastReceivedSequence = restore.sequence;
_recoveryGapBaselineSequence = null;
onRestore(restore);
if (recoveryGapBaselineSequence != null &&
recoveryGapBaselineSequence < restore.sequence) {
unawaited(
_recoverHistoricalOutput(
afterSequence: recoveryGapBaselineSequence,
stopBeforeSequence: restore.sequence,
),
);
}
}
void _handleInputAck(String inputId) {
if (_removePendingInputById(inputId)) {
diagnosticLog?.add('socket.input.ack', inputId);
}
}
Future<HistoryWindow?> loadRecentHistoryWindow() async {
var history = controller.historyWindow.outputSeedText.isNotEmpty
? controller.historyWindow
: await _loadHistory();
while (history != null &&
history.hasMoreAbove &&
_countOutputLines(history.outputSeedText) < recentRecoveryOutputLineTarget) {
history = await _loadHistory(loadOlder: true);
}
return history;
}
Future<HistoryWindow?> _loadHistory({bool loadOlder = false}) async {
Future<void> _loadHistory() async {
try {
final payload = await apiClient.getSessionJournal(
final payload = await apiClient.getSessionHistory(
session.sessionId,
limit: historyPageSize,
beforeSequence: loadOlder
? controller.historyWindow.oldestSequence
: null,
lineCount: _historyLineCount,
);
final history = _buildHistoryWindow(
payload,
existing: loadOlder ? controller.historyWindow : null,
final history = HistoryWindow(
lines: ((payload['lines'] as List?) ?? const <dynamic>[])
.map((line) => line.toString())
.toList(growable: false),
hasMoreAbove: payload['hasMoreAbove'] == true,
);
controller.loadHistory(history);
onHistoryLoaded?.call(history);
diagnosticLog?.add(
'history.loaded',
'${history.lines.length} lines, more=${history.hasMoreAbove}, newest=${history.newestSequence}',
'${history.lines.length} lines, more=${history.hasMoreAbove}',
);
return history;
} catch (_) {
return null;
}
}
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 (_) {}
}
Future<void> _recoverHistoricalOutput({
required int afterSequence,
required int stopBeforeSequence,
}) 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;
}
var reachedStop = false;
for (final item in items) {
if (item.sequence <= cursor) {
continue;
}
if (item.sequence >= stopBeforeSequence) {
reachedStop = true;
break;
}
if (item.kind == 'output') {
diagnosticLog?.add('socket.frame.recover', item.payload);
controller.registerIncomingFrame();
controller.applyFrame(item.payload);
onFrame(item.payload);
}
cursor = item.sequence;
}
if (reachedStop || payload['hasMoreAfter'] != true) {
break;
}
}
} catch (_) {}
}
void _scheduleReconnect() {
_cancelPendingReconnect();
_cancelPendingResize();
_reconnectPending = true;
controller.markReconnecting();
_connectionStatus = 'Connection lost. Reconnecting...';
@ -536,7 +295,6 @@ class TerminalSessionCoordinator extends ChangeNotifier {
}
void _disposeActiveSessionInBackground() {
_cancelPendingResize();
final activeSession = _socketSession;
_socketSession = null;
if (activeSession != null) {
@ -545,7 +303,6 @@ class TerminalSessionCoordinator extends ChangeNotifier {
}
Future<void> _closeActiveSession() async {
_cancelPendingResize();
final activeSession = _socketSession;
_socketSession = null;
if (activeSession != null) {
@ -563,14 +320,6 @@ class TerminalSessionCoordinator extends ChangeNotifier {
return timer.cancel;
}
static CancelResize _defaultResizeScheduler(
Duration delay,
void Function() callback,
) {
final timer = Timer(delay, callback);
return timer.cancel;
}
static String _formatInputForDiagnostics(String input) {
return input.replaceAll('\r', r'\r').replaceAll('\n', r'\n');
}
@ -583,23 +332,18 @@ class TerminalSessionCoordinator extends ChangeNotifier {
return _connectionAttemptInProgress || _reconnectPending;
}
_PendingInputDispatch? _enqueuePendingInput(String input) {
void _bufferInput(String input) {
if (input.isEmpty) {
return null;
return;
}
final dispatch = _PendingInputDispatch(
inputId: _buildPendingInputId(),
input: input,
);
_pendingInputs.add(dispatch);
_pendingInputs.add(input);
_pendingInputCharacterCount += input.length;
while (_pendingInputCharacterCount > pendingInputCharacterLimit &&
_pendingInputs.isNotEmpty) {
final removed = _pendingInputs.removeAt(0);
_pendingInputCharacterCount -= removed.input.length;
_pendingInputCharacterCount -= removed.length;
}
return dispatch;
}
void _flushPendingInputs(TerminalSocketSession socketSession) {
@ -607,238 +351,25 @@ class TerminalSessionCoordinator extends ChangeNotifier {
return;
}
for (final pendingInput in _pendingInputs) {
final result = socketSession.sendInput(
pendingInput.input,
inputId: pendingInput.inputId,
);
final pendingInputs = List<String>.of(_pendingInputs);
_pendingInputs.clear();
_pendingInputCharacterCount = 0;
for (var index = 0; index < pendingInputs.length; index += 1) {
final input = pendingInputs[index];
final result = socketSession.sendInput(input);
if (result == TerminalSocketDispatchResult.sent) {
diagnosticLog?.add(
'socket.input.flush',
'id=${pendingInput.inputId} ${_formatInputForDiagnostics(pendingInput.input)}',
);
diagnosticLog?.add('socket.input.flush', _formatInputForDiagnostics(input));
continue;
}
if (result == TerminalSocketDispatchResult.noTransport) {
diagnosticLog?.add(
'socket.input.flush.pause',
pendingInput.inputId,
);
for (final remainingInput in pendingInputs.skip(index)) {
_bufferInput(remainingInput);
}
}
break;
}
}
bool _removePendingInputById(String inputId) {
final index = _pendingInputs.indexWhere(
(pendingInput) => pendingInput.inputId == inputId,
);
if (index < 0) {
return false;
}
final removed = _pendingInputs.removeAt(index);
_pendingInputCharacterCount -= removed.input.length;
if (_pendingInputCharacterCount < 0) {
_pendingInputCharacterCount = 0;
}
return true;
}
void _flushPendingResize() {
_cancelResize = null;
if (_isDisposed) {
return;
}
final socketSession = _socketSession;
final columns = _pendingResizeColumns;
final rows = _pendingResizeRows;
if (socketSession == null || columns == null || rows == null) {
return;
}
if (!_isBackendResizeEnabled) {
return;
}
_pendingResizeColumns = null;
_pendingResizeRows = null;
if (_lastSentColumns == columns && _lastSentRows == rows) {
return;
}
_sendResize(socketSession, columns, rows);
}
void _cancelPendingResize() {
_cancelResize?.call();
_cancelResize = null;
_pendingResizeColumns = null;
_pendingResizeRows = null;
}
void _sendResize(TerminalSocketSession socketSession, int columns, int rows) {
socketSession.sendResize(columns, rows);
_lastSentColumns = columns;
_lastSentRows = rows;
diagnosticLog?.add('socket.resize.send', '${columns}x${rows}');
}
bool _isCurrentSession(
TerminalSocketSession socketSession,
int sessionGeneration,
) {
return !_isDisposed &&
sessionGeneration == _sessionGeneration &&
identical(_socketSession, socketSession);
}
HistoryWindow _buildHistoryWindow(
Map<String, dynamic> payload, {
HistoryWindow? existing,
}) {
final items = _readJournalItems(payload);
final lines = _renderHistoryLines(items);
final outputSeedText = _buildOutputSeed(items);
final mergedLines = existing == null
? lines
: <String>[...lines, ...existing.lines];
final mergedOutputSeed = existing == null
? outputSeedText
: outputSeedText + existing.outputSeedText;
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,
outputSeedText: mergedOutputSeed,
oldestSequence: oldestSequence,
newestSequence: newestSequence,
currentSequence: (payload['currentSequence'] as num?)?.toInt(),
);
}
String _buildOutputSeed(List<_JournalItem> items) {
final buffer = StringBuffer();
for (final item in items) {
if (item.kind == 'output') {
buffer.write(item.payload);
}
}
return buffer.toString();
}
int _countOutputLines(String text) {
if (text.isEmpty) {
return 0;
}
final normalized = text.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
final parts = normalized.split('\n');
if (parts.isNotEmpty && parts.last.isEmpty) {
return parts.length - 1;
}
return parts.length;
}
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)),
)
.where((item) => item.sessionId == session.sessionId)
.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');
}
String _buildPendingInputId() {
_nextInputId += 1;
return '$_inputDispatchScope-input-$_nextInputId';
}
static String _buildInputDispatchScope(String sessionId) {
_inputDispatchInstanceCounter += 1;
return '$sessionId-${_inputDispatchInstanceCounter}';
}
}
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?) ?? '',
);
}
}
class _PendingInputDispatch {
const _PendingInputDispatch({
required this.inputId,
required this.input,
});
final String inputId;
final String input;
}

View File

@ -1,35 +0,0 @@
class TerminalSnapshot {
const TerminalSnapshot({
required this.sessionId,
required this.projectId,
required this.sessionName,
required this.bufferText,
required this.updatedAtUtc,
});
final String sessionId;
final String? projectId;
final String sessionName;
final String bufferText;
final String updatedAtUtc;
factory TerminalSnapshot.fromJson(Map<String, dynamic> json) {
return TerminalSnapshot(
sessionId: json['sessionId'] as String,
projectId: json['projectId'] as String?,
sessionName: (json['sessionName'] as String?) ?? '',
bufferText: (json['bufferText'] as String?) ?? '',
updatedAtUtc: (json['updatedAtUtc'] as String?) ?? '',
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'sessionId': sessionId,
'projectId': projectId,
'sessionName': sessionName,
'bufferText': bufferText,
'updatedAtUtc': updatedAtUtc,
};
}
}

View File

@ -1,111 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'terminal_snapshot.dart';
class TerminalSnapshotStorage {
TerminalSnapshotStorage({Future<File> Function()? storageFileLoader})
: _storageFileLoader = storageFileLoader ?? _defaultStorageFile;
static const String storageFileName = 'terminal_snapshots_v1.json';
final Future<File> Function() _storageFileLoader;
Future<TerminalSnapshot?> read(String sessionId) async {
final snapshots = await _readAll();
return snapshots[sessionId];
}
Future<void> save(TerminalSnapshot snapshot) async {
try {
final snapshots = await _readAll();
snapshots[snapshot.sessionId] = snapshot;
await _writeAll(snapshots);
} catch (_) {}
}
Future<void> delete(String sessionId) async {
try {
final snapshots = await _readAll();
if (snapshots.remove(sessionId) == null) {
return;
}
await _writeAll(snapshots);
} catch (_) {}
}
Future<void> deleteByProjectId(String projectId) async {
try {
final snapshots = await _readAll();
snapshots.removeWhere((_, snapshot) => snapshot.projectId == projectId);
await _writeAll(snapshots);
} catch (_) {}
}
Future<void> pruneToSessionIds(Set<String> sessionIds) async {
try {
final snapshots = await _readAll();
snapshots.removeWhere((sessionId, _) => !sessionIds.contains(sessionId));
await _writeAll(snapshots);
} catch (_) {}
}
Future<Map<String, TerminalSnapshot>> _readAll() async {
final storageFile = await _storageFileLoader();
if (!await storageFile.exists()) {
return <String, TerminalSnapshot>{};
}
try {
final raw = await storageFile.readAsString(encoding: utf8);
if (raw.trim().isEmpty) {
return <String, TerminalSnapshot>{};
}
final decoded = jsonDecode(raw);
if (decoded is! List) {
return <String, TerminalSnapshot>{};
}
final snapshots = <String, TerminalSnapshot>{};
for (final item in decoded) {
if (item is! Map) {
continue;
}
final snapshot = TerminalSnapshot.fromJson(
Map<String, dynamic>.from(item),
);
if (snapshot.sessionId.isEmpty) {
continue;
}
snapshots[snapshot.sessionId] = snapshot;
}
return snapshots;
} catch (_) {
return <String, TerminalSnapshot>{};
}
}
Future<void> _writeAll(Map<String, TerminalSnapshot> snapshots) async {
final storageFile = await _storageFileLoader();
await storageFile.parent.create(recursive: true);
await storageFile.writeAsString(
jsonEncode(
snapshots.values
.map((snapshot) => snapshot.toJson())
.toList(growable: false),
),
encoding: utf8,
flush: true,
);
}
static Future<File> _defaultStorageFile() async {
final rootDirectory = Directory.systemTemp.parent;
final appSupportDirectory = Directory(
'${rootDirectory.path}/Library/Application Support',
);
return File('${appSupportDirectory.path}/$storageFileName');
}
}

View File

@ -7,8 +7,6 @@ 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';
typedef TerminalSocketTransportFactory =
TerminalSocketTransport Function(Uri uri);
@ -57,9 +55,7 @@ class TerminalSocketSession {
bool _isAttached = false;
Future<void> connect({
required void Function(TerminalOutputPayload output) onOutput,
required void Function(TerminalRestorePayload restore) onRestore,
void Function(String inputId)? onInputAck,
required void Function(String frame) onFrame,
void Function()? onDisconnected,
}) async {
if (_transport != null || _subscription != null) {
@ -86,18 +82,7 @@ class TerminalSocketSession {
return;
}
if (_handleRestoreFrame(message, onRestore)) {
return;
}
if (_handleInputAckFrame(message, onInputAck)) {
return;
}
final output = _decodeOutputFrame(message);
if (output != null) {
onOutput(output);
}
onFrame(message);
},
onError: (error, stackTrace) {
final wasAttached = _isAttached;
@ -137,7 +122,7 @@ class TerminalSocketSession {
}
}
TerminalSocketDispatchResult sendInput(String input, {String? inputId}) {
TerminalSocketDispatchResult sendInput(String input) {
final transport = _transport;
if (input.isEmpty) {
return TerminalSocketDispatchResult.emptyInput;
@ -148,9 +133,7 @@ class TerminalSocketSession {
}
try {
transport.send(
jsonEncode(socketClient.buildInputMessage(input, inputId: inputId)),
);
transport.send(jsonEncode(socketClient.buildInputMessage(input)));
return TerminalSocketDispatchResult.sent;
} catch (_) {
_handleTransportClosed(transport);
@ -165,9 +148,7 @@ class TerminalSocketSession {
}
try {
transport.send(
jsonEncode(socketClient.buildResizeMessage(columns, rows)),
);
transport.send(jsonEncode(socketClient.buildResizeMessage(columns, rows)));
} catch (_) {
_handleTransportClosed(transport);
}
@ -186,9 +167,7 @@ class TerminalSocketSession {
bool _handleAttachedAck(String frame) {
try {
final decoded = jsonDecode(frame);
if (decoded is Map &&
decoded['type'] == 'attached' &&
_matchesSessionId(decoded)) {
if (decoded is Map && decoded['type'] == 'attached') {
return true;
}
} catch (_) {}
@ -196,77 +175,6 @@ class TerminalSocketSession {
return false;
}
bool _handleRestoreFrame(
String frame,
void Function(TerminalRestorePayload restore) onRestore,
) {
try {
final decoded = jsonDecode(frame);
if (decoded is Map &&
decoded['type'] == 'restore' &&
_matchesSessionId(decoded)) {
onRestore(
TerminalRestorePayload.fromJson(Map<String, dynamic>.from(decoded)),
);
return true;
}
} catch (_) {}
return false;
}
bool _handleInputAckFrame(
String frame,
void Function(String inputId)? onInputAck,
) {
if (onInputAck == null) {
return false;
}
try {
final decoded = jsonDecode(frame);
if (decoded is Map &&
decoded['type'] == 'inputAck' &&
_matchesSessionId(decoded)) {
final inputId = decoded['inputId'];
if (inputId is String && inputId.isNotEmpty) {
onInputAck(inputId);
return true;
}
}
} catch (_) {}
return false;
}
TerminalOutputPayload? _decodeOutputFrame(String frame) {
try {
final decoded = jsonDecode(frame);
if (decoded is Map &&
decoded['type'] == 'output' &&
_matchesSessionId(decoded)) {
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,
);
}
bool _matchesSessionId(Map decoded) {
final messageSessionId = decoded['sessionId'];
return messageSessionId is String && messageSessionId == sessionId;
}
void _handleTransportClosed(TerminalSocketTransport? transport) {
if (transport == null) {
_subscription = null;

View File

@ -6,7 +6,7 @@ packages:
description:
name: _fe_analyzer_shared
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "85.0.0"
analyzer:
@ -14,7 +14,7 @@ packages:
description:
name: analyzer
sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.7.1"
args:
@ -22,7 +22,7 @@ packages:
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.7.0"
async:
@ -30,7 +30,7 @@ packages:
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.13.1"
boolean_selector:
@ -38,7 +38,7 @@ packages:
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.2"
build:
@ -46,7 +46,7 @@ packages:
description:
name: build
sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.4"
build_config:
@ -54,7 +54,7 @@ packages:
description:
name: build_config
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.2"
build_daemon:
@ -62,7 +62,7 @@ packages:
description:
name: build_daemon
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.1.1"
build_resolvers:
@ -70,7 +70,7 @@ packages:
description:
name: build_resolvers
sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.4"
build_runner:
@ -78,7 +78,7 @@ packages:
description:
name: build_runner
sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.4"
build_runner_core:
@ -86,7 +86,7 @@ packages:
description:
name: build_runner_core
sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "9.1.2"
built_collection:
@ -94,7 +94,7 @@ packages:
description:
name: built_collection
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.1.1"
built_value:
@ -102,23 +102,23 @@ packages:
description:
name: built_value
sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "8.12.5"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.1"
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.4"
clock:
@ -126,7 +126,7 @@ packages:
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.2"
code_builder:
@ -134,7 +134,7 @@ packages:
description:
name: code_builder
sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.11.1"
collection:
@ -142,7 +142,7 @@ packages:
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.19.1"
convert:
@ -150,7 +150,7 @@ packages:
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.2"
crypto:
@ -158,7 +158,7 @@ packages:
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.7"
cupertino_icons:
@ -166,7 +166,7 @@ packages:
description:
name: cupertino_icons
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.9"
dart_style:
@ -174,7 +174,7 @@ packages:
description:
name: dart_style
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.1"
dio:
@ -182,7 +182,7 @@ packages:
description:
name: dio
sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.9.2"
dio_web_adapter:
@ -190,7 +190,7 @@ packages:
description:
name: dio_web_adapter
sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.2"
equatable:
@ -198,7 +198,7 @@ packages:
description:
name: equatable
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.8"
fake_async:
@ -206,15 +206,23 @@ packages:
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.1"
fixnum:
@ -222,7 +230,7 @@ packages:
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.1"
flutter:
@ -235,7 +243,7 @@ packages:
description:
name: flutter_lints
sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.0.0"
flutter_riverpod:
@ -243,7 +251,7 @@ packages:
description:
name: flutter_riverpod
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.6.1"
flutter_test:
@ -261,7 +269,7 @@ packages:
description:
name: freezed
sha256: "59a584c24b3acdc5250bb856d0d3e9c0b798ed14a4af1ddb7dc1c7b41df91c9c"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.8"
freezed_annotation:
@ -269,7 +277,7 @@ packages:
description:
name: freezed_annotation
sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.4"
frontend_server_client:
@ -277,7 +285,7 @@ packages:
description:
name: frontend_server_client
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.0.0"
glob:
@ -285,7 +293,7 @@ packages:
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.3"
go_router:
@ -293,7 +301,7 @@ packages:
description:
name: go_router
sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "14.8.1"
graphs:
@ -301,7 +309,7 @@ packages:
description:
name: graphs
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.2"
http:
@ -309,7 +317,7 @@ packages:
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.6.0"
http_multi_server:
@ -317,7 +325,7 @@ packages:
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.2.2"
http_parser:
@ -325,7 +333,7 @@ packages:
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.1.2"
io:
@ -333,7 +341,7 @@ packages:
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.5"
js:
@ -341,7 +349,7 @@ packages:
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.2"
json_annotation:
@ -349,7 +357,7 @@ packages:
description:
name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.9.0"
json_serializable:
@ -357,7 +365,7 @@ packages:
description:
name: json_serializable
sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.9.5"
leak_tracker:
@ -365,7 +373,7 @@ packages:
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
@ -373,7 +381,7 @@ packages:
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.10"
leak_tracker_testing:
@ -381,7 +389,7 @@ packages:
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.2"
lints:
@ -389,7 +397,7 @@ packages:
description:
name: lints
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.1.1"
logging:
@ -397,39 +405,39 @@ packages:
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.12.19"
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.13.0"
version: "0.11.1"
meta:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.17.0"
version: "1.16.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.0"
mocktail:
@ -437,7 +445,7 @@ packages:
description:
name: mocktail
sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.4"
package_config:
@ -445,7 +453,7 @@ packages:
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
path:
@ -453,15 +461,55 @@ packages:
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.9.1"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.8"
pool:
dependency: transitive
description:
name: pool
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.5.2"
pub_semver:
@ -469,7 +517,7 @@ packages:
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
pubspec_parse:
@ -477,7 +525,7 @@ packages:
description:
name: pubspec_parse
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.5.0"
quiver:
@ -485,7 +533,7 @@ packages:
description:
name: quiver
sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.2.2"
riverpod:
@ -493,15 +541,71 @@ packages:
description:
name: riverpod
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.6.1"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.23"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.6"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.1"
shelf:
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.2"
shelf_web_socket:
@ -509,7 +613,7 @@ packages:
description:
name: shelf_web_socket
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.0"
sky_engine:
@ -522,7 +626,7 @@ packages:
description:
name: source_gen
sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.0"
source_helper:
@ -530,7 +634,7 @@ packages:
description:
name: source_helper
sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.7"
source_span:
@ -538,7 +642,7 @@ packages:
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.10.2"
stack_trace:
@ -546,7 +650,7 @@ packages:
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.12.1"
state_notifier:
@ -554,7 +658,7 @@ packages:
description:
name: state_notifier
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.0"
stream_channel:
@ -562,7 +666,7 @@ packages:
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.4"
stream_transform:
@ -570,7 +674,7 @@ packages:
description:
name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.1"
string_scanner:
@ -578,7 +682,7 @@ packages:
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.1"
term_glyph:
@ -586,23 +690,23 @@ packages:
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev"
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.10"
version: "0.7.6"
timing:
dependency: transitive
description:
name: timing
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.2"
typed_data:
@ -610,7 +714,7 @@ packages:
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.0"
vector_math:
@ -618,7 +722,7 @@ packages:
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
vm_service:
@ -626,7 +730,7 @@ packages:
description:
name: vm_service
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "15.0.2"
watcher:
@ -634,7 +738,7 @@ packages:
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.2.1"
web:
@ -642,7 +746,7 @@ packages:
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.1"
web_socket:
@ -650,7 +754,7 @@ packages:
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.1"
web_socket_channel:
@ -658,15 +762,23 @@ packages:
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.3"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.0"
xterm:
dependency: "direct main"
description:
name: xterm
sha256: "168dfedca77cba33fdb6f52e2cd001e9fde216e398e89335c19b524bb22da3a2"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.0.0"
yaml:
@ -674,7 +786,7 @@ packages:
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.3"
zmodem:
@ -682,9 +794,9 @@ packages:
description:
name: zmodem
sha256: "3b7e5b29f3a7d8aee472029b05165a68438eff2f3f7766edf13daba1e297adbf"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.0.6"
sdks:
dart: ">=3.9.2 <4.0.0"
flutter: ">=3.22.0"
flutter: ">=3.35.0"

View File

@ -37,6 +37,7 @@ dependencies:
freezed_annotation: ^2.4.4
json_annotation: ^4.9.0
xterm: ^4.0.0
shared_preferences: ^2.5.3
cupertino_icons: ^1.0.8
dev_dependencies:

View File

@ -143,42 +143,6 @@ 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

@ -1,47 +0,0 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:term_remote_ctl/core/network/agent_base_uri_storage.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory tempDirectory;
late File storageFile;
late AgentBaseUriStorage storage;
setUp(() async {
tempDirectory = await Directory.systemTemp.createTemp(
'agent_base_uri_storage_test_',
);
storageFile = File(
'${tempDirectory.path}/${AgentBaseUriStorage.storageFileName}',
);
storage = AgentBaseUriStorage(storageFileLoader: () async => storageFile);
});
tearDown(() async {
if (await tempDirectory.exists()) {
await tempDirectory.delete(recursive: true);
}
});
test('returns null when no stored uri exists', () async {
expect(await storage.read(), isNull);
});
test('writes and restores a previously saved uri', () async {
final expected = Uri.parse('https://host.example:9443');
await storage.write(expected);
final restored = await storage.read();
expect(restored, expected);
});
test('ignores malformed persisted content', () async {
await storageFile.writeAsString('not-a-uri', flush: true);
expect(await storage.read(), isNull);
});
}

View File

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

View File

@ -1,39 +1,23 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:term_remote_ctl/features/presets/preset_command.dart';
import 'package:term_remote_ctl/features/presets/preset_repository.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory tempDirectory;
late PresetRepository repository;
setUp(() async {
tempDirectory = await Directory.systemTemp.createTemp(
'preset_repository_test_',
);
repository = PresetRepository(
storageFileLoader: () async =>
File('${tempDirectory.path}/${PresetRepository.storageFileName}'),
);
});
tearDown(() async {
if (await tempDirectory.exists()) {
await tempDirectory.delete(recursive: true);
}
setUp(() {
SharedPreferences.setMockInitialValues(<String, Object>{});
});
test('listPresets restores persisted presets from local storage', () async {
final storageFile = File(
'${tempDirectory.path}/${PresetRepository.storageFileName}',
);
await storageFile.writeAsString(
'[{"id":"preset-1","label":"ssh prod","commandText":"ssh admin@prod"}]',
);
SharedPreferences.setMockInitialValues(<String, Object>{
'preset_commands_v1': <String>[
'{"id":"preset-1","label":"ssh prod","commandText":"ssh admin@prod"}',
],
});
final repository = PresetRepository();
final presets = await repository.listPresets();
expect(presets, [
@ -49,6 +33,8 @@ void main() {
});
test('savePreset appends a new preset and persists it', () async {
final repository = PresetRepository();
final savedPreset = await repository.savePreset(
const PresetCommand(
id: 'preset-1',
@ -66,12 +52,13 @@ void main() {
});
test('savePreset updates an existing preset in place', () async {
final storageFile = File(
'${tempDirectory.path}/${PresetRepository.storageFileName}',
);
await storageFile.writeAsString(
'[{"id":"preset-1","label":"ssh prod","commandText":"ssh admin@prod"}]',
);
SharedPreferences.setMockInitialValues(<String, Object>{
'preset_commands_v1': <String>[
'{"id":"preset-1","label":"ssh prod","commandText":"ssh admin@prod"}',
],
});
final repository = PresetRepository();
await repository.savePreset(
const PresetCommand(
@ -88,13 +75,14 @@ void main() {
});
test('deletePreset removes a persisted preset', () async {
final storageFile = File(
'${tempDirectory.path}/${PresetRepository.storageFileName}',
);
await storageFile.writeAsString(
'[{"id":"preset-1","label":"ssh prod","commandText":"ssh admin@prod"},'
'{"id":"preset-2","label":"git pull","commandText":"git pull --ff-only"}]',
);
SharedPreferences.setMockInitialValues(<String, Object>{
'preset_commands_v1': <String>[
'{"id":"preset-1","label":"ssh prod","commandText":"ssh admin@prod"}',
'{"id":"preset-2","label":"git pull","commandText":"git pull --ff-only"}',
],
});
final repository = PresetRepository();
await repository.deletePreset('preset-1');
final reloaded = await repository.listPresets();

View File

@ -1,11 +1,7 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:term_remote_ctl/core/network/agent_api_client.dart';
import 'package:term_remote_ctl/features/sessions/session.dart';
import 'package:term_remote_ctl/features/sessions/session_repository.dart';
import 'package:term_remote_ctl/features/terminal/terminal_snapshot.dart';
import 'package:term_remote_ctl/features/terminal/terminal_snapshot_storage.dart';
void main() {
test('lists sessions from the agent and maps them to models', () async {
@ -50,23 +46,6 @@ void main() {
expect(session.sessionId, 'xyz');
expect(session.name, 'new-session');
});
test(
'deletes the local snapshot after deleting a session from the agent',
() async {
final client = _FakeAgentApiClient();
final snapshotStorage = _FakeTerminalSnapshotStorage();
final repository = SessionRepository(
client,
snapshotStorage: snapshotStorage,
);
await repository.deleteSession('abc');
expect(client.deletedSessionIds, ['abc']);
expect(snapshotStorage.deletedSessionIds, ['abc']);
},
);
}
class _FakeAgentApiClient extends AgentApiClient {
@ -78,7 +57,6 @@ class _FakeAgentApiClient extends AgentApiClient {
int listCalls = 0;
String? lastCreatedName;
final List<String> deletedSessionIds = <String>[];
@override
Future<List<Map<String, dynamic>>> listSessions() async {
@ -100,32 +78,4 @@ class _FakeAgentApiClient extends AgentApiClient {
'status': 'idle',
};
}
@override
Future<void> deleteSession(String sessionId) async {
deletedSessionIds.add(sessionId);
}
}
class _FakeTerminalSnapshotStorage extends TerminalSnapshotStorage {
_FakeTerminalSnapshotStorage() : super(storageFileLoader: _unsupportedFile);
final List<String> deletedSessionIds = <String>[];
@override
Future<void> save(TerminalSnapshot snapshot) async {}
@override
Future<TerminalSnapshot?> read(String sessionId) async {
return null;
}
@override
Future<void> delete(String sessionId) async {
deletedSessionIds.add(sessionId);
}
}
Future<File> _unsupportedFile() {
throw UnimplementedError('This test does not use file storage.');
}

View File

@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:term_remote_ctl/features/terminal/terminal_input_controller.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test(
'direct mode emits inserted text, backspace, and carriage return',
() async {
final directInputs = <String>[];
final bufferedCommands = <String>[];
final controller = TerminalInputController(
onBufferedSubmit: bufferedCommands.add,
onDirectInput: directInputs.add,
);
addTearDown(controller.dispose);
controller.setMode(TerminalInputMode.direct);
controller.textController.value = const TextEditingValue(
text: 'ls',
selection: TextSelection.collapsed(offset: 2),
);
expect(directInputs, ['ls']);
expect(controller.textController.text, ' ');
expect(controller.textController.selection.baseOffset, 2);
controller.textController.value = const TextEditingValue(
text: ' ',
selection: TextSelection.collapsed(offset: 1),
);
expect(directInputs, ['ls', '\x7f']);
expect(controller.textController.text, ' ');
expect(controller.textController.selection.baseOffset, 2);
await controller.submit();
expect(directInputs, ['ls', '\x7f', '\r']);
expect(bufferedCommands, isEmpty);
},
);
test(
'switching modes preserves the buffered draft at the end of the field',
() {
final controller = TerminalInputController(
onBufferedSubmit: (_) {},
onDirectInput: (_) {},
);
addTearDown(controller.dispose);
controller.textController.value = const TextEditingValue(
text: 'git status',
selection: TextSelection.collapsed(offset: 10),
);
controller.setMode(TerminalInputMode.direct);
expect(controller.textController.text, ' ');
expect(controller.textController.selection.baseOffset, 2);
controller.setMode(TerminalInputMode.buffered);
expect(controller.textController.text, 'git status');
expect(controller.textController.selection.baseOffset, 10);
expect(controller.textController.selection.extentOffset, 10);
},
);
}

View File

@ -1,7 +1,7 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:term_remote_ctl/core/network/agent_api_client.dart';
@ -9,164 +9,82 @@ import 'package:term_remote_ctl/core/network/agent_connection_providers.dart';
import 'package:term_remote_ctl/features/presets/preset_command.dart';
import 'package:term_remote_ctl/features/presets/preset_providers.dart';
import 'package:term_remote_ctl/features/presets/preset_repository.dart';
import 'package:term_remote_ctl/features/sessions/session.dart';
import 'package:term_remote_ctl/features/terminal/terminal_page.dart';
import 'package:term_remote_ctl/features/terminal/terminal_snapshot.dart';
import 'package:term_remote_ctl/features/terminal/terminal_snapshot_storage.dart';
import 'package:term_remote_ctl/features/terminal/terminal_socket_session.dart';
import 'package:xterm/xterm.dart';
import 'package:term_remote_ctl/features/sessions/session.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
testWidgets(
'terminal page uses the command deck instead of a bottom text field',
(tester) async {
await _pumpTerminalPage(tester);
expect(find.byType(TextField), findsNothing);
expect(find.byKey(const Key('terminal_command_deck')), findsOneWidget);
expect(
find.byKey(const Key('terminal_mode_read_button')),
findsOneWidget,
);
expect(
find.byKey(const Key('terminal_mode_edit_button')),
findsOneWidget,
);
},
);
testWidgets(
'terminal view becomes focusable only after edit mode is selected',
(tester) async {
await _pumpTerminalPage(tester);
var terminalView = tester.widget<TerminalView>(find.byType(TerminalView));
expect(terminalView.focusNode, isNotNull);
expect(terminalView.focusNode!.canRequestFocus, isFalse);
await tester.tap(find.byKey(const Key('terminal_mode_edit_button')));
await tester.pumpAndSettle();
terminalView = tester.widget<TerminalView>(find.byType(TerminalView));
expect(terminalView.focusNode!.canRequestFocus, isTrue);
},
);
testWidgets('terminal view uses multiline keyboard semantics', (
testWidgets('terminal keeps the extra key tray hidden until requested', (
tester,
) async {
await _pumpTerminalPage(tester);
final terminalView = tester.widget<TerminalView>(find.byType(TerminalView));
expect(find.byKey(const Key('terminal_key_tray')), findsNothing);
expect(
find.byKey(const Key('terminal_keys_toggle_button')),
findsOneWidget,
);
expect(terminalView.keyboardType, TextInputType.multiline);
await tester.tap(find.byKey(const Key('terminal_keys_toggle_button')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('terminal_key_tray')), findsOneWidget);
expect(
find.byKey(const Key('terminal_quick_key_symbol_at')),
findsOneWidget,
);
expect(
find.byKey(const Key('terminal_quick_key_symbol_slash')),
findsOneWidget,
);
});
testWidgets('terminal view enables mobile delete detection', (tester) async {
await _pumpTerminalPage(tester);
testWidgets('terminal long press repeats arrow keys', (tester) async {
final transportFactory = _QueuedTerminalSocketTransportFactory();
final terminalView = tester.widget<TerminalView>(find.byType(TerminalView));
await _pumpTerminalPage(tester, socketFactory: transportFactory.factory);
expect(terminalView.deleteDetection, isTrue);
await tester.tap(find.byKey(const Key('terminal_keys_toggle_button')));
await tester.pumpAndSettle();
final gesture = await tester.startGesture(
tester.getCenter(find.byKey(const Key('terminal_quick_key_up'))),
);
await tester.pump(const Duration(milliseconds: 700));
await gesture.up();
await tester.pumpAndSettle();
final sentInputs = transportFactory.createdTransports.single.sentMessages
.where((message) => message.contains('"type":"input"'))
.toList(growable: false);
expect(sentInputs.length, greaterThanOrEqualTo(2));
expect(sentInputs.last, contains(r'"input":"\u001b[A"'));
});
testWidgets('terminal view freezes auto resize while editing and restores it after returning to read', (tester) async {
await _pumpTerminalPage(tester);
testWidgets('terminal sends hardware escape in direct mode', (tester) async {
final transportFactory = _QueuedTerminalSocketTransportFactory();
var terminalView = tester.widget<TerminalView>(find.byType(TerminalView));
await _pumpTerminalPage(tester, socketFactory: transportFactory.factory);
expect(terminalView.autoResize, isTrue);
expect(terminalView.terminal.reflowEnabled, isFalse);
await tester.tap(find.byKey(const Key('terminal_mode_button')));
await tester.pumpAndSettle();
await tester.tap(find.byType(TextField));
await tester.pumpAndSettle();
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('terminal_mode_edit_button')));
await tester.pump();
terminalView = tester.widget<TerminalView>(find.byType(TerminalView));
expect(terminalView.autoResize, isFalse);
await tester.tap(find.byKey(const Key('terminal_mode_read_button')));
await tester.pump();
terminalView = tester.widget<TerminalView>(find.byType(TerminalView));
expect(terminalView.autoResize, isFalse);
await tester.pump(const Duration(milliseconds: 260));
terminalView = tester.widget<TerminalView>(find.byType(TerminalView));
expect(terminalView.autoResize, isTrue);
expect(
transportFactory.createdTransports.single.sentMessages.last,
contains(r'"input":"\u001b"'),
);
});
testWidgets(
'soft keyboard newline is sent as terminal enter after edit mode is selected',
(tester) async {
final transportFactory = _QueuedTerminalSocketTransportFactory();
await _pumpTerminalPage(tester, socketFactory: transportFactory.factory);
await tester.tap(find.byKey(const Key('terminal_mode_edit_button')));
await tester.pumpAndSettle();
final terminalView = tester.widget<TerminalView>(
find.byType(TerminalView),
);
terminalView.terminal.onOutput?.call('\n');
await tester.pumpAndSettle();
expect(
transportFactory.createdTransports.single.sentMessages.last,
contains(r'"input":"\r"'),
);
},
);
testWidgets(
'terminal more controls exposes reconnect, presets, and quick keys',
(tester) async {
final transportFactory = _QueuedTerminalSocketTransportFactory();
await _pumpTerminalPage(
tester,
socketFactory: transportFactory.factory,
presetRepository: _MemoryPresetRepository([
const PresetCommand(
id: 'preset-1',
label: 'ssh prod',
commandText: 'ssh admin@prod',
),
]),
);
await tester.tap(find.byKey(const Key('terminal_more_controls_button')));
await tester.pumpAndSettle();
expect(
find.byKey(const Key('terminal_reconnect_inline_button')),
findsOneWidget,
);
expect(
find.byKey(const Key('terminal_manage_presets_button')),
findsOneWidget,
);
expect(
find.byKey(const Key('terminal_quick_key_ctrl_c')),
findsOneWidget,
);
expect(find.text('ssh prod'), findsOneWidget);
await tester.tap(find.byKey(const Key('terminal_mode_edit_button')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('terminal_quick_key_ctrl_c')));
await tester.pumpAndSettle();
expect(
transportFactory.createdTransports.single.sentMessages.last,
contains(r'"input":"\u0003"'),
);
},
);
testWidgets('terminal more controls opens preset management', (tester) async {
testWidgets('terminal opens the presets sheet and launches management', (
tester,
) async {
await _pumpTerminalPage(
tester,
presetRepository: _MemoryPresetRepository([
@ -178,15 +96,12 @@ void main() {
]),
);
await tester.tap(find.byKey(const Key('terminal_more_controls_button')));
await tester.tap(find.byKey(const Key('terminal_presets_button')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('terminal_presets_sheet')), findsOneWidget);
expect(find.text('ssh prod'), findsOneWidget);
await tester.ensureVisible(
find.byKey(const Key('terminal_manage_presets_button')),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('terminal_manage_presets_button')));
await tester.pumpAndSettle();
@ -204,9 +119,6 @@ Future<void> _pumpTerminalPage(
ProviderScope(
overrides: [
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
terminalSnapshotStorageProvider.overrideWithValue(
_MemoryTerminalSnapshotStorage(),
),
terminalSocketSessionFactoryProvider.overrideWithValue(
socketFactory ??
TerminalSocketSessionFactory(
@ -253,58 +165,18 @@ class _MemoryPresetRepository extends PresetRepository {
}
}
class _MemoryTerminalSnapshotStorage extends TerminalSnapshotStorage {
_MemoryTerminalSnapshotStorage() : super(storageFileLoader: _unsupportedFile);
@override
Future<TerminalSnapshot?> read(String sessionId) async {
return null;
}
@override
Future<void> save(TerminalSnapshot snapshot) async {}
@override
Future<void> delete(String sessionId) async {}
@override
Future<void> deleteByProjectId(String projectId) async {}
@override
Future<void> pruneToSessionIds(Set<String> sessionIds) async {}
}
class _FakeAgentApiClient extends AgentApiClient {
_FakeAgentApiClient() : super(Uri.parse('http://100.81.30.82:5067'));
@override
Future<Map<String, dynamic>> getSessionJournal(
Future<Map<String, dynamic>> getSessionHistory(
String sessionId, {
int limit = 200,
int? beforeSequence,
int? afterSequence,
int lineCount = 200,
}) async {
return <String, dynamic>{
'sessionId': sessionId,
'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,
'lines': <String>['one', 'two'],
'hasMoreAbove': true,
};
}
}
@ -352,7 +224,3 @@ class _FakeTerminalSocketTransport implements TerminalSocketTransport {
_incoming.add(message);
}
}
Future<File> _unsupportedFile() {
throw UnimplementedError('This test does not use file storage.');
}

View File

@ -1,33 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:term_remote_ctl/features/terminal/terminal_restore_decision.dart';
void main() {
test(
'replaces the local terminal content when restore is a shorter authoritative prefix',
() {
final decision = decideTerminalRestore(
currentText: 'PS> git status\r\nmodified: file.txt\r\nPS> ',
restoreText: 'PS> git status\r\n',
);
expect(decision, TerminalRestoreDecision.replaceWithRestore);
});
test('replaces local content when restore extends the current content', () {
final decision = decideTerminalRestore(
currentText: 'PS> git',
restoreText: 'PS> git status\r\nPS> ',
);
expect(decision, TerminalRestoreDecision.replaceWithRestore);
});
test('replaces local content when there is no local terminal state yet', () {
final decision = decideTerminalRestore(
currentText: '',
restoreText: 'PS> git status',
);
expect(decision, TerminalRestoreDecision.replaceWithRestore);
});
}

View File

@ -1,89 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:term_remote_ctl/features/terminal/terminal_screen_snapshot.dart';
import 'package:term_remote_ctl/features/terminal/terminal_screen_state.dart';
import 'package:xterm/xterm.dart';
void main() {
test(
'experiment display text trims trailing blank rows beyond the cursor',
() {
final state = TerminalScreenState.fromSnapshot(
const TerminalScreenSnapshot(
sessionId: 'session-1',
screenVersion: 4,
sourceSequence: 10,
rows: 24,
columns: 80,
cursorRow: 0,
cursorColumn: 7,
cursorVisible: true,
activeBuffer: 'primary',
primaryBuffer: TerminalScreenBuffer(
viewport: [TerminalScreenLine(index: 0, text: 'PS> git')],
),
),
);
expect(state.toDisplayText(), 'PS> git');
},
);
test(
'experiment display text preserves blank rows that still contain the cursor',
() {
final state = TerminalScreenState.fromSnapshot(
const TerminalScreenSnapshot(
sessionId: 'session-1',
screenVersion: 4,
sourceSequence: 10,
rows: 24,
columns: 80,
cursorRow: 1,
cursorColumn: 0,
cursorVisible: true,
activeBuffer: 'primary',
primaryBuffer: TerminalScreenBuffer(
viewport: [TerminalScreenLine(index: 0, text: 'build finished')],
),
),
);
expect(state.toDisplayText(), 'build finished\n');
},
);
test(
'experiment replay sequence restores the cursor to the backend screen position',
() {
final state = TerminalScreenState.fromSnapshot(
const TerminalScreenSnapshot(
sessionId: 'session-1',
screenVersion: 4,
sourceSequence: 10,
rows: 4,
columns: 12,
cursorRow: 2,
cursorColumn: 3,
cursorVisible: true,
activeBuffer: 'primary',
primaryBuffer: TerminalScreenBuffer(
viewport: [
TerminalScreenLine(index: 0, text: 'header'),
TerminalScreenLine(index: 2, text: 'pwd'),
],
),
),
);
final terminal = Terminal(maxLines: 1000);
terminal.resize(12, 4);
terminal.write('stale-output');
terminal.write(state.toReplaySequence());
expect(terminal.buffer.getText(), contains('header'));
expect(terminal.buffer.getText(), contains('\n\npwd'));
expect(terminal.buffer.cursorY, 2);
expect(terminal.buffer.cursorX, 3);
},
);
}

View File

@ -5,9 +5,6 @@ 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_decision.dart';
import 'package:term_remote_ctl/features/terminal/terminal_restore_payload.dart';
import 'package:term_remote_ctl/features/terminal/terminal_session_coordinator.dart';
import 'package:term_remote_ctl/features/terminal/terminal_socket_session.dart';
@ -29,7 +26,6 @@ void main() {
session: session,
sessionFactory: sessionFactory.create,
onFrame: (_) {},
onRestore: (_) {},
viewportProvider: () => const TerminalViewport(columns: 132, rows: 40),
);
@ -67,7 +63,6 @@ void main() {
session: session,
sessionFactory: sessionFactory.create,
onFrame: (_) {},
onRestore: (_) {},
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
reconnectScheduler: reconnectScheduler.schedule,
);
@ -106,7 +101,6 @@ void main() {
session: session,
sessionFactory: sessionFactory.create,
onFrame: (_) {},
onRestore: (_) {},
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
reconnectScheduler: reconnectScheduler.schedule,
);
@ -123,165 +117,24 @@ void main() {
expect(sessionFactory.createdSessions, hasLength(2));
expect(sessionFactory.createdSessions.last.sentInputs, ['dir\r']);
expect(sessionFactory.createdSessions.last.sentInputIds, hasLength(1));
expect(
sessionFactory.createdSessions.last.sentInputIds.single,
endsWith('-input-1'),
);
},
);
test(
'unacknowledged input is resent after reconnect and removed only after ack',
() async {
final controller = TerminalInteractionController();
final apiClient = _FakeAgentApiClient();
final sessionFactory = _FakeTerminalSessionFactory();
final reconnectScheduler = _FakeReconnectScheduler();
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),
reconnectScheduler: reconnectScheduler.schedule,
);
await coordinator.start();
final firstSession = sessionFactory.createdSessions.single;
coordinator.sendInput('dir\r');
final firstInputId = firstSession.sentInputIds.single;
expect(firstInputId, endsWith('-input-1'));
firstSession.disconnect();
await reconnectScheduler.runPending();
final secondSession = sessionFactory.createdSessions.last;
expect(secondSession.sentInputs, ['dir\r']);
expect(secondSession.sentInputIds, [firstInputId]);
secondSession.ackInput(firstInputId);
secondSession.disconnect();
await reconnectScheduler.runPending();
final thirdSession = sessionFactory.createdSessions.last;
expect(thirdSession.sentInputs, isEmpty);
},
);
test('re-entering the same session generates fresh input ids', () async {
final firstController = TerminalInteractionController();
final firstApiClient = _FakeAgentApiClient();
final firstSessionFactory = _FakeTerminalSessionFactory();
final session = Session(
sessionId: 'abc',
name: 'codex-main',
status: 'idle',
);
final firstCoordinator = TerminalSessionCoordinator(
controller: firstController,
apiClient: firstApiClient,
session: session,
sessionFactory: firstSessionFactory.create,
onFrame: (_) {},
onRestore: (_) {},
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
);
await firstCoordinator.start();
firstCoordinator.sendInput('dir\r');
final firstInputId = firstSessionFactory.createdSessions.single.sentInputIds.single;
await firstCoordinator.close();
final secondController = TerminalInteractionController();
final secondApiClient = _FakeAgentApiClient();
final secondSessionFactory = _FakeTerminalSessionFactory();
final secondCoordinator = TerminalSessionCoordinator(
controller: secondController,
apiClient: secondApiClient,
session: session,
sessionFactory: secondSessionFactory.create,
onFrame: (_) {},
onRestore: (_) {},
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
);
await secondCoordinator.start();
secondCoordinator.sendInput('dir\r');
final secondInputId = secondSessionFactory.createdSessions.single.sentInputIds.single;
expect(secondInputId, isNot(firstInputId));
expect(firstInputId, endsWith('-input-1'));
expect(secondInputId, endsWith('-input-1'));
await secondCoordinator.close();
});
test('restore payloads are treated as authoritative over provisional text', () {
final decision = decideTerminalRestore(
currentText: 'PS> git status\r\nmodified: file.txt\r\nPS> ',
restoreText: 'PS> git status\r\n',
);
expect(decision, TerminalRestoreDecision.replaceWithRestore);
});
test(
'loadOlderHistory pages backward with beforeSequence instead of expanding lineCount',
'loadOlderHistory increases the requested history window size',
() async {
final controller = TerminalInteractionController();
final apiClient = _FakeAgentApiClient(
journalResponses: [
responses: [
<String, dynamic>{
'sessionId': 'abc',
'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,
'lines': <String>['one', 'two'],
'hasMoreAbove': true,
},
<String, dynamic>{
'sessionId': 'abc',
'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,
'lines': <String>['zero', 'one', 'two'],
'hasMoreAbove': false,
},
],
);
@ -297,196 +150,18 @@ void main() {
session: session,
sessionFactory: sessionFactory.create,
onFrame: (_) {},
onRestore: (_) {},
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
);
await coordinator.start();
await coordinator.loadOlderHistory();
expect(apiClient.requestedJournalBeforeSequences, [null, 201]);
expect(controller.historyWindow.lines, [
'[output] zero',
'[attach]',
'[output] one',
'[output] two',
]);
expect(controller.historyWindow.outputSeedText, 'zero\r\none\r\ntwo\r\n');
expect(apiClient.requestedLineCounts, [200, 400]);
expect(controller.historyWindow.lines, ['zero', 'one', '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();
@ -502,7 +177,6 @@ void main() {
session: session,
sessionFactory: sessionFactory.create,
onFrame: (_) {},
onRestore: (_) {},
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
);
@ -533,20 +207,13 @@ void main() {
session: session,
sessionFactory: sessionFactory.create,
onFrame: receivedFrames.add,
onRestore: (_) {},
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
);
await coordinator.start();
controller.enterScrollback();
sessionFactory.createdSessions.single.emitOutput(
const TerminalOutputPayload(
sessionId: 'abc',
sequence: 1,
chunk: 'next-line',
),
);
sessionFactory.createdSessions.single.emitFrame('next-line');
expect(receivedFrames, ['next-line']);
expect(controller.hasPendingLiveOutput, isTrue);
@ -565,439 +232,33 @@ void main() {
expect(controller.liveLines.last, 'line-299');
expect(controller.liveLines, isNot(contains('line-0')));
});
test(
'rapid resize updates are coalesced into the final stable viewport',
() async {
final controller = TerminalInteractionController();
final apiClient = _FakeAgentApiClient();
final sessionFactory = _FakeTerminalSessionFactory();
final resizeScheduler = _FakeResizeScheduler();
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),
resizeScheduler: resizeScheduler.schedule,
);
await coordinator.start();
final socketSession = sessionFactory.createdSessions.single;
coordinator.handleTerminalResize(100, 30);
coordinator.handleTerminalResize(98, 26);
expect(socketSession.resizeCalls, const [
[80, 24],
]);
expect(resizeScheduler.pendingCallback, isNotNull);
resizeScheduler.runPending();
expect(socketSession.resizeCalls, const [
[80, 24],
[98, 26],
]);
},
);
test(
'edit mode freezes backend resize until read mode resumes',
() async {
final controller = TerminalInteractionController();
final apiClient = _FakeAgentApiClient();
final sessionFactory = _FakeTerminalSessionFactory();
final resizeScheduler = _FakeResizeScheduler();
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),
resizeScheduler: resizeScheduler.schedule,
);
await coordinator.start();
final socketSession = sessionFactory.createdSessions.single;
coordinator.setBackendResizeEnabled(false);
coordinator.handleTerminalResize(98, 26);
resizeScheduler.runPending();
expect(socketSession.resizeCalls, const [
[80, 24],
]);
coordinator.setBackendResizeEnabled(true);
expect(socketSession.resizeCalls, const [
[80, 24],
[98, 26],
]);
},
);
test(
'frames from a replaced socket session are ignored after reconnect starts',
() async {
final controller = TerminalInteractionController();
final apiClient = _FakeAgentApiClient();
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();
final firstSession = sessionFactory.createdSessions.first;
firstSession.disposeCompleter = Completer<void>();
await coordinator.reconnectNow();
expect(sessionFactory.createdSessions, hasLength(2));
final secondSession = sessionFactory.createdSessions.last;
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']);
},
);
test('restore payloads are forwarded to the UI callback', () async {
final controller = TerminalInteractionController();
final apiClient = _FakeAgentApiClient();
final sessionFactory = _FakeTerminalSessionFactory();
final session = Session(
sessionId: 'abc',
name: 'codex-main',
status: 'idle',
);
final restores = <TerminalRestorePayload>[];
final coordinator = TerminalSessionCoordinator(
controller: controller,
apiClient: apiClient,
session: session,
sessionFactory: sessionFactory.create,
onFrame: (_) {},
onRestore: restores.add,
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
);
await coordinator.start();
sessionFactory.createdSessions.single.emitRestore(
const TerminalRestorePayload(
sessionId: 'abc',
sequence: 7,
screenText: 'PS> gi',
pendingInput: 't status',
),
);
expect(restores, hasLength(1));
expect(restores.single.pendingInput, 't status');
});
test(
'restore sequence fills reconnect gap before newer live output continues',
() 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': 5,
'kind': 'output',
'payload': 'gap-5',
'timestampUtc': '2026-04-07T03:20:05Z',
},
{
'sessionId': 'abc',
'sequence': 6,
'kind': 'output',
'payload': 'gap-6',
'timestampUtc': '2026-04-07T03:20:06Z',
},
],
'hasMoreBefore': true,
'hasMoreAfter': false,
'currentSequence': 7,
},
],
);
final sessionFactory = _FakeTerminalSessionFactory();
final reconnectScheduler = _FakeReconnectScheduler();
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),
reconnectScheduler: reconnectScheduler.schedule,
);
await coordinator.start();
sessionFactory.createdSessions.single.emitRestore(
const TerminalRestorePayload(
sessionId: 'abc',
sequence: 4,
screenText: 'before-gap',
pendingInput: '',
),
);
sessionFactory.createdSessions.single.disconnect();
await reconnectScheduler.runPending();
final secondSession = sessionFactory.createdSessions.last;
secondSession.emitRestore(
const TerminalRestorePayload(
sessionId: 'abc',
sequence: 7,
screenText: 'tail',
pendingInput: '',
),
);
await Future<void>.delayed(Duration.zero);
expect(apiClient.requestedJournalAfterSequences, [4]);
expect(receivedFrames, ['gap-5', 'gap-6']);
},
);
test(
'loadRecentHistoryWindow pages backward until the recent recovery window is filled',
() async {
final controller = TerminalInteractionController();
controller.applyFrame('skip-initial-history-load');
final apiClient = _FakeAgentApiClient(
journalResponses: [
<String, dynamic>{
'sessionId': 'abc',
'items': [
{
'sessionId': 'abc',
'sequence': 6,
'kind': 'output',
'payload': 'middle\r\n',
'timestampUtc': '2026-04-07T03:20:06Z',
},
{
'sessionId': 'abc',
'sequence': 7,
'kind': 'output',
'payload': 'tail',
'timestampUtc': '2026-04-07T03:20:07Z',
},
],
'hasMoreBefore': true,
'hasMoreAfter': false,
'currentSequence': 7,
},
<String, dynamic>{
'sessionId': 'abc',
'items': [
{
'sessionId': 'abc',
'sequence': 5,
'kind': 'output',
'payload': 'header\r\n',
'timestampUtc': '2026-04-07T03:20:05Z',
},
],
'hasMoreBefore': false,
'hasMoreAfter': true,
'currentSequence': 7,
},
],
);
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();
final history = await coordinator.loadRecentHistoryWindow();
expect(apiClient.requestedJournalBeforeSequences, [null, 6]);
expect(history, isNotNull);
expect(history!.outputSeedText, 'header\r\nmiddle\r\ntail');
},
);
test(
'loadRecentHistoryWindow ignores journal items from a different session',
() async {
final controller = TerminalInteractionController();
controller.applyFrame('skip-initial-history-load');
final apiClient = _FakeAgentApiClient(
journalResponses: [
<String, dynamic>{
'sessionId': 'abc',
'items': [
{
'sessionId': 'other-session',
'sequence': 5,
'kind': 'output',
'payload': 'wrong-output\r\n',
'timestampUtc': '2026-04-07T03:20:05Z',
},
{
'sessionId': 'abc',
'sequence': 6,
'kind': 'output',
'payload': 'right-output\r\n',
'timestampUtc': '2026-04-07T03:20:06Z',
},
],
'hasMoreBefore': false,
'hasMoreAfter': false,
'currentSequence': 6,
},
],
);
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();
final history = await coordinator.loadRecentHistoryWindow();
expect(history, isNotNull);
expect(history!.outputSeedText, 'right-output\r\n');
expect(history.lines, ['[output] right-output']);
},
);
}
class _FakeAgentApiClient extends AgentApiClient {
_FakeAgentApiClient({List<Map<String, dynamic>>? journalResponses})
: _journalResponses =
journalResponses ??
_FakeAgentApiClient({List<Map<String, dynamic>>? responses})
: _responses =
responses ??
[
<String, dynamic>{
'sessionId': 'abc',
'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,
'lines': <String>['one', 'two'],
'hasMoreAbove': true,
},
],
super(Uri.parse('https://host:9443'));
final List<Map<String, dynamic>> _journalResponses;
final requestedJournalBeforeSequences = <int?>[];
final requestedJournalAfterSequences = <int?>[];
final List<Map<String, dynamic>> _responses;
final requestedLineCounts = <int>[];
var _index = 0;
@override
Future<Map<String, dynamic>> getSessionJournal(
Future<Map<String, dynamic>> getSessionHistory(
String sessionId, {
int limit = 200,
int? beforeSequence,
int? afterSequence,
int lineCount = 200,
}) async {
requestedJournalBeforeSequences.add(beforeSequence);
if (afterSequence != null) {
requestedJournalAfterSequences.add(afterSequence);
}
final response = _journalResponses[_index];
if (_index < _journalResponses.length - 1) {
requestedLineCounts.add(lineCount);
final response = _responses[_index];
if (_index < _responses.length - 1) {
_index += 1;
}
@ -1028,27 +289,18 @@ class _FakeTerminalSocketSession extends TerminalSocketSession {
final bool autoConnect;
final resizeCalls = <List<int>>[];
final sentInputs = <String>[];
final sentInputIds = <String>[];
int disposeCount = 0;
Completer<void>? disposeCompleter;
Completer<void> _connectCompleter = Completer<void>();
void Function(String frame)? _onFrame;
void Function(TerminalOutputPayload output)? _onOutput;
void Function()? _onDisconnected;
void Function(String inputId)? _onInputAck;
bool _isDisconnected = false;
void Function(TerminalRestorePayload restore)? _onRestore;
@override
Future<void> connect({
required void Function(TerminalOutputPayload output) onOutput,
required void Function(TerminalRestorePayload restore) onRestore,
void Function(String inputId)? onInputAck,
required void Function(String frame) onFrame,
void Function()? onDisconnected,
}) {
_onOutput = onOutput;
_onRestore = onRestore;
_onInputAck = onInputAck;
_onFrame = onFrame;
_onDisconnected = onDisconnected;
if (autoConnect && !_connectCompleter.isCompleted) {
_connectCompleter.complete();
@ -1062,15 +314,12 @@ class _FakeTerminalSocketSession extends TerminalSocketSession {
}
@override
TerminalSocketDispatchResult sendInput(String input, {String? inputId}) {
TerminalSocketDispatchResult sendInput(String input) {
if (_isDisconnected || !_connectCompleter.isCompleted) {
return TerminalSocketDispatchResult.noTransport;
}
sentInputs.add(input);
if (inputId != null) {
sentInputIds.add(inputId);
}
return TerminalSocketDispatchResult.sent;
}
@ -1080,12 +329,8 @@ class _FakeTerminalSocketSession extends TerminalSocketSession {
}
}
void emitOutput(TerminalOutputPayload output) {
_onOutput?.call(output);
}
void emitRestore(TerminalRestorePayload restore) {
_onRestore?.call(restore);
void emitFrame(String frame) {
_onFrame?.call(frame);
}
void disconnect() {
@ -1093,14 +338,9 @@ class _FakeTerminalSocketSession extends TerminalSocketSession {
_onDisconnected?.call();
}
void ackInput(String inputId) {
_onInputAck?.call(inputId);
}
@override
Future<void> dispose() async {
disposeCount += 1;
await disposeCompleter?.future;
}
}
@ -1126,20 +366,3 @@ class _FakeReconnectScheduler {
}
}
}
class _FakeResizeScheduler {
void Function()? pendingCallback;
CancelReconnect schedule(Duration _, void Function() callback) {
pendingCallback = callback;
return () {
pendingCallback = null;
};
}
void runPending() {
final callback = pendingCallback;
pendingCallback = null;
callback?.call();
}
}

View File

@ -1,125 +0,0 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:term_remote_ctl/features/terminal/terminal_snapshot.dart';
import 'package:term_remote_ctl/features/terminal/terminal_snapshot_storage.dart';
void main() {
late Directory tempDirectory;
late File storageFile;
late TerminalSnapshotStorage storage;
setUp(() async {
tempDirectory = await Directory.systemTemp.createTemp(
'terminal_snapshot_storage_test_',
);
storageFile = File(
'${tempDirectory.path}/${TerminalSnapshotStorage.storageFileName}',
);
storage = TerminalSnapshotStorage(
storageFileLoader: () async => storageFile,
);
});
tearDown(() async {
if (await tempDirectory.exists()) {
await tempDirectory.delete(recursive: true);
}
});
test('saves and reads a snapshot by session id', () async {
const snapshot = TerminalSnapshot(
sessionId: 'session-1',
projectId: 'project-1',
sessionName: 'codex-main',
bufferText: 'PS> git status',
updatedAtUtc: '2026-04-06T09:00:00Z',
);
await storage.save(snapshot);
final restored = await storage.read('session-1');
expect(restored, isNotNull);
expect(restored!.bufferText, 'PS> git status');
expect(restored.projectId, 'project-1');
});
test('deletes a single snapshot by session id', () async {
await storage.save(
const TerminalSnapshot(
sessionId: 'session-1',
projectId: 'project-1',
sessionName: 'codex-main',
bufferText: 'one',
updatedAtUtc: '2026-04-06T09:00:00Z',
),
);
await storage.save(
const TerminalSnapshot(
sessionId: 'session-2',
projectId: 'project-1',
sessionName: 'cloud-code',
bufferText: 'two',
updatedAtUtc: '2026-04-06T09:01:00Z',
),
);
await storage.delete('session-1');
expect(await storage.read('session-1'), isNull);
expect(await storage.read('session-2'), isNotNull);
});
test('deletes all snapshots for a project id', () async {
await storage.save(
const TerminalSnapshot(
sessionId: 'session-1',
projectId: 'project-1',
sessionName: 'codex-main',
bufferText: 'one',
updatedAtUtc: '2026-04-06T09:00:00Z',
),
);
await storage.save(
const TerminalSnapshot(
sessionId: 'session-2',
projectId: 'project-2',
sessionName: 'cloud-code',
bufferText: 'two',
updatedAtUtc: '2026-04-06T09:01:00Z',
),
);
await storage.deleteByProjectId('project-1');
expect(await storage.read('session-1'), isNull);
expect(await storage.read('session-2'), isNotNull);
});
test('prunes snapshots that are no longer in the live session set', () async {
await storage.save(
const TerminalSnapshot(
sessionId: 'session-1',
projectId: 'project-1',
sessionName: 'codex-main',
bufferText: 'one',
updatedAtUtc: '2026-04-06T09:00:00Z',
),
);
await storage.save(
const TerminalSnapshot(
sessionId: 'session-2',
projectId: 'project-2',
sessionName: 'cloud-code',
bufferText: 'two',
updatedAtUtc: '2026-04-06T09:01:00Z',
),
);
await storage.pruneToSessionIds({'session-2'});
expect(await storage.read('session-1'), isNull);
expect(await storage.read('session-2'), isNotNull);
});
}

View File

@ -2,8 +2,6 @@ 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_socket_session.dart';
void main() {
@ -15,13 +13,11 @@ void main() {
transportFactory: (_) => transport,
);
final outputs = <TerminalOutputPayload>[];
final frames = <String>[];
var completed = false;
final connectFuture = session
.connect(onOutput: outputs.add, onRestore: (_) {})
.then((_) {
completed = true;
});
final connectFuture = session.connect(onFrame: frames.add).then((_) {
completed = true;
});
await Future<void>.delayed(Duration.zero);
expect(
@ -35,37 +31,28 @@ void main() {
await connectFuture;
expect(completed, isTrue);
expect(outputs, isEmpty);
expect(frames, isEmpty);
transport.emit(
'{"type":"output","sessionId":"session-123","sequence":3,"chunk":"abc"}',
);
transport.emit('abc');
await Future<void>.delayed(Duration.zero);
expect(outputs.single.sequence, 3);
expect(outputs.single.chunk, 'abc');
expect(frames, ['abc']);
await session.dispose();
});
test(
'connect fails if the socket closes before attach acknowledgement',
() async {
final transport = _FakeTerminalSocketTransport();
final session = TerminalSocketSession(
sessionId: 'session-123',
socketClient: AgentSocketClient(Uri.parse('https://host:9443')),
transportFactory: (_) => transport,
);
test('connect fails if the socket closes before attach acknowledgement', () async {
final transport = _FakeTerminalSocketTransport();
final session = TerminalSocketSession(
sessionId: 'session-123',
socketClient: AgentSocketClient(Uri.parse('https://host:9443')),
transportFactory: (_) => transport,
);
final connectFuture = session.connect(
onOutput: (_) {},
onRestore: (_) {},
);
await transport.close();
final connectFuture = session.connect(onFrame: (_) {});
await transport.close();
await expectLater(connectFuture, throwsStateError);
},
);
await expectLater(connectFuture, throwsStateError);
});
test('sendInput serializes the input message', () async {
final transport = _FakeTerminalSocketTransport();
@ -75,15 +62,15 @@ void main() {
transportFactory: (_) => transport,
);
final connectFuture = session.connect(onOutput: (_) {}, onRestore: (_) {});
final connectFuture = session.connect(onFrame: (_) {});
await Future<void>.delayed(Duration.zero);
transport.emit('{"type":"attached","sessionId":"session-123"}');
await connectFuture;
session.sendInput('dir\r', inputId: 'input-1');
session.sendInput('dir\r');
expect(
transport.sentMessages,
contains('{"type":"input","input":"dir\\r","inputId":"input-1"}'),
contains('{"type":"input","input":"dir\\r"}'),
);
await session.dispose();
@ -97,7 +84,7 @@ void main() {
transportFactory: (_) => transport,
);
final connectFuture = session.connect(onOutput: (_) {}, onRestore: (_) {});
final connectFuture = session.connect(onFrame: (_) {});
await Future<void>.delayed(Duration.zero);
transport.emit('{"type":"attached","sessionId":"session-123"}');
await connectFuture;
@ -122,8 +109,7 @@ void main() {
var disconnectCount = 0;
final connectFuture = session.connect(
onOutput: (_) {},
onRestore: (_) {},
onFrame: (_) {},
onDisconnected: () {
disconnectCount += 1;
},
@ -146,7 +132,7 @@ void main() {
transportFactory: (_) => transport,
);
final connectFuture = session.connect(onOutput: (_) {}, onRestore: (_) {});
final connectFuture = session.connect(onFrame: (_) {});
await Future<void>.delayed(Duration.zero);
transport.emit('{"type":"attached","sessionId":"session-123"}');
await connectFuture;
@ -159,125 +145,6 @@ void main() {
TerminalSocketDispatchResult.noTransport,
);
});
test('connect routes restore frames to onRestore before live output', () async {
final transport = _FakeTerminalSocketTransport();
final session = TerminalSocketSession(
sessionId: 'session-123',
socketClient: AgentSocketClient(Uri.parse('https://host:9443')),
transportFactory: (_) => transport,
);
final outputs = <TerminalOutputPayload>[];
final restores = <TerminalRestorePayload>[];
final connectFuture = session.connect(
onOutput: outputs.add,
onRestore: restores.add,
);
await Future<void>.delayed(Duration.zero);
transport.emit('{"type":"attached","sessionId":"session-123"}');
await connectFuture;
transport.emit(
'{"type":"restore","sessionId":"session-123","sequence":4,"screenText":"PS> gi","pendingInput":"t status"}',
);
transport.emit(
'{"type":"output","sessionId":"session-123","sequence":5,"chunk":"live-output"}',
);
await Future<void>.delayed(Duration.zero);
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(outputs, hasLength(1));
expect(outputs.single.sequence, 5);
expect(outputs.single.chunk, 'live-output');
});
test('connect routes input acknowledgements to onInputAck', () async {
final transport = _FakeTerminalSocketTransport();
final session = TerminalSocketSession(
sessionId: 'session-123',
socketClient: AgentSocketClient(Uri.parse('https://host:9443')),
transportFactory: (_) => transport,
);
final acknowledgements = <String>[];
final connectFuture = session.connect(
onOutput: (_) {},
onRestore: (_) {},
onInputAck: acknowledgements.add,
);
await Future<void>.delayed(Duration.zero);
transport.emit('{"type":"attached","sessionId":"session-123"}');
await connectFuture;
transport.emit(
'{"type":"inputAck","sessionId":"session-123","inputId":"input-1"}',
);
await Future<void>.delayed(Duration.zero);
expect(acknowledgements, ['input-1']);
});
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 ignores restore and output frames for a different session', () async {
final transport = _FakeTerminalSocketTransport();
final session = TerminalSocketSession(
sessionId: 'session-123',
socketClient: AgentSocketClient(Uri.parse('https://host:9443')),
transportFactory: (_) => transport,
);
final outputs = <TerminalOutputPayload>[];
final restores = <TerminalRestorePayload>[];
final connectFuture = session.connect(
onOutput: outputs.add,
onRestore: restores.add,
);
await Future<void>.delayed(Duration.zero);
transport.emit('{"type":"attached","sessionId":"session-123"}');
await connectFuture;
transport.emit(
'{"type":"restore","sessionId":"session-999","sequence":4,"screenText":"wrong","pendingInput":""}',
);
transport.emit(
'{"type":"output","sessionId":"session-999","sequence":5,"chunk":"wrong-output"}',
);
await Future<void>.delayed(Duration.zero);
expect(restores, isEmpty);
expect(outputs, isEmpty);
});
}
class _FakeTerminalSocketTransport implements TerminalSocketTransport {

View File

@ -5,7 +5,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:term_remote_ctl/app/app.dart';
import 'package:term_remote_ctl/core/network/agent_api_client.dart';
import 'package:term_remote_ctl/core/network/agent_base_uri_storage.dart';
import 'package:term_remote_ctl/core/network/agent_connection_providers.dart';
import 'package:term_remote_ctl/features/projects/project.dart';
import 'package:term_remote_ctl/features/projects/project_repository.dart';
@ -27,9 +26,6 @@ void main() {
ProviderScope(
overrides: [
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
agentBaseUriStorageProvider.overrideWithValue(
_MemoryAgentBaseUriStorage(),
),
projectRepositoryProvider.overrideWithValue(projectRepository),
sessionRepositoryProvider.overrideWithValue(sessionRepository),
terminalSocketSessionFactoryProvider.overrideWithValue(
@ -140,13 +136,3 @@ class _FakeTerminalSocketTransport implements TerminalSocketTransport {
_incoming.add(message);
}
}
class _MemoryAgentBaseUriStorage extends AgentBaseUriStorage {
@override
Future<Uri?> read() async {
return null;
}
@override
Future<void> write(Uri uri) async {}
}

View File

@ -7,8 +7,6 @@ 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_session_coordinator.dart';
import 'package:term_remote_ctl/features/terminal/terminal_socket_session.dart';
@ -28,7 +26,6 @@ void main() {
baseUri: Uri.parse('https://host.example:9443'),
diagnosticLog: diagnosticLog,
onFrame: (_) {},
onRestore: (_) {},
viewportProvider: () => const TerminalViewport(columns: 120, rows: 30),
);
@ -63,7 +60,6 @@ void main() {
baseUri: Uri.parse('https://host.example:9443'),
diagnosticLog: diagnosticLog,
onFrame: (_) {},
onRestore: (_) {},
viewportProvider: () => const TerminalViewport(columns: 120, rows: 30),
);
@ -89,18 +85,14 @@ class _FakeAgentApiClient extends AgentApiClient {
_FakeAgentApiClient() : super(Uri.parse('https://host.example:9443'));
@override
Future<Map<String, dynamic>> getSessionJournal(
Future<Map<String, dynamic>> getSessionHistory(
String sessionId, {
int limit = 200,
int? beforeSequence,
int? afterSequence,
int lineCount = 200,
}) async {
return <String, dynamic>{
'sessionId': sessionId,
'items': const <Map<String, dynamic>>[],
'hasMoreBefore': false,
'hasMoreAfter': false,
'currentSequence': 0,
'lines': const <String>[],
'hasMoreAbove': false,
};
}
}
@ -116,8 +108,7 @@ class _RecordingTerminalSocketSession extends TerminalSocketSession {
@override
Future<void> connect({
required void Function(TerminalOutputPayload output) onOutput,
required void Function(TerminalRestorePayload restore) onRestore,
required void Function(String frame) onFrame,
void Function()? onDisconnected,
}) async {}

File diff suppressed because it is too large Load Diff

View File

@ -14,48 +14,14 @@ public static class SessionEndpoints
var group = endpoints.MapGroup("/api/sessions");
group.MapGet(string.Empty, (SessionRegistry registry) => Results.Ok(registry.List()));
group.MapGet("/{sessionId}/history", async (
group.MapGet("/{sessionId}/history", (
string sessionId,
int? lineCount,
SessionRegistry registry,
CancellationToken cancellationToken) =>
SessionRegistry registry) =>
{
try
{
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);
return Results.Ok(registry.GetHistory(sessionId, lineCount ?? 200));
}
catch (KeyNotFoundException)
{

View File

@ -16,10 +16,6 @@ public sealed class AgentOptions
public int RingBufferLineLimit { get; set; }
public int SessionJournalRetentionDays { get; set; } = 7;
public bool EnableBackendScreenProtocol { get; set; }
public bool HasHttpsEndpoint => HttpsPort > 0;
public bool HasHttpEndpoint => HttpPort > 0;

View File

@ -21,7 +21,6 @@ public static class AgentOptionsServiceCollectionExtensions
options.HttpPort = effectiveOptions.HttpPort;
options.WebSocketFrameFlushMilliseconds = effectiveOptions.WebSocketFrameFlushMilliseconds;
options.RingBufferLineLimit = effectiveOptions.RingBufferLineLimit;
options.EnableBackendScreenProtocol = effectiveOptions.EnableBackendScreenProtocol;
})
.ValidateOnStart();

View File

@ -1,155 +0,0 @@
using System.Text;
using System.Text.RegularExpressions;
namespace TermRemoteCtl.Agent.History;
public sealed class PendingInputEchoTracker
{
private const int RecentOutputCharacterLimit = 4096;
private static readonly Regex AnsiSequenceRegex = new(@"\x1B\[[0-?]*[ -/]*[@-~]", RegexOptions.Compiled);
private readonly StringBuilder _pendingVisibleInput = new();
private readonly StringBuilder _recentVisibleOutput = new();
public void Record(string input)
{
ArgumentNullException.ThrowIfNull(input);
for (var index = 0; index < input.Length; index += 1)
{
var current = input[index];
switch (current)
{
case '\r':
_pendingVisibleInput.Append("\r\n");
if (index + 1 < input.Length && input[index + 1] == '\n')
{
index += 1;
}
break;
case '\n':
_pendingVisibleInput.Append("\r\n");
break;
case '\b':
case '\u007f':
RemoveLastVisibleCharacter();
break;
case '\t':
_pendingVisibleInput.Append('\t');
break;
case '\u001b':
index = SkipEscapeSequence(input, index);
break;
default:
if (!char.IsControl(current))
{
_pendingVisibleInput.Append(current);
}
break;
}
}
}
public void ObserveOutput(string chunk)
{
ArgumentNullException.ThrowIfNull(chunk);
if (_pendingVisibleInput.Length == 0)
{
return;
}
var visibleOutput = StripAnsiSequences(chunk);
if (string.IsNullOrEmpty(visibleOutput))
{
return;
}
_recentVisibleOutput.Append(visibleOutput);
TrimRecentOutput();
var normalizedPending = NormalizeForMatching(_pendingVisibleInput.ToString());
if (normalizedPending.Length == 0)
{
return;
}
var normalizedRecentOutput = NormalizeForMatching(_recentVisibleOutput.ToString());
if (normalizedRecentOutput.Contains(normalizedPending, StringComparison.Ordinal))
{
_pendingVisibleInput.Clear();
_recentVisibleOutput.Clear();
}
}
public string GetVisibleSuffix()
{
return _pendingVisibleInput.ToString();
}
private void RemoveLastVisibleCharacter()
{
if (_pendingVisibleInput.Length == 0)
{
return;
}
if (_pendingVisibleInput.Length >= 2 &&
_pendingVisibleInput[^2] == '\r' &&
_pendingVisibleInput[^1] == '\n')
{
return;
}
_pendingVisibleInput.Remove(_pendingVisibleInput.Length - 1, 1);
}
private static int SkipEscapeSequence(string input, int startIndex)
{
var index = startIndex + 1;
if (index >= input.Length)
{
return startIndex;
}
if (input[index] != '[')
{
return index;
}
index += 1;
while (index < input.Length)
{
var current = input[index];
if (current >= '@' && current <= '~')
{
return index;
}
index += 1;
}
return input.Length - 1;
}
private void TrimRecentOutput()
{
if (_recentVisibleOutput.Length <= RecentOutputCharacterLimit)
{
return;
}
_recentVisibleOutput.Remove(0, _recentVisibleOutput.Length - RecentOutputCharacterLimit);
}
private static string StripAnsiSequences(string chunk)
{
return AnsiSequenceRegex.Replace(chunk, string.Empty);
}
private static string NormalizeForMatching(string text)
{
return text.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace('\r', '\n');
}
}

View File

@ -1,12 +1,10 @@
using System.Text;
using System.Collections.Concurrent;
namespace TermRemoteCtl.Agent.History;
public sealed class SessionHistoryStore
{
private static readonly UTF8Encoding Utf8WithoutBom = new(false);
private readonly ConcurrentDictionary<string, SemaphoreSlim> _writeGates = new();
private readonly string _historyRootPath;
public SessionHistoryStore(string rootPath)
@ -21,47 +19,30 @@ public sealed class SessionHistoryStore
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
ArgumentNullException.ThrowIfNull(chunk);
var gate = _writeGates.GetOrAdd(sessionId, static _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var filePath = Path.Combine(_historyRootPath, $"{sessionId}.log");
await using var stream = new FileStream(
filePath,
FileMode.Append,
FileAccess.Write,
FileShare.Read,
4096,
FileOptions.Asynchronous);
await using var writer = new StreamWriter(stream, Utf8WithoutBom);
await writer.WriteAsync(chunk.AsMemory(), cancellationToken).ConfigureAwait(false);
await writer.FlushAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
gate.Release();
}
var filePath = Path.Combine(_historyRootPath, $"{sessionId}.log");
await using var stream = new FileStream(
filePath,
FileMode.Append,
FileAccess.Write,
FileShare.Read,
4096,
FileOptions.Asynchronous);
await using var writer = new StreamWriter(stream, Utf8WithoutBom);
await writer.WriteAsync(chunk.AsMemory(), cancellationToken);
await writer.FlushAsync(cancellationToken);
}
public async Task DeleteAsync(string sessionId, CancellationToken cancellationToken)
public Task DeleteAsync(string sessionId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
cancellationToken.ThrowIfCancellationRequested();
var gate = _writeGates.GetOrAdd(sessionId, static _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
var filePath = Path.Combine(_historyRootPath, $"{sessionId}.log");
if (File.Exists(filePath))
{
var filePath = Path.Combine(_historyRootPath, $"{sessionId}.log");
if (File.Exists(filePath))
{
File.Delete(filePath);
}
}
finally
{
gate.Release();
_writeGates.TryRemove(sessionId, out _);
File.Delete(filePath);
}
return Task.CompletedTask;
}
}

View File

@ -1,8 +0,0 @@
namespace TermRemoteCtl.Agent.History;
public sealed record SessionIoEvent(
string SessionId,
long Sequence,
string Kind,
string Payload,
DateTimeOffset TimestampUtc);

View File

@ -1,161 +0,0 @@
using System.Text;
using System.Text.Json;
using System.Collections.Concurrent;
namespace TermRemoteCtl.Agent.History;
public sealed class SessionIoJournalStore
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
private static readonly UTF8Encoding Utf8WithoutBom = new(false);
private readonly ConcurrentDictionary<string, SemaphoreSlim> _writeGates = new();
private readonly string _sessionRoot;
public SessionIoJournalStore(string rootPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
_sessionRoot = Path.Combine(rootPath, "sessions");
Directory.CreateDirectory(_sessionRoot);
}
public async Task AppendAsync(SessionIoEvent ioEvent, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(ioEvent);
var gate = _writeGates.GetOrAdd(ioEvent.SessionId, static _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var filePath = Path.Combine(_sessionRoot, $"{ioEvent.SessionId}.io.jsonl");
var line = JsonSerializer.Serialize(ioEvent, SerializerOptions) + Environment.NewLine;
await File.AppendAllTextAsync(filePath, line, Utf8WithoutBom, cancellationToken).ConfigureAwait(false);
}
finally
{
gate.Release();
}
}
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 async Task DeleteAsync(string sessionId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
cancellationToken.ThrowIfCancellationRequested();
var gate = _writeGates.GetOrAdd(sessionId, static _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var filePath = Path.Combine(_sessionRoot, $"{sessionId}.io.jsonl");
if (File.Exists(filePath))
{
File.Delete(filePath);
}
}
finally
{
gate.Release();
_writeGates.TryRemove(sessionId, out _);
}
}
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,
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

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

View File

@ -23,23 +23,13 @@ builder.Services.AddSingleton(serviceProvider =>
builder.Services.AddSingleton<ProjectRegistry>();
builder.Services.AddSingleton<SessionRegistry>();
builder.Services.AddSingleton<IConPtySessionFactory, ConPtySessionFactory>();
builder.Services.AddSingleton<ISessionHost>(serviceProvider =>
{
return new PowerShellSessionHost(
serviceProvider.GetRequiredService<IConPtySessionFactory>(),
serviceProvider.GetRequiredService<SessionRegistry>());
});
builder.Services.AddSingleton<ISessionHost, PowerShellSessionHost>();
builder.Services.AddSingleton<ITerminalDiagnosticsSink, LoggingTerminalDiagnosticsSink>();
builder.Services.AddSingleton(serviceProvider =>
{
var options = serviceProvider.GetRequiredService<IOptions<AgentOptions>>().Value;
return new SessionHistoryStore(options.DataRoot);
});
builder.Services.AddSingleton(serviceProvider =>
{
var options = serviceProvider.GetRequiredService<IOptions<AgentOptions>>().Value;
return new SessionIoJournalStore(options.DataRoot);
});
// Task 2 uses ASP.NET Core's local development certificate so HttpsPort is a truthful HTTPS listener.
builder.WebHost.ConfigureKestrel(kestrel =>
{

View File

@ -1,8 +1,10 @@
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.Sessions;
using TermRemoteCtl.Agent.Terminal.Screen;
using TermRemoteCtl.Agent.Terminal;
namespace TermRemoteCtl.Agent.Realtime;
@ -42,6 +44,7 @@ public static class TerminalWebSocketHandler
var host = context.RequestServices.GetRequiredService<ISessionHost>();
var diagnostics = context.RequestServices.GetRequiredService<ITerminalDiagnosticsSink>();
var options = context.RequestServices.GetRequiredService<IOptions<AgentOptions>>().Value;
using var socket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false);
try
{
@ -58,55 +61,32 @@ 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));
void HandleOutput(object? sender, TerminalOutputEventArgs args)
{
if (string.Equals(args.SessionId, sessionId, StringComparison.Ordinal))
{
_ = SendJsonAsync(
socket,
new TerminalOutputResponse(args.SessionId, args.Sequence, args.Chunk),
sendGate,
context.RequestAborted);
batcher.Append(args.Chunk);
}
}
try
{
await registry.RecordAttachAsync(sessionId, context.RequestAborted).ConfigureAwait(false);
var restore = registry.GetRestoreSnapshot(sessionId);
await SendJsonAsync(socket, new TerminalAttachResponse(sessionId), sendGate, context.RequestAborted).ConfigureAwait(false);
await SendJsonAsync(
socket,
new TerminalRestoreResponse(
restore.SessionId,
restore.Sequence,
restore.ScreenText,
restore.PendingInput,
restore.CursorRow,
restore.CursorColumn,
MapScreenSnapshot(restore.ScreenSnapshot)),
sendGate,
context.RequestAborted).ConfigureAwait(false);
var replay = registry.GetReplaySnapshot(sessionId);
host.OutputReceived += HandleOutput;
await ReceiveLoopAsync(
context,
socket,
host,
registry,
diagnostics,
sessionId,
sendGate).ConfigureAwait(false);
await SendJsonAsync(socket, new TerminalAttachResponse(sessionId), sendGate, context.RequestAborted).ConfigureAwait(false);
if (!string.IsNullOrEmpty(replay))
{
await SendTextAsync(socket, replay, sendGate, context.RequestAborted).ConfigureAwait(false);
}
await ReceiveLoopAsync(context, socket, host, diagnostics, sessionId).ConfigureAwait(false);
}
finally
{
host.OutputReceived -= HandleOutput;
try
{
await registry.RecordDetachAsync(sessionId, CancellationToken.None).ConfigureAwait(false);
}
catch
{
}
}
}
@ -114,10 +94,8 @@ public static class TerminalWebSocketHandler
HttpContext context,
WebSocket socket,
ISessionHost host,
SessionRegistry registry,
ITerminalDiagnosticsSink diagnostics,
string sessionId,
SemaphoreSlim sendGate)
string sessionId)
{
var buffer = new byte[4096];
@ -145,24 +123,18 @@ public static class TerminalWebSocketHandler
await HandleClientMessageAsync(
Encoding.UTF8.GetString(message.ToArray()),
socket,
registry,
host,
diagnostics,
sessionId,
sendGate,
context.RequestAborted).ConfigureAwait(false);
}
}
private static async Task HandleClientMessageAsync(
string payload,
WebSocket socket,
SessionRegistry registry,
ISessionHost host,
ITerminalDiagnosticsSink diagnostics,
string sessionId,
SemaphoreSlim sendGate,
CancellationToken cancellationToken)
{
TerminalClientMessage? message;
@ -176,9 +148,7 @@ 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))
{
if (message is not null && string.Equals(message.Type, "attach", StringComparison.OrdinalIgnoreCase))
{
@ -192,47 +162,8 @@ public static class TerminalWebSocketHandler
{
if (!string.IsNullOrEmpty(message.Input))
{
if (!string.IsNullOrWhiteSpace(message.InputId))
{
if (!registry.TryBeginInputReceipt(sessionId, message.InputId, out var existingReceipt))
{
if (existingReceipt is not null && await existingReceipt.ConfigureAwait(false))
{
await SendJsonAsync(
socket,
new TerminalInputAckResponse(sessionId, message.InputId),
sendGate,
cancellationToken).ConfigureAwait(false);
}
return;
}
}
try
{
diagnostics.Record("backend.input.received", sessionId, SanitizeDiagnosticText(message.Input));
await host.WriteInputAsync(sessionId, message.Input, cancellationToken).ConfigureAwait(false);
await registry.RecordInputAsync(sessionId, message.Input, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(message.InputId))
{
registry.CompleteInputReceipt(sessionId, message.InputId, succeeded: true);
await SendJsonAsync(
socket,
new TerminalInputAckResponse(sessionId, message.InputId),
sendGate,
cancellationToken).ConfigureAwait(false);
}
}
catch
{
if (!string.IsNullOrWhiteSpace(message.InputId))
{
registry.CompleteInputReceipt(sessionId, message.InputId, succeeded: false);
}
throw;
}
diagnostics.Record("backend.input.received", sessionId, SanitizeDiagnosticText(message.Input));
await host.WriteInputAsync(sessionId, message.Input, cancellationToken).ConfigureAwait(false);
}
return;
@ -240,11 +171,6 @@ public static class TerminalWebSocketHandler
if (message.Columns is > 0 && message.Rows is > 0)
{
await registry.RecordResizeAsync(
sessionId,
message.Columns.Value,
message.Rows.Value,
cancellationToken).ConfigureAwait(false);
await host.ResizeAsync(sessionId, message.Columns.Value, message.Rows.Value, cancellationToken).ConfigureAwait(false);
}
}
@ -254,39 +180,9 @@ public static class TerminalWebSocketHandler
return input.Replace("\r", "\\r", StringComparison.Ordinal).Replace("\n", "\\n", StringComparison.Ordinal);
}
private static TerminalScreenSnapshotResponse? MapScreenSnapshot(
TerminalScreenSnapshot? snapshot)
{
if (snapshot is null)
{
return null;
}
return new TerminalScreenSnapshotResponse(
snapshot.ScreenVersion,
snapshot.SourceSequence,
snapshot.Rows,
snapshot.Columns,
snapshot.CursorRow,
snapshot.CursorColumn,
snapshot.CursorVisible,
snapshot.ActiveBuffer,
MapScreenBuffer(snapshot.PrimaryBuffer),
snapshot.AlternateBuffer is null ? null : MapScreenBuffer(snapshot.AlternateBuffer));
}
private static TerminalScreenBufferSnapshotResponse MapScreenBuffer(
TerminalScreenBufferSnapshot buffer)
{
return new TerminalScreenBufferSnapshotResponse(
buffer.Viewport
.Select(static line => new TerminalScreenLineSnapshotResponse(line.Index, line.Text))
.ToArray());
}
private static async Task SendJsonAsync(
WebSocket socket,
object response,
TerminalAttachResponse response,
SemaphoreSlim sendGate,
CancellationToken cancellationToken)
{
@ -294,6 +190,16 @@ 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,
@ -319,51 +225,10 @@ public static class TerminalWebSocketHandler
private sealed record TerminalAttachResponse(string SessionId, string Type = "attached");
private sealed record TerminalRestoreResponse(
string SessionId,
long Sequence,
string ScreenText,
string PendingInput,
int? CursorRow,
int? CursorColumn,
TerminalScreenSnapshotResponse? ScreenSnapshot,
string Type = "restore");
private sealed record TerminalOutputResponse(
string SessionId,
long Sequence,
string Chunk,
string Type = "output");
private sealed record TerminalInputAckResponse(
string SessionId,
string InputId,
string Type = "inputAck");
private sealed record TerminalScreenSnapshotResponse(
long ScreenVersion,
long SourceSequence,
int Rows,
int Columns,
int CursorRow,
int CursorColumn,
bool CursorVisible,
string ActiveBuffer,
TerminalScreenBufferSnapshotResponse PrimaryBuffer,
TerminalScreenBufferSnapshotResponse? AlternateBuffer);
private sealed record TerminalScreenBufferSnapshotResponse(
IReadOnlyList<TerminalScreenLineSnapshotResponse> Viewport);
private sealed record TerminalScreenLineSnapshotResponse(
int Index,
string Text);
private sealed record TerminalClientMessage(
string Type,
string? SessionId,
string? Input,
string? InputId,
int? Columns,
int? Rows);
}

View File

@ -2,36 +2,22 @@ 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;
public sealed class SessionRegistry
{
private const int ReplayCharacterLimit = 262_144;
private const string ScreenProtocolDisabledMessage =
"Backend screen protocol is disabled on the mainline product path.";
private readonly ConcurrentDictionary<string, SessionRecord> _records = new();
private readonly ConcurrentDictionary<string, TerminalRingBuffer> _historyBySession = new();
private readonly ConcurrentDictionary<string, TerminalReplayBuffer> _replayBySession = new();
private readonly ConcurrentDictionary<string, PendingInputEchoTracker> _pendingInputEchoBySession = new();
private readonly ConcurrentDictionary<string, TerminalInputReceiptTracker> _inputReceiptsBySession = 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;
private readonly bool _enableBackendScreenProtocol;
public SessionRegistry(
SessionHistoryStore historyStore,
SessionIoJournalStore journalStore,
IOptions<AgentOptions> options)
public SessionRegistry(SessionHistoryStore historyStore, IOptions<AgentOptions> options)
{
_historyStore = historyStore;
_journalStore = journalStore;
_ringBufferLineLimit = options.Value.RingBufferLineLimit;
_enableBackendScreenProtocol = options.Value.EnableBackendScreenProtocol;
}
public SessionRecord Create(
@ -54,13 +40,6 @@ public sealed class SessionRegistry
_records[record.SessionId] = record;
_historyBySession[record.SessionId] = new TerminalRingBuffer(_ringBufferLineLimit);
_replayBySession[record.SessionId] = new TerminalReplayBuffer(ReplayCharacterLimit);
_pendingInputEchoBySession[record.SessionId] = new PendingInputEchoTracker();
_inputReceiptsBySession[record.SessionId] = new TerminalInputReceiptTracker();
if (_enableBackendScreenProtocol)
{
_screenBySession[record.SessionId] = new TerminalScreenEngine();
}
_sequenceBySession[record.SessionId] = 0;
return record;
}
@ -103,14 +82,6 @@ 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);
@ -128,103 +99,11 @@ public sealed class SessionRegistry
sessionId,
_ => new TerminalReplayBuffer(ReplayCharacterLimit));
replay.Append(chunk);
var pendingInputEcho = _pendingInputEchoBySession.GetOrAdd(
sessionId,
_ => new PendingInputEchoTracker());
pendingInputEcho.ObserveOutput(chunk);
var updatedAtUtc = DateTimeOffset.UtcNow;
var ioEvent = new SessionIoEvent(
sessionId,
AdvanceSequence(sessionId),
"output",
chunk,
updatedAtUtc);
if (_enableBackendScreenProtocol)
{
var screen = _screenBySession.GetOrAdd(sessionId, _ => new TerminalScreenEngine());
screen.ApplyOutput(chunk, ioEvent.Sequence);
}
_records[sessionId] = record with { UpdatedAtUtc = updatedAtUtc };
_records[sessionId] = record with { UpdatedAtUtc = DateTimeOffset.UtcNow };
await _historyStore.AppendAsync(sessionId, chunk, cancellationToken).ConfigureAwait(false);
await _journalStore.AppendAsync(ioEvent, cancellationToken).ConfigureAwait(false);
return ioEvent;
}
public async Task<SessionIoEvent> RecordInputAsync(
string sessionId,
string input,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
ArgumentNullException.ThrowIfNull(input);
if (!_records.TryGetValue(sessionId, out var record))
{
throw new KeyNotFoundException($"Session '{sessionId}' was not found.");
}
var pendingInputEcho = _pendingInputEchoBySession.GetOrAdd(
sessionId,
_ => new PendingInputEchoTracker());
pendingInputEcho.Record(input);
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 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);
if (_enableBackendScreenProtocol)
{
var screen = _screenBySession.GetOrAdd(sessionId, _ => new TerminalScreenEngine());
screen.Resize(columns, rows);
}
_records[sessionId] = record with { UpdatedAtUtc = updatedAtUtc };
await _journalStore.AppendAsync(ioEvent, cancellationToken).ConfigureAwait(false);
return ioEvent;
}
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)
public SessionHistorySnapshot GetHistory(string sessionId, int lineCount)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(lineCount);
@ -234,17 +113,11 @@ public sealed class SessionRegistry
throw new KeyNotFoundException($"Session '{sessionId}' was not found.");
}
var journalPage = await _journalStore.ReadAsync(
var history = _historyBySession.GetOrAdd(
sessionId,
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);
_ => new TerminalRingBuffer(_ringBufferLineLimit));
var lines = history.GetSnapshotLines();
var skipCount = Math.Max(0, lines.Count - lineCount);
return new SessionHistorySnapshot(
sessionId,
@ -252,24 +125,6 @@ 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);
@ -282,100 +137,7 @@ public sealed class SessionRegistry
var replay = _replayBySession.GetOrAdd(
sessionId,
_ => new TerminalReplayBuffer(ReplayCharacterLimit));
var pendingInputEcho = _pendingInputEchoBySession.GetOrAdd(
sessionId,
_ => new PendingInputEchoTracker());
return string.Concat(replay.GetSnapshot(), pendingInputEcho.GetVisibleSuffix());
}
public SessionRestoreSnapshot GetRestoreSnapshot(string sessionId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
if (!_records.ContainsKey(sessionId))
{
throw new KeyNotFoundException($"Session '{sessionId}' was not found.");
}
var replay = _replayBySession.GetOrAdd(
sessionId,
_ => new TerminalReplayBuffer(ReplayCharacterLimit));
var pendingInputEcho = _pendingInputEchoBySession.GetOrAdd(
sessionId,
_ => new PendingInputEchoTracker());
var sequence = GetCurrentSequence(sessionId);
var screenSnapshot = _enableBackendScreenProtocol
? _screenBySession.GetOrAdd(sessionId, _ => new TerminalScreenEngine()).CreateSnapshot(sessionId)
: null;
return new SessionRestoreSnapshot(
sessionId,
sequence,
replay.GetSnapshot(),
pendingInputEcho.GetVisibleSuffix(),
null,
null,
screenSnapshot);
}
public bool TryBeginInputReceipt(
string sessionId,
string inputId,
out Task<bool>? existingReceipt)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
ArgumentException.ThrowIfNullOrWhiteSpace(inputId);
if (!_records.ContainsKey(sessionId))
{
throw new KeyNotFoundException($"Session '{sessionId}' was not found.");
}
var tracker = _inputReceiptsBySession.GetOrAdd(
sessionId,
_ => new TerminalInputReceiptTracker());
return tracker.TryBegin(inputId, out existingReceipt);
}
public void CompleteInputReceipt(string sessionId, string inputId, bool succeeded)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
ArgumentException.ThrowIfNullOrWhiteSpace(inputId);
var tracker = _inputReceiptsBySession.GetOrAdd(
sessionId,
_ => new TerminalInputReceiptTracker());
tracker.Complete(inputId, succeeded);
}
public TerminalScreenSnapshot GetScreenSnapshot(string sessionId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
if (!_enableBackendScreenProtocol)
{
throw new InvalidOperationException(ScreenProtocolDisabledMessage);
}
if (!_records.ContainsKey(sessionId))
{
throw new KeyNotFoundException($"Session '{sessionId}' was not found.");
}
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);
return replay.GetSnapshot();
}
public async Task DeleteAsync(string sessionId, CancellationToken cancellationToken)
@ -389,51 +151,6 @@ public sealed class SessionRegistry
_historyBySession.TryRemove(sessionId, out _);
_replayBySession.TryRemove(sessionId, out _);
_pendingInputEchoBySession.TryRemove(sessionId, out _);
_inputReceiptsBySession.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

@ -1,12 +0,0 @@
using TermRemoteCtl.Agent.Terminal.Screen;
namespace TermRemoteCtl.Agent.Sessions;
public sealed record SessionRestoreSnapshot(
string SessionId,
long Sequence,
string ScreenText,
string PendingInput,
int? CursorRow,
int? CursorColumn,
TerminalScreenSnapshot? ScreenSnapshot);

View File

@ -1,45 +0,0 @@
using System.Collections.Concurrent;
namespace TermRemoteCtl.Agent.Sessions;
internal sealed class TerminalInputReceiptTracker
{
private readonly ConcurrentDictionary<string, TaskCompletionSource<bool>> _receipts = new();
public bool TryBegin(string inputId, out Task<bool>? existingReceipt)
{
ArgumentException.ThrowIfNullOrWhiteSpace(inputId);
var completionSource = new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
if (_receipts.TryAdd(inputId, completionSource))
{
existingReceipt = null;
return true;
}
existingReceipt = _receipts[inputId].Task;
return false;
}
public void Complete(string inputId, bool succeeded)
{
ArgumentException.ThrowIfNullOrWhiteSpace(inputId);
if (!_receipts.TryGetValue(inputId, out var completionSource))
{
return;
}
if (succeeded)
{
completionSource.TrySetResult(true);
return;
}
if (_receipts.TryRemove(inputId, out var removed))
{
removed.TrySetResult(false);
}
}
}

View File

@ -15,17 +15,14 @@ public interface ISessionHost
public sealed class TerminalOutputEventArgs : EventArgs
{
public TerminalOutputEventArgs(string sessionId, string chunk, long sequence = 0)
public TerminalOutputEventArgs(string sessionId, string chunk)
{
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,5 @@
using System.Collections.Concurrent;
using System.Runtime.Versioning;
using System.Threading.Channels;
using TermRemoteCtl.Agent.Sessions;
namespace TermRemoteCtl.Agent.Terminal;
@ -11,12 +10,8 @@ internal sealed class PowerShellSessionHost : ISessionHost, IAsyncDisposable
private readonly IConPtySessionFactory _sessionFactory;
private readonly SessionRegistry _sessionRegistry;
private readonly ConcurrentDictionary<string, IConPtySession> _sessions = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, SessionOutputProcessor> _outputProcessors = new(StringComparer.Ordinal);
private readonly CancellationTokenSource _disposeCancellation = new();
public PowerShellSessionHost(
IConPtySessionFactory sessionFactory,
SessionRegistry sessionRegistry)
public PowerShellSessionHost(IConPtySessionFactory sessionFactory, SessionRegistry sessionRegistry)
{
_sessionFactory = sessionFactory;
_sessionRegistry = sessionRegistry;
@ -55,7 +50,6 @@ internal sealed class PowerShellSessionHost : ISessionHost, IAsyncDisposable
return;
}
_outputProcessors.GetOrAdd(sessionId, CreateOutputProcessor);
session.OutputReceived += HandleSessionOutput;
try
@ -108,13 +102,10 @@ internal sealed class PowerShellSessionHost : ISessionHost, IAsyncDisposable
session.OutputReceived -= HandleSessionOutput;
await session.DisposeAsync().ConfigureAwait(false);
await DisposeOutputProcessorAsync(sessionId).ConfigureAwait(false);
}
public async ValueTask DisposeAsync()
{
_disposeCancellation.Cancel();
foreach (var session in _sessions.Values)
{
session.OutputReceived -= HandleSessionOutput;
@ -122,86 +113,11 @@ internal sealed class PowerShellSessionHost : ISessionHost, IAsyncDisposable
}
_sessions.Clear();
foreach (var sessionId in _outputProcessors.Keys)
{
await DisposeOutputProcessorAsync(sessionId).ConfigureAwait(false);
}
_disposeCancellation.Dispose();
}
private void HandleSessionOutput(object? sender, TerminalOutputEventArgs args)
{
if (_outputProcessors.TryGetValue(args.SessionId, out var processor))
{
processor.Channel.Writer.TryWrite(args.Chunk);
}
_ = _sessionRegistry.AppendOutputAsync(args.SessionId, args.Chunk, CancellationToken.None);
OutputReceived?.Invoke(this, args);
}
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 SessionOutputProcessor CreateOutputProcessor(string sessionId)
{
var channel = Channel.CreateUnbounded<string>(
new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false,
});
var pumpTask = Task.Run(
() => PumpSessionOutputAsync(sessionId, channel.Reader, _disposeCancellation.Token),
CancellationToken.None);
return new SessionOutputProcessor(channel, pumpTask);
}
private async Task DisposeOutputProcessorAsync(string sessionId)
{
if (!_outputProcessors.TryRemove(sessionId, out var processor))
{
return;
}
processor.Channel.Writer.TryComplete();
try
{
await processor.PumpTask.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
}
}
private async Task PumpSessionOutputAsync(
string sessionId,
ChannelReader<string> reader,
CancellationToken cancellationToken)
{
await foreach (var chunk in reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
try
{
await PublishOutputAsync(sessionId, chunk).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return;
}
catch
{
// Keep one broken session from poisoning every other session's output path.
}
}
}
private sealed record SessionOutputProcessor(
Channel<string> Channel,
Task PumpTask);
}

View File

@ -1,391 +0,0 @@
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)
{
return false;
}
if (chunk[index + 1] == ']')
{
return TryConsumeOperatingSystemCommand(chunk, ref index);
}
if (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 static bool TryConsumeOperatingSystemCommand(string chunk, ref int index)
{
var cursor = index + 2;
while (cursor < chunk.Length)
{
var current = chunk[cursor];
if (current == '\u0007')
{
index = cursor;
return true;
}
if (current == '\u001b' &&
cursor + 1 < chunk.Length &&
chunk[cursor + 1] == '\\')
{
index = cursor + 1;
return true;
}
cursor += 1;
}
index = chunk.Length - 1;
return true;
}
private bool ApplyCsi(string parameterText, char command)
{
switch (command)
{
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

@ -1,129 +0,0 @@
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

@ -1,21 +0,0 @@
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

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

View File

@ -32,7 +32,6 @@ 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));
@ -62,7 +61,6 @@ 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));
@ -74,7 +72,6 @@ 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

@ -7,8 +7,6 @@ using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using TermRemoteCtl.Agent.Configuration;
using TermRemoteCtl.Agent.Sessions;
using TermRemoteCtl.Agent.Terminal;
@ -17,12 +15,10 @@ namespace TermRemoteCtl.Agent.IntegrationTests.Realtime;
public sealed class TerminalWebSocketHandlerTests
{
[Fact]
[Trait("Track", "Mainline")]
public async Task Attach_Streams_Output_And_Forwards_Input()
{
await using var fixture = new TerminalApiFixture();
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(
@ -38,36 +34,11 @@ public sealed class TerminalWebSocketHandlerTests
Assert.Equal("attached", attachedPayload!.Type);
Assert.Equal(session.SessionId, attachedPayload.SessionId);
var restoreFrame = await ReceiveTextAsync(socket, CancellationToken.None);
var restorePayload = JsonSerializer.Deserialize<TerminalRestoreResponse>(
restoreFrame,
new JsonSerializerOptions(JsonSerializerDefaults.Web));
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 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 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 outputFrame = await ReceiveTextAsync(socket, CancellationToken.None);
Assert.Equal("abcdef", outputFrame);
var inputMessage = JsonSerializer.Serialize(new { type = "input", input = "dir" });
await socket.SendAsync(Encoding.UTF8.GetBytes(inputMessage), WebSocketMessageType.Text, true, CancellationToken.None);
@ -79,12 +50,10 @@ public sealed class TerminalWebSocketHandlerTests
}
[Fact]
[Trait("Track", "Mainline")]
public async Task Attach_Replays_Recent_Output_For_Existing_Session()
{
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);
@ -100,20 +69,11 @@ public sealed class TerminalWebSocketHandlerTests
Assert.NotNull(attachedPayload);
Assert.Equal("attached", attachedPayload!.Type);
var restoreFrame = await ReceiveTextAsync(socket, CancellationToken.None);
var restorePayload = JsonSerializer.Deserialize<TerminalRestoreResponse>(
restoreFrame,
new JsonSerializerOptions(JsonSerializerDefaults.Web));
Assert.NotNull(restorePayload);
Assert.Equal("restore", restorePayload!.Type);
Assert.Equal("prompt> dir\r\nnext> ", restorePayload.ScreenText);
Assert.Equal(string.Empty, restorePayload.PendingInput);
Assert.Equal(2L, restorePayload.Sequence);
var replayFrame = await ReceiveTextAsync(socket, CancellationToken.None);
Assert.Equal("prompt> dir\r\nnext> ", replayFrame);
}
[Fact]
[Trait("Track", "Mainline")]
public async Task Attach_Does_Not_Duplicate_Output_Produced_During_Replay_Boundary()
{
await using var fixture = new TerminalApiFixture();
@ -135,153 +95,15 @@ public sealed class TerminalWebSocketHandlerTests
Assert.NotNull(attachedPayload);
Assert.Equal("attached", attachedPayload!.Type);
var restoreFrame = await ReceiveTextAsync(socket, CancellationToken.None);
var restorePayload = JsonSerializer.Deserialize<TerminalRestoreResponse>(
restoreFrame,
new JsonSerializerOptions(JsonSerializerDefaults.Web));
Assert.NotNull(restorePayload);
Assert.Equal("prompt> dir\r\n", restorePayload!.ScreenText);
Assert.Equal(string.Empty, restorePayload.PendingInput);
Assert.Equal(2L, restorePayload.Sequence);
var replayFrame = await ReceiveTextAsync(socket, CancellationToken.None);
Assert.Equal("prompt> dir\r\n", replayFrame);
var liveFrame = await ReceiveTextAsync(socket, CancellationToken.None);
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);
Assert.Equal("next> ", liveFrame);
await AssertNoAdditionalTextFrameAsync(socket, TimeSpan.FromMilliseconds(200));
}
[Fact]
[Trait("Track", "Mainline")]
public async Task Reattach_Replays_Visible_User_Input_When_No_Output_Echo_Has_Arrived_Yet()
{
await using var fixture = new TerminalApiFixture();
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);
var inputMessage = JsonSerializer.Serialize(new { type = "input", input = "dir" });
await socket.SendAsync(Encoding.UTF8.GetBytes(inputMessage), WebSocketMessageType.Text, true, CancellationToken.None);
await WaitForConditionAsync(() => fixture.TerminalHost.Inputs.Contains(("input", session.SessionId, "dir")), TimeSpan.FromSeconds(2));
}
using WebSocket replaySocket = await fixture.Server.CreateWebSocketClient().ConnectAsync(
new Uri($"ws://localhost/ws/terminal?sessionId={session.SessionId}"),
CancellationToken.None);
_ = await ReceiveTextAsync(replaySocket, CancellationToken.None);
var restoreFrame = await ReceiveTextAsync(replaySocket, CancellationToken.None);
var restorePayload = JsonSerializer.Deserialize<TerminalRestoreResponse>(
restoreFrame,
new JsonSerializerOptions(JsonSerializerDefaults.Web));
Assert.NotNull(restorePayload);
Assert.Equal("dir", restorePayload!.PendingInput);
Assert.Equal(string.Empty, restorePayload.ScreenText);
Assert.Equal(4L, restorePayload.Sequence);
}
[Fact]
[Trait("Track", "Mainline")]
public async Task Reattach_Returns_Restore_Payload_With_Pending_Input()
{
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.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);
var restoreFrame = await ReceiveTextAsync(socket, CancellationToken.None);
Assert.Contains("\"type\":\"restore\"", restoreFrame);
Assert.Contains("\"pendingInput\":\"dir\"", restoreFrame);
Assert.Contains("\"sequence\":2", restoreFrame);
}
[Fact]
[Trait("Track", "Mainline")]
public async Task Input_Acknowledgement_Deduplicates_Replayed_Client_Input()
{
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 firstSocket = await fixture.Server.CreateWebSocketClient().ConnectAsync(
new Uri($"ws://localhost/ws/terminal?sessionId={session.SessionId}"),
CancellationToken.None);
_ = await ReceiveTextAsync(firstSocket, CancellationToken.None);
_ = await ReceiveTextAsync(firstSocket, CancellationToken.None);
var inputMessage = JsonSerializer.Serialize(new { type = "input", input = "dir", inputId = "input-1" });
await firstSocket.SendAsync(Encoding.UTF8.GetBytes(inputMessage), WebSocketMessageType.Text, true, CancellationToken.None);
var firstAckFrame = await ReceiveTextAsync(firstSocket, CancellationToken.None);
Assert.Contains("\"type\":\"inputAck\"", firstAckFrame);
Assert.Contains("\"inputId\":\"input-1\"", firstAckFrame);
using WebSocket secondSocket = await fixture.Server.CreateWebSocketClient().ConnectAsync(
new Uri($"ws://localhost/ws/terminal?sessionId={session.SessionId}"),
CancellationToken.None);
_ = await ReceiveTextAsync(secondSocket, CancellationToken.None);
_ = await ReceiveTextAsync(secondSocket, CancellationToken.None);
await secondSocket.SendAsync(Encoding.UTF8.GetBytes(inputMessage), WebSocketMessageType.Text, true, CancellationToken.None);
var duplicateAckFrame = await ReceiveTextAsync(secondSocket, CancellationToken.None);
Assert.Contains("\"type\":\"inputAck\"", duplicateAckFrame);
Assert.Contains("\"inputId\":\"input-1\"", duplicateAckFrame);
Assert.Single(fixture.TerminalHost.Inputs.Where(item => item == ("input", session.SessionId, "dir")));
}
[Fact]
[Trait("Track", "Mainline")]
public async Task Attach_Includes_Screen_Snapshot_When_Backend_Screen_Protocol_Is_Enabled()
{
await using var fixture = new TerminalApiFixture(enableBackendScreenProtocol: true);
var registry = fixture.Services.GetRequiredService<SessionRegistry>();
fixture.TerminalHost.Registry = registry;
var session = registry.Create("Shell", DateTimeOffset.UtcNow);
await registry.RecordResizeAsync(session.SessionId, 40, 10, CancellationToken.None);
await registry.RecordOutputAsync(session.SessionId, "\u001b[2J\u001b[Hprompt> 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);
var restoreFrame = await ReceiveTextAsync(socket, CancellationToken.None);
var restorePayload = JsonSerializer.Deserialize<TerminalRestoreResponse>(
restoreFrame,
new JsonSerializerOptions(JsonSerializerDefaults.Web));
Assert.NotNull(restorePayload);
Assert.NotNull(restorePayload!.ScreenSnapshot);
Assert.Equal("primary", restorePayload.ScreenSnapshot!.ActiveBuffer);
Assert.Equal(2L, restorePayload.ScreenSnapshot.SourceSequence);
}
private static async Task<string> ReceiveTextAsync(WebSocket socket, CancellationToken cancellationToken)
{
var buffer = new byte[4096];
@ -331,12 +153,10 @@ public sealed class TerminalWebSocketHandlerTests
private readonly string _dataRoot = Path.Combine(Path.GetTempPath(), "TermRemoteCtl.Tests", Guid.NewGuid().ToString("N"));
private readonly TestTerminalSessionHost _terminalHost;
private readonly RecordingTerminalDiagnosticsSink _diagnostics = new();
private readonly bool _enableBackendScreenProtocol;
public TerminalApiFixture(bool enableBackendScreenProtocol = false)
public TerminalApiFixture()
{
_terminalHost = new TestTerminalSessionHost();
_enableBackendScreenProtocol = enableBackendScreenProtocol;
}
public TestTerminalSessionHost TerminalHost => _terminalHost;
@ -354,21 +174,11 @@ public sealed class TerminalWebSocketHandlerTests
["Agent:BindAddress"] = "127.0.0.1",
["Agent:HttpsPort"] = "9443",
["Agent:WebSocketFrameFlushMilliseconds"] = "33",
["Agent:RingBufferLineLimit"] = "4000",
["Agent:EnableBackendScreenProtocol"] = _enableBackendScreenProtocol.ToString()
["Agent:RingBufferLineLimit"] = "4000"
});
});
builder.ConfigureServices(services =>
{
services.PostConfigure<AgentOptions>(options =>
{
options.DataRoot = _dataRoot;
options.BindAddress = "127.0.0.1";
options.HttpsPort = 9443;
options.WebSocketFrameFlushMilliseconds = 33;
options.RingBufferLineLimit = 4000;
options.EnableBackendScreenProtocol = _enableBackendScreenProtocol;
});
services.RemoveAll<ISessionHost>();
services.AddSingleton(_terminalHost);
services.AddSingleton<ISessionHost>(sp => sp.GetRequiredService<TestTerminalSessionHost>());
@ -379,26 +189,9 @@ public sealed class TerminalWebSocketHandlerTests
public new async ValueTask DisposeAsync()
{
await base.DisposeAsync();
if (!Directory.Exists(_dataRoot))
if (Directory.Exists(_dataRoot))
{
return;
}
for (var attempt = 0; attempt < 5; attempt += 1)
{
try
{
Directory.Delete(_dataRoot, true);
return;
}
catch (IOException) when (attempt < 4)
{
await Task.Delay(50);
}
catch (UnauthorizedAccessException) when (attempt < 4)
{
await Task.Delay(50);
}
Directory.Delete(_dataRoot, true);
}
}
}
@ -485,34 +278,10 @@ public sealed class TerminalWebSocketHandlerTests
public void EmitOutput(string sessionId, string chunk)
{
var ioEvent = Registry?.RecordOutputAsync(sessionId, chunk, CancellationToken.None).GetAwaiter().GetResult();
_outputReceived?.Invoke(
this,
new TerminalOutputEventArgs(sessionId, chunk, ioEvent?.Sequence ?? 0));
Registry?.AppendOutputAsync(sessionId, chunk, CancellationToken.None).GetAwaiter().GetResult();
_outputReceived?.Invoke(this, new TerminalOutputEventArgs(sessionId, chunk));
}
}
private sealed record TerminalAttachResponse(string SessionId, string Type);
private sealed record TerminalRestoreResponse(
string SessionId,
long Sequence,
string ScreenText,
string PendingInput,
int? CursorRow,
int? CursorColumn,
TerminalScreenSnapshotResponse? ScreenSnapshot,
string Type);
private sealed record TerminalScreenSnapshotResponse(
long ScreenVersion,
long SourceSequence,
string ActiveBuffer);
private sealed record TerminalOutputResponse(
string SessionId,
long Sequence,
string Chunk,
string Type);
}

View File

@ -56,13 +56,7 @@ 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,55 +33,6 @@ 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"));
@ -113,18 +64,4 @@ 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 = await registry.GetHistoryAsync(session.SessionId, 2, CancellationToken.None);
var history = registry.GetHistory(session.SessionId, 2);
Assert.Equal(["two", "three"], history.Lines);
Assert.True(history.HasMoreAbove);
@ -83,179 +83,6 @@ public class SessionRegistryTests
Assert.Equal("prompt> dir\r\n", replay);
}
[Fact]
public async Task AppendIoEventAsync_Persists_Input_And_Output_In_Order()
{
using var harness = SessionRegistryHarness.Create();
var store = new SessionIoJournalStore(harness.DataRoot);
await store.AppendAsync(
new SessionIoEvent("session-1", 1, "input", "dir", DateTimeOffset.UtcNow),
CancellationToken.None);
await store.AppendAsync(
new SessionIoEvent("session-1", 2, "output", "dir\r\n", DateTimeOffset.UtcNow),
CancellationToken.None);
var lines = await File.ReadAllLinesAsync(
Path.Combine(harness.DataRoot, "sessions", "session-1.io.jsonl"));
Assert.Equal(2, lines.Length);
Assert.Contains("\"kind\":\"input\"", lines[0]);
Assert.Contains("\"kind\":\"output\"", lines[1]);
}
[Fact]
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);
await registry.RecordInputAsync(session.SessionId, "dir", CancellationToken.None);
var replay = registry.GetReplaySnapshot(session.SessionId);
Assert.Equal("dir", replay);
}
[Fact]
public async Task AppendOutputAsync_Clears_Pending_Input_After_Command_Is_Echoed()
{
using var harness = SessionRegistryHarness.Create();
var registry = harness.Registry;
var session = registry.Create("Shell", DateTimeOffset.UtcNow);
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);
Assert.Equal("prompt> dir\r\nnext> ", replay);
}
[Fact]
public async Task AppendOutputAsync_Clears_Pending_Input_When_Echo_Arrives_Across_Multiple_Chunks()
{
using var harness = SessionRegistryHarness.Create();
var registry = harness.Registry;
var session = registry.Create("Shell", DateTimeOffset.UtcNow);
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);
var replay = registry.GetReplaySnapshot(session.SessionId);
Assert.Equal("prompt> dir\r\nnext> ", replay);
}
[Fact]
public async Task GetRestoreSnapshot_Includes_Pending_Visible_Input()
{
using var harness = SessionRegistryHarness.Create();
var session = harness.Registry.Create("Shell", DateTimeOffset.UtcNow);
await harness.Registry.RecordInputAsync(session.SessionId, "git status", CancellationToken.None);
var snapshot = harness.Registry.GetRestoreSnapshot(session.SessionId);
Assert.Equal(string.Empty, snapshot.ScreenText);
Assert.Equal("git status", snapshot.PendingInput);
Assert.True(snapshot.Sequence > 0);
}
[Fact]
public async Task GetRestoreSnapshot_Does_Not_Duplicate_Acknowledged_Input()
{
using var harness = SessionRegistryHarness.Create();
var session = harness.Registry.Create("Shell", DateTimeOffset.UtcNow);
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);
Assert.Equal("PS> dir\r\nnext> ", snapshot.ScreenText);
Assert.Equal(string.Empty, snapshot.PendingInput);
}
[Fact]
public async Task Experiment_GetScreenSnapshot_Returns_Authoritative_Primary_Buffer_State()
{
using var harness = SessionRegistryHarness.Create(enableBackendScreenProtocol: true);
var session = harness.Registry.Create("Shell", DateTimeOffset.UtcNow);
await harness.Registry.RecordResizeAsync(session.SessionId, 40, 10, CancellationToken.None);
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()
{
@ -269,7 +96,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")));
await Assert.ThrowsAsync<KeyNotFoundException>(() => registry.GetHistoryAsync(session.SessionId, 10, CancellationToken.None));
Assert.Throws<KeyNotFoundException>(() => registry.GetHistory(session.SessionId, 10));
}
private sealed class SessionRegistryHarness : IDisposable
@ -284,9 +111,7 @@ public class SessionRegistryTests
public SessionRegistry Registry { get; }
public static SessionRegistryHarness Create(
int lineLimit = 4000,
bool enableBackendScreenProtocol = false)
public static SessionRegistryHarness Create(int lineLimit = 4000)
{
var dataRoot = Path.Combine(Path.GetTempPath(), "TermRemoteCtl.Tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dataRoot);
@ -295,11 +120,9 @@ public class SessionRegistryTests
{
DataRoot = dataRoot,
RingBufferLineLimit = lineLimit,
EnableBackendScreenProtocol = enableBackendScreenProtocol,
});
var historyStore = new SessionHistoryStore(dataRoot);
var journalStore = new SessionIoJournalStore(dataRoot);
var registry = new SessionRegistry(historyStore, journalStore, options);
var registry = new SessionRegistry(historyStore, options);
return new SessionRegistryHarness(dataRoot, registry);
}

View File

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

View File

@ -46,51 +46,16 @@ 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 = await harness.Registry.GetHistoryAsync(session.SessionId, 2, CancellationToken.None);
var history = harness.Registry.GetHistory(session.SessionId, 2);
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()
{
@ -110,53 +75,6 @@ public class PowerShellSessionHostTests
Assert.True(factory.CreatedSessions.Last().StartCount > 0);
}
[Fact]
public async Task Output_Failure_From_One_Session_Does_Not_Block_Other_Sessions()
{
var factory = new FakeConPtySessionFactory();
using var harness = HostHarness.Create(factory);
await using var host = harness.Host;
var blockedSession = harness.Registry.Create("blocked", DateTimeOffset.UtcNow);
var healthySession = harness.Registry.Create("healthy", DateTimeOffset.UtcNow);
await host.StartAsync(blockedSession.SessionId, CancellationToken.None);
await host.StartAsync(healthySession.SessionId, CancellationToken.None);
var blockedLogPath = Path.Combine(
harness.DataRoot,
"sessions",
$"{blockedSession.SessionId}.log");
Directory.CreateDirectory(Path.GetDirectoryName(blockedLogPath)!);
await using var blockingStream = new FileStream(
blockedLogPath,
FileMode.OpenOrCreate,
FileAccess.ReadWrite,
FileShare.None);
using var received = new ManualResetEventSlim(false);
var outputs = new List<TerminalOutputEventArgs>();
host.OutputReceived += (_, args) =>
{
lock (outputs)
{
outputs.Add(args);
if (args.SessionId == healthySession.SessionId)
{
received.Set();
}
}
};
factory.CreatedSessions[0].EmitOutput(blockedSession.SessionId, "blocked-output");
factory.CreatedSessions[1].EmitOutput(healthySession.SessionId, "healthy-output");
Assert.True(received.Wait(TimeSpan.FromSeconds(2)));
Assert.Contains(
outputs,
item => item.SessionId == healthySession.SessionId &&
item.Chunk == "healthy-output");
}
private sealed class HostHarness : IDisposable
{
private HostHarness(string dataRoot, SessionRegistry registry, PowerShellSessionHost host)
@ -182,10 +100,7 @@ public class PowerShellSessionHostTests
DataRoot = dataRoot,
RingBufferLineLimit = lineLimit,
});
var registry = new SessionRegistry(
new SessionHistoryStore(dataRoot),
new SessionIoJournalStore(dataRoot),
options);
var registry = new SessionRegistry(new SessionHistoryStore(dataRoot), options);
var host = new PowerShellSessionHost(factory, registry);
return new HostHarness(dataRoot, registry, host);
@ -195,22 +110,7 @@ public class PowerShellSessionHostTests
{
if (Directory.Exists(DataRoot))
{
for (var attempt = 0; attempt < 5; attempt += 1)
{
try
{
Directory.Delete(DataRoot, true);
break;
}
catch (IOException) when (attempt < 4)
{
Thread.Sleep(50);
}
catch (UnauthorizedAccessException) when (attempt < 4)
{
Thread.Sleep(50);
}
}
Directory.Delete(DataRoot, true);
}
}
}

View File

@ -1,135 +0,0 @@
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);
}
[Fact]
public void ApplyOutput_Osc_Window_Title_Does_Not_Leak_Into_Visible_Screen()
{
var engine = new TerminalScreenEngine(rows: 4, cols: 80);
engine.ApplyOutput("\u001b]0;C:\\WINDOWS\\system32\\WindowsPowerShell\\v1.0\\powershell.exe\u0007", sourceSequence: 1);
engine.ApplyOutput("PS D:\\App\\python\\MLplatform> pwd", sourceSequence: 2);
var snapshot = engine.CreateSnapshot("session-1");
Assert.DoesNotContain(
snapshot.PrimaryBuffer.Viewport,
line => line.Text.Contains("]0;", StringComparison.Ordinal));
Assert.DoesNotContain(
snapshot.PrimaryBuffer.Viewport,
line => line.Text.Contains("powershell.exe", StringComparison.OrdinalIgnoreCase));
Assert.StartsWith(
"PS D:\\App\\python\\MLplatform> pwd",
snapshot.PrimaryBuffer.Viewport[0].Text,
StringComparison.Ordinal);
Assert.Equal(0, snapshot.CursorRow);
Assert.Equal(32, snapshot.CursorColumn);
}
[Fact]
public void ApplyOutput_Osc_Window_Title_With_String_Terminator_Does_Not_Leak_Into_Visible_Screen()
{
var engine = new TerminalScreenEngine(rows: 4, cols: 80);
engine.ApplyOutput("\u001b]0;PowerShell Title\u001b\\", sourceSequence: 1);
engine.ApplyOutput("prompt> ", sourceSequence: 2);
var snapshot = engine.CreateSnapshot("session-1");
Assert.DoesNotContain(
snapshot.PrimaryBuffer.Viewport,
line => line.Text.Contains("PowerShell Title", StringComparison.Ordinal));
Assert.StartsWith("prompt> ", snapshot.PrimaryBuffer.Viewport[0].Text, StringComparison.Ordinal);
Assert.Equal(0, snapshot.CursorRow);
Assert.Equal(8, snapshot.CursorColumn);
}
}

View File

@ -1,63 +0,0 @@
# Local Terminal Snapshot 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:** Add same-device terminal snapshot recovery so iOS can reopen an existing session from local state first, then reconcile with agent restore data, while cleaning up local snapshots when sessions or projects are deleted.
**Architecture:** The mobile app will persist per-session terminal snapshots in a UTF-8 JSON file. `TerminalPage` will load and save snapshots, while repository and page deletion flows will remove them so local state stays aligned with session lifecycle.
**Tech Stack:** Flutter, Dart, Riverpod, xterm, file-based JSON storage, Flutter widget/unit tests
---
### Task 1: Add Terminal Snapshot Storage
**Files:**
- Create: `apps/mobile_app/lib/features/terminal/terminal_snapshot.dart`
- Create: `apps/mobile_app/lib/features/terminal/terminal_snapshot_storage.dart`
- Modify: `apps/mobile_app/lib/core/network/agent_connection_providers.dart`
- Test: `apps/mobile_app/test/features/terminal/terminal_snapshot_storage_test.dart`
- [ ] Write failing storage tests for save, read, delete, delete-by-project, and prune.
- [ ] Run the targeted storage test and confirm it fails for missing classes.
- [ ] Implement the snapshot model and file-backed storage.
- [ ] Wire the storage into Riverpod.
- [ ] Run the targeted storage test again and confirm it passes.
### Task 2: Add Snapshot-Aware Terminal Restore
**Files:**
- Create: `apps/mobile_app/lib/features/terminal/terminal_restore_decision.dart`
- Modify: `apps/mobile_app/lib/features/terminal/terminal_page.dart`
- Test: `apps/mobile_app/test/features/terminal/terminal_restore_decision_test.dart`
- Test: `apps/mobile_app/test/widget_test.dart`
- [ ] Write failing restore decision tests for keep-local, replace-with-restore, and empty-state behavior.
- [ ] Write failing widget coverage for loading a local snapshot on reopen and refusing to replace richer local content with a shorter restore payload.
- [ ] Run the targeted terminal tests and confirm they fail.
- [ ] Implement snapshot load/save hooks and restore merge logic in `TerminalPage`.
- [ ] Run the targeted terminal tests again and confirm they pass.
### Task 3: Clean Up Snapshots During Deletion Flows
**Files:**
- Modify: `apps/mobile_app/lib/features/sessions/session_repository.dart`
- Modify: `apps/mobile_app/lib/features/projects/project_list_page.dart`
- Modify: `apps/mobile_app/lib/features/projects/project_detail_page.dart`
- Modify: `apps/mobile_app/test/features/sessions/session_repository_test.dart`
- Modify: `apps/mobile_app/test/widget_test.dart`
- [ ] Write failing tests for session deletion removing local snapshots.
- [ ] Write failing widget coverage for project deletion removing local snapshots for that project.
- [ ] Run the targeted deletion tests and confirm they fail.
- [ ] Implement repository cleanup and project-level cleanup calls.
- [ ] Run the targeted deletion tests again and confirm they pass.
### Task 4: Verify End-to-End Behavior
**Files:**
- Modify as needed: `apps/mobile_app/test/widget_test.dart`
- [ ] Run the focused Flutter test targets that cover snapshots, terminal restore, repository cleanup, and deletion flows.
- [ ] Run the broader mobile app test suite if the focused tests are green.
- [ ] Review failures and make minimal fixes only if verification exposes real regressions.

View File

@ -1,536 +0,0 @@
# Terminal Reconnect 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:** Add backend-owned reconnect restore state and a raw terminal I/O journal so ordinary shell sessions reconnect with visible input and output intact.
**Architecture:** Keep the existing helper-backed ConPTY runtime and websocket transport, but separate session runtime, raw journal, and restore state. Move reconnect truth to the backend by emitting an explicit `restore` payload during websocket attach, while the Flutter client applies that payload as authoritative terminal state instead of inferring from output replay alone.
**Tech Stack:** ASP.NET Core, WebSocket, helper-backed ConPTY, Flutter, Riverpod, xterm, JSON lines storage, xUnit, Flutter widget tests.
---
### Task 1: Add Raw Terminal Journal Models And Storage
**Files:**
- Create: `apps/windows_agent/src/TermRemoteCtl.Agent/History/SessionIoEvent.cs`
- Create: `apps/windows_agent/src/TermRemoteCtl.Agent/History/SessionIoJournalStore.cs`
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptions.cs`
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Program.cs`
- Test: `apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs`
- [ ] **Step 1: Write the failing journal storage tests**
```csharp
[Fact]
public async Task AppendIoEventAsync_Persists_Input_And_Output_In_Order()
{
using var harness = SessionRegistryHarness.Create();
var store = new SessionIoJournalStore(harness.DataRoot);
await store.AppendAsync(new SessionIoEvent("session-1", 1, "input", "dir", DateTimeOffset.UtcNow), CancellationToken.None);
await store.AppendAsync(new SessionIoEvent("session-1", 2, "output", "dir\r\n", DateTimeOffset.UtcNow), CancellationToken.None);
var lines = await File.ReadAllLinesAsync(Path.Combine(harness.DataRoot, "sessions", "session-1.io.jsonl"));
Assert.Equal(2, lines.Length);
Assert.Contains("\"kind\":\"input\"", lines[0]);
Assert.Contains("\"kind\":\"output\"", lines[1]);
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/TermRemoteCtl.Agent.Tests.csproj --filter AppendIoEventAsync_Persists_Input_And_Output_In_Order`
Expected: FAIL with missing `SessionIoJournalStore` or `SessionIoEvent`
- [ ] **Step 3: Add the journal event record**
```csharp
namespace TermRemoteCtl.Agent.History;
public sealed record SessionIoEvent(
string SessionId,
long Sequence,
string Kind,
string Payload,
DateTimeOffset TimestampUtc);
```
- [ ] **Step 4: Add the journal file store**
```csharp
public sealed class SessionIoJournalStore
{
private readonly string _sessionRoot;
public SessionIoJournalStore(string rootPath)
{
_sessionRoot = Path.Combine(rootPath, "sessions");
Directory.CreateDirectory(_sessionRoot);
}
public async Task AppendAsync(SessionIoEvent ioEvent, CancellationToken cancellationToken)
{
var filePath = Path.Combine(_sessionRoot, $"{ioEvent.SessionId}.io.jsonl");
var line = JsonSerializer.Serialize(ioEvent) + Environment.NewLine;
await File.AppendAllTextAsync(filePath, line, new UTF8Encoding(false), cancellationToken).ConfigureAwait(false);
}
}
```
- [ ] **Step 5: Add configuration and service registration**
```csharp
public sealed class AgentOptions
{
public string DataRoot { get; set; } = string.Empty;
public int RingBufferLineLimit { get; set; } = 4000;
public int SessionJournalRetentionDays { get; set; } = 7;
}
```
```csharp
builder.Services.AddSingleton<SessionIoJournalStore>(sp =>
{
var options = sp.GetRequiredService<IOptions<AgentOptions>>().Value;
return new SessionIoJournalStore(options.DataRoot);
});
```
- [ ] **Step 6: Run the backend unit test to verify it passes**
Run: `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/TermRemoteCtl.Agent.Tests.csproj --filter SessionRegistryTests`
Expected: PASS
- [ ] **Step 7: Commit**
```bash
git add apps/windows_agent/src/TermRemoteCtl.Agent/History/SessionIoEvent.cs apps/windows_agent/src/TermRemoteCtl.Agent/History/SessionIoJournalStore.cs apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptions.cs apps/windows_agent/src/TermRemoteCtl.Agent/Program.cs apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs
git commit -m "feat: add terminal session io journal"
```
### Task 2: Extend Session Registry With Restore Snapshot State
**Files:**
- Create: `apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRestoreSnapshot.cs`
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs`
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/History/PendingInputEchoTracker.cs`
- Test: `apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs`
- [ ] **Step 1: Write failing restore snapshot tests**
```csharp
[Fact]
public void 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");
var snapshot = harness.Registry.GetRestoreSnapshot(session.SessionId);
Assert.Equal(string.Empty, snapshot.ScreenText);
Assert.Equal("git status", snapshot.PendingInput);
Assert.True(snapshot.Sequence > 0);
}
```
```csharp
[Fact]
public async Task GetRestoreSnapshot_Does_Not_Duplicate_Acknowledged_Input()
{
using var harness = SessionRegistryHarness.Create();
var session = harness.Registry.Create("Shell", DateTimeOffset.UtcNow);
harness.Registry.RecordInputEcho(session.SessionId, "dir\r");
await harness.Registry.AppendOutputAsync(session.SessionId, "PS> dir\r\nnext> ", CancellationToken.None);
var snapshot = harness.Registry.GetRestoreSnapshot(session.SessionId);
Assert.Equal("PS> dir\r\nnext> ", snapshot.ScreenText);
Assert.Equal(string.Empty, snapshot.PendingInput);
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/TermRemoteCtl.Agent.Tests.csproj --filter "GetRestoreSnapshot_"`
Expected: FAIL with missing `GetRestoreSnapshot`
- [ ] **Step 3: Add the restore snapshot model**
```csharp
namespace TermRemoteCtl.Agent.Sessions;
public sealed record SessionRestoreSnapshot(
string SessionId,
long Sequence,
string ScreenText,
string PendingInput,
int? CursorRow,
int? CursorColumn);
```
- [ ] **Step 4: Extend `SessionRegistry` to track restore sequence**
```csharp
private readonly ConcurrentDictionary<string, long> _sequenceBySession = new();
public long NextSequence(string sessionId)
{
return _sequenceBySession.AddOrUpdate(sessionId, 1, (_, current) => current + 1);
}
```
- [ ] **Step 5: Add restore snapshot retrieval**
```csharp
public SessionRestoreSnapshot GetRestoreSnapshot(string sessionId)
{
var replay = _replayBySession.GetOrAdd(sessionId, _ => new TerminalReplayBuffer(ReplayCharacterLimit));
var pending = _pendingInputEchoBySession.GetOrAdd(sessionId, _ => new PendingInputEchoTracker());
var sequence = _sequenceBySession.GetOrAdd(sessionId, 1);
return new SessionRestoreSnapshot(
sessionId,
sequence,
replay.GetSnapshot(),
pending.GetVisibleSuffix(),
null,
null);
}
```
- [ ] **Step 6: Update input/output mutation points**
```csharp
public void RecordInputEcho(string sessionId, string input)
{
var tracker = _pendingInputEchoBySession.GetOrAdd(sessionId, _ => new PendingInputEchoTracker());
tracker.Record(input);
NextSequence(sessionId);
}
```
```csharp
public async Task AppendOutputAsync(string sessionId, string chunk, CancellationToken cancellationToken)
{
// existing history and replay mutations
_pendingInputEchoBySession.GetOrAdd(sessionId, _ => new PendingInputEchoTracker()).ObserveOutput(chunk);
NextSequence(sessionId);
await _historyStore.AppendAsync(sessionId, chunk, cancellationToken).ConfigureAwait(false);
}
```
- [ ] **Step 7: Run the backend unit tests to verify they pass**
Run: `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/TermRemoteCtl.Agent.Tests.csproj --filter SessionRegistryTests`
Expected: PASS
- [ ] **Step 8: Commit**
```bash
git add apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRestoreSnapshot.cs apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs apps/windows_agent/src/TermRemoteCtl.Agent/History/PendingInputEchoTracker.cs apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs
git commit -m "feat: add session restore snapshot state"
```
### Task 3: Emit Restore Payload And Journal Events On Websocket Attach
**Files:**
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs`
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/PowerShellSessionHost.cs`
- Test: `apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs`
- [ ] **Step 1: Write failing websocket restore tests**
```csharp
[Fact]
public async Task Reattach_Returns_Restore_Payload_With_Pending_Input()
{
await using var fixture = new TerminalApiFixture();
var registry = fixture.Services.GetRequiredService<SessionRegistry>();
var session = registry.Create("Shell", DateTimeOffset.UtcNow);
registry.RecordInputEcho(session.SessionId, "dir");
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 restoreFrame = await ReceiveTextAsync(socket, CancellationToken.None);
Assert.Contains("\"type\":\"restore\"", restoreFrame);
Assert.Contains("\"pendingInput\":\"dir\"", restoreFrame);
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/TermRemoteCtl.Agent.IntegrationTests.csproj --filter Reattach_Returns_Restore_Payload_With_Pending_Input`
Expected: FAIL because only replay text is sent
- [ ] **Step 3: Add the restore response contract**
```csharp
private sealed record TerminalRestoreResponse(
string SessionId,
long Sequence,
string ScreenText,
string PendingInput,
int? CursorRow,
int? CursorColumn,
string Type = "restore");
```
- [ ] **Step 4: Send restore payload after attach acknowledgement**
```csharp
var restore = registry.GetRestoreSnapshot(sessionId);
await SendJsonAsync(socket, new TerminalAttachResponse(sessionId), sendGate, context.RequestAborted).ConfigureAwait(false);
await SendJsonAsync(
socket,
new TerminalRestoreResponse(
restore.SessionId,
restore.Sequence,
restore.ScreenText,
restore.PendingInput,
restore.CursorRow,
restore.CursorColumn),
sendGate,
context.RequestAborted).ConfigureAwait(false);
```
- [ ] **Step 5: Journal websocket lifecycle and PTY traffic**
```csharp
await journalStore.AppendAsync(new SessionIoEvent(sessionId, registry.NextSequence(sessionId), "attach", string.Empty, DateTimeOffset.UtcNow), context.RequestAborted);
```
```csharp
registry.RecordInputEcho(sessionId, message.Input);
await journalStore.AppendAsync(new SessionIoEvent(sessionId, registry.NextSequence(sessionId), "input", message.Input, DateTimeOffset.UtcNow), cancellationToken);
await host.WriteInputAsync(sessionId, message.Input, cancellationToken).ConfigureAwait(false);
```
```csharp
_ = _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);
```
- [ ] **Step 6: Run integration tests to verify they pass**
Run: `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/TermRemoteCtl.Agent.IntegrationTests.csproj --filter TerminalWebSocketHandlerTests`
Expected: PASS
- [ ] **Step 7: Commit**
```bash
git add apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/PowerShellSessionHost.cs apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs
git commit -m "feat: send terminal restore payload on attach"
```
### Task 4: Teach Flutter To Restore From Backend Snapshot
**Files:**
- Create: `apps/mobile_app/lib/features/terminal/terminal_restore_payload.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`
- Test: `apps/mobile_app/test/features/terminal/terminal_socket_session_test.dart`
- Test: `apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart`
- Test: `apps/mobile_app/test/widget_test.dart`
- [ ] **Step 1: Write failing Flutter restore tests**
```dart
testWidgets('terminal reconnect applies restore payload before live frames', (tester) async {
final transportFactory = _QueuedTerminalSocketTransportFactory(
connectionStartupFrames: [
const [
_StartupFrame('{"type":"attached","sessionId":"session-1"}'),
_StartupFrame('{"type":"restore","sessionId":"session-1","sequence":4,"screenText":"PS> gi","pendingInput":"t status"}'),
],
],
);
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 status'));
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `C:\tools\flutter\bin\flutter.bat test test/features/terminal/terminal_socket_session_test.dart test/widget_test.dart`
Expected: FAIL because restore frames are not parsed separately
- [ ] **Step 3: Add restore payload model**
```dart
class TerminalRestorePayload {
const TerminalRestorePayload({
required this.sessionId,
required this.sequence,
required this.screenText,
required this.pendingInput,
});
factory TerminalRestorePayload.fromJson(Map<String, dynamic> json) {
return TerminalRestorePayload(
sessionId: json['sessionId'] as String,
sequence: json['sequence'] as int,
screenText: (json['screenText'] as String?) ?? '',
pendingInput: (json['pendingInput'] as String?) ?? '',
);
}
}
```
- [ ] **Step 4: Parse restore frames separately from live output**
```dart
Future<void> connect({
required void Function(String frame) onFrame,
required void Function(TerminalRestorePayload restore) onRestore,
void Function()? onDisconnected,
})
```
```dart
if (decoded is Map && decoded['type'] == 'restore') {
onRestore(TerminalRestorePayload.fromJson(Map<String, dynamic>.from(decoded)));
return;
}
```
- [ ] **Step 5: Apply restore payload as authoritative state in `TerminalPage`**
```dart
void _handleRestorePayload(TerminalRestorePayload restore) {
_resetTerminalForReplay();
final combined = restore.screenText + restore.pendingInput;
if (combined.isNotEmpty) {
terminal.write(combined);
}
}
```
- [ ] **Step 6: Run Flutter tests to verify they pass**
Run: `C:\tools\flutter\bin\flutter.bat test test/features/terminal/terminal_page_input_test.dart test/features/terminal/terminal_socket_session_test.dart test/features/terminal/terminal_session_coordinator_test.dart test/widget_test.dart`
Expected: PASS
- [ ] **Step 7: Commit**
```bash
git add apps/mobile_app/lib/features/terminal/terminal_restore_payload.dart apps/mobile_app/lib/features/terminal/terminal_socket_session.dart apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart apps/mobile_app/lib/features/terminal/terminal_page.dart apps/mobile_app/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
git commit -m "feat: restore terminal state from backend snapshot"
```
### Task 5: Make Restore Snapshot The Primary Reconnect Path
**Files:**
- Modify: `apps/mobile_app/lib/features/terminal/terminal_page.dart`
- Modify: `apps/mobile_app/test/widget_test.dart`
- Modify: `docs/testing/manual-smoke-checklist.md`
- [ ] **Step 1: Write failing test that proves reconnect no longer depends on output-only replay**
```dart
testWidgets('terminal reconnect restores pending input without history seed fallback', (tester) async {
final transportFactory = _QueuedTerminalSocketTransportFactory(
connectionStartupFrames: [
const [
_StartupFrame('{"type":"attached","sessionId":"session-1"}'),
_StartupFrame('{"type":"restore","sessionId":"session-1","sequence":7,"screenText":"PS> gi","pendingInput":"t status"}'),
],
],
);
await _pumpTerminalPage(
tester,
session: _session('session-1', 'codex-main'),
apiClient: _FakeAgentApiClient(lines: const <String>[]),
socketFactory: TerminalSocketSessionFactory(transportFactory: transportFactory.create),
);
final terminal = tester.widget<TerminalView>(find.byType(TerminalView)).terminal;
expect(terminal.buffer.getText(), contains('PS> git status'));
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `C:\tools\flutter\bin\flutter.bat test test/widget_test.dart`
Expected: FAIL until restore payload becomes the primary reconnect source
- [ ] **Step 3: Simplify reconnect restore logic**
```dart
if (connectionState == TerminalConnectionState.reconnecting) {
_resetTerminalForReplay();
_historySeeded = false;
_receivedSocketFrame = false;
}
```
```dart
if (_receivedRestorePayload) {
_cancelHistorySeedTimer();
return;
}
```
- [ ] **Step 4: Update the manual smoke checklist**
```markdown
10. Type a partial command, background the app, reopen it, and confirm the typed command is still visible.
11. Execute a command, reconnect during output, and confirm the command is not duplicated after restore.
```
- [ ] **Step 5: Run final verification**
Run: `C:\tools\flutter\bin\flutter.bat test test/widget_test.dart test/features/terminal/terminal_page_input_test.dart test/features/terminal/terminal_socket_session_test.dart test/features/terminal/terminal_session_coordinator_test.dart`
Expected: PASS
Run: `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/TermRemoteCtl.Agent.Tests.csproj`
Expected: PASS
Run: `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/TermRemoteCtl.Agent.IntegrationTests.csproj --filter "TerminalWebSocketHandlerTests|SessionHistoryApiTests"`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add apps/mobile_app/lib/features/terminal/terminal_page.dart apps/mobile_app/test/widget_test.dart docs/testing/manual-smoke-checklist.md
git commit -m "refactor: make terminal restore snapshot authoritative"
```
## Self-Review
- Spec coverage:
- raw journal is covered by Tasks 1 and 3
- restore snapshot state is covered by Task 2
- websocket attach restore protocol is covered by Task 3
- Flutter restore consumption is covered by Tasks 4 and 5
- Placeholder scan:
- each task contains concrete files, code, commands, and expected results
- Type consistency:
- `SessionIoEvent`, `SessionRestoreSnapshot`, `TerminalRestorePayload`, `GetRestoreSnapshot`, and `RecordInputEcho` are used consistently across the plan
## Execution Handoff
Plan complete and saved to `docs/superpowers/plans/2026-04-06-terminal-reconnect-recovery.md`. Two execution options:
**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration
**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints
**Which approach?**

View File

@ -1,96 +0,0 @@
# 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

@ -1,278 +0,0 @@
# 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

@ -1,216 +0,0 @@
# Terminal Authoritative Timeline 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:** Rebuild recent terminal output from the backend journal after reconnect or reopen so the frontend no longer treats local snapshots or replay tails as the final recovery truth.
**Architecture:** Keep local snapshots as provisional first paint only. After websocket attach restore arrives, fetch a fresh recent journal window from the agent and use it as the authoritative recent timeline for the terminal buffer. For reconnects within an existing page, also keep sequence-gap recovery so missing output between the last seen sequence and the restore sequence is filled without losing continuity.
**Tech Stack:** Flutter, Dart, xterm, existing journal API, websocket restore/output flow
---
### Task 1: Define An Authoritative Recent History Seed
**Files:**
- Modify: `apps/mobile_app/lib/features/terminal/history_window.dart`
- Modify: `apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart`
- Test: `apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart`
- [ ] **Step 1: Write the failing test for raw recent output seed construction**
```dart
test('history window exposes a raw output seed for authoritative rebuild', () async {
final coordinator = TerminalSessionCoordinator(...);
await coordinator.start();
expect(controller.historyWindow.outputSeedText, 'one\r\ntwo\r\n');
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `C:\tools\flutter\bin\flutter.bat test test/features/terminal/terminal_session_coordinator_test.dart`
Expected: FAIL because `HistoryWindow` has no `outputSeedText`
- [ ] **Step 3: Add the output seed to `HistoryWindow`**
```dart
class HistoryWindow {
const HistoryWindow({
required this.lines,
required this.hasMoreAbove,
required this.outputSeedText,
this.oldestSequence,
this.newestSequence,
this.currentSequence,
});
final String outputSeedText;
final int? currentSequence;
}
```
- [ ] **Step 4: Build the output seed from raw journal output payloads**
```dart
String _buildOutputSeed(List<_JournalItem> items) {
final buffer = StringBuffer();
for (final item in items) {
if (item.kind == 'output') {
buffer.write(item.payload);
}
}
return buffer.toString();
}
```
- [ ] **Step 5: Run test to verify it passes**
Run: `C:\tools\flutter\bin\flutter.bat test test/features/terminal/terminal_session_coordinator_test.dart`
Expected: PASS
### Task 2: Reconnect Gap Recovery Uses The Existing Sequence Anchor
**Files:**
- Modify: `apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart`
- Test: `apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart`
- [ ] **Step 1: Write the failing reconnect gap recovery test**
```dart
test('restore sequence fills reconnect gap before live output continues', () async {
...
await coordinator.start();
sessionFactory.createdSessions.single.emitRestore(
const TerminalRestorePayload(
sessionId: 'abc',
sequence: 7,
screenText: 'tail',
pendingInput: '',
),
);
expect(apiClient.requestedJournalAfterSequences, [4]);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `C:\tools\flutter\bin\flutter.bat test test/features/terminal/terminal_session_coordinator_test.dart`
Expected: FAIL because reconnect restore does not fill the pre-restore gap
- [ ] **Step 3: Capture reconnect baseline before opening the next socket**
```dart
if (isReconnect && _lastReceivedSequence != null) {
_recoveryGapBaselineSequence = _lastReceivedSequence;
}
```
- [ ] **Step 4: Recover missing output after restore without rewinding sequence state**
```dart
if (baseline != null && baseline < restore.sequence) {
await _recoverHistoricalOutput(
afterSequence: baseline,
stopBeforeSequence: restore.sequence,
);
}
```
- [ ] **Step 5: Run test to verify it passes**
Run: `C:\tools\flutter\bin\flutter.bat test test/features/terminal/terminal_session_coordinator_test.dart`
Expected: PASS
### Task 3: Replace Provisional Snapshot Content With An Authoritative Recent Journal Window
**Files:**
- Modify: `apps/mobile_app/lib/features/terminal/terminal_page.dart`
- Modify: `apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart`
- Test: `apps/mobile_app/test/widget_test.dart`
- [ ] **Step 1: Write the failing reopen recovery widget test**
```dart
testWidgets('re-entering a session replaces provisional snapshot content with authoritative recent journal output', (tester) async {
...
expect(terminal.buffer.getText(), contains('middle-output'));
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `C:\tools\flutter\bin\flutter.bat test test/widget_test.dart`
Expected: FAIL because reopen still leaves only snapshot/replay tail content
- [ ] **Step 3: Mark local snapshot rendering as provisional**
```dart
bool _hasProvisionalSnapshot = false;
Future<void> _restoreLocalSnapshot() async {
...
terminal.write(snapshot.bufferText);
_hasProvisionalSnapshot = true;
}
```
- [ ] **Step 4: After restore, fetch a fresh recent journal window and replace provisional content**
```dart
Future<void> _rebuildRecentTimelineFromJournal() async {
final history = await _coordinator.loadRecentHistoryWindow();
if (!mounted || history == null || history.outputSeedText.isEmpty) {
return;
}
_resetTerminalForReplay();
terminal.write(history.outputSeedText);
_hasProvisionalSnapshot = false;
}
```
- [ ] **Step 5: Keep the replacement safe by skipping it when newer live output already arrived**
```dart
if (history.currentSequence != null &&
_coordinator.lastReceivedSequence != null &&
_coordinator.lastReceivedSequence! > history.currentSequence!) {
return;
}
```
- [ ] **Step 6: Run test to verify it passes**
Run: `C:\tools\flutter\bin\flutter.bat test test/widget_test.dart`
Expected: PASS
### Task 4: Focused Verification
**Files:**
- Test: `apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart`
- Test: `apps/mobile_app/test/widget_test.dart`
- [ ] **Step 1: Run coordinator recovery tests**
Run: `C:\tools\flutter\bin\flutter.bat test test/features/terminal/terminal_session_coordinator_test.dart`
Expected: PASS
- [ ] **Step 2: Run widget recovery tests**
Run: `C:\tools\flutter\bin\flutter.bat test test/widget_test.dart`
Expected: PASS
- [ ] **Step 3: Commit**
```bash
git add apps/mobile_app/lib/features/terminal/history_window.dart \
apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart \
apps/mobile_app/lib/features/terminal/terminal_page.dart \
apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart \
apps/mobile_app/test/widget_test.dart \
docs/superpowers/plans/2026-04-10-terminal-authoritative-timeline-recovery.md
git commit -m "Recover terminal timeline from authoritative journal history"
```

View File

@ -1,99 +0,0 @@
# Terminal Truth-Source Reset Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Reset the terminal architecture so the mainline uses a single rendering truth source, while isolating backend-authoritative screen recovery into an explicit experiment path.
**Architecture:** Keep backend journal, restore, and output as the durable data path; keep frontend `xterm` as the visible rendering path; remove backend screen protocol from the default runtime path; quarantine the backend screen engine and related protocol into an experiment track.
**Tech Stack:** ASP.NET Core minimal APIs, C#, Flutter, xterm, websocket_channel, Flutter widget tests, .NET unit tests, Markdown specs and plans.
---
### Task 1: Lock The Mainline Runtime Boundary With Failing Frontend Tests
**Files:**
- Modify: `apps/mobile_app/test/widget_test.dart`
- Modify: `apps/mobile_app/test/features/terminal/terminal_page_input_test.dart`
- Test: `apps/mobile_app/test/widget_test.dart`
- Test: `apps/mobile_app/test/features/terminal/terminal_page_input_test.dart`
- [ ] **Step 1: Add a failing widget test that proves `TerminalPage` ignores backend `screen_snapshot` by default and still renders legacy `restore` content**
- [ ] **Step 2: Add or update a widget test that proves backend screen protocol only affects rendering when explicitly enabled**
- [ ] **Step 3: Run `C:\\tools\\flutter\\bin\\flutter.bat test test/widget_test.dart --plain-name "screen snapshot"` from `apps/mobile_app` and confirm the new expectations fail before the implementation change**
- [ ] **Step 4: Run `C:\\tools\\flutter\\bin\\flutter.bat test test/features/terminal/terminal_page_input_test.dart` to verify the terminal input baseline remains intact before refactoring**
### Task 2: Remove Backend Screen Protocol From The Default Mobile Path
**Files:**
- Modify: `apps/mobile_app/lib/features/terminal/terminal_page.dart`
- Modify: `apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart`
- Modify: `apps/mobile_app/lib/features/terminal/terminal_socket_session.dart`
- Modify: `apps/mobile_app/test/widget_test.dart`
- [ ] **Step 1: Add an explicit runtime flag on `TerminalPage` and `TerminalSessionCoordinator` so backend screen protocol is disabled on the default app path**
- [ ] **Step 2: Ensure the default runtime path subscribes only to `restore` and `output` as rendering truth**
- [ ] **Step 3: Keep screen protocol callbacks available only for explicit experiment wiring**
- [ ] **Step 4: Re-run `C:\\tools\\flutter\\bin\\flutter.bat test test/widget_test.dart --plain-name "screen snapshot"` and confirm the default-path tests pass**
- [ ] **Step 5: Re-run `C:\\tools\\flutter\\bin\\flutter.bat test test/features/terminal/terminal_page_input_test.dart` and confirm input behavior still passes**
### Task 3: Strip Mainline Rendering Dependency On Screen Models
**Files:**
- Modify: `apps/mobile_app/lib/features/terminal/terminal_page.dart`
- Modify: `apps/mobile_app/lib/features/terminal/terminal_screen_snapshot.dart`
- Modify: `apps/mobile_app/lib/features/terminal/terminal_screen_state.dart`
- Modify: `apps/mobile_app/test/features/terminal/terminal_screen_state_test.dart`
- [ ] **Step 1: Remove any mainline rendering assumptions that convert backend screen models into replay text for `xterm`**
- [ ] **Step 2: Keep screen-model helpers only if they are clearly marked as experiment-only and are no longer part of the default runtime path**
- [ ] **Step 3: Drop or relocate tests that currently validate screen-model replay as mainline terminal behavior**
- [ ] **Step 4: Run the targeted Flutter tests that still belong to the mainline and confirm no mainline test depends on backend screen snapshot replay**
### Task 4: Simplify Backend Mainline Websocket Contract
**Files:**
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs`
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs`
- Modify: `apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs`
- [ ] **Step 1: Add failing backend tests that define the stable mainline websocket contract as `attached + restore + output`**
- [ ] **Step 2: Remove default runtime emission of `screen_snapshot`, `screen_patch`, and `screen_sync` from the mainline websocket flow, or gate them behind an explicit experiment switch**
- [ ] **Step 3: Keep sequence-aware `restore` and `output` semantics intact**
- [ ] **Step 4: Run `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/TermRemoteCtl.Agent.IntegrationTests.csproj --filter "TerminalWebSocketHandlerTests"` and verify the stable contract passes**
### Task 5: Isolate Backend Screen Engine Into An Experiment Track
**Files:**
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/Screen/TerminalScreenEngine.cs`
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/Screen/TerminalScreenPatch.cs`
- Modify: `apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/TerminalScreenEngineTests.cs`
- Modify: `docs/superpowers/specs/2026-04-10-terminal-truth-source-reset-design.md`
- [ ] **Step 1: Stop treating `TerminalScreenEngine` as a production-ready mainline dependency**
- [ ] **Step 2: Rename, relocate, or clearly annotate screen-engine code and tests as experiment-only**
- [ ] **Step 3: Keep the experiment test suite runnable in isolation, but do not let it define default product behavior**
- [ ] **Step 4: Run `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/TermRemoteCtl.Agent.Tests.csproj --filter "TerminalScreenEngineTests"` and verify the experiment suite remains green in isolation**
### Task 6: Clean Up Docs And Developer Guidance
**Files:**
- Modify: `docs/superpowers/specs/2026-04-10-terminal-truth-source-reset-design.md`
- Modify: `docs/testing/manual-smoke-checklist.md`
- Modify: `README.md`
- [ ] **Step 1: Document that the stable product path is `restore/output + xterm`, not backend screen snapshots**
- [ ] **Step 2: Document that backend-authoritative screen recovery is an experiment branch concern**
- [ ] **Step 3: Update manual smoke checks to focus the mainline on stable shell input, reconnect, resize, and repeated command execution**
- [ ] **Step 4: Add a short note in `README.md` or a developer-facing doc explaining the single-truth-source rule**
### Task 7: Final Verification
**Files:**
- Verify only
- [ ] **Step 1: Run `C:\\tools\\flutter\\bin\\flutter.bat test test/widget_test.dart test/features/terminal/terminal_page_input_test.dart` from `apps/mobile_app`**
- [ ] **Step 2: Run `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/TermRemoteCtl.Agent.IntegrationTests.csproj --filter "TerminalWebSocketHandlerTests"`**
- [ ] **Step 3: Run `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/TermRemoteCtl.Agent.Tests.csproj --filter "TerminalScreenEngineTests"` only as experiment verification, not as a mainline gate**
- [ ] **Step 4: Perform a manual smoke check with `pwd -> ls -> pwd`, reconnect once, resize once, and verify the prompt remains usable**
- [ ] **Step 5: Summarize the final boundary in release notes or task output: mainline is stable-shell-first, experiment branch is backend-screen-first**

View File

@ -1,95 +0,0 @@
# Local Terminal Snapshot Recovery Design
## Goal
Improve same-device iPhone terminal recovery so returning to an existing session feels continuous even when the websocket reconnects after app backgrounding or process restart.
## Product Decision
For this phase, the same device is the primary recovery target. The mobile app should prefer its own last-known terminal view for immediate restore, while the Windows agent remains the source of truth for live continuation and correction.
## Current Problem
- The terminal page clears its buffer during reconnect.
- The agent restore payload is based on a bounded replay text buffer instead of a full screen model.
- The mobile UI therefore replaces a richer local view with a shorter replay approximation.
- Deleting a session removes server history but does not have a local terminal snapshot concept to clean up.
## Scope
### In Scope
- Persist a local terminal snapshot keyed by `sessionId`
- Restore that snapshot when reopening the same session on the same device
- Save a fresh snapshot when the app backgrounds and after terminal content changes
- Merge agent restore data with local state so truncated restore payloads do not overwrite a richer local snapshot
- Delete local snapshots when a session is deleted
- Delete local snapshots for a project when the project is deleted
- Prune local snapshots whose sessions no longer exist on the agent when the session list is refreshed
### Out Of Scope
- Full multi-device restore correctness
- Full VT/TUI state emulation
- Cross-device snapshot synchronization
- Replay journal UI
## Architecture
### Local Snapshot Storage
Add a mobile-side repository that stores terminal snapshots in one UTF-8 JSON file under app support storage.
Each snapshot should include:
- `sessionId`
- `projectId`
- `sessionName`
- `bufferText`
- `updatedAtUtc`
This storage is authoritative only for immediate same-device UX restoration.
### Terminal Restore Strategy
On terminal page startup:
1. Read the local snapshot for the target session.
2. If present, render it immediately before the socket attach completes.
3. Connect to the agent as usual.
On reconnect:
1. Keep the current terminal content visible.
2. Do not clear the terminal just because reconnect started.
3. When the agent `restore` payload arrives, compare it with the current local content.
4. Replace the terminal only when the agent payload is clearly newer or divergent.
5. Keep the local content when the agent payload is an obvious prefix or shorter truncation of the local content.
### Session Lifecycle Cleanup
- Session deletion removes the matching local snapshot.
- Project deletion removes all local snapshots that belong to that project.
- Session list refresh prunes local snapshots whose session ids are no longer returned by the agent.
## Business Rationale
This design optimizes for the experience the user actually cares about: returning to the same phone and seeing the same work surface without a jarring reset. It avoids pretending that the current backend replay payload is a real terminal screen snapshot while still preserving agent-side truth for ongoing runtime continuity.
## Risks
- A divergent local snapshot could still be visually stale until the agent restore arrives.
- Prefix-based merge logic is intentionally shell-oriented and not correct for advanced fullscreen TUIs.
## Mitigations
- Keep the merge heuristic conservative and fall back to the agent restore when content diverges.
- Keep the snapshot data model simple so a future backend screen-state model can replace it cleanly.
## Acceptance Criteria
- Returning to the same session on the same device restores the last local terminal view immediately when available.
- Reconnect no longer clears the terminal before restore arrives.
- A shorter agent replay payload does not overwrite a richer local terminal snapshot.
- Deleting a session removes its local snapshot.
- Deleting a project removes local snapshots for that project.

View File

@ -1,347 +0,0 @@
# Terminal Reconnect Recovery Design
## Goal
Upgrade terminal reconnect handling from "recent output replay" to "session survives and the user can recover what they were seeing", while also introducing a raw terminal I/O journal for diagnostics and audit.
## Product Context
- Audience: mobile users connecting to a Windows terminal agent over unstable local, Wi-Fi, or mobile networks
- Primary pain point: after iOS backgrounding or transient disconnects, the terminal often reconnects to a shell that is still alive, but the restored screen is incomplete
- Current business impact:
- users cannot trust that the terminal after reconnect reflects the last visible state
- users lose confidence when typed commands disappear while output remains
- support and debugging are harder because the system stores output-oriented history but not a full terminal event record
## Current State
### What Works Today
- The Windows agent keeps the ConPTY-backed session alive across client disconnects.
- The mobile app reconnects automatically and re-attaches the websocket session.
- The backend stores:
- line-oriented history for scrollback APIs
- a replay buffer of recent output text for websocket attach replay
- The mobile app suppresses obvious duplicate replay on reconnect and preserves ordinary shell output better than before.
### What Is Structurally Missing
- The restore model is still output-centric.
- The backend does not maintain an authoritative renderable screen snapshot.
- The restore path cannot fully represent:
- pending user input that has not yet been echoed back
- cursor position and terminal state beyond what raw output text implies
- richer VT state such as alternate screen, screen clears, or in-line editing
- There is no raw I/O journal that can answer "what exactly was sent and received".
### Consequence
The product currently preserves session runtime better than it preserves session experience. The shell is usually still alive, but the user-visible screen after reconnect is only an approximation.
## Scope
### In Scope
- Reconnect recovery for ordinary shell-oriented terminal use
- Raw terminal I/O journal for input, output, resize, attach, and detach events
- Backend-owned restore state exposed explicitly to clients
- Mobile terminal restore flow updated to consume restore payloads instead of guessing from output replay
- Reliable recovery for:
- prompt + partially typed command
- typed command that has not fully echoed yet
- ordinary command output and prompt progression
### Out Of Scope
- Full fidelity recovery for all fullscreen and curses-based TUIs such as `vim`, `less`, `top`, or `htop`
- Cross-device collaborative session editing
- Server-side playback UI for historical terminal sessions
- Security and retention policy redesign beyond what the journal feature minimally needs
## Problem Statement
The system currently treats reconnect recovery as a transport problem. Professionally, it is a state recovery problem.
There are three different truths in a terminal system:
1. The runtime truth: the PTY process is still alive
2. The audit truth: what bytes and semantic events went in and out
3. The UX truth: what the user last saw on screen
Today, TermRemoteCtl handles runtime truth reasonably well, has partial output logging, and approximates UX truth by replaying recent output. The design must separate these concerns instead of overloading output replay to serve every use case.
## Design Principles
- The backend owns terminal truth. The client should stop reconstructing terminal state from ambiguous fragments when the backend can provide a better restore payload.
- Preserve session continuity first, then preserve screen continuity.
- Store audit data and restore data separately because they serve different business needs.
- Keep the first implementation focused on shell workflows. Do not claim full VT recovery until the system actually maintains full terminal state.
- Prefer explicit protocol payloads over frontend heuristics.
## Approach Options
### Option A: Keep Output Replay and Add More Client Heuristics
- Pros: smallest short-term change
- Cons: compounds existing fragility, keeps recovery logic split across backend and Flutter UI, still does not provide audit completeness
### Option B: Add Raw Journal and Backend-Owned Restore Snapshot
- Pros: best balance of correctness, implementation size, and future extensibility
- Cons: requires protocol changes and new backend state management
### Option C: Introduce a Full Headless Terminal Emulator on the Agent Immediately
- Pros: strongest long-term terminal fidelity
- Cons: largest implementation cost, more operational and compatibility risk, too large for the immediate business pain
## Recommended Approach
Choose Option B.
This addresses the actual user complaint with the smallest architecture change that still moves the system in a professional direction. It creates the right boundaries:
- PTY runtime remains the source of process continuity
- raw journal becomes the source of audit truth
- restore snapshot becomes the source of reconnect UX truth
It also leaves room for a future Option C if the product later needs true fullscreen TUI restoration.
## Target Architecture
### Runtime Layer
Keep the existing ConPTY-backed session host. It remains responsible for starting shells, forwarding input, reading output, and handling resize.
### Journal Layer
Add a raw session journal that records semantic terminal events with timestamps and sequence numbers.
Event kinds:
- `attach`
- `detach`
- `input`
- `output`
- `resize`
Each journal entry should include:
- session id
- monotonic sequence number
- UTC timestamp
- event kind
- payload
The journal exists for observability, debugging, and future playback. It is not the primary reconnect payload.
### Restore Layer
Add a backend restore state object that is cheap to update and explicit about what the client should restore.
The initial restore state should include:
- recent renderable output snapshot
- pending visible input suffix that has not yet been authoritatively echoed
- cursor metadata when available
- sequence number of the snapshot
The restore state should be generated entirely on the backend and sent to clients during attach.
## Restore Model
### Phase 1 Restore Fidelity
The first professional-grade target is not "full VT screen emulator". It is:
- shell prompt continuity
- visible command continuity
- ordinary command output continuity
- reconnect without losing the user's typed-but-not-yet-echoed command
This is enough to solve the main business complaint and avoid overengineering.
### Restore Data Components
#### 1. Recent Renderable Output Snapshot
This remains output-derived, but it is formalized as part of a restore payload instead of being an implicit replay side effect.
#### 2. Pending Visible Input
This tracks user input that should still be visible after reconnect because the shell has not fully echoed or absorbed it yet.
Examples:
- User typed `git status` but the reconnect happened before the line was echoed
- User typed part of a command and the connection dropped before Enter
This data should be:
- updated when client input is received
- reduced or cleared when backend output confirms the echo
- appended to the restore payload after the renderable output snapshot
#### 3. Snapshot Sequence
The restore payload should carry a sequence number so the client can reason about whether live frames arrived before or after the restore payload and suppress stale duplication safely.
## Protocol Changes
### Existing Attach Flow
Today the websocket attach path sends:
- `attached`
- replay text, if present
- live text frames
### New Attach Flow
The websocket attach path should send:
- `attached`
- `restore` JSON payload
- optional live delta frames after the snapshot sequence
Proposed restore payload shape:
```json
{
"type": "restore",
"sessionId": "session-123",
"sequence": 1042,
"screenText": "PS C:\\repo> gi",
"pendingInput": "t status",
"cursor": {
"column": 18,
"row": 0
}
}
```
For the first implementation, `cursor` can be optional. The key business requirement is that `screenText + pendingInput` reconstructs what the user expects to see for normal shell usage.
## Client Behavior
The Flutter client should stop treating reconnect restore as "history seed plus attach replay text plus duplicate suppression guesses".
Instead:
- reset the terminal buffer on reconnect
- apply the backend `restore` payload as the first authoritative state
- then process live frames that are newer than the restore sequence
- keep client-side suppression only as a narrow safety net, not as the primary correctness strategy
This simplifies the frontend and moves recovery correctness to the component that actually owns the session.
## Raw Journal Design
### Why Journal Separately
The journal answers different questions than restore state:
- Restore state answers: "What should the user see right now?"
- Journal answers: "What happened in this session over time?"
If these remain separate:
- reconnect UX can stay fast and compact
- diagnostics stay trustworthy
- future features like export, playback, or audit retention remain possible
### Storage Requirements
- append-only per-session file storage is sufficient for the first version
- use UTF-8 JSON lines
- store compact payloads and escaped control characters explicitly
- retention should be configurable
## Sequence And Ack Model
The system should become explicit about input confirmation.
Business rule:
- Input is considered pending until restore state logic sees output that makes the input visibly present on screen.
This is not a full mosh-style predictive protocol. It is a practical echo-ack model suitable for shell workflows.
Benefits:
- typed commands no longer vanish during reconnect
- echoed commands do not get duplicated after reconnect
## Testing Strategy
### Backend Unit Tests
Add tests for:
- pending visible input is added to restore state
- echoed output clears matching pending input
- split output chunks still clear pending input correctly
- journal records input, output, resize, attach, and detach entries in order
### Backend Integration Tests
Add websocket attach tests for:
- reconnect after input but before echo returns visible input in restore payload
- reconnect after echo does not duplicate the command
- restore payload and subsequent live frames preserve ordering by sequence
### Flutter Tests
Add tests for:
- terminal page restores from `restore` payload rather than text replay only
- reconnect clears stale terminal content and applies authoritative restore state
- visible command text survives reconnect for ordinary shell scenarios
### Manual Product Tests
Validate on iOS first:
- type without pressing Enter, background app, resume, confirm input is still visible
- press Enter, background during prompt/output transition, resume, confirm no duplicate command text
- run several ordinary PowerShell commands and verify prompt continuity
## Rollout Plan
### Phase 1
- Add raw journal
- Add restore payload with renderable output snapshot and pending input
- Update mobile client to consume restore payload
- Keep existing replay text path behind a compatibility fallback during rollout
### Phase 2
- Remove fallback replay heuristics once restore payload is proven stable
- Expose journal inspection tooling for diagnostics if needed
### Phase 3
- Evaluate whether the product needs full terminal state emulation for fullscreen TUI recovery
## Risks
- A partial restore model could still mishandle advanced VT interaction and create false confidence
- Journal retention could grow storage unexpectedly if left unbounded
- Client/server sequence handling could introduce duplicate or missing frames if not carefully tested
## Mitigations
- Explicitly define Phase 1 as shell-oriented, not full TUI recovery
- Keep journal retention configurable and bounded
- Make restore sequence ordering part of integration tests before rollout
- Keep restore payload explicit and versionable
## Acceptance Criteria
- A reconnect after ordinary shell input preserves what the user expects to see, including pending typed commands
- A reconnect after command echo does not duplicate visible command text
- The backend stores raw input and output events separately from restore state
- The mobile client restores terminal state from backend-owned restore payloads instead of depending primarily on replay heuristics
- The architecture is ready for later upgrade to full screen-state emulation without rewriting the journal layer

View File

@ -1,221 +0,0 @@
# 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

@ -1,613 +0,0 @@
# 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

View File

@ -1,202 +0,0 @@
# Terminal Authoritative Timeline Recovery Design
## Goal
Make terminal reconnect and app reopen recover the recent terminal timeline as a trustworthy server-owned history, not as a best-effort replay tail.
The user experience target is closer to a chat timeline:
- input and output both matter
- ordering must match what actually happened
- reconnect or reopen must not silently drop a middle segment
- local cache may improve speed, but it must not define correctness
## Product Position
This product is a remote terminal client backed by a long-lived server-side session.
That means:
- the backend owns session continuity
- the backend must own timeline truth
- the frontend should only accelerate first paint and display the authoritative timeline
The current product only partially does this. The backend owns the process and keeps a journal, but the frontend still reconstructs recovery from local snapshots and bounded replay text. That is why users can see the latest output while missing a middle segment.
## Problem Statement
Today the reconnect and reopen path mixes three different sources:
- local mobile snapshot
- websocket restore built from a bounded replay buffer
- journal-backed history browsing
These sources answer different questions:
- local snapshot answers `what did this device last paint`
- replay buffer answers `what recent output tail can the backend quickly send`
- journal answers `what actually happened`
The product currently treats the first two as recovery truth. That is the root cause of incomplete history after reconnect or reopen.
## Required Product Behavior
For ordinary shell workflows, the product must behave like a trustworthy timeline:
- if the user typed commands while the app was gone, those input events remain in order
- if the terminal produced output while the app was gone, that output remains in order
- when the user returns, the recent timeline is rebuilt from backend truth
- local cache may paint first, but it must be corrected by backend truth
This phase does not promise full screen-state recovery for every TUI or cursor-addressed redraw case.
This phase does promise trustworthy recent terminal history.
## Architecture Decision
The backend journal becomes the authoritative recovery source for recent timeline reconstruction.
The roles become:
- `SessionIoJournalStore`: authoritative terminal timeline source
- websocket `restore`: reconnect anchor only
- local snapshot: same-device acceleration only
- frontend `xterm`: renderer of the recovered timeline
## Recovery Model
### 1. Snapshot Role
The local snapshot is allowed to do only one thing:
- show provisional content immediately while waiting for backend recovery
It must also persist the last authoritative sequence seen by the client.
It must not be treated as the final recovered history.
### 2. Restore Role
The websocket restore payload remains useful, but only as:
- a fast reconnect anchor
- the backend's current sequence baseline
- an approximation of the latest visible tail
It must not be treated as the full recovery answer.
### 3. Journal Role
The journal is the source of truth for:
- input events
- output events
- their ordering
- gap detection across reconnect and reopen
When the client returns and has an earlier sequence than the backend restore sequence, the client must request journal events after the local snapshot sequence and rebuild the missing recent timeline.
## Recovery Flow
### Reopen Flow
1. Read local snapshot
2. Paint snapshot text provisionally if available
3. Record snapshot `lastSequence` as recovery baseline
4. Connect websocket
5. Receive websocket `restore` with current backend sequence
6. If backend sequence is newer than local baseline, fetch journal events after local baseline
7. Rebuild the missing recent timeline into the terminal
8. Continue with live websocket output
### Reconnect Flow
1. Keep local buffer on screen during reconnect
2. Receive websocket `restore`
3. Compare restore sequence against the last client sequence
4. Fetch journal events for any missing gap
5. Apply missing input and output events in order
6. Continue with live websocket output
## Rendering Rules
For this phase, recent timeline rebuild should focus on trustworthy shell history, not full terminal emulation.
Rules:
- output events are written back into `xterm`
- input events are represented in history and recovery bookkeeping
- the frontend must avoid double-applying journal output and later websocket live frames
- reconnect gap recovery and reopen recovery must share the same sequence-based logic
## Sequence Rules
The frontend must persist and use a single authoritative sequence concept:
- `lastReceivedSequence` from websocket output or restore
- `snapshot.lastSequence` when writing local cache
- `restore.sequence` from backend attach
Gap rule:
- if `restore.sequence <= snapshot.lastSequence`, no journal catch-up is needed
- if `restore.sequence > snapshot.lastSequence`, fetch journal events after `snapshot.lastSequence`
## Scope Boundaries
### In Scope
- recent timeline correctness for ordinary shell use
- reconnect gap recovery using journal sequence
- reopen recovery using snapshot sequence plus journal catch-up
- trustworthy ordering of recent input and output
### Out Of Scope
- full server-side terminal screen ownership
- exact restoration of arbitrary cursor-addressed screen state
- perfect recovery for fullscreen TUIs
- replay of the entire session into the frontend buffer with no limit
## Recommended Implementation Shape
### Frontend
Add snapshot metadata:
- `lastSequence`
Update coordinator behavior:
- allow the page to set a recovery baseline sequence before connect
- after restore, recover journal events after that baseline
- reuse the existing sequence-gap recovery path for reconnect and reopen
Update page behavior:
- local snapshot paints first
- snapshot sequence is passed to coordinator as provisional baseline
- restore no longer serves as the final complete answer
### Backend
No immediate protocol redesign is required for phase 1.
The existing journal API and restore sequence are enough to implement authoritative recent timeline recovery.
## Why This Is The Right Phase 1
This is the shortest path that fixes the real trust issue.
It does not pretend the replay buffer is complete.
It does not force an immediate full server-side screen engine rollout.
It moves correctness to the only durable source already present in the product: the journal.
That is the right business move because users first need confidence that nothing in the recent timeline disappeared.
## Success Criteria
- reconnect no longer loses a middle segment when newer output arrived while disconnected
- reopen no longer restores only the replay tail when the backend has a newer recent timeline
- local snapshot remains useful for first paint but does not override backend truth
- focused tests prove sequence-based catch-up for reconnect and reopen

View File

@ -1,275 +0,0 @@
# Terminal Truth-Source Reset Design
## Goal
Stop the terminal from drifting into unusable state by resetting the architecture to a single rendering truth source on the mainline, while isolating backend-authoritative screen recovery into an explicit experiment branch.
## Decision
The mainline must use:
- frontend `xterm` as the only terminal rendering truth
- backend journal and websocket output as the only terminal data truth
- local mobile snapshot as a speed-only cache
The mainline must not use:
- backend `screen_snapshot` as a rendering truth
- backend `screen_patch` as a rendering truth
- frontend replay of backend screen models back into `xterm`
The backend-authoritative screen protocol stays in the repository only as an experiment and must not remain on the default product path.
## Why This Reset Is Necessary
The current system mixes two incompatible terminal architectures:
1. `xterm`-driven rendering
2. backend screen-model-driven rendering
Each one can work if it is the only truth source. They break when both are live at the same time.
Today the project has all of the following active or partially active:
- backend raw output journal
- backend replay buffer
- backend pending input echo tracker
- backend screen engine
- websocket `restore`
- websocket `output`
- websocket `screen_snapshot`
- websocket `screen_patch`
- frontend local snapshot
- frontend `xterm`
That creates multiple answers to the same business question:
`What should the user see on screen right now?`
As soon as reconnect, resize, delayed echo, PowerShell control sequences, or prompt line editing show up, the answers diverge.
## Root Cause
### Business View
Users do not care whether reconnect shows something plausible. They care whether the terminal remains stable:
- the prompt stays readable
- typed input appears where expected
- later commands do not corrupt earlier output
- reconnect does not make the session less trustworthy
The current design fails that because the product lets two different models of the terminal race to control the same screen.
### Technical View
The current backend screen engine is not strong enough to own rendering truth:
- it only supports a narrow subset of escape semantics
- it does not model scroll behavior correctly for a real shell stream
- it does not model PowerShell line editing, prompt redraw, or table layout with enough fidelity
- it still leaks some terminal control semantics into visible text unless patched case by case
At the same time, the frontend is already using `xterm`, which is itself a terminal interpreter. Feeding backend screen state back into `xterm` means the system interprets terminal state twice.
That is the structural reason the output gets more chaotic over time.
## Mainline Architecture
### Rendering Truth
Mainline rendering truth is:
- websocket `output` frames
- websocket `restore`
- local provisional snapshot until backend restore arrives
- `xterm` buffer as the actual visible state
This means:
- the backend does not send a screen model that the frontend treats as authoritative
- the frontend does not try to reconstruct a backend screen model on top of `xterm`
- reconnect correctness is limited to shell-oriented restore, not full terminal-state recovery
That limitation is acceptable on the mainline because stability is more important than partial fidelity masquerading as correctness.
### History Truth
Mainline history truth remains:
- backend journal and sequence model
- backend history API built from durable journal data
This stays because it is already aligned with the right authority boundary:
- journal answers `what happened`
- `xterm` answers `what is visible now`
### Local Snapshot Role
Local mobile snapshot remains allowed only as:
- a same-device startup acceleration layer
- a provisional paint while waiting for backend restore
It must never override backend restore correctness.
## What Stays On The Mainline
### Backend
Keep:
- `SessionIoJournalStore`
- `SessionHistoryStore`
- durable session journal APIs
- sequence-aware `restore`
- sequence-aware `output`
- `PendingInputEchoTracker` only as part of shell-oriented restore
- `TerminalReplayBuffer` only as part of shell-oriented restore
Keep these files on the product path:
- `apps/windows_agent/src/TermRemoteCtl.Agent/History/SessionIoJournalStore.cs`
- `apps/windows_agent/src/TermRemoteCtl.Agent/History/SessionHistoryStore.cs`
- `apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs`
- `apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs`
- `apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRestoreSnapshot.cs`
### Frontend
Keep:
- `TerminalSocketSession` attach, restore, output handling
- `TerminalSessionCoordinator` reconnect, pending input buffering, history browsing
- `TerminalPage` live output rendering through `xterm`
- local snapshot storage and restore
Keep these files on the product path:
- `apps/mobile_app/lib/features/terminal/terminal_socket_session.dart`
- `apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart`
- `apps/mobile_app/lib/features/terminal/terminal_page.dart`
- `apps/mobile_app/lib/features/terminal/terminal_snapshot.dart`
- `apps/mobile_app/lib/features/terminal/terminal_snapshot_storage.dart`
## What Must Be Removed From The Mainline
These behaviors must be removed from default runtime behavior:
- frontend rendering based on `screen_snapshot`
- frontend rendering based on `screen_patch`
- frontend conversion of backend screen models into escape replay strings
- backend attach contract that expects the client to consume both `restore` and `screen_snapshot` as active truths
- backend live contract that expects the client to consume both `output` and `screen_patch` as active truths
These files or code paths should no longer participate in the default product path:
- `apps/mobile_app/lib/features/terminal/terminal_screen_snapshot.dart`
- `apps/mobile_app/lib/features/terminal/terminal_screen_patch.dart`
- `apps/mobile_app/lib/features/terminal/terminal_screen_state.dart`
- screen-protocol branches inside `terminal_page.dart`
- screen-protocol branches inside `terminal_session_coordinator.dart`
- screen-protocol parsing that is wired into the default runtime path inside `terminal_socket_session.dart`
## What Moves To The Experiment Branch
The backend-authoritative screen effort should continue only behind an explicit experiment boundary.
That experiment branch owns:
- `TerminalScreenEngine`
- `TerminalScreenSnapshot`
- `TerminalScreenPatch`
- websocket `screen_snapshot`
- websocket `screen_patch`
- websocket `screen_sync`
- frontend screen snapshot and patch models
- frontend screen snapshot and patch application
These files belong to the experiment track:
- `apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/Screen/TerminalScreenEngine.cs`
- `apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/Screen/TerminalScreenSnapshot.cs`
- `apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/Screen/TerminalScreenPatch.cs`
- `apps/mobile_app/lib/features/terminal/terminal_screen_snapshot.dart`
- `apps/mobile_app/lib/features/terminal/terminal_screen_patch.dart`
- `apps/mobile_app/lib/features/terminal/terminal_screen_state.dart`
The experiment is valid only if it eventually reaches this architecture:
- backend owns a real terminal emulator
- frontend does not feed screen models back into `xterm`
- frontend renders screen state directly from backend screen data
If that condition is not met, the experiment should not merge back into mainline.
## Runtime Boundary
There must be an explicit runtime boundary between:
- stable mainline behavior
- experimental screen-protocol behavior
Recommended rule:
- mainline build defaults `enableBackendScreenProtocol = false`
- experiment branch may enable it by explicit wiring
- no hidden fallback should silently re-enable screen protocol in production paths
## Testing Strategy
### Mainline Tests
Mainline tests should prove:
- `pwd -> ls -> pwd` remains stable
- reconnect keeps input working
- resize does not corrupt future input
- output remains append-oriented and readable through `xterm`
- local snapshot is provisional and restore remains authoritative
Mainline tests must not assert backend screen snapshot correctness.
### Experiment Tests
Experiment tests should prove:
- scroll semantics
- cursor-addressed redraw
- PowerShell title or control sequences
- prompt editing
- alternate buffer behavior
- screen version continuity
These tests should not block mainline unless the experiment is being promoted.
## Migration Plan
### Phase 1: Mainline Reset
- disable screen protocol on the default mobile path
- stop using backend screen models to drive visible terminal state
- keep journal, restore, output, and local snapshot semantics intact
- remove or quarantine tests that assume backend screen protocol is part of default production behavior
### Phase 2: Codebase Cleanup
- remove dead mainline branches for screen protocol
- move screen protocol files and tests under an explicit experiment area or keep them feature-gated with clear naming
- update docs so the team cannot mistake the experiment for the product path
### Phase 3: Experiment Continuation
- continue backend-authoritative screen work in isolation
- do not merge until the frontend rendering model also changes
## Acceptance Criteria
This reset is complete when:
- the default app path no longer consumes `screen_snapshot` or `screen_patch`
- terminal rendering uses only one truth source on the mainline
- repeated shell commands do not progressively corrupt the visible prompt
- reconnect and resize no longer activate competing rendering paths
- the repository documents that backend-authoritative screen recovery is experimental, not production truth

View File

@ -3,14 +3,9 @@
1. Start the Windows agent on the primary Windows machine.
2. Pair the iPhone app with a fresh one-time pairing code.
3. Create `codex-main` and `cloud-code` sessions.
4. In `codex-main`, run `pwd`, then `ls`, then `pwd` again and confirm the prompt stays readable.
5. Start a noisy command in `codex-main` and confirm live output keeps appending in the terminal view.
6. Background the app for one minute.
7. Reopen the app and confirm the same session is still alive and input still works.
8. Type a partial command, background the app, reopen it, and confirm the typed command is still visible after restore.
9. Resize the terminal once and confirm later input still appears in the expected prompt position.
10. Scroll upward and confirm older history loads without corrupting the live terminal.
11. Trigger one preset command and confirm it appears in the terminal.
12. Terminate one session and confirm only that session exits.
13. Execute a command, reconnect during output, and confirm the command is not duplicated after restore.
14. Confirm the mainline terminal stays driven by `restore + output + xterm`; do not use backend `screen_snapshot` or `screen_patch` as the visible truth.
4. Start a noisy command in `codex-main`.
5. Background the app for one minute.
6. Reopen the app and confirm the same session is still alive.
7. Scroll upward and confirm older history loads.
8. Trigger one preset command and confirm it appears in the terminal.
9. Terminate one session and confirm only that session exits.