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
+226 -63
View File
@@ -11,7 +11,9 @@ library;
import 'dart:async';
import 'dart:io';
import 'package:clide/builtin/claude/src/claude_config.dart';
import 'package:clide/builtin/claude/src/clipboard_paste.dart';
import 'package:clide/builtin/claude/src/slash_commands.dart';
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:clide/kernel/src/theme/tokens.dart';
import 'package:clide/widgets/widgets.dart';
@@ -43,6 +45,7 @@ class ClaudeComposer extends StatefulWidget {
this.enabled = true,
this.hint = 'Message Claude… (Enter to send · Shift+Enter for newline)',
this.pasteResolver,
this.slashCommandsResolver,
});
/// Called with the composed message (typed text plus attachment `@path`
@@ -58,30 +61,187 @@ class ClaudeComposer extends StatefulWidget {
/// file/image clipboard support and tests can fake it.
final Future<List<ComposerAttachment>> Function()? pasteResolver;
/// Source of the slash-command list for the typeahead. Defaults to the
/// app-wide [ClaudeConfig]; injected in tests (T-152).
final Iterable<String> Function()? slashCommandsResolver;
@override
State<ClaudeComposer> createState() => _ClaudeComposerState();
}
class _ClaudeComposerState extends State<ClaudeComposer> {
final TextEditingController _controller = TextEditingController();
final FocusNode _focus = FocusNode();
late final FocusNode _focus = FocusNode(onKeyEvent: _onKey);
final List<ComposerAttachment> _attachments = [];
// Slash typeahead state (T-152).
final LayerLink _link = LayerLink();
OverlayEntry? _overlay;
SlashQuery? _query;
List<String> _suggestions = const [];
int _selected = 0;
@override
void initState() {
super.initState();
_controller.addListener(_onTextChanged);
_focus.addListener(_onFocusChanged);
}
@override
void dispose() {
_closeTypeahead();
_controller.removeListener(_onTextChanged);
_focus.removeListener(_onFocusChanged);
_controller.dispose();
_focus.dispose();
super.dispose();
}
void _onTextChanged() => setState(() {});
void _onTextChanged() {
setState(() {});
_syncTypeahead();
}
void _onFocusChanged() {
if (!_focus.hasFocus) _closeTypeahead();
}
Iterable<String> _commands() => (widget.slashCommandsResolver ?? () => activeClaudeConfig?.slashCommands ?? const <String>[])();
/// Recompute the active slash query + suggestions from the current text and
/// caret, opening/updating/closing the typeahead overlay accordingly.
void _syncTypeahead() {
if (!widget.enabled) return;
final sel = _controller.selection;
final q = sel.isCollapsed && sel.baseOffset >= 0 ? activeSlashQuery(_controller.text, sel.baseOffset) : null;
final suggestions = q == null ? const <String>[] : filterSlashCommands(q.query, _commands());
if (q == null || suggestions.isEmpty) {
_closeTypeahead();
return;
}
_query = q;
_suggestions = suggestions;
_selected = 0;
if (_overlay == null) {
_overlay = OverlayEntry(builder: _buildTypeahead);
Overlay.of(context).insert(_overlay!);
} else {
_overlay!.markNeedsBuild();
}
}
void _closeTypeahead() {
_overlay?.remove();
_overlay = null;
_query = null;
_suggestions = const [];
_selected = 0;
}
void _moveSelection(int delta) {
if (_suggestions.isEmpty) return;
_selected = (_selected + delta) % _suggestions.length;
if (_selected < 0) _selected += _suggestions.length;
_overlay?.markNeedsBuild();
}
void _completeSelected() {
final q = _query;
if (q == null || _suggestions.isEmpty) return;
final r = completeSlash(_controller.text, q, _suggestions[_selected]);
_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) {
if (_overlay == null) return KeyEventResult.ignored;
if (e is! KeyDownEvent && e is! KeyRepeatEvent) return KeyEventResult.ignored;
switch (e.logicalKey) {
case LogicalKeyboardKey.arrowDown:
_moveSelection(1);
return KeyEventResult.handled;
case LogicalKeyboardKey.arrowUp:
_moveSelection(-1);
return KeyEventResult.handled;
case LogicalKeyboardKey.escape:
_closeTypeahead();
return KeyEventResult.handled;
case LogicalKeyboardKey.enter:
case LogicalKeyboardKey.numpadEnter:
case LogicalKeyboardKey.tab:
_completeSelected();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
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;
@@ -140,73 +300,76 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
return Padding(
padding: const EdgeInsets.fromLTRB(10, 6, 10, 10),
child: Container(
decoration: BoxDecoration(
border: Border.all(color: theme.globalBorder),
borderRadius: BorderRadius.circular(6),
),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (_attachments.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Wrap(
spacing: 6,
runSpacing: 6,
children: [for (final a in _attachments) _chip(theme, a)],
child: CompositedTransformTarget(
link: _link,
child: Container(
decoration: BoxDecoration(
border: Border.all(color: theme.globalBorder),
borderRadius: BorderRadius.circular(6),
),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (_attachments.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Wrap(
spacing: 6,
runSpacing: 6,
children: [for (final a in _attachments) _chip(theme, a)],
),
),
),
Semantics(
label: widget.hint,
textField: true,
child: Shortcuts(
shortcuts: const {
SingleActivator(LogicalKeyboardKey.enter): SubmitComposerIntent(),
SingleActivator(LogicalKeyboardKey.numpadEnter): SubmitComposerIntent(),
},
child: Actions(
actions: {
SubmitComposerIntent: CallbackAction<SubmitComposerIntent>(
onInvoke: (_) {
_submit();
return null;
},
),
PasteTextIntent: CallbackAction<PasteTextIntent>(
onInvoke: (_) {
unawaited(_handlePaste());
return null;
},
),
Semantics(
label: widget.hint,
textField: true,
child: Shortcuts(
shortcuts: const {
SingleActivator(LogicalKeyboardKey.enter): SubmitComposerIntent(),
SingleActivator(LogicalKeyboardKey.numpadEnter): SubmitComposerIntent(),
},
child: Stack(
children: [
if (!hasText)
Positioned(
left: 0,
top: 0,
right: 0,
child: ClideText(widget.hint, muted: true, fontSize: clideFontBody),
),
EditableText(
controller: _controller,
focusNode: _focus,
readOnly: !widget.enabled,
style: TextStyle(fontSize: clideFontBody, color: fg, height: 1.4),
cursorColor: theme.globalFocus,
backgroundCursorColor: theme.globalTextMuted,
maxLines: 8,
minLines: 1,
child: Actions(
actions: {
SubmitComposerIntent: CallbackAction<SubmitComposerIntent>(
onInvoke: (_) {
_submit();
return null;
},
),
],
PasteTextIntent: CallbackAction<PasteTextIntent>(
onInvoke: (_) {
unawaited(_handlePaste());
return null;
},
),
},
child: Stack(
children: [
if (!hasText)
Positioned(
left: 0,
top: 0,
right: 0,
child: ClideText(widget.hint, muted: true, fontSize: clideFontBody),
),
EditableText(
controller: _controller,
focusNode: _focus,
readOnly: !widget.enabled,
style: TextStyle(fontSize: clideFontBody, color: fg, height: 1.4),
cursorColor: theme.globalFocus,
backgroundCursorColor: theme.globalTextMuted,
maxLines: 8,
minLines: 1,
),
],
),
),
),
),
),
],
],
),
),
),
);
@@ -26,3 +26,61 @@ bool isKnownSlashCommand(String text, Iterable<String> known) {
final token = slashCommandToken(text);
return token != null && known.contains(token);
}
bool _isWs(String c) => c == ' ' || c == '\t' || c == '\n';
/// An in-progress slash query at the cursor — the `/` position and the word
/// typed after it so far — used to drive the composer typeahead (T-152).
class SlashQuery {
const SlashQuery({required this.start, required this.query});
/// Index of the `/` in the text.
final int start;
/// Text between the `/` and the cursor (no leading slash, no whitespace).
final String query;
@override
bool operator ==(Object other) => other is SlashQuery && other.start == start && other.query == query;
@override
int get hashCode => Object.hash(start, query);
}
/// The slash query at [cursor] in [text], or null when the cursor isn't inside
/// a slash token. The token is the run of non-whitespace ending at the cursor;
/// it qualifies only when that run starts with `/` — which, by construction, is
/// at the start of the text or right after whitespace. So `/mod` and `go /mod`
/// both match (inline triggers), but `a/b` (a path) does not, and the query
/// closes once a space follows the command word.
SlashQuery? activeSlashQuery(String text, int cursor) {
if (cursor < 0 || cursor > text.length) return null;
var start = cursor;
while (start > 0 && !_isWs(text[start - 1])) {
start--;
}
if (start >= cursor) return null; // empty run (cursor at a boundary)
if (text[start] != '/') return null; // run isn't a slash token
return SlashQuery(start: start, query: text.substring(start + 1, cursor));
}
/// Commands matching [query] (case-insensitive prefix), de-duplicated, sorted,
/// capped at [limit]. Empty query returns the leading [limit] commands.
List<String> filterSlashCommands(String query, Iterable<String> commands, {int limit = 8}) {
final q = query.toLowerCase();
final seen = <String>{};
final matches = <String>[];
for (final c in commands) {
if (c.toLowerCase().startsWith(q) && seen.add(c)) matches.add(c);
}
matches.sort();
return matches.length > limit ? matches.sublist(0, limit) : matches;
}
/// Replace the slash token described by [q] in [text] with `/<command> `,
/// returning the new text and the cursor offset just past the inserted space.
({String text, int cursor}) completeSlash(String text, SlashQuery q, String command) {
final insert = '/$command ';
final end = q.start + 1 + q.query.length;
return (text: text.replaceRange(q.start, end, insert), cursor: q.start + insert.length);
}