chore: commit all pending project changes

This commit is contained in:
sladro 2026-03-31 18:13:06 +08:00
parent 4ed8525209
commit d9cf4e72e2
43 changed files with 2894 additions and 455 deletions

View File

@ -0,0 +1,35 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "TermRemoteCtl"
[setup]
script = ""
[[actions]]
name = "运行"
icon = "run"
command = '''
chcp 65001
[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
$OutputEncoding = [System.Text.UTF8Encoding]::new($false)
$env:PYTHONUTF8='1'
$env:PYTHONIOENCODING='utf-8'
Set-Location 'D:\App\Flutter\TermRemoteCtl\apps\mobile_app'
C:\tools\flutter\bin\flutter.bat build apk --debug
'''
[[actions]]
name = "后端启动"
icon = "tool"
command = '''
Get-Process dotnet -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
$proj='D:\App\Flutter\TermRemoteCtl\apps\windows_agent\src\TermRemoteCtl.Agent\TermRemoteCtl.Agent.csproj'
$work='D:\App\Flutter\TermRemoteCtl\work'
New-Item -ItemType Directory -Force $work | Out-Null
$stdout=Join-Path $work 'windows_agent.stdout.log'
$stderr=Join-Path $work 'windows_agent.stderr.log'
$proc=Start-Process dotnet -ArgumentList @('run','--project',$proj) -WorkingDirectory 'D:\App\Flutter\TermRemoteCtl' -RedirectStandardOutput $stdout -RedirectStandardError $stderr -PassThru
Start-Sleep -Seconds 6
Invoke-WebRequest -UseBasicParsing 'http://127.0.0.1:5067/health'
'''

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import '../features/sessions/session_list_page.dart';
import '../features/projects/project_list_page.dart';
class TermRemoteCtlApp extends StatelessWidget {
const TermRemoteCtlApp({super.key});
@ -10,7 +10,7 @@ class TermRemoteCtlApp extends StatelessWidget {
return MaterialApp(
title: 'TermRemoteCtl',
theme: ThemeData(colorSchemeSeed: Colors.blue),
home: const SessionListPage(),
home: const ProjectListPage(),
);
}
}

View File

@ -66,6 +66,10 @@ class AgentApiClient {
return _readJsonMap(response.data, 'project');
}
Future<void> deleteProject(String projectId) async {
await _dio.deleteUri(projectUri(projectId));
}
Future<Map<String, dynamic>> createSession({
String? name,
String? projectId,

View File

@ -1,6 +1,8 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'agent_api_client.dart';
import '../../features/projects/project.dart';
import '../../features/projects/project_repository.dart';
import '../../features/sessions/session_repository.dart';
import '../../features/sessions/session.dart';
@ -16,6 +18,14 @@ final sessionRepositoryProvider = Provider<SessionRepository>((ref) {
return SessionRepository(ref.watch(agentApiClientProvider));
});
final projectRepositoryProvider = Provider<ProjectRepository>((ref) {
return ProjectRepository(ref.watch(agentApiClientProvider));
});
final projectsProvider = FutureProvider<List<Project>>((ref) {
return ref.watch(projectRepositoryProvider).listProjects();
});
final sessionsProvider = FutureProvider<List<Session>>((ref) {
return ref.watch(sessionRepositoryProvider).listSessions();
});

View File

@ -0,0 +1,18 @@
import 'package:dio/dio.dart';
String formatAgentError(Object error, {String fallback = 'Request failed.'}) {
if (error case DioException(response: final response?)) {
final data = response.data;
if (data is Map) {
final code = data['error'];
if (code == 'invalid_working_directory') {
return 'Working directory does not exist. Update the project path and try again.';
}
if (code == 'project_not_found') {
return 'Project could not be found. Refresh the list and try again.';
}
}
}
return '$fallback $error';
}

View File

@ -0,0 +1,41 @@
import '../sessions/session.dart';
class Project {
Project({
required this.projectId,
required this.name,
required this.workingDirectory,
required this.createdAtUtc,
required this.updatedAtUtc,
});
final String projectId;
final String name;
final String workingDirectory;
final DateTime createdAtUtc;
final DateTime updatedAtUtc;
factory Project.fromJson(Map<String, dynamic> json) => Project(
projectId: json['projectId'] as String,
name: json['name'] as String,
workingDirectory: json['workingDirectory'] as String,
createdAtUtc: DateTime.parse(json['createdAtUtc'] as String),
updatedAtUtc: DateTime.parse(json['updatedAtUtc'] as String),
);
}
class ProjectDetail {
ProjectDetail({required this.project, required this.recentSessions});
final Project project;
final List<Session> recentSessions;
factory ProjectDetail.fromJson(Map<String, dynamic> json) => ProjectDetail(
project: Project.fromJson(json),
recentSessions: ((json['recentSessions'] as List?) ?? const <dynamic>[])
.map(
(entry) => Session.fromJson(Map<String, dynamic>.from(entry as Map)),
)
.toList(growable: false),
);
}

View File

@ -0,0 +1,315 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/network/agent_connection_providers.dart';
import '../../core/network/agent_error_formatter.dart';
import '../sessions/session.dart';
import '../terminal/terminal_page.dart';
import 'project.dart';
class ProjectDetailPage extends ConsumerStatefulWidget {
const ProjectDetailPage({super.key, required this.project});
final Project project;
@override
ConsumerState<ProjectDetailPage> createState() => _ProjectDetailPageState();
}
class _ProjectDetailPageState extends ConsumerState<ProjectDetailPage> {
late Future<ProjectDetail> _detailFuture;
@override
void initState() {
super.initState();
_detailFuture = _loadDetail();
}
Future<ProjectDetail> _loadDetail() {
return ref
.read(projectRepositoryProvider)
.getProjectDetail(widget.project.projectId);
}
Future<void> _refreshDetail() async {
setState(() {
_detailFuture = _loadDetail();
});
await _detailFuture;
}
Future<void> _openNewTerminal() async {
final repository = ref.read(sessionRepositoryProvider);
try {
final session = await repository.createSession(
projectId: widget.project.projectId,
);
if (!mounted) {
return;
}
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => TerminalPage(
session: session,
agentBaseUri: ref.read(agentBaseUriProvider),
project: widget.project,
),
),
);
await _refreshDetail();
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
formatAgentError(error, fallback: 'Failed to open terminal.'),
),
),
);
}
}
Future<void> _editProject(Project project) async {
final nameController = TextEditingController(text: project.name);
final pathController = TextEditingController(
text: project.workingDirectory,
);
try {
final shouldSave = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Edit project'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Project name'),
),
const SizedBox(height: 12),
TextField(
controller: pathController,
decoration: const InputDecoration(
labelText: 'Working directory',
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Save'),
),
],
),
);
if (shouldSave != true) {
return;
}
await ref
.read(projectRepositoryProvider)
.updateProject(
projectId: project.projectId,
name: nameController.text.trim(),
workingDirectory: pathController.text.trim(),
);
await _refreshDetail();
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
formatAgentError(error, fallback: 'Failed to update project.'),
),
),
);
}
}
void _openExistingSession(Session session, Project project) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => TerminalPage(
session: session,
agentBaseUri: ref.read(agentBaseUriProvider),
project: project,
),
),
);
}
Future<void> _deleteProject(Project project) async {
final shouldDelete = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Delete project'),
content: Text(
'Delete "${project.name}"? This also deletes all sessions and terminal history for this project.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete'),
),
],
);
},
);
if (shouldDelete != true) {
return;
}
try {
await ref
.read(projectRepositoryProvider)
.deleteProject(project.projectId);
ref.invalidate(projectsProvider);
ref.invalidate(sessionsProvider);
if (!mounted) {
return;
}
Navigator.of(context).pop();
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
formatAgentError(error, fallback: 'Failed to delete project.'),
),
),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.project.name),
actions: [
IconButton(
onPressed: _refreshDetail,
tooltip: 'Refresh project',
icon: const Icon(Icons.refresh),
),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _openNewTerminal,
icon: const Icon(Icons.add),
label: const Text('Open terminal'),
),
body: FutureBuilder<ProjectDetail>(
future: _detailFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text('Failed to load project: ${snapshot.error}'),
),
);
}
final detail = snapshot.data!;
final project = detail.project;
return ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
project.name,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(project.workingDirectory),
const SizedBox(height: 16),
Row(
children: [
FilledButton.icon(
onPressed: _openNewTerminal,
icon: const Icon(Icons.terminal),
label: const Text('Open terminal'),
),
const SizedBox(width: 12),
OutlinedButton.icon(
onPressed: () => _editProject(project),
icon: const Icon(Icons.edit_outlined),
label: const Text('Edit'),
),
const SizedBox(width: 12),
OutlinedButton.icon(
onPressed: () => _deleteProject(project),
icon: const Icon(Icons.delete_outline),
label: const Text('Delete'),
),
],
),
],
),
),
),
const SizedBox(height: 16),
Text(
'Recent sessions',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
if (detail.recentSessions.isEmpty)
const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('No recent sessions for this project yet.'),
),
)
else
...detail.recentSessions.map(
(session) => Card(
child: ListTile(
onTap: () => _openExistingSession(session, project),
leading: const Icon(Icons.terminal),
title: Text(session.name),
subtitle: Text('Status: ${session.status}'),
),
),
),
],
);
},
),
);
}
}

View File

