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
+4
View File
@@ -153,6 +153,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Changed
- The Claude composer's **slash typeahead** and the team-chat **@-mention**
list now ride the shared `ClideAnchoredOverlay` + `ClideMenu` popover
primitive, alongside the menu bar. The @-mention list gains full keyboard
nav (arrows/Enter), and both narrow live as you type. (T-286, D-88)
- Ticket cards now show parentage as a small **tree** — the parent as a muted,
clickable breadcrumb above and the card's own ticket **bold** under a ``
connector — instead of the ambiguous inline `T-1 ← T-9` arrow. (T-281)
+37 -92
View File
@@ -126,12 +126,12 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
late final FocusNode _focus = widget.focusNode ?? FocusNode();
final List<ComposerAttachment> _attachments = [];
// Slash typeahead state (T-152).
final LayerLink _link = LayerLink();
OverlayEntry? _overlay;
// Slash typeahead state (T-152). The popover is a ClideTypeahead driven by
// [_suggestions] (D-88); [_slashNav] is its highlight, advanced from _onKey
// while the EditableText keeps focus.
SlashQuery? _query;
List<String> _suggestions = const [];
int _selected = 0;
final ClideMenuListController _slashNav = ClideMenuListController(isSelectable: (_) => true, length: 0);
// Prompt-history navigation (T-163). _historyIndex is null when editing
// the live draft; otherwise it indexes [widget.history]. _stash holds the
@@ -159,7 +159,7 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
@override
void dispose() {
_closeTypeahead();
_slashNav.dispose();
_controller.removeListener(_onTextChanged);
_focus.removeListener(_onFocusChanged);
_controller.dispose();
@@ -207,42 +207,47 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
_closeTypeahead();
return;
}
// _onTextChanged (our only caller) already setState'd; just update state +
// the highlight, and ClideTypeahead opens the popover from [_suggestions].
_query = q;
_suggestions = suggestions;
_selected = 0;
if (_overlay == null) {
_overlay = OverlayEntry(builder: _buildTypeahead);
Overlay.of(context).insert(_overlay!);
} else {
_overlay!.markNeedsBuild();
}
_slashNav.length = suggestions.length;
_slashNav.setHighlight(0);
}
void _closeTypeahead() {
_overlay?.remove();
_overlay = null;
if (_query == null && _suggestions.isEmpty) return;
if (mounted) {
setState(() {
_query = null;
_suggestions = const [];
_selected = 0;
});
} else {
_query = null;
_suggestions = const [];
}
_slashNav.length = 0;
}
void _moveSelection(int delta) {
if (_suggestions.isEmpty) return;
_selected = (_selected + delta) % _suggestions.length;
if (_selected < 0) _selected += _suggestions.length;
_overlay?.markNeedsBuild();
}
void _moveSelection(int delta) => delta > 0 ? _slashNav.moveNext() : _slashNav.movePrev();
void _completeSelected() {
final i = _slashNav.highlighted;
if (i < 0 || i >= _suggestions.length) return;
_complete(_suggestions[i]);
}
/// Replace the active `/query` with [command] (keyboard Tab/Enter or a mouse
/// click on a typeahead row). The replacement re-runs _syncTypeahead via the
/// controller listener; the caret now sits after a space, so the query closes.
void _complete(String command) {
final q = _query;
if (q == null || _suggestions.isEmpty) return;
final r = completeSlash(_controller.text, q, _suggestions[_selected]);
if (q == null) return;
final r = completeSlash(_controller.text, q, command);
_controller.value = TextEditingValue(
text: r.text,
selection: TextSelection.collapsed(offset: r.cursor),
);
// The replacement re-runs _syncTypeahead via the controller listener; the
// caret now sits after a space, so the query closes.
}
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
@@ -258,7 +263,7 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
// Escape: dismiss the typeahead if open, otherwise interrupt the running
// turn — the escape hatch from a runaway (D-78).
if (e.logicalKey == LogicalKeyboardKey.escape) {
if (_overlay != null) {
if (_suggestions.isNotEmpty) {
_closeTypeahead();
return KeyEventResult.handled;
}
@@ -268,7 +273,7 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
}
return KeyEventResult.ignored;
}
if (_overlay == null) {
if (_suggestions.isEmpty) {
// Typeahead closed → Up/Down recall prompt history, but only once the
// caret reaches the first/last line so multi-line editing still works
// line-by-line first (T-163, Claude-CLI-style).
@@ -357,69 +362,6 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
_applyingHistory = false;
}
Widget _buildTypeahead(BuildContext ctx) {
final theme = ClideTheme.of(ctx).surface;
return Positioned(
left: 0,
top: 0,
child: CompositedTransformFollower(
link: _link,
targetAnchor: Alignment.topLeft,
followerAnchor: Alignment.bottomLeft,
offset: const Offset(0, -4),
showWhenUnlinked: false,
child: SizedBox(
width: 320,
child: Container(
decoration: BoxDecoration(
color: theme.panelBackground,
border: Border.all(color: theme.globalBorder),
borderRadius: BorderRadius.circular(6),
),
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < _suggestions.length; i++) _suggestionRow(theme, i),
],
),
),
),
),
);
}
Widget _suggestionRow(SurfaceTokens theme, int i) {
final selected = i == _selected;
return Semantics(
button: true,
selected: selected,
label: '/${_suggestions[i]}',
child: GestureDetector(
onTap: () {
_selected = i;
_completeSelected();
_focus.requestFocus();
},
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Container(
color: selected ? theme.panelActiveBorder : null,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
child: ClideText(
'/${_suggestions[i]}',
fontSize: clideFontSmall,
fontFamily: clideMonoFamily,
color: theme.globalForeground,
maxLines: 1,
),
),
),
),
);
}
void _submit() {
if (!widget.enabled) return;
final text = _controller.text;
@@ -477,8 +419,11 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
return Padding(
padding: const EdgeInsets.fromLTRB(10, 6, 10, 10),
child: CompositedTransformTarget(
link: _link,
child: ClideTypeahead(
suggestions: _suggestions,
onSelect: _complete,
navController: _slashNav,
formatLabel: (c) => '/$c',
child: Container(
decoration: BoxDecoration(
border: Border.all(color: theme.globalBorder),
+18 -4
View File
@@ -51,6 +51,13 @@ class ClideTypeahead extends StatefulWidget {
class _ClideTypeaheadState extends State<ClideTypeahead> {
final ClideOverlayController _overlay = ClideOverlayController();
// The live suggestion list bridged into the overlay. The OverlayEntry is a
// separate subtree that does NOT rebuild when this widget does, so a plain
// captured list would go stale as the user types; a ValueListenableBuilder
// inside the entry rebuilds the menu live without an (illegal, mid-build)
// markNeedsBuild on the entry.
final ValueNotifier<List<String>> _items = ValueNotifier(const []);
@override
void initState() {
super.initState();
@@ -63,11 +70,14 @@ class _ClideTypeaheadState extends State<ClideTypeahead> {
_scheduleSync();
}
// Drive open/close off the suggestion list, post-frame — opening inserts an
// OverlayEntry, which is illegal during the parent's build.
// Drive open/close + live content off the suggestion list, post-frame.
// Opening inserts an OverlayEntry and pushing the new list notifies the
// overlay's ValueListenableBuilder — both rebuild widgets, which is illegal
// during the parent's build, so defer to after the frame.
void _scheduleSync() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_items.value = widget.suggestions;
if (widget.suggestions.isEmpty) {
_overlay.close();
} else {
@@ -79,6 +89,7 @@ class _ClideTypeaheadState extends State<ClideTypeahead> {
@override
void dispose() {
_overlay.dispose();
_items.dispose();
super.dispose();
}
@@ -93,16 +104,19 @@ class _ClideTypeaheadState extends State<ClideTypeahead> {
captureFocus: false, // the text field keeps focus
dismissOnEscape: false, // the host routes Esc
anchor: widget.child,
overlayBuilder: (ctx, ctrl) => ClideMenu(
overlayBuilder: (ctx, ctrl) => ValueListenableBuilder<List<String>>(
valueListenable: _items,
builder: (ctx, items, _) => ClideMenu(
autofocus: false,
hoverHighlight: widget.navController == null,
controller: widget.navController,
minWidth: 0,
maxWidth: widget.maxWidth,
entries: [
for (final s in widget.suggestions) ClideMenuItem(label: widget.formatLabel?.call(s) ?? s, onSelect: () => widget.onSelect(s)),
for (final s in items) ClideMenuItem(label: widget.formatLabel?.call(s) ?? s, onSelect: () => widget.onSelect(s)),
],
),
),
);
}
}
@@ -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);