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
2 changes: 2 additions & 0 deletions apps/flutter/lib/src/client/app_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -188,12 +188,14 @@ final class ComposerSlashCommand {
required this.name,
required this.description,
required this.insert,
this.aliases = const <String>[],
this.disabledReason,
});

final String name;
final String description;
final String insert;
final List<String> aliases;
final String? disabledReason;
}

Expand Down
65 changes: 55 additions & 10 deletions apps/flutter/lib/src/client/t4_client_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,9 @@ final class T4ClientController extends ChangeNotifier implements T4Actions {
if (session == null) return const SessionComposerState();
final choices = <ComposerModelChoice>[];
final seen = <String>{};
final slashCommands = <ComposerSlashCommand>[];
final slashCommands = <String, ComposerSlashCommand>{};
final operationCapabilities =
_catalogFrame?.operations ?? const <OperationCapability>[];
for (final item in _catalogItems) {
if (item.kind == 'model') {
final selector = modelItemSelector(item);
Expand All @@ -205,7 +207,7 @@ final class T4ClientController extends ChangeNotifier implements T4Actions {
);
continue;
}
if (item.kind != 'command') continue;
if (item.kind != 'command' || operationCapabilities.isNotEmpty) continue;
final bareName = item.name.replaceFirst(RegExp(r'^/+'), '');
final missingCapability = item.capabilities
?.where((capability) => !_grantedCapabilities.contains(capability))
Expand All @@ -215,13 +217,54 @@ final class T4ClientController extends ChangeNotifier implements T4Actions {
: missingCapability == null
? null
: 'Not granted on this host';
slashCommands.add(
ComposerSlashCommand(
name: '/$bareName',
description: item.description ?? '',
insert: '/$bareName ',
disabledReason: disabledReason,
),
final name = '/$bareName';
slashCommands[name] = ComposerSlashCommand(
name: name,
description: item.description ?? '',
insert: '$name ',
disabledReason: disabledReason,
);
}
for (final operation in operationCapabilities) {
if (!operation.operationId.startsWith('slash.')) continue;
final bareName = operation.operationId.substring('slash.'.length);
if (bareName.isEmpty) continue;
final name = '/$bareName';
final metadata = operation.raw['metadata'];
final rawAliases = metadata is Map<String, Object?>
? metadata['aliases']
: null;
final aliases = rawAliases is List<Object?>
? rawAliases
.whereType<String>()
.where((alias) => alias.isNotEmpty)
.map((alias) => '/${alias.replaceFirst(RegExp(r'^/+'), '')}')
.toList(growable: false)
: const <String>[];
final missingCapability = operation.capabilities
?.where((capability) => !_grantedCapabilities.contains(capability))
.firstOrNull;
String? disabledReason;
if (!operation.supported) {
disabledReason =
operation.disabledReason?.message ?? 'Not available on this host';
} else if (missingCapability != null) {
disabledReason = missingCapability == 'terminal.io'
? 'Needs terminal access on this host'
: 'Not granted on this host';
} else if ((session.turnActive || _submitting) &&
bareName == 'compact') {
disabledReason = 'Wait for the turn to finish';
} else if ((session.turnActive || _submitting) &&
bareName == 'retry') {
disabledReason = 'A turn is already running';
}
slashCommands[name] = ComposerSlashCommand(
name: name,
aliases: List<String>.unmodifiable(aliases),
description: operation.description ?? '',
insert: '$name ',
disabledReason: disabledReason,
);
}
final groups = groupModelChoices(_catalogItems);
Expand Down Expand Up @@ -258,7 +301,9 @@ final class T4ClientController extends ChangeNotifier implements T4Actions {
modelSelector: session.modelSelector,
modelChoices: List<ComposerModelChoice>.unmodifiable(choices),
modelGroups: List<ModelProviderGroup>.unmodifiable(groups),
slashCommands: List<ComposerSlashCommand>.unmodifiable(slashCommands),
slashCommands: List<ComposerSlashCommand>.unmodifiable(
slashCommands.values,
),
thinking: session.thinking,
thinkingLevels: List<String>.unmodifiable(levels),
fastEnabled: session.fast,
Expand Down
11 changes: 9 additions & 2 deletions apps/flutter/lib/src/ui/conversation_pane.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1118,7 +1118,11 @@ final class _PromptComposerState extends State<_PromptComposer> {
? const <ComposerSlashCommand>[]
: composer.slashCommands
.where(
(command) => command.name.toLowerCase().contains(slashQuery),
(command) =>
command.name.toLowerCase().contains(slashQuery) ||
command.aliases.any(
(alias) => alias.toLowerCase().contains(slashQuery),
),
)
.take(5)
.toList(growable: false);
Expand Down Expand Up @@ -1157,7 +1161,10 @@ final class _PromptComposerState extends State<_PromptComposer> {
enabled: command.disabledReason == null,
title: Text(command.name),
subtitle: Text(
command.disabledReason ?? command.description,
command.disabledReason ??
(command.aliases.isEmpty
? command.description
: '${command.description} · ${command.aliases.join(' ')}'),
),
onTap: command.disabledReason == null
? () => _selectSlashCommand(command)
Expand Down
97 changes: 97 additions & 0 deletions apps/flutter/test/client/t4_client_controller_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,103 @@ void main() {
},
);

test(
'composer uses official OMP operation capabilities and keeps terminal-only commands disabled',
() 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: const <String>[
'sessions.read',
'sessions.prompt',
'catalog.read',
],
features: const <String>['catalog.metadata'],
),
);
await _flush();
final list = channel.sentJson.firstWhere(
(frame) => frame['command'] == 'session.list',
);
channel.emit(
_response(
list,
command: 'session.list',
result: _sessionListResult('host-alpha'),
),
);
channel.emit(<String, Object?>{
'v': 'omp-app/1',
'type': 'catalog',
'hostId': 'host-alpha',
'revision': 'catalog-operation-capabilities',
'items': <Object?>[
<String, Object?>{
'id': 'command:session.cancel',
'kind': 'command',
'name': 'session.cancel',
},
],
'operations': <Object?>[
<String, Object?>{
'operationId': 'session.prompt',
'label': 'Prompt',
'execution': 'typed',
'supported': true,
},
<String, Object?>{
'operationId': 'slash.compact',
'label': '/compact',
'description': 'Compact the active conversation',
'execution': 'headless',
'supported': true,
'capabilities': <Object?>['sessions.prompt'],
'metadata': <String, Object?>{
'aliases': <Object?>['compress'],
},
},
<String, Object?>{
'operationId': 'slash.plan',
'label': '/plan',
'description': 'Toggle plan mode',
'execution': 'terminal-only',
'supported': false,
'disabledReason': <String, Object?>{
'code': 'terminal_only',
'message': '/plan requires the OMP terminal interface.',
},
},
],
});
await _flush();

final commands = controller.state.composer.slashCommands;
expect(commands.map((command) => command.name), <String>[
'/compact',
'/plan',
]);
expect(commands.first.aliases, <String>['/compress']);
expect(commands.first.disabledReason, isNull);
expect(
commands.last.disabledReason,
'/plan requires the OMP terminal interface.',
);
},
);

test('command ids are unique across controller restarts', () async {
final profile = _profile('alpha');
final directory = _MemoryDirectoryStore(
Expand Down
63 changes: 63 additions & 0 deletions apps/flutter/test/ui/host_flow_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -811,6 +811,69 @@ void main() {
expect(onLabel.style?.color, isNull);
},
);

testWidgets(
'slash menu finds aliases and explains terminal-only commands',
(tester) async {
final profile = HostProfile.parseTailnetAddress(
'https://alpha.tailnet-name.ts.net',
);
final state = T4ViewState(
connectionPhase: ConnectionPhase.ready,
hostDirectory: HostDirectory.empty().upsert(profile),
authenticationPhase: AuthenticationPhase.paired,
grantedCapabilities: t4RequestedCapabilities.toSet(),
selectedSessionId: 'session-alpha',
sessions: const <SessionSummary>[
SessionSummary(
hostId: 'host-alpha',
sessionId: 'session-alpha',
projectId: 'project-alpha',
projectName: 'Project Alpha',
title: 'Capability-aware slash menu',
revision: 'revision-alpha',
status: 'idle',
),
],
composer: const SessionComposerState(
slashCommands: <ComposerSlashCommand>[
ComposerSlashCommand(
name: '/compact',
aliases: <String>['/compress'],
description: 'Compact the active conversation',
insert: '/compact ',
),
ComposerSlashCommand(
name: '/plan',
description: 'Toggle plan mode',
insert: '/plan ',
disabledReason: '/plan requires the OMP terminal interface.',
),
],
),
);

await pumpApp(
tester,
state: state,
actions: _FakeActions(),
size: compactPhone,
);
await tester.enterText(find.byType(TextField).last, '/');
await tester.pump();
expect(find.text('/compact'), findsOneWidget);
expect(find.text('/plan'), findsOneWidget);
expect(
find.text('/plan requires the OMP terminal interface.'),
findsOneWidget,
);

await tester.enterText(find.byType(TextField).last, '/compress');
await tester.pump();
expect(find.text('/compact'), findsOneWidget);
expect(find.text('/plan'), findsNothing);
},
);
testWidgets('keeps the latest message visible when the keyboard opens', (
tester,
) async {
Expand Down
Loading
Loading