TermRemoteCtl/apps/mobile_app/lib/features/terminal/terminal_page.dart
2026-04-04 21:29:03 +08:00

1307 lines
44 KiB
Dart

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';
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_management_page.dart';
import '../presets/preset_panel.dart';
import '../presets/preset_providers.dart';
import '../projects/project.dart';
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';
class TerminalPage extends ConsumerStatefulWidget {
const TerminalPage({
super.key,
required this.session,
required this.agentBaseUri,
this.project,
});
final Session session;
final Uri agentBaseUri;
final Project? project;
@override
ConsumerState<TerminalPage> createState() => _TerminalPageState();
}
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 = [
_QuickTerminalKey(keyId: 'esc', label: 'Esc', input: '\u001b'),
_QuickTerminalKey(keyId: 'tab', label: 'Tab', input: '\t'),
_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: 'backspace',
label: 'Backspace',
input: '\x7f',
repeatable: true,
),
_QuickTerminalKey(
keyId: 'up',
label: 'Up',
input: '\u001b[A',
repeatable: true,
),
_QuickTerminalKey(
keyId: 'down',
label: 'Down',
input: '\u001b[B',
repeatable: true,
),
_QuickTerminalKey(
keyId: 'left',
label: 'Left',
input: '\u001b[D',
repeatable: true,
),
_QuickTerminalKey(
keyId: 'right',
label: 'Right',
input: '\u001b[C',
repeatable: true,
),
];
final Terminal terminal = Terminal(maxLines: 1000);
final TerminalInteractionController controller =
TerminalInteractionController();
final TerminalDiagnosticLog _diagnosticLog = TerminalDiagnosticLog();
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;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_coordinator = TerminalSessionCoordinator(
controller: controller,
apiClient: ref.read(agentApiClientProvider),
session: widget.session,
sessionFactory: ref.read(terminalSocketSessionFactoryProvider).create,
baseUri: widget.agentBaseUri,
diagnosticLog: _diagnosticLog,
onFrame: _handleTerminalFrame,
onHistoryLoaded: _handleHistoryLoaded,
viewportProvider: () => TerminalViewport(
columns: terminal.viewWidth,
rows: terminal.viewHeight,
),
);
_inputController = TerminalInputController(
onBufferedSubmit: (command) => _sendLine(command),
onDirectInput: _sendDirectInput,
);
_pageStateListenable = Listenable.merge([
controller,
_coordinator,
_inputController,
]);
_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);
};
unawaited(_coordinator.start());
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_pageStateListenable.removeListener(_handlePageStateChanged);
_historySeedTimer?.cancel();
_terminalFocusNode.dispose();
_inputController.dispose();
_terminalScrollController.dispose();
unawaited(_coordinator.close());
controller.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
if (_shouldReconnectOnResume) {
_shouldReconnectOnResume = false;
_diagnosticLog.add('app.lifecycle.resumed', widget.session.sessionId);
unawaited(_coordinator.reconnectNow());
}
return;
}
if (state == AppLifecycleState.hidden ||
state == AppLifecycleState.paused) {
_shouldReconnectOnResume = true;
_diagnosticLog.add('app.lifecycle.suspended', state.name);
unawaited(_coordinator.suspendForBackground());
}
}
Future<void> _openSiblingTerminal() async {
final project = widget.project;
if (project == null) {
return;
}
final repository = ref.read(sessionRepositoryProvider);
try {
final session = await repository.createSession(
projectId: project.projectId,
);
if (!mounted) {
return;
}
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => TerminalPage(
session: session,
agentBaseUri: widget.agentBaseUri,
project: project,
),
),
);
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
formatAgentError(error, fallback: 'Failed to open terminal.'),
),
),
);
}
}
void _jumpToBottom() {
controller.jumpToLive();
if (!_terminalScrollController.hasClients) {
return;
}
_terminalScrollController.animateTo(
_terminalScrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
);
}
Future<void> _sendLine(String input) async {
if (!_canSendInput || input.trim().isEmpty) {
return;
}
_sendTerminalInput(
'$input\r',
diagnosticEvent: 'ui.input.send',
detail: input,
);
}
Future<void> _submitInput() async {
if (!_canSendInput) {
return;
}
await _inputController.submit();
}
void _sendQuickKey(_QuickTerminalKey quickKey) {
_sendTerminalInput(
quickKey.input,
diagnosticEvent: 'ui.input.quick',
detail: quickKey.label,
);
}
void _sendTerminalInput(
String input, {
required String diagnosticEvent,
required String detail,
}) {
_diagnosticLog.add(diagnosticEvent, detail);
_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,
..._controlTerminalKeys,
].singleWhere((key) => key.keyId == keyId);
}
void _handleTerminalFrame(String frame) {
if (_awaitingAttachReplayFrame) {
_awaitingAttachReplayFrame = false;
if (_shouldSuppressAttachReplay(frame)) {
_receivedSocketFrame = true;
_cancelHistorySeedTimer();
return;
}
}
_receivedSocketFrame = true;
_cancelHistorySeedTimer();
terminal.write(frame);
}
void _handleHistoryLoaded(HistoryWindow history) {
if (history.lines.isEmpty) {
_pendingHistorySeed = null;
return;
}
_pendingHistorySeed = history.lines.join('\r\n');
_scheduleHistorySeedIfNeeded();
}
void _handlePageStateChanged() {
final connectionState = _connectionState;
if (_lastConnectionState != connectionState) {
if (connectionState == TerminalConnectionState.connecting ||
connectionState == TerminalConnectionState.reconnecting) {
_awaitingAttachReplayFrame = true;
if (connectionState == TerminalConnectionState.reconnecting) {
_resetTerminalForReplay();
}
}
_lastConnectionState = connectionState;
}
_scheduleHistorySeedIfNeeded();
}
void _scheduleHistorySeedIfNeeded() {
if (_historySeeded ||
_receivedSocketFrame ||
_connectionState != TerminalConnectionState.connected) {
_cancelHistorySeedTimer();
return;
}
final pendingHistorySeed = _pendingHistorySeed;
if (pendingHistorySeed == null ||
pendingHistorySeed.isEmpty ||
_terminalHasVisibleContent) {
_historySeeded = _terminalHasVisibleContent;
_cancelHistorySeedTimer();
return;
}
_historySeedTimer ??= Timer(_historySeedDelay, () {
_historySeedTimer = null;
if (!mounted ||
_historySeeded ||
_receivedSocketFrame ||
_connectionState != TerminalConnectionState.connected ||
_terminalHasVisibleContent) {
return;
}
final historySeed = _pendingHistorySeed;
if (historySeed == null || historySeed.isEmpty) {
return;
}
terminal.write(historySeed);
_historySeeded = true;
});
}
void _cancelHistorySeedTimer() {
_historySeedTimer?.cancel();
_historySeedTimer = null;
}
bool get _terminalHasVisibleContent =>
terminal.buffer.getText().trim().isNotEmpty;
void _resetTerminalForReplay() {
if (!_terminalHasVisibleContent) {
return;
}
terminal.buffer.clear();
terminal.buffer.setCursor(0, 0);
terminal.notifyListeners();
}
bool _shouldSuppressAttachReplay(String frame) {
final normalizedFrame = _normalizeTerminalText(frame);
if (normalizedFrame.isEmpty) {
return false;
}
if (!_historySeeded && !_terminalHasVisibleContent) {
return false;
}
final normalizedTerminalText = _normalizeTerminalText(
terminal.buffer.getText(),
);
if (normalizedTerminalText.isNotEmpty &&
normalizedTerminalText.endsWith(normalizedFrame)) {
return true;
}
final pendingHistorySeed = _pendingHistorySeed;
return _historySeeded &&
pendingHistorySeed != null &&
_normalizeTerminalText(pendingHistorySeed) == normalizedFrame;
}
static String _normalizeTerminalText(String text) {
return text.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
}
Future<void> _showDiagnostics() {
return showModalBottomSheet<void>(
context: context,
backgroundColor: const Color(0xFF13191F),
isScrollControlled: true,
builder: (context) {
return SafeArea(
child: AnimatedBuilder(
animation: _diagnosticLog,
builder: (context, _) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Diagnostics',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 280),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: const Color(0xFF0E1217),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF2D241B)),
),
child: _diagnosticLog.entries.isEmpty
? Text(
'No diagnostics yet.',
style: Theme.of(context).textTheme.bodySmall,
)
: ListView.separated(
key: const Key('terminal_diagnostics_list'),
shrinkWrap: true,
itemCount: _diagnosticLog.entries.length,
itemBuilder: (context, index) {
return Text(
_diagnosticLog.entries[index],
style: Theme.of(
context,
).textTheme.bodySmall,
);
},
separatorBuilder: (context, index) =>
const SizedBox(height: 6),
),
),
),
],
),
);
},
),
);
},
);
}
Future<void> _showToolsSheet() {
return showModalBottomSheet<void>(
context: context,
backgroundColor: const Color(0xFF13191F),
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_tools_sheet'),
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Terminal tools',
style: Theme.of(context).textTheme.titleMedium,
),
const Spacer(),
StatusPill(
label: _statusLabel,
icon: _statusIcon,
color: _statusColor(context),
),
],
),
const SizedBox(height: 12),
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: 12),
Text(
_coordinator.connectionStatus,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
);
},
),
);
},
);
}
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()),
);
}
@override
Widget build(BuildContext context) {
final width = MediaQuery.sizeOf(context).width;
final isCompact = width < 420;
final isTight = width < 400;
final workingDirectory =
widget.project?.workingDirectory ??
widget.session.workingDirectory ??
'';
return CallbackShortcuts(
bindings: _shortcutBindings(),
child: Scaffold(
appBar: AppBar(
toolbarHeight: 44,
titleSpacing: 0,
title: Container(
key: const Key('terminal_header_panel'),
padding: const EdgeInsets.symmetric(vertical: 2),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
widget.session.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall,
),
if (!isTight)
Text(
workingDirectory,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
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),
),
),
),
],
);
},
),
],
),
body: SafeArea(
top: false,
minimum: AppTheme.pagePadding,
child: TextFieldTapRegion(
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: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
child: TerminalView(
terminal,
focusNode: _terminalFocusNode,
autofocus: false,
scrollController: _terminalScrollController,
),
),
),
),
),
const SizedBox(height: 6),
_buildCommandDeck(context, isCompact),
],
),
),
),
),
);
}
Widget _buildScrollbackSection(BuildContext context, bool isCompact) {
return AnimatedBuilder(
animation: _pageStateListenable,
builder: (context, _) {
if (controller.isFollowingLiveOutput) {
return const SizedBox.shrink();
}
return Flexible(
fit: FlexFit.loose,
child: SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 8),
child: Column(
children: [
AppPanel(
tone: AppPanelTone.subdued,
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isCompact)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Recent scrollback',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 4),
Text(
'${controller.historyWindow.lines.length} lines loaded',
style: Theme.of(context).textTheme.labelMedium,
),
],
)
else
Row(
children: [
Text(
'Recent scrollback',
style: Theme.of(context).textTheme.titleSmall,
),
const Spacer(),
Text(
'${controller.historyWindow.lines.length} lines loaded',
style: Theme.of(context).textTheme.labelMedium,
),
],
),
const SizedBox(height: 4),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 64),
child: DecoratedBox(
decoration: BoxDecoration(
color: const Color(0xFF0D1115),
borderRadius: BorderRadius.zero,
border: Border.all(color: const Color(0xFF2A231B)),
),
child: ListView.separated(
key: const Key('terminal_scrollback_list'),
shrinkWrap: true,
itemCount: controller.historyWindow.lines.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
child: Text(
controller.historyWindow.lines[index],
style: Theme.of(context).textTheme.bodySmall,
),
);
},
separatorBuilder: (context, index) => Divider(
height: 1,
color: Theme.of(
context,
).colorScheme.outlineVariant,
),
),
),
),
const SizedBox(height: 4),
Container(
key: const Key('terminal_scrollback_actions'),
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration(
color: const Color(0xFF0D1115),
borderRadius: BorderRadius.zero,
border: Border.all(color: const Color(0xFF2A231B)),
),
child: isCompact
? _buildCompactHistoryActions(context)
: _buildWideHistoryActions(context),
),
],
),
),
const SizedBox(height: 6),
AppPanel(
tone: AppPanelTone.emphasis,
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
borderRadius: BorderRadius.zero,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.pause_circle_outline,
size: 18,
color: const Color(0xFFC2A574),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Browsing history. Live output is still arriving.',
style: Theme.of(context).textTheme.bodySmall,
),
if (controller.hasPendingLiveOutput)
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: controller.jumpToLive,
child: const Text('New output available'),
),
),
],
),
),
],
),
),
],
),
),
);
},
);
}
Widget _buildCompactHistoryActions(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
controller.historyWindow.hasMoreAbove
? 'Recent history is loaded. Older lines are not loaded yet.'
: 'All loaded history is visible.',
style: Theme.of(context).textTheme.bodySmall,
),
if (controller.historyWindow.hasMoreAbove) ...[
const SizedBox(height: 8),
TextButton.icon(
onPressed: _coordinator.isLoadingOlderHistory
? null
: _coordinator.loadOlderHistory,
icon: _coordinator.isLoadingOlderHistory
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.unfold_less_double),
label: Text(
_coordinator.isLoadingOlderHistory
? 'Loading older lines...'
: 'Load older lines',
),
),
],
],
);
}
Widget _buildWideHistoryActions(BuildContext context) {
return Row(
children: [
Expanded(
child: Text(
controller.historyWindow.hasMoreAbove
? 'Recent history is loaded. Older lines are not loaded yet.'
: 'All loaded history is visible.',
style: Theme.of(context).textTheme.bodySmall,
),
),
if (controller.historyWindow.hasMoreAbove) ...[
const SizedBox(width: 8),
TextButton.icon(
onPressed: _coordinator.isLoadingOlderHistory
? null
: _coordinator.loadOlderHistory,
icon: _coordinator.isLoadingOlderHistory
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.unfold_less_double),
label: Text(
_coordinator.isLoadingOlderHistory
? 'Loading older lines...'
: 'Load older lines',
),
),
],
],
);
}
Widget _buildCommandDeck(BuildContext context, bool isCompact) {
return AppPanel(
key: const Key('terminal_command_deck'),
tone: AppPanelTone.emphasis,
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
borderRadius: BorderRadius.zero,
child: AnimatedBuilder(
animation: _pageStateListenable,
builder: (context, _) {
return Column(
key: const Key('terminal_action_bar'),
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(child: _buildInputField(context)),
const SizedBox(width: 8),
_buildModeButton(),
const SizedBox(width: 6),
_buildIconActionButton(
key: const Key('terminal_keys_toggle_button'),
onPressed: _toggleKeyTray,
tooltip: _isKeyTrayVisible
? 'Hide quick keys'
: 'Show quick keys',
icon: Icon(
_isKeyTrayVisible
? Icons.keyboard_arrow_down
: Icons.keyboard_command_key,
),
),
const SizedBox(width: 6),
_buildIconActionButton(
key: const Key('terminal_presets_button'),
onPressed: _showPresetsSheet,
tooltip: 'Show presets',
icon: const Icon(Icons.flash_on_outlined),
),
const SizedBox(width: 6),
_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,
),
),
],
);
},
),
);
}
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() {
return KeyedSubtree(
key: const Key('terminal_direct_input_toggle'),
child: _buildIconActionButton(
key: const Key('terminal_mode_button'),
onPressed: _canSendInput ? _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 _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),
),
);
}
Widget _buildCommandDeckAction(Widget child) {
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',
TerminalConnectionState.reconnecting => 'Reconnecting',
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;
IconData get _statusIcon => switch (_connectionState) {
TerminalConnectionState.connecting => Icons.sync,
TerminalConnectionState.connected => Icons.check_circle,
TerminalConnectionState.reconnecting => Icons.refresh,
TerminalConnectionState.disconnected => Icons.portable_wifi_off,
};
Color _statusColor(BuildContext context) => switch (_connectionState) {
TerminalConnectionState.connecting => Theme.of(
context,
).colorScheme.tertiary,
TerminalConnectionState.connected => Theme.of(context).colorScheme.primary,
TerminalConnectionState.reconnecting => Theme.of(
context,
).colorScheme.secondary,
TerminalConnectionState.disconnected => Theme.of(context).colorScheme.error,
};
TerminalConnectionState get _connectionState => controller.connectionState;
}
class _QuickTerminalKey {
const _QuickTerminalKey({
required this.keyId,
required this.label,
required this.input,
this.repeatable = false,
});
final String keyId;
final String label;
final String input;
final bool repeatable;
}