From 39ab30d7156cf51da839ed0a6c958bf48d60f3fd Mon Sep 17 00:00:00 2001 From: sladro Date: Mon, 6 Apr 2026 07:45:38 +0800 Subject: [PATCH] Simplify terminal session actions --- .../terminal/terminal_input_controller.dart | 227 -------- .../lib/features/terminal/terminal_page.dart | 497 +++++------------- .../terminal_input_controller_test.dart | 67 --- .../terminal/terminal_page_input_test.dart | 71 +-- apps/mobile_app/test/widget_test.dart | 219 ++------ 5 files changed, 178 insertions(+), 903 deletions(-) delete mode 100644 apps/mobile_app/lib/features/terminal/terminal_input_controller.dart delete mode 100644 apps/mobile_app/test/features/terminal/terminal_input_controller_test.dart diff --git a/apps/mobile_app/lib/features/terminal/terminal_input_controller.dart b/apps/mobile_app/lib/features/terminal/terminal_input_controller.dart deleted file mode 100644 index dc5e6a5..0000000 --- a/apps/mobile_app/lib/features/terminal/terminal_input_controller.dart +++ /dev/null @@ -1,227 +0,0 @@ -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 - ? 'Direct mode: keys send immediately' - : 'Buffered mode: type a command and send'; - - 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 8a563d8..c0372b3 100644 --- a/apps/mobile_app/lib/features/terminal/terminal_page.dart +++ b/apps/mobile_app/lib/features/terminal/terminal_page.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:xterm/xterm.dart'; @@ -9,6 +8,7 @@ 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 '../presets/preset_command.dart'; import '../presets/preset_management_page.dart'; import '../presets/preset_panel.dart'; import '../presets/preset_providers.dart'; @@ -17,7 +17,6 @@ import '../sessions/session.dart'; import 'terminal_diagnostic_log.dart'; import 'history_window.dart'; import 'repeatable_terminal_key_button.dart'; -import 'terminal_input_controller.dart'; import 'terminal_interaction_controller.dart'; import 'terminal_session_coordinator.dart'; import 'terminal_socket_session.dart'; @@ -98,14 +97,12 @@ class _TerminalPageState extends ConsumerState final FocusNode _terminalFocusNode = FocusNode(canRequestFocus: false); final ScrollController _terminalScrollController = ScrollController(); late final TerminalSessionCoordinator _coordinator; - late final TerminalInputController _inputController; late final Listenable _pageStateListenable; Timer? _historySeedTimer; String? _pendingHistorySeed; bool _receivedSocketFrame = false; bool _historySeeded = false; bool _awaitingAttachReplayFrame = true; - bool _isKeyTrayVisible = false; bool _shouldReconnectOnResume = false; TerminalConnectionState? _lastConnectionState; @@ -127,23 +124,14 @@ class _TerminalPageState extends ConsumerState rows: terminal.viewHeight, ), ); - _inputController = TerminalInputController( - onBufferedSubmit: (command) => _sendLine(command), - onDirectInput: _sendDirectInput, - ); - _pageStateListenable = Listenable.merge([ - controller, - _coordinator, - _inputController, - ]); + _pageStateListenable = Listenable.merge([controller, _coordinator]); _pageStateListenable.addListener(_handlePageStateChanged); terminal.onResize = (width, height, _, _) { _coordinator.handleTerminalResize(width, height); }; terminal.onOutput = (data) { - final normalizedData = _normalizeTerminalDirectInput(data); - _diagnosticLog.add('ui.terminal.key', normalizedData); - _coordinator.sendInput(normalizedData); + _diagnosticLog.add('ui.terminal.key', data); + _coordinator.sendInput(data); }; unawaited(_coordinator.start()); } @@ -154,7 +142,6 @@ class _TerminalPageState extends ConsumerState _pageStateListenable.removeListener(_handlePageStateChanged); _historySeedTimer?.cancel(); _terminalFocusNode.dispose(); - _inputController.dispose(); _terminalScrollController.dispose(); unawaited(_coordinator.close()); controller.dispose(); @@ -244,12 +231,16 @@ class _TerminalPageState extends ConsumerState ); } - Future _submitInput() async { + void _sendEnter() { if (!_canSendInput) { return; } - await _inputController.submit(); + _sendTerminalInput( + '\r', + diagnosticEvent: 'ui.input.send', + detail: r'\r', + ); } void _sendQuickKey(_QuickTerminalKey quickKey) { @@ -269,48 +260,6 @@ class _TerminalPageState extends ConsumerState _coordinator.sendInput(input); } - 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() { - setState(_inputController.toggleMode); - if (_canSendInput) { - _inputController.requestFocus(); - } - } - - void _toggleKeyTray() { - setState(() { - _isKeyTrayVisible = !_isKeyTrayVisible; - }); - } - - 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'); - } - _QuickTerminalKey _quickKey(String keyId) { return <_QuickTerminalKey>[ ..._symbolTerminalKeys, @@ -505,7 +454,17 @@ class _TerminalPageState extends ConsumerState ); } - Future _showToolsSheet() { + Future _showActionsSheet() async { + var presets = const []; + try { + presets = await ref.read(presetRepositoryProvider).listPresets(); + } catch (_) { + presets = const []; + } + if (!mounted) { + return; + } + return showModalBottomSheet( context: context, backgroundColor: const Color(0xFF13191F), @@ -519,14 +478,14 @@ class _TerminalPageState extends ConsumerState child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), child: Column( - key: const Key('terminal_tools_sheet'), + key: const Key('terminal_actions_sheet'), mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( - 'Terminal tools', + 'Terminal actions', style: Theme.of(context).textTheme.titleMedium, ), const Spacer(), @@ -538,35 +497,15 @@ class _TerminalPageState extends ConsumerState ], ), const SizedBox(height: 12), + Text( + 'Session', + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: [ - _buildModeButton(closeSheetOnPressed: true), - _buildIconActionButton( - key: const Key('terminal_keys_toggle_button'), - onPressed: () { - Navigator.of(context).pop(); - _toggleKeyTray(); - }, - tooltip: _isKeyTrayVisible - ? 'Hide quick keys' - : 'Show quick keys', - icon: Icon( - _isKeyTrayVisible - ? Icons.keyboard_arrow_down - : Icons.keyboard_command_key, - ), - ), - _buildIconActionButton( - key: const Key('terminal_presets_button'), - onPressed: () { - Navigator.of(context).pop(); - unawaited(_showPresetsSheet()); - }, - tooltip: 'Show presets', - icon: const Icon(Icons.flash_on_outlined), - ), OutlinedButton.icon( onPressed: () { Navigator.of(context).pop(); @@ -603,6 +542,30 @@ class _TerminalPageState extends ConsumerState ), ], ), + const SizedBox(height: 16), + Text( + 'Quick keys', + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 8), + _buildQuickKeysSection(), + const SizedBox(height: 16), + Text( + 'Presets', + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 8), + PresetPanel( + presets: presets, + onPresetSelected: (preset) { + Navigator.of(context).pop(); + unawaited(_sendLine(preset.commandText)); + }, + onManagePressed: () { + Navigator.of(context).pop(); + unawaited(_openPresetManagementPage()); + }, + ), const SizedBox(height: 12), Text( _coordinator.connectionStatus, @@ -619,43 +582,6 @@ class _TerminalPageState extends ConsumerState ); } - Future _showPresetsSheet() async { - final presets = await ref.read(presetRepositoryProvider).listPresets(); - if (!mounted) { - return; - } - - await showModalBottomSheet( - context: context, - backgroundColor: const Color(0xFF13191F), - builder: (context) { - return SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), - child: Column( - key: const Key('terminal_presets_sheet'), - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - PresetPanel( - presets: presets, - onPresetSelected: (preset) { - Navigator.of(context).pop(); - unawaited(_sendLine(preset.commandText)); - }, - onManagePressed: () { - Navigator.of(context).pop(); - unawaited(_openPresetManagementPage()); - }, - ), - ], - ), - ), - ); - }, - ); - } - Future _openPresetManagementPage() { return Navigator.of(context).push( MaterialPageRoute(builder: (context) => const PresetManagementPage()), @@ -672,9 +598,7 @@ class _TerminalPageState extends ConsumerState widget.session.workingDirectory ?? ''; - return CallbackShortcuts( - bindings: _shortcutBindings(), - child: Scaffold( + return Scaffold( appBar: AppBar( toolbarHeight: 44, titleSpacing: 0, @@ -743,6 +667,13 @@ class _TerminalPageState extends ConsumerState ), ), ), + IconButton( + key: const Key('terminal_actions_button'), + onPressed: _showActionsSheet, + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.tune), + tooltip: 'Show actions', + ), ], ); }, @@ -752,41 +683,36 @@ class _TerminalPageState extends ConsumerState body: SafeArea( top: false, minimum: AppTheme.pagePadding, - child: TextFieldTapRegion( - child: Column( + 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: 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: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 8, - ), - child: TerminalView( - terminal, - focusNode: _terminalFocusNode, - autofocus: false, - scrollController: _terminalScrollController, - ), + borderRadius: BorderRadius.zero, + border: Border.all(color: const Color(0xFF332B22)), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + child: TerminalView( + terminal, + focusNode: _terminalFocusNode, + autofocus: false, + scrollController: _terminalScrollController, ), ), ), @@ -795,10 +721,8 @@ class _TerminalPageState extends ConsumerState _buildCommandDeck(context, isCompact), ], ), - ), ), - ), - ); + ); } Widget _buildScrollbackSection(BuildContext context, bool isCompact) { @@ -1031,68 +955,22 @@ class _TerminalPageState extends ConsumerState builder: (context, _) { return Column( key: const Key('terminal_action_bar'), - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - Row( - children: [ - Expanded(child: _buildInputField(context)), - const SizedBox(width: 8), - _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', - ), - ), - const SizedBox(width: 6), - _buildCommandDeckAction( - FilledButton( - key: const Key('terminal_send_button'), - onPressed: _canSendInput ? _submitInput : null, - style: FilledButton.styleFrom( - minimumSize: Size(isCompact ? 40 : 0, 38), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.compact, - padding: EdgeInsets.symmetric( - horizontal: isCompact ? 12 : 16, - ), - ), - child: isCompact - ? Icon( - _inputController.isDirectInputEnabled - ? Icons.keyboard_return - : Icons.send, - ) - : Text( - _inputController.isDirectInputEnabled - ? 'Enter' - : 'Send', - ), - ), - ), - ], - ), - if (_isKeyTrayVisible) ...[ - const SizedBox(height: 8), - _buildKeyTray(), - ], - if (_inputController.isDirectInputEnabled && - _inputController.composingText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - 'Composing: ${_inputController.composingText}', - key: const Key('terminal_direct_input_composing_label'), - style: Theme.of(context).textTheme.bodySmall, + _buildCommandDeckAction( + FilledButton.icon( + key: const Key('terminal_send_button'), + onPressed: _canSendInput ? _sendEnter : null, + style: FilledButton.styleFrom( + minimumSize: const Size(0, 42), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, ), + icon: const Icon(Icons.keyboard_return), + label: Text(isCompact ? 'Enter' : 'Send Enter'), ), + ), ], ); }, @@ -1100,128 +978,33 @@ 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, - isDense: true, - ), - onTap: _inputController.syncDirectSelection, - onEditingComplete: () {}, - onSubmitted: (_) => _submitInput(), - ); - } - - Widget _buildModeButton({bool closeSheetOnPressed = false}) { - return KeyedSubtree( - key: const Key('terminal_direct_input_toggle'), - child: _buildIconActionButton( - key: const Key('terminal_mode_button'), - onPressed: _canSendInput - ? () { - if (closeSheetOnPressed) { - Navigator.of(context).pop(); - } - _toggleDirectInput(); - } - : null, - tooltip: _inputController.isDirectInputEnabled - ? 'Switch to buffered mode' - : 'Switch to direct mode', - icon: Icon( - _inputController.isDirectInputEnabled - ? Icons.keyboard_hide - : Icons.keyboard, - ), - ), - ); - } - - Widget _buildIconActionButton({ - required Key key, - required VoidCallback? onPressed, - required String tooltip, - required Widget icon, - }) { - return _buildCommandDeckAction( - IconButton.filledTonal( - key: key, - onPressed: onPressed, - visualDensity: VisualDensity.compact, - style: IconButton.styleFrom( - backgroundColor: const Color(0xFF241F1A), - foregroundColor: const Color(0xFFD7C4A0), - ), - tooltip: tooltip, - icon: icon, - ), - ); - } - - Widget _buildKeyTray() { - return Container( - key: const Key('terminal_key_tray'), - width: double.infinity, - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: const Color(0xFF11161B), - border: Border.all(color: const Color(0xFF3F3428)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildKeyTrayRow(_symbolTerminalKeys), - const SizedBox(height: 8), - _buildKeyTrayRow(_controlTerminalKeys), - ], - ), + Widget _buildQuickKeysSection() { + return Column( + children: [ + _buildKeyTrayRow(_controlTerminalKeys), + const SizedBox(height: 8), + _buildKeyTrayRow(_symbolTerminalKeys), + ], ); } Widget _buildKeyTrayRow(List<_QuickTerminalKey> keys) { - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: keys - .map((quickKey) { - return Padding( - padding: const EdgeInsets.only(right: 8), - child: _buildCommandDeckAction( - RepeatableTerminalKeyButton( - key: Key('terminal_quick_key_${quickKey.keyId}'), - enabled: _canSendInput, - repeatable: quickKey.repeatable, - label: quickKey.label, - onPressed: () => _sendQuickKey(quickKey), - ), - ), - ); - }) - .toList(growable: false), - ), + return Wrap( + spacing: 8, + runSpacing: 8, + children: keys + .map((quickKey) { + return _buildCommandDeckAction( + RepeatableTerminalKeyButton( + key: Key('terminal_quick_key_${quickKey.keyId}'), + enabled: _canSendInput, + repeatable: quickKey.repeatable, + label: quickKey.label, + onPressed: () => _sendQuickKey(quickKey), + ), + ); + }) + .toList(growable: false), ); } @@ -1229,42 +1012,6 @@ class _TerminalPageState extends ConsumerState return ExcludeFocus(child: child); } - Map _shortcutBindings() { - if (!_inputController.isDirectInputEnabled || !_canSendInput) { - return const {}; - } - - return { - const SingleActivator(LogicalKeyboardKey.escape): () { - _sendQuickKey(_quickKey('esc')); - }, - const SingleActivator(LogicalKeyboardKey.tab): () { - _sendQuickKey(_quickKey('tab')); - }, - const SingleActivator(LogicalKeyboardKey.arrowUp): () { - _sendQuickKey(_quickKey('up')); - }, - const SingleActivator(LogicalKeyboardKey.arrowDown): () { - _sendQuickKey(_quickKey('down')); - }, - const SingleActivator(LogicalKeyboardKey.arrowLeft): () { - _sendQuickKey(_quickKey('left')); - }, - const SingleActivator(LogicalKeyboardKey.arrowRight): () { - _sendQuickKey(_quickKey('right')); - }, - const SingleActivator(LogicalKeyboardKey.keyC, control: true): () { - _sendQuickKey(_quickKey('ctrl_c')); - }, - const SingleActivator(LogicalKeyboardKey.keyD, control: true): () { - _sendQuickKey(_quickKey('ctrl_d')); - }, - const SingleActivator(LogicalKeyboardKey.keyL, control: true): () { - _sendQuickKey(_quickKey('ctrl_l')); - }, - }; - } - String get _statusLabel => switch (_connectionState) { TerminalConnectionState.connecting => 'Connecting', TerminalConnectionState.connected => 'Connected', 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 deleted file mode 100644 index e6d6667..0000000 --- a/apps/mobile_app/test/features/terminal/terminal_input_controller_test.dart +++ /dev/null @@ -1,67 +0,0 @@ -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/features/terminal/terminal_page_input_test.dart b/apps/mobile_app/test/features/terminal/terminal_page_input_test.dart index 68a300b..af57a87 100644 --- a/apps/mobile_app/test/features/terminal/terminal_page_input_test.dart +++ b/apps/mobile_app/test/features/terminal/terminal_page_input_test.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:term_remote_ctl/core/network/agent_api_client.dart'; @@ -16,76 +15,41 @@ import 'package:term_remote_ctl/features/sessions/session.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - testWidgets('terminal keeps the extra key tray hidden until requested', ( + testWidgets('terminal page removes the bottom text input field', ( tester, ) async { await _pumpTerminalPage(tester); - expect(find.byKey(const Key('terminal_key_tray')), findsNothing); - expect(find.byKey(const Key('terminal_keys_toggle_button')), findsNothing); - - await tester.tap(find.byKey(const Key('terminal_toggle_actions_button'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('terminal_keys_toggle_button'))); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('terminal_key_tray')), findsOneWidget); - expect( - find.byKey(const Key('terminal_quick_key_symbol_at')), - findsOneWidget, - ); - expect( - find.byKey(const Key('terminal_quick_key_symbol_slash')), - findsOneWidget, - ); + expect(find.byType(TextField), findsNothing); + expect(find.byKey(const Key('terminal_send_button')), findsOneWidget); }); - testWidgets('terminal long press repeats arrow keys', (tester) async { + testWidgets('terminal actions sheet unifies session actions and quick keys', ( + tester, + ) async { final transportFactory = _QueuedTerminalSocketTransportFactory(); await _pumpTerminalPage(tester, socketFactory: transportFactory.factory); - await tester.tap(find.byKey(const Key('terminal_toggle_actions_button'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('terminal_keys_toggle_button'))); + await tester.tap(find.byKey(const Key('terminal_actions_button'))); await tester.pumpAndSettle(); - final gesture = await tester.startGesture( - tester.getCenter(find.byKey(const Key('terminal_quick_key_up'))), - ); - await tester.pump(const Duration(milliseconds: 700)); - await gesture.up(); - await tester.pumpAndSettle(); + 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); - final sentInputs = transportFactory.createdTransports.single.sentMessages - .where((message) => message.contains('"type":"input"')) - .toList(growable: false); - - expect(sentInputs.length, greaterThanOrEqualTo(2)); - expect(sentInputs.last, contains(r'"input":"\u001b[A"')); - }); - - testWidgets('terminal sends hardware escape in direct mode', (tester) async { - final transportFactory = _QueuedTerminalSocketTransportFactory(); - - await _pumpTerminalPage(tester, socketFactory: transportFactory.factory); - - await tester.tap(find.byKey(const Key('terminal_toggle_actions_button'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('terminal_mode_button'))); - await tester.pumpAndSettle(); - await tester.tap(find.byType(TextField)); - await tester.pumpAndSettle(); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.tap(find.byKey(const Key('terminal_quick_key_up'))); await tester.pumpAndSettle(); expect( transportFactory.createdTransports.single.sentMessages.last, - contains(r'"input":"\u001b"'), + contains(r'"input":"\u001b[A"'), ); }); - testWidgets('terminal opens the presets sheet and launches management', ( + testWidgets('terminal actions sheet opens preset management', ( tester, ) async { await _pumpTerminalPage( @@ -99,12 +63,9 @@ void main() { ]), ); - await tester.tap(find.byKey(const Key('terminal_toggle_actions_button'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('terminal_presets_button'))); + await tester.tap(find.byKey(const Key('terminal_actions_button'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('terminal_presets_sheet')), findsOneWidget); expect(find.text('ssh prod'), findsOneWidget); await tester.tap(find.byKey(const Key('terminal_manage_presets_button'))); diff --git a/apps/mobile_app/test/widget_test.dart b/apps/mobile_app/test/widget_test.dart index c9f9d90..6f83d4c 100644 --- a/apps/mobile_app/test/widget_test.dart +++ b/apps/mobile_app/test/widget_test.dart @@ -9,6 +9,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:term_remote_ctl/app/app.dart'; import 'package:term_remote_ctl/core/network/agent_api_client.dart'; import 'package:term_remote_ctl/core/network/agent_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/projects/project.dart'; import 'package:term_remote_ctl/features/projects/project_detail_page.dart'; import 'package:term_remote_ctl/features/projects/project_repository.dart'; @@ -79,10 +82,7 @@ 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_toggle_actions_button')), - findsOneWidget, - ); + expect(find.byKey(const Key('terminal_actions_button')), findsOneWidget); }); testWidgets('project list deletes a project after confirmation', ( @@ -269,31 +269,22 @@ void main() { await _openProjectTerminal(tester); - expect(find.byKey(const Key('terminal_tools_sheet')), findsNothing); - expect( - find.byKey(const Key('terminal_toggle_actions_button')), - findsOneWidget, - ); expect(find.byKey(const Key('terminal_action_bar')), findsOneWidget); expect(find.byKey(const Key('terminal_send_button')), findsOneWidget); expect(find.byKey(const Key('terminal_command_deck')), findsOneWidget); - expect(find.byKey(const Key('terminal_key_tray')), findsNothing); - expect(find.byKey(const Key('terminal_mode_button')), findsNothing); - expect(find.byKey(const Key('terminal_keys_toggle_button')), findsNothing); - expect(find.byKey(const Key('terminal_presets_button')), findsNothing); + expect(find.byType(TextField), findsNothing); + expect(find.byKey(const Key('terminal_actions_sheet')), findsNothing); + expect(find.byKey(const Key('terminal_actions_button')), findsOneWidget); + expect(find.byKey(const Key('terminal_toggle_actions_button')), findsNothing); - await tester.tap(find.byKey(const Key('terminal_toggle_actions_button'))); + await tester.tap(find.byKey(const Key('terminal_actions_button'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('terminal_tools_sheet')), findsOneWidget); + 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_mode_button')), findsOneWidget); - expect( - find.byKey(const Key('terminal_keys_toggle_button')), - findsOneWidget, - ); - expect(find.byKey(const Key('terminal_presets_button')), findsOneWidget); + expect(find.byKey(const Key('terminal_quick_key_ctrl_c')), findsOneWidget); + expect(find.text('ssh prod'), findsNothing); }, ); @@ -311,14 +302,9 @@ void main() { await _openProjectTerminal(tester); - expect(find.byKey(const Key('terminal_key_tray')), findsNothing); - expect(find.byKey(const Key('terminal_keys_toggle_button')), findsNothing); - await tester.tap(find.byKey(const Key('terminal_toggle_actions_button'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('terminal_keys_toggle_button'))); + await tester.tap(find.byKey(const Key('terminal_actions_button'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('terminal_tools_sheet')), findsNothing); 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); @@ -338,7 +324,7 @@ void main() { ); }); - testWidgets('terminal direct input mode is explicit and toggleable', ( + testWidgets('terminal page no longer exposes input mode switching', ( tester, ) async { await _pumpApp( @@ -349,33 +335,10 @@ void main() { await _openProjectTerminal(tester); - final commandField = tester.widget(find.byType(TextField).last); - expect( - commandField.decoration?.hintText, - 'Buffered mode: type a command and send', - ); - - await tester.tap(find.byKey(const Key('terminal_toggle_actions_button'))); + expect(find.byType(TextField), findsNothing); + await tester.tap(find.byKey(const Key('terminal_actions_button'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('terminal_mode_button'))); - await tester.pumpAndSettle(); - - final directField = tester.widget(find.byType(TextField).last); - expect( - directField.decoration?.hintText, - 'Direct mode: keys send immediately', - ); - - await tester.tap(find.byKey(const Key('terminal_toggle_actions_button'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('terminal_mode_button'))); - await tester.pumpAndSettle(); - - final bufferedField = tester.widget(find.byType(TextField).last); - expect( - bufferedField.decoration?.hintText, - 'Buffered mode: type a command and send', - ); + expect(find.byKey(const Key('terminal_mode_button')), findsNothing); }); testWidgets( @@ -414,25 +377,21 @@ void main() { ); await _openProjectTerminal(tester); - await tester.enterText(find.byType(TextField).last, 'dir'); await tester.tap(find.byKey(const Key('terminal_send_button'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('terminal_toggle_actions_button'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('terminal_keys_toggle_button'))); + await tester.tap(find.byKey(const Key('terminal_actions_button'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('terminal_quick_key_ctrl_l'))); await tester.pumpAndSettle(); transportFactory.createdTransports.single.emit('command-output'); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('terminal_toggle_actions_button'))); - await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('terminal_diagnostics_button'))); await tester.pumpAndSettle(); expect(find.textContaining('ui.input.quick | Ctrl+L'), findsOneWidget); expect(find.textContaining('socket.input.tx | '), findsWidgets); + expect(find.textContaining(r'socket.input.tx | \r'), findsOneWidget); expect( find.textContaining('socket.frame.rx | command-output'), findsOneWidget, @@ -440,7 +399,7 @@ void main() { }, ); - testWidgets('terminal send keeps the command input focused', (tester) async { + testWidgets('terminal send button writes carriage return', (tester) async { final transportFactory = _QueuedTerminalSocketTransportFactory(); await _pumpApp( @@ -453,129 +412,13 @@ void main() { ); 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_toggle_actions_button'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('terminal_mode_button'))); - 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', ( @@ -600,7 +443,7 @@ void main() { await transportFactory.createdTransports.first.close(); await tester.pump(); - await tester.tap(find.byKey(const Key('terminal_toggle_actions_button'))); + await tester.tap(find.byKey(const Key('terminal_actions_button'))); await tester.pumpAndSettle(); expect(find.text('Connection lost. Reconnecting...'), findsOneWidget); @@ -691,7 +534,7 @@ void main() { await _openProjectTerminal(tester); expect(sessionRepository.createCount, 1); - await tester.tap(find.byKey(const Key('terminal_toggle_actions_button'))); + await tester.tap(find.byKey(const Key('terminal_actions_button'))); await tester.pumpAndSettle(); await tester.tap(find.text('New terminal')); await tester.pumpAndSettle(); @@ -1076,6 +919,9 @@ Future _pumpApp( ), projectRepositoryProvider.overrideWithValue(projectRepository), sessionRepositoryProvider.overrideWithValue(sessionRepository), + presetRepositoryProvider.overrideWithValue( + _MemoryPresetRepository(const []), + ), terminalSocketSessionFactoryProvider.overrideWithValue( socketFactory ?? TerminalSocketSessionFactory( @@ -1111,6 +957,9 @@ Future _pumpTerminalPage( agentApiClientProvider.overrideWithValue( apiClient ?? _FakeAgentApiClient(), ), + presetRepositoryProvider.overrideWithValue( + _MemoryPresetRepository(const []), + ), terminalSocketSessionFactoryProvider.overrideWithValue( socketFactory ?? TerminalSocketSessionFactory( @@ -1260,6 +1109,18 @@ Session _session(String sessionId, String name) { ); } +class _MemoryPresetRepository extends PresetRepository { + _MemoryPresetRepository(List presets) + : _presets = List.of(presets); + + final List _presets; + + @override + Future> listPresets() async { + return List.of(_presets); + } +} + int _countOccurrences(String source, String pattern) { if (pattern.isEmpty) { return 0;