The composer sourced its slash suggestions only from the CLI probe (activeClaudeConfig.slashCommands), which never advertises clide-owned commands, so /resume and /fork were missing from the typeahead. Union kClideOwnedCommands onto the command source unconditionally — whether a caller supplies a resolver or the default probe is used — de-duped via a Set so /clear (in both) appears once. T-162. Co-Authored-By: Claude <noreply@anthropic.com>
480 lines
16 KiB
Dart
480 lines
16 KiB
Dart
/// Native input composer for the Claude pane (epic T-132, T-138).
|
||
///
|
||
/// A no-Material [EditableText] (D-7) below the [ConversationView].
|
||
/// Enter submits; Shift+Enter inserts a newline. The composed message
|
||
/// (typed text plus any attachment `@path` tokens) is handed to
|
||
/// [ClaudeComposer.onSubmit]; the pane delivers it to Claude. Pasted
|
||
/// files/images show as removable chips (T-142); plain text paste falls
|
||
/// through to the default.
|
||
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';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:flutter/widgets.dart';
|
||
|
||
/// Encodes [text] for delivery to Claude's PTY via `pane.write`.
|
||
///
|
||
/// Single-line input is sent literally followed by a carriage return
|
||
/// (submit). Multi-line input is wrapped in bracketed-paste markers so
|
||
/// the embedded newlines are treated as pasted content rather than a
|
||
/// stream of submits, then a trailing CR submits the whole block.
|
||
String encodeClaudeInput(String text) {
|
||
if (text.contains('\n')) {
|
||
return '\x1b[200~$text\x1b[201~\r';
|
||
}
|
||
return '$text\r';
|
||
}
|
||
|
||
/// Intent fired when the user presses Enter (no modifiers) to submit.
|
||
class SubmitComposerIntent extends Intent {
|
||
const SubmitComposerIntent();
|
||
}
|
||
|
||
class ClaudeComposer extends StatefulWidget {
|
||
const ClaudeComposer({
|
||
super.key,
|
||
required this.onSubmit,
|
||
this.enabled = true,
|
||
this.hint = 'Message Claude… (Enter to send · Shift+Enter for newline)',
|
||
this.pasteResolver,
|
||
this.slashCommandsResolver,
|
||
this.onInterrupt,
|
||
this.busy = false,
|
||
});
|
||
|
||
/// Called with the composed message (typed text plus attachment `@path`
|
||
/// tokens) when the user submits. The pane delivers it to Claude.
|
||
final void Function(String text) onSubmit;
|
||
|
||
final bool enabled;
|
||
final String hint;
|
||
|
||
/// Optional override of paste handling: returns the attachments on the
|
||
/// clipboard (files / images), or an empty list to fall back to the
|
||
/// default plain-text paste. Injected so the pane can wire in native
|
||
/// 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;
|
||
|
||
/// Interrupt the running turn — fired by the Stop button and by Escape
|
||
/// (when the typeahead is closed). The escape hatch for a runaway turn.
|
||
final VoidCallback? onInterrupt;
|
||
|
||
/// Whether a turn is in flight; shows the Stop affordance.
|
||
final bool busy;
|
||
|
||
@override
|
||
State<ClaudeComposer> createState() => _ClaudeComposerState();
|
||
}
|
||
|
||
class _ClaudeComposerState extends State<ClaudeComposer> {
|
||
final TextEditingController _controller = TextEditingController();
|
||
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(() {});
|
||
_syncTypeahead();
|
||
}
|
||
|
||
void _onFocusChanged() {
|
||
if (!_focus.hasFocus) _closeTypeahead();
|
||
}
|
||
|
||
Iterable<String> _commands() {
|
||
// Always union the clide-owned commands (/clear, /resume, /fork) onto the
|
||
// command source — whether that's a caller-supplied resolver or the default
|
||
// ClaudeConfig probe — since the CLI probe never advertises them (T-162).
|
||
// The Set literal de-dupes (e.g. 'clear' present in both).
|
||
final base = widget.slashCommandsResolver?.call() ?? activeClaudeConfig?.slashCommands ?? const <String>[];
|
||
return {...base, ...kClideOwnedCommands};
|
||
}
|
||
|
||
/// 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 (e is! KeyDownEvent && e is! KeyRepeatEvent) return KeyEventResult.ignored;
|
||
// Escape: dismiss the typeahead if open, otherwise interrupt the running
|
||
// turn — the escape hatch from a runaway (D-78).
|
||
if (e.logicalKey == LogicalKeyboardKey.escape) {
|
||
if (_overlay != null) {
|
||
_closeTypeahead();
|
||
return KeyEventResult.handled;
|
||
}
|
||
if (widget.onInterrupt != null) {
|
||
widget.onInterrupt!();
|
||
return KeyEventResult.handled;
|
||
}
|
||
return KeyEventResult.ignored;
|
||
}
|
||
if (_overlay == null) return KeyEventResult.ignored;
|
||
switch (e.logicalKey) {
|
||
case LogicalKeyboardKey.arrowDown:
|
||
_moveSelection(1);
|
||
return KeyEventResult.handled;
|
||
case LogicalKeyboardKey.arrowUp:
|
||
_moveSelection(-1);
|
||
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;
|
||
final text = _controller.text;
|
||
final tokens = _attachments.map((a) => a.pathToken);
|
||
if (text.trim().isEmpty && _attachments.isEmpty) return;
|
||
// Typed text first, then the attachment @path references.
|
||
final message = [
|
||
if (text.trim().isNotEmpty) text,
|
||
...tokens,
|
||
].join(' ');
|
||
widget.onSubmit(message);
|
||
_controller.clear();
|
||
setState(() => _attachments.clear());
|
||
}
|
||
|
||
Future<void> _handlePaste() async {
|
||
final resolver = widget.pasteResolver;
|
||
if (resolver != null) {
|
||
final attachments = await resolver();
|
||
if (attachments.isNotEmpty) {
|
||
if (!mounted) return;
|
||
setState(() => _attachments.addAll(attachments));
|
||
return;
|
||
}
|
||
}
|
||
// Fall back to the default plain-text paste.
|
||
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||
final clip = data?.text;
|
||
if (clip != null && clip.isNotEmpty && mounted) {
|
||
_insertAtCursor(clip);
|
||
}
|
||
}
|
||
|
||
void _removeAttachment(ComposerAttachment attachment) {
|
||
setState(() => _attachments.remove(attachment));
|
||
}
|
||
|
||
void _insertAtCursor(String insertion) {
|
||
final value = _controller.value;
|
||
final sel = value.selection;
|
||
final base = sel.isValid ? sel : TextSelection.collapsed(offset: value.text.length);
|
||
final newText = value.text.replaceRange(base.start, base.end, insertion);
|
||
final caret = base.start + insertion.length;
|
||
_controller.value = TextEditingValue(
|
||
text: newText,
|
||
selection: TextSelection.collapsed(offset: caret),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = ClideTheme.of(context).surface;
|
||
final hasText = _controller.text.isNotEmpty;
|
||
final fg = widget.enabled ? theme.globalForeground : theme.globalTextMuted;
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(10, 6, 10, 10),
|
||
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: [
|
||
// Running turn → an always-reachable Stop (also bound to Escape).
|
||
if (widget.busy && widget.onInterrupt != null)
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
child: Row(
|
||
children: [
|
||
ClideText('running…', muted: true, fontSize: clideFontMeta),
|
||
const Spacer(),
|
||
ClideButton(
|
||
label: 'Stop ⎋',
|
||
variant: ClideButtonVariant.primary,
|
||
onPressed: widget.onInterrupt,
|
||
semanticHint: 'Interrupt the running turn (Escape)',
|
||
),
|
||
],
|
||
),
|
||
),
|
||
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;
|
||
},
|
||
),
|
||
},
|
||
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,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// One attachment chip: a thumbnail (images) or file icon (other types),
|
||
/// the filename, and a remove × that cancels the attachment before send.
|
||
Widget _chip(SurfaceTokens theme, ComposerAttachment a) {
|
||
return Container(
|
||
constraints: const BoxConstraints(maxWidth: 220),
|
||
decoration: BoxDecoration(
|
||
color: theme.panelBackground,
|
||
border: Border.all(color: theme.globalBorder),
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
padding: const EdgeInsets.fromLTRB(6, 4, 4, 4),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
_chipLeading(theme, a),
|
||
const SizedBox(width: 6),
|
||
Flexible(
|
||
child: ClideText(
|
||
a.fileName,
|
||
fontSize: clideFontSmall,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
const SizedBox(width: 4),
|
||
Semantics(
|
||
button: true,
|
||
label: 'Remove ${a.fileName}',
|
||
child: GestureDetector(
|
||
key: ValueKey('composer-remove-${a.path}'),
|
||
onTap: () => _removeAttachment(a),
|
||
child: MouseRegion(
|
||
cursor: SystemMouseCursors.click,
|
||
child: ClideIcon(PhosphorIcons.xMark, size: 12, color: theme.globalTextMuted),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _chipLeading(SurfaceTokens theme, ComposerAttachment a) {
|
||
const dim = 28.0;
|
||
if (a.isImage) {
|
||
return ClipRRect(
|
||
borderRadius: BorderRadius.circular(4),
|
||
child: Image.file(
|
||
File(a.path),
|
||
width: dim,
|
||
height: dim,
|
||
fit: BoxFit.cover,
|
||
errorBuilder: (_, __, ___) => ClideIcon(PhosphorIcons.image, size: 18, color: theme.globalTextMuted),
|
||
),
|
||
);
|
||
}
|
||
return ClideIcon(PhosphorIcons.fileText, size: 18, color: theme.globalTextMuted);
|
||
}
|
||
}
|