diff --git a/apps/flutter/lib/src/client/app_state.dart b/apps/flutter/lib/src/client/app_state.dart index afadfc4a..4da9e6e3 100644 --- a/apps/flutter/lib/src/client/app_state.dart +++ b/apps/flutter/lib/src/client/app_state.dart @@ -34,6 +34,7 @@ const List t4RequestedFeatures = [ 'agent.transcript', 'terminal.io', 'files.list', + 'files.search', 'files.diff', 'audit.tail', 'catalog.metadata', @@ -101,6 +102,7 @@ final class SessionSummary { this.fast = false, this.fastAvailable = false, this.turnActive = false, + this.isPaused = false, this.queuedFollowUpCount = 0, }); @@ -122,6 +124,7 @@ final class SessionSummary { final bool fast; final bool fastAvailable; final bool turnActive; + final bool isPaused; final int queuedFollowUpCount; bool get archived => archivedAt != null; @@ -211,6 +214,7 @@ final class SessionComposerState { this.fastEnabled = false, this.fastAvailable = false, this.turnActive = false, + this.isPaused = false, this.queuedFollowUpCount = 0, }); @@ -226,6 +230,7 @@ final class SessionComposerState { final bool fastEnabled; final bool fastAvailable; final bool turnActive; + final bool isPaused; final int queuedFollowUpCount; } @@ -421,6 +426,13 @@ final class FileWorkspaceState { final String? error; } +final class ProjectFileSearchResult { + const ProjectFileSearchResult({required this.paths, required this.truncated}); + + final List paths; + final bool truncated; +} + final class ReviewWorkspaceItem { const ReviewWorkspaceItem({ required this.reviewId, @@ -711,6 +723,12 @@ abstract interface class T4Actions { Future cancelTurn(); + Future pauseSession(); + + Future resumeSession(); + + Future compactSession({String? instructions}); + Future setSessionModel(String selector); Future setSessionThinking(String level); @@ -734,6 +752,10 @@ abstract interface class T4Actions { void closeTerminal(String terminalId); Future listFiles([String path = '']); + Future searchProjectFiles( + String query, { + int limit = 12, + }); Future readFile(String path); Future loadSessionDiff(); Future writeFile(String path, String content); diff --git a/apps/flutter/lib/src/client/t4_client_controller.dart b/apps/flutter/lib/src/client/t4_client_controller.dart index 19a8bc90..400f5005 100644 --- a/apps/flutter/lib/src/client/t4_client_controller.dart +++ b/apps/flutter/lib/src/client/t4_client_controller.dart @@ -315,6 +315,7 @@ final class T4ClientController extends ChangeNotifier implements T4Actions { fastEnabled: session.fast, fastAvailable: session.fastAvailable, turnActive: session.turnActive || _submitting, + isPaused: session.isPaused, queuedFollowUpCount: session.queuedFollowUpCount, ); } @@ -1660,6 +1661,88 @@ final class T4ClientController extends ChangeNotifier implements T4Actions { ); } + @override + Future pauseSession() async { + final session = state.selectedSession; + if (session == null || session.isPaused) return; + final frame = await _runSessionOperation( + prefix: 'session-pause', + command: 'session.pause', + capability: 'sessions.control', + session: session, + ); + final result = frame.result; + if (result is Map && result['paused'] is bool) { + _setSessionPaused(session.sessionId, result['paused']! as bool); + } + } + + @override + Future resumeSession() async { + final session = state.selectedSession; + if (session == null || !session.isPaused) return; + final frame = await _runSessionOperation( + prefix: 'session-resume', + command: 'session.resume', + capability: 'sessions.control', + session: session, + ); + final result = frame.result; + final paused = result is Map && result['paused'] is bool + ? result['paused']! as bool + : false; + _setSessionPaused(session.sessionId, paused); + } + + @override + Future compactSession({String? instructions}) async { + final session = state.selectedSession; + if (session == null || session.turnActive || session.isPaused) return; + final normalized = instructions?.trim(); + await _runSessionOperation( + prefix: 'session-compact', + command: 'session.compact', + capability: 'sessions.control', + session: session, + args: { + if (normalized != null && normalized.isNotEmpty) + 'instructions': normalized, + }, + ); + } + + void _setSessionPaused(String sessionId, bool paused) { + _sessions = _sessions + .map( + (session) => session.sessionId == sessionId + ? SessionSummary( + hostId: session.hostId, + sessionId: session.sessionId, + title: session.title, + revision: session.revision, + status: session.status, + projectId: session.projectId, + projectName: session.projectName, + updatedAt: session.updatedAt, + archivedAt: session.archivedAt, + working: session.working, + modelSelector: session.modelSelector, + modelDisplayName: session.modelDisplayName, + thinking: session.thinking, + thinkingSupported: session.thinkingSupported, + thinkingLevels: session.thinkingLevels, + fast: session.fast, + fastAvailable: session.fastAvailable, + turnActive: session.turnActive, + isPaused: paused, + queuedFollowUpCount: session.queuedFollowUpCount, + ) + : session, + ) + .toList(growable: false); + _publish(); + } + @override Future setSessionModel(String selector) async { final session = state.selectedSession; @@ -2016,6 +2099,65 @@ final class T4ClientController extends ChangeNotifier implements T4Actions { ); }); + @override + Future searchProjectFiles( + String query, { + int limit = 12, + }) async { + final normalized = query.trim(); + if (normalized.isEmpty) { + throw ArgumentError.value(query, 'query', 'must not be blank'); + } + if (utf8.encode(normalized).length > 256) { + throw ArgumentError.value(query, 'query', 'must be at most 256 bytes'); + } + if (limit < 1 || limit > 50) { + throw RangeError.range(limit, 1, 50, 'limit'); + } + if (!_grantedFeatures.contains('files.search')) { + throw StateError('This host does not support project file search.'); + } + final session = _developerSession(); + final frame = await _runComposerCommand( + prefix: 'files-search', + command: 'files.search', + session: session, + capability: 'files.list', + args: {'query': normalized, 'limit': limit}, + ); + final result = frame.result; + if (result is! Map || + result['matches'] is! List || + result['truncated'] is! bool) { + throw const FormatException('files.search result is invalid'); + } + if (result.keys.toSet().difference(const { + 'matches', + 'truncated', + }).isNotEmpty || + (result['matches']! as List).length > 50) { + throw const FormatException('files.search result is invalid'); + } + final paths = []; + final seen = {}; + for (final raw in result['matches']! as List) { + if (raw is! Map || + raw.keys.toSet().difference(const {'path'}).isNotEmpty || + raw['path'] is! String) { + throw const FormatException('files.search match is invalid'); + } + final path = _safeProjectSearchPath(raw['path']! as String); + if (!seen.add(path)) { + throw const FormatException('files.search match is duplicated'); + } + paths.add(path); + } + return ProjectFileSearchResult( + paths: List.unmodifiable(paths), + truncated: result['truncated']! as bool, + ); + } + @override Future readFile(String path) => _runDeveloperOperation(() async { final session = _developerSession(); @@ -2917,6 +3059,7 @@ final class T4ClientController extends ChangeNotifier implements T4Actions { fastAvailable: liveState['fastAvailable'] == true, turnActive: streaming || pendingApproval == true || pendingUserInput == true, + isPaused: liveState['isPaused'] == true, queuedFollowUpCount: queuedCount, ); } @@ -3573,6 +3716,7 @@ final class T4ClientController extends ChangeNotifier implements T4Actions { fast: session.fast, fastAvailable: session.fastAvailable, turnActive: session.turnActive, + isPaused: session.isPaused, queuedFollowUpCount: session.queuedFollowUpCount, ) : session, @@ -3965,6 +4109,7 @@ final class T4ClientController extends ChangeNotifier implements T4Actions { fast: result.fastActive ?? result.fast ?? session.fast, fastAvailable: result.fastAvailable ?? session.fastAvailable, turnActive: result.isStreaming || session.turnActive, + isPaused: result.isPaused, queuedFollowUpCount: result.queuedMessageCount, ); }) @@ -4940,6 +5085,26 @@ String _humanizeSettingPath(String path) => path ) .join(' · '); +String _safeProjectSearchPath(String value) { + final hasControl = value.runes.any((rune) => rune <= 0x1f || rune == 0x7f); + if (value.isEmpty || + utf8.encode(value).length > 4096 || + hasControl || + value.contains(r'\') || + value.startsWith('/') || + RegExp(r'^[A-Za-z]:').hasMatch(value) || + value.startsWith('~')) { + throw const FormatException( + 'files.search path must be a safe relative POSIX path', + ); + } + final parts = value.split('/'); + if (parts.any((part) => part.isEmpty || part == '.' || part == '..')) { + throw const FormatException('files.search path contains an unsafe segment'); + } + return value; +} + final class _PendingCommand { _PendingCommand({ required this.commandId, diff --git a/apps/flutter/lib/src/protocol/wire_encoder.dart b/apps/flutter/lib/src/protocol/wire_encoder.dart index 10540df7..740026cb 100644 --- a/apps/flutter/lib/src/protocol/wire_encoder.dart +++ b/apps/flutter/lib/src/protocol/wire_encoder.dart @@ -374,6 +374,7 @@ const Set _knownCommands = { 'files.write', 'files.patch', 'files.list', + 'files.search', 'files.diff', 'review.read', 'review.apply', diff --git a/apps/flutter/lib/src/ui/adaptive_session_shell.dart b/apps/flutter/lib/src/ui/adaptive_session_shell.dart index 480a909a..2241e076 100644 --- a/apps/flutter/lib/src/ui/adaptive_session_shell.dart +++ b/apps/flutter/lib/src/ui/adaptive_session_shell.dart @@ -28,6 +28,7 @@ final class _AdaptiveSessionShellState extends State<_AdaptiveSessionShell> { bool _showSettings = false; bool _showSearch = false; bool _showUsage = false; + int _developerInitialTab = 0; Future _connect() async { if (_connecting) return; @@ -131,10 +132,11 @@ final class _AdaptiveSessionShellState extends State<_AdaptiveSessionShell> { void _closeAttention() => setState(() => _showAttention = false); - void _openDeveloper() => setState(() { + void _openDeveloper({int initialTab = 0}) => setState(() { _showHostManager = false; _showAttention = false; _showDeveloper = true; + _developerInitialTab = initialTab; _showSettings = false; _showSearch = false; _showUsage = false; @@ -142,6 +144,28 @@ final class _AdaptiveSessionShellState extends State<_AdaptiveSessionShell> { void _closeDeveloper() => setState(() => _showDeveloper = false); + bool get _canQuickOpen => + widget.state.connectionPhase == ConnectionPhase.ready && + widget.state.selectedSession != null && + widget.state.grantedCapabilities.contains('files.list') && + widget.state.grantedCapabilities.contains('files.read') && + widget.state.grantedFeatures.contains('files.search'); + + Future _openQuickOpen() async { + if (!_canQuickOpen) return; + final path = await showDialog( + context: context, + builder: (context) => _QuickOpenDialog(actions: widget.actions), + ); + if (path == null || !mounted) return; + try { + await widget.actions.readFile(path); + if (mounted) _openDeveloper(initialTab: 1); + } on Object { + if (mounted) _showActionFailure('Could not open that project file.'); + } + } + void _openSettings({required bool closeDrawer}) { setState(() { _showHostManager = false; @@ -308,6 +332,7 @@ final class _AdaptiveSessionShellState extends State<_AdaptiveSessionShell> { return _DeveloperSurfacesPane( state: widget.state, actions: widget.actions, + initialTab: _developerInitialTab, showHeader: showHeader, onDone: _closeDeveloper, ); @@ -321,6 +346,7 @@ final class _AdaptiveSessionShellState extends State<_AdaptiveSessionShell> { onOpenSessions: showHeader ? null : _openNavigation, onOpenAttention: _openAttention, onOpenDeveloper: _openDeveloper, + onOpenQuickOpen: _openQuickOpen, ); } @@ -445,7 +471,13 @@ final class _AdaptiveSessionShellState extends State<_AdaptiveSessionShell> { !_showAttention && !_showDeveloper && !_showSearch && - !_showUsage) + !_showUsage) ...[ + if (_canQuickOpen) + IconButton( + onPressed: () => unawaited(_openQuickOpen()), + tooltip: 'Quick open project file', + icon: const Icon(Icons.search), + ), Badge( isLabelVisible: widget.state.urgentAttentionCount > 0, label: Text('${widget.state.urgentAttentionCount}'), @@ -455,6 +487,7 @@ final class _AdaptiveSessionShellState extends State<_AdaptiveSessionShell> { icon: const Icon(Icons.inbox_outlined), ), ), + ], if (!_showHostManager && !_showAttention && !_showSearch && diff --git a/apps/flutter/lib/src/ui/conversation_pane.dart b/apps/flutter/lib/src/ui/conversation_pane.dart index 35a22e6e..411a34ef 100644 --- a/apps/flutter/lib/src/ui/conversation_pane.dart +++ b/apps/flutter/lib/src/ui/conversation_pane.dart @@ -9,6 +9,7 @@ final class _ConversationPane extends StatelessWidget { this.onOpenSessions, required this.onOpenAttention, required this.onOpenDeveloper, + required this.onOpenQuickOpen, }); final T4ViewState state; @@ -18,6 +19,7 @@ final class _ConversationPane extends StatelessWidget { final VoidCallback? onOpenSessions; final VoidCallback onOpenAttention; final VoidCallback onOpenDeveloper; + final Future Function() onOpenQuickOpen; @override Widget build(BuildContext context) { @@ -33,6 +35,7 @@ final class _ConversationPane extends StatelessWidget { state: state, onOpenAttention: onOpenAttention, onOpenDeveloper: onOpenDeveloper, + onOpenQuickOpen: onOpenQuickOpen, ), if (showError) _ConnectionErrorBanner( @@ -60,11 +63,13 @@ final class _ConversationHeader extends StatelessWidget { required this.state, required this.onOpenAttention, required this.onOpenDeveloper, + required this.onOpenQuickOpen, }); final T4ViewState state; final VoidCallback onOpenAttention; final VoidCallback onOpenDeveloper; + final Future Function() onOpenQuickOpen; @override Widget build(BuildContext context) { @@ -108,6 +113,18 @@ final class _ConversationHeader extends StatelessWidget { ], ), ), + IconButton( + onPressed: + state.connectionPhase == ConnectionPhase.ready && + state.grantedFeatures.contains('files.search') && + state.grantedCapabilities.contains('files.list') && + state.grantedCapabilities.contains('files.read') && + session != null + ? () => unawaited(onOpenQuickOpen()) + : null, + tooltip: 'Quick open project file', + icon: const Icon(Icons.search), + ), IconButton( onPressed: onOpenDeveloper, tooltip: 'Open developer tools', @@ -931,6 +948,7 @@ final class _PromptComposerState extends State<_PromptComposer> { widget.state.connectionPhase == ConnectionPhase.ready && widget.state.selectedSession != null && widget.state.grantedCapabilities.contains('sessions.prompt') && + !widget.state.composer.isPaused && !_sending; bool get _canSubmit => @@ -1112,6 +1130,14 @@ final class _PromptComposerState extends State<_PromptComposer> { Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; final composer = widget.state.composer; + final showControls = + widget.state.selectedSession != null && + widget.state.grantedCapabilities.contains('sessions.control'); + final canControl = + showControls && + widget.state.connectionPhase == ConnectionPhase.ready && + widget.state.grantedCapabilities.contains('sessions.control') && + !widget.state.sessionOperationPending; final slashQuery = _textController.text.startsWith('/') ? _textController.text.split(RegExp(r'\s')).first.toLowerCase() : ''; @@ -1257,6 +1283,36 @@ final class _PromptComposerState extends State<_PromptComposer> { ), ), ), + if (showControls && composer.isPaused) + ActionChip( + avatar: const Icon(Icons.play_arrow, size: 20), + label: const Text('Resume'), + onPressed: canControl + ? () => unawaited( + _runControl(widget.actions.resumeSession), + ) + : null, + ) + else if (showControls && composer.turnActive) + ActionChip( + avatar: const Icon(Icons.pause, size: 20), + label: const Text('Pause'), + onPressed: canControl + ? () => unawaited( + _runControl(widget.actions.pauseSession), + ) + : null, + ) + else if (showControls) + ActionChip( + avatar: const Icon(Icons.compress, size: 20), + label: const Text('Compact'), + onPressed: canControl + ? () => unawaited( + _runControl(widget.actions.compactSession), + ) + : null, + ), if (composer.turnActive) ...[ ActionChip( avatar: const Icon(Icons.stop, size: 20), @@ -1308,6 +1364,8 @@ final class _PromptComposerState extends State<_PromptComposer> { decoration: InputDecoration( hintText: widget.state.selectedSession == null ? 'Choose a session to begin' + : composer.isPaused + ? 'Resume the session to continue' : composer.turnActive ? 'Steer the active turn' : 'Message T4', diff --git a/apps/flutter/lib/src/ui/developer_surfaces.dart b/apps/flutter/lib/src/ui/developer_surfaces.dart index fcd15cec..35a54cb6 100644 --- a/apps/flutter/lib/src/ui/developer_surfaces.dart +++ b/apps/flutter/lib/src/ui/developer_surfaces.dart @@ -4,12 +4,14 @@ final class _DeveloperSurfacesPane extends StatefulWidget { const _DeveloperSurfacesPane({ required this.state, required this.actions, + required this.initialTab, required this.onDone, required this.showHeader, }); final T4ViewState state; final T4Actions actions; + final int initialTab; final VoidCallback onDone; final bool showHeader; @@ -47,7 +49,11 @@ final class _DeveloperSurfacesPaneState extends State<_DeveloperSurfacesPane> @override void initState() { super.initState(); - _tabs = TabController(length: 5, vsync: this); + _tabs = TabController( + length: 5, + initialIndex: _boundedDeveloperTab(widget.initialTab), + vsync: this, + ); _activitySnapshot = List.of(widget.state.activities); final workspace = widget.state.fileWorkspace; if (workspace.content != null && workspace.path.isNotEmpty) { @@ -63,6 +69,10 @@ final class _DeveloperSurfacesPaneState extends State<_DeveloperSurfacesPane> @override void didUpdateWidget(covariant _DeveloperSurfacesPane oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.initialTab != widget.initialTab && + _tabs.index != widget.initialTab) { + _tabs.animateTo(_boundedDeveloperTab(widget.initialTab)); + } if (!_activityPaused && !identical(oldWidget.state.activities, widget.state.activities)) { _activitySnapshot = List.of(widget.state.activities); @@ -698,6 +708,12 @@ final class _DeveloperSurfacesPaneState extends State<_DeveloperSurfacesPane> } } +int _boundedDeveloperTab(int index) => index < 0 + ? 0 + : index > 4 + ? 4 + : index; + final class _DeveloperSurfacesHeader extends StatelessWidget { const _DeveloperSurfacesHeader({required this.onDone}); diff --git a/apps/flutter/lib/src/ui/quick_open_dialog.dart b/apps/flutter/lib/src/ui/quick_open_dialog.dart new file mode 100644 index 00000000..8e271d46 --- /dev/null +++ b/apps/flutter/lib/src/ui/quick_open_dialog.dart @@ -0,0 +1,205 @@ +part of 't4_app.dart'; + +final class _QuickOpenDialog extends StatefulWidget { + const _QuickOpenDialog({required this.actions}); + + final T4Actions actions; + + @override + State<_QuickOpenDialog> createState() => _QuickOpenDialogState(); +} + +final class _QuickOpenDialogState extends State<_QuickOpenDialog> { + final TextEditingController _queryController = TextEditingController(); + final FocusNode _queryFocus = FocusNode(debugLabel: 'Quick open query'); + Timer? _debounce; + int _requestGeneration = 0; + List _paths = const []; + bool _loading = false; + bool _truncated = false; + String? _error; + + @override + void dispose() { + _debounce?.cancel(); + _queryController.dispose(); + _queryFocus.dispose(); + super.dispose(); + } + + void _scheduleSearch(String value) { + _debounce?.cancel(); + final query = value.trim(); + if (query.isEmpty) { + _requestGeneration += 1; + setState(() { + _paths = const []; + _loading = false; + _truncated = false; + _error = null; + }); + return; + } + _requestGeneration += 1; + setState(() { + _paths = const []; + _loading = true; + _truncated = false; + _error = null; + }); + _debounce = Timer(const Duration(milliseconds: 220), () { + unawaited(_search(query)); + }); + } + + Future _search(String query) async { + final generation = ++_requestGeneration; + setState(() { + _loading = true; + _error = null; + }); + try { + final result = await widget.actions.searchProjectFiles(query); + if (!mounted || generation != _requestGeneration) return; + setState(() { + _paths = result.paths; + _truncated = result.truncated; + _loading = false; + }); + } on Object { + if (!mounted || generation != _requestGeneration) return; + setState(() { + _paths = const []; + _truncated = false; + _loading = false; + _error = 'Project search failed. Try again.'; + }); + } + } + + @override + Widget build(BuildContext context) { + final query = _queryController.text.trim(); + return Dialog( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 620, maxHeight: 560), + child: Padding( + padding: const EdgeInsets.all(_T4Space.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Quick open', + style: Theme.of(context).textTheme.titleLarge, + ), + ), + IconButton( + onPressed: () => Navigator.pop(context), + tooltip: 'Close quick open', + icon: const Icon(Icons.close), + ), + ], + ), + const SizedBox(height: _T4Space.sm), + TextField( + controller: _queryController, + focusNode: _queryFocus, + autofocus: true, + onChanged: _scheduleSearch, + onSubmitted: (value) { + _debounce?.cancel(); + final normalized = value.trim(); + if (normalized.isNotEmpty) unawaited(_search(normalized)); + }, + decoration: const InputDecoration( + prefixIcon: Icon(Icons.search), + labelText: 'Find a project file', + hintText: 'Type part of a file name or path', + ), + ), + const SizedBox(height: _T4Space.sm), + if (_loading) const LinearProgressIndicator(), + if (_error case final error?) ...[ + const SizedBox(height: _T4Space.sm), + Text( + error, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + Flexible( + child: query.isEmpty + ? const _QuickOpenMessage( + icon: Icons.keyboard_outlined, + message: 'Start typing to search this project.', + ) + : !_loading && _error == null && _paths.isEmpty + ? const _QuickOpenMessage( + icon: Icons.search_off_outlined, + message: 'No matching project files.', + ) + : ListView.builder( + shrinkWrap: true, + itemCount: _paths.length, + itemBuilder: (context, index) { + final path = _paths[index]; + return ListTile( + leading: const Icon( + Icons.insert_drive_file_outlined, + ), + title: Text( + path.split('/').last, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + path, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: () => Navigator.pop(context, path), + ); + }, + ), + ), + if (_truncated) + const Padding( + padding: EdgeInsets.only(top: _T4Space.sm), + child: Text( + 'More matches exist. Keep typing to narrow the list.', + ), + ), + ], + ), + ), + ), + ); + } +} + +final class _QuickOpenMessage extends StatelessWidget { + const _QuickOpenMessage({required this.icon, required this.message}); + + final IconData icon; + final String message; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(_T4Space.xl), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(height: _T4Space.sm), + Text(message, textAlign: TextAlign.center), + ], + ), + ), + ); + } +} diff --git a/apps/flutter/lib/src/ui/t4_app.dart b/apps/flutter/lib/src/ui/t4_app.dart index e5dc9c06..bdf650e1 100644 --- a/apps/flutter/lib/src/ui/t4_app.dart +++ b/apps/flutter/lib/src/ui/t4_app.dart @@ -20,6 +20,7 @@ part 'attention_pane.dart'; part 'conversation_pane.dart'; part 'developer_surfaces.dart'; part 'host_management.dart'; +part 'quick_open_dialog.dart'; part 'transcript_search_pane.dart'; part 'usage_status_pane.dart'; part 'settings_pane.dart'; diff --git a/apps/flutter/test/client/t4_client_controller_test.dart b/apps/flutter/test/client/t4_client_controller_test.dart index 06d87e6a..8acabb4a 100644 --- a/apps/flutter/test/client/t4_client_controller_test.dart +++ b/apps/flutter/test/client/t4_client_controller_test.dart @@ -1948,6 +1948,120 @@ void main() { expect(channel.sentJson.last, containsPair('decision', 'deny')); }); + test( + 'searches project files and runs pause resume and compact controls', + () async { + final profile = _profile('alpha'); + final connector = _FakeConnector(); + final controller = _controller( + _MemoryDirectoryStore( + directory: const HostDirectory.empty().upsert(profile), + ), + _MemoryCredentialStore(), + connector, + ); + addTearDown(controller.dispose); + await controller.initialize(); + final channel = connector.channels.single; + channel.emit( + _welcome( + 'host-alpha', + capabilities: t4RequestedCapabilities, + features: const ['files.search'], + ), + ); + await _flush(); + final list = channel.sentJson.last; + channel.emit( + _response( + list, + command: 'session.list', + result: _sessionListResult('host-alpha'), + ), + ); + await _flush(); + channel.emit( + _snapshot( + 'host-alpha', + 'session-alpha', + revision: 'revision-session-alpha', + ), + ); + await _flush(); + + final searching = controller.searchProjectFiles(' main ', limit: 5); + await _flush(); + final search = channel.sentJson.last; + expect(search['command'], 'files.search'); + expect(search['args'], {'query': 'main', 'limit': 5}); + channel.emit( + _response( + search, + command: 'files.search', + result: { + 'matches': [ + {'path': 'lib/main.dart'}, + {'path': 'test/main_test.dart'}, + ], + 'truncated': true, + }, + ), + ); + final searchResult = await searching; + expect(searchResult.paths, [ + 'lib/main.dart', + 'test/main_test.dart', + ]); + expect(searchResult.truncated, isTrue); + + final pausing = controller.pauseSession(); + await _flush(); + final pause = channel.sentJson.last; + expect(pause['command'], 'session.pause'); + channel.emit( + _response( + pause, + command: 'session.pause', + result: {'paused': true, 'changed': true}, + ), + ); + await pausing; + expect(controller.state.composer.isPaused, isTrue); + + final resuming = controller.resumeSession(); + await _flush(); + final resume = channel.sentJson.last; + expect(resume['command'], 'session.resume'); + channel.emit( + _response( + resume, + command: 'session.resume', + result: {'resumed': true, 'paused': false}, + ), + ); + await resuming; + expect(controller.state.composer.isPaused, isFalse); + + final compacting = controller.compactSession( + instructions: 'Keep the current implementation plan.', + ); + await _flush(); + final compact = channel.sentJson.last; + expect(compact['command'], 'session.compact'); + expect(compact['args'], { + 'instructions': 'Keep the current implementation plan.', + }); + channel.emit( + _response( + compact, + command: 'session.compact', + result: {'compacted': true}, + ), + ); + await compacting; + }, + ); + test( 'projects terminal, files, audit, and preview developer state', () async { diff --git a/apps/flutter/test/ui/host_flow_test.dart b/apps/flutter/test/ui/host_flow_test.dart index 5b9ef14a..b7aadb52 100644 --- a/apps/flutter/test/ui/host_flow_test.dart +++ b/apps/flutter/test/ui/host_flow_test.dart @@ -1457,6 +1457,136 @@ void main() { 'selector': 'button', }); }); + + testWidgets('quick open searches the selected project and opens its file', ( + tester, + ) async { + final profile = HostProfile.parseTailnetAddress( + 'https://alpha.tailnet-name.ts.net', + ); + final actions = _FakeActions( + projectFileSearchResult: const ProjectFileSearchResult( + paths: ['lib/main.dart', 'test/main_test.dart'], + truncated: false, + ), + ); + await pumpApp( + tester, + state: T4ViewState( + connectionPhase: ConnectionPhase.ready, + hostDirectory: HostDirectory.empty().upsert(profile), + authenticationPhase: AuthenticationPhase.paired, + grantedCapabilities: t4RequestedCapabilities.toSet(), + grantedFeatures: const {'files.search'}, + selectedSessionId: 'session-alpha', + sessions: const [ + SessionSummary( + hostId: 'host-alpha', + sessionId: 'session-alpha', + projectId: 'project-alpha', + projectName: 'Project Alpha', + title: 'Quick open fixture', + revision: 'revision-alpha', + status: 'idle', + ), + ], + ), + actions: actions, + size: compactPhone, + ); + + await tester.tap(find.byTooltip('Quick open project file')); + await tester.pumpAndSettle(); + expect(find.text('Quick open'), findsOneWidget); + + await tester.enterText( + find.widgetWithText(TextField, 'Find a project file'), + 'main', + ); + await tester.pump(const Duration(milliseconds: 250)); + await tester.pumpAndSettle(); + expect(actions.projectFileQueries, ['main']); + expect(find.text('lib/main.dart'), findsOneWidget); + + await tester.tap(find.text('lib/main.dart')); + await tester.pumpAndSettle(); + expect(actions.readFilePaths, ['lib/main.dart']); + expect(find.text('Developer tools'), findsOneWidget); + expect(find.text('Files'), findsOneWidget); + }); + + testWidgets('composer exposes pause, resume, and manual compaction', ( + tester, + ) async { + final profile = HostProfile.parseTailnetAddress( + 'https://alpha.tailnet-name.ts.net', + ); + final actions = _FakeActions(); + T4ViewState stateFor({required bool turnActive, required bool isPaused}) => + T4ViewState( + connectionPhase: ConnectionPhase.ready, + hostDirectory: HostDirectory.empty().upsert(profile), + authenticationPhase: AuthenticationPhase.paired, + grantedCapabilities: t4RequestedCapabilities.toSet(), + selectedSessionId: 'session-alpha', + sessions: [ + SessionSummary( + hostId: 'host-alpha', + sessionId: 'session-alpha', + projectId: 'project-alpha', + projectName: 'Project Alpha', + title: 'Control fixture', + revision: 'revision-alpha', + status: turnActive ? 'active' : 'idle', + turnActive: turnActive, + isPaused: isPaused, + ), + ], + composer: SessionComposerState( + turnActive: turnActive, + isPaused: isPaused, + ), + ); + + await pumpApp( + tester, + state: stateFor(turnActive: true, isPaused: false), + actions: actions, + size: compactPhone, + ); + expect(find.text('Pause'), findsOneWidget); + expect(find.text('Stop'), findsOneWidget); + await tester.tap(find.text('Pause')); + await tester.pumpAndSettle(); + expect(actions.pauseSessionCalls, 1); + + await tester.pumpWidget( + T4App( + state: stateFor(turnActive: false, isPaused: true), + actions: actions, + credentialsAreVolatile: false, + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Resume'), findsOneWidget); + expect(find.text('Resume the session to continue'), findsOneWidget); + await tester.tap(find.text('Resume')); + await tester.pumpAndSettle(); + expect(actions.resumeSessionCalls, 1); + + await tester.pumpWidget( + T4App( + state: stateFor(turnActive: false, isPaused: false), + actions: actions, + credentialsAreVolatile: false, + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Compact'), findsOneWidget); + await tester.tap(find.text('Compact')); + await tester.pumpAndSettle(); + expect(actions.compactSessionCalls, 1); + }); } final class _FakeActions implements T4Actions { @@ -1467,6 +1597,7 @@ final class _FakeActions implements T4Actions { this.transcriptContextResult, this.usageReadResult, this.brokerStatusResult, + this.projectFileSearchResult, }); final Object? addHostError; @@ -1475,6 +1606,7 @@ final class _FakeActions implements T4Actions { final TranscriptContextResult? transcriptContextResult; final UsageReadResult? usageReadResult; final BrokerStatusResult? brokerStatusResult; + final ProjectFileSearchResult? projectFileSearchResult; final List transcriptQueries = []; final List contextAnchors = []; final List addedAddresses = []; @@ -1495,6 +1627,9 @@ final class _FakeActions implements T4Actions { final List submittedPrompts = []; final List queuedPrompts = []; int cancelTurnCalls = 0; + int pauseSessionCalls = 0; + int resumeSessionCalls = 0; + int compactSessionCalls = 0; int loadEarlierCalls = 0; final List selectedModels = []; final List selectedThinkingLevels = []; @@ -1504,6 +1639,8 @@ final class _FakeActions implements T4Actions { attentionResponses = <({AttentionItem item, AttentionResponse response})>[]; final List retriedSessionIds = []; final List cancelledAgentIds = []; + final List projectFileQueries = []; + final List readFilePaths = []; final List<({String path, String content})> fileWrites = <({String path, String content})>[]; final List refreshedReviewIds = []; @@ -1670,6 +1807,21 @@ final class _FakeActions implements T4Actions { cancelTurnCalls += 1; } + @override + Future pauseSession() async { + pauseSessionCalls += 1; + } + + @override + Future resumeSession() async { + resumeSessionCalls += 1; + } + + @override + Future compactSession({String? instructions}) async { + compactSessionCalls += 1; + } + @override Future setSessionModel(String selector) async { selectedModels.add(selector); @@ -1718,7 +1870,19 @@ final class _FakeActions implements T4Actions { Future listFiles([String path = '']) async {} @override - Future readFile(String path) async {} + Future searchProjectFiles( + String query, { + int limit = 12, + }) async { + projectFileQueries.add(query); + return projectFileSearchResult ?? + (throw UnsupportedError('project file search is not configured')); + } + + @override + Future readFile(String path) async { + readFilePaths.add(path); + } @override Future loadSessionDiff() async {} diff --git a/docs/OMP_T4_CAPABILITY_AUDIT.md b/docs/OMP_T4_CAPABILITY_AUDIT.md index 745fdc1d..14d1ce4a 100644 --- a/docs/OMP_T4_CAPABILITY_AUDIT.md +++ b/docs/OMP_T4_CAPABILITY_AUDIT.md @@ -27,8 +27,8 @@ The truthful-command foundation is split cleanly between merged host work and th - **Merged:** PR #113 queries official OMP's bounded `get_available_commands`, supplements omitted terminal-only commands from a pinned reviewed manifest, and blocks known terminal-only slash text before prompt dispatch. - **Merged:** PR #114 proves restart continuity through the official adapter on macOS. - **Merged:** PR #117 preserves `catalog.get.result.operations` through the desktop runtime. -- **This sprint:** web/Electron and Flutter build their slash menus from those capabilities; typed commands such as `session.cancel` are no longer mistaken for slash commands; unsupported entries remain visible and disabled with OMP's reason. -- **Already merged:** PR #106 provides confined project file search for the web/Electron Quick Open path. Flutter still needs its own Quick Open surface. +- **Merged:** PRs #118 and #120 make web/Electron and Flutter build truthful slash menus from the runtime capability contract and fail closed when that contract is absent or unavailable. +- **This sprint:** Flutter adds project Quick Open plus visible pause, resume, and manual compaction controls over existing typed commands. No new fork behavior is required. The tracker distinguishes merged source, work in this sprint, and public release state. None of the new adapter/client work is claimed as packaged desktop, Android, or iOS proof yet. @@ -40,8 +40,10 @@ The tracker distinguishes merged source, work in this sprint, and public release | Official OMP command discovery and rejection | Merged | Receives through host | Receives through host | Receives through host | PR #113 green | | Restart continuity | macOS proof merged | Shared host behavior | Shared host behavior | Shared host behavior | PR #114 green; other Gate 0 rows open | | Preserve `catalog.get.operations` | Merged host response | Merged in PR #117 | Merged in PR #117 | Already decoded | Response and live-frame client tests pass | -| Capability-aware slash menu | Host rejects unsafe fallback | Implemented this sprint | Implemented this sprint | Implemented this sprint | Web tests pass; Flutter tests authored but local SDK is unavailable | -| Project Quick Open | `files.search` merged | Implemented on `main` | Implemented on `main` | Missing | PR #106 green; Flutter is next UI-only slice | +| Capability-aware slash menu | Host rejects unsafe fallback | Implemented on `main` | Implemented on `main` | Implemented on `main` | PRs #118 and #120 are green and merged | +| Project Quick Open | `files.search` merged | Implemented on `main` | Implemented on `main` | Implemented this sprint | Flutter analysis and the full 168-test suite pass locally; platform CI pending | +| Pause/resume controls | Typed commands merged | Not consistently visible | Not consistently visible | Implemented this sprint | Controller and widget coverage pass locally; platform CI pending | +| Manual compaction | Typed command merged | Partial slash/control UX | Partial slash/control UX | Implemented this sprint | Direct action is locally verified; richer strategy/result UX remains | This matrix is intentionally stricter than “the protocol supports it.” A row is complete only when the connected runtime advertises it, the client exposes an honest action or explanation, and the target package has been exercised. @@ -52,7 +54,7 @@ This matrix is intentionally stricter than “the protocol supports it.” A row | Original OMP | [`can1357/oh-my-pi@89d6a8f6`](https://github.com/can1357/oh-my-pi/commit/89d6a8f6d14286f32f09ec9c8aa8af7b3451d2d6), version 17.0.6 | Current original product surface | | Lycaon OMP fork | [`lyc-aon/oh-my-pi@8476f445`](https://github.com/lyc-aon/oh-my-pi/commit/8476f4451ed95c5d5401785d279a93d3c659fac4), tag [`t4code-17.0.5-appserver-10`](https://github.com/lyc-aon/oh-my-pi/releases/tag/t4code-17.0.5-appserver-10) | Current released thin authority bridge; transitional compatibility input | | Shared upstream base | [`can1357/oh-my-pi@9fd6e971`](https://github.com/can1357/oh-my-pi/commit/9fd6e97113f5ed3a847e66d346970efdf8afcad9), version 17.0.5 | Last shared OMP point | -| T4 `main` | [`fcc67cb1`](https://github.com/LycaonLLC/t4-code/commit/fcc67cb1b91aae357c02e00b2f1670eb6961bc93), version 0.1.30 in source | Official-OMP classification, restart proof, and desktop capability preservation are merged | +| T4 `main` | [`298165bc`](https://github.com/LycaonLLC/t4-code/commit/298165bce4e6f57c19f9814798d50c4aa28b4bd8), version 0.1.30 in source | Official-OMP classification, restart proof, capability-aware clients, and contract hardening are merged | | Flutter merge | [`LycaonLLC/t4-code#104`](https://github.com/LycaonLLC/t4-code/pull/104) | New shared desktop/mobile client now on `main` | | Public T4 release | [`v0.1.28`](https://github.com/LycaonLLC/t4-code/releases/tag/v0.1.28) | Latest public release visible during the audit | @@ -149,12 +151,11 @@ Fork PR [#22](https://github.com/lyc-aon/oh-my-pi/pull/22) removed more than 37, | Priority | Gap | Why it matters | Best patch path | |---|---|---|---| | T0 | Official adapter Gate 0 is incomplete | A clean command catalog is not enough to prove all lifecycle and failure behavior | Finish Linux, steer/follow-up, approval, cancellation, and dispatch-crash scenarios | -| T0 | Client capability propagation is not on `main` | The host knows the truth, but current clients can drop or ignore it | Complete and merge this desktop/web/Flutter slice | | T0 | Release state is ambiguous | Source says v0.1.30 while public GitHub release remains v0.1.28 | Separate `on main`, `verified package`, and `publicly released` in the tracker/release gate | | T0 | Plan, goal, branch/fork/tree, handoff, and provider auth lack complete typed app flows | These are central OMP workflows, not decorative terminal features | Typed T4 commands backed by existing OMP RPC where possible | -| T1 | Queue and pause/resume controls are not consistently exposed | Cross-device control needs explicit, predictable behavior | Finish client UI over existing wire commands | +| T1 | Queue and pause/resume controls are not consistently exposed outside Flutter | Cross-device control needs explicit, predictable behavior | Add equally visible controls to web/Capacitor over the same typed commands | | T1 | Child agents can be viewed/cancelled but not fully steered or messaged | Agent orchestration is a major reason to use OMP | Add typed agent actions and OMP event projection | -| T1 | Flutter project Quick Open is missing | Confined project search is merged in the host and web client but not exposed in Flutter | Add a shared Flutter search state and compact/wide picker over `files.search` | +| T1 | Project/worktree context is not consistent across clients | Quick Open is now covered, but branch/worktree identity is still easy to miss | Project a shared read-only context summary from runtime truth | | T1 | Mobile tool output is mostly generic | It is safe, but harder to scan than desktop | Share semantic tool-view models, then use platform-specific layouts | | T2 | MCP, skills, plugins, extensions, and marketplaces are mainly catalog/settings data | Advanced OMP setup still requires the terminal | Start read-only, then add bounded management actions | | T2 | Memory, checkpoints, rewind, collaboration, and sharing lack first-class app flows | Useful but less common and higher-risk to expose casually | Typed commands with confirmation and clear results | @@ -276,18 +277,17 @@ official OMP version + commit ### Phase 0: finish the official-OMP foundation -1. Merge capability-aware desktop/web and Flutter presentation. -2. Finish the open Gate 0 scenarios: Linux, steer/follow-up, approval, cancellation, and ambiguous dispatch-crash recovery. -3. Generate a compatibility snapshot from the official adapter smoke. -4. Track source, verified package, and public release status separately. +1. Finish the open Gate 0 scenarios: Linux, steer/follow-up, approval, cancellation, and ambiguous dispatch-crash recovery. +2. Generate a compatibility snapshot from the official adapter smoke. +3. Track source, verified package, and public release status separately. ### Phase 1: close the daily-workflow gaps 1. Plan and goal modes. 2. Branch/fork/tree and handoff. 3. Provider login/logout and setup. -4. Queue controls plus visible pause/resume and manual compaction. -5. Flutter Quick Open plus consistent project/worktree context. +4. Queue controls plus matching pause/resume and compaction controls in web/Capacitor. +5. Consistent project/worktree context across clients. 6. Child-agent steer, follow-up, and wake/message actions. ### Phase 2: make desktop and mobile equally useful diff --git a/docs/OMP_T4_CAPABILITY_TRACKER.csv b/docs/OMP_T4_CAPABILITY_TRACKER.csv index fd90dd72..bd4968f3 100644 --- a/docs/OMP_T4_CAPABILITY_TRACKER.csv +++ b/docs/OMP_T4_CAPABILITY_TRACKER.csv @@ -13,7 +13,7 @@ "P02","T0","Projects","Create project or start session in a project","CLI cwd and new session","Typed session.create and project roots","Code: full","Code: full","Code: full","Needs packaged proof","Existing typed command","Do not create a second project database" "P03","T1","Projects","Reveal project in native file manager","CLI/path known","Privacy-safe project reveal support in integration","Code: full","Platform","Code: partial","Mobile needs a native equivalent such as copy/share path","Platform adapter","Not every desktop action has a meaningful phone equivalent" "P04","T1","Projects","Project file tree","OMP file tools and cwd","Bounded files.list authority","Code: full","Code: partial","Code: full","Responsive/mobile ergonomics need proof","Client UI over existing files.list","File scope stays confined by OMP authority" -"P05","T1","Projects","Search project files","grep/glob/search tools","Host file authority exists","Code: full","Code: full","Missing","PR #106 merged the confined host and web Quick Open path; Flutter has no equivalent screen","Add Flutter compact/wide Quick Open over the same files.search operation","Host confinement and result bounds are already implemented" +"P05","T1","Projects","Search project files","grep/glob/search tools","Host file authority exists","Code: full","Code: full","Code: full (sprint)","Flutter Quick Open is implemented in this sprint; packaged device proof remains","Run Flutter analysis, widget tests, and Android/iOS build lanes","Uses the same confined and bounded files.search authority as web/Electron" "P06","T1","Projects","Git branch and worktree context","Native git and worktree commands","T4 wire has optional workspace/runtime metadata","Code: partial","Code: partial","Code: partial","Context is not consistently visible","Project a read-only summary; keep mutation typed and guarded","Workspace features are negotiated, not universal" "P07","T2","Projects","Create/import/archive/recover Git workspaces","OMP worktree management","T4 host wire defines lifecycle","Code: partial","Code: partial","Code: partial","Protocol breadth exceeds proven client UX","Typed workspace actions gated by negotiated feature","Do not infer support from command definitions alone" "P08","T2","Projects","Project scripts and common commands","Shell and custom commands","No complete app-specific catalog proven","Missing","Missing","Missing","No ergonomic script surface","Read-only command catalog, then guarded bash.run","Avoid executing arbitrary catalog text without confirmation" @@ -23,12 +23,12 @@ "S04","T0","Sessions","Archive and restore","Session management operations","Authority methods and typed commands","Code: full","Code: full","Code: full","No major source gap","Existing typed commands","Verify archive filters on each client" "S05","T0","Sessions","Close terminate and delete","Native lifecycle","Typed close/delete and authority methods","Code: full","Code: full","Code: full","Destructive copy and confirmation need platform proof","Existing typed commands with confirmation","Delete must remain explicit" "S06","T0","Sessions","Retry last turn","/retry and RPC retry","Typed session.retry plus verified slash handler","Code: full","Code: full","Code: full","No major source gap","Prefer typed command","Do not rely on slash text where typed command exists" -"S07","T1","Sessions","Pause and resume an active agent","/pause and runtime controls","Typed wire commands exist","Code: partial","Code: partial","Code: partial","Controls are not consistently visible and verified","Client UI over existing session.pause/resume","Distinguish pause from cancel" +"S07","T1","Sessions","Pause and resume an active agent","/pause and runtime controls","Typed wire commands exist","Code: partial","Code: partial","Code: full (sprint)","Flutter now has explicit pause and resume controls; web/Capacitor still need equally visible controls","Expose the same typed controls in the shared web composer","Flutter keeps pause separate from stop/cancel" "S08","T1","Sessions","Reset provider-facing state without replacing session","/fresh","Verified headless slash handler","Slash only","Slash only","Slash only","No first-class explanation or result","Typed RPC wrapper or keep an explicitly marked headless slash","Useful for provider conversation state" "S09","T0","Sessions","Branch fork and navigate session tree","/branch /fork /tree and RPC branch/get_branch_messages","TUI commands are cataloged but not headless","Missing","Missing","Missing","Central OMP session-tree behavior is absent","Typed wire commands over existing RPC","Do not parse JSONL directly in clients" "S10","T0","Sessions","Compact handoff and resume elsewhere","/compact /handoff /resume and RPC compact/handoff","Compact is typed; handoff/resume commands are TUI-only","Code: partial","Code: partial","Code: partial","Compaction exists; full handoff workflow does not","Typed wire commands over existing RPC","Show summary outcome and resulting session identity" "S11","T2","Sessions","Move or re-root a session","/move has a headless handler","Verified headless slash handler","Slash only","Slash only","Slash only","Server-side path entry is awkward on mobile","Typed authority action with path validation","Keep root confinement in OMP" -"S12","T1","Sessions","Manual compaction and strategy visibility","Multiple compaction strategies and /compact","Typed compact command and state fields","Code: partial","Code: partial","Code: partial","Action exists but strategy/result UX is incomplete","Client UI over typed command plus projected result","Auto-compaction state should remain read-only truth" +"S12","T1","Sessions","Manual compaction and strategy visibility","Multiple compaction strategies and /compact","Typed compact command and state fields","Code: partial","Code: partial","Code: full (sprint)","Flutter exposes manual compact; strategy selection and a richer result remain future work","Verify the direct control first, then add strategy/result UX from runtime truth","Auto-compaction state remains read-only truth" "S13","T2","Sessions","Shake or reduce stale context","/shake has a headless handler","Verified headless slash handler","Slash only","Slash only","Slash only","No native explanation or result","Keep marked slash initially; later typed operation","Less common than ordinary compact" "S14","T2","Sessions","Export dump and copy transcript","/export /dump /copy","Export and dump have headless handlers; copy is TUI-only","Slash only","Slash only","Slash only","Server-side output destinations are not ergonomic on devices","Typed artifact export and native share sheet","Do not assume a remote host clipboard is the user's clipboard" "S15","T2","Sessions","Encrypted share collaboration join and leave","/share /collab /join /leave and browser guest","Share is headless; collaboration commands are TUI-only","Code: partial","Code: partial","Missing","Full read/write collaboration is not mapped","Typed collaboration contract with explicit permissions","Higher security and identity risk than transcript export"