1404 lines
45 KiB
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 }
|