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
@@ -2204,3 +2204,5 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by,
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-151', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 09:54:31', '2026-05-23 09:54:31', '2026-05-23 09:54:31', NULL, '52df08bff6479b6a84c128f5a63ea312', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-151', 'status', 'in_progress', 'done', NULL, '2026-05-23 10:20:12', '2026-05-23 10:20:12', '2026-05-23 10:20:12', NULL, 'ed8ce28a1b0c7e995887dcfd32456aec', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-153', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 10:31:31', '2026-05-23 10:31:31', '2026-05-23 10:31:31', NULL, 'd5a1f02df5c8be27b9dc62b1ce277d67', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-153', 'status', 'in_progress', 'done', NULL, '2026-05-23 10:35:56', '2026-05-23 10:35:56', '2026-05-23 10:35:56', NULL, 'ddf23a60cdfbfcd79b5d68af10f37928', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-152', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 10:37:57', '2026-05-23 10:37:57', '2026-05-23 10:37:57', NULL, 'e12bfc4f8896514fb13450e0f83cf6c6', 1) ON CONFLICT(hash) DO NOTHING;
+2
View File
@@ -2590,3 +2590,5 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority,
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-151', 'task', 'T-132', 'ClaudeConfig service: layered config + skills/commands + version-keyed slash probe', 'Builtin-owned (lib/builtin/claude/) app-wide source of truth for Claude Code''s environment per D-76. Loads and layers GLOBAL (~/.claude) as base with LOCAL (.claude) overriding: skills (skills/*/SKILL.md frontmatter name+description), custom slash commands (commands/*.md), settings.json, and permission rules (allow/deny/ask). Built-in slash commands (not on disk) come from a one-shot ''claude --output-format stream-json'' probe, cached keyed on the resolved claude version id so additions/deprecations re-capture on upgrade; a small static list is the fallback. FileWatcher (lib/src/files/watcher.dart) on both .claude dirs plus an explicit refresh; expose typed, listenable views. No kernel changes — builtin-owned (Claude is a non-disableable extension, but still an extension). Acceptance: empirically confirm the stream-json init message carries slash_commands for the pinned CC version (spike folded in here) before consumers rely on it; unit tests with fixture global+local .claude dirs covering layering, watcher-driven refresh, probe cache keyed on version, and static fallback; degrades gracefully on parse miss; coverage >= floor. Blocks the typeahead, command-aware send, and status-pane wiring.', 'in_progress', 'high', NULL, NULL, 'D-76', '2026-05-23 09:52:14', '2026-05-23 09:54:31', NULL, '65cd97999dfa1b15ffbf59e88478d5e2', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-151', 'task', 'T-132', 'ClaudeConfig service: layered config + skills/commands + version-keyed slash probe', 'Builtin-owned (lib/builtin/claude/) app-wide source of truth for Claude Code''s environment per D-76. Loads and layers GLOBAL (~/.claude) as base with LOCAL (.claude) overriding: skills (skills/*/SKILL.md frontmatter name+description), custom slash commands (commands/*.md), settings.json, and permission rules (allow/deny/ask). Built-in slash commands (not on disk) come from a one-shot ''claude --output-format stream-json'' probe, cached keyed on the resolved claude version id so additions/deprecations re-capture on upgrade; a small static list is the fallback. FileWatcher (lib/src/files/watcher.dart) on both .claude dirs plus an explicit refresh; expose typed, listenable views. No kernel changes — builtin-owned (Claude is a non-disableable extension, but still an extension). Acceptance: empirically confirm the stream-json init message carries slash_commands for the pinned CC version (spike folded in here) before consumers rely on it; unit tests with fixture global+local .claude dirs covering layering, watcher-driven refresh, probe cache keyed on version, and static fallback; degrades gracefully on parse miss; coverage >= floor. Blocks the typeahead, command-aware send, and status-pane wiring.', 'done', 'high', NULL, NULL, 'D-76', '2026-05-23 09:52:14', '2026-05-23 10:20:12', NULL, '3bd3ace6e31bca9f7458145fb2171425', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-153', 'task', 'T-132', 'Command-aware send: bypass bracketed-paste for valid slash commands', 'Fix the delivery mismatch where slash commands behave differently in clide than the CLI. tmux.sendMessage (lib/builtin/claude/src/tmux_session.dart) always uses paste-buffer -p (bracketed paste), and Claude''s TUI deliberately does not run slash-command parsing on bracketed-pasted content — so /cmd and /skill arrive as literal prompt text instead of invoking. Fix: when the submitted input is a valid slash command (per ClaudeConfig, T-151) — or single-line input generally — deliver it typed via tmux send-keys -l -- <text> then Enter, so the TUI parses it like the CLI; keep paste-buffer -p only for multi-line content (mirrors encodeClaudeInput''s existing single-vs-multiline logic on the PTY fallback path). Acceptance: a typed or typeahead-selected slash command actually invokes the skill/command in a real make-run session (parity with the CLI); multi-line messages still arrive as one block, not a stream of submits; unit tests on the send-encoding branches (single-line vs multi-line vs recognized-command); coverage >= floor. Blocked by T-151.', 'in_progress', 'medium', NULL, NULL, 'D-76', '2026-05-23 09:52:27', '2026-05-23 10:31:31', NULL, 'fc5104b0410e9db5da9f85962f7f3ac9', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-153', 'task', 'T-132', 'Command-aware send: bypass bracketed-paste for valid slash commands', 'Fix the delivery mismatch where slash commands behave differently in clide than the CLI. tmux.sendMessage (lib/builtin/claude/src/tmux_session.dart) always uses paste-buffer -p (bracketed paste), and Claude''s TUI deliberately does not run slash-command parsing on bracketed-pasted content — so /cmd and /skill arrive as literal prompt text instead of invoking. Fix: when the submitted input is a valid slash command (per ClaudeConfig, T-151) — or single-line input generally — deliver it typed via tmux send-keys -l -- <text> then Enter, so the TUI parses it like the CLI; keep paste-buffer -p only for multi-line content (mirrors encodeClaudeInput''s existing single-vs-multiline logic on the PTY fallback path). Acceptance: a typed or typeahead-selected slash command actually invokes the skill/command in a real make-run session (parity with the CLI); multi-line messages still arrive as one block, not a stream of submits; unit tests on the send-encoding branches (single-line vs multi-line vs recognized-command); coverage >= floor. Blocked by T-151.', 'done', 'medium', NULL, NULL, 'D-76', '2026-05-23 09:52:27', '2026-05-23 10:35:56', NULL, '05aa3d095767c48b831ece6289897483', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-152', 'task', 'T-132', 'Composer slash-command typeahead (inline detection, CLI-style)', 'Make ClaudeComposer (lib/builtin/claude/src/claude_composer.dart) slash-aware. Detect a /token at the cursor ANYWHERE in the text — inline mid-sentence, not only at position 0 (user requirement) — and show a typeahead popup listing matching slash commands + skills sourced from ClaudeConfig (T-151), filtering as the token grows. Keyboard nav: Up/Down to move, Tab/Enter to complete, Esc to dismiss; reuse ClidePalette interaction patterns. Completing inserts the command token at the cursor. Never re-scans the filesystem — reads from ClaudeConfig and refreshes when it changes. Acceptance: typing / opens the list; an inline / after existing text also opens it; arrow/Tab/Enter/Esc behave; the list reflects ClaudeConfig (skills + custom commands + built-ins); widget tests; a11y (focusable, labelled, contrast). Blocked by T-151.', 'in_progress', 'medium', NULL, NULL, 'D-76', '2026-05-23 09:52:20', '2026-05-23 10:37:57', NULL, '470583866efdc3cb6aec47e013519348', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+8
View File
@@ -18,6 +18,14 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Added
- Slash-command typeahead in the Claude composer (T-152) — typing `/`
(anywhere in the message, not just at the start) pops a list of
matching commands and skills sourced from the Claude environment;
arrow keys move, Enter/Tab completes, Escape dismisses.
- Claude environment service (T-151) — clide reads skills, commands,
settings, and permissions from `~/.claude` and the repo's `.claude`
(layered), and caches the slash-command list per claude version. Backs
the typeahead and command-aware send.
- Per-session status in the bottom status bar (T-145, T-150) — the
active Claude pane shows its model · permission mode (accept-edits /
plan / …) · context-token count in a status-bar slot, swapping to the
+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);
}
@@ -130,6 +130,84 @@ void main() {
expect(find.text('notes.txt'), findsNothing);
});
Future<List<String>> pumpWithCommands(
WidgetTester tester,
List<String> commands,
) async {
final submitted = <String>[];
await tester.pumpWidget(harness(
f,
ClaudeComposer(
onSubmit: submitted.add,
slashCommandsResolver: () => commands,
),
));
return submitted;
}
testWidgets('typing a slash opens the typeahead with matching commands', (tester) async {
await pumpWithCommands(tester, ['model', 'memory', 'clear']);
await tester.enterText(find.byType(EditableText), '/m');
await tester.pump();
expect(find.text('/model'), findsOneWidget);
expect(find.text('/memory'), findsOneWidget);
expect(find.text('/clear'), findsNothing);
});
testWidgets('an inline slash (mid-message) also opens the typeahead', (tester) async {
await pumpWithCommands(tester, ['clear', 'compact']);
await tester.enterText(find.byType(EditableText), 'hey /cl');
await tester.pump();
expect(find.text('/clear'), findsOneWidget);
});
testWidgets('arrow-down + Enter completes the selected command (no submit)', (tester) async {
final submitted = await pumpWithCommands(tester, ['model', 'memory']);
await tester.enterText(find.byType(EditableText), '/m'); // → [memory, model]
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); // select 'model'
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
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');
expect(find.text('/memory'), findsNothing, reason: 'popup closes after completion');
});
testWidgets('Tab completes the top suggestion', (tester) async {
await pumpWithCommands(tester, ['clear', 'compact']);
await tester.enterText(find.byType(EditableText), '/cle');
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
expect(tester.widget<EditableText>(find.byType(EditableText)).controller.text, '/clear ');
});
testWidgets('Escape dismisses the typeahead', (tester) async {
await pumpWithCommands(tester, ['model']);
await tester.enterText(find.byType(EditableText), '/mo');
await tester.pump();
expect(find.text('/model'), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pump();
expect(find.text('/model'), findsNothing);
});
testWidgets('with the popup closed, Enter still submits', (tester) async {
final submitted = await pumpWithCommands(tester, ['model']);
await tester.enterText(find.byType(EditableText), 'plain message');
await tester.pump();
expect(find.text('/model'), findsNothing);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(submitted, ['plain message']);
});
testWidgets('remove × cancels the attachment before send', (tester) async {
final submitted = <String>[];
await tester.pumpWidget(harness(
@@ -35,4 +35,57 @@ void main() {
expect(isKnownSlashCommand('/clear\nand more', {'clear'}), isFalse);
});
});
group('activeSlashQuery', () {
test('matches a slash token at the cursor, including inline', () {
expect(activeSlashQuery('/mod', 4), const SlashQuery(start: 0, query: 'mod'));
expect(activeSlashQuery('go /mo', 6), const SlashQuery(start: 3, query: 'mo'));
expect(activeSlashQuery('/', 1), const SlashQuery(start: 0, query: ''));
});
test('no match for paths, post-space, or non-slash runs', () {
expect(activeSlashQuery('a/b', 3), isNull); // slash mid-token (path)
expect(activeSlashQuery('/model sonnet', 13), isNull); // closed after space
expect(activeSlashQuery('hello', 5), isNull);
expect(activeSlashQuery('/model ', 7), isNull); // cursor right after space
});
test('uses the cursor, not the end of text', () {
// Cursor sits right after "/mo" inside "/model".
expect(activeSlashQuery('/model', 3), const SlashQuery(start: 0, query: 'mo'));
});
});
group('filterSlashCommands', () {
const commands = ['model', 'memory', 'clear', 'compact', 'context'];
test('prefix-filters case-insensitively and sorts', () {
expect(filterSlashCommands('co', commands), ['compact', 'context']);
expect(filterSlashCommands('M', commands), ['memory', 'model']);
});
test('empty query returns all (sorted, capped)', () {
expect(filterSlashCommands('', commands, limit: 3), ['clear', 'compact', 'context']);
});
test('de-duplicates', () {
expect(filterSlashCommands('p', ['pql', 'pql', 'plan']), ['plan', 'pql']);
});
});
group('completeSlash', () {
test('replaces the token with /command and a trailing space', () {
const q = SlashQuery(start: 0, query: 'mo');
final r = completeSlash('/mo', q, 'model');
expect(r.text, '/model ');
expect(r.cursor, 7);
});
test('completes an inline token without disturbing surrounding text', () {
const q = SlashQuery(start: 3, query: 'mo');
final r = completeSlash('go /mo now', q, 'model');
expect(r.text, 'go /model now');
expect(r.cursor, 10);
});
});
}