flesh out the Claude prompt UX: options, stepper, collapse
Builds on the in-composer prompt surface (D-78): - Permission prompts (T-175): Allow / Allow-and-don't-ask-again / Deny. "Don't ask again" appears only when the request carries a permission_suggestion and echoes it back as updatedPermissions. An optional note rides Deny as the message, or Allow as a follow-up user message (the protocol has no allow-with-message). - AskUserQuestion picker (T-176): a single question renders bare; 2-4 questions step one at a time (nav shows "N · Header", ✓ when answered) then a review/confirm screen. Each question offers an "Other" free-text choice and a per-choice note; multi-select joins labels. A "chat instead" escape denies the prompt so the user can type freely. On submit the answer is echoed into the log, since the card is ephemeral. - Collapsed tool cards (T-177): multi-line tool_use / tool_result start collapsed behind a one-line summary; one-line output renders inline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -170,6 +170,24 @@ void main() {
|
||||
expect(find.text('error'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a one-line tool result renders inline (no collapse caret)', (tester) async {
|
||||
await pumpWith(tester, [_result('hello-from-spike')]);
|
||||
expect(find.text('hello-from-spike'), findsOneWidget);
|
||||
expect(find.byType(ClideIcon), findsNothing); // not collapsible → no caret
|
||||
});
|
||||
|
||||
testWidgets('a multi-line tool result starts collapsed with a first-line summary', (tester) async {
|
||||
await pumpWith(tester, [_result('first line\nsecond line\nthird line')]);
|
||||
// Collapsed: caret present, summary (first line) shown, full body hidden.
|
||||
expect(find.byType(ClideIcon), findsOneWidget);
|
||||
expect(find.text('first line'), findsOneWidget);
|
||||
expect(find.text('first line\nsecond line\nthird line'), findsNothing);
|
||||
|
||||
await tester.tap(find.byType(ClideIcon));
|
||||
await tester.pump();
|
||||
expect(find.text('first line\nsecond line\nthird line'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('select-all + copy spans multiple cards', (tester) async {
|
||||
final clipboard = _MockClipboard();
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, clipboard.handleMethodCall);
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import 'package:clide/builtin/claude/src/prompt_card.dart';
|
||||
import 'package:clide/builtin/claude/src/stream_json_session.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
import '../../helpers/widget_harness.dart';
|
||||
|
||||
ToolPrompt permissionPrompt() => const ToolPrompt(
|
||||
ToolPrompt permissionPrompt({List<dynamic> suggestions = const []}) => ToolPrompt(
|
||||
promptId: 'req-1',
|
||||
toolName: 'Write',
|
||||
displayName: 'Write',
|
||||
description: 'banana.txt',
|
||||
input: {'file_path': '/tmp/banana.txt', 'content': 'banana'},
|
||||
input: const {'file_path': '/tmp/banana.txt', 'content': 'banana'},
|
||||
permissionSuggestions: suggestions,
|
||||
);
|
||||
|
||||
ToolPrompt questionPrompt({bool multi = false}) => ToolPrompt(
|
||||
@@ -32,6 +34,34 @@ ToolPrompt questionPrompt({bool multi = false}) => ToolPrompt(
|
||||
},
|
||||
);
|
||||
|
||||
ToolPrompt twoQuestionPrompt() => const ToolPrompt(
|
||||
promptId: 'req-2q',
|
||||
toolName: 'AskUserQuestion',
|
||||
displayName: 'AskUserQuestion',
|
||||
input: {
|
||||
'questions': [
|
||||
{
|
||||
'question': 'Which pet?',
|
||||
'header': 'Pet',
|
||||
'multiSelect': false,
|
||||
'options': [
|
||||
{'label': 'Cats', 'description': ''},
|
||||
{'label': 'Dogs', 'description': ''},
|
||||
],
|
||||
},
|
||||
{
|
||||
'question': 'How eaten?',
|
||||
'header': 'Eaten',
|
||||
'multiSelect': false,
|
||||
'options': [
|
||||
{'label': 'Fresh', 'description': ''},
|
||||
{'label': 'Smoothie', 'description': ''},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
@@ -79,6 +109,51 @@ void main() {
|
||||
expect((decision as DenyTool).message, isNotEmpty);
|
||||
});
|
||||
|
||||
testWidgets('permission: no "don\'t ask again" button without a suggestion', (tester) async {
|
||||
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: permissionPrompt(), onResolve: (_, __) {})));
|
||||
await tester.pump();
|
||||
expect(find.text("Allow & don't ask again"), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('permission: "don\'t ask again" shows with a suggestion and returns updatedPermissions', (tester) async {
|
||||
ToolDecision? decision;
|
||||
const sugg = [
|
||||
{'type': 'setMode', 'mode': 'acceptEdits', 'destination': 'session'}
|
||||
];
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
ToolPromptCard(prompt: permissionPrompt(suggestions: sugg), onResolve: (_, d) => decision = d),
|
||||
));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text("Allow & don't ask again"), findsOneWidget);
|
||||
await tester.tap(find.text("Allow & don't ask again"));
|
||||
await tester.pump();
|
||||
expect((decision as AllowTool).updatedPermissions, hasLength(1));
|
||||
});
|
||||
|
||||
testWidgets('permission: a typed note rides Deny as the message', (tester) async {
|
||||
ToolDecision? decision;
|
||||
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: permissionPrompt(), onResolve: (_, d) => decision = d)));
|
||||
await tester.pump();
|
||||
await tester.enterText(find.byType(EditableText), 'write it under docs/ instead');
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Deny'));
|
||||
await tester.pump();
|
||||
expect((decision as DenyTool).message, 'write it under docs/ instead');
|
||||
});
|
||||
|
||||
testWidgets('permission: a typed note rides Allow as a follow-up note', (tester) async {
|
||||
ToolDecision? decision;
|
||||
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: permissionPrompt(), onResolve: (_, d) => decision = d)));
|
||||
await tester.pump();
|
||||
await tester.enterText(find.byType(EditableText), 'fyi: sandbox only');
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Allow'));
|
||||
await tester.pump();
|
||||
expect((decision as AllowTool).followUpNote, 'fyi: sandbox only');
|
||||
});
|
||||
|
||||
testWidgets('question card: Submit is gated until an option is picked, then returns answers', (tester) async {
|
||||
ToolDecision? decision;
|
||||
await tester.pumpWidget(harness(
|
||||
@@ -122,4 +197,78 @@ void main() {
|
||||
final answers = (decision as AllowTool).updatedInput['answers'] as Map;
|
||||
expect(answers['Do you prefer cats or dogs?'], 'Cats, Dogs');
|
||||
});
|
||||
|
||||
testWidgets('question card: "Other…" free-text becomes the answer value', (tester) async {
|
||||
ToolDecision? decision;
|
||||
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: questionPrompt(), onResolve: (_, d) => decision = d)));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('○ Other…'));
|
||||
await tester.pump();
|
||||
// Two fields now: [0] = the Other free-text, [1] = the per-choice note.
|
||||
await tester.enterText(find.byType(EditableText).first, 'Kiwi');
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Submit'));
|
||||
await tester.pump();
|
||||
|
||||
final answers = (decision as AllowTool).updatedInput['answers'] as Map;
|
||||
expect(answers['Do you prefer cats or dogs?'], 'Kiwi'); // not the word "Other"
|
||||
});
|
||||
|
||||
testWidgets('question card: a per-choice note is appended to the label', (tester) async {
|
||||
ToolDecision? decision;
|
||||
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: questionPrompt(), onResolve: (_, d) => decision = d)));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.textContaining('Dogs'));
|
||||
await tester.pump();
|
||||
await tester.enterText(find.byType(EditableText), 'only big ones'); // the note field
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Submit'));
|
||||
await tester.pump();
|
||||
|
||||
final answers = (decision as AllowTool).updatedInput['answers'] as Map;
|
||||
expect(answers['Do you prefer cats or dogs?'], 'Dogs — only big ones');
|
||||
});
|
||||
|
||||
testWidgets('multi-question: steps through to review, then submits both answers', (tester) async {
|
||||
ToolDecision? decision;
|
||||
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: twoQuestionPrompt(), onResolve: (_, d) => decision = d)));
|
||||
await tester.pump();
|
||||
|
||||
// Stepper nav shows numbered headers; only question 1 is visible.
|
||||
expect(find.textContaining('1 · Pet'), findsOneWidget);
|
||||
expect(find.text('Which pet?'), findsOneWidget);
|
||||
expect(find.text('How eaten?'), findsNothing);
|
||||
|
||||
await tester.tap(find.textContaining('Dogs'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Next ›'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('How eaten?'), findsOneWidget);
|
||||
await tester.tap(find.textContaining('Fresh'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Review ›'));
|
||||
await tester.pump();
|
||||
|
||||
// Review screen lists both answers; submit delivers them.
|
||||
expect(find.text('Review your answers'), findsOneWidget);
|
||||
await tester.tap(find.text('Submit answers'));
|
||||
await tester.pump();
|
||||
|
||||
final answers = (decision as AllowTool).updatedInput['answers'] as Map;
|
||||
expect(answers['Which pet?'], 'Dogs');
|
||||
expect(answers['How eaten?'], 'Fresh');
|
||||
});
|
||||
|
||||
testWidgets('question card: "chat instead" denies the prompt', (tester) async {
|
||||
ToolDecision? decision;
|
||||
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: questionPrompt(), onResolve: (_, d) => decision = d)));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('chat instead'));
|
||||
await tester.pump();
|
||||
expect(decision, isA<DenyTool>());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -179,6 +179,50 @@ void main() {
|
||||
expect((decision['updatedInput'] as Map)['content'], 'banana');
|
||||
});
|
||||
|
||||
test('a permission request carries its permission_suggestions', () async {
|
||||
proc.emit(jsonEncode({
|
||||
'type': 'control_request',
|
||||
'request_id': 'rs',
|
||||
'request': {
|
||||
'subtype': 'can_use_tool',
|
||||
'tool_name': 'Write',
|
||||
'input': {'file_path': '/tmp/x'},
|
||||
'permission_suggestions': [
|
||||
{'type': 'setMode', 'mode': 'acceptEdits', 'destination': 'session'}
|
||||
],
|
||||
},
|
||||
}));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(session.pendingPrompt!.permissionSuggestions, hasLength(1));
|
||||
});
|
||||
|
||||
test('resolvePrompt(allow with updatedPermissions) echoes them in the response', () async {
|
||||
proc.emit(canUseTool('rp'));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
session.resolvePrompt(
|
||||
'rp',
|
||||
AllowTool(const {
|
||||
'x': 1
|
||||
}, updatedPermissions: const [
|
||||
{'type': 'setMode'}
|
||||
]));
|
||||
final decision = ((jsonDecode(proc.writes.single) as Map)['response'] as Map)['response'] as Map;
|
||||
expect(decision['behavior'], 'allow');
|
||||
expect(decision['updatedPermissions'], hasLength(1));
|
||||
});
|
||||
|
||||
test('resolvePrompt(allow with a follow-up note) sends the note as a user message', () async {
|
||||
proc.emit(canUseTool('rn'));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
session.resolvePrompt('rn', AllowTool(const {'x': 1}, followUpNote: 'use docs/ instead'));
|
||||
|
||||
// first write = control_response (allow), second = the follow-up message
|
||||
expect(proc.writes, hasLength(2));
|
||||
final follow = jsonDecode(proc.writes[1]) as Map;
|
||||
expect(follow['type'], 'user');
|
||||
expect((follow['message'] as Map)['content'], 'use docs/ instead');
|
||||
});
|
||||
|
||||
test('resolvePrompt(deny) writes a deny decision with a message', () async {
|
||||
proc.emit(canUseTool('req-3'));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
@@ -189,6 +233,29 @@ void main() {
|
||||
expect(decision['message'], 'nope');
|
||||
});
|
||||
|
||||
test('resolving an AskUserQuestion leaves an answered echo in the log', () async {
|
||||
proc.emit(jsonEncode({
|
||||
'type': 'control_request',
|
||||
'request_id': 'aq',
|
||||
'request': {
|
||||
'subtype': 'can_use_tool',
|
||||
'tool_name': 'AskUserQuestion',
|
||||
'input': {'questions': <dynamic>[]},
|
||||
},
|
||||
}));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
session.resolvePrompt(
|
||||
'aq',
|
||||
AllowTool(const {
|
||||
'answers': {'Pet': 'Dogs'}
|
||||
}));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final echo = items.whereType<UserMessage>().toList();
|
||||
expect(echo, hasLength(1));
|
||||
expect(echo.single.text, contains('Pet → Dogs'));
|
||||
});
|
||||
|
||||
test('prompts queue: resolving the head surfaces the next', () async {
|
||||
proc.emit(canUseTool('q1'));
|
||||
proc.emit(canUseTool('q2'));
|
||||
|
||||
Reference in New Issue
Block a user