feat: refine terminal input controls

This commit is contained in:
sladro 2026-04-06 12:10:01 +08:00
parent e071f73490
commit 2dd5113044
2 changed files with 228 additions and 82 deletions

View File

@ -41,30 +41,53 @@ class TerminalPage extends ConsumerStatefulWidget {
class _TerminalPageState extends ConsumerState<TerminalPage> class _TerminalPageState extends ConsumerState<TerminalPage>
with WidgetsBindingObserver { with WidgetsBindingObserver {
static const Duration _historySeedDelay = Duration(milliseconds: 120); static const Duration _historySeedDelay = Duration(milliseconds: 120);
static const List<_QuickTerminalKey> _symbolTerminalKeys = [ static const List<_QuickTerminalKey> _editingControlKeys = [
_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 = [
_QuickTerminalKey(keyId: 'esc', label: 'Esc', input: '\u001b'), _QuickTerminalKey(keyId: 'esc', label: 'Esc', input: '\u001b'),
_QuickTerminalKey(keyId: 'tab', label: 'Tab', input: '\t'), _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_c', label: 'Ctrl+C', input: '\u0003'),
_QuickTerminalKey(keyId: 'ctrl_d', label: 'Ctrl+D', input: '\u0004'), _QuickTerminalKey(keyId: 'ctrl_d', label: 'Ctrl+D', input: '\u0004'),
_QuickTerminalKey(keyId: 'ctrl_l', label: 'Ctrl+L', input: '\u000c'), _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( _QuickTerminalKey(
keyId: 'backspace', keyId: 'backspace',
label: 'Backspace', label: 'Backspace',
input: '\x7f', input: '\x7f',
repeatable: true, 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( _QuickTerminalKey(
keyId: 'up', keyId: 'up',
label: 'Up', label: 'Up',
@ -90,6 +113,18 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
repeatable: true, 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 Terminal terminal = Terminal(maxLines: 1000);
final TerminalInteractionController controller = final TerminalInteractionController controller =
@ -107,6 +142,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
bool _awaitingAttachReplayFrame = true; bool _awaitingAttachReplayFrame = true;
bool _awaitingReconnectRestore = false; bool _awaitingReconnectRestore = false;
bool _shouldReconnectOnResume = false; bool _shouldReconnectOnResume = false;
_TerminalInputMode _inputMode = _TerminalInputMode.read;
TerminalConnectionState? _lastConnectionState; TerminalConnectionState? _lastConnectionState;
@override @override
@ -138,6 +174,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
_diagnosticLog.add('ui.terminal.key', normalizedInput); _diagnosticLog.add('ui.terminal.key', normalizedInput);
_coordinator.sendInput(normalizedInput); _coordinator.sendInput(normalizedInput);
}; };
_applyInputMode();
unawaited(_coordinator.start()); unawaited(_coordinator.start());
} }
@ -236,14 +273,6 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
); );
} }
void _sendEnter() {
if (!_canSendInput) {
return;
}
_sendTerminalInput('\r', diagnosticEvent: 'ui.input.send', detail: r'\r');
}
void _sendQuickKey(_QuickTerminalKey quickKey) { void _sendQuickKey(_QuickTerminalKey quickKey) {
_sendTerminalInput( _sendTerminalInput(
quickKey.input, quickKey.input,
@ -263,11 +292,44 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
_QuickTerminalKey _quickKey(String keyId) { _QuickTerminalKey _quickKey(String keyId) {
return <_QuickTerminalKey>[ return <_QuickTerminalKey>[
..._editingControlKeys,
..._navigationKeys,
..._symbolTerminalKeys, ..._symbolTerminalKeys,
..._controlTerminalKeys,
].singleWhere((key) => key.keyId == keyId); ].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) { void _handleTerminalFrame(String frame) {
if (_awaitingAttachReplayFrame) { if (_awaitingAttachReplayFrame) {
_awaitingAttachReplayFrame = false; _awaitingAttachReplayFrame = false;
@ -585,13 +647,6 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text(
'Quick keys',
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 8),
_buildQuickKeysSection(),
const SizedBox(height: 16),
Text( Text(
'Presets', 'Presets',
style: Theme.of(context).textTheme.labelLarge, style: Theme.of(context).textTheme.labelLarge,
@ -748,13 +803,17 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
horizontal: 10, horizontal: 10,
vertical: 8, vertical: 8,
), ),
child: TerminalView( child: GestureDetector(
terminal, behavior: HitTestBehavior.translucent,
focusNode: _terminalFocusNode, onTap: _handleTerminalSurfaceTap,
autofocus: false, child: TerminalView(
keyboardType: TextInputType.multiline, terminal,
deleteDetection: true, focusNode: _terminalFocusNode,
scrollController: _terminalScrollController, autofocus: false,
keyboardType: TextInputType.multiline,
deleteDetection: true,
scrollController: _terminalScrollController,
),
), ),
), ),
), ),
@ -1000,19 +1059,43 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_buildCommandDeckAction( Container(
FilledButton.icon( key: const Key('terminal_mode_button'),
key: const Key('terminal_send_button'), width: double.infinity,
onPressed: _canSendInput ? _sendEnter : null, padding: const EdgeInsets.only(bottom: 8),
style: FilledButton.styleFrom( child: Wrap(
minimumSize: const Size(0, 42), spacing: 8,
tapTargetSize: MaterialTapTargetSize.shrinkWrap, runSpacing: 8,
visualDensity: VisualDensity.compact, crossAxisAlignment: WrapCrossAlignment.center,
), children: [
icon: const Icon(Icons.keyboard_return), Text(
label: Text(isCompact ? 'Enter' : 'Send Enter'), '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<TerminalPage>
Widget _buildQuickKeysSection() { Widget _buildQuickKeysSection() {
return Column( return Column(
children: [ children: [
_buildKeyTrayRow(_controlTerminalKeys), _buildKeyTrayRow(_editingControlKeys),
const SizedBox(height: 8),
_buildKeyTrayRow(_navigationKeys),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildKeyTrayRow(_symbolTerminalKeys), _buildKeyTrayRow(_symbolTerminalKeys),
], ],
@ -1054,6 +1139,32 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
return ExcludeFocus(child: child); 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) { String get _statusLabel => switch (_connectionState) {
TerminalConnectionState.connecting => 'Connecting', TerminalConnectionState.connecting => 'Connecting',
TerminalConnectionState.connected => 'Connected', TerminalConnectionState.connected => 'Connected',
@ -1068,7 +1179,8 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
TerminalConnectionState.disconnected => 'Off', TerminalConnectionState.disconnected => 'Off',
}; };
bool get _canSendInput => controller.canSendInput; bool get _canSendInput =>
controller.canSendInput && _inputMode == _TerminalInputMode.edit;
IconData get _statusIcon => switch (_connectionState) { IconData get _statusIcon => switch (_connectionState) {
TerminalConnectionState.connecting => Icons.sync, TerminalConnectionState.connecting => Icons.sync,
@ -1104,3 +1216,5 @@ class _QuickTerminalKey {
final String input; final String input;
final bool repeatable; final bool repeatable;
} }
enum _TerminalInputMode { read, edit }

View File

@ -348,7 +348,7 @@ void main() {
}); });
testWidgets( 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 { (tester) async {
await _pumpApp( await _pumpApp(
tester, tester,
@ -359,31 +359,19 @@ void main() {
await _openProjectTerminal(tester); await _openProjectTerminal(tester);
expect(find.byKey(const Key('terminal_action_bar')), 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_send_button')), findsNothing);
expect(find.byKey(const Key('terminal_command_deck')), findsOneWidget); expect(find.byKey(const Key('terminal_command_deck')), findsOneWidget);
expect(find.byType(TextField), findsNothing); 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_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(); final transportFactory = _QueuedTerminalSocketTransportFactory();
await _pumpApp( await _pumpApp(
@ -397,19 +385,24 @@ void main() {
await _openProjectTerminal(tester); 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_esc')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_tab')), 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_c')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_ctrl_d')), 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_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_up')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_down')), 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_left')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_right')), 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.tap(find.byKey(const Key('terminal_quick_key_esc')));
await tester.pump(); 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, tester,
) async { ) async {
await _pumpApp( await _pumpApp(
@ -431,9 +424,45 @@ void main() {
await _openProjectTerminal(tester); await _openProjectTerminal(tester);
expect(find.byType(TextField), findsNothing); 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(); 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( testWidgets(
@ -472,21 +501,20 @@ void main() {
); );
await _openProjectTerminal(tester); 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_actions_button')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('terminal_quick_key_ctrl_l'))); await tester.tap(find.byKey(const Key('terminal_quick_key_ctrl_l')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
transportFactory.createdTransports.single.emit('command-output'); transportFactory.createdTransports.single.emit('command-output');
await tester.pumpAndSettle(); 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_button')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.textContaining('ui.input.quick | Ctrl+L'), findsOneWidget); expect(find.textContaining('ui.input.quick | Ctrl+L'), findsOneWidget);
expect(find.textContaining('socket.input.tx | '), findsWidgets); expect(find.textContaining('socket.input.tx |'), findsOneWidget);
expect(find.textContaining(r'socket.input.tx | \r'), findsOneWidget);
expect( expect(
find.textContaining('socket.frame.rx | command-output'), find.textContaining('socket.frame.rx | command-output'),
findsOneWidget, 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(); final transportFactory = _QueuedTerminalSocketTransportFactory();
await _pumpApp( await _pumpApp(
@ -507,7 +535,9 @@ void main() {
); );
await _openProjectTerminal(tester); 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(); await tester.pumpAndSettle();
expect( expect(
@ -695,6 +725,8 @@ void main() {
transportFactory: transportFactory.create, transportFactory: transportFactory.create,
), ),
); );
await tester.pump(const Duration(milliseconds: 200));
await tester.pumpAndSettle();
final terminal = tester final terminal = tester
.widget<TerminalView>(find.byType(TerminalView)) .widget<TerminalView>(find.byType(TerminalView))