From 7ac7965e7e3ee53902ab3d5f7ed6b80e09872e32 Mon Sep 17 00:00:00 2001 From: sladro Date: Wed, 1 Apr 2026 21:37:08 +0800 Subject: [PATCH] Refine terminal input and session replay behavior --- .gitignore | 47 +- .../res/drawable-v21/launch_background.xml | 2 +- .../main/res/drawable/launch_background.xml | 2 +- .../app/src/main/res/values-night/styles.xml | 6 +- .../app/src/main/res/values/styles.xml | 6 +- .../Runner/Base.lproj/LaunchScreen.storyboard | 2 +- .../ios/Runner/Base.lproj/Main.storyboard | 13 +- .../network/agent_connection_providers.dart | 2 +- .../features/projects/project_list_page.dart | 4 +- .../features/sessions/session_list_page.dart | 4 +- .../terminal/terminal_input_controller.dart | 227 ++++++++++ .../lib/features/terminal/terminal_page.dart | 409 +++++++++++------- apps/mobile_app/pubspec.lock | 186 ++++---- .../agent_connection_providers_test.dart | 31 +- .../terminal_input_controller_test.dart | 67 +++ apps/mobile_app/test/project_home_test.dart | 2 +- apps/mobile_app/test/widget_test.dart | 310 ++++++++++++- .../History/TerminalReplayBuffer.cs | 60 +++ .../Realtime/TerminalWebSocketHandler.cs | 5 + .../Sessions/SessionRegistry.cs | 23 + .../Realtime/TerminalWebSocketHandlerTests.cs | 24 + .../Sessions/SessionRegistryTests.cs | 15 + 22 files changed, 1144 insertions(+), 303 deletions(-) create mode 100644 apps/mobile_app/lib/features/terminal/terminal_input_controller.dart create mode 100644 apps/mobile_app/test/features/terminal/terminal_input_controller_test.dart create mode 100644 apps/windows_agent/src/TermRemoteCtl.Agent/History/TerminalReplayBuffer.cs diff --git a/.gitignore b/.gitignore index 6c7de53..3a48717 100644 --- a/.gitignore +++ b/.gitignore @@ -1,29 +1,56 @@ -# Flutter +# OS +.DS_Store +Thumbs.db + +# Editors +.idea/ +.vscode/ +*.swp +*.swo + +# Flutter / Dart .dart_tool/ .flutter-plugins .flutter-plugins-dependencies .packages -build/ +.pub/ +**/build/ -# iOS +# Flutter generated platform files +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/flutter_export_environment.sh +**/ios/Flutter/ephemeral/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/flutter_assets/ +**/ios/Runner/GeneratedPluginRegistrant.* + +# iOS / Xcode local artifacts **/ios/Pods/ **/ios/.symlinks/ +**/ios/DerivedData/ +**/ios/xcuserdata/ +**/*.pbxuser +**/*.mode1v3 +**/*.mode2v3 +**/*.perspectivev3 +**/*.moved-aside +**/*.xcuserstate -# Android +# Android local artifacts **/android/.gradle/ **/android/local.properties -# .NET +# .NET build outputs **/bin/ **/obj/ .vs/ -# Local security artifacts +# Local project artifacts data/ certs/ - -# Brainstorm artifacts .superpowers/ - -# Local worktrees .worktrees/ + +# Local notes +docs/ios-build.md diff --git a/apps/mobile_app/android/app/src/main/res/drawable-v21/launch_background.xml b/apps/mobile_app/android/app/src/main/res/drawable-v21/launch_background.xml index f74085f..d3f4435 100644 --- a/apps/mobile_app/android/app/src/main/res/drawable-v21/launch_background.xml +++ b/apps/mobile_app/android/app/src/main/res/drawable-v21/launch_background.xml @@ -1,7 +1,7 @@ - + - + diff --git a/apps/mobile_app/android/app/src/main/res/values/styles.xml b/apps/mobile_app/android/app/src/main/res/values/styles.xml index cb1ef88..9a982bb 100644 --- a/apps/mobile_app/android/app/src/main/res/values/styles.xml +++ b/apps/mobile_app/android/app/src/main/res/values/styles.xml @@ -1,10 +1,13 @@ + #05070A + diff --git a/apps/mobile_app/ios/Runner/Base.lproj/LaunchScreen.storyboard b/apps/mobile_app/ios/Runner/Base.lproj/LaunchScreen.storyboard index f2e259c..ca8ba6d 100644 --- a/apps/mobile_app/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ b/apps/mobile_app/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -19,7 +19,7 @@ - + diff --git a/apps/mobile_app/ios/Runner/Base.lproj/Main.storyboard b/apps/mobile_app/ios/Runner/Base.lproj/Main.storyboard index f3c2851..1948c57 100644 --- a/apps/mobile_app/ios/Runner/Base.lproj/Main.storyboard +++ b/apps/mobile_app/ios/Runner/Base.lproj/Main.storyboard @@ -1,8 +1,10 @@ - - + + + - + + @@ -14,13 +16,14 @@ - + - + + diff --git a/apps/mobile_app/lib/core/network/agent_connection_providers.dart b/apps/mobile_app/lib/core/network/agent_connection_providers.dart index 6355217..abe858e 100644 --- a/apps/mobile_app/lib/core/network/agent_connection_providers.dart +++ b/apps/mobile_app/lib/core/network/agent_connection_providers.dart @@ -7,7 +7,7 @@ import '../../features/sessions/session_repository.dart'; import '../../features/sessions/session.dart'; final agentBaseUriProvider = StateProvider((ref) { - return Uri.parse('http://10.0.2.2:5067'); + return Uri.parse('http://100.81.30.82:5067'); }); final agentApiClientProvider = Provider((ref) { diff --git a/apps/mobile_app/lib/features/projects/project_list_page.dart b/apps/mobile_app/lib/features/projects/project_list_page.dart index 8814d97..156f50a 100644 --- a/apps/mobile_app/lib/features/projects/project_list_page.dart +++ b/apps/mobile_app/lib/features/projects/project_list_page.dart @@ -411,7 +411,7 @@ class _ProjectListPageState extends ConsumerState { TextField( controller: _agentUrlController, decoration: const InputDecoration( - hintText: 'http://10.0.2.2:5067', + hintText: 'http://100.81.30.82:5067', border: OutlineInputBorder(), ), keyboardType: TextInputType.url, @@ -433,7 +433,7 @@ class _ProjectListPageState extends ConsumerState { child: TextField( controller: _agentUrlController, decoration: const InputDecoration( - hintText: 'http://10.0.2.2:5067', + hintText: 'http://100.81.30.82:5067', border: OutlineInputBorder(), ), keyboardType: TextInputType.url, diff --git a/apps/mobile_app/lib/features/sessions/session_list_page.dart b/apps/mobile_app/lib/features/sessions/session_list_page.dart index f996ec5..65df7f8 100644 --- a/apps/mobile_app/lib/features/sessions/session_list_page.dart +++ b/apps/mobile_app/lib/features/sessions/session_list_page.dart @@ -202,7 +202,7 @@ class _SessionListPageState extends ConsumerState { TextField( controller: _agentUrlController, decoration: const InputDecoration( - hintText: 'http://10.0.2.2:5067', + hintText: 'http://100.81.30.82:5067', border: OutlineInputBorder(), ), keyboardType: TextInputType.url, @@ -224,7 +224,7 @@ class _SessionListPageState extends ConsumerState { child: TextField( controller: _agentUrlController, decoration: const InputDecoration( - hintText: 'http://10.0.2.2:5067', + hintText: 'http://100.81.30.82:5067', border: OutlineInputBorder(), ), keyboardType: TextInputType.url, diff --git a/apps/mobile_app/lib/features/terminal/terminal_input_controller.dart b/apps/mobile_app/lib/features/terminal/terminal_input_controller.dart new file mode 100644 index 0000000..67b326f --- /dev/null +++ b/apps/mobile_app/lib/features/terminal/terminal_input_controller.dart @@ -0,0 +1,227 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +enum TerminalInputMode { buffered, direct } + +typedef TerminalBufferedSubmit = FutureOr 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 + ? 'Keyboard sends straight to terminal' + : 'Send command or input'; + + Future 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), + ); +} diff --git a/apps/mobile_app/lib/features/terminal/terminal_page.dart b/apps/mobile_app/lib/features/terminal/terminal_page.dart index 333d84f..f473f87 100644 --- a/apps/mobile_app/lib/features/terminal/terminal_page.dart +++ b/apps/mobile_app/lib/features/terminal/terminal_page.dart @@ -4,14 +4,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:xterm/xterm.dart'; -import '../../app/app_theme.dart'; import '../../app/ui_shell.dart'; import '../../core/network/agent_connection_providers.dart'; import '../../core/network/agent_error_formatter.dart'; import '../projects/project.dart'; import '../sessions/session.dart'; import 'terminal_diagnostic_log.dart'; -import 'history_window.dart'; +import 'terminal_input_controller.dart'; import 'terminal_interaction_controller.dart'; import 'terminal_session_coordinator.dart'; import 'terminal_socket_session.dart'; @@ -49,18 +48,15 @@ class _TerminalPageState extends ConsumerState { final TerminalInteractionController controller = TerminalInteractionController(); final TerminalDiagnosticLog _diagnosticLog = TerminalDiagnosticLog(); - final FocusNode _terminalFocusNode = FocusNode(); - final FocusNode _inputFocusNode = FocusNode(); - final TextEditingController _inputController = TextEditingController(); + final FocusNode _terminalFocusNode = FocusNode(canRequestFocus: false); final ScrollController _terminalScrollController = ScrollController(); late final TerminalSessionCoordinator _coordinator; - late final Listenable _controllerAndCoordinator; - bool _isDirectInputEnabled = false; + late final TerminalInputController _inputController; + late final Listenable _pageStateListenable; @override void initState() { super.initState(); - _terminalFocusNode.canRequestFocus = false; _coordinator = TerminalSessionCoordinator( controller: controller, apiClient: ref.read(agentApiClientProvider), @@ -69,23 +65,27 @@ class _TerminalPageState extends ConsumerState { baseUri: widget.agentBaseUri, diagnosticLog: _diagnosticLog, onFrame: terminal.write, - onHistoryLoaded: (history) { - if (history.lines.isNotEmpty) { - terminal.write('${history.lines.join('\r\n')}\r\n'); - } - }, viewportProvider: () => TerminalViewport( columns: terminal.viewWidth, rows: terminal.viewHeight, ), ); - _controllerAndCoordinator = Listenable.merge([controller, _coordinator]); + _inputController = TerminalInputController( + onBufferedSubmit: (command) => _sendLine(command), + onDirectInput: _sendDirectInput, + ); + _pageStateListenable = Listenable.merge([ + controller, + _coordinator, + _inputController, + ]); terminal.onResize = (width, height, _, _) { _coordinator.handleTerminalResize(width, height); }; terminal.onOutput = (data) { - _diagnosticLog.add('ui.terminal.key', data); - _coordinator.sendInput(data); + final normalizedData = _normalizeTerminalDirectInput(data); + _diagnosticLog.add('ui.terminal.key', normalizedData); + _coordinator.sendInput(normalizedData); }; unawaited(_coordinator.start()); } @@ -93,7 +93,6 @@ class _TerminalPageState extends ConsumerState { @override void dispose() { _terminalFocusNode.dispose(); - _inputFocusNode.dispose(); _inputController.dispose(); _terminalScrollController.dispose(); unawaited(_coordinator.close()); @@ -153,8 +152,7 @@ class _TerminalPageState extends ConsumerState { ); } - Future _sendLine() async { - final input = _inputController.text; + Future _sendLine(String input) async { if (!_canSendInput || input.trim().isEmpty) { return; } @@ -164,7 +162,14 @@ class _TerminalPageState extends ConsumerState { diagnosticEvent: 'ui.input.send', detail: input, ); - _inputController.clear(); + } + + Future _submitInput() async { + if (!_canSendInput) { + return; + } + + await _inputController.submit(); } void _sendQuickKey(_QuickTerminalKey quickKey) { @@ -182,28 +187,44 @@ class _TerminalPageState extends ConsumerState { }) { _diagnosticLog.add(diagnosticEvent, detail); _coordinator.sendInput(input); - if (_isDirectInputEnabled) { - _terminalFocusNode.requestFocus(); - } else { - _inputFocusNode.requestFocus(); + } + + void _sendDirectInput(String input) { + _sendTerminalInput( + input, + diagnosticEvent: 'ui.input.direct', + detail: _describeTerminalInput(input), + ); + } + + String _normalizeTerminalDirectInput(String data) { + if (!_inputController.isDirectInputEnabled || data.isEmpty) { + return data; } + + return data.replaceAll('\r\n', '\r').replaceAll('\n', '\r'); } void _toggleDirectInput() { - final enabled = !_isDirectInputEnabled; - setState(() { - _isDirectInputEnabled = enabled; - _terminalFocusNode.canRequestFocus = enabled; - }); - - if (enabled) { - _terminalFocusNode.requestFocus(); - } else { - _terminalFocusNode.unfocus(); - _inputFocusNode.requestFocus(); + setState(_inputController.toggleMode); + if (_canSendInput) { + _inputController.requestFocus(); } } + void _handleTerminalSurfaceTap() { + if (!_canSendInput || !_inputController.isDirectInputEnabled) { + return; + } + + _inputController.requestFocus(); + _inputController.syncDirectSelection(); + } + + String _describeTerminalInput(String input) { + return input.replaceAll('\r', r'\r').replaceAll('\n', r'\n'); + } + Future _showDiagnostics() { return showModalBottomSheet( context: context, @@ -274,7 +295,7 @@ class _TerminalPageState extends ConsumerState { builder: (context) { return SafeArea( child: AnimatedBuilder( - animation: _controllerAndCoordinator, + animation: _pageStateListenable, builder: (context, _) { return SingleChildScrollView( child: Padding( @@ -435,35 +456,41 @@ class _TerminalPageState extends ConsumerState { ), body: Padding( padding: const EdgeInsets.fromLTRB(16, 6, 16, 10), - child: Column( - children: [ - _buildScrollbackSection(context, isCompact), - Expanded( - child: Container( - key: const Key('terminal_surface_panel'), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Theme.of(context).colorScheme.surfaceContainerHighest, - const Color(0xFF090B0E), - ], + child: TextFieldTapRegion( + child: Column( + children: [ + _buildScrollbackSection(context, isCompact), + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: _handleTerminalSurfaceTap, + child: Container( + key: const Key('terminal_surface_panel'), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Theme.of(context).colorScheme.surfaceContainerHighest, + const Color(0xFF090B0E), + ], + ), + borderRadius: BorderRadius.zero, + border: Border.all(color: const Color(0xFF332B22)), + ), + child: TerminalView( + terminal, + focusNode: _terminalFocusNode, + autofocus: false, + scrollController: _terminalScrollController, + ), ), - borderRadius: BorderRadius.zero, - border: Border.all(color: const Color(0xFF332B22)), - ), - child: TerminalView( - terminal, - focusNode: _terminalFocusNode, - autofocus: false, - scrollController: _terminalScrollController, ), ), - ), - const SizedBox(height: 6), - _buildCommandDeck(context, isCompact), - ], + const SizedBox(height: 6), + _buildCommandDeck(context, isCompact), + ], + ), ), ), ); @@ -471,7 +498,7 @@ class _TerminalPageState extends ConsumerState { Widget _buildScrollbackSection(BuildContext context, bool isCompact) { return AnimatedBuilder( - animation: _controllerAndCoordinator, + animation: _pageStateListenable, builder: (context, _) { if (controller.isFollowingLiveOutput) { return const SizedBox.shrink(); @@ -695,7 +722,7 @@ class _TerminalPageState extends ConsumerState { padding: const EdgeInsets.fromLTRB(8, 6, 8, 6), borderRadius: BorderRadius.zero, child: AnimatedBuilder( - animation: _controllerAndCoordinator, + animation: _pageStateListenable, builder: (context, _) { return Column( key: const Key('terminal_action_bar'), @@ -716,23 +743,27 @@ class _TerminalPageState extends ConsumerState { .map((quickKey) { return Padding( padding: const EdgeInsets.only(right: 8), - child: OutlinedButton( - key: Key('terminal_quick_key_${quickKey.keyId}'), - onPressed: _canSendInput - ? () => _sendQuickKey(quickKey) - : null, - style: OutlinedButton.styleFrom( - foregroundColor: const Color(0xFFE8DED1), - backgroundColor: const Color(0xFF151A20), - side: const BorderSide(color: Color(0xFF3F3428)), - minimumSize: const Size(0, 34), - padding: const EdgeInsets.symmetric( - horizontal: 10, + child: _buildCommandDeckAction( + OutlinedButton( + key: Key('terminal_quick_key_${quickKey.keyId}'), + onPressed: _canSendInput + ? () => _sendQuickKey(quickKey) + : null, + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFFE8DED1), + backgroundColor: const Color(0xFF151A20), + side: const BorderSide( + color: Color(0xFF3F3428), + ), + minimumSize: const Size(0, 34), + padding: const EdgeInsets.symmetric( + horizontal: 10, + ), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, ), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.compact, + child: Text(quickKey.label), ), - child: Text(quickKey.label), ), ); }) @@ -744,59 +775,63 @@ class _TerminalPageState extends ConsumerState { Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - TextField( - controller: _inputController, - focusNode: _inputFocusNode, - enabled: _canSendInput, - decoration: const InputDecoration( - hintText: 'Send command or input', - ), - onSubmitted: (_) => _sendLine(), - ), + _buildInputField(context), const SizedBox(height: 6), Row( children: [ Expanded( - child: FilledButton( - key: const Key('terminal_send_button'), - onPressed: _canSendInput ? _sendLine : null, - style: FilledButton.styleFrom( - minimumSize: const Size(0, 38), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.compact, + child: _buildCommandDeckAction( + FilledButton( + key: const Key('terminal_send_button'), + onPressed: _canSendInput ? _submitInput : null, + style: FilledButton.styleFrom( + minimumSize: const Size(0, 38), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + child: Text( + _inputController.isDirectInputEnabled + ? 'Enter' + : 'Send', + ), ), - child: const Text('Send'), ), ), const SizedBox(width: 8), - IconButton.filledTonal( - key: const Key('terminal_direct_input_toggle'), - onPressed: _canSendInput ? _toggleDirectInput : null, - visualDensity: VisualDensity.compact, - style: IconButton.styleFrom( - backgroundColor: const Color(0xFF241F1A), - foregroundColor: const Color(0xFFD7C4A0), + _buildCommandDeckAction( + IconButton.filledTonal( + key: const Key('terminal_direct_input_toggle'), + onPressed: _canSendInput + ? _toggleDirectInput + : null, + visualDensity: VisualDensity.compact, + style: IconButton.styleFrom( + backgroundColor: const Color(0xFF241F1A), + foregroundColor: const Color(0xFFD7C4A0), + ), + icon: Icon( + _inputController.isDirectInputEnabled + ? Icons.keyboard_hide + : Icons.keyboard, + ), + tooltip: _inputController.isDirectInputEnabled + ? 'Disable direct input' + : 'Enable direct input', ), - icon: Icon( - _isDirectInputEnabled - ? Icons.keyboard_hide - : Icons.keyboard, - ), - tooltip: _isDirectInputEnabled - ? 'Disable direct input' - : 'Enable direct input', ), const SizedBox(width: 8), - IconButton.filledTonal( - key: const Key('terminal_toggle_actions_button'), - onPressed: _showToolsSheet, - visualDensity: VisualDensity.compact, - style: IconButton.styleFrom( - backgroundColor: const Color(0xFF241F1A), - foregroundColor: const Color(0xFFD7C4A0), + _buildCommandDeckAction( + IconButton.filledTonal( + key: const Key('terminal_toggle_actions_button'), + onPressed: _showToolsSheet, + visualDensity: VisualDensity.compact, + style: IconButton.styleFrom( + backgroundColor: const Color(0xFF241F1A), + foregroundColor: const Color(0xFFD7C4A0), + ), + icon: const Icon(Icons.tune), + tooltip: 'Show tools', ), - icon: const Icon(Icons.tune), - tooltip: 'Show tools', ), ], ), @@ -805,66 +840,78 @@ class _TerminalPageState extends ConsumerState { else Row( children: [ - Expanded( - child: TextField( - controller: _inputController, - focusNode: _inputFocusNode, - enabled: _canSendInput, - decoration: const InputDecoration( - hintText: 'Send command or input', + Expanded(child: _buildInputField(context)), + const SizedBox(width: 8), + _buildCommandDeckAction( + FilledButton( + key: const Key('terminal_send_button'), + onPressed: _canSendInput ? _submitInput : null, + style: FilledButton.styleFrom( + minimumSize: const Size(0, 38), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + child: Text( + _inputController.isDirectInputEnabled + ? 'Enter' + : 'Send', ), - onSubmitted: (_) => _sendLine(), ), ), const SizedBox(width: 8), - FilledButton( - key: const Key('terminal_send_button'), - onPressed: _canSendInput ? _sendLine : null, - style: FilledButton.styleFrom( - minimumSize: const Size(0, 38), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, + _buildCommandDeckAction( + IconButton.filledTonal( + key: const Key('terminal_direct_input_toggle'), + onPressed: _canSendInput ? _toggleDirectInput : null, visualDensity: VisualDensity.compact, + style: IconButton.styleFrom( + backgroundColor: const Color(0xFF241F1A), + foregroundColor: const Color(0xFFD7C4A0), + ), + icon: Icon( + _inputController.isDirectInputEnabled + ? Icons.keyboard_hide + : Icons.keyboard, + ), + tooltip: _inputController.isDirectInputEnabled + ? 'Disable direct input' + : 'Enable direct input', ), - child: const Text('Send'), ), const SizedBox(width: 8), - IconButton.filledTonal( - key: const Key('terminal_direct_input_toggle'), - onPressed: _canSendInput ? _toggleDirectInput : null, - visualDensity: VisualDensity.compact, - style: IconButton.styleFrom( - backgroundColor: const Color(0xFF241F1A), - foregroundColor: const Color(0xFFD7C4A0), + _buildCommandDeckAction( + IconButton.filledTonal( + key: const Key('terminal_toggle_actions_button'), + onPressed: _showToolsSheet, + visualDensity: VisualDensity.compact, + style: IconButton.styleFrom( + backgroundColor: const Color(0xFF241F1A), + foregroundColor: const Color(0xFFD7C4A0), + ), + icon: const Icon(Icons.tune), + tooltip: 'Show tools', ), - icon: Icon( - _isDirectInputEnabled - ? Icons.keyboard_hide - : Icons.keyboard, - ), - tooltip: _isDirectInputEnabled - ? 'Disable direct input' - : 'Enable direct input', - ), - const SizedBox(width: 8), - IconButton.filledTonal( - key: const Key('terminal_toggle_actions_button'), - onPressed: _showToolsSheet, - visualDensity: VisualDensity.compact, - style: IconButton.styleFrom( - backgroundColor: const Color(0xFF241F1A), - foregroundColor: const Color(0xFFD7C4A0), - ), - icon: const Icon(Icons.tune), - tooltip: 'Show tools', ), ], ), const SizedBox(height: 4), Text( - _isDirectInputEnabled ? 'Direct input enabled' : 'Browse mode', + _inputController.isDirectInputEnabled + ? 'Direct input enabled' + : 'Browse mode', key: const Key('terminal_input_mode_label'), style: Theme.of(context).textTheme.bodySmall, ), + if (_inputController.isDirectInputEnabled && + _inputController.composingText != null) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + 'Composing: ${_inputController.composingText}', + key: const Key('terminal_direct_input_composing_label'), + style: Theme.of(context).textTheme.bodySmall, + ), + ), ], ); }, @@ -872,6 +919,42 @@ class _TerminalPageState extends ConsumerState { ); } + Widget _buildInputField(BuildContext context) { + final isDirectInputEnabled = _inputController.isDirectInputEnabled; + final baseStyle = Theme.of(context).textTheme.bodyMedium; + + return TextField( + controller: _inputController.textController, + focusNode: _inputController.focusNode, + enabled: _canSendInput, + keyboardType: TextInputType.text, + textInputAction: TextInputAction.send, + autocorrect: false, + enableSuggestions: false, + enableIMEPersonalizedLearning: false, + smartDashesType: SmartDashesType.disabled, + smartQuotesType: SmartQuotesType.disabled, + textCapitalization: TextCapitalization.none, + enableInteractiveSelection: !isDirectInputEnabled, + showCursor: !isDirectInputEnabled, + selectAllOnFocus: false, + style: isDirectInputEnabled + ? baseStyle?.copyWith(color: Colors.transparent) + : baseStyle, + cursorColor: isDirectInputEnabled + ? Colors.transparent + : Theme.of(context).colorScheme.primary, + decoration: InputDecoration(hintText: _inputController.hintText), + onTap: _inputController.syncDirectSelection, + onEditingComplete: () {}, + onSubmitted: (_) => _submitInput(), + ); + } + + Widget _buildCommandDeckAction(Widget child) { + return ExcludeFocus(child: child); + } + String get _statusLabel => switch (_connectionState) { TerminalConnectionState.connecting => 'Connecting', TerminalConnectionState.connected => 'Connected', diff --git a/apps/mobile_app/pubspec.lock b/apps/mobile_app/pubspec.lock index ad38974..6c1530c 100644 --- a/apps/mobile_app/pubspec.lock +++ b/apps/mobile_app/pubspec.lock @@ -6,7 +6,7 @@ packages: description: name: _fe_analyzer_shared sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "85.0.0" analyzer: @@ -14,7 +14,7 @@ packages: description: name: analyzer sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "7.7.1" args: @@ -22,7 +22,7 @@ packages: description: name: args sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.7.0" async: @@ -30,7 +30,7 @@ packages: description: name: async sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.13.1" boolean_selector: @@ -38,7 +38,7 @@ packages: description: name: boolean_selector sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.2" build: @@ -46,7 +46,7 @@ packages: description: name: build sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.5.4" build_config: @@ -54,7 +54,7 @@ packages: description: name: build_config sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.1.2" build_daemon: @@ -62,7 +62,7 @@ packages: description: name: build_daemon sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.1.1" build_resolvers: @@ -70,7 +70,7 @@ packages: description: name: build_resolvers sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.5.4" build_runner: @@ -78,7 +78,7 @@ packages: description: name: build_runner sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.5.4" build_runner_core: @@ -86,7 +86,7 @@ packages: description: name: build_runner_core sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "9.1.2" built_collection: @@ -94,7 +94,7 @@ packages: description: name: built_collection sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "5.1.1" built_value: @@ -102,23 +102,23 @@ packages: description: name: built_value sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "8.12.5" characters: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 - url: "https://pub.flutter-io.cn" + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: name: checked_yaml sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.0.4" clock: @@ -126,7 +126,7 @@ packages: description: name: clock sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.1.2" code_builder: @@ -134,7 +134,7 @@ packages: description: name: code_builder sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.11.1" collection: @@ -142,7 +142,7 @@ packages: description: name: collection sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.19.1" convert: @@ -150,7 +150,7 @@ packages: description: name: convert sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.1.2" crypto: @@ -158,7 +158,7 @@ packages: description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.0.7" cupertino_icons: @@ -166,7 +166,7 @@ packages: description: name: cupertino_icons sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.0.9" dart_style: @@ -174,7 +174,7 @@ packages: description: name: dart_style sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.1.1" dio: @@ -182,7 +182,7 @@ packages: description: name: dio sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "5.9.2" dio_web_adapter: @@ -190,7 +190,7 @@ packages: description: name: dio_web_adapter sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.2" equatable: @@ -198,7 +198,7 @@ packages: description: name: equatable sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.0.8" fake_async: @@ -206,7 +206,7 @@ packages: description: name: fake_async sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.3.3" file: @@ -214,7 +214,7 @@ packages: description: name: file sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "7.0.1" fixnum: @@ -222,7 +222,7 @@ packages: description: name: fixnum sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.1.1" flutter: @@ -235,7 +235,7 @@ packages: description: name: flutter_lints sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "5.0.0" flutter_riverpod: @@ -243,7 +243,7 @@ packages: description: name: flutter_riverpod sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.6.1" flutter_test: @@ -261,7 +261,7 @@ packages: description: name: freezed sha256: "59a584c24b3acdc5250bb856d0d3e9c0b798ed14a4af1ddb7dc1c7b41df91c9c" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.5.8" freezed_annotation: @@ -269,7 +269,7 @@ packages: description: name: freezed_annotation sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.4.4" frontend_server_client: @@ -277,7 +277,7 @@ packages: description: name: frontend_server_client sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.0.0" glob: @@ -285,7 +285,7 @@ packages: description: name: glob sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.3" go_router: @@ -293,7 +293,7 @@ packages: description: name: go_router sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "14.8.1" graphs: @@ -301,7 +301,7 @@ packages: description: name: graphs sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.3.2" http: @@ -309,7 +309,7 @@ packages: description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.6.0" http_multi_server: @@ -317,7 +317,7 @@ packages: description: name: http_multi_server sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.2.2" http_parser: @@ -325,7 +325,7 @@ packages: description: name: http_parser sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.1.2" io: @@ -333,7 +333,7 @@ packages: description: name: io sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.0.5" js: @@ -341,7 +341,7 @@ packages: description: name: js sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.7.2" json_annotation: @@ -349,7 +349,7 @@ packages: description: name: json_annotation sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.9.0" json_serializable: @@ -357,7 +357,7 @@ packages: description: name: json_serializable sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "6.9.5" leak_tracker: @@ -365,7 +365,7 @@ packages: description: name: leak_tracker sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "11.0.2" leak_tracker_flutter_testing: @@ -373,7 +373,7 @@ packages: description: name: leak_tracker_flutter_testing sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.0.10" leak_tracker_testing: @@ -381,7 +381,7 @@ packages: description: name: leak_tracker_testing sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.0.2" lints: @@ -389,7 +389,7 @@ packages: description: name: lints sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "5.1.1" logging: @@ -397,39 +397,39 @@ packages: description: name: logging sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.3.0" matcher: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 - url: "https://pub.flutter-io.cn" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec - url: "https://pub.flutter-io.cn" + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c - url: "https://pub.flutter-io.cn" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: name: mime sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.0.0" mocktail: @@ -437,7 +437,7 @@ packages: description: name: mocktail sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.0.4" package_config: @@ -445,7 +445,7 @@ packages: description: name: package_config sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.2.0" path: @@ -453,7 +453,7 @@ packages: description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.9.1" pool: @@ -461,7 +461,7 @@ packages: description: name: pool sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.5.2" pub_semver: @@ -469,7 +469,7 @@ packages: description: name: pub_semver sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.2.0" pubspec_parse: @@ -477,7 +477,7 @@ packages: description: name: pubspec_parse sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.5.0" quiver: @@ -485,7 +485,7 @@ packages: description: name: quiver sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.2.2" riverpod: @@ -493,7 +493,7 @@ packages: description: name: riverpod sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.6.1" shelf: @@ -501,7 +501,7 @@ packages: description: name: shelf sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.4.2" shelf_web_socket: @@ -509,7 +509,7 @@ packages: description: name: shelf_web_socket sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.0.0" sky_engine: @@ -522,7 +522,7 @@ packages: description: name: source_gen sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.0.0" source_helper: @@ -530,7 +530,7 @@ packages: description: name: source_helper sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.3.7" source_span: @@ -538,7 +538,7 @@ packages: description: name: source_span sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.10.2" stack_trace: @@ -546,7 +546,7 @@ packages: description: name: stack_trace sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.12.1" state_notifier: @@ -554,7 +554,7 @@ packages: description: name: state_notifier sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.0.0" stream_channel: @@ -562,7 +562,7 @@ packages: description: name: stream_channel sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.4" stream_transform: @@ -570,7 +570,7 @@ packages: description: name: stream_transform sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.1" string_scanner: @@ -578,7 +578,7 @@ packages: description: name: string_scanner sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.4.1" term_glyph: @@ -586,23 +586,23 @@ packages: description: name: term_glyph sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.2.2" test_api: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" - url: "https://pub.flutter-io.cn" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.10" timing: dependency: transitive description: name: timing sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.0.2" typed_data: @@ -610,7 +610,7 @@ packages: description: name: typed_data sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.4.0" vector_math: @@ -618,7 +618,7 @@ packages: description: name: vector_math sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.2.0" vm_service: @@ -626,7 +626,7 @@ packages: description: name: vm_service sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "15.0.2" watcher: @@ -634,7 +634,7 @@ packages: description: name: watcher sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.2.1" web: @@ -642,7 +642,7 @@ packages: description: name: web sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.1.1" web_socket: @@ -650,7 +650,7 @@ packages: description: name: web_socket sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.0.1" web_socket_channel: @@ -658,7 +658,7 @@ packages: description: name: web_socket_channel sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.0.3" xterm: @@ -666,7 +666,7 @@ packages: description: name: xterm sha256: "168dfedca77cba33fdb6f52e2cd001e9fde216e398e89335c19b524bb22da3a2" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.0.0" yaml: @@ -674,7 +674,7 @@ packages: description: name: yaml sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.1.3" zmodem: @@ -682,7 +682,7 @@ packages: description: name: zmodem sha256: "3b7e5b29f3a7d8aee472029b05165a68438eff2f3f7766edf13daba1e297adbf" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.0.6" sdks: diff --git a/apps/mobile_app/test/core/network/agent_connection_providers_test.dart b/apps/mobile_app/test/core/network/agent_connection_providers_test.dart index 4f4fbe2..20277f8 100644 --- a/apps/mobile_app/test/core/network/agent_connection_providers_test.dart +++ b/apps/mobile_app/test/core/network/agent_connection_providers_test.dart @@ -4,13 +4,13 @@ import 'package:term_remote_ctl/core/network/agent_api_client.dart'; import 'package:term_remote_ctl/core/network/agent_connection_providers.dart'; void main() { - test('uses the emulator-local http agent base uri by default', () { + test('uses the configured default http agent base uri', () { final container = ProviderContainer(); addTearDown(container.dispose); final baseUri = container.read(agentBaseUriProvider); - expect(baseUri.toString(), 'http://10.0.2.2:5067'); + expect(baseUri.toString(), 'http://100.81.30.82:5067'); }); test('builds the agent client from the configured base uri', () { @@ -28,20 +28,23 @@ void main() { expect(client.baseUri.toString(), 'https://host.example:9443'); }); - test('session repository provider depends on the configured agent client', () async { - final client = _FakeAgentApiClient(Uri.parse('https://host.example:9443')); - final container = ProviderContainer( - overrides: [ - agentApiClientProvider.overrideWith((ref) => client), - ], - ); - addTearDown(container.dispose); + test( + 'session repository provider depends on the configured agent client', + () async { + final client = _FakeAgentApiClient( + Uri.parse('https://host.example:9443'), + ); + final container = ProviderContainer( + overrides: [agentApiClientProvider.overrideWith((ref) => client)], + ); + addTearDown(container.dispose); - final repository = container.read(sessionRepositoryProvider); - await repository.listSessions(); + final repository = container.read(sessionRepositoryProvider); + await repository.listSessions(); - expect(client.listCalls, 1); - }); + expect(client.listCalls, 1); + }, + ); } class _FakeAgentApiClient extends AgentApiClient { diff --git a/apps/mobile_app/test/features/terminal/terminal_input_controller_test.dart b/apps/mobile_app/test/features/terminal/terminal_input_controller_test.dart new file mode 100644 index 0000000..e6d6667 --- /dev/null +++ b/apps/mobile_app/test/features/terminal/terminal_input_controller_test.dart @@ -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 = []; + final bufferedCommands = []; + 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); + }, + ); +} diff --git a/apps/mobile_app/test/project_home_test.dart b/apps/mobile_app/test/project_home_test.dart index 14c5fdb..2e240de 100644 --- a/apps/mobile_app/test/project_home_test.dart +++ b/apps/mobile_app/test/project_home_test.dart @@ -106,7 +106,7 @@ class _FakeSessionRepository extends SessionRepository { } class _FakeAgentApiClient extends AgentApiClient { - _FakeAgentApiClient() : super(Uri.parse('http://10.0.2.2:5067')); + _FakeAgentApiClient() : super(Uri.parse('http://100.81.30.82:5067')); } class _FakeTerminalSocketTransport implements TerminalSocketTransport { diff --git a/apps/mobile_app/test/widget_test.dart b/apps/mobile_app/test/widget_test.dart index 4a765e7..c65db0d 100644 --- a/apps/mobile_app/test/widget_test.dart +++ b/apps/mobile_app/test/widget_test.dart @@ -13,6 +13,7 @@ import 'package:term_remote_ctl/features/projects/project_repository.dart'; 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_socket_session.dart'; import 'package:xterm/xterm.dart'; @@ -36,11 +37,13 @@ void main() { expect(materialApp.theme?.colorScheme.primary, const Color(0xFFC2A574)); expect(find.byKey(const Key('project_page_header')), findsOneWidget); expect(find.text('Agent base URL'), findsOneWidget); - expect(agentUrlField.controller?.text, 'http://10.0.2.2:5067'); - expect(agentUrlField.decoration?.hintText, 'http://10.0.2.2:5067'); + expect(agentUrlField.controller?.text, 'http://100.81.30.82:5067'); + expect(agentUrlField.decoration?.hintText, 'http://100.81.30.82:5067'); expect(find.byKey(const Key('agent_connection_readout')), findsOneWidget); expect( - find.text('Project requests use this base origin: http://10.0.2.2:5067.'), + find.text( + 'Project requests use this base origin: http://100.81.30.82:5067.', + ), findsOneWidget, ); expect(find.text('codex-main'), findsOneWidget); @@ -354,6 +357,142 @@ void main() { }, ); + testWidgets('terminal send keeps the command input focused', (tester) async { + final transportFactory = _QueuedTerminalSocketTransportFactory(); + + await _pumpApp( + tester, + projectRepository: _FakeProjectRepository(), + sessionRepository: _FakeSessionRepository(), + socketFactory: TerminalSocketSessionFactory( + transportFactory: transportFactory.create, + ), + ); + + await _openProjectTerminal(tester); + + final commandField = find.byType(TextField).last; + final editableField = find.byType(EditableText).last; + + await tester.tap(commandField); + await tester.pumpAndSettle(); + expect( + tester.widget(editableField).focusNode.hasFocus, + isTrue, + ); + + await tester.enterText(commandField, 'dir'); + await tester.tap(find.byKey(const Key('terminal_send_button'))); + await tester.pumpAndSettle(); + + expect( + tester.widget(editableField).focusNode.hasFocus, + isTrue, + ); + expect(tester.widget(commandField).controller?.text, isEmpty); + }); + + testWidgets('terminal keyboard submit keeps the command input focused', ( + tester, + ) async { + final transportFactory = _QueuedTerminalSocketTransportFactory(); + + await _pumpApp( + tester, + projectRepository: _FakeProjectRepository(), + sessionRepository: _FakeSessionRepository(), + socketFactory: TerminalSocketSessionFactory( + transportFactory: transportFactory.create, + ), + ); + + await _openProjectTerminal(tester); + + final commandField = find.byType(TextField).last; + final editableField = find.byType(EditableText).last; + + await tester.tap(commandField); + await tester.pumpAndSettle(); + await tester.enterText(commandField, 'dir'); + await tester.testTextInput.receiveAction(TextInputAction.send); + await tester.pumpAndSettle(); + + expect( + tester.widget(editableField).focusNode.hasFocus, + isTrue, + ); + expect(tester.widget(commandField).controller?.text, isEmpty); + }); + + testWidgets('terminal done action keeps the command input focused', ( + tester, + ) async { + final transportFactory = _QueuedTerminalSocketTransportFactory(); + + await _pumpApp( + tester, + projectRepository: _FakeProjectRepository(), + sessionRepository: _FakeSessionRepository(), + socketFactory: TerminalSocketSessionFactory( + transportFactory: transportFactory.create, + ), + ); + + await _openProjectTerminal(tester); + + final commandField = find.byType(TextField).last; + final editableField = find.byType(EditableText).last; + + await tester.tap(commandField); + await tester.pumpAndSettle(); + await tester.enterText(commandField, 'dir'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect( + tester.widget(editableField).focusNode.hasFocus, + isTrue, + ); + expect(tester.widget(commandField).controller?.text, isEmpty); + }); + + testWidgets('terminal direct input keyboard action sends carriage return', ( + tester, + ) async { + final transportFactory = _QueuedTerminalSocketTransportFactory(); + + await _pumpApp( + tester, + projectRepository: _FakeProjectRepository(), + sessionRepository: _FakeSessionRepository(), + socketFactory: TerminalSocketSessionFactory( + transportFactory: transportFactory.create, + ), + ); + + await _openProjectTerminal(tester); + final commandField = find.byType(TextField).last; + final editableField = find.byType(EditableText).last; + + await tester.tap(find.byKey(const Key('terminal_direct_input_toggle'))); + await tester.pumpAndSettle(); + await tester.tap(commandField); + await tester.pumpAndSettle(); + await tester.enterText(commandField, 'pwd'); + await tester.pumpAndSettle(); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect( + transportFactory.createdTransports.single.sentMessages.last, + contains(r'"input":"\r"'), + ); + expect( + tester.widget(editableField).focusNode.hasFocus, + isTrue, + ); + }); + testWidgets('terminal page reconnects after the socket closes', ( tester, ) async { @@ -455,6 +594,114 @@ void main() { }, ); + testWidgets( + 'terminal attach replay keeps the cursor on the last restored line', + (tester) async { + final apiClient = _SequencedHistoryAgentApiClient( + responses: [ + { + 'sessionId': 'session-1', + 'lines': ['one', 'two'], + 'hasMoreAbove': true, + }, + { + 'sessionId': 'session-1', + 'lines': ['zero', 'one', 'two'], + 'hasMoreAbove': false, + }, + ], + ); + final transportFactory = _QueuedTerminalSocketTransportFactory( + startupFrames: const [ + '{"type":"attached","sessionId":"session-1"}', + 'one\r\ntwo', + ], + ); + + await _pumpApp( + tester, + projectRepository: _FakeProjectRepository(), + sessionRepository: _FakeSessionRepository(), + apiClient: apiClient, + socketFactory: TerminalSocketSessionFactory( + transportFactory: transportFactory.create, + ), + ); + + await _openProjectTerminal(tester); + + var terminal = tester + .widget(find.byType(TerminalView)) + .terminal; + + expect(terminal.buffer.cursorY, 1); + expect(terminal.buffer.cursorX, 3); + expect(terminal.buffer.getText(), contains('one\ntwo')); + + await tester.tap(find.textContaining('Live |')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Load older lines')); + await tester.tap(find.text('Load older lines')); + await tester.pumpAndSettle(); + + terminal = tester + .widget(find.byType(TerminalView)) + .terminal; + + expect(terminal.buffer.getText(), contains('one\ntwo')); + expect(terminal.buffer.getText(), isNot(contains('zero'))); + expect(terminal.buffer.cursorY, 1); + expect(terminal.buffer.cursorX, 3); + }, + ); + + testWidgets( + 're-entering an existing session restores the terminal cursor to the last line', + (tester) async { + final transportFactory = _QueuedTerminalSocketTransportFactory( + startupFrames: const [ + '{"type":"attached","sessionId":"session-1"}', + 'one\r\ntwo', + ], + ); + final session = _session('session-1', 'codex-main'); + + await _pumpTerminalPage( + tester, + session: session, + socketFactory: TerminalSocketSessionFactory( + transportFactory: transportFactory.create, + ), + ); + + var terminal = tester + .widget(find.byType(TerminalView)) + .terminal; + expect(terminal.buffer.getText(), contains('one\ntwo')); + expect(terminal.buffer.cursorY, 1); + expect(terminal.buffer.cursorX, 3); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + + await _pumpTerminalPage( + tester, + session: session, + socketFactory: TerminalSocketSessionFactory( + transportFactory: transportFactory.create, + ), + ); + + terminal = tester + .widget(find.byType(TerminalView)) + .terminal; + expect(transportFactory.createCount, 2); + expect(terminal.buffer.getText(), contains('one\ntwo')); + expect(terminal.buffer.cursorY, 1); + expect(terminal.buffer.cursorX, 3); + }, + ); + testWidgets('session list deletes a session after confirmation', ( tester, ) async { @@ -523,6 +770,37 @@ Future _pumpApp( await tester.pumpAndSettle(); } +Future _pumpTerminalPage( + WidgetTester tester, { + required Session session, + AgentApiClient? apiClient, + TerminalSocketSessionFactory? socketFactory, +}) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + agentApiClientProvider.overrideWithValue( + apiClient ?? _FakeAgentApiClient(), + ), + terminalSocketSessionFactoryProvider.overrideWithValue( + socketFactory ?? + TerminalSocketSessionFactory( + transportFactory: (_) => + _FakeTerminalSocketTransport(autoAttach: true), + ), + ), + ], + child: MaterialApp( + home: TerminalPage( + session: session, + agentBaseUri: Uri.parse('http://100.81.30.82:5067'), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + Future _openProjectTerminal(WidgetTester tester) async { await tester.tap(find.widgetWithText(FilledButton, 'Open terminal')); await tester.pumpAndSettle(); @@ -679,7 +957,7 @@ class _FailingSessionRepository extends SessionRepository { } class _FakeAgentApiClient extends AgentApiClient { - _FakeAgentApiClient() : super(Uri.parse('http://10.0.2.2:5067')); + _FakeAgentApiClient() : super(Uri.parse('http://100.81.30.82:5067')); @override Future> getSessionHistory( @@ -719,15 +997,25 @@ class _SequencedHistoryAgentApiClient extends _FakeAgentApiClient { } class _FakeTerminalSocketTransport implements TerminalSocketTransport { - _FakeTerminalSocketTransport({this.autoAttach = false}) { - if (autoAttach) { + _FakeTerminalSocketTransport({ + this.autoAttach = false, + this.startupFrames = const [], + }) { + if (autoAttach && startupFrames.isEmpty) { Future.microtask(() { emit('{"type":"attached","sessionId":"session-1"}'); }); + } else if (startupFrames.isNotEmpty) { + Future.microtask(() { + for (final frame in startupFrames) { + emit(frame); + } + }); } } final bool autoAttach; + final List startupFrames; final _incoming = StreamController.broadcast(); final sentMessages = []; @@ -750,11 +1038,19 @@ class _FakeTerminalSocketTransport implements TerminalSocketTransport { } class _QueuedTerminalSocketTransportFactory { + _QueuedTerminalSocketTransportFactory({ + this.startupFrames = const [], + }); + + final List startupFrames; final createdTransports = <_FakeTerminalSocketTransport>[]; int createCount = 0; TerminalSocketTransport create(Uri _) { - final transport = _FakeTerminalSocketTransport(autoAttach: true); + final transport = _FakeTerminalSocketTransport( + autoAttach: startupFrames.isEmpty, + startupFrames: startupFrames, + ); createdTransports.add(transport); createCount += 1; return transport; diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/History/TerminalReplayBuffer.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/History/TerminalReplayBuffer.cs new file mode 100644 index 0000000..a73f24c --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/History/TerminalReplayBuffer.cs @@ -0,0 +1,60 @@ +namespace TermRemoteCtl.Agent.History; + +public sealed class TerminalReplayBuffer +{ + private readonly int _maxCharacters; + private readonly Queue _chunks = new(); + private int _characterCount; + + public TerminalReplayBuffer(int maxCharacters) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxCharacters); + _maxCharacters = maxCharacters; + } + + public void Append(string chunk) + { + ArgumentNullException.ThrowIfNull(chunk); + + if (chunk.Length == 0) + { + return; + } + + if (chunk.Length >= _maxCharacters) + { + _chunks.Clear(); + _chunks.Enqueue(chunk[^_maxCharacters..]); + _characterCount = _maxCharacters; + return; + } + + _chunks.Enqueue(chunk); + _characterCount += chunk.Length; + TrimToLimit(); + } + + public string GetSnapshot() + { + return string.Concat(_chunks); + } + + private void TrimToLimit() + { + while (_characterCount > _maxCharacters && _chunks.Count > 0) + { + var oldest = _chunks.Dequeue(); + var overflow = _characterCount - _maxCharacters; + if (oldest.Length <= overflow) + { + _characterCount -= oldest.Length; + continue; + } + + var trimmed = oldest[overflow..]; + _chunks.Enqueue(trimmed); + _characterCount -= overflow; + break; + } + } +} diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs index ed3598a..323975e 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs @@ -78,6 +78,11 @@ public static class TerminalWebSocketHandler try { await SendJsonAsync(socket, new TerminalAttachResponse(sessionId), sendGate, context.RequestAborted).ConfigureAwait(false); + var replay = registry.GetReplaySnapshot(sessionId); + if (!string.IsNullOrEmpty(replay)) + { + await SendTextAsync(socket, replay, sendGate, context.RequestAborted).ConfigureAwait(false); + } await ReceiveLoopAsync(context, socket, host, diagnostics, sessionId).ConfigureAwait(false); } finally diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs index 42f2dbc..610ef24 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs @@ -7,8 +7,10 @@ namespace TermRemoteCtl.Agent.Sessions; public sealed class SessionRegistry { + private const int ReplayCharacterLimit = 262_144; private readonly ConcurrentDictionary _records = new(); private readonly ConcurrentDictionary _historyBySession = new(); + private readonly ConcurrentDictionary _replayBySession = new(); private readonly SessionHistoryStore _historyStore; private readonly int _ringBufferLineLimit; @@ -37,6 +39,7 @@ public sealed class SessionRegistry _records[record.SessionId] = record; _historyBySession[record.SessionId] = new TerminalRingBuffer(_ringBufferLineLimit); + _replayBySession[record.SessionId] = new TerminalReplayBuffer(ReplayCharacterLimit); return record; } @@ -92,6 +95,10 @@ public sealed class SessionRegistry sessionId, _ => new TerminalRingBuffer(_ringBufferLineLimit)); history.Append(chunk); + var replay = _replayBySession.GetOrAdd( + sessionId, + _ => new TerminalReplayBuffer(ReplayCharacterLimit)); + replay.Append(chunk); _records[sessionId] = record with { UpdatedAtUtc = DateTimeOffset.UtcNow }; await _historyStore.AppendAsync(sessionId, chunk, cancellationToken).ConfigureAwait(false); } @@ -118,6 +125,21 @@ public sealed class SessionRegistry skipCount > 0); } + public string GetReplaySnapshot(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)); + return replay.GetSnapshot(); + } + public async Task DeleteAsync(string sessionId, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); @@ -128,6 +150,7 @@ public sealed class SessionRegistry } _historyBySession.TryRemove(sessionId, out _); + _replayBySession.TryRemove(sessionId, out _); await _historyStore.DeleteAsync(sessionId, cancellationToken).ConfigureAwait(false); } } diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs index 185687c..a8e6ee9 100644 --- a/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs @@ -49,6 +49,30 @@ public sealed class TerminalWebSocketHandlerTests TimeSpan.FromSeconds(2)); } + [Fact] + public async Task Attach_Replays_Recent_Output_For_Existing_Session() + { + await using var fixture = new TerminalApiFixture(); + var registry = fixture.Services.GetRequiredService(); + var session = registry.Create("Shell", DateTimeOffset.UtcNow); + await registry.AppendOutputAsync(session.SessionId, "prompt> dir\r\nnext> ", CancellationToken.None); + + using WebSocket socket = await fixture.Server.CreateWebSocketClient().ConnectAsync( + new Uri($"ws://localhost/ws/terminal?sessionId={session.SessionId}"), + CancellationToken.None); + + var attachedFrame = await ReceiveTextAsync(socket, CancellationToken.None); + var attachedPayload = JsonSerializer.Deserialize( + attachedFrame, + new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + Assert.NotNull(attachedPayload); + Assert.Equal("attached", attachedPayload!.Type); + + var replayFrame = await ReceiveTextAsync(socket, CancellationToken.None); + Assert.Equal("prompt> dir\r\nnext> ", replayFrame); + } + private static async Task ReceiveTextAsync(WebSocket socket, CancellationToken cancellationToken) { var buffer = new byte[4096]; diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs index b4ac68e..7cd4b41 100644 --- a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs @@ -68,6 +68,21 @@ public class SessionRegistryTests Assert.Equal("dir\r\n", content); } + [Fact] + public async Task AppendOutputAsync_Stores_Recent_Replay_Output() + { + using var harness = SessionRegistryHarness.Create(); + var registry = harness.Registry; + var session = registry.Create("Shell", DateTimeOffset.UtcNow); + + await registry.AppendOutputAsync(session.SessionId, "prompt> ", CancellationToken.None); + await registry.AppendOutputAsync(session.SessionId, "dir\r\n", CancellationToken.None); + + var replay = registry.GetReplaySnapshot(session.SessionId); + + Assert.Equal("prompt> dir\r\n", replay); + } + [Fact] public async Task Delete_Removes_Session_Record_And_History_Log() {