@ -0,0 +1,412 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/network/agent_connection_providers.dart';
import '../../core/network/agent_error_formatter.dart';
import '../sessions/session_list_page.dart';
import '../terminal/terminal_page.dart';
import 'project.dart';
import 'project_detail_page.dart';
class ProjectListPage extends ConsumerStatefulWidget {
const ProjectListPage({super.key});
@override
ConsumerState<ProjectListPage> createState() => _ProjectListPageState();
}
class _ProjectListPageState extends ConsumerState<ProjectListPage> {
late final TextEditingController _agentUrlController;
@override
void initState() {
super.initState();
_agentUrlController = TextEditingController(
text: ref.read(agentBaseUriProvider).toString(),
);
}
@override
void dispose() {
_agentUrlController.dispose();
super.dispose();
}
Future<void> _reloadProjects() async {
ref.invalidate(projectsProvider);
try {
await ref.read(projectsProvider.future);
} catch (error) {
if (!mounted) {
return;
}
_showMessage('Failed to load projects: $error');
}
}
void _showMessage(String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
Future<void> _applyAgentUrl() async {
final parsedUri = Uri.tryParse(_agentUrlController.text.trim());
if (parsedUri == null ||
parsedUri.scheme.isEmpty ||
parsedUri.host.isEmpty) {
_showMessage('Enter a valid agent base URL.');
return;
}
final current = ref.read(agentBaseUriProvider);
if (current == parsedUri) {
return;
}
ref.read(agentBaseUriProvider.notifier).state = parsedUri;
ref.invalidate(sessionsProvider);
await _reloadProjects();
}
Future<void> _showProjectEditor({Project? existing}) async {
final nameController = TextEditingController(text: existing?.name ?? '');
final pathController = TextEditingController(
text: existing?.workingDirectory ?? '',
);
try {
final shouldSubmit = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: Text(existing == null ? 'New project' : 'Edit project'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
autofocus: true,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: 'Project name',
hintText: 'TermRemoteCtl',
),
),
const SizedBox(height: 12),
TextField(
controller: pathController,
textInputAction: TextInputAction.done,
decoration: const InputDecoration(
labelText: 'Working directory',
hintText: r'C:\repo\termremotectl',
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(existing == null ? 'Create' : 'Save'),
),
],
);
},
);
if (shouldSubmit != true) {
return;
}
final name = nameController.text.trim();
final workingDirectory = pathController.text.trim();
if (name.isEmpty || workingDirectory.isEmpty) {
_showMessage('Project name and working directory are required.');
return;
}
final repository = ref.read(projectRepositoryProvider);
if (existing == null) {
await repository.createProject(
name: name,
workingDirectory: workingDirectory,
);
} else {
await repository.updateProject(
projectId: existing.projectId,
name: name,
workingDirectory: workingDirectory,
);
}
await _reloadProjects();
} catch (error) {
if (!mounted) {
return;
}
_showMessage('Failed to save project: $error');
}
}
void _openProject(Project project) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => ProjectDetailPage(project: project),
),
);
}
Future<void> _deleteProject(Project project) async {
final shouldDelete = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Delete project'),
content: Text(
'Delete "${project.name}"? This also deletes all sessions and terminal history for this project.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete'),
),
],
);
},
);
if (shouldDelete != true) {
return;
}
try {
await ref
.read(projectRepositoryProvider)
.deleteProject(project.projectId);
ref.invalidate(sessionsProvider);
await _reloadProjects();
} catch (error) {
if (!mounted) {
return;
}
_showMessage('Failed to delete project: $error');
}
}
Future<void> _openTerminal(Project project) async {
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: ref.read(agentBaseUriProvider),
project: project,
),
),
);
} catch (error) {
if (!mounted) {
return;
}
_showMessage(
formatAgentError(error, fallback: 'Failed to open terminal.'),
);
}
}
Widget _buildAgentConfigCard(Uri baseUri) {
return Card(
margin: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Agent base URL',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
controller: _agentUrlController,
decoration: const InputDecoration(
hintText: 'http://10.0.2.2:5067',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.url,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _applyAgentUrl(),
),
),
const SizedBox(width: 12),
FilledButton(
onPressed: _applyAgentUrl,
child: const Text('Use'),
),
],
),
const SizedBox(height: 8),
Text(
'Project requests use this base origin: ${baseUri.toString()}.',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
);
}
Widget _buildProjectsBody(AsyncValue<List<Project>> projectsAsync) {
return projectsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) {
return RefreshIndicator(
onRefresh: _reloadProjects,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(24),
children: [
const SizedBox(height: 96),
const Icon(Icons.folder_off_outlined, size: 48),
const SizedBox(height: 16),
Text(
'Could not load projects',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text('$error', textAlign: TextAlign.center),
],
),
);
},
data: (projects) {
return RefreshIndicator(
onRefresh: _reloadProjects,
child: projects.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(24),
children: [
const SizedBox(height: 56),
Icon(
Icons.folder_open,
size: 56,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16),
Text(
'No projects yet',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Add a project to launch new terminals in a known working directory.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
],
)
: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
itemCount: projects.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final project = projects[index];
return Card(
child: ListTile(
onTap: () => _openProject(project),
leading: const Icon(Icons.folder_copy_outlined),
title: Text(project.name),
subtitle: Text(project.workingDirectory),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
key: Key(
'project_delete_button_${project.projectId}',
),
tooltip: 'Delete project',
onPressed: () => _deleteProject(project),
icon: const Icon(Icons.delete_outline),
),
FilledButton(
onPressed: () => _openTerminal(project),
child: const Text('Open terminal'),
),
],
),
),
);
},
),
);
},
);
}
@override
Widget build(BuildContext context) {
final baseUri = ref.watch(agentBaseUriProvider);
final projectsAsync = ref.watch(projectsProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Projects'),
actions: [
IconButton(
onPressed: _reloadProjects,
tooltip: 'Refresh projects',
icon: const Icon(Icons.refresh),
),
IconButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const SessionListPage(),
),
);
},
tooltip: 'All sessions',
icon: const Icon(Icons.terminal),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: _showProjectEditor,
tooltip: 'Create project',
child: const Icon(Icons.add),
),
body: Column(
children: [
_buildAgentConfigCard(baseUri),
Expanded(child: _buildProjectsBody(projectsAsync)),
],
),
);
}
}

View File

@ -0,0 +1,47 @@
import 'package:term_remote_ctl/core/network/agent_api_client.dart';
import 'project.dart';
class ProjectRepository {
ProjectRepository(this._client);
final AgentApiClient _client;
Future<List<Project>> listProjects() async {
final projects = await _client.listProjects();
return projects.map(Project.fromJson).toList(growable: false);
}
Future<Project> createProject({
required String name,
required String workingDirectory,
}) async {
final project = await _client.createProject(
name: name,
workingDirectory: workingDirectory,
);
return Project.fromJson(project);
}
Future<Project> updateProject({
required String projectId,
required String name,
required String workingDirectory,
}) async {
final project = await _client.updateProject(
projectId: projectId,
name: name,
workingDirectory: workingDirectory,
);
return Project.fromJson(project);
}
Future<ProjectDetail> getProjectDetail(String projectId) async {
final detail = await _client.getProjectDetail(projectId);
return ProjectDetail.fromJson(detail);
}
Future<void> deleteProject(String projectId) {
return _client.deleteProject(projectId);
}
}

View File

@ -3,15 +3,35 @@ class Session {
required this.sessionId,
required this.name,
required this.status,
this.projectId,
this.workingDirectory,
this.createdAtUtc,
this.updatedAtUtc,
});
final String sessionId;
final String name;
final String status;
final String? projectId;
final String? workingDirectory;
final DateTime? createdAtUtc;
final DateTime? updatedAtUtc;
factory Session.fromJson(Map<String, dynamic> json) => Session(
sessionId: json['sessionId'] as String,
name: json['name'] as String,
status: json['status'] as String,
);
sessionId: json['sessionId'] as String,
name: json['name'] as String,
status: json['status'] as String,
projectId: json['projectId'] as String?,
workingDirectory: json['workingDirectory'] as String?,
createdAtUtc: _tryParseDateTime(json['createdAtUtc']),
updatedAtUtc: _tryParseDateTime(json['updatedAtUtc']),
);
static DateTime? _tryParseDateTime(dynamic value) {
if (value is! String || value.isEmpty) {
return null;
}
return DateTime.tryParse(value);
}
}

View File

@ -0,0 +1,42 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
class TerminalDiagnosticLog extends ChangeNotifier {
TerminalDiagnosticLog({this.maxEntries = 40});
final int maxEntries;
final List<String> _entries = <String>[];
bool _notificationScheduled = false;
List<String> get entries => List.unmodifiable(_entries);
void add(String event, [String? detail]) {
final message = detail == null || detail.isEmpty
? event
: '$event | $detail';
_entries.insert(0, message);
if (_entries.length > maxEntries) {
_entries.removeLast();
}
_notifySafely();
}
void _notifySafely() {
final schedulerPhase = SchedulerBinding.instance.schedulerPhase;
if (schedulerPhase == SchedulerPhase.idle ||
schedulerPhase == SchedulerPhase.postFrameCallbacks) {
notifyListeners();
return;
}
if (_notificationScheduled) {
return;
}
_notificationScheduled = true;
SchedulerBinding.instance.addPostFrameCallback((_) {
_notificationScheduled = false;
notifyListeners();
});
}
}

View File

