Simplify terminal session actions

This commit is contained in:
sladro 2026-04-06 07:45:38 +08:00
parent 0de2cfe262
commit 39ab30d715
5 changed files with 178 additions and 903 deletions

View File

@ -1,227 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
enum TerminalInputMode { buffered, direct }
typedef TerminalBufferedSubmit = FutureOr<void> 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<void> 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),
);
}

View File

@ -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<TerminalPage>
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<TerminalPage>
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<TerminalPage>
_pageStateListenable.removeListener(_handlePageStateChanged);
_historySeedTimer?.cancel();
_terminalFocusNode.dispose();
_inputController.dispose();
_terminalScrollController.dispose();
unawaited(_coordinator.close());
controller.dispose();
@ -244,12 +231,16 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
);
}
Future<void> _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<TerminalPage>
_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<TerminalPage>
);
}
Future<void> _showToolsSheet() {
Future<void> _showActionsSheet() async {
var presets = const <PresetCommand>[];
try {
presets = await ref.read(presetRepositoryProvider).listPresets();
} catch (_) {
presets = const <PresetCommand>[];
}
if (!mounted) {
return;
}
return showModalBottomSheet<void>(
context: context,
backgroundColor: const Color(0xFF13191F),
@ -519,14 +478,14 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
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<TerminalPage>
],
),
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<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,
),
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<TerminalPage>
);
}
Future<void> _showPresetsSheet() async {
final presets = await ref.read(presetRepositoryProvider).listPresets();
if (!mounted) {
return;
}
await showModalBottomSheet<void>(
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<void> _openPresetManagementPage() {
return Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const PresetManagementPage()),
@ -672,9 +598,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
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<TerminalPage>
),
),
),
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<TerminalPage>
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<TerminalPage>
_buildCommandDeck(context, isCompact),
],
),
),
),
),
);
);
}
Widget _buildScrollbackSection(BuildContext context, bool isCompact) {
@ -1031,68 +955,22 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
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<TerminalPage>
);
}
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<TerminalPage>
return ExcludeFocus(child: child);
}
Map<ShortcutActivator, VoidCallback> _shortcutBindings() {
if (!_inputController.isDirectInputEnabled || !_canSendInput) {
return const <ShortcutActivator, VoidCallback>{};
}
return <ShortcutActivator, VoidCallback>{
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',

View File

@ -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 = <String>[];
final bufferedCommands = <String>[];
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);
},
);
}

View File

@ -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')));

View File

@ -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<TextField>(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<TextField>(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<TextField>(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<EditableText>(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<EditableText>(editableField).focusNode.hasFocus,
isTrue,
);
expect(tester.widget<TextField>(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<EditableText>(editableField).focusNode.hasFocus,
isTrue,
);
expect(tester.widget<TextField>(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<EditableText>(editableField).focusNode.hasFocus,
isTrue,
);
expect(tester.widget<TextField>(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<EditableText>(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<void> _pumpApp(
),
projectRepositoryProvider.overrideWithValue(projectRepository),
sessionRepositoryProvider.overrideWithValue(sessionRepository),
presetRepositoryProvider.overrideWithValue(
_MemoryPresetRepository(const <PresetCommand>[]),
),
terminalSocketSessionFactoryProvider.overrideWithValue(
socketFactory ??
TerminalSocketSessionFactory(
@ -1111,6 +957,9 @@ Future<void> _pumpTerminalPage(
agentApiClientProvider.overrideWithValue(
apiClient ?? _FakeAgentApiClient(),
),
presetRepositoryProvider.overrideWithValue(
_MemoryPresetRepository(const <PresetCommand>[]),
),
terminalSocketSessionFactoryProvider.overrideWithValue(
socketFactory ??
TerminalSocketSessionFactory(
@ -1260,6 +1109,18 @@ Session _session(String sessionId, String name) {
);
}
class _MemoryPresetRepository extends PresetRepository {
_MemoryPresetRepository(List<PresetCommand> presets)
: _presets = List<PresetCommand>.of(presets);
final List<PresetCommand> _presets;
@override
Future<List<PresetCommand>> listPresets() async {
return List<PresetCommand>.of(_presets);
}
}
int _countOccurrences(String source, String pattern) {
if (pattern.isEmpty) {
return 0;