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:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
_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<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),
|
||||
|
||||
@@ -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,15 +104,18 @@ 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(
|
||||
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<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 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);
|
||||
|
||||
Reference in New Issue
Block a user