migrate the slash typeahead onto ClideTypeahead (T-286, D-88)

Swap the composer's hand-rolled LayerLink/OverlayEntry slash popover for the
shared ClideTypeahead, driven by a ClideMenuListController for arrow/Enter nav
while the EditableText keeps focus. The key pipeline (Esc-fallthrough,
Tab-complete, history) stays in the composer.

ClideTypeahead now bridges its live suggestions through a ValueNotifier so the
popover narrows as you type — the OverlayEntry is a separate subtree that does
not rebuild with the host, so a captured list would go stale. The notifier and
open/close run post-frame to avoid rebuilding widgets during the parent's build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 23:02:07 +02:00
co-authored by Claude Opus 4.8
parent 60312eff3b
commit 1c20bf128a
4 changed files with 78 additions and 105 deletions
@@ -151,6 +151,7 @@ void main() {
await pumpWithCommands(tester, ['model', 'memory', 'clear']);
await tester.enterText(find.byType(EditableText), '/m');
await tester.pump();
await tester.pump(); // ClideTypeahead inserts the popover post-frame
expect(find.text('/model'), findsOneWidget);
expect(find.text('/memory'), findsOneWidget);
@@ -161,6 +162,7 @@ void main() {
await pumpWithCommands(tester, ['clear', 'compact']);
await tester.enterText(find.byType(EditableText), 'hey /cl');
await tester.pump();
await tester.pump(); // ClideTypeahead inserts the popover post-frame
expect(find.text('/clear'), findsOneWidget);
});
@@ -173,6 +175,7 @@ void main() {
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
await tester.pump(); // popover closes post-frame after completion
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');
@@ -192,10 +195,12 @@ void main() {
await pumpWithCommands(tester, ['model']);
await tester.enterText(find.byType(EditableText), '/mo');
await tester.pump();
await tester.pump(); // ClideTypeahead inserts the popover post-frame
expect(find.text('/model'), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pump();
await tester.pump(); // popover closes post-frame
expect(find.text('/model'), findsNothing);
});
@@ -260,11 +265,13 @@ void main() {
));
await tester.enterText(find.byType(EditableText), '/mo');
await tester.pump();
await tester.pump(); // ClideTypeahead inserts the popover post-frame
expect(find.text('/model'), findsOneWidget);
// First Escape only dismisses the popup; it does not interrupt.
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pump();
await tester.pump(); // popover closes post-frame
expect(find.text('/model'), findsNothing);
expect(interrupts, 0);
@@ -302,6 +309,7 @@ void main() {
await pumpWithCommands(tester, ['model']);
await tester.enterText(find.byType(EditableText), '/res');
await tester.pump();
await tester.pump(); // ClideTypeahead inserts the popover post-frame
// /resume must appear (sourced from kClideOwnedCommands).
expect(find.text('/resume'), findsOneWidget);
@@ -314,6 +322,7 @@ void main() {
await pumpWithCommands(tester, ['clear', 'model']);
await tester.enterText(find.byType(EditableText), '/cl');
await tester.pump();
await tester.pump(); // ClideTypeahead inserts the popover post-frame
// /clear must appear exactly once (filterSlashCommands de-dupes via seen set).
expect(find.text('/clear'), findsOneWidget);
@@ -328,6 +337,7 @@ void main() {
));
await tester.enterText(find.byType(EditableText), '/fo');
await tester.pump();
await tester.pump(); // ClideTypeahead inserts the popover post-frame
// /fork is a kClideOwnedCommands member; it must appear without a probe.
expect(find.text('/fork'), findsOneWidget);