TermRemoteCtl/apps/mobile_app/lib/features/terminal/terminal_page.dart

1404 lines
45 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/services.dart';
import 'package:xterm/xterm.dart';
import 'package:xterm/src/ui/controller.dart' as xterm_ui;
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';
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_interaction_controller.dart';
import 'terminal_restore_payload.dart';
import 'terminal_restore_decision.dart';
import 'terminal_session_coordinator.dart';
import 'terminal_snapshot.dart';
import 'terminal_snapshot_storage.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 Duration _terminalResizeSettleDelay = Duration(milliseconds: 240);
static const List<_QuickTerminalKey> _editingControlKeys = [
_QuickTerminalKey(keyId: 'esc', label: 'Esc', input: '\u001b'),
_QuickTerminalKey(keyId: 'tab', label: 'Tab', input: '\t'),
_QuickTerminalKey(keyId: 'enter', label: 'Enter', input: '\r'),
_QuickTerminalKey(keyId: 'ctrl_c', label: 'Ctrl+C', input: '\u0003'),
_QuickTerminalKey(keyId: 'ctrl_d', label: 'Ctrl+D', input: '\u0004'),
_QuickTerminalKey(keyId: 'ctrl_l', label: 'Ctrl+L', input: '\u000c'),
_QuickTerminalKey(keyId: 'ctrl_u', label: 'Ctrl+U', input: '\u0015'),
_QuickTerminalKey(keyId: 'ctrl_z', label: 'Ctrl+Z', input: '\u001a'),
_QuickTerminalKey(
keyId: 'backspace',
label: 'Backspace',
input: '\x7f',
repeatable: true,
),
_QuickTerminalKey(
keyId: 'delete',
label: 'Del',
input: '\u001b[3~',
repeatable: true,
),
];
static const List<_QuickTerminalKey> _navigationKeys = [
_QuickTerminalKey(
keyId: 'home',
label: 'Home',
input: '\u001b[H',
repeatable: true,
),
_QuickTerminalKey(
keyId: 'end',
label: 'End',
input: '\u001b[F',
repeatable: true,
),
_QuickTerminalKey(
keyId: 'page_up',
label: 'PgUp',
input: '\u001b[5~',
repeatable: true,
),
_QuickTerminalKey(
keyId: 'page_down',
label: 'PgDn',
input: '\u001b[6~',
repeatable: true,
),
_QuickTerminalKey(
keyId: 'up',
label: 'Up',
input: '\u001b[A',
icon: Icons.arrow_upward,
repeatable: true,
),
_QuickTerminalKey(
keyId: 'down',
label: 'Down',
input: '\u001b[B',
icon: Icons.arrow_downward,
repeatable: true,
),
_QuickTerminalKey(
keyId: 'left',
label: 'Left',
input: '\u001b[D',
icon: Icons.arrow_back,
repeatable: true,
),
_QuickTerminalKey(
keyId: 'right',
label: 'Right',
input: '\u001b[C',
icon: Icons.arrow_forward,
repeatable: true,
),
];
static const List<_QuickTerminalKey> _symbolTerminalKeys = [
_QuickTerminalKey(keyId: 'symbol_at', label: '@', input: '@'),
_QuickTerminalKey(keyId: 'symbol_slash', label: '/', input: '/'),
_QuickTerminalKey(keyId: 'symbol_dash', label: '-', input: '-'),
_QuickTerminalKey(keyId: 'symbol_underscore', label: '_', input: '_'),
_QuickTerminalKey(keyId: 'symbol_dot', label: '.', input: '.'),
_QuickTerminalKey(keyId: 'symbol_colon', label: ':', input: ':'),
_QuickTerminalKey(keyId: 'symbol_tilde', label: '~', input: '~'),
_QuickTerminalKey(keyId: 'symbol_backslash', label: r'\', input: r'\'),
_QuickTerminalKey(keyId: 'symbol_pipe', label: '|', input: '|'),
_QuickTerminalKey(keyId: 'symbol_dollar', label: r'$', input: r'$'),
];
final Terminal terminal = Terminal(maxLines: 1000, reflowEnabled: false);
final TerminalInteractionController controller =
TerminalInteractionController();
final xterm_ui.TerminalController _terminalViewController =
xterm_ui.TerminalController();
final TerminalDiagnosticLog _diagnosticLog = TerminalDiagnosticLog();
final FocusNode _terminalFocusNode = FocusNode();
final ScrollController _terminalScrollController = ScrollController();
late final TerminalSessionCoordinator _coordinator;
late final TerminalSnapshotStorage _snapshotStorage;
late final Listenable _pageStateListenable;
Timer? _historySeedTimer;
Timer? _terminalResizeSettleTimer;
Timer? _snapshotPersistTimer;
String? _pendingHistorySeed;
bool _receivedSocketFrame = false;
bool _receivedRestorePayload = false;
bool _historySeeded = false;
bool _awaitingAttachReplayFrame = true;
bool _awaitingReconnectRestore = false;
bool _shouldReconnectOnResume = false;
bool _showExpandedControls = false;
bool _hasProvisionalSnapshot = false;
bool _terminalAutoResizeEnabled = true;
_TerminalInputMode _inputMode = _TerminalInputMode.read;
TerminalConnectionState? _lastConnectionState;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_snapshotStorage = ref.read(terminalSnapshotStorageProvider);
_coordinator = TerminalSessionCoordinator(
controller: controller,
apiClient: ref.read(agentApiClientProvider),
session: widget.session,
sessionFactory: ref.read(terminalSocketSessionFactoryProvider).create,
baseUri: widget.agentBaseUri,
diagnosticLog: _diagnosticLog,
onFrame: _handleTerminalFrame,
onRestore: _handleRestorePayload,
onHistoryLoaded: _handleHistoryLoaded,
viewportProvider: () => TerminalViewport(
columns: terminal.viewWidth,
rows: terminal.viewHeight,
),
);
_pageStateListenable = Listenable.merge([controller, _coordinator]);
_pageStateListenable.addListener(_handlePageStateChanged);
terminal.onResize = (width, height, _, _) {
_coordinator.handleTerminalResize(width, height);
};
terminal.onOutput = (data) {
final normalizedInput = _normalizeTerminalKeyboardInput(data);
_diagnosticLog.add('ui.terminal.key', normalizedInput);
_coordinator.sendInput(normalizedInput);
};
_applyInputMode(freezeResize: false);
unawaited(_bootstrapTerminal());
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_pageStateListenable.removeListener(_handlePageStateChanged);
_historySeedTimer?.cancel();
_terminalResizeSettleTimer?.cancel();
_snapshotPersistTimer?.cancel();
unawaited(_persistTerminalSnapshot());
_terminalFocusNode.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(_persistSnapshotAndSuspend());
}
}
@override
void didChangeMetrics() {
if (_inputMode == _TerminalInputMode.edit) {
_cancelTerminalResizeSettle();
_setTerminalAutoResizeEnabled(false);
return;
}
_freezeTerminalResizeUntilSettled();
}
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,
);
}
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);
}
_QuickTerminalKey _quickKey(String keyId) {
return <_QuickTerminalKey>[
..._editingControlKeys,
..._navigationKeys,
..._symbolTerminalKeys,
].singleWhere((key) => key.keyId == keyId);
}
void _setInputMode(_TerminalInputMode mode) {
if (_inputMode == mode) {
if (mode == _TerminalInputMode.edit) {
_terminalFocusNode.requestFocus();
}
return;
}
setState(() {
_inputMode = mode;
});
_applyInputMode();
}
void _applyInputMode({bool freezeResize = true}) {
_coordinator.setBackendResizeEnabled(_inputMode != _TerminalInputMode.edit);
if (_inputMode == _TerminalInputMode.edit) {
_cancelTerminalResizeSettle();
_setTerminalAutoResizeEnabled(false);
} else if (freezeResize) {
_freezeTerminalResizeUntilSettled();
} else {
_cancelTerminalResizeSettle();
_setTerminalAutoResizeEnabled(true);
}
_terminalFocusNode.canRequestFocus = _inputMode == _TerminalInputMode.edit;
if (_inputMode == _TerminalInputMode.edit) {
_terminalFocusNode.requestFocus();
return;
}
_terminalFocusNode.unfocus();
}
void _freezeTerminalResizeUntilSettled() {
_setTerminalAutoResizeEnabled(false);
_terminalResizeSettleTimer?.cancel();
_terminalResizeSettleTimer = Timer(_terminalResizeSettleDelay, () {
_terminalResizeSettleTimer = null;
if (!mounted || _inputMode == _TerminalInputMode.edit) {
return;
}
_setTerminalAutoResizeEnabled(true);
});
}
void _cancelTerminalResizeSettle() {
_terminalResizeSettleTimer?.cancel();
_terminalResizeSettleTimer = null;
}
void _setTerminalAutoResizeEnabled(bool enabled) {
if (_terminalAutoResizeEnabled == enabled || !mounted) {
return;
}
setState(() {
_terminalAutoResizeEnabled = enabled;
});
}
void _handleTerminalSurfaceTap() {
if (_inputMode != _TerminalInputMode.edit) {
return;
}
_terminalFocusNode.requestFocus();
}
void _restoreEditFocusIfNeeded() {
if (_inputMode == _TerminalInputMode.edit) {
_terminalFocusNode.requestFocus();
}
}
Future<void> _copySelectedOrVisibleText() async {
final selection = _terminalViewController.selection;
final selectedText = selection == null
? ''
: terminal.buffer.getText(selection).trimRight();
final text = selectedText.isNotEmpty
? selectedText
: terminal.buffer.getText().trimRight();
await _copyToClipboard(text);
}
Future<void> _copyRecentOutput() async {
await _copyToClipboard(terminal.buffer.getText().trimRight());
}
Future<void> _copyToClipboard(String text) async {
if (text.isEmpty) {
return;
}
await Clipboard.setData(ClipboardData(text: text));
if (!mounted) {
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Copied to clipboard')));
}
void _handleTerminalFrame(String frame) {
if (_awaitingAttachReplayFrame) {
_awaitingAttachReplayFrame = false;
if (_shouldSuppressAttachReplay(frame)) {
_receivedSocketFrame = true;
_cancelHistorySeedTimer();
return;
}
}
_receivedSocketFrame = true;
_awaitingReconnectRestore = false;
_cancelHistorySeedTimer();
terminal.write(frame);
_scheduleSnapshotPersist();
}
void _handleRestorePayload(TerminalRestorePayload restore) {
_awaitingAttachReplayFrame = false;
_receivedSocketFrame = true;
_receivedRestorePayload = true;
_awaitingReconnectRestore = false;
_cancelHistorySeedTimer();
final combined = restore.screenText + restore.pendingInput;
if (combined.isEmpty) {
_scheduleSnapshotPersist();
return;
}
final decision = decideTerminalRestore(
currentText: terminal.buffer.getText(),
restoreText: combined,
);
if (decision == TerminalRestoreDecision.replaceWithRestore) {
_resetTerminalForReplay();
terminal.write(combined);
}
_historySeeded = _terminalHasVisibleContent;
_scheduleSnapshotPersist();
if (_hasProvisionalSnapshot) {
unawaited(_rebuildRecentTimelineFromJournal());
}
}
void _handleHistoryLoaded(HistoryWindow history) {
final seedText = history.outputSeedText;
if (seedText.isEmpty) {
_pendingHistorySeed = null;
return;
}
_pendingHistorySeed = seedText;
_scheduleHistorySeedIfNeeded();
}
void _handlePageStateChanged() {
final connectionState = _connectionState;
if (_lastConnectionState != connectionState) {
if (connectionState == TerminalConnectionState.connecting ||
connectionState == TerminalConnectionState.reconnecting) {
_awaitingAttachReplayFrame = true;
_receivedRestorePayload = false;
if (connectionState == TerminalConnectionState.reconnecting) {
_awaitingReconnectRestore = true;
_receivedSocketFrame = false;
} else {
_awaitingReconnectRestore = false;
}
}
_lastConnectionState = connectionState;
}
_scheduleHistorySeedIfNeeded();
}
void _scheduleHistorySeedIfNeeded() {
if (_receivedRestorePayload) {
_cancelHistorySeedTimer();
return;
}
if (_awaitingReconnectRestore) {
_cancelHistorySeedTimer();
return;
}
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 = _trimTrailingNewlines(
_normalizeTerminalText(frame),
);
if (normalizedFrame.isEmpty) {
return false;
}
if (!_historySeeded && !_terminalHasVisibleContent) {
return false;
}
final normalizedTerminalText = _trimTrailingNewlines(
_normalizeTerminalText(terminal.buffer.getText()),
);
if (normalizedTerminalText.isNotEmpty &&
normalizedTerminalText.endsWith(normalizedFrame)) {
return true;
}
final pendingHistorySeed = _pendingHistorySeed;
return _historySeeded &&
pendingHistorySeed != null &&
_trimTrailingNewlines(_normalizeTerminalText(pendingHistorySeed)) ==
normalizedFrame;
}
static String _normalizeTerminalText(String text) {
return text.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
}
static String _trimTrailingNewlines(String text) {
var end = text.length;
while (end > 0 && text.codeUnitAt(end - 1) == 0x0A) {
end -= 1;
}
return end == text.length ? text : text.substring(0, end);
}
static String _normalizeTerminalKeyboardInput(String input) {
if (!input.contains('\n')) {
return input;
}
return input.replaceAll('\r\n', '\r').replaceAll('\n', '\r');
}
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> _openPresetManagementPage() {
return Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const PresetManagementPage()),
);
}
@override
Widget build(BuildContext context) {
final width = MediaQuery.sizeOf(context).width;
final isTight = width < 400;
final workingDirectory =
widget.project?.workingDirectory ??
widget.session.workingDirectory ??
'';
return 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: const [],
),
body: SafeArea(
top: false,
minimum: AppTheme.pagePadding,
child: Column(
children: [
_buildScrollbackSection(context),
Expanded(
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: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: _handleTerminalSurfaceTap,
child: TerminalView(
terminal,
controller: _terminalViewController,
focusNode: _terminalFocusNode,
autofocus: false,
autoResize: _terminalAutoResizeEnabled,
keyboardType: TextInputType.multiline,
deleteDetection: true,
readOnly: _inputMode == _TerminalInputMode.read,
scrollController: _terminalScrollController,
),
),
),
),
),
const SizedBox(height: 6),
_buildCommandDeck(context),
],
),
),
);
}
Widget _buildScrollbackSection(BuildContext context) {
final isCompact = MediaQuery.sizeOf(context).width < 420;
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: MediaQuery.sizeOf(context).width < 420
? _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) {
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 ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 220),
child: SingleChildScrollView(
child: Column(
key: const Key('terminal_action_bar'),
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Container(
key: const Key('terminal_mode_button'),
width: double.infinity,
padding: const EdgeInsets.only(bottom: 8),
child: Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
'Input mode',
style: Theme.of(context).textTheme.labelLarge,
),
_buildModeButton(
key: const Key('terminal_mode_read_button'),
label: 'Read',
icon: Icons.menu_book_outlined,
selected: _inputMode == _TerminalInputMode.read,
onPressed: () =>
_setInputMode(_TerminalInputMode.read),
),
_buildModeButton(
key: const Key('terminal_mode_edit_button'),
label: 'Edit',
icon: Icons.keyboard_outlined,
selected: _inputMode == _TerminalInputMode.edit,
onPressed: () =>
_setInputMode(_TerminalInputMode.edit),
),
],
),
),
_buildPinnedQuickKeysRow(),
if (_showExpandedControls) ...[
const SizedBox(height: 8),
_buildExpandedControls(context),
],
const SizedBox(height: 8),
_buildStatusRow(context),
],
),
),
);
},
),
);
}
Widget _buildPinnedQuickKeysRow() {
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
_buildMoreControlsButton(),
..._navigationKeys
.where(
(key) => switch (key.keyId) {
'up' || 'down' || 'left' || 'right' => true,
_ => false,
},
)
.map(_buildQuickKeyButton),
],
);
}
Widget _buildExpandedControls(BuildContext context) {
final presetsFuture = ref.watch(presetRepositoryProvider).listPresets();
return FutureBuilder<List<PresetCommand>>(
future: presetsFuture,
builder: (context, snapshot) {
final presets = snapshot.data ?? const <PresetCommand>[];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildKeyTrayRow(_editingControlKeys),
const SizedBox(height: 8),
_buildKeyTrayRow(
_navigationKeys
.where((key) {
return key.keyId != 'up' &&
key.keyId != 'down' &&
key.keyId != 'left' &&
key.keyId != 'right';
})
.toList(growable: false),
),
const SizedBox(height: 8),
_buildKeyTrayRow(_symbolTerminalKeys),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
key: const Key('terminal_latest_inline_button'),
onPressed: _jumpToBottom,
icon: const Icon(Icons.vertical_align_bottom),
label: const Text('Latest'),
),
OutlinedButton.icon(
key: const Key('terminal_reconnect_inline_button'),
onPressed: () => unawaited(_coordinator.reconnectNow()),
icon: const Icon(Icons.refresh),
label: const Text('Reconnect'),
),
if (widget.project != null)
OutlinedButton.icon(
key: const Key('terminal_new_inline_button'),
onPressed: () => unawaited(_openSiblingTerminal()),
icon: const Icon(Icons.add_box_outlined),
label: const Text('New terminal'),
),
TextButton.icon(
key: const Key('terminal_copy_selected_button'),
onPressed: _copySelectedOrVisibleText,
icon: const Icon(Icons.copy_outlined),
label: const Text('Copy Selected'),
),
TextButton.icon(
key: const Key('terminal_copy_recent_output_button'),
onPressed: _copyRecentOutput,
icon: const Icon(Icons.content_paste_go_outlined),
label: const Text('Copy Recent Output'),
),
TextButton.icon(
key: const Key('terminal_diagnostics_inline_button'),
onPressed: _showDiagnostics,
icon: const Icon(Icons.bug_report_outlined),
label: const Text('Diagnostics'),
),
],
),
const SizedBox(height: 8),
PresetPanel(
presets: presets,
onPresetSelected: (preset) {
unawaited(_sendLine(preset.commandText));
},
onManagePressed: () {
unawaited(_openPresetManagementPage());
},
),
],
);
},
);
}
Widget _buildKeyTrayRow(List<_QuickTerminalKey> keys) {
return Wrap(
spacing: 8,
runSpacing: 8,
children: keys.map(_buildQuickKeyButton).toList(growable: false),
);
}
Widget _buildQuickKeyButton(_QuickTerminalKey quickKey) {
return _buildCommandDeckAction(
RepeatableTerminalKeyButton(
key: Key('terminal_quick_key_${quickKey.keyId}'),
enabled: _canSendInput,
repeatable: quickKey.repeatable,
label: quickKey.label,
icon: quickKey.icon,
onPressed: () => _sendQuickKey(quickKey),
),
);
}
Widget _buildMoreControlsButton() {
return _buildCommandDeckAction(
RepeatableTerminalKeyButton(
key: const Key('terminal_more_controls_button'),
onPressed: () {
setState(() {
_showExpandedControls = !_showExpandedControls;
});
},
icon: _showExpandedControls ? Icons.expand_less : Icons.expand_more,
label: _showExpandedControls ? 'Less' : 'More',
),
);
}
Widget _buildStatusRow(BuildContext context) {
final mode = controller.isFollowingLiveOutput ? 'Live' : 'Scrollback';
final modeLabel = mode;
return Wrap(
key: const Key('terminal_status_summary'),
spacing: 8,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
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),
),
const SizedBox(width: 4),
StatusPill(
label: _statusLabel,
icon: _statusIcon,
color: _statusColor(context),
),
if (_connectionState != TerminalConnectionState.connected)
Text(
_coordinator.connectionStatus,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
],
);
}
Widget _buildCommandDeckAction(Widget child) {
return ExcludeFocus(child: child);
}
Widget _buildModeButton({
required Key key,
required String label,
required IconData icon,
required bool selected,
required VoidCallback onPressed,
}) {
final colorScheme = Theme.of(context).colorScheme;
return OutlinedButton.icon(
key: key,
onPressed: onPressed,
style: OutlinedButton.styleFrom(
backgroundColor: selected
? colorScheme.primary.withValues(alpha: 0.18)
: Colors.transparent,
side: BorderSide(
color: selected ? colorScheme.primary : const Color(0xFF4A3E31),
),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
icon: Icon(icon, size: 16),
label: Text(label),
);
}
String get _statusLabel => switch (_connectionState) {
TerminalConnectionState.connecting => 'Connecting',
TerminalConnectionState.connected => 'Connected',
TerminalConnectionState.reconnecting => 'Reconnecting',
TerminalConnectionState.disconnected => 'Offline',
};
bool get _canSendInput =>
controller.canSendInput && _inputMode == _TerminalInputMode.edit;
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;
Future<void> _bootstrapTerminal() async {
await _restoreLocalSnapshot();
if (!mounted) {
return;
}
await _coordinator.start();
}
Future<void> _restoreLocalSnapshot() async {
final snapshot = await _snapshotStorage.read(widget.session.sessionId);
if (!mounted || snapshot == null || snapshot.bufferText.isEmpty) {
return;
}
if (_terminalHasVisibleContent) {
return;
}
terminal.write(snapshot.bufferText);
_hasProvisionalSnapshot = true;
_historySeeded = true;
}
Future<void> _rebuildRecentTimelineFromJournal() async {
final history = await _coordinator.loadRecentHistoryWindow();
if (!mounted || history == null || history.outputSeedText.isEmpty) {
return;
}
final lastReceivedSequence = _coordinator.lastReceivedSequence;
if (lastReceivedSequence != null &&
history.currentSequence != null &&
lastReceivedSequence > history.currentSequence!) {
_hasProvisionalSnapshot = false;
return;
}
_resetTerminalForReplay();
terminal.write(history.outputSeedText);
_hasProvisionalSnapshot = false;
_historySeeded = true;
_scheduleSnapshotPersist();
}
void _scheduleSnapshotPersist() {
_snapshotPersistTimer?.cancel();
_snapshotPersistTimer = Timer(const Duration(milliseconds: 180), () {
_snapshotPersistTimer = null;
unawaited(_persistTerminalSnapshot());
});
}
Future<void> _persistSnapshotAndSuspend() async {
await _persistTerminalSnapshot();
await _coordinator.suspendForBackground();
}
Future<void> _persistTerminalSnapshot() async {
_snapshotPersistTimer?.cancel();
_snapshotPersistTimer = null;
final bufferText = terminal.buffer.getText();
if (bufferText.trim().isEmpty) {
return;
}
await _snapshotStorage.save(
TerminalSnapshot(
sessionId: widget.session.sessionId,
projectId: widget.session.projectId ?? widget.project?.projectId,
sessionName: widget.session.name,
bufferText: bufferText,
updatedAtUtc: DateTime.now().toUtc().toIso8601String(),
),
);
}
}
class _QuickTerminalKey {
const _QuickTerminalKey({
required this.keyId,
required this.label,
required this.input,
this.icon,
this.repeatable = false,
});
final String keyId;
final String label;
final String input;
final IconData? icon;
final bool repeatable;
}
enum _TerminalInputMode { read, edit }