Compare commits
No commits in common. "master" and "codex/terminal-reconnect-stability" have entirely different histories.
master
...
codex/term
1
.gitignore
vendored
1
.gitignore
vendored
@ -50,7 +50,6 @@ Thumbs.db
|
||||
data/
|
||||
certs/
|
||||
.superpowers/
|
||||
.codex/
|
||||
.worktrees/
|
||||
work/*.log
|
||||
work/dotnet-*/
|
||||
|
||||
10
README.md
10
README.md
@ -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.
|
||||
|
||||
@ -1,2 +1 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
@ -1,2 +0,0 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
@ -1,2 +1 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -75,7 +75,7 @@
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
buildConfiguration = "Profile"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
|
||||
@ -4,7 +4,4 @@
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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),
|
||||
);
|
||||
}
|
||||
@ -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
@ -1,8 +0,0 @@
|
||||
enum TerminalRestoreDecision { keepLocal, replaceWithRestore }
|
||||
|
||||
TerminalRestoreDecision decideTerminalRestore({
|
||||
required String currentText,
|
||||
required String restoreText,
|
||||
}) {
|
||||
return TerminalRestoreDecision.replaceWithRestore;
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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',
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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.');
|
||||
}
|
||||
|
||||
@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -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.');
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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 {}
|
||||
}
|
||||
|
||||
@ -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
@ -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)
|
||||
{
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
namespace TermRemoteCtl.Agent.History;
|
||||
|
||||
public sealed record SessionIoEvent(
|
||||
string SessionId,
|
||||
long Sequence,
|
||||
string Kind,
|
||||
string Payload,
|
||||
DateTimeOffset TimestampUtc);
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
namespace TermRemoteCtl.Agent.History;
|
||||
|
||||
public sealed record SessionJournalPage(
|
||||
string SessionId,
|
||||
IReadOnlyList<SessionIoEvent> Items,
|
||||
bool HasMoreBefore,
|
||||
bool HasMoreAfter,
|
||||
long CurrentSequence);
|
||||
@ -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 =>
|
||||
{
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
@ -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);
|
||||
@ -5,7 +5,6 @@
|
||||
"HttpsPort": 0,
|
||||
"HttpPort": 5067,
|
||||
"WebSocketFrameFlushMilliseconds": 33,
|
||||
"RingBufferLineLimit": 4000,
|
||||
"EnableBackendScreenProtocol": false
|
||||
"RingBufferLineLimit": 4000
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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);
|
||||
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
@ -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?**
|
||||
@ -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**
|
||||
@ -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.
|
||||
@ -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"
|
||||
```
|
||||
@ -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**
|
||||
@ -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.
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user