diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index 8d29b953..bda701ab 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -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; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index 0cbfabbe..560cafc5 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -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 -- 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 -- 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); diff --git a/CHANGELOG.md b/CHANGELOG.md index eab1bd23..94c1b3ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/builtin/claude/src/claude_composer.dart b/lib/builtin/claude/src/claude_composer.dart index d4a18581..0f914d45 100644 --- a/lib/builtin/claude/src/claude_composer.dart +++ b/lib/builtin/claude/src/claude_composer.dart @@ -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> Function()? pasteResolver; + /// Source of the slash-command list for the typeahead. Defaults to the + /// app-wide [ClaudeConfig]; injected in tests (T-152). + final Iterable Function()? slashCommandsResolver; + @override State createState() => _ClaudeComposerState(); } class _ClaudeComposerState extends State { final TextEditingController _controller = TextEditingController(); - final FocusNode _focus = FocusNode(); + late final FocusNode _focus = FocusNode(onKeyEvent: _onKey); final List _attachments = []; + // Slash typeahead state (T-152). + final LayerLink _link = LayerLink(); + OverlayEntry? _overlay; + SlashQuery? _query; + List _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 _commands() => (widget.slashCommandsResolver ?? () => activeClaudeConfig?.slashCommands ?? const [])(); + + /// 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 [] : 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 { 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( - onInvoke: (_) { - _submit(); - return null; - }, - ), - PasteTextIntent: CallbackAction( - 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( + onInvoke: (_) { + _submit(); + return null; + }, ), - ], + PasteTextIntent: CallbackAction( + 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, + ), + ], + ), ), ), ), - ), - ], + ], + ), ), ), ); diff --git a/lib/builtin/claude/src/slash_commands.dart b/lib/builtin/claude/src/slash_commands.dart index 74f04413..9e06a60f 100644 --- a/lib/builtin/claude/src/slash_commands.dart +++ b/lib/builtin/claude/src/slash_commands.dart @@ -26,3 +26,61 @@ bool isKnownSlashCommand(String text, Iterable 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 filterSlashCommands(String query, Iterable commands, {int limit = 8}) { + final q = query.toLowerCase(); + final seen = {}; + final matches = []; + 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 `/ `, +/// 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); +} diff --git a/test/builtin/claude/claude_composer_test.dart b/test/builtin/claude/claude_composer_test.dart index e2eae26b..d7f5492a 100644 --- a/test/builtin/claude/claude_composer_test.dart +++ b/test/builtin/claude/claude_composer_test.dart @@ -130,6 +130,84 @@ void main() { expect(find.text('notes.txt'), findsNothing); }); + Future> pumpWithCommands( + WidgetTester tester, + List commands, + ) async { + final submitted = []; + 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(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(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 = []; await tester.pumpWidget(harness( diff --git a/test/builtin/claude/slash_commands_test.dart b/test/builtin/claude/slash_commands_test.dart index 4094e134..be7d5a30 100644 --- a/test/builtin/claude/slash_commands_test.dart +++ b/test/builtin/claude/slash_commands_test.dart @@ -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); + }); + }); }