@ -10,6 +10,8 @@ enum TerminalConnectionState {
}
class TerminalInteractionController extends ChangeNotifier {
static const int maxTrackedLiveLines = 200;
TerminalInteractionController({
HistoryWindow historyWindow = const HistoryWindow(
lines: <String>[],
@ -22,6 +24,7 @@ class TerminalInteractionController extends ChangeNotifier {
bool _hasPendingLiveOutput = false;
HistoryWindow _historyWindow;
final List<String> _liveLines = <String>[];
int _liveOutputCount = 0;
TerminalConnectionState get connectionState => _connectionState;
@ -29,12 +32,15 @@ class TerminalInteractionController extends ChangeNotifier {
bool get hasPendingLiveOutput => _hasPendingLiveOutput;
bool get canSendInput => _connectionState == TerminalConnectionState.connected;
bool get canSendInput =>
_connectionState == TerminalConnectionState.connected;
HistoryWindow get historyWindow => _historyWindow;
List<String> get liveLines => List.unmodifiable(_liveLines);
int get liveOutputCount => _liveOutputCount;
void markConnecting() {
_connectionState = TerminalConnectionState.connecting;
notifyListeners();
@ -75,6 +81,10 @@ class TerminalInteractionController extends ChangeNotifier {
void applyFrame(String chunk) {
_liveLines.add(chunk);
_liveOutputCount += 1;
while (_liveLines.length > maxTrackedLiveLines) {
_liveLines.removeAt(0);
}
notifyListeners();
}
@ -82,7 +92,14 @@ class TerminalInteractionController extends ChangeNotifier {
_historyWindow = historyWindow;
_liveLines
..clear()
..addAll(historyWindow.lines);
..addAll(
historyWindow.lines.length <= maxTrackedLiveLines
? historyWindow.lines
: historyWindow.lines.sublist(
historyWindow.lines.length - maxTrackedLiveLines,
),
);
_liveOutputCount = historyWindow.lines.length;
_hasPendingLiveOutput = false;
notifyListeners();
}

View File

@ -5,7 +5,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:xterm/xterm.dart';
import '../../core/network/agent_connection_providers.dart';
import '../../core/network/agent_error_formatter.dart';
import '../projects/project.dart';
import '../sessions/session.dart';
import 'terminal_diagnostic_log.dart';
import 'history_window.dart';
import 'terminal_interaction_controller.dart';
import 'terminal_session_coordinator.dart';
@ -16,21 +19,36 @@ class TerminalPage extends ConsumerStatefulWidget {
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> {
static const List<_QuickTerminalKey> _quickTerminalKeys = [
_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: 'up', label: 'Up', input: '\u001b[A'),
_QuickTerminalKey(keyId: 'down', label: 'Down', input: '\u001b[B'),
_QuickTerminalKey(keyId: 'left', label: 'Left', input: '\u001b[D'),
_QuickTerminalKey(keyId: 'right', label: 'Right', input: '\u001b[C'),
];
final Terminal terminal = Terminal(maxLines: 1000);
final TerminalInteractionController controller =
TerminalInteractionController();
final TerminalDiagnosticLog _diagnosticLog = TerminalDiagnosticLog();
final FocusNode _terminalFocusNode = FocusNode();
final TextEditingController _inputController = TextEditingController();
final ScrollController _terminalScrollController = ScrollController();
late final TerminalSessionCoordinator _coordinator;
late final Listenable _controllerAndCoordinator;
@ -43,6 +61,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
session: widget.session,
sessionFactory: ref.read(terminalSocketSessionFactoryProvider).create,
baseUri: widget.agentBaseUri,
diagnosticLog: _diagnosticLog,
onFrame: terminal.write,
onHistoryLoaded: (history) {
if (history.lines.isNotEmpty) {
@ -58,34 +77,261 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
terminal.onResize = (width, height, _, _) {
_coordinator.handleTerminalResize(width, height);
};
terminal.onOutput = _coordinator.sendInput;
terminal.onOutput = (data) {
_diagnosticLog.add('ui.terminal.key', data);
_coordinator.sendInput(data);
};
unawaited(_coordinator.start());
}
@override
void dispose() {
_terminalFocusNode.dispose();
_inputController.dispose();
_terminalScrollController.dispose();
unawaited(_coordinator.close());
controller.dispose();
super.dispose();
}
Future<void> _sendLine() async {
final input = _inputController.text;
if (!_canSendInput || input.trim().isEmpty) {
Future<void> _openSiblingTerminal() async {
final project = widget.project;
if (project == null) {
return;
}
_coordinator.sendInput('$input\r');
_inputController.clear();
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,
);
}
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);
_terminalFocusNode.requestFocus();
}
Future<void> _showDiagnostics() {
return showModalBottomSheet<void>(
context: context,
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: Theme.of(
context,
).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
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,
builder: (context) {
return SafeArea(
child: AnimatedBuilder(
animation: _controllerAndCoordinator,
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(),
_StatusChip(
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,
),
],
),
),
);
},
),
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.session.name),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(widget.session.name),
Text(
widget.project?.workingDirectory ??
widget.session.workingDirectory ??
'',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
actions: [
AnimatedBuilder(
animation: controller,
@ -110,7 +356,9 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
horizontal: 8,
vertical: 6,
),
child: Text('$mode | ${controller.liveLines.length} lines'),
child: Text(
'$mode | ${controller.liveLines.length} lines',
),
),
),
),
@ -137,166 +385,190 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
return const SizedBox.shrink();
}
return Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(12, 6, 12, 0),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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: 6),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 72),
child: DecoratedBox(
return Flexible(
fit: FlexFit.loose,
child: SingleChildScrollView(
padding: const EdgeInsets.only(top: 6),
child: Column(
children: [
Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(12, 0, 12, 0),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
),
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.surfaceContainerHigh,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
),
),
const SizedBox(height: 6),
Container(
key: const Key('terminal_scrollback_actions'),
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
),
child: 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,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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,
),
],
),
),
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 SizedBox(height: 6),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 72),
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
),
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,
),
)
: const Icon(Icons.unfold_less_double),
label: Text(
_coordinator.isLoadingOlderHistory
? 'Loading older lines...'
: 'Load older lines',
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: 6),
Container(
key: const Key('terminal_scrollback_actions'),
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
),
child: 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',
),
),
],
],
),
),
],
],
),
),
),
],
),
);
},
),
AnimatedBuilder(
animation: controller,
builder: (context, _) {
if (controller.isFollowingLiveOutput) {
return const SizedBox.shrink();
}
return Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(12, 4, 12, 0),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.pause_circle_outline,
size: 18,
color: Theme.of(context).colorScheme.onSecondaryContainer,
),
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?.copyWith(
color: Theme.of(context)
.colorScheme
.onSecondaryContainer,
Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(12, 4, 12, 0),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.pause_circle_outline,
size: 18,
color: Theme.of(
context,
).colorScheme.onSecondaryContainer,
),
),
if (controller.hasPendingLiveOutput)
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: controller.jumpToLive,
child: const Text('New output available'),
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
?.copyWith(
color: Theme.of(
context,
).colorScheme.onSecondaryContainer,
),
),
if (controller.hasPendingLiveOutput)
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: controller.jumpToLive,
child: const Text(
'New output available',
),
),
),
],
),
),
],
],
),
),
),
],
],
),
),
);
},
@ -306,91 +578,76 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
terminal,
focusNode: _terminalFocusNode,
autofocus: true,
scrollController: _terminalScrollController,
),
),
AnimatedBuilder(
animation: _controllerAndCoordinator,
builder: (context, _) {
return Material(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Padding(
padding: const EdgeInsets.all(12),
child: Container(
key: const Key('terminal_controls_panel'),
padding: const EdgeInsets.all(12),
Material(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
child: AnimatedBuilder(
animation: _controllerAndCoordinator,
builder: (context, _) {
return Container(
key: const Key('terminal_action_bar'),
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16),
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Terminal controls',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 12),
Row(
children: [
_StatusChip(
label: _statusLabel,
icon: _statusIcon,
color: _statusColor(context),
),
const SizedBox(width: 8),
_StatusChip(
label: controller.historyWindow.lines.isEmpty
? 'No history'
: 'History ready',
icon: controller.historyWindow.hasMoreAbove
? Icons.history_toggle_off
: Icons.history,
color: Theme.of(context).colorScheme.secondary,
),
const Spacer(),
OutlinedButton.icon(
onPressed: () => unawaited(_coordinator.reconnectNow()),
icon: const Icon(Icons.refresh),
label: const Text('Reconnect'),
),
],
),
const SizedBox(height: 12),
TextField(
controller: _inputController,
enabled: _canSendInput,
decoration: const InputDecoration(
labelText: 'Send input',
hintText: 'dir',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _sendLine(),
),
const SizedBox(height: 8),
Row(
children: [
FilledButton(
onPressed: _canSendInput ? _sendLine : null,
child: const Text('Send'),
),
const SizedBox(width: 12),
Expanded(
child: Text(
_coordinator.connectionStatus,
style: Theme.of(context).textTheme.bodySmall,
),
),
],
boxShadow: [
BoxShadow(
color: Theme.of(
context,
).shadowColor.withValues(alpha: 0.06),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
),
),
),
);
},
child: Row(
children: [
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: _quickTerminalKeys
.map((quickKey) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: OutlinedButton(
key: Key(
'terminal_quick_key_${quickKey.keyId}',
),
onPressed: _canSendInput
? () => _sendQuickKey(quickKey)
: null,
child: Text(quickKey.label),
),
);
})
.toList(growable: false),
),
),
),
const SizedBox(width: 8),
IconButton.filledTonal(
key: const Key('terminal_toggle_actions_button'),
onPressed: _showToolsSheet,
icon: const Icon(Icons.tune),
tooltip: 'Show tools',
),
],
),
);
},
),
),
),
],
),
@ -398,35 +655,47 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
}
String get _statusLabel => switch (_connectionState) {
TerminalConnectionState.connecting => 'Connecting',
TerminalConnectionState.connected => 'Connected',
TerminalConnectionState.reconnecting => 'Reconnecting',
TerminalConnectionState.disconnected => 'Offline',
};
TerminalConnectionState.connecting => 'Connecting',
TerminalConnectionState.connected => 'Connected',
TerminalConnectionState.reconnecting => 'Reconnecting',
TerminalConnectionState.disconnected => 'Offline',
};
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,
};
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.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,
});
final String keyId;
final String label;
final String input;
}
class _StatusChip extends StatelessWidget {
const _StatusChip({
required this.label,

View File

@ -4,25 +4,22 @@ import 'package:flutter/foundation.dart';
import '../../core/network/agent_api_client.dart';
import '../sessions/session.dart';
import 'terminal_diagnostic_log.dart';
import 'history_window.dart';
import 'terminal_interaction_controller.dart';
import 'terminal_socket_session.dart';
typedef CancelReconnect = void Function();
typedef ReconnectScheduler = CancelReconnect Function(
Duration delay,
Future<void> Function() callback,
);
typedef TerminalSessionFactory = TerminalSocketSession Function({
required Uri baseUri,
required Session session,
});
typedef ReconnectScheduler =
CancelReconnect Function(Duration delay, Future<void> Function() callback);
typedef TerminalSessionFactory =
TerminalSocketSession Function({
required Uri baseUri,
required Session session,
});
class TerminalViewport {
const TerminalViewport({
required this.columns,
required this.rows,
});
const TerminalViewport({required this.columns, required this.rows});
final int columns;
final int rows;
@ -38,9 +35,10 @@ class TerminalSessionCoordinator extends ChangeNotifier {
required this.viewportProvider,
Uri? baseUri,
this.onHistoryLoaded,
this.diagnosticLog,
ReconnectScheduler? reconnectScheduler,
}) : baseUri = baseUri ?? _defaultBaseUri,
_reconnectScheduler = reconnectScheduler ?? _defaultReconnectScheduler;
}) : baseUri = baseUri ?? _defaultBaseUri,
_reconnectScheduler = reconnectScheduler ?? _defaultReconnectScheduler;
static final Uri _defaultBaseUri = Uri(
scheme: 'https',
@ -48,7 +46,7 @@ class TerminalSessionCoordinator extends ChangeNotifier {
port: 9443,
);
static const Duration reconnectDelay = Duration(seconds: 1);
static const int initialHistoryLineCount = 1000;
static const int initialHistoryLineCount = 200;
final TerminalInteractionController controller;
final AgentApiClient apiClient;
@ -58,6 +56,7 @@ class TerminalSessionCoordinator extends ChangeNotifier {
final TerminalViewport Function() viewportProvider;
final Uri baseUri;
final void Function(HistoryWindow history)? onHistoryLoaded;
final TerminalDiagnosticLog? diagnosticLog;
final ReconnectScheduler _reconnectScheduler;
TerminalSocketSession? _socketSession;
@ -81,15 +80,17 @@ class TerminalSessionCoordinator extends ChangeNotifier {
if (isReconnect) {
controller.markReconnecting();
_connectionStatus = 'Reconnecting to ${session.name}...';
diagnosticLog?.add('socket.reconnect.start', session.sessionId);
} else {
controller.markConnecting();
_connectionStatus = 'Connecting...';
diagnosticLog?.add('socket.connect.start', session.sessionId);
}
notifyListeners();
_disposeActiveSessionInBackground();
if (!isReconnect && controller.liveLines.isEmpty) {
if (!isReconnect && controller.liveOutputCount == 0) {
await _loadHistory();
}
@ -97,10 +98,7 @@ class TerminalSessionCoordinator extends ChangeNotifier {
return;
}
final socketSession = sessionFactory(
baseUri: baseUri,
session: session,
);
final socketSession = sessionFactory(baseUri: baseUri, session: session);
_socketSession = socketSession;
try {
@ -121,8 +119,13 @@ class TerminalSessionCoordinator extends ChangeNotifier {
final viewport = viewportProvider();
socketSession.sendResize(viewport.columns, viewport.rows);
diagnosticLog?.add(
'socket.resize.send',
'${viewport.columns}x${viewport.rows}',
);
controller.markConnected();
_connectionStatus = 'Attached to ${session.name}';
diagnosticLog?.add('socket.attach.ack', session.sessionId);
notifyListeners();
} catch (error) {
if (_isDisposed || !identical(_socketSession, socketSession)) {
@ -131,17 +134,42 @@ class TerminalSessionCoordinator extends ChangeNotifier {
controller.markDisconnected();
_connectionStatus = 'Live connection unavailable: $error';
diagnosticLog?.add('socket.connect.error', '$error');
notifyListeners();
_scheduleReconnect();
}
}
void handleTerminalResize(int columns, int rows) {
diagnosticLog?.add('ui.terminal.resize', '${columns}x${rows}');
_socketSession?.sendResize(columns, rows);
}
void sendInput(String input) {
_socketSession?.sendInput(input);
final socketSession = _socketSession;
if (socketSession == null) {
diagnosticLog?.add(
'socket.input.skip',
'reason=no-session input=${_formatInputForDiagnostics(input)}',
);
return;
}
final result = socketSession.sendInput(input);
switch (result) {
case TerminalSocketDispatchResult.sent:
diagnosticLog?.add(
'socket.input.tx',
_formatInputForDiagnostics(input),
);
case TerminalSocketDispatchResult.noTransport:
diagnosticLog?.add(
'socket.input.skip',
'reason=no-transport input=${_formatInputForDiagnostics(input)}',
);
case TerminalSocketDispatchResult.emptyInput:
diagnosticLog?.add('socket.input.skip', 'reason=empty-input');
}
}
Future<void> loadOlderHistory() async {
@ -151,6 +179,7 @@ class TerminalSessionCoordinator extends ChangeNotifier {
_isLoadingOlderHistory = true;
_historyLineCount += initialHistoryLineCount;
diagnosticLog?.add('history.load.older', 'lineCount=$_historyLineCount');
notifyListeners();
try {
@ -173,6 +202,7 @@ class TerminalSessionCoordinator extends ChangeNotifier {
}
void _handleFrame(String chunk) {
diagnosticLog?.add('socket.frame.rx', chunk);
controller.registerIncomingFrame();
controller.applyFrame(chunk);
onFrame(chunk);
@ -192,14 +222,18 @@ class TerminalSessionCoordinator extends ChangeNotifier {
);
controller.loadHistory(history);
onHistoryLoaded?.call(history);
} catch (_) {
}
diagnosticLog?.add(
'history.loaded',
'${history.lines.length} lines, more=${history.hasMoreAbove}',
);
} catch (_) {}
}
void _scheduleReconnect() {
_cancelPendingReconnect();
controller.markReconnecting();
_connectionStatus = 'Connection lost. Reconnecting...';
diagnosticLog?.add('socket.disconnect', session.sessionId);
notifyListeners();
_cancelReconnect = _reconnectScheduler(reconnectDelay, () async {
if (_isDisposed) {
@ -240,4 +274,8 @@ class TerminalSessionCoordinator extends ChangeNotifier {
});
return timer.cancel;
}
static String _formatInputForDiagnostics(String input) {
return input.replaceAll('\r', r'\r').replaceAll('\n', r'\n');
}
}

View File

@ -8,18 +8,21 @@ import '../../core/network/agent_api_client.dart';
import '../../core/network/agent_socket_client.dart';
import '../sessions/session.dart';
typedef TerminalSocketTransportFactory = TerminalSocketTransport Function(Uri uri);
typedef TerminalSocketTransportFactory =
TerminalSocketTransport Function(Uri uri);
final terminalSocketSessionFactoryProvider =
Provider<TerminalSocketSessionFactory>((ref) {
return TerminalSocketSessionFactory(
transportFactory: WebSocketTerminalSocketTransport.connect,
);
});
return TerminalSocketSessionFactory(
transportFactory: WebSocketTerminalSocketTransport.connect,
);
});
class TerminalSocketSessionFactory {
TerminalSocketSessionFactory({TerminalSocketTransportFactory? transportFactory})
: _transportFactory = transportFactory ?? WebSocketTerminalSocketTransport.connect;
TerminalSocketSessionFactory({
TerminalSocketTransportFactory? transportFactory,
}) : _transportFactory =
transportFactory ?? WebSocketTerminalSocketTransport.connect;
final TerminalSocketTransportFactory _transportFactory;
@ -40,7 +43,8 @@ class TerminalSocketSession {
required this.sessionId,
required this.socketClient,
TerminalSocketTransportFactory? transportFactory,
}) : _transportFactory = transportFactory ?? WebSocketTerminalSocketTransport.connect;
}) : _transportFactory =
transportFactory ?? WebSocketTerminalSocketTransport.connect;
final String sessionId;
final AgentSocketClient socketClient;
@ -58,7 +62,9 @@ class TerminalSocketSession {
await dispose();
}
final transport = _transportFactory(socketClient.buildTerminalSocketUri(sessionId));
final transport = _transportFactory(
socketClient.buildTerminalSocketUri(sessionId),
);
_transport = transport;
final attachedCompleter = Completer<void>();
@ -105,13 +111,18 @@ class TerminalSocketSession {
}
}
void sendInput(String input) {
TerminalSocketDispatchResult sendInput(String input) {
final transport = _transport;
if (transport == null || input.isEmpty) {
return;
if (input.isEmpty) {
return TerminalSocketDispatchResult.emptyInput;
}
if (transport == null) {
return TerminalSocketDispatchResult.noTransport;
}
transport.send(jsonEncode(socketClient.buildInputMessage(input)));
return TerminalSocketDispatchResult.sent;
}
void sendResize(int columns, int rows) {
@ -132,8 +143,7 @@ class TerminalSocketSession {
await subscription?.cancel();
try {
await transport?.close();
} catch (_) {
}
} catch (_) {}
}
bool _handleAttachedAck(String frame) {
@ -142,13 +152,14 @@ class TerminalSocketSession {
if (decoded is Map && decoded['type'] == 'attached') {
return true;
}
} catch (_) {
}
} catch (_) {}
return false;
}
}
enum TerminalSocketDispatchResult { sent, noTransport, emptyInput }
abstract class TerminalSocketTransport {
Stream<dynamic> get stream;
void send(String message);

View File

@ -89,6 +89,20 @@ void main() {
);
});
test('deletes a project by id', () async {
final adapter = _FakeHttpClientAdapter();
final dio = Dio()..httpClientAdapter = adapter;
final client = AgentApiClient(Uri.parse('https://host:9443'), dio: dio);
await client.deleteProject('abc');
expect(adapter.lastOptions?.method, 'DELETE');
expect(
adapter.lastOptions?.uri.toString(),
'https://host:9443/api/projects/abc',
);
});
test('posts pairing redeem payload to the redeem endpoint', () async {
final adapter = _FakeHttpClientAdapter();
final dio = Dio()..httpClientAdapter = adapter;

View File

@ -0,0 +1,83 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:term_remote_ctl/core/network/agent_api_client.dart';
import 'package:term_remote_ctl/features/projects/project.dart';
import 'package:term_remote_ctl/features/projects/project_repository.dart';
void main() {
test('lists projects from the agent and maps them to models', () async {
final client = _FakeAgentApiClient(
projects: const [
<String, dynamic>{
'projectId': 'abc',
'name': 'TermRemoteCtl',
'workingDirectory': r'C:\repo\termremotectl',
'createdAtUtc': '2026-03-30T10:00:00Z',
'updatedAtUtc': '2026-03-30T10:00:00Z',
},
],
);
final repository = ProjectRepository(client);
final projects = await repository.listProjects();
expect(projects, hasLength(1));
expect(projects.single, isA<Project>());
expect(projects.single.projectId, 'abc');
expect(projects.single.workingDirectory, r'C:\repo\termremotectl');
});
test('creates a project through the agent and maps the response', () async {
final client = _FakeAgentApiClient(
createdProject: const <String, dynamic>{
'projectId': 'xyz',
'name': 'TRC',
'workingDirectory': r'D:\workspace\trc',
'createdAtUtc': '2026-03-30T10:00:00Z',
'updatedAtUtc': '2026-03-30T10:00:00Z',
},
);
final repository = ProjectRepository(client);
final project = await repository.createProject(
name: 'TRC',
workingDirectory: r'D:\workspace\trc',
);
expect(client.lastCreatedProjectName, 'TRC');
expect(client.lastCreatedProjectWorkingDirectory, r'D:\workspace\trc');
expect(project.projectId, 'xyz');
});
}
class _FakeAgentApiClient extends AgentApiClient {
_FakeAgentApiClient({this.projects = const [], this.createdProject})
: super(Uri.parse('https://host:9443'));
final List<Map<String, dynamic>> projects;
final Map<String, dynamic>? createdProject;
String? lastCreatedProjectName;
String? lastCreatedProjectWorkingDirectory;
@override
Future<List<Map<String, dynamic>>> listProjects() async {
return projects;
}
@override
Future<Map<String, dynamic>> createProject({
required String name,
required String workingDirectory,
}) async {
lastCreatedProjectName = name;
lastCreatedProjectWorkingDirectory = workingDirectory;
return createdProject ??
<String, dynamic>{
'projectId': 'generated',
'name': name,
'workingDirectory': workingDirectory,
'createdAtUtc': '2026-03-30T10:00:00Z',
'updatedAtUtc': '2026-03-30T10:00:00Z',
};
}
}

View File

@ -39,7 +39,7 @@ void main() {
);
final repository = SessionRepository(client);
final session = await repository.createSession('new-session');
final session = await repository.createSession(name: 'new-session');
expect(client.lastCreatedName, 'new-session');
expect(session, isA<Session>());
@ -49,10 +49,8 @@ void main() {
}
class _FakeAgentApiClient extends AgentApiClient {
_FakeAgentApiClient({
this.sessions = const [],
this.createdSession,
}) : super(Uri.parse('https://host:9443'));
_FakeAgentApiClient({this.sessions = const [], this.createdSession})
: super(Uri.parse('https://host:9443'));
final List<Map<String, dynamic>> sessions;
final Map<String, dynamic>? createdSession;
@ -67,12 +65,16 @@ class _FakeAgentApiClient extends AgentApiClient {
}
@override
Future<Map<String, dynamic>> createSession(String name) async {
Future<Map<String, dynamic>> createSession({
String? name,
String? projectId,
String? workingDirectory,
}) async {
lastCreatedName = name;
return createdSession ??
<String, dynamic>{
'sessionId': 'generated',
'name': name,
'name': name ?? 'generated',
'status': 'idle',
};
}

View File

@ -9,138 +9,179 @@ import 'package:term_remote_ctl/features/terminal/terminal_session_coordinator.d
import 'package:term_remote_ctl/features/terminal/terminal_socket_session.dart';
void main() {
test('start stays connecting until attach completes and then sends resize', () async {
test(
'start stays connecting until attach completes and then sends resize',
() async {
final controller = TerminalInteractionController();
final apiClient = _FakeAgentApiClient();
final sessionFactory = _FakeTerminalSessionFactory(autoConnect: false);
final session = Session(
sessionId: 'abc',
name: 'codex-main',
status: 'idle',
);
final coordinator = TerminalSessionCoordinator(
controller: controller,
apiClient: apiClient,
session: session,
sessionFactory: sessionFactory.create,
onFrame: (_) {},
viewportProvider: () => const TerminalViewport(columns: 132, rows: 40),
);
final startFuture = coordinator.start();
await Future<void>.delayed(Duration.zero);
expect(controller.connectionState, TerminalConnectionState.connecting);
expect(sessionFactory.createdSessions.single.resizeCalls, isEmpty);
sessionFactory.createdSessions.single.completeConnect();
await startFuture;
expect(controller.connectionState, TerminalConnectionState.connected);
expect(sessionFactory.createdSessions.single.resizeCalls, const [
[132, 40],
]);
},
);
test(
'disconnected session schedules reconnect and reconnects when triggered',
() async {
final controller = TerminalInteractionController();
final apiClient = _FakeAgentApiClient();
final sessionFactory = _FakeTerminalSessionFactory();
final reconnectScheduler = _FakeReconnectScheduler();
final session = Session(
sessionId: 'abc',
name: 'codex-main',
status: 'idle',
);
final coordinator = TerminalSessionCoordinator(
controller: controller,
apiClient: apiClient,
session: session,
sessionFactory: sessionFactory.create,
onFrame: (_) {},
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
reconnectScheduler: reconnectScheduler.schedule,
);
await coordinator.start();
expect(sessionFactory.createdSessions, hasLength(1));
sessionFactory.createdSessions.single.disconnect();
expect(controller.connectionState, TerminalConnectionState.reconnecting);
expect(sessionFactory.createdSessions, hasLength(1));
expect(reconnectScheduler.pendingCallback, isNotNull);
await reconnectScheduler.runPending();
expect(sessionFactory.createdSessions, hasLength(2));
expect(controller.connectionState, TerminalConnectionState.connected);
},
);
test(
'loadOlderHistory increases the requested history window size',
() async {
final controller = TerminalInteractionController();
final apiClient = _FakeAgentApiClient(
responses: [
<String, dynamic>{
'sessionId': 'abc',
'lines': <String>['one', 'two'],
'hasMoreAbove': true,
},
<String, dynamic>{
'sessionId': 'abc',
'lines': <String>['zero', 'one', 'two'],
'hasMoreAbove': false,
},
],
);
final sessionFactory = _FakeTerminalSessionFactory();
final session = Session(
sessionId: 'abc',
name: 'codex-main',
status: 'idle',
);
final coordinator = TerminalSessionCoordinator(
controller: controller,
apiClient: apiClient,
session: session,
sessionFactory: sessionFactory.create,
onFrame: (_) {},
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
);
await coordinator.start();
await coordinator.loadOlderHistory();
expect(apiClient.requestedLineCounts, [200, 400]);
expect(controller.historyWindow.lines, ['zero', 'one', 'two']);
expect(controller.historyWindow.hasMoreAbove, isFalse);
},
);
test(
'incoming frames while browsing history flag pending live output',
() async {
final controller = TerminalInteractionController();
final apiClient = _FakeAgentApiClient();
final sessionFactory = _FakeTerminalSessionFactory();
final session = Session(
sessionId: 'abc',
name: 'codex-main',
status: 'idle',
);
final receivedFrames = <String>[];
final coordinator = TerminalSessionCoordinator(
controller: controller,
apiClient: apiClient,
session: session,
sessionFactory: sessionFactory.create,
onFrame: receivedFrames.add,
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
);
await coordinator.start();
controller.enterScrollback();
sessionFactory.createdSessions.single.emitFrame('next-line');
expect(receivedFrames, ['next-line']);
expect(controller.hasPendingLiveOutput, isTrue);
expect(controller.liveLines, contains('next-line'));
},
);
test('incoming frames keep only a bounded recent live cache', () {
final controller = TerminalInteractionController();
final apiClient = _FakeAgentApiClient();
final sessionFactory = _FakeTerminalSessionFactory(autoConnect: false);
final session = Session(sessionId: 'abc', name: 'codex-main', status: 'idle');
final coordinator = TerminalSessionCoordinator(
controller: controller,
apiClient: apiClient,
session: session,
sessionFactory: sessionFactory.create,
onFrame: (_) {},
viewportProvider: () => const TerminalViewport(columns: 132, rows: 40),
);
final startFuture = coordinator.start();
await Future<void>.delayed(Duration.zero);
for (var index = 0; index < 300; index += 1) {
controller.applyFrame('line-$index');
}
expect(controller.connectionState, TerminalConnectionState.connecting);
expect(sessionFactory.createdSessions.single.resizeCalls, isEmpty);
sessionFactory.createdSessions.single.completeConnect();
await startFuture;
expect(controller.connectionState, TerminalConnectionState.connected);
expect(sessionFactory.createdSessions.single.resizeCalls, const [
[132, 40],
]);
});
test('disconnected session schedules reconnect and reconnects when triggered', () async {
final controller = TerminalInteractionController();
final apiClient = _FakeAgentApiClient();
final sessionFactory = _FakeTerminalSessionFactory();
final reconnectScheduler = _FakeReconnectScheduler();
final session = Session(sessionId: 'abc', name: 'codex-main', status: 'idle');
final coordinator = TerminalSessionCoordinator(
controller: controller,
apiClient: apiClient,
session: session,
sessionFactory: sessionFactory.create,
onFrame: (_) {},
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
reconnectScheduler: reconnectScheduler.schedule,
);
await coordinator.start();
expect(sessionFactory.createdSessions, hasLength(1));
sessionFactory.createdSessions.single.disconnect();
expect(controller.connectionState, TerminalConnectionState.reconnecting);
expect(sessionFactory.createdSessions, hasLength(1));
expect(reconnectScheduler.pendingCallback, isNotNull);
await reconnectScheduler.runPending();
expect(sessionFactory.createdSessions, hasLength(2));
expect(controller.connectionState, TerminalConnectionState.connected);
});
test('loadOlderHistory increases the requested history window size', () async {
final controller = TerminalInteractionController();
final apiClient = _FakeAgentApiClient(
responses: [
<String, dynamic>{
'sessionId': 'abc',
'lines': <String>['one', 'two'],
'hasMoreAbove': true,
},
<String, dynamic>{
'sessionId': 'abc',
'lines': <String>['zero', 'one', 'two'],
'hasMoreAbove': false,
},
],
);
final sessionFactory = _FakeTerminalSessionFactory();
final session = Session(sessionId: 'abc', name: 'codex-main', status: 'idle');
final coordinator = TerminalSessionCoordinator(
controller: controller,
apiClient: apiClient,
session: session,
sessionFactory: sessionFactory.create,
onFrame: (_) {},
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
);
await coordinator.start();
await coordinator.loadOlderHistory();
expect(apiClient.requestedLineCounts, [1000, 2000]);
expect(controller.historyWindow.lines, ['zero', 'one', 'two']);
expect(controller.historyWindow.hasMoreAbove, isFalse);
});
test('incoming frames while browsing history flag pending live output', () async {
final controller = TerminalInteractionController();
final apiClient = _FakeAgentApiClient();
final sessionFactory = _FakeTerminalSessionFactory();
final session = Session(sessionId: 'abc', name: 'codex-main', status: 'idle');
final receivedFrames = <String>[];
final coordinator = TerminalSessionCoordinator(
controller: controller,
apiClient: apiClient,
session: session,
sessionFactory: sessionFactory.create,
onFrame: receivedFrames.add,
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
);
await coordinator.start();
controller.enterScrollback();
sessionFactory.createdSessions.single.emitFrame('next-line');
expect(receivedFrames, ['next-line']);
expect(controller.hasPendingLiveOutput, isTrue);
expect(controller.liveLines, contains('next-line'));
expect(controller.liveLines.length, lessThanOrEqualTo(200));
expect(controller.liveLines.last, 'line-299');
expect(controller.liveLines, isNot(contains('line-0')));
});
}
class _FakeAgentApiClient extends AgentApiClient {
_FakeAgentApiClient({List<Map<String, dynamic>>? responses})
: _responses = responses ??
[
<String, dynamic>{
'sessionId': 'abc',
'lines': <String>['one', 'two'],
'hasMoreAbove': true,
},
],
super(Uri.parse('https://host:9443'));
: _responses =
responses ??
[
<String, dynamic>{
'sessionId': 'abc',
'lines': <String>['one', 'two'],
'hasMoreAbove': true,
},
],
super(Uri.parse('https://host:9443'));
final List<Map<String, dynamic>> _responses;
final requestedLineCounts = <int>[];
@ -179,10 +220,7 @@ class _FakeTerminalSessionFactory {
class _FakeTerminalSocketSession extends TerminalSocketSession {
_FakeTerminalSocketSession({required this.autoConnect})
: super(
sessionId: 'abc',
socketClient: _FakeAgentSocketClient(),
);
: super(sessionId: 'abc', socketClient: _FakeAgentSocketClient());
final bool autoConnect;
final resizeCalls = <List<int>>[];

View File

@ -0,0 +1,130 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:term_remote_ctl/app/app.dart';
import 'package:term_remote_ctl/core/network/agent_api_client.dart';
import 'package:term_remote_ctl/core/network/agent_connection_providers.dart';
import 'package:term_remote_ctl/features/projects/project.dart';
import 'package:term_remote_ctl/features/projects/project_repository.dart';
import 'package:term_remote_ctl/features/sessions/session.dart';
import 'package:term_remote_ctl/features/sessions/session_repository.dart';
import 'package:term_remote_ctl/features/terminal/terminal_socket_session.dart';
void main() {
testWidgets(
'home renders projects and opens a new terminal from a project card',
(tester) async {
final projectRepository = _FakeProjectRepository();
final sessionRepository = _FakeSessionRepository();
final socketFactory = TerminalSocketSessionFactory(
transportFactory: (_) => _FakeTerminalSocketTransport(autoAttach: true),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
projectRepositoryProvider.overrideWithValue(projectRepository),
sessionRepositoryProvider.overrideWithValue(sessionRepository),
terminalSocketSessionFactoryProvider.overrideWithValue(
socketFactory,
),
],
child: const TermRemoteCtlApp(),
),
);
await tester.pumpAndSettle();
expect(find.text('Projects'), findsOneWidget);
expect(find.text('TermRemoteCtl'), findsOneWidget);
expect(find.text(r'C:\repo\termremotectl'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Open terminal'));
await tester.pumpAndSettle();
expect(sessionRepository.lastCreatedProjectId, 'project-1');
expect(find.text('TermRemoteCtl'), findsOneWidget);
expect(find.text(r'C:\repo\termremotectl'), findsOneWidget);
},
);
}
class _FakeProjectRepository extends ProjectRepository {
_FakeProjectRepository()
: _projects = [
Project(
projectId: 'project-1',
name: 'TermRemoteCtl',
workingDirectory: r'C:\repo\termremotectl',
createdAtUtc: DateTime.parse('2026-03-30T10:00:00Z'),
updatedAtUtc: DateTime.parse('2026-03-30T10:00:00Z'),
),
],
super(_FakeAgentApiClient());
final List<Project> _projects;
@override
Future<List<Project>> listProjects() async => List<Project>.of(_projects);
}
class _FakeSessionRepository extends SessionRepository {
_FakeSessionRepository() : super(_FakeAgentApiClient());
String? lastCreatedProjectId;
@override
Future<Session> createSession({
String? name,
String? projectId,
String? workingDirectory,
}) async {
lastCreatedProjectId = projectId;
return Session(
sessionId: 'session-1',
name: 'TermRemoteCtl',
status: 'created',
projectId: projectId,
workingDirectory: workingDirectory ?? r'C:\repo\termremotectl',
createdAtUtc: DateTime.parse('2026-03-30T10:00:00Z'),
updatedAtUtc: DateTime.parse('2026-03-30T10:00:00Z'),
);
}
@override
Future<List<Session>> listSessions() async => const <Session>[];
}
class _FakeAgentApiClient extends AgentApiClient {
_FakeAgentApiClient() : super(Uri.parse('http://10.0.2.2:5067'));
}
class _FakeTerminalSocketTransport implements TerminalSocketTransport {
_FakeTerminalSocketTransport({this.autoAttach = false}) {
if (autoAttach) {
Future<void>.microtask(() {
emit('{"type":"attached","sessionId":"session-1"}');
});
}
}
final bool autoAttach;
final _incoming = StreamController<dynamic>.broadcast();
@override
Stream<dynamic> get stream => _incoming.stream;
@override
void send(String message) {}
@override
Future<void> close() async {
await _incoming.close();
}
void emit(String message) {
_incoming.add(message);
}
}

View File

@ -0,0 +1,126 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:term_remote_ctl/core/network/agent_api_client.dart';
import 'package:term_remote_ctl/core/network/agent_socket_client.dart';
import 'package:term_remote_ctl/features/sessions/session.dart';
import 'package:term_remote_ctl/features/terminal/history_window.dart';
import 'package:term_remote_ctl/features/terminal/terminal_diagnostic_log.dart';
import 'package:term_remote_ctl/features/terminal/terminal_interaction_controller.dart';
import 'package:term_remote_ctl/features/terminal/terminal_session_coordinator.dart';
import 'package:term_remote_ctl/features/terminal/terminal_socket_session.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test(
'sendInput records socket.input.tx when the socket session forwards input',
() async {
final diagnosticLog = TerminalDiagnosticLog();
final socketSession = _RecordingTerminalSocketSession();
final coordinator = TerminalSessionCoordinator(
controller: TerminalInteractionController(),
apiClient: _FakeAgentApiClient(),
session: _session,
sessionFactory: ({required baseUri, required session}) => socketSession,
baseUri: Uri.parse('https://host.example:9443'),
diagnosticLog: diagnosticLog,
onFrame: (_) {},
viewportProvider: () => const TerminalViewport(columns: 120, rows: 30),
);
addTearDown(coordinator.close);
await coordinator.start();
coordinator.sendInput('dir\r');
expect(socketSession.sentInputs, ['dir\r']);
expect(
diagnosticLog.entries,
contains(
predicate<String>(
(entry) => entry.contains('socket.input.tx | dir\\r'),
),
),
);
},
);
test(
'sendInput records socket.input.skip when no socket session is active',
() {
final diagnosticLog = TerminalDiagnosticLog();
final coordinator = TerminalSessionCoordinator(
controller: TerminalInteractionController(),
apiClient: _FakeAgentApiClient(),
session: _session,
sessionFactory: ({required baseUri, required session}) {
throw UnimplementedError('sessionFactory should not be called');
},
baseUri: Uri.parse('https://host.example:9443'),
diagnosticLog: diagnosticLog,
onFrame: (_) {},
viewportProvider: () => const TerminalViewport(columns: 120, rows: 30),
);
coordinator.sendInput('dir\r');
expect(
diagnosticLog.entries,
contains(
predicate<String>((entry) => entry.contains('socket.input.skip')),
),
);
},
);
}
final Session _session = Session(
sessionId: 'abc',
name: 'codex-main',
status: 'idle',
);
class _FakeAgentApiClient extends AgentApiClient {
_FakeAgentApiClient() : super(Uri.parse('https://host.example:9443'));
@override
Future<Map<String, dynamic>> getSessionHistory(
String sessionId, {
int lineCount = 200,
}) async {
return <String, dynamic>{
'sessionId': sessionId,
'lines': const <String>[],
'hasMoreAbove': false,
};
}
}
class _RecordingTerminalSocketSession extends TerminalSocketSession {
_RecordingTerminalSocketSession()
: super(
sessionId: 'abc',
socketClient: AgentSocketClient(Uri.parse('https://host.example:9443')),
);
final List<String> sentInputs = <String>[];
@override
Future<void> connect({
required void Function(String frame) onFrame,
void Function()? onDisconnected,
}) async {}
@override
TerminalSocketDispatchResult sendInput(String input) {
sentInputs.add(input);
return TerminalSocketDispatchResult.sent;
}
@override
void sendResize(int columns, int rows) {}
@override
Future<void> dispose() async {}
}

View File

@ -62,6 +62,36 @@ void main() {
);
});
testWidgets('project list deletes a project after confirmation', (
tester,
) async {
final projectRepository = _FakeProjectRepository.withProjects([
Project(
projectId: 'project-1',
name: 'codex-main',
workingDirectory: r'C:\repo\codex-main',
createdAtUtc: DateTime.parse('2026-03-30T10:00:00Z'),
updatedAtUtc: DateTime.parse('2026-03-30T10:00:00Z'),
),
]);
await _pumpApp(
tester,
projectRepository: projectRepository,
sessionRepository: _FakeSessionRepository(),
);
expect(find.text('codex-main'), findsOneWidget);
await tester.tap(find.byKey(const Key('project_delete_button_project-1')));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
await tester.pumpAndSettle();
expect(projectRepository.deletedProjectIds, ['project-1']);
expect(find.text('codex-main'), findsNothing);
});
testWidgets(
'terminal page keeps tools hidden until the user opens the tools sheet',
(tester) async {
@ -78,8 +108,9 @@ void main() {
find.byKey(const Key('terminal_toggle_actions_button')),
findsOneWidget,
);
expect(find.byKey(const Key('terminal_input_bar')), findsOneWidget);
expect(find.byKey(const Key('terminal_send_button')), findsOneWidget);
expect(find.byKey(const Key('terminal_action_bar')), findsOneWidget);
expect(find.byKey(const Key('terminal_send_button')), findsNothing);
expect(find.byKey(const Key('terminal_input_bar')), findsNothing);
await tester.tap(find.byKey(const Key('terminal_toggle_actions_button')));
await tester.pumpAndSettle();
@ -90,6 +121,39 @@ void main() {
},
);
testWidgets('terminal tools expose quick terminal keys', (tester) async {
final transportFactory = _QueuedTerminalSocketTransportFactory();
await _pumpApp(
tester,
projectRepository: _FakeProjectRepository(),
sessionRepository: _FakeSessionRepository(),
socketFactory: TerminalSocketSessionFactory(
transportFactory: transportFactory.create,
),
);
await _openProjectTerminal(tester);
expect(find.byKey(const Key('terminal_quick_key_esc')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_tab')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_ctrl_c')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_ctrl_d')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_ctrl_l')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_up')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_down')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_left')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_right')), findsOneWidget);
await tester.tap(find.byKey(const Key('terminal_quick_key_esc')));
await tester.pump();
expect(
transportFactory.createdTransports.single.sentMessages.last,
contains('"input":"\\u001b"'),
);
});
testWidgets(
'project launch surfaces a friendly message when the working directory is invalid',
(tester) async {
@ -126,8 +190,7 @@ void main() {
);
await _openProjectTerminal(tester);
await tester.enterText(find.byType(TextField).last, 'dir');
await tester.tap(find.widgetWithText(FilledButton, 'Send'));
await tester.tap(find.byKey(const Key('terminal_quick_key_ctrl_l')));
await tester.pumpAndSettle();
transportFactory.createdTransports.single.emit('command-output');
@ -137,8 +200,8 @@ void main() {
await tester.tap(find.byKey(const Key('terminal_diagnostics_button')));
await tester.pumpAndSettle();
expect(find.textContaining('ui.input.send | dir'), findsOneWidget);
expect(find.textContaining(r'socket.input.tx | dir\r'), findsOneWidget);
expect(find.textContaining('ui.input.quick | Ctrl+L'), findsOneWidget);
expect(find.textContaining('socket.input.tx | '), findsOneWidget);
expect(
find.textContaining('socket.frame.rx | command-output'),
findsOneWidget,
@ -330,7 +393,12 @@ class _FakeProjectRepository extends ProjectRepository {
],
super(_FakeAgentApiClient());
_FakeProjectRepository.withProjects(List<Project> projects)
: _projects = List<Project>.of(projects),
super(_FakeAgentApiClient());
final List<Project> _projects;
final List<String> deletedProjectIds = <String>[];
@override
Future<List<Project>> listProjects() async => List<Project>.of(_projects);
@ -342,6 +410,12 @@ class _FakeProjectRepository extends ProjectRepository {
recentSessions: const <Session>[],
);
}
@override
Future<void> deleteProject(String projectId) async {
deletedProjectIds.add(projectId);
_projects.removeWhere((project) => project.projectId == projectId);
}
}
class _FakeSessionRepository extends SessionRepository {

View File

@ -0,0 +1,154 @@
using System.Text.Json;
using TermRemoteCtl.Agent.Projects;
using TermRemoteCtl.Agent.Security;
using TermRemoteCtl.Agent.Sessions;
using TermRemoteCtl.Agent.Terminal;
namespace TermRemoteCtl.Agent.Api;
public static class ProjectEndpoints
{
public static IEndpointRouteBuilder MapProjectEndpoints(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/projects");
group.MapGet(string.Empty, (ProjectRegistry registry) => Results.Ok(registry.List()));
group.MapGet("/{projectId}", (
string projectId,
ProjectRegistry projects,
SessionRegistry sessions) =>
{
if (!projects.TryGet(projectId, out var project) || project is null)
{
return Results.NotFound();
}
return Results.Ok(new ProjectDetailResponse(
project.ProjectId,
project.Name,
project.WorkingDirectory,
project.CreatedAtUtc,
project.UpdatedAtUtc,
sessions.ListRecentForProject(projectId, 10)
.Select(session => new ProjectSessionSummary(
session.SessionId,
session.Name,
session.Status,
session.CreatedAtUtc,
session.UpdatedAtUtc))
.ToArray()));
});
group.MapDelete("/{projectId}", async (
string projectId,
ProjectRegistry projects,
SessionRegistry sessions,
ISessionHost host,
CancellationToken cancellationToken) =>
{
if (!projects.TryGet(projectId, out _) )
{
return Results.NotFound();
}
foreach (var session in sessions.ListForProject(projectId))
{
await host.StopAsync(session.SessionId, cancellationToken).ConfigureAwait(false);
await sessions.DeleteAsync(session.SessionId, cancellationToken).ConfigureAwait(false);
}
try
{
projects.Delete(projectId);
return Results.NoContent();
}
catch (KeyNotFoundException)
{
return Results.NotFound();
}
});
group.MapPost(string.Empty, async (
HttpRequest httpRequest,
ProjectRegistry registry,
IClock clock,
CancellationToken cancellationToken) =>
{
var request = await ReadProjectRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (request is null || string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.WorkingDirectory))
{
return Results.BadRequest(new { error = "invalid_request" });
}
if (!Directory.Exists(request.WorkingDirectory))
{
return Results.BadRequest(new { error = "invalid_working_directory" });
}
var record = registry.Create(request.Name, request.WorkingDirectory, clock.UtcNow);
return Results.Ok(record);
});
group.MapPut("/{projectId}", async (
string projectId,
HttpRequest httpRequest,
ProjectRegistry registry,
IClock clock,
CancellationToken cancellationToken) =>
{
var request = await ReadProjectRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (request is null || string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.WorkingDirectory))
{
return Results.BadRequest(new { error = "invalid_request" });
}
if (!Directory.Exists(request.WorkingDirectory))
{
return Results.BadRequest(new { error = "invalid_working_directory" });
}
try
{
return Results.Ok(registry.Update(projectId, request.Name, request.WorkingDirectory, clock.UtcNow));
}
catch (KeyNotFoundException)
{
return Results.NotFound();
}
});
return endpoints;
}
private static async Task<ProjectRequest?> ReadProjectRequestAsync(
HttpRequest httpRequest,
CancellationToken cancellationToken)
{
try
{
return await httpRequest.ReadFromJsonAsync<ProjectRequest>(cancellationToken).ConfigureAwait(false);
}
catch (JsonException)
{
return null;
}
catch (BadHttpRequestException)
{
return null;
}
}
private sealed record ProjectRequest(string Name, string WorkingDirectory);
private sealed record ProjectDetailResponse(
string ProjectId,
string Name,
string WorkingDirectory,
DateTimeOffset CreatedAtUtc,
DateTimeOffset UpdatedAtUtc,
IReadOnlyList<ProjectSessionSummary> RecentSessions);
private sealed record ProjectSessionSummary(
string SessionId,
string Name,
string Status,
DateTimeOffset CreatedAtUtc,
DateTimeOffset UpdatedAtUtc);
}

View File

@ -2,6 +2,7 @@ using Microsoft.Extensions.Options;
using TermRemoteCtl.Agent.Api;
using TermRemoteCtl.Agent.Configuration;
using TermRemoteCtl.Agent.History;
using TermRemoteCtl.Agent.Projects;
using TermRemoteCtl.Agent.Realtime;
using TermRemoteCtl.Agent.Security;
using TermRemoteCtl.Agent.Sessions;
@ -14,9 +15,16 @@ builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddSingleton<PairingService>();
builder.Services.AddSingleton<TrustedDeviceStore>();
builder.Services.AddSingleton<AuditLog>();
builder.Services.AddSingleton(serviceProvider =>
{
var options = serviceProvider.GetRequiredService<IOptions<AgentOptions>>().Value;
return new ProjectStore(options.DataRoot);
});
builder.Services.AddSingleton<ProjectRegistry>();
builder.Services.AddSingleton<SessionRegistry>();
builder.Services.AddSingleton<IConPtySessionFactory, ConPtySessionFactory>();
builder.Services.AddSingleton<ISessionHost, PowerShellSessionHost>();
builder.Services.AddSingleton<ITerminalDiagnosticsSink, LoggingTerminalDiagnosticsSink>();
builder.Services.AddSingleton(serviceProvider =>
{
var options = serviceProvider.GetRequiredService<IOptions<AgentOptions>>().Value;
@ -35,6 +43,7 @@ Directory.CreateDirectory(agentOptions.DataRoot);
app.MapGet("/health", () => Results.Json(new { status = "ok" }));
app.MapPairingEndpoints();
app.MapProjectEndpoints();
app.MapSessionEndpoints();
app.MapTerminalSocket();

View File

@ -0,0 +1,8 @@
namespace TermRemoteCtl.Agent.Projects;
public sealed record ProjectRecord(
string ProjectId,
string Name,
string WorkingDirectory,
DateTimeOffset CreatedAtUtc,
DateTimeOffset UpdatedAtUtc);

View File

@ -0,0 +1,89 @@
using System.Collections.Concurrent;
namespace TermRemoteCtl.Agent.Projects;
public sealed class ProjectRegistry
{
private readonly ConcurrentDictionary<string, ProjectRecord> _records = new(StringComparer.Ordinal);
private readonly ProjectStore _store;
public ProjectRegistry(ProjectStore store)
{
_store = store;
foreach (var project in store.Load())
{
_records[project.ProjectId] = project;
}
}
public IReadOnlyList<ProjectRecord> List()
{
return _records.Values
.OrderBy(record => record.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(record => record.WorkingDirectory, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
public bool TryGet(string projectId, out ProjectRecord? record)
{
ArgumentException.ThrowIfNullOrWhiteSpace(projectId);
return _records.TryGetValue(projectId, out record);
}
public ProjectRecord Create(string name, string workingDirectory, DateTimeOffset now)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentException.ThrowIfNullOrWhiteSpace(workingDirectory);
var record = new ProjectRecord(
Guid.NewGuid().ToString("N"),
name.Trim(),
workingDirectory.Trim(),
now,
now);
_records[record.ProjectId] = record;
Persist();
return record;
}
public ProjectRecord Update(string projectId, string name, string workingDirectory, DateTimeOffset now)
{
ArgumentException.ThrowIfNullOrWhiteSpace(projectId);
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentException.ThrowIfNullOrWhiteSpace(workingDirectory);
if (!_records.TryGetValue(projectId, out var existing))
{
throw new KeyNotFoundException($"Project '{projectId}' was not found.");
}
var updated = existing with
{
Name = name.Trim(),
WorkingDirectory = workingDirectory.Trim(),
UpdatedAtUtc = now,
};
_records[projectId] = updated;
Persist();
return updated;
}
public void Delete(string projectId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(projectId);
if (!_records.TryRemove(projectId, out _))
{
throw new KeyNotFoundException($"Project '{projectId}' was not found.");
}
Persist();
}
private void Persist()
{
_store.Save(_records.Values.OrderBy(record => record.Name, StringComparer.OrdinalIgnoreCase).ToArray());
}
}

View File

@ -0,0 +1,46 @@
using System.Text.Json;
namespace TermRemoteCtl.Agent.Projects;
public sealed class ProjectStore
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
};
private readonly string _filePath;
private readonly object _gate = new();
public ProjectStore(string rootPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
Directory.CreateDirectory(rootPath);
_filePath = Path.Combine(rootPath, "projects.json");
}
public IReadOnlyList<ProjectRecord> Load()
{
lock (_gate)
{
if (!File.Exists(_filePath))
{
return [];
}
using var stream = new FileStream(_filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
return JsonSerializer.Deserialize<List<ProjectRecord>>(stream, JsonOptions) ?? [];
}
}
public void Save(IReadOnlyCollection<ProjectRecord> projects)
{
ArgumentNullException.ThrowIfNull(projects);
lock (_gate)
{
using var stream = new FileStream(_filePath, FileMode.Create, FileAccess.Write, FileShare.None);
JsonSerializer.Serialize(stream, projects, JsonOptions);
}
}
}

View File

@ -43,6 +43,7 @@ public static class TerminalWebSocketHandler
}
var host = context.RequestServices.GetRequiredService<ISessionHost>();
var diagnostics = context.RequestServices.GetRequiredService<ITerminalDiagnosticsSink>();
var options = context.RequestServices.GetRequiredService<IOptions<AgentOptions>>().Value;
using var socket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false);
try
@ -77,7 +78,7 @@ public static class TerminalWebSocketHandler
try
{
await SendJsonAsync(socket, new TerminalAttachResponse(sessionId), sendGate, context.RequestAborted).ConfigureAwait(false);
await ReceiveLoopAsync(context, socket, host, sessionId).ConfigureAwait(false);
await ReceiveLoopAsync(context, socket, host, diagnostics, sessionId).ConfigureAwait(false);
}
finally
{
@ -89,6 +90,7 @@ public static class TerminalWebSocketHandler
HttpContext context,
WebSocket socket,
ISessionHost host,
ITerminalDiagnosticsSink diagnostics,
string sessionId)
{
var buffer = new byte[4096];
@ -118,6 +120,7 @@ public static class TerminalWebSocketHandler
await HandleClientMessageAsync(
Encoding.UTF8.GetString(message.ToArray()),
host,
diagnostics,
sessionId,
context.RequestAborted).ConfigureAwait(false);
}
@ -126,6 +129,7 @@ public static class TerminalWebSocketHandler
private static async Task HandleClientMessageAsync(
string payload,
ISessionHost host,
ITerminalDiagnosticsSink diagnostics,
string sessionId,
CancellationToken cancellationToken)
{
@ -154,6 +158,7 @@ public static class TerminalWebSocketHandler
{
if (!string.IsNullOrEmpty(message.Input))
{
diagnostics.Record("backend.input.received", sessionId, SanitizeDiagnosticText(message.Input));
await host.WriteInputAsync(sessionId, message.Input, cancellationToken).ConfigureAwait(false);
}
@ -166,6 +171,11 @@ public static class TerminalWebSocketHandler
}
}
private static string SanitizeDiagnosticText(string input)
{
return input.Replace("\r", "\\r", StringComparison.Ordinal).Replace("\n", "\\n", StringComparison.Ordinal);
}
private static async Task SendJsonAsync(
WebSocket socket,
TerminalAttachResponse response,

View File

@ -4,6 +4,8 @@ public sealed record SessionRecord(
string SessionId,
string Name,
string Status,
string? ProjectId,
string? WorkingDirectory,
DateTimeOffset CreatedAtUtc,
DateTimeOffset UpdatedAtUtc);

View File

@ -59,6 +59,16 @@ public sealed class SessionRegistry
.ToArray();
}
public IReadOnlyList<SessionRecord> ListForProject(string projectId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(projectId);
return _records.Values
.Where(record => string.Equals(record.ProjectId, projectId, StringComparison.Ordinal))
.OrderByDescending(record => record.UpdatedAtUtc)
.ToArray();
}
public bool TryGet(string sessionId, out SessionRecord? record)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<ActiveDebugProfile>https</ActiveDebugProfile>
</PropertyGroup>
</Project>

View File

@ -5,13 +5,14 @@ namespace TermRemoteCtl.Agent.Terminal;
[SupportedOSPlatform("windows")]
internal sealed class ConPtySessionFactory : IConPtySessionFactory
{
public IConPtySession Create(string sessionId)
public IConPtySession Create(string sessionId, string? workingDirectory = null)
{
ConPtyInterop.EnsureSupported();
return new HelperBackedConPtySession(
sessionId,
ConPtyInterop.ResolveShellPath(),
ConPtyInterop.ResolveShellArguments(),
HelperPathResolver.ResolveHelperExePath());
HelperPathResolver.ResolveHelperExePath(),
workingDirectory);
}
}

View File

@ -13,6 +13,7 @@ internal sealed class HelperBackedConPtySession : IConPtySession
private readonly string _shellPath;
private readonly string _shellArguments;
private readonly string _helperExePath;
private readonly string? _workingDirectory;
private readonly string _commandPipeName = $"termremotectl-cmd-{Guid.NewGuid():N}";
private readonly string _outputPipeName = $"termremotectl-out-{Guid.NewGuid():N}";
private Process? _helperProcess;
@ -24,12 +25,13 @@ internal sealed class HelperBackedConPtySession : IConPtySession
private bool _started;
private bool _disposed;
public HelperBackedConPtySession(string sessionId, string shellPath, string shellArguments, string helperExePath)
public HelperBackedConPtySession(string sessionId, string shellPath, string shellArguments, string helperExePath, string? workingDirectory = null)
{
_sessionId = sessionId;
_shellPath = shellPath;
_shellArguments = shellArguments;
_helperExePath = helperExePath;
_workingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? null : workingDirectory;
}
public event EventHandler<TerminalOutputEventArgs>? OutputReceived;
@ -62,6 +64,11 @@ internal sealed class HelperBackedConPtySession : IConPtySession
startInfo.ArgumentList.Add("120");
startInfo.ArgumentList.Add("--rows");
startInfo.ArgumentList.Add("30");
if (_workingDirectory is not null)
{
startInfo.ArgumentList.Add("--working-directory");
startInfo.ArgumentList.Add(_workingDirectory);
}
_helperProcess = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start ConPTY helper process.");

View File

@ -13,5 +13,5 @@ internal interface IConPtySession : IAsyncDisposable
internal interface IConPtySessionFactory
{
IConPtySession Create(string sessionId);
IConPtySession Create(string sessionId, string? workingDirectory = null);
}

View File

@ -0,0 +1,6 @@
namespace TermRemoteCtl.Agent.Terminal;
public interface ITerminalDiagnosticsSink
{
void Record(string eventName, string sessionId, string detail);
}

View File

@ -0,0 +1,22 @@
using Microsoft.Extensions.Logging;
namespace TermRemoteCtl.Agent.Terminal;
internal sealed class LoggingTerminalDiagnosticsSink : ITerminalDiagnosticsSink
{
private readonly ILogger<LoggingTerminalDiagnosticsSink> _logger;
public LoggingTerminalDiagnosticsSink(ILogger<LoggingTerminalDiagnosticsSink> logger)
{
_logger = logger;
}
public void Record(string eventName, string sessionId, string detail)
{
_logger.LogInformation(
"Terminal diagnostic {EventName} session={SessionId} detail={Detail}",
eventName,
sessionId,
detail);
}
}

View File

@ -1,7 +1,7 @@
{
"Agent": {
"DataRoot": "C:\\ProgramData\\TermRemoteCtl",
"BindAddress": "localhost",
"BindAddress": "0.0.0.0",
"HttpsPort": 0,
"HttpPort": 5067,
"WebSocketFrameFlushMilliseconds": 33,

View File

@ -30,7 +30,12 @@ internal static class Program
await using var writer = new StreamWriter(outputPipe, new UTF8Encoding(false), leaveOpen: true) { AutoFlush = true };
using var reader = new StreamReader(commandPipe, new UTF8Encoding(false), detectEncodingFromByteOrderMarks: false, leaveOpen: true);
var session = ConPtyRuntime.Start(options.ShellPath, options.ShellArguments, options.Columns, options.Rows);
var session = ConPtyRuntime.Start(
options.ShellPath,
options.ShellArguments,
options.WorkingDirectory,
options.Columns,
options.Rows);
try
{
@ -119,6 +124,7 @@ internal static class Program
public required string OutputPipeName { get; init; }
public required string ShellPath { get; init; }
public required string ShellArguments { get; init; }
public string? WorkingDirectory { get; init; }
public int Columns { get; init; } = 120;
public int Rows { get; init; } = 30;
@ -136,6 +142,7 @@ internal static class Program
OutputPipeName = values["--output-pipe"],
ShellPath = values["--shell-path"],
ShellArguments = values.TryGetValue("--shell-args", out var shellArgs) ? shellArgs : "",
WorkingDirectory = values.TryGetValue("--working-directory", out var workingDirectory) ? workingDirectory : null,
Columns = values.TryGetValue("--columns", out var columns) ? int.Parse(columns) : 120,
Rows = values.TryGetValue("--rows", out var rows) ? int.Parse(rows) : 30
};
@ -175,7 +182,7 @@ internal static class Program
}
}
public static ConPtyRuntime Start(string shellPath, string shellArguments, int columns, int rows)
public static ConPtyRuntime Start(string shellPath, string shellArguments, string? workingDirectory, int columns, int rows)
{
var inputPipe = CreatePipePair();
var outputPipe = CreatePipePair();
@ -216,7 +223,7 @@ internal static class Program
false,
EXTENDED_STARTUPINFO_PRESENT,
IntPtr.Zero,
null,
string.IsNullOrWhiteSpace(workingDirectory) ? null : workingDirectory,
ref startupInfo,
out var processInfo))
{

View File

@ -0,0 +1,164 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using TermRemoteCtl.Agent.Sessions;
namespace TermRemoteCtl.Agent.IntegrationTests;
public sealed class ProjectApiTests
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
[Fact]
public async Task Create_List_Update_And_Detail_Project_Flow_Works()
{
await using var fixture = new AgentFixture();
using var client = fixture.CreateClient();
var createResponse = await client.PostAsJsonAsync(
"/api/projects",
new { name = "TermRemoteCtl", workingDirectory = fixture.ValidProjectPath });
createResponse.EnsureSuccessStatusCode();
var created = await createResponse.Content.ReadFromJsonAsync<ProjectResponse>(JsonOptions);
Assert.NotNull(created);
Assert.Equal("TermRemoteCtl", created!.Name);
Assert.Equal(fixture.ValidProjectPath, created.WorkingDirectory);
var listed = await client.GetFromJsonAsync<List<ProjectResponse>>("/api/projects", JsonOptions);
Assert.NotNull(listed);
Assert.Contains(listed!, project => project.ProjectId == created.ProjectId);
var registry = fixture.Services.GetRequiredService<SessionRegistry>();
registry.Create(
"TermRemoteCtl",
DateTimeOffset.UtcNow,
projectId: created.ProjectId,
workingDirectory: created.WorkingDirectory);
var detail = await client.GetFromJsonAsync<ProjectDetailResponse>(
$"/api/projects/{created.ProjectId}",
JsonOptions);
Assert.NotNull(detail);
Assert.Equal(created.ProjectId, detail!.ProjectId);
Assert.Single(detail.RecentSessions);
var updateResponse = await client.PutAsJsonAsync(
$"/api/projects/{created.ProjectId}",
new { name = "TRC", workingDirectory = fixture.UpdatedProjectPath });
updateResponse.EnsureSuccessStatusCode();
var updated = await updateResponse.Content.ReadFromJsonAsync<ProjectResponse>(JsonOptions);
Assert.NotNull(updated);
Assert.Equal("TRC", updated!.Name);
Assert.Equal(fixture.UpdatedProjectPath, updated.WorkingDirectory);
}
[Fact]
public async Task Create_Project_Returns_BadRequest_For_Invalid_WorkingDirectory()
{
await using var fixture = new AgentFixture();
using var client = fixture.CreateClient();
var response = await client.PostAsJsonAsync(
"/api/projects",
new { name = "Broken", workingDirectory = "Z:\\path\\does-not-exist" });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Delete_Project_Cascades_Associated_Sessions()
{
await using var fixture = new AgentFixture();
using var client = fixture.CreateClient();
var createProjectResponse = await client.PostAsJsonAsync(
"/api/projects",
new { name = "TermRemoteCtl", workingDirectory = fixture.ValidProjectPath });
createProjectResponse.EnsureSuccessStatusCode();
var project = await createProjectResponse.Content.ReadFromJsonAsync<ProjectResponse>(JsonOptions);
Assert.NotNull(project);
var createSessionResponse = await client.PostAsJsonAsync(
"/api/sessions",
new { projectId = project!.ProjectId });
createSessionResponse.EnsureSuccessStatusCode();
var session = await createSessionResponse.Content.ReadFromJsonAsync<SessionResponse>(JsonOptions);
Assert.NotNull(session);
var deleteResponse = await client.DeleteAsync($"/api/projects/{project.ProjectId}");
deleteResponse.EnsureSuccessStatusCode();
var listedProjects = await client.GetFromJsonAsync<List<ProjectResponse>>("/api/projects", JsonOptions);
Assert.NotNull(listedProjects);
Assert.DoesNotContain(listedProjects!, item => item.ProjectId == project.ProjectId);
var listedSessions = await client.GetFromJsonAsync<List<SessionResponse>>("/api/sessions", JsonOptions);
Assert.NotNull(listedSessions);
Assert.DoesNotContain(listedSessions!, item => item.SessionId == session!.SessionId);
}
private sealed class AgentFixture : WebApplicationFactory<Program>
{
private readonly string _dataRoot = Path.Combine(Path.GetTempPath(), "TermRemoteCtl.Tests", Guid.NewGuid().ToString("N"));
public string ValidProjectPath => Path.Combine(_dataRoot, "project-a");
public string UpdatedProjectPath => Path.Combine(_dataRoot, "project-b");
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
Directory.CreateDirectory(ValidProjectPath);
Directory.CreateDirectory(UpdatedProjectPath);
builder.UseEnvironment("Development");
builder.ConfigureAppConfiguration((_, configBuilder) =>
{
configBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
["Agent:DataRoot"] = _dataRoot,
["Agent:BindAddress"] = "127.0.0.1",
["Agent:HttpsPort"] = "9443",
["Agent:WebSocketFrameFlushMilliseconds"] = "33",
["Agent:RingBufferLineLimit"] = "4000"
});
});
}
public new async ValueTask DisposeAsync()
{
await base.DisposeAsync();
if (Directory.Exists(_dataRoot))
{
Directory.Delete(_dataRoot, true);
}
}
}
private sealed record ProjectResponse(
string ProjectId,
string Name,
string WorkingDirectory,
DateTimeOffset CreatedAtUtc,
DateTimeOffset UpdatedAtUtc);
private sealed record ProjectDetailResponse(
string ProjectId,
string Name,
string WorkingDirectory,
IReadOnlyList<SessionSummaryResponse> RecentSessions);
private sealed record SessionSummaryResponse(string SessionId, string Name, string Status);
private sealed record SessionResponse(
string SessionId,
string Name,
string Status,
string? ProjectId,
DateTimeOffset CreatedAtUtc,
DateTimeOffset UpdatedAtUtc);
}

View File

@ -18,7 +18,8 @@ public class ConPtySessionFactoryTests
using var harness = HostHarness.Create();
await using var host = harness.Host;
await host.StartAsync("smoke", CancellationToken.None);
var session = harness.Registry.Create("smoke", DateTimeOffset.UtcNow);
await host.StartAsync(session.SessionId, CancellationToken.None);
}
[Fact]
@ -32,6 +33,7 @@ public class ConPtySessionFactoryTests
var output = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
using var harness = HostHarness.Create();
await using var host = harness.Host;
var session = harness.Registry.Create("smoke", DateTimeOffset.UtcNow);
host.OutputReceived += (_, args) =>
{
if (args.Chunk.Contains("smoke", StringComparison.OrdinalIgnoreCase))
@ -40,9 +42,9 @@ public class ConPtySessionFactoryTests
}
};
await host.StartAsync("smoke", CancellationToken.None);
await host.StartAsync(session.SessionId, CancellationToken.None);
await Task.Delay(1000);
await host.WriteInputAsync("smoke", "Write-Output smoke\r\n", CancellationToken.None);
await host.WriteInputAsync(session.SessionId, "Write-Output smoke\r\n", CancellationToken.None);
var completed = await Task.WhenAny(output.Task, Task.Delay(TimeSpan.FromSeconds(20)));
Assert.True(ReferenceEquals(output.Task, completed), "Timed out waiting for shell output.");
@ -51,14 +53,17 @@ public class ConPtySessionFactoryTests
private sealed class HostHarness : IDisposable
{
private HostHarness(string dataRoot, PowerShellSessionHost host)
private HostHarness(string dataRoot, SessionRegistry registry, PowerShellSessionHost host)
{
DataRoot = dataRoot;
Registry = registry;
Host = host;
}
public string DataRoot { get; }
public SessionRegistry Registry { get; }
public PowerShellSessionHost Host { get; }
public static HostHarness Create()
@ -74,7 +79,7 @@ public class ConPtySessionFactoryTests
var registry = new SessionRegistry(new SessionHistoryStore(dataRoot), options);
var host = new PowerShellSessionHost(new ConPtySessionFactory(), registry);
return new HostHarness(dataRoot, host);
return new HostHarness(dataRoot, registry, host);
}
public void Dispose()

View File

@ -15,12 +15,30 @@ public class PowerShellSessionHostTests
using var harness = HostHarness.Create(factory);
await using var host = harness.Host;
await host.StartAsync("alpha", CancellationToken.None);
await host.ResizeAsync("alpha", 120, 40, CancellationToken.None);
var session = harness.Registry.Create("alpha", DateTimeOffset.UtcNow);
await host.StartAsync(session.SessionId, CancellationToken.None);
await host.ResizeAsync(session.SessionId, 120, 40, CancellationToken.None);
Assert.Equal((120, 40), factory.Session.ResizeCalls.Single());
}
[Fact]
public async Task StartAsync_Forwards_WorkingDirectory_To_SessionFactory()
{
var factory = new FakeConPtySessionFactory();
using var harness = HostHarness.Create(factory);
await using var host = harness.Host;
var session = harness.Registry.Create(
"alpha",
DateTimeOffset.UtcNow,
workingDirectory: @"C:\repo\termremotectl");
await host.StartAsync(session.SessionId, CancellationToken.None);
Assert.Equal(@"C:\repo\termremotectl", factory.LastWorkingDirectory);
}
[Fact]
public async Task Session_Output_Is_Captured_In_Registry_History()
{
@ -81,10 +99,12 @@ public class PowerShellSessionHostTests
private sealed class FakeConPtySessionFactory : IConPtySessionFactory
{
public FakeConPtySession Session { get; } = new();
public string? LastWorkingDirectory { get; private set; }
public IConPtySession Create(string sessionId)
public IConPtySession Create(string sessionId, string? workingDirectory = null)
{
Session.SessionId = sessionId;
LastWorkingDirectory = workingDirectory;
return Session;
}
}

View File

View File

@ -0,0 +1,127 @@
姝e湪鐢熸垚...
warn: Microsoft.AspNetCore.Server.Kestrel[0]
Overriding address(es) 'http://localhost:5067'. Binding to endpoints defined via IConfiguration and/or UseKestrel() instead.
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://0.0.0.0:5067
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
Content root path: D:\App\Flutter\TermRemoteCtl\apps\windows_agent\src\TermRemoteCtl.Agent
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=l
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=s
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=ls
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=\r
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=d
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=i
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=r
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=dir
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=r
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=r
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=\r
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=\r
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=l
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=s
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=ls
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=\r
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=ls\r
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=dir\r
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=pwd\r
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=c
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=o
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=d
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=e
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=x
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=codex
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=\r
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=这个项目是干什么的\r
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=有哪些具体功能?\r
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=6
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=/
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=e
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=x
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=i
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=t
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=exit
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=t
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=t
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=t
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=\r