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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user