Files
clide/lib/builtin/claude/src/claude_composer.dart
T
jpmschweitzerandClaude 3c9cc4cbae surface clide-owned commands in the slash typeahead
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>
2026-05-31 16:17:14 +02:00

480 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 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);
}
}