feat: add local terminal snapshot recovery

This commit is contained in:
sladro 2026-04-06 16:19:08 +08:00
parent 900a6f0567
commit e79148e9a3
15 changed files with 1280 additions and 201 deletions

View File

@ -6,6 +6,7 @@ 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();
@ -15,12 +16,21 @@ 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));
return SessionRepository(
ref.watch(agentApiClientProvider),
snapshotStorage: ref.watch(terminalSnapshotStorageProvider),
);
});
final projectRepositoryProvider = Provider<ProjectRepository>((ref) {

View File

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

View File

@ -220,6 +220,9 @@ class _ProjectListPageState extends ConsumerState<ProjectListPage> {
await ref
.read(projectRepositoryProvider)
.deleteProject(project.projectId);
await ref
.read(terminalSnapshotStorageProvider)
.deleteByProjectId(project.projectId);
ref.invalidate(sessionsProvider);
await _reloadProjects();
} catch (error) {

View File

@ -1,14 +1,21 @@
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);
SessionRepository(this._client, {TerminalSnapshotStorage? snapshotStorage})
: _snapshotStorage = snapshotStorage;
final AgentApiClient _client;
final TerminalSnapshotStorage? _snapshotStorage;
Future<List<Session>> listSessions() async {
final sessions = await _client.listSessions();
return sessions.map(Session.fromJson).toList(growable: false);
final mapped = sessions.map(Session.fromJson).toList(growable: false);
await _snapshotStorage?.pruneToSessionIds(
mapped.map((session) => session.sessionId).toSet(),
);
return mapped;
}
Future<Session> createSession({
@ -24,7 +31,8 @@ class SessionRepository {
return Session.fromJson(session);
}
Future<void> deleteSession(String sessionId) {
return _client.deleteSession(sessionId);
Future<void> deleteSession(String sessionId) async {
await _client.deleteSession(sessionId);
await _snapshotStorage?.delete(sessionId);
}
}

View File

@ -2,7 +2,9 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/services.dart';
import 'package:xterm/xterm.dart';
import 'package:xterm/src/ui/controller.dart' as xterm_ui;
import '../../app/app_theme.dart';
import '../../app/ui_shell.dart';
@ -19,7 +21,10 @@ import 'history_window.dart';
import 'repeatable_terminal_key_button.dart';
import 'terminal_interaction_controller.dart';
import 'terminal_restore_payload.dart';
import 'terminal_restore_decision.dart';
import 'terminal_session_coordinator.dart';
import 'terminal_snapshot.dart';
import 'terminal_snapshot_storage.dart';
import 'terminal_socket_session.dart';
class TerminalPage extends ConsumerStatefulWidget {
@ -133,12 +138,16 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
final Terminal terminal = Terminal(maxLines: 1000);
final TerminalInteractionController controller =
TerminalInteractionController();
final xterm_ui.TerminalController _terminalViewController =
xterm_ui.TerminalController();
final TerminalDiagnosticLog _diagnosticLog = TerminalDiagnosticLog();
final FocusNode _terminalFocusNode = FocusNode();
final ScrollController _terminalScrollController = ScrollController();
late final TerminalSessionCoordinator _coordinator;
late final TerminalSnapshotStorage _snapshotStorage;
late final Listenable _pageStateListenable;
Timer? _historySeedTimer;
Timer? _snapshotPersistTimer;
String? _pendingHistorySeed;
bool _receivedSocketFrame = false;
bool _receivedRestorePayload = false;
@ -154,6 +163,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_snapshotStorage = ref.read(terminalSnapshotStorageProvider);
_coordinator = TerminalSessionCoordinator(
controller: controller,
apiClient: ref.read(agentApiClientProvider),
@ -180,7 +190,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
_coordinator.sendInput(normalizedInput);
};
_applyInputMode();
unawaited(_coordinator.start());
unawaited(_bootstrapTerminal());
}
@override
@ -188,6 +198,8 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
WidgetsBinding.instance.removeObserver(this);
_pageStateListenable.removeListener(_handlePageStateChanged);
_historySeedTimer?.cancel();
_snapshotPersistTimer?.cancel();
unawaited(_persistTerminalSnapshot());
_terminalFocusNode.dispose();
_terminalScrollController.dispose();
unawaited(_coordinator.close());
@ -210,7 +222,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
state == AppLifecycleState.paused) {
_shouldReconnectOnResume = true;
_diagnosticLog.add('app.lifecycle.suspended', state.name);
unawaited(_coordinator.suspendForBackground());
unawaited(_persistSnapshotAndSuspend());
}
}
@ -335,6 +347,36 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
_terminalFocusNode.requestFocus();
}
Future<void> _copySelectedOrVisibleText() async {
final selection = _terminalViewController.selection;
final selectedText = selection == null
? ''
: terminal.buffer.getText(selection).trimRight();
final text = selectedText.isNotEmpty
? selectedText
: terminal.buffer.getText().trimRight();
await _copyToClipboard(text);
}
Future<void> _copyRecentOutput() async {
await _copyToClipboard(terminal.buffer.getText().trimRight());
}
Future<void> _copyToClipboard(String text) async {
if (text.isEmpty) {
return;
}
await Clipboard.setData(ClipboardData(text: text));
if (!mounted) {
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Copied to clipboard')));
}
void _handleTerminalFrame(String frame) {
if (_awaitingAttachReplayFrame) {
_awaitingAttachReplayFrame = false;
@ -349,6 +391,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
_awaitingReconnectRestore = false;
_cancelHistorySeedTimer();
terminal.write(frame);
_scheduleSnapshotPersist();
}
void _handleRestorePayload(TerminalRestorePayload restore) {
@ -356,15 +399,23 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
_receivedSocketFrame = true;
_receivedRestorePayload = true;
_awaitingReconnectRestore = false;
_historySeeded = false;
_cancelHistorySeedTimer();
_resetTerminalForReplay();
final combined = restore.screenText + restore.pendingInput;
if (combined.isEmpty) {
_scheduleSnapshotPersist();
return;
}
terminal.write(combined);
final decision = decideTerminalRestore(
currentText: terminal.buffer.getText(),
restoreText: combined,
);
if (decision == TerminalRestoreDecision.replaceWithRestore) {
_resetTerminalForReplay();
terminal.write(combined);
}
_historySeeded = _terminalHasVisibleContent;
_scheduleSnapshotPersist();
}
void _handleHistoryLoaded(HistoryWindow history) {
@ -386,8 +437,6 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
_receivedRestorePayload = false;
if (connectionState == TerminalConnectionState.reconnecting) {
_awaitingReconnectRestore = true;
_resetTerminalForReplay();
_historySeeded = false;
_receivedSocketFrame = false;
} else {
_awaitingReconnectRestore = false;
@ -465,7 +514,9 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
}
bool _shouldSuppressAttachReplay(String frame) {
final normalizedFrame = _normalizeTerminalText(frame);
final normalizedFrame = _trimTrailingNewlines(
_normalizeTerminalText(frame),
);
if (normalizedFrame.isEmpty) {
return false;
}
@ -474,8 +525,8 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
return false;
}
final normalizedTerminalText = _normalizeTerminalText(
terminal.buffer.getText(),
final normalizedTerminalText = _trimTrailingNewlines(
_normalizeTerminalText(terminal.buffer.getText()),
);
if (normalizedTerminalText.isNotEmpty &&
normalizedTerminalText.endsWith(normalizedFrame)) {
@ -485,13 +536,23 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
final pendingHistorySeed = _pendingHistorySeed;
return _historySeeded &&
pendingHistorySeed != null &&
_normalizeTerminalText(pendingHistorySeed) == normalizedFrame;
_trimTrailingNewlines(_normalizeTerminalText(pendingHistorySeed)) ==
normalizedFrame;
}
static String _normalizeTerminalText(String text) {
return text.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
}
static String _trimTrailingNewlines(String text) {
var end = text.length;
while (end > 0 && text.codeUnitAt(end - 1) == 0x0A) {
end -= 1;
}
return end == text.length ? text : text.substring(0, end);
}
static String _normalizeTerminalKeyboardInput(String input) {
if (!input.contains('\n')) {
return input;
@ -638,10 +699,12 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
onTap: _handleTerminalSurfaceTap,
child: TerminalView(
terminal,
controller: _terminalViewController,
focusNode: _terminalFocusNode,
autofocus: false,
keyboardType: TextInputType.multiline,
deleteDetection: true,
readOnly: _inputMode == _TerminalInputMode.read,
scrollController: _terminalScrollController,
),
),
@ -885,55 +948,62 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
child: AnimatedBuilder(
animation: _pageStateListenable,
builder: (context, _) {
return Column(
key: const Key('terminal_action_bar'),
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Container(
key: const Key('terminal_mode_button'),
width: double.infinity,
padding: const EdgeInsets.only(bottom: 8),
child: Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
'Input mode',
style: Theme.of(context).textTheme.labelLarge,
),
_buildModeButton(
key: const Key('terminal_mode_read_button'),
label: 'Read',
icon: Icons.menu_book_outlined,
selected: _inputMode == _TerminalInputMode.read,
onPressed: () => _setInputMode(_TerminalInputMode.read),
),
_buildModeButton(
key: const Key('terminal_mode_edit_button'),
label: 'Edit',
icon: Icons.keyboard_outlined,
selected: _inputMode == _TerminalInputMode.edit,
onPressed: () => _setInputMode(_TerminalInputMode.edit),
),
Text(
_inputMode == _TerminalInputMode.read
? 'Read mode prevents the terminal from taking focus.'
: 'Edit mode keeps the terminal ready for typing.',
style: Theme.of(context).textTheme.bodySmall,
return ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 220),
child: SingleChildScrollView(
child: Column(
key: const Key('terminal_action_bar'),
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Container(
key: const Key('terminal_mode_button'),
width: double.infinity,
padding: const EdgeInsets.only(bottom: 8),
child: Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
'Input mode',
style: Theme.of(context).textTheme.labelLarge,
),
_buildModeButton(
key: const Key('terminal_mode_read_button'),
label: 'Read',
icon: Icons.menu_book_outlined,
selected: _inputMode == _TerminalInputMode.read,
onPressed: () =>
_setInputMode(_TerminalInputMode.read),
),
_buildModeButton(
key: const Key('terminal_mode_edit_button'),
label: 'Edit',
icon: Icons.keyboard_outlined,
selected: _inputMode == _TerminalInputMode.edit,
onPressed: () =>
_setInputMode(_TerminalInputMode.edit),
),
Text(
_inputMode == _TerminalInputMode.read
? 'Read mode prevents the terminal from taking focus.'
: 'Edit mode keeps the terminal ready for typing.',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
_buildPinnedQuickKeysRow(),
if (_showExpandedControls) ...[
const SizedBox(height: 8),
_buildExpandedControls(context),
],
),
const SizedBox(height: 8),
_buildStatusRow(context),
],
),
_buildPinnedQuickKeysRow(),
if (_showExpandedControls) ...[
const SizedBox(height: 8),
_buildExpandedControls(context),
],
const SizedBox(height: 8),
_buildStatusRow(context),
],
),
);
},
),
@ -947,10 +1017,12 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
children: [
_buildMoreControlsButton(),
..._navigationKeys
.where((key) => switch (key.keyId) {
'up' || 'down' || 'left' || 'right' => true,
_ => false,
})
.where(
(key) => switch (key.keyId) {
'up' || 'down' || 'left' || 'right' => true,
_ => false,
},
)
.map(_buildQuickKeyButton),
],
);
@ -968,12 +1040,14 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
_buildKeyTrayRow(_editingControlKeys),
const SizedBox(height: 8),
_buildKeyTrayRow(
_navigationKeys.where((key) {
return key.keyId != 'up' &&
key.keyId != 'down' &&
key.keyId != 'left' &&
key.keyId != 'right';
}).toList(growable: false),
_navigationKeys
.where((key) {
return key.keyId != 'up' &&
key.keyId != 'down' &&
key.keyId != 'left' &&
key.keyId != 'right';
})
.toList(growable: false),
),
const SizedBox(height: 8),
_buildKeyTrayRow(_symbolTerminalKeys),
@ -1001,6 +1075,18 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
icon: const Icon(Icons.add_box_outlined),
label: const Text('New terminal'),
),
TextButton.icon(
key: const Key('terminal_copy_selected_button'),
onPressed: _copySelectedOrVisibleText,
icon: const Icon(Icons.copy_outlined),
label: const Text('Copy Selected'),
),
TextButton.icon(
key: const Key('terminal_copy_recent_output_button'),
onPressed: _copyRecentOutput,
icon: const Icon(Icons.content_paste_go_outlined),
label: const Text('Copy Recent Output'),
),
TextButton.icon(
key: const Key('terminal_diagnostics_inline_button'),
onPressed: _showDiagnostics,
@ -1029,9 +1115,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
return Wrap(
spacing: 8,
runSpacing: 8,
children: keys
.map(_buildQuickKeyButton)
.toList(growable: false),
children: keys.map(_buildQuickKeyButton).toList(growable: false),
);
}
@ -1160,6 +1244,62 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
};
TerminalConnectionState get _connectionState => controller.connectionState;
Future<void> _bootstrapTerminal() async {
await _restoreLocalSnapshot();
if (!mounted) {
return;
}
await _coordinator.start();
}
Future<void> _restoreLocalSnapshot() async {
final snapshot = await _snapshotStorage.read(widget.session.sessionId);
if (!mounted || snapshot == null || snapshot.bufferText.isEmpty) {
return;
}
if (_terminalHasVisibleContent) {
return;
}
terminal.write(snapshot.bufferText);
_historySeeded = true;
}
void _scheduleSnapshotPersist() {
_snapshotPersistTimer?.cancel();
_snapshotPersistTimer = Timer(const Duration(milliseconds: 180), () {
_snapshotPersistTimer = null;
unawaited(_persistTerminalSnapshot());
});
}
Future<void> _persistSnapshotAndSuspend() async {
await _persistTerminalSnapshot();
await _coordinator.suspendForBackground();
}
Future<void> _persistTerminalSnapshot() async {
_snapshotPersistTimer?.cancel();
_snapshotPersistTimer = null;
final bufferText = terminal.buffer.getText();
if (bufferText.trim().isEmpty) {
return;
}
await _snapshotStorage.save(
TerminalSnapshot(
sessionId: widget.session.sessionId,
projectId: widget.session.projectId ?? widget.project?.projectId,
sessionName: widget.session.name,
bufferText: bufferText,
updatedAtUtc: DateTime.now().toUtc().toIso8601String(),
),
);
}
}
class _QuickTerminalKey {

View File

@ -0,0 +1,35 @@
enum TerminalRestoreDecision { keepLocal, replaceWithRestore }
TerminalRestoreDecision decideTerminalRestore({
required String currentText,
required String restoreText,
}) {
if (restoreText.isEmpty) {
return TerminalRestoreDecision.keepLocal;
}
if (currentText.isEmpty) {
return TerminalRestoreDecision.replaceWithRestore;
}
final normalizedCurrent = _normalizeTerminalText(currentText);
final normalizedRestore = _normalizeTerminalText(restoreText);
if (normalizedCurrent.isEmpty) {
return TerminalRestoreDecision.replaceWithRestore;
}
if (normalizedCurrent == normalizedRestore) {
return TerminalRestoreDecision.keepLocal;
}
if (normalizedCurrent.startsWith(normalizedRestore)) {
return TerminalRestoreDecision.keepLocal;
}
return TerminalRestoreDecision.replaceWithRestore;
}
String _normalizeTerminalText(String text) {
return text.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
}

View File

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

View File

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

View File

@ -1,7 +1,11 @@
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 {
@ -46,6 +50,23 @@ 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 {
@ -57,6 +78,7 @@ class _FakeAgentApiClient extends AgentApiClient {
int listCalls = 0;
String? lastCreatedName;
final List<String> deletedSessionIds = <String>[];
@override
Future<List<Map<String, dynamic>>> listSessions() async {
@ -78,4 +100,32 @@ class _FakeAgentApiClient extends AgentApiClient {
'status': 'idle',
};
}
@override
Future<void> deleteSession(String sessionId) async {
deletedSessionIds.add(sessionId);
}
}
class _FakeTerminalSnapshotStorage extends TerminalSnapshotStorage {
_FakeTerminalSnapshotStorage() : super(storageFileLoader: _unsupportedFile);
final List<String> deletedSessionIds = <String>[];
@override
Future<void> save(TerminalSnapshot snapshot) async {}
@override
Future<TerminalSnapshot?> read(String sessionId) async {
return null;
}
@override
Future<void> delete(String sessionId) async {
deletedSessionIds.add(sessionId);
}
}
Future<File> _unsupportedFile() {
throw UnimplementedError('This test does not use file storage.');
}

View File

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -8,33 +9,50 @@ 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/terminal/terminal_page.dart';
import 'package:term_remote_ctl/features/terminal/terminal_socket_session.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';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
testWidgets('terminal page removes the bottom text input field', (
tester,
) async {
await _pumpTerminalPage(tester);
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_send_button')), findsOneWidget);
});
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 remains focusable for soft keyboard input', (
tester,
) async {
await _pumpTerminalPage(tester);
testWidgets(
'terminal view becomes focusable only after edit mode is selected',
(tester) async {
await _pumpTerminalPage(tester);
final terminalView = tester.widget<TerminalView>(find.byType(TerminalView));
var terminalView = tester.widget<TerminalView>(find.byType(TerminalView));
expect(terminalView.focusNode, isNotNull);
expect(terminalView.focusNode!.canRequestFocus, isFalse);
expect(terminalView.focusNode, isNotNull);
expect(terminalView.focusNode!.canRequestFocus, isTrue);
});
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', (
tester,
@ -54,49 +72,75 @@ void main() {
expect(terminalView.deleteDetection, isTrue);
});
testWidgets('soft keyboard newline is sent as terminal enter', (
tester,
) async {
final transportFactory = _QueuedTerminalSocketTransportFactory();
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 _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();
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"'),
);
});
expect(
transportFactory.createdTransports.single.sentMessages.last,
contains(r'"input":"\r"'),
);
},
);
testWidgets('terminal actions sheet unifies session actions and quick keys', (
tester,
) async {
final transportFactory = _QueuedTerminalSocketTransportFactory();
testWidgets(
'terminal more controls exposes reconnect, presets, and quick keys',
(tester) async {
final transportFactory = _QueuedTerminalSocketTransportFactory();
await _pumpTerminalPage(tester, socketFactory: transportFactory.factory);
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_actions_button')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('terminal_more_controls_button')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('terminal_actions_sheet')), findsOneWidget);
expect(find.text('Reconnect'), findsOneWidget);
expect(find.text('Latest'), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_ctrl_c')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_up')), findsOneWidget);
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_quick_key_up')));
await tester.pumpAndSettle();
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":"\u001b[A"'),
);
});
expect(
transportFactory.createdTransports.single.sentMessages.last,
contains(r'"input":"\u0003"'),
);
},
);
testWidgets('terminal actions sheet opens preset management', (tester) async {
testWidgets('terminal more controls opens preset management', (tester) async {
await _pumpTerminalPage(
tester,
presetRepository: _MemoryPresetRepository([
@ -108,11 +152,15 @@ void main() {
]),
);
await tester.tap(find.byKey(const Key('terminal_actions_button')));
await tester.tap(find.byKey(const Key('terminal_more_controls_button')));
await tester.pumpAndSettle();
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();
@ -130,6 +178,9 @@ Future<void> _pumpTerminalPage(
ProviderScope(
overrides: [
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
terminalSnapshotStorageProvider.overrideWithValue(
_MemoryTerminalSnapshotStorage(),
),
terminalSocketSessionFactoryProvider.overrideWithValue(
socketFactory ??
TerminalSocketSessionFactory(
@ -176,6 +227,27 @@ 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'));
@ -235,3 +307,7 @@ class _FakeTerminalSocketTransport implements TerminalSocketTransport {
_incoming.add(message);
}
}
Future<File> _unsupportedFile() {
throw UnimplementedError('This test does not use file storage.');
}

View File

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

View File

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

View File

@ -5,6 +5,7 @@ import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/services.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';
@ -20,6 +21,8 @@ import 'package:term_remote_ctl/features/sessions/session.dart';
import 'package:term_remote_ctl/features/sessions/session_list_page.dart';
import 'package:term_remote_ctl/features/sessions/session_repository.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';
@ -97,12 +100,12 @@ void main() {
overrides: [
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
agentBaseUriStorageProvider.overrideWithValue(
_MemoryAgentBaseUriStorage(
Uri.parse('http://100.81.30.82:5067'),
),
_MemoryAgentBaseUriStorage(Uri.parse('http://100.81.30.82:5067')),
),
projectRepositoryProvider.overrideWithValue(projectRepository),
sessionRepositoryProvider.overrideWithValue(_FakeSessionRepository()),
sessionRepositoryProvider.overrideWithValue(
_FakeSessionRepository(),
),
presetRepositoryProvider.overrideWithValue(
_MemoryPresetRepository(const <PresetCommand>[]),
),
@ -144,7 +147,10 @@ void main() {
expect(find.byKey(const Key('terminal_surface_panel')), findsOneWidget);
expect(find.byKey(const Key('terminal_command_deck')), findsOneWidget);
expect(find.byKey(const Key('terminal_status_summary')), findsOneWidget);
expect(find.byKey(const Key('terminal_more_controls_button')), findsOneWidget);
expect(
find.byKey(const Key('terminal_more_controls_button')),
findsOneWidget,
);
});
testWidgets('project list deletes a project after confirmation', (
@ -159,11 +165,21 @@ void main() {
updatedAtUtc: DateTime.parse('2026-03-30T10:00:00Z'),
),
]);
final snapshotStorage = _MemoryTerminalSnapshotStorage([
const TerminalSnapshot(
sessionId: 'session-1',
projectId: 'project-1',
sessionName: 'codex-main',
bufferText: 'local-cache',
updatedAtUtc: '2026-04-06T09:00:00Z',
),
]);
await _pumpApp(
tester,
projectRepository: projectRepository,
sessionRepository: _FakeSessionRepository(),
snapshotStorage: snapshotStorage,
);
expect(find.text('codex-main'), findsOneWidget);
@ -175,6 +191,7 @@ void main() {
expect(projectRepository.deletedProjectIds, ['project-1']);
expect(find.text('codex-main'), findsNothing);
expect(await snapshotStorage.read('session-1'), isNull);
});
testWidgets(
@ -274,32 +291,33 @@ void main() {
expect(find.text('Recent sessions'), findsOneWidget);
});
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"}',
),
],
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,
),
);
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'));
},
);
final terminal = tester
.widget<TerminalView>(find.byType(TerminalView))
.terminal;
expect(terminal.buffer.getText(), contains('PS> git status'));
});
testWidgets(
'terminal page keeps the command deck above the bottom safe area',
@ -362,13 +380,22 @@ void main() {
expect(find.byKey(const Key('terminal_send_button')), findsNothing);
expect(find.byKey(const Key('terminal_command_deck')), findsOneWidget);
expect(find.byType(TextField), findsNothing);
expect(find.byKey(const Key('terminal_mode_read_button')), findsOneWidget);
expect(find.byKey(const Key('terminal_mode_edit_button')), findsOneWidget);
expect(
find.byKey(const Key('terminal_mode_read_button')),
findsOneWidget,
);
expect(
find.byKey(const Key('terminal_mode_edit_button')),
findsOneWidget,
);
expect(find.byKey(const Key('terminal_quick_key_up')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_down')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_left')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_right')), findsOneWidget);
expect(find.byKey(const Key('terminal_more_controls_button')), findsOneWidget);
expect(
find.byKey(const Key('terminal_more_controls_button')),
findsOneWidget,
);
expect(find.byKey(const Key('terminal_actions_button')), findsNothing);
expect(find.byKey(const Key('terminal_quick_key_ctrl_c')), findsNothing);
expect(find.byKey(const Key('terminal_quick_key_enter')), findsNothing);
@ -389,59 +416,85 @@ void main() {
final moreButtonSize = tester.getSize(
find.byKey(const Key('terminal_more_controls_button')),
);
final upKeySize = tester.getSize(find.byKey(const Key('terminal_quick_key_up')));
final upKeySize = tester.getSize(
find.byKey(const Key('terminal_quick_key_up')),
);
expect(moreButtonSize.height, upKeySize.height);
expect(moreButtonSize.width, upKeySize.width);
});
testWidgets('terminal more controls button toggles expanded quick terminal keys', (tester) async {
final transportFactory = _QueuedTerminalSocketTransportFactory();
testWidgets(
'terminal more controls button toggles expanded quick terminal keys',
(tester) async {
final transportFactory = _QueuedTerminalSocketTransportFactory();
await _pumpApp(
tester,
projectRepository: _FakeProjectRepository(),
sessionRepository: _FakeSessionRepository(),
socketFactory: TerminalSocketSessionFactory(
transportFactory: transportFactory.create,
),
);
await _pumpApp(
tester,
projectRepository: _FakeProjectRepository(),
sessionRepository: _FakeSessionRepository(),
socketFactory: TerminalSocketSessionFactory(
transportFactory: transportFactory.create,
),
);
await _openProjectTerminal(tester);
await _openProjectTerminal(tester);
expect(find.byKey(const Key('terminal_quick_key_up')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_down')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_left')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_right')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_esc')), findsNothing);
expect(find.byKey(const Key('terminal_quick_key_ctrl_c')), findsNothing);
expect(find.byKey(const Key('terminal_quick_key_up')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_down')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_left')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_right')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_esc')), findsNothing);
expect(find.byKey(const Key('terminal_quick_key_ctrl_c')), findsNothing);
await tester.tap(find.byKey(const Key('terminal_more_controls_button')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('terminal_more_controls_button')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('terminal_quick_key_esc')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_tab')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_ctrl_c')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_ctrl_d')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_ctrl_l')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_ctrl_z')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_delete')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_home')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_end')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_page_up')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_page_down')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_enter')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_esc')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_tab')), findsOneWidget);
expect(
find.byKey(const Key('terminal_quick_key_ctrl_c')),
findsOneWidget,
);
expect(
find.byKey(const Key('terminal_quick_key_ctrl_d')),
findsOneWidget,
);
expect(
find.byKey(const Key('terminal_quick_key_ctrl_l')),
findsOneWidget,
);
expect(
find.byKey(const Key('terminal_quick_key_ctrl_z')),
findsOneWidget,
);
expect(
find.byKey(const Key('terminal_quick_key_delete')),
findsOneWidget,
);
expect(find.byKey(const Key('terminal_quick_key_home')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_end')), findsOneWidget);
expect(
find.byKey(const Key('terminal_quick_key_page_up')),
findsOneWidget,
);
expect(
find.byKey(const Key('terminal_quick_key_page_down')),
findsOneWidget,
);
expect(find.byKey(const Key('terminal_quick_key_enter')), 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_esc')));
await tester.pump();
await tester.tap(find.byKey(const Key('terminal_mode_edit_button')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('terminal_quick_key_esc')));
await tester.pump();
expect(
transportFactory.createdTransports.single.sentMessages.last,
contains('"input":"\\u001b"'),
);
});
expect(
transportFactory.createdTransports.single.sentMessages.last,
contains('"input":"\\u001b"'),
);
},
);
testWidgets('terminal page exposes reading and editing mode switching', (
tester,
@ -547,7 +600,12 @@ void main() {
transportFactory.createdTransports.single.emit('command-output');
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('terminal_diagnostics_inline_button')));
await tester.ensureVisible(
find.byKey(const Key('terminal_diagnostics_inline_button')),
);
await tester.tap(
find.byKey(const Key('terminal_diagnostics_inline_button')),
);
await tester.pumpAndSettle();
expect(find.textContaining('ui.input.quick | Ctrl+L'), findsOneWidget);
@ -559,7 +617,9 @@ void main() {
},
);
testWidgets('terminal enter quick key writes carriage return', (tester) async {
testWidgets('terminal enter quick key writes carriage return', (
tester,
) async {
final transportFactory = _QueuedTerminalSocketTransportFactory();
await _pumpApp(
@ -585,6 +645,90 @@ void main() {
);
});
testWidgets('read mode copy uses selected terminal text first', (
tester,
) async {
String? copiedText;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'Clipboard.setData') {
copiedText = (call.arguments as Map)['text'] as String?;
}
return null;
});
addTearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null);
});
await _pumpTerminalPage(
tester,
session: _session('session-1', 'codex-main'),
);
final terminalView = tester.widget<TerminalView>(find.byType(TerminalView));
terminalView.controller!.setSelection(
terminalView.terminal.buffer.createAnchor(0, 0),
terminalView.terminal.buffer.createAnchor(3, 0),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('terminal_more_controls_button')));
await tester.pumpAndSettle();
await tester.ensureVisible(
find.byKey(const Key('terminal_copy_selected_button')),
);
await tester.tap(find.byKey(const Key('terminal_copy_selected_button')));
await tester.pumpAndSettle();
expect(copiedText, 'one');
});
testWidgets('copy recent output includes recent terminal buffer content', (
tester,
) async {
String? copiedText;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'Clipboard.setData') {
copiedText = (call.arguments as Map)['text'] as String?;
}
return null;
});
addTearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null);
});
final transportFactory = _QueuedTerminalSocketTransportFactory();
await _pumpApp(
tester,
projectRepository: _FakeProjectRepository(),
sessionRepository: _FakeSessionRepository(),
socketFactory: TerminalSocketSessionFactory(
transportFactory: transportFactory.create,
),
);
await _openProjectTerminal(tester);
transportFactory.createdTransports.single.emit('three');
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('terminal_more_controls_button')));
await tester.pumpAndSettle();
await tester.ensureVisible(
find.byKey(const Key('terminal_copy_recent_output_button')),
);
await tester.tap(
find.byKey(const Key('terminal_copy_recent_output_button')),
);
await tester.pumpAndSettle();
expect(copiedText, contains('one\ntwo'));
expect(copiedText, contains('three'));
});
testWidgets('terminal page reconnects after the socket closes', (
tester,
) async {
@ -697,6 +841,9 @@ void main() {
await tester.tap(find.byKey(const Key('terminal_more_controls_button')));
await tester.pumpAndSettle();
await tester.ensureVisible(
find.byKey(const Key('terminal_new_inline_button')),
);
await tester.tap(find.byKey(const Key('terminal_new_inline_button')));
await tester.pumpAndSettle();
@ -956,11 +1103,111 @@ void main() {
await tester.pump(const Duration(milliseconds: 120));
await tester.pumpAndSettle();
terminal = tester.widget<TerminalView>(find.byType(TerminalView)).terminal;
terminal = tester
.widget<TerminalView>(find.byType(TerminalView))
.terminal;
expect(terminal.buffer.getText(), contains('PS> git status'));
},
);
testWidgets(
're-entering an existing session restores the local terminal snapshot',
(tester) async {
final snapshotStorage = _MemoryTerminalSnapshotStorage([
const TerminalSnapshot(
sessionId: 'session-1',
projectId: 'project-1',
sessionName: 'codex-main',
bufferText: 'local-snapshot-output',
updatedAtUtc: '2026-04-06T09:00:00Z',
),
]);
await _pumpTerminalPage(
tester,
session: _session('session-1', 'codex-main'),
socketFactory: TerminalSocketSessionFactory(
transportFactory: (_) =>
_FakeTerminalSocketTransport(autoAttach: true),
),
snapshotStorage: snapshotStorage,
);
final terminal = tester
.widget<TerminalView>(find.byType(TerminalView))
.terminal;
expect(terminal.buffer.getText(), contains('local-snapshot-output'));
},
);
testWidgets(
'terminal reconnect keeps a richer local snapshot when restore is shorter',
(tester) async {
final snapshotStorage = _MemoryTerminalSnapshotStorage([
const TerminalSnapshot(
sessionId: 'session-1',
projectId: 'project-1',
sessionName: 'codex-main',
bufferText: 'PS> git status\r\nmodified: file.txt\r\nPS> ',
updatedAtUtc: '2026-04-06T09:00:00Z',
),
]);
final transportFactory = _QueuedTerminalSocketTransportFactory(
connectionStartupFrames: const [
[
_StartupFrame('{"type":"attached","sessionId":"session-1"}'),
_StartupFrame(
'{"type":"restore","sessionId":"session-1","sequence":4,"screenText":"PS> git status\\r\\n","pendingInput":""}',
),
],
],
);
await _pumpTerminalPage(
tester,
session: _session('session-1', 'codex-main'),
socketFactory: TerminalSocketSessionFactory(
transportFactory: transportFactory.create,
),
snapshotStorage: snapshotStorage,
);
final terminal = tester
.widget<TerminalView>(find.byType(TerminalView))
.terminal;
expect(terminal.buffer.getText(), contains('modified: file.txt'));
},
);
testWidgets(
'terminal page persists a local snapshot before suspending to background',
(tester) async {
final snapshotStorage = _MemoryTerminalSnapshotStorage();
final transportFactory = _QueuedTerminalSocketTransportFactory(
startupFrames: const [
'{"type":"attached","sessionId":"session-1"}',
'one\r\ntwo',
],
);
await _pumpTerminalPage(
tester,
session: _session('session-1', 'codex-main'),
socketFactory: TerminalSocketSessionFactory(
transportFactory: transportFactory.create,
),
snapshotStorage: snapshotStorage,
);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
await tester.pump();
final snapshot = await snapshotStorage.read('session-1');
expect(snapshot, isNotNull);
expect(snapshot!.bufferText, contains('one\ntwo'));
},
);
testWidgets(
're-entering an existing session restores the terminal cursor to the last line',
(tester) async {
@ -1127,6 +1374,7 @@ Future<void> _pumpApp(
required SessionRepository sessionRepository,
AgentApiClient? apiClient,
TerminalSocketSessionFactory? socketFactory,
TerminalSnapshotStorage? snapshotStorage,
}) async {
await tester.pumpWidget(
ProviderScope(
@ -1142,6 +1390,9 @@ Future<void> _pumpApp(
presetRepositoryProvider.overrideWithValue(
_MemoryPresetRepository(const <PresetCommand>[]),
),
terminalSnapshotStorageProvider.overrideWithValue(
snapshotStorage ?? _MemoryTerminalSnapshotStorage(),
),
terminalSocketSessionFactoryProvider.overrideWithValue(
socketFactory ??
TerminalSocketSessionFactory(
@ -1162,6 +1413,7 @@ Future<void> _pumpTerminalPage(
AgentApiClient? apiClient,
TerminalSocketSessionFactory? socketFactory,
MediaQueryData? mediaQueryData,
TerminalSnapshotStorage? snapshotStorage,
}) async {
Widget page = TerminalPage(
session: session,
@ -1183,6 +1435,9 @@ Future<void> _pumpTerminalPage(
presetRepositoryProvider.overrideWithValue(
_MemoryPresetRepository(const <PresetCommand>[]),
),
terminalSnapshotStorageProvider.overrideWithValue(
snapshotStorage ?? _MemoryTerminalSnapshotStorage(),
),
terminalSocketSessionFactoryProvider.overrideWithValue(
socketFactory ??
TerminalSocketSessionFactory(
@ -1360,6 +1615,45 @@ class _MemoryAgentBaseUriStorage extends AgentBaseUriStorage {
}
}
class _MemoryTerminalSnapshotStorage extends TerminalSnapshotStorage {
_MemoryTerminalSnapshotStorage([List<TerminalSnapshot> snapshots = const []])
: _snapshots = {
for (final snapshot in snapshots) snapshot.sessionId: snapshot,
},
super(storageFileLoader: _unsupportedSnapshotFile);
final Map<String, TerminalSnapshot> _snapshots;
@override
Future<TerminalSnapshot?> read(String sessionId) async {
return _snapshots[sessionId];
}
@override
Future<void> save(TerminalSnapshot snapshot) async {
_snapshots[snapshot.sessionId] = snapshot;
}
@override
Future<void> delete(String sessionId) async {
_snapshots.remove(sessionId);
}
@override
Future<void> deleteByProjectId(String projectId) async {
_snapshots.removeWhere((_, snapshot) => snapshot.projectId == projectId);
}
@override
Future<void> pruneToSessionIds(Set<String> sessionIds) async {
_snapshots.removeWhere((sessionId, _) => !sessionIds.contains(sessionId));
}
}
Future<File> _unsupportedSnapshotFile() {
throw UnimplementedError('This test does not use file storage.');
}
int _countOccurrences(String source, String pattern) {
if (pattern.isEmpty) {
return 0;

View File

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

View File

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