Files
clide/test/builtin/claude/claude_composer_test.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

338 lines
12 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.
/// Tests for the native Claude input composer (T-138): Enter submits,
/// Shift+Enter does not, blank input is ignored, and submitted text is
/// PTY-encoded for `pane.write`.
library;
import 'package:clide/builtin/claude/src/claude_composer.dart';
import 'package:clide/builtin/claude/src/clipboard_paste.dart';
import 'package:clide/builtin/claude/src/slash_commands.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
void main() {
group('encodeClaudeInput', () {
test('single-line input gets a trailing carriage return', () {
expect(encodeClaudeInput('hello claude'), 'hello claude\r');
});
test('multi-line input is wrapped in bracketed-paste markers', () {
expect(
encodeClaudeInput('line one\nline two'),
'\x1b[200~line one\nline two\x1b[201~\r',
);
});
});
group('ClaudeComposer', () {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
Future<List<String>> pump(WidgetTester tester, {bool enabled = true}) async {
final submitted = <String>[];
await tester.pumpWidget(harness(
f,
ClaudeComposer(enabled: enabled, onSubmit: submitted.add),
));
return submitted;
}
testWidgets('Enter submits the text and clears the field', (tester) async {
final submitted = await pump(tester);
await tester.enterText(find.byType(EditableText), 'hey there');
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(submitted, ['hey there']);
expect(tester.widget<EditableText>(find.byType(EditableText)).controller.text, isEmpty);
});
testWidgets('Shift+Enter does not submit', (tester) async {
final submitted = await pump(tester);
await tester.enterText(find.byType(EditableText), 'draft');
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
await tester.pump();
expect(submitted, isEmpty);
});
testWidgets('blank input is ignored', (tester) async {
final submitted = await pump(tester);
await tester.enterText(find.byType(EditableText), ' ');
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(submitted, isEmpty);
});
testWidgets('disabled composer is read-only', (tester) async {
await pump(tester, enabled: false);
expect(tester.widget<EditableText>(find.byType(EditableText)).readOnly, isTrue);
});
Future<void> pasteAttachment(WidgetTester tester) async {
tester.widget<EditableText>(find.byType(EditableText)).focusNode.requestFocus();
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.control);
await tester.sendKeyEvent(LogicalKeyboardKey.keyV);
await tester.sendKeyUpEvent(LogicalKeyboardKey.control);
await tester.pumpAndSettle();
}
testWidgets('Ctrl+V of a file shows a chip and leaves the text field empty', (tester) async {
// WidgetsApp provides DefaultTextEditingShortcuts in the real app;
// the bare harness doesn't, so wrap explicitly to map Ctrl+V ->
// PasteTextIntent, which the composer's Actions override intercepts.
await tester.pumpWidget(harness(
f,
DefaultTextEditingShortcuts(
child: ClaudeComposer(
onSubmit: (_) {},
pasteResolver: () async => const [ComposerAttachment(path: '/tmp/notes.txt', isImage: false)],
),
),
));
await pasteAttachment(tester);
expect(find.text('notes.txt'), findsOneWidget);
expect(tester.widget<EditableText>(find.byType(EditableText)).controller.text, isEmpty);
});
testWidgets('submit appends attachment @path tokens to the message', (tester) async {
final submitted = <String>[];
await tester.pumpWidget(harness(
f,
DefaultTextEditingShortcuts(
child: ClaudeComposer(
onSubmit: submitted.add,
pasteResolver: () async => const [ComposerAttachment(path: '/tmp/notes.txt', isImage: false)],
),
),
));
await tester.enterText(find.byType(EditableText), 'look at this');
await pasteAttachment(tester);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(submitted, ['look at this @/tmp/notes.txt']);
// Chip cleared after send.
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(
f,
DefaultTextEditingShortcuts(
child: ClaudeComposer(
onSubmit: submitted.add,
pasteResolver: () async => const [ComposerAttachment(path: '/tmp/notes.txt', isImage: false)],
),
),
));
await pasteAttachment(tester);
expect(find.text('notes.txt'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('composer-remove-/tmp/notes.txt')));
await tester.pumpAndSettle();
expect(find.text('notes.txt'), findsNothing);
await tester.enterText(find.byType(EditableText), 'just text');
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(submitted, ['just text']);
});
testWidgets('Escape interrupts when the typeahead is closed', (tester) async {
var interrupts = 0;
await tester.pumpWidget(harness(
f,
ClaudeComposer(onSubmit: (_) {}, onInterrupt: () => interrupts++),
));
tester.widget<EditableText>(find.byType(EditableText)).focusNode.requestFocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pump();
expect(interrupts, 1);
});
testWidgets('Escape closes the typeahead before it interrupts', (tester) async {
var interrupts = 0;
await tester.pumpWidget(harness(
f,
ClaudeComposer(
onSubmit: (_) {},
onInterrupt: () => interrupts++,
slashCommandsResolver: () => ['model'],
),
));
await tester.enterText(find.byType(EditableText), '/mo');
await tester.pump();
expect(find.text('/model'), findsOneWidget);
// First Escape only dismisses the popup; it does not interrupt.
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pump();
expect(find.text('/model'), findsNothing);
expect(interrupts, 0);
// A second Escape, now with the popup closed, interrupts.
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pump();
expect(interrupts, 1);
});
testWidgets('the Stop button shows when busy and interrupts on tap', (tester) async {
var interrupts = 0;
await tester.pumpWidget(harness(
f,
ClaudeComposer(onSubmit: (_) {}, busy: true, onInterrupt: () => interrupts++),
));
expect(find.text('Stop ⎋'), findsOneWidget);
await tester.tap(find.text('Stop ⎋'));
await tester.pump();
expect(interrupts, 1);
});
testWidgets('no Stop button when idle', (tester) async {
await tester.pumpWidget(harness(
f,
ClaudeComposer(onSubmit: (_) {}, onInterrupt: () {}),
));
expect(find.text('Stop ⎋'), findsNothing);
});
// T-162: clide-owned commands surface in typeahead even when absent from the
// CLI probe (slashCommandsResolver).
testWidgets('clide-owned /resume surfaces even when absent from the probe list', (tester) async {
// The probe list (CLI-sourced) only has 'model' — no 'resume' or 'fork'.
await pumpWithCommands(tester, ['model']);
await tester.enterText(find.byType(EditableText), '/res');
await tester.pump();
// /resume must appear (sourced from kClideOwnedCommands).
expect(find.text('/resume'), findsOneWidget);
// /model must not appear (doesn't match '/res').
expect(find.text('/model'), findsNothing);
});
testWidgets('clide-owned /clear surfaces without duplicate when also in probe', (tester) async {
// 'clear' is in both the probe list AND kClideOwnedCommands.
await pumpWithCommands(tester, ['clear', 'model']);
await tester.enterText(find.byType(EditableText), '/cl');
await tester.pump();
// /clear must appear exactly once (filterSlashCommands de-dupes via seen set).
expect(find.text('/clear'), findsOneWidget);
});
testWidgets('clide-owned commands are reachable via the default resolver', (tester) async {
// No slashCommandsResolver → default path; kClideOwnedCommands must be included.
final submitted = <String>[];
await tester.pumpWidget(harness(
f,
ClaudeComposer(onSubmit: submitted.add),
));
await tester.enterText(find.byType(EditableText), '/fo');
await tester.pump();
// /fork is a kClideOwnedCommands member; it must appear without a probe.
expect(find.text('/fork'), findsOneWidget);
// Sanity: kClideOwnedCommands is the source (not a coincidence).
expect(kClideOwnedCommands, contains('fork'));
});
});
}