diff --git a/apps/mobile_app/lib/features/terminal/repeatable_terminal_key_button.dart b/apps/mobile_app/lib/features/terminal/repeatable_terminal_key_button.dart index bb05056..20c4a91 100644 --- a/apps/mobile_app/lib/features/terminal/repeatable_terminal_key_button.dart +++ b/apps/mobile_app/lib/features/terminal/repeatable_terminal_key_button.dart @@ -5,13 +5,15 @@ import 'package:flutter/material.dart'; class RepeatableTerminalKeyButton extends StatefulWidget { const RepeatableTerminalKeyButton({ super.key, - required this.label, required this.onPressed, + this.label, + this.icon, this.enabled = true, this.repeatable = false, - }); + }) : assert(label != null || icon != null); - final String label; + final String? label; + final IconData? icon; final VoidCallback onPressed; final bool enabled; final bool repeatable; @@ -61,11 +63,15 @@ class _RepeatableTerminalKeyButtonState backgroundColor: const Color(0xFF151A20), side: const BorderSide(color: Color(0xFF3F3428)), minimumSize: const Size(0, 34), - padding: const EdgeInsets.symmetric(horizontal: 10), + padding: EdgeInsets.symmetric( + horizontal: widget.label == null ? 8 : 10, + ), tapTargetSize: MaterialTapTargetSize.shrinkWrap, visualDensity: VisualDensity.compact, ), - child: Text(widget.label), + child: widget.icon != null + ? Icon(widget.icon, size: 18) + : Text(widget.label!), ), ); } diff --git a/apps/mobile_app/lib/features/terminal/terminal_page.dart b/apps/mobile_app/lib/features/terminal/terminal_page.dart index 70c8911..ade017e 100644 --- a/apps/mobile_app/lib/features/terminal/terminal_page.dart +++ b/apps/mobile_app/lib/features/terminal/terminal_page.dart @@ -92,24 +92,28 @@ class _TerminalPageState extends ConsumerState keyId: 'up', label: 'Up', input: '\u001b[A', + icon: Icons.arrow_upward, repeatable: true, ), _QuickTerminalKey( keyId: 'down', label: 'Down', input: '\u001b[B', + icon: Icons.arrow_downward, repeatable: true, ), _QuickTerminalKey( keyId: 'left', label: 'Left', input: '\u001b[D', + icon: Icons.arrow_back, repeatable: true, ), _QuickTerminalKey( keyId: 'right', label: 'Right', input: '\u001b[C', + icon: Icons.arrow_forward, repeatable: true, ), ]; @@ -142,6 +146,7 @@ class _TerminalPageState extends ConsumerState bool _awaitingAttachReplayFrame = true; bool _awaitingReconnectRestore = false; bool _shouldReconnectOnResume = false; + bool _showExpandedControls = false; _TerminalInputMode _inputMode = _TerminalInputMode.read; TerminalConnectionState? _lastConnectionState; @@ -558,127 +563,6 @@ class _TerminalPageState extends ConsumerState ); } - 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), - isScrollControlled: true, - builder: (context) { - return SafeArea( - child: AnimatedBuilder( - animation: _pageStateListenable, - builder: (context, _) { - return SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), - child: Column( - key: const Key('terminal_actions_sheet'), - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - 'Terminal actions', - style: Theme.of(context).textTheme.titleMedium, - ), - const Spacer(), - StatusPill( - label: _statusLabel, - icon: _statusIcon, - color: _statusColor(context), - ), - ], - ), - const SizedBox(height: 12), - Text( - 'Session', - style: Theme.of(context).textTheme.labelLarge, - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - OutlinedButton.icon( - onPressed: () { - Navigator.of(context).pop(); - _jumpToBottom(); - }, - icon: const Icon(Icons.vertical_align_bottom), - label: const Text('Latest'), - ), - OutlinedButton.icon( - onPressed: () { - Navigator.of(context).pop(); - unawaited(_coordinator.reconnectNow()); - }, - icon: const Icon(Icons.refresh), - label: const Text('Reconnect'), - ), - if (widget.project != null) - OutlinedButton.icon( - onPressed: () { - Navigator.of(context).pop(); - unawaited(_openSiblingTerminal()); - }, - icon: const Icon(Icons.add_box_outlined), - label: const Text('New terminal'), - ), - TextButton.icon( - key: const Key('terminal_diagnostics_button'), - onPressed: () async { - Navigator.of(context).pop(); - await _showDiagnostics(); - }, - icon: const Icon(Icons.bug_report_outlined), - label: const Text('Diagnostics'), - ), - ], - ), - 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, - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - ), - ); - }, - ), - ); - }, - ); - } - Future _openPresetManagementPage() { return Navigator.of(context).push( MaterialPageRoute(builder: (context) => const PresetManagementPage()), @@ -688,7 +572,6 @@ class _TerminalPageState extends ConsumerState @override Widget build(BuildContext context) { final width = MediaQuery.sizeOf(context).width; - final isCompact = width < 420; final isTight = width < 400; final workingDirectory = widget.project?.workingDirectory ?? @@ -722,67 +605,14 @@ class _TerminalPageState extends ConsumerState ], ), ), - actions: [ - AnimatedBuilder( - animation: controller, - builder: (context, _) { - final mode = controller.isFollowingLiveOutput - ? 'Live' - : 'Scrollback'; - final modeLabel = isCompact - ? mode - : '$mode | ${controller.liveLines.length} lines'; - final statusLabel = isCompact - ? _compactStatusLabel - : _statusLabel; - - return Row( - key: const Key('terminal_status_summary'), - mainAxisSize: MainAxisSize.min, - children: [ - TextButton( - onPressed: controller.isFollowingLiveOutput - ? controller.enterScrollback - : controller.jumpToLive, - style: TextButton.styleFrom( - foregroundColor: const Color(0xFFD8C4A0), - minimumSize: const Size(0, 32), - padding: const EdgeInsets.symmetric(horizontal: 8), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.compact, - ), - child: Text(modeLabel), - ), - SizedBox(width: isCompact ? 2 : 4), - Padding( - padding: EdgeInsets.only(right: isCompact ? 4 : 8), - child: Center( - child: StatusPill( - label: statusLabel, - icon: _statusIcon, - color: _statusColor(context), - ), - ), - ), - IconButton( - key: const Key('terminal_actions_button'), - onPressed: _showActionsSheet, - visualDensity: VisualDensity.compact, - icon: const Icon(Icons.tune), - tooltip: 'Show actions', - ), - ], - ); - }, - ), - ], + actions: const [], ), body: SafeArea( top: false, minimum: AppTheme.pagePadding, child: Column( children: [ - _buildScrollbackSection(context, isCompact), + _buildScrollbackSection(context), Expanded( child: Container( key: const Key('terminal_surface_panel'), @@ -819,14 +649,15 @@ class _TerminalPageState extends ConsumerState ), ), const SizedBox(height: 6), - _buildCommandDeck(context, isCompact), + _buildCommandDeck(context), ], ), ), ); } - Widget _buildScrollbackSection(BuildContext context, bool isCompact) { + Widget _buildScrollbackSection(BuildContext context) { + final isCompact = MediaQuery.sizeOf(context).width < 420; return AnimatedBuilder( animation: _pageStateListenable, builder: (context, _) { @@ -922,7 +753,7 @@ class _TerminalPageState extends ConsumerState borderRadius: BorderRadius.zero, border: Border.all(color: const Color(0xFF2A231B)), ), - child: isCompact + child: MediaQuery.sizeOf(context).width < 420 ? _buildCompactHistoryActions(context) : _buildWideHistoryActions(context), ), @@ -1045,7 +876,7 @@ class _TerminalPageState extends ConsumerState ); } - Widget _buildCommandDeck(BuildContext context, bool isCompact) { + Widget _buildCommandDeck(BuildContext context) { return AppPanel( key: const Key('terminal_command_deck'), tone: AppPanelTone.emphasis, @@ -1095,7 +926,13 @@ class _TerminalPageState extends ConsumerState ], ), ), - _buildQuickKeysSection(), + _buildPinnedQuickKeysRow(), + if (_showExpandedControls) ...[ + const SizedBox(height: 8), + _buildExpandedControls(context), + ], + const SizedBox(height: 8), + _buildStatusRow(context), ], ); }, @@ -1103,38 +940,167 @@ class _TerminalPageState extends ConsumerState ); } - Widget _buildQuickKeysSection() { - return Column( + Widget _buildPinnedQuickKeysRow() { + return Wrap( + spacing: 8, + runSpacing: 8, children: [ - _buildKeyTrayRow(_editingControlKeys), - const SizedBox(height: 8), - _buildKeyTrayRow(_navigationKeys), - const SizedBox(height: 8), - _buildKeyTrayRow(_symbolTerminalKeys), + _buildMoreControlsButton(), + ..._navigationKeys + .where((key) => switch (key.keyId) { + 'up' || 'down' || 'left' || 'right' => true, + _ => false, + }) + .map(_buildQuickKeyButton), ], ); } + Widget _buildExpandedControls(BuildContext context) { + final presetsFuture = ref.watch(presetRepositoryProvider).listPresets(); + return FutureBuilder>( + future: presetsFuture, + builder: (context, snapshot) { + final presets = snapshot.data ?? const []; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildKeyTrayRow(_editingControlKeys), + const SizedBox(height: 8), + _buildKeyTrayRow( + _navigationKeys.where((key) { + return key.keyId != 'up' && + key.keyId != 'down' && + key.keyId != 'left' && + key.keyId != 'right'; + }).toList(growable: false), + ), + const SizedBox(height: 8), + _buildKeyTrayRow(_symbolTerminalKeys), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlinedButton.icon( + key: const Key('terminal_latest_inline_button'), + onPressed: _jumpToBottom, + icon: const Icon(Icons.vertical_align_bottom), + label: const Text('Latest'), + ), + OutlinedButton.icon( + key: const Key('terminal_reconnect_inline_button'), + onPressed: () => unawaited(_coordinator.reconnectNow()), + icon: const Icon(Icons.refresh), + label: const Text('Reconnect'), + ), + if (widget.project != null) + OutlinedButton.icon( + key: const Key('terminal_new_inline_button'), + onPressed: () => unawaited(_openSiblingTerminal()), + icon: const Icon(Icons.add_box_outlined), + label: const Text('New terminal'), + ), + TextButton.icon( + key: const Key('terminal_diagnostics_inline_button'), + onPressed: _showDiagnostics, + icon: const Icon(Icons.bug_report_outlined), + label: const Text('Diagnostics'), + ), + ], + ), + const SizedBox(height: 8), + PresetPanel( + presets: presets, + onPresetSelected: (preset) { + unawaited(_sendLine(preset.commandText)); + }, + onManagePressed: () { + unawaited(_openPresetManagementPage()); + }, + ), + ], + ); + }, + ); + } + Widget _buildKeyTrayRow(List<_QuickTerminalKey> keys) { 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), - ), - ); - }) + .map(_buildQuickKeyButton) .toList(growable: false), ); } + Widget _buildQuickKeyButton(_QuickTerminalKey quickKey) { + return _buildCommandDeckAction( + RepeatableTerminalKeyButton( + key: Key('terminal_quick_key_${quickKey.keyId}'), + enabled: _canSendInput, + repeatable: quickKey.repeatable, + label: quickKey.label, + icon: quickKey.icon, + onPressed: () => _sendQuickKey(quickKey), + ), + ); + } + + Widget _buildMoreControlsButton() { + return _buildCommandDeckAction( + OutlinedButton.icon( + key: const Key('terminal_more_controls_button'), + onPressed: () { + setState(() { + _showExpandedControls = !_showExpandedControls; + }); + }, + icon: Icon(_showExpandedControls ? Icons.expand_less : Icons.expand_more), + label: Text(_showExpandedControls ? 'Less' : 'More'), + ), + ); + } + + Widget _buildStatusRow(BuildContext context) { + final mode = controller.isFollowingLiveOutput ? 'Live' : 'Scrollback'; + final modeLabel = '$mode | ${controller.liveLines.length} lines'; + return Wrap( + key: const Key('terminal_status_summary'), + spacing: 8, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + TextButton( + onPressed: controller.isFollowingLiveOutput + ? controller.enterScrollback + : controller.jumpToLive, + style: TextButton.styleFrom( + foregroundColor: const Color(0xFFD8C4A0), + minimumSize: const Size(0, 32), + padding: const EdgeInsets.symmetric(horizontal: 8), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + child: Text(modeLabel), + ), + const SizedBox(width: 4), + StatusPill( + label: _statusLabel, + icon: _statusIcon, + color: _statusColor(context), + ), + Text( + _coordinator.connectionStatus, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ); + } + Widget _buildCommandDeckAction(Widget child) { return ExcludeFocus(child: child); } @@ -1172,13 +1138,6 @@ class _TerminalPageState extends ConsumerState TerminalConnectionState.disconnected => 'Offline', }; - String get _compactStatusLabel => switch (_connectionState) { - TerminalConnectionState.connecting => 'Sync', - TerminalConnectionState.connected => 'On', - TerminalConnectionState.reconnecting => 'Sync', - TerminalConnectionState.disconnected => 'Off', - }; - bool get _canSendInput => controller.canSendInput && _inputMode == _TerminalInputMode.edit; @@ -1208,12 +1167,14 @@ class _QuickTerminalKey { required this.keyId, required this.label, required this.input, + this.icon, this.repeatable = false, }); final String keyId; final String label; final String input; + final IconData? icon; final bool repeatable; } diff --git a/apps/mobile_app/test/widget_test.dart b/apps/mobile_app/test/widget_test.dart index 6d8ae10..a93b37d 100644 --- a/apps/mobile_app/test/widget_test.dart +++ b/apps/mobile_app/test/widget_test.dart @@ -144,7 +144,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_actions_button')), findsOneWidget); + expect(find.byKey(const Key('terminal_more_controls_button')), findsOneWidget); }); testWidgets('project list deletes a project after confirmation', ( @@ -348,7 +348,7 @@ void main() { }); testWidgets( - 'terminal page shows mode controls and inline quick keys in the command deck', + 'terminal page keeps the action entry in the command deck instead of the app bar', (tester) async { await _pumpApp( tester, @@ -364,14 +364,18 @@ void main() { expect(find.byType(TextField), findsNothing); expect(find.byKey(const Key('terminal_mode_read_button')), findsOneWidget); expect(find.byKey(const Key('terminal_mode_edit_button')), findsOneWidget); - expect(find.byKey(const Key('terminal_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_quick_key_down')), findsOneWidget); + expect(find.byKey(const Key('terminal_quick_key_left')), findsOneWidget); + expect(find.byKey(const Key('terminal_quick_key_right')), findsOneWidget); + expect(find.byKey(const Key('terminal_more_controls_button')), findsOneWidget); + expect(find.byKey(const Key('terminal_actions_button')), findsNothing); + expect(find.byKey(const Key('terminal_quick_key_ctrl_c')), findsNothing); + expect(find.byKey(const Key('terminal_quick_key_enter')), findsNothing); }, ); - testWidgets('terminal command deck exposes expanded quick terminal keys', (tester) async { + testWidgets('terminal more controls button toggles expanded quick terminal keys', (tester) async { final transportFactory = _QueuedTerminalSocketTransportFactory(); await _pumpApp( @@ -385,6 +389,16 @@ void main() { await _openProjectTerminal(tester); + expect(find.byKey(const Key('terminal_quick_key_up')), findsOneWidget); + expect(find.byKey(const Key('terminal_quick_key_down')), findsOneWidget); + expect(find.byKey(const Key('terminal_quick_key_left')), findsOneWidget); + expect(find.byKey(const Key('terminal_quick_key_right')), findsOneWidget); + expect(find.byKey(const Key('terminal_quick_key_esc')), findsNothing); + expect(find.byKey(const Key('terminal_quick_key_ctrl_c')), findsNothing); + + await tester.tap(find.byKey(const Key('terminal_more_controls_button'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('terminal_quick_key_esc')), findsOneWidget); expect(find.byKey(const Key('terminal_quick_key_tab')), findsOneWidget); expect(find.byKey(const Key('terminal_quick_key_ctrl_c')), findsOneWidget); @@ -396,10 +410,7 @@ void main() { 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); + expect(find.byKey(const Key('terminal_quick_key_enter')), findsOneWidget); await tester.tap(find.byKey(const Key('terminal_mode_edit_button'))); await tester.pumpAndSettle(); @@ -427,6 +438,10 @@ void main() { expect(find.byKey(const Key('terminal_mode_button')), findsOneWidget); expect(find.text('Read'), findsOneWidget); expect(find.text('Edit'), findsOneWidget); + expect(find.byIcon(Icons.arrow_upward), findsWidgets); + expect(find.byIcon(Icons.arrow_downward), findsWidgets); + expect(find.byIcon(Icons.arrow_back), findsWidgets); + expect(find.byIcon(Icons.arrow_forward), findsWidgets); }); testWidgets('terminal quick keys stay disabled until edit mode is selected', ( @@ -445,6 +460,8 @@ void main() { await _openProjectTerminal(tester); + await tester.tap(find.byKey(const Key('terminal_more_controls_button'))); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('terminal_quick_key_ctrl_l'))); await tester.pump(); @@ -503,14 +520,14 @@ void main() { await _openProjectTerminal(tester); await tester.tap(find.byKey(const Key('terminal_mode_edit_button'))); await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('terminal_more_controls_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.tap(find.byKey(const Key('terminal_diagnostics_inline_button'))); await tester.pumpAndSettle(); expect(find.textContaining('ui.input.quick | Ctrl+L'), findsOneWidget); @@ -537,6 +554,8 @@ void main() { await _openProjectTerminal(tester); await tester.tap(find.byKey(const Key('terminal_mode_edit_button'))); await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('terminal_more_controls_button'))); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('terminal_quick_key_enter'))); await tester.pumpAndSettle(); @@ -568,9 +587,6 @@ void main() { await transportFactory.createdTransports.first.close(); await tester.pump(); - await tester.tap(find.byKey(const Key('terminal_actions_button'))); - await tester.pumpAndSettle(); - expect(find.text('Connection lost. Reconnecting...'), findsOneWidget); await tester.pump(const Duration(seconds: 2)); @@ -659,9 +675,9 @@ void main() { await _openProjectTerminal(tester); expect(sessionRepository.createCount, 1); - await tester.tap(find.byKey(const Key('terminal_actions_button'))); + await tester.tap(find.byKey(const Key('terminal_more_controls_button'))); await tester.pumpAndSettle(); - await tester.tap(find.text('New terminal')); + await tester.tap(find.byKey(const Key('terminal_new_inline_button'))); await tester.pumpAndSettle(); expect(sessionRepository.createCount, 2);