Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/flutter/lib/src/client/app_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const List<String> t4RequestedFeatures = <String>[
'agent.transcript',
'terminal.io',
'files.list',
'files.search',
'files.diff',
'audit.tail',
'catalog.metadata',
Expand Down Expand Up @@ -101,6 +102,7 @@ final class SessionSummary {
this.fast = false,
this.fastAvailable = false,
this.turnActive = false,
this.isPaused = false,
this.queuedFollowUpCount = 0,
});

Expand All @@ -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;
Expand Down Expand Up @@ -211,6 +214,7 @@ final class SessionComposerState {
this.fastEnabled = false,
this.fastAvailable = false,
this.turnActive = false,
this.isPaused = false,
this.queuedFollowUpCount = 0,
});

Expand All @@ -226,6 +230,7 @@ final class SessionComposerState {
final bool fastEnabled;
final bool fastAvailable;
final bool turnActive;
final bool isPaused;
final int queuedFollowUpCount;
}

Expand Down Expand Up @@ -421,6 +426,13 @@ final class FileWorkspaceState {
final String? error;
}

final class ProjectFileSearchResult {
const ProjectFileSearchResult({required this.paths, required this.truncated});

final List<String> paths;
final bool truncated;
}

final class ReviewWorkspaceItem {
const ReviewWorkspaceItem({
required this.reviewId,
Expand Down Expand Up @@ -711,6 +723,12 @@ abstract interface class T4Actions {

Future<void> cancelTurn();

Future<void> pauseSession();

Future<void> resumeSession();

Future<void> compactSession({String? instructions});

Future<void> setSessionModel(String selector);

Future<void> setSessionThinking(String level);
Expand All @@ -734,6 +752,10 @@ abstract interface class T4Actions {
void closeTerminal(String terminalId);

Future<void> listFiles([String path = '']);
Future<ProjectFileSearchResult> searchProjectFiles(
String query, {
int limit = 12,
});
Future<void> readFile(String path);
Future<void> loadSessionDiff();
Future<void> writeFile(String path, String content);
Expand Down
165 changes: 165 additions & 0 deletions apps/flutter/lib/src/client/t4_client_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
Expand Down Expand Up @@ -1660,6 +1661,88 @@ final class T4ClientController extends ChangeNotifier implements T4Actions {
);
}

@override
Future<void> 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<String, Object?> && result['paused'] is bool) {
_setSessionPaused(session.sessionId, result['paused']! as bool);
}
}

@override
Future<void> 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<String, Object?> && result['paused'] is bool
? result['paused']! as bool
: false;
_setSessionPaused(session.sessionId, paused);
}

@override
Future<void> 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: <String, Object?>{
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<void> setSessionModel(String selector) async {
final session = state.selectedSession;
Expand Down Expand Up @@ -2016,6 +2099,65 @@ final class T4ClientController extends ChangeNotifier implements T4Actions {
);
});

@override
Future<ProjectFileSearchResult> 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: <String, Object?>{'query': normalized, 'limit': limit},
);
final result = frame.result;
if (result is! Map<String, Object?> ||
result['matches'] is! List<Object?> ||
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<Object?>).length > 50) {
throw const FormatException('files.search result is invalid');
}
final paths = <String>[];
final seen = <String>{};
for (final raw in result['matches']! as List<Object?>) {
if (raw is! Map<String, Object?> ||
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<String>.unmodifiable(paths),
truncated: result['truncated']! as bool,
);
}

@override
Future<void> readFile(String path) => _runDeveloperOperation(() async {
final session = _developerSession();
Expand Down Expand Up @@ -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,
);
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
);
})
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/flutter/lib/src/protocol/wire_encoder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,7 @@ const Set<String> _knownCommands = {
'files.write',
'files.patch',
'files.list',
'files.search',
'files.diff',
'review.read',
'review.apply',
Expand Down
37 changes: 35 additions & 2 deletions apps/flutter/lib/src/ui/adaptive_session_shell.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ final class _AdaptiveSessionShellState extends State<_AdaptiveSessionShell> {
bool _showSettings = false;
bool _showSearch = false;
bool _showUsage = false;
int _developerInitialTab = 0;

Future<void> _connect() async {
if (_connecting) return;
Expand Down Expand Up @@ -131,17 +132,40 @@ 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;
});

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<void> _openQuickOpen() async {
if (!_canQuickOpen) return;
final path = await showDialog<String>(
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;
Expand Down Expand Up @@ -308,6 +332,7 @@ final class _AdaptiveSessionShellState extends State<_AdaptiveSessionShell> {
return _DeveloperSurfacesPane(
state: widget.state,
actions: widget.actions,
initialTab: _developerInitialTab,
showHeader: showHeader,
onDone: _closeDeveloper,
);
Expand All @@ -321,6 +346,7 @@ final class _AdaptiveSessionShellState extends State<_AdaptiveSessionShell> {
onOpenSessions: showHeader ? null : _openNavigation,
onOpenAttention: _openAttention,
onOpenDeveloper: _openDeveloper,
onOpenQuickOpen: _openQuickOpen,
);
}

Expand Down Expand Up @@ -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}'),
Expand All @@ -455,6 +487,7 @@ final class _AdaptiveSessionShellState extends State<_AdaptiveSessionShell> {
icon: const Icon(Icons.inbox_outlined),
),
),
],
if (!_showHostManager &&
!_showAttention &&
!_showSearch &&
Expand Down
Loading
Loading