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:
2026-05-25 09:30:50 +02:00
co-authored by Claude Opus 4.7
parent fbf5a720a8
commit de264e7141
8 changed files with 324 additions and 16 deletions
@@ -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);
+151 -2
View File
@@ -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'));