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