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
@@ -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'));