From 1c20bf128a9933ddba2dfc65ca0bac581034b792 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 8 Jun 2026 23:02:07 +0200 Subject: [PATCH] migrate the slash typeahead onto ClideTypeahead (T-286, D-88) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 4 + lib/builtin/claude/src/claude_composer.dart | 133 +++++------------- lib/widgets/src/clide_typeahead.dart | 36 +++-- test/builtin/claude/claude_composer_test.dart | 10 ++ 4 files changed, 78 insertions(+), 105 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8907e7b..1e8cc660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/lib/builtin/claude/src/claude_composer.dart b/lib/builtin/claude/src/claude_composer.dart index 31308959..9135fb7e 100644 --- a/lib/builtin/claude/src/claude_composer.dart +++ b/lib/builtin/claude/src/claude_composer.dart @@ -126,12 +126,12 @@ class _ClaudeComposerState extends State { late final FocusNode _focus = widget.focusNode ?? FocusNode(); final List _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 _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 { @override void dispose() { - _closeTypeahead(); + _slashNav.dispose(); _controller.removeListener(_onTextChanged); _focus.removeListener(_onFocusChanged); _controller.dispose(); @@ -207,42 +207,47 @@ class _ClaudeComposerState extends State { _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; - _query = null; - _suggestions = const []; - _selected = 0; + if (_query == null && _suggestions.isEmpty) return; + if (mounted) { + setState(() { + _query = null; + _suggestions = const []; + }); + } 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 { // 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 { } 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 { _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 { 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), diff --git a/lib/widgets/src/clide_typeahead.dart b/lib/widgets/src/clide_typeahead.dart index d01cd8fd..27f69cc2 100644 --- a/lib/widgets/src/clide_typeahead.dart +++ b/lib/widgets/src/clide_typeahead.dart @@ -51,6 +51,13 @@ class ClideTypeahead extends StatefulWidget { class _ClideTypeaheadState extends State { 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> _items = ValueNotifier(const []); + @override void initState() { super.initState(); @@ -63,11 +70,14 @@ class _ClideTypeaheadState extends State { _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 { @override void dispose() { _overlay.dispose(); + _items.dispose(); super.dispose(); } @@ -93,15 +104,18 @@ class _ClideTypeaheadState extends State { captureFocus: false, // the text field keeps focus dismissOnEscape: false, // the host routes Esc anchor: widget.child, - overlayBuilder: (ctx, ctrl) => 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)), - ], + overlayBuilder: (ctx, ctrl) => ValueListenableBuilder>( + 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 items) ClideMenuItem(label: widget.formatLabel?.call(s) ?? s, onSelect: () => widget.onSelect(s)), + ], + ), ), ); } diff --git a/test/builtin/claude/claude_composer_test.dart b/test/builtin/claude/claude_composer_test.dart index 27d5e179..957ba342 100644 --- a/test/builtin/claude/claude_composer_test.dart +++ b/test/builtin/claude/claude_composer_test.dart @@ -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(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);