1308 lines
44 KiB
Dart
1308 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 '../presets/preset_repository.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;
|
|
}
|