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>
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<TerminalPage>
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<TerminalPage>
bool _awaitingAttachReplayFrame = true;
bool _awaitingReconnectRestore = false;
bool _shouldReconnectOnResume = false;
_TerminalInputMode _inputMode = _TerminalInputMode.read;
TerminalConnectionState? _lastConnectionState;
@override
@ -138,6 +174,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
_diagnosticLog.add('ui.terminal.key', normalizedInput);
_coordinator.sendInput(normalizedInput);
};
_applyInputMode();
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) {
_sendTerminalInput(
quickKey.input,
@ -263,11 +292,44 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
_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<TerminalPage>
],
),
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<TerminalPage>
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<TerminalPage>
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<TerminalPage>
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<TerminalPage>
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<TerminalPage>
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 }

View File

@ -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<TerminalView>(find.byType(TerminalView))