From 2dd51130445cc81ccd7b59958ae8e99e13f85b11 Mon Sep 17 00:00:00 2001 From: sladro Date: Mon, 6 Apr 2026 12:10:01 +0800 Subject: [PATCH] feat: refine terminal input controls --- .../lib/features/terminal/terminal_page.dart | 212 ++++++++++++++---- apps/mobile_app/test/widget_test.dart | 98 +++++--- 2 files changed, 228 insertions(+), 82 deletions(-) diff --git a/apps/mobile_app/lib/features/terminal/terminal_page.dart b/apps/mobile_app/lib/features/terminal/terminal_page.dart index b74e845..70c8911 100644 --- a/apps/mobile_app/lib/features/terminal/terminal_page.dart +++ b/apps/mobile_app/lib/features/terminal/terminal_page.dart @@ -41,30 +41,53 @@ class TerminalPage extends ConsumerStatefulWidget { class _TerminalPageState extends ConsumerState with WidgetsBindingObserver { static const Duration _historySeedDelay = Duration(milliseconds: 120); - static const List<_QuickTerminalKey> _symbolTerminalKeys = [ - _QuickTerminalKey(keyId: 'symbol_at', label: '@', input: '@'), - _QuickTerminalKey(keyId: 'symbol_slash', label: '/', input: '/'), - _QuickTerminalKey(keyId: 'symbol_dash', label: '-', input: '-'), - _QuickTerminalKey(keyId: 'symbol_underscore', label: '_', input: '_'), - _QuickTerminalKey(keyId: 'symbol_dot', label: '.', input: '.'), - _QuickTerminalKey(keyId: 'symbol_colon', label: ':', input: ':'), - _QuickTerminalKey(keyId: 'symbol_tilde', label: '~', input: '~'), - _QuickTerminalKey(keyId: 'symbol_backslash', label: r'\', input: r'\'), - _QuickTerminalKey(keyId: 'symbol_pipe', label: '|', input: '|'), - _QuickTerminalKey(keyId: 'symbol_dollar', label: r'$', input: r'$'), - ]; - static const List<_QuickTerminalKey> _controlTerminalKeys = [ + static const List<_QuickTerminalKey> _editingControlKeys = [ _QuickTerminalKey(keyId: 'esc', label: 'Esc', input: '\u001b'), _QuickTerminalKey(keyId: 'tab', label: 'Tab', input: '\t'), + _QuickTerminalKey(keyId: 'enter', label: 'Enter', input: '\r'), _QuickTerminalKey(keyId: 'ctrl_c', label: 'Ctrl+C', input: '\u0003'), _QuickTerminalKey(keyId: 'ctrl_d', label: 'Ctrl+D', input: '\u0004'), _QuickTerminalKey(keyId: 'ctrl_l', label: 'Ctrl+L', input: '\u000c'), + _QuickTerminalKey(keyId: 'ctrl_u', label: 'Ctrl+U', input: '\u0015'), + _QuickTerminalKey(keyId: 'ctrl_z', label: 'Ctrl+Z', input: '\u001a'), _QuickTerminalKey( keyId: 'backspace', label: 'Backspace', input: '\x7f', repeatable: true, ), + _QuickTerminalKey( + keyId: 'delete', + label: 'Del', + input: '\u001b[3~', + repeatable: true, + ), + ]; + static const List<_QuickTerminalKey> _navigationKeys = [ + _QuickTerminalKey( + keyId: 'home', + label: 'Home', + input: '\u001b[H', + repeatable: true, + ), + _QuickTerminalKey( + keyId: 'end', + label: 'End', + input: '\u001b[F', + repeatable: true, + ), + _QuickTerminalKey( + keyId: 'page_up', + label: 'PgUp', + input: '\u001b[5~', + repeatable: true, + ), + _QuickTerminalKey( + keyId: 'page_down', + label: 'PgDn', + input: '\u001b[6~', + repeatable: true, + ), _QuickTerminalKey( keyId: 'up', label: 'Up', @@ -90,6 +113,18 @@ class _TerminalPageState extends ConsumerState repeatable: true, ), ]; + static const List<_QuickTerminalKey> _symbolTerminalKeys = [ + _QuickTerminalKey(keyId: 'symbol_at', label: '@', input: '@'), + _QuickTerminalKey(keyId: 'symbol_slash', label: '/', input: '/'), + _QuickTerminalKey(keyId: 'symbol_dash', label: '-', input: '-'), + _QuickTerminalKey(keyId: 'symbol_underscore', label: '_', input: '_'), + _QuickTerminalKey(keyId: 'symbol_dot', label: '.', input: '.'), + _QuickTerminalKey(keyId: 'symbol_colon', label: ':', input: ':'), + _QuickTerminalKey(keyId: 'symbol_tilde', label: '~', input: '~'), + _QuickTerminalKey(keyId: 'symbol_backslash', label: r'\', input: r'\'), + _QuickTerminalKey(keyId: 'symbol_pipe', label: '|', input: '|'), + _QuickTerminalKey(keyId: 'symbol_dollar', label: r'$', input: r'$'), + ]; final Terminal terminal = Terminal(maxLines: 1000); final TerminalInteractionController controller = @@ -107,6 +142,7 @@ class _TerminalPageState extends ConsumerState bool _awaitingAttachReplayFrame = true; bool _awaitingReconnectRestore = false; bool _shouldReconnectOnResume = false; + _TerminalInputMode _inputMode = _TerminalInputMode.read; TerminalConnectionState? _lastConnectionState; @override @@ -138,6 +174,7 @@ class _TerminalPageState extends ConsumerState _diagnosticLog.add('ui.terminal.key', normalizedInput); _coordinator.sendInput(normalizedInput); }; + _applyInputMode(); unawaited(_coordinator.start()); } @@ -236,14 +273,6 @@ class _TerminalPageState extends ConsumerState ); } - void _sendEnter() { - if (!_canSendInput) { - return; - } - - _sendTerminalInput('\r', diagnosticEvent: 'ui.input.send', detail: r'\r'); - } - void _sendQuickKey(_QuickTerminalKey quickKey) { _sendTerminalInput( quickKey.input, @@ -263,11 +292,44 @@ class _TerminalPageState extends ConsumerState _QuickTerminalKey _quickKey(String keyId) { return <_QuickTerminalKey>[ + ..._editingControlKeys, + ..._navigationKeys, ..._symbolTerminalKeys, - ..._controlTerminalKeys, ].singleWhere((key) => key.keyId == keyId); } + void _setInputMode(_TerminalInputMode mode) { + if (_inputMode == mode) { + if (mode == _TerminalInputMode.edit) { + _terminalFocusNode.requestFocus(); + } + return; + } + + setState(() { + _inputMode = mode; + }); + _applyInputMode(); + } + + void _applyInputMode() { + _terminalFocusNode.canRequestFocus = _inputMode == _TerminalInputMode.edit; + if (_inputMode == _TerminalInputMode.edit) { + _terminalFocusNode.requestFocus(); + return; + } + + _terminalFocusNode.unfocus(); + } + + void _handleTerminalSurfaceTap() { + if (_inputMode != _TerminalInputMode.edit) { + return; + } + + _terminalFocusNode.requestFocus(); + } + void _handleTerminalFrame(String frame) { if (_awaitingAttachReplayFrame) { _awaitingAttachReplayFrame = false; @@ -585,13 +647,6 @@ 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, @@ -748,13 +803,17 @@ class _TerminalPageState extends ConsumerState horizontal: 10, vertical: 8, ), - child: TerminalView( - terminal, - focusNode: _terminalFocusNode, - autofocus: false, - keyboardType: TextInputType.multiline, - deleteDetection: true, - scrollController: _terminalScrollController, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: _handleTerminalSurfaceTap, + child: TerminalView( + terminal, + focusNode: _terminalFocusNode, + autofocus: false, + keyboardType: TextInputType.multiline, + deleteDetection: true, + scrollController: _terminalScrollController, + ), ), ), ), @@ -1000,19 +1059,43 @@ class _TerminalPageState extends ConsumerState crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - _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'), + Container( + key: const Key('terminal_mode_button'), + width: double.infinity, + padding: const EdgeInsets.only(bottom: 8), + child: Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + 'Input mode', + style: Theme.of(context).textTheme.labelLarge, + ), + _buildModeButton( + key: const Key('terminal_mode_read_button'), + label: 'Read', + icon: Icons.menu_book_outlined, + selected: _inputMode == _TerminalInputMode.read, + onPressed: () => _setInputMode(_TerminalInputMode.read), + ), + _buildModeButton( + key: const Key('terminal_mode_edit_button'), + label: 'Edit', + icon: Icons.keyboard_outlined, + selected: _inputMode == _TerminalInputMode.edit, + onPressed: () => _setInputMode(_TerminalInputMode.edit), + ), + Text( + _inputMode == _TerminalInputMode.read + ? 'Read mode prevents the terminal from taking focus.' + : 'Edit mode keeps the terminal ready for typing.', + style: Theme.of(context).textTheme.bodySmall, + ), + ], ), ), + _buildQuickKeysSection(), ], ); }, @@ -1023,7 +1106,9 @@ class _TerminalPageState extends ConsumerState Widget _buildQuickKeysSection() { return Column( children: [ - _buildKeyTrayRow(_controlTerminalKeys), + _buildKeyTrayRow(_editingControlKeys), + const SizedBox(height: 8), + _buildKeyTrayRow(_navigationKeys), const SizedBox(height: 8), _buildKeyTrayRow(_symbolTerminalKeys), ], @@ -1054,6 +1139,32 @@ class _TerminalPageState extends ConsumerState return ExcludeFocus(child: child); } + Widget _buildModeButton({ + required Key key, + required String label, + required IconData icon, + required bool selected, + required VoidCallback onPressed, + }) { + final colorScheme = Theme.of(context).colorScheme; + return OutlinedButton.icon( + key: key, + onPressed: onPressed, + style: OutlinedButton.styleFrom( + backgroundColor: selected + ? colorScheme.primary.withValues(alpha: 0.18) + : Colors.transparent, + side: BorderSide( + color: selected ? colorScheme.primary : const Color(0xFF4A3E31), + ), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + icon: Icon(icon, size: 16), + label: Text(label), + ); + } + String get _statusLabel => switch (_connectionState) { TerminalConnectionState.connecting => 'Connecting', TerminalConnectionState.connected => 'Connected', @@ -1068,7 +1179,8 @@ class _TerminalPageState extends ConsumerState TerminalConnectionState.disconnected => 'Off', }; - bool get _canSendInput => controller.canSendInput; + bool get _canSendInput => + controller.canSendInput && _inputMode == _TerminalInputMode.edit; IconData get _statusIcon => switch (_connectionState) { TerminalConnectionState.connecting => Icons.sync, @@ -1104,3 +1216,5 @@ class _QuickTerminalKey { final String input; final bool repeatable; } + +enum _TerminalInputMode { read, edit } diff --git a/apps/mobile_app/test/widget_test.dart b/apps/mobile_app/test/widget_test.dart index 5eb6858..6d8ae10 100644 --- a/apps/mobile_app/test/widget_test.dart +++ b/apps/mobile_app/test/widget_test.dart @@ -348,7 +348,7 @@ void main() { }); testWidgets( - 'terminal page keeps tools hidden until the user opens the tools sheet', + 'terminal page shows mode controls and inline quick keys in the command deck', (tester) async { await _pumpApp( tester, @@ -359,31 +359,19 @@ void main() { await _openProjectTerminal(tester); expect(find.byKey(const Key('terminal_action_bar')), findsOneWidget); - expect(find.byKey(const Key('terminal_send_button')), findsOneWidget); + expect(find.byKey(const Key('terminal_send_button')), findsNothing); expect(find.byKey(const Key('terminal_command_deck')), findsOneWidget); expect(find.byType(TextField), findsNothing); - expect(find.byKey(const Key('terminal_actions_sheet')), findsNothing); + expect(find.byKey(const Key('terminal_mode_read_button')), findsOneWidget); + expect(find.byKey(const Key('terminal_mode_edit_button')), findsOneWidget); + expect(find.byKey(const Key('terminal_quick_key_ctrl_c')), findsOneWidget); + expect(find.byKey(const Key('terminal_quick_key_up')), findsOneWidget); + expect(find.byKey(const Key('terminal_quick_key_enter')), findsOneWidget); 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_actions_button'))); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('terminal_actions_sheet')), findsOneWidget); - expect(find.text('Reconnect'), findsOneWidget); - expect(find.text('Latest'), findsOneWidget); - expect( - find.byKey(const Key('terminal_quick_key_ctrl_c')), - findsOneWidget, - ); - expect(find.text('ssh prod'), findsNothing); }, ); - testWidgets('terminal tools expose quick terminal keys', (tester) async { + testWidgets('terminal command deck exposes expanded quick terminal keys', (tester) async { final transportFactory = _QueuedTerminalSocketTransportFactory(); await _pumpApp( @@ -397,19 +385,24 @@ void main() { await _openProjectTerminal(tester); - await tester.tap(find.byKey(const Key('terminal_actions_button'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('terminal_quick_key_esc')), findsOneWidget); expect(find.byKey(const Key('terminal_quick_key_tab')), findsOneWidget); expect(find.byKey(const Key('terminal_quick_key_ctrl_c')), findsOneWidget); expect(find.byKey(const Key('terminal_quick_key_ctrl_d')), findsOneWidget); expect(find.byKey(const Key('terminal_quick_key_ctrl_l')), findsOneWidget); + expect(find.byKey(const Key('terminal_quick_key_ctrl_z')), findsOneWidget); + expect(find.byKey(const Key('terminal_quick_key_delete')), findsOneWidget); + expect(find.byKey(const Key('terminal_quick_key_home')), findsOneWidget); + expect(find.byKey(const Key('terminal_quick_key_end')), findsOneWidget); + expect(find.byKey(const Key('terminal_quick_key_page_up')), findsOneWidget); + expect(find.byKey(const Key('terminal_quick_key_page_down')), findsOneWidget); expect(find.byKey(const Key('terminal_quick_key_up')), findsOneWidget); expect(find.byKey(const Key('terminal_quick_key_down')), findsOneWidget); expect(find.byKey(const Key('terminal_quick_key_left')), findsOneWidget); expect(find.byKey(const Key('terminal_quick_key_right')), findsOneWidget); + await tester.tap(find.byKey(const Key('terminal_mode_edit_button'))); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('terminal_quick_key_esc'))); await tester.pump(); @@ -419,7 +412,7 @@ void main() { ); }); - testWidgets('terminal page no longer exposes input mode switching', ( + testWidgets('terminal page exposes reading and editing mode switching', ( tester, ) async { await _pumpApp( @@ -431,9 +424,45 @@ void main() { await _openProjectTerminal(tester); expect(find.byType(TextField), findsNothing); - await tester.tap(find.byKey(const Key('terminal_actions_button'))); + expect(find.byKey(const Key('terminal_mode_button')), findsOneWidget); + expect(find.text('Read'), findsOneWidget); + expect(find.text('Edit'), findsOneWidget); + }); + + testWidgets('terminal quick keys stay disabled until edit mode is selected', ( + tester, + ) async { + final transportFactory = _QueuedTerminalSocketTransportFactory(); + + await _pumpApp( + tester, + projectRepository: _FakeProjectRepository(), + sessionRepository: _FakeSessionRepository(), + socketFactory: TerminalSocketSessionFactory( + transportFactory: transportFactory.create, + ), + ); + + await _openProjectTerminal(tester); + + await tester.tap(find.byKey(const Key('terminal_quick_key_ctrl_l'))); + await tester.pump(); + + expect( + transportFactory.createdTransports.single.sentMessages, + isNot(contains(contains('"input":"\\f"'))), + ); + + await tester.tap(find.byKey(const Key('terminal_mode_edit_button'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('terminal_mode_button')), findsNothing); + + await tester.tap(find.byKey(const Key('terminal_quick_key_ctrl_l'))); + await tester.pump(); + + expect( + transportFactory.createdTransports.single.sentMessages.last, + contains('"input":"\\f"'), + ); }); testWidgets( @@ -472,21 +501,20 @@ void main() { ); await _openProjectTerminal(tester); - await tester.tap(find.byKey(const Key('terminal_send_button'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('terminal_actions_button'))); + await tester.tap(find.byKey(const Key('terminal_mode_edit_button'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('terminal_quick_key_ctrl_l'))); await tester.pumpAndSettle(); transportFactory.createdTransports.single.emit('command-output'); await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('terminal_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.input.tx |'), findsOneWidget); expect( find.textContaining('socket.frame.rx | command-output'), findsOneWidget, @@ -494,7 +522,7 @@ void main() { }, ); - testWidgets('terminal send button writes carriage return', (tester) async { + testWidgets('terminal enter quick key writes carriage return', (tester) async { final transportFactory = _QueuedTerminalSocketTransportFactory(); await _pumpApp( @@ -507,7 +535,9 @@ void main() { ); await _openProjectTerminal(tester); - await tester.tap(find.byKey(const Key('terminal_send_button'))); + await tester.tap(find.byKey(const Key('terminal_mode_edit_button'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('terminal_quick_key_enter'))); await tester.pumpAndSettle(); expect( @@ -695,6 +725,8 @@ void main() { transportFactory: transportFactory.create, ), ); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pumpAndSettle(); final terminal = tester .widget(find.byType(TerminalView))