add slash-command typeahead to the Claude composer

Typing a slash — anywhere in the message, not just at the start — opens
an anchored typeahead listing matching commands and skills from
ClaudeConfig's slash list. Arrow keys move the selection, Enter/Tab
completes (inserting "/command "), Escape dismisses; with the popup
closed, Enter still submits and Tab still traverses. The recognition,
filtering, and completion are pure functions (slash_commands.dart) so
they're cheaply unit-tested; the overlay is a no-Material
CompositedTransformFollower keyed off the field's focus node.

T-152.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 12:43:49 +02:00
co-authored by Claude Opus 4.7
parent 5fad8dc430
commit f6a5d72ccd
7 changed files with 427 additions and 63 deletions
@@ -130,6 +130,84 @@ void main() {
expect(find.text('notes.txt'), findsNothing);
});
Future<List<String>> pumpWithCommands(
WidgetTester tester,
List<String> commands,
) async {
final submitted = <String>[];
await tester.pumpWidget(harness(
f,
ClaudeComposer(
onSubmit: submitted.add,
slashCommandsResolver: () => commands,
),
));
return submitted;
}
testWidgets('typing a slash opens the typeahead with matching commands', (tester) async {
await pumpWithCommands(tester, ['model', 'memory', 'clear']);
await tester.enterText(find.byType(EditableText), '/m');
await tester.pump();
expect(find.text('/model'), findsOneWidget);
expect(find.text('/memory'), findsOneWidget);
expect(find.text('/clear'), findsNothing);
});
testWidgets('an inline slash (mid-message) also opens the typeahead', (tester) async {
await pumpWithCommands(tester, ['clear', 'compact']);
await tester.enterText(find.byType(EditableText), 'hey /cl');
await tester.pump();
expect(find.text('/clear'), findsOneWidget);
});
testWidgets('arrow-down + Enter completes the selected command (no submit)', (tester) async {
final submitted = await pumpWithCommands(tester, ['model', 'memory']);
await tester.enterText(find.byType(EditableText), '/m'); // → [memory, model]
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); // select 'model'
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(tester.widget<EditableText>(find.byType(EditableText)).controller.text, '/model ');
expect(submitted, isEmpty, reason: 'Enter completes, it does not submit, while the popup is open');
expect(find.text('/memory'), findsNothing, reason: 'popup closes after completion');
});
testWidgets('Tab completes the top suggestion', (tester) async {
await pumpWithCommands(tester, ['clear', 'compact']);
await tester.enterText(find.byType(EditableText), '/cle');
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
expect(tester.widget<EditableText>(find.byType(EditableText)).controller.text, '/clear ');
});
testWidgets('Escape dismisses the typeahead', (tester) async {
await pumpWithCommands(tester, ['model']);
await tester.enterText(find.byType(EditableText), '/mo');
await tester.pump();
expect(find.text('/model'), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pump();
expect(find.text('/model'), findsNothing);
});
testWidgets('with the popup closed, Enter still submits', (tester) async {
final submitted = await pumpWithCommands(tester, ['model']);
await tester.enterText(find.byType(EditableText), 'plain message');
await tester.pump();
expect(find.text('/model'), findsNothing);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(submitted, ['plain message']);
});
testWidgets('remove × cancels the attachment before send', (tester) async {
final submitted = <String>[];
await tester.pumpWidget(harness(
@@ -35,4 +35,57 @@ void main() {
expect(isKnownSlashCommand('/clear\nand more', {'clear'}), isFalse);
});
});
group('activeSlashQuery', () {
test('matches a slash token at the cursor, including inline', () {
expect(activeSlashQuery('/mod', 4), const SlashQuery(start: 0, query: 'mod'));
expect(activeSlashQuery('go /mo', 6), const SlashQuery(start: 3, query: 'mo'));
expect(activeSlashQuery('/', 1), const SlashQuery(start: 0, query: ''));
});
test('no match for paths, post-space, or non-slash runs', () {
expect(activeSlashQuery('a/b', 3), isNull); // slash mid-token (path)
expect(activeSlashQuery('/model sonnet', 13), isNull); // closed after space
expect(activeSlashQuery('hello', 5), isNull);
expect(activeSlashQuery('/model ', 7), isNull); // cursor right after space
});
test('uses the cursor, not the end of text', () {
// Cursor sits right after "/mo" inside "/model".
expect(activeSlashQuery('/model', 3), const SlashQuery(start: 0, query: 'mo'));
});
});
group('filterSlashCommands', () {
const commands = ['model', 'memory', 'clear', 'compact', 'context'];
test('prefix-filters case-insensitively and sorts', () {
expect(filterSlashCommands('co', commands), ['compact', 'context']);
expect(filterSlashCommands('M', commands), ['memory', 'model']);
});
test('empty query returns all (sorted, capped)', () {
expect(filterSlashCommands('', commands, limit: 3), ['clear', 'compact', 'context']);
});
test('de-duplicates', () {
expect(filterSlashCommands('p', ['pql', 'pql', 'plan']), ['plan', 'pql']);
});
});
group('completeSlash', () {
test('replaces the token with /command and a trailing space', () {
const q = SlashQuery(start: 0, query: 'mo');
final r = completeSlash('/mo', q, 'model');
expect(r.text, '/model ');
expect(r.cursor, 7);
});
test('completes an inline token without disturbing surrounding text', () {
const q = SlashQuery(start: 3, query: 'mo');
final r = completeSlash('go /mo now', q, 'model');
expect(r.text, 'go /model now');
expect(r.cursor, 10);
});
});
}