From de264e7141ebf5cdd5cb6b4887e0e88a3f4eef89 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 25 May 2026 09:30:50 +0200 Subject: [PATCH] flesh out the Claude prompt UX: options, stepper, collapse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 15 +- lib/builtin/claude/src/conversation_card.dart | 17 +- lib/builtin/claude/src/conversation_view.dart | 28 +++- lib/builtin/claude/src/prompt_card.dart | Bin 7059 -> 14683 bytes .../claude/src/stream_json_session.dart | 42 ++++- .../claude/conversation_view_test.dart | 18 +++ test/builtin/claude/prompt_card_test.dart | 153 +++++++++++++++++- .../claude/stream_json_session_test.dart | 67 ++++++++ 8 files changed, 324 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1043dde0..875ac7cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,14 +18,19 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added -- Native permission & AskUserQuestion prompts (T-166, D-78) — when Claude - needs tool approval or asks a question, an inline card appears in the - conversation with Allow/Deny or selectable options; the decision is - returned over the stream-json control channel. Closes the prompt gap - the tmux model couldn't surface. +- Native permission & AskUserQuestion prompts (T-166, T-175, T-176, D-78) — + when Claude needs tool approval or asks a question, the composer is + replaced by a prompt: Allow / Allow-and-don't-ask-again / Deny for + permissions (with an optional note), and a single-question or + stepped-with-review option picker for AskUserQuestion (with "Other" + free-text and per-choice notes). Closes the prompt gap the tmux model + couldn't surface. - Conversation message cards (T-173) — every turn in the Claude pane now renders through one card template with a copy button on hover and a collapse/expand caret for tool calls, results, and thinking. +- Collapsed-by-default tool cards (T-177) — multi-line tool calls and + results start collapsed behind a one-line summary; one-line output stays + inline so a caret never hides a single line. - Claude meta sidebar (T-141, T-157) — an always-pickable left-panel tab showing Claude activity (the latest day's messages/sessions/tool-calls plus lifetime totals, from `stats-cache.json`) and, when a tmux team is diff --git a/lib/builtin/claude/src/conversation_card.dart b/lib/builtin/claude/src/conversation_card.dart index 2bafe55e..4153b9a5 100644 --- a/lib/builtin/claude/src/conversation_card.dart +++ b/lib/builtin/claude/src/conversation_card.dart @@ -37,6 +37,7 @@ class ConversationCard extends StatefulWidget { this.actions = const [], this.collapsible = false, this.collapsedByDefault = false, + this.collapsedSummary, this.borderColor, }); @@ -54,6 +55,11 @@ class ConversationCard extends StatefulWidget { final bool collapsible; final bool collapsedByDefault; + /// One-line gist shown next to the label while collapsed (e.g. the tool's + /// key arg, or a result's first line), so a collapsed card still says what + /// it holds. Null → just the label. + final String? collapsedSummary; + /// Border colour for the bordered variant (e.g. error red); defaults to the /// panel border. final Color? borderColor; @@ -127,11 +133,20 @@ class _ConversationCardState extends State { } Widget _header(SurfaceTokens tokens) { + final summary = widget.collapsedSummary; return Row( children: [ if (widget.collapsible) _caret(tokens), ClideText(widget.label, fontSize: clideFontSmall, color: widget.accent, fontFamily: clideMonoFamily), - const Spacer(), + // While collapsed, show a one-line gist next to the label so the card + // still says what it holds. + if (_collapsed && summary != null) ...[ + const SizedBox(width: 10), + Expanded( + child: ClideText(summary, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily, maxLines: 1), + ), + ] else + const Spacer(), // Hover-revealed actions. (Always-reachable keyboard a11y for these is // a follow-up detail; the collapse caret above is always visible.) if (_hover) ..._actions(tokens), diff --git a/lib/builtin/claude/src/conversation_view.dart b/lib/builtin/claude/src/conversation_view.dart index c85cd89c..e86bd8fe 100644 --- a/lib/builtin/claude/src/conversation_view.dart +++ b/lib/builtin/claude/src/conversation_view.dart @@ -149,26 +149,34 @@ class _ConversationTurn extends StatelessWidget { Widget _toolUse(AssistantToolUse t) { final pretty = const JsonEncoder.withIndent(' ').convert(t.input); + // Collapse only the bulky multi-line form; a trivial one-liner just shows. + final multiline = pretty.contains('\n'); return ConversationCard( variant: ConversationCardVariant.bordered, accent: tokens.globalFocus, label: t.name, copyText: pretty, - collapsible: true, + collapsible: multiline, + collapsedByDefault: multiline, + collapsedSummary: multiline ? _toolUseSummary(t) : null, body: ClideCodeBlock(source: pretty, language: 'json'), ); } Widget _toolResult(ToolResultMessage t) { final accent = t.isError ? tokens.statusError : tokens.globalTextMuted; + // A one-line result is all chrome to collapse — show it inline. Only fold + // away multi-line output, behind a summary of its first line. + final multiline = t.content.contains('\n'); return ConversationCard( variant: ConversationCardVariant.bordered, accent: accent, borderColor: t.isError ? tokens.statusError : tokens.panelBorder, label: t.isError ? 'error' : 'result', copyText: t.content, - collapsible: true, - collapsedByDefault: true, + collapsible: multiline, + collapsedByDefault: multiline, + collapsedSummary: multiline ? _firstLine(t.content) : null, body: ClideText( t.content, fontSize: clideFontMeta, @@ -177,4 +185,18 @@ class _ConversationTurn extends StatelessWidget { ), ); } + + /// A compact one-liner for a collapsed tool-use card: the most telling arg. + String _toolUseSummary(AssistantToolUse t) { + final input = t.input; + final key = + input['file_path'] ?? input['command'] ?? input['path'] ?? input['pattern'] ?? input['url'] ?? (input.values.isNotEmpty ? input.values.first : null); + final s = key?.toString().replaceAll('\n', ' ') ?? ''; + return s.length > 80 ? '${s.substring(0, 80)}…' : s; + } + + String _firstLine(String content) { + final line = content.split('\n').first.trim(); + return line.length > 80 ? '${line.substring(0, 80)}…' : line; + } } diff --git a/lib/builtin/claude/src/prompt_card.dart b/lib/builtin/claude/src/prompt_card.dart index ba87f6ad0ff8d186476f6e71894f17704e5a8414..76dd327d6ceb0106494db33ab1feecce53112006 100644 GIT binary patch literal 14683 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 delta 2328 zcmbVOO>7%g5SD40L~dd=O`M-PO(uW!ZgAEOB`P>gpj81up`t`IpfojG?~D7I^{%~d zoz#k=aN>eQNe8J=1X3j=#08LYKtg~b4sfZ2dPYd47lb&J1LB5Y-dnGKA`S>&V$XXs z@0)MFnaPhHo_Kp<+Z-54pJ7e~R$LEE$eTX1z;|6c1=8R|)9Li~?e3Uc^ttOmk>^&K zXKepEIVewnJK$eokaL|i<_S~Y(LuPFjvPOtF38iO=`axMW>En5r3<}i;Tw78Fpm<|vSB1)*v!fMS5ly5b(ounlMBAb zo&5a9ral{ug7Q0=4Pnefp5CZ?lL!9Pp2W4bOHWJCVDXg|<}t^IqURQhK6tLLnB|V| zD#nCaAWm%8hx@O;Lj*|B0a!{?82CfmlV`GuZR`KS2KsDbsjUaEPG7_a2U??Cgc)G0 zBk|lUB?hp<9n%Igmy>YkpneCQAR(+WLL&%#Sv>2kxR7bKEt;M~JmKziVQ05vI`Wzd zDZ|Q7Q>drI=6^ZBcRK$xG36YB1FUE$R;VYok0qI~EMy-OUU8|x6$?8BNV(PSxvEdxA&y3=& z*ueg@MQwwTbPDgqTA?PAMU}`rWShP5LC=wFK()n0j)>lqF&RCsnk*3H*Ds2j3d*?FdPG$z9j* z{n253wZ8*DoNU95L}zRAlx@4~_;KPaI(jSqHL2qVMl{}xkvqk*uj^Gt+wo3+yfH{D zDF;cdT)3?FQJ}|r$;zp>FISZC%hb+BQbp;*TQdhE%jonB#lj{I1`N@vAKXhRels?P zpAW|RLLmuau~tGci(3P|TeUO86fc-XO*cr^9%sT}G@&Y*##Db-TWg6KtE{rzNaDY; zFNdQRR8K4M6qIy1#_Dfz$70u*rNeLy&uQDutyVQf4$3JN#py&GzwT?tbbl0YOm=qz zsIf&Iu~5ET;6BlZpC)GTm&D+)tWc&?j##JRs@0y;sez;h!iws_mu38H>~M>Gmc$mv zuSVJrrl2;nB<2l_jh=pJ`WP;BUciq=d-2`z_b@#X$FZS7yfiU}pLBKOjfokI9qJ_A zEqX*C{l$?(fPYRT@RdV{F?n=oztSY$i}y?YPzv==H5uGZ#n01NHq>vOCQ0)C&Fj?W zHQT0|0{3se*=!6`sz(G^Gd*rPegnYN?K3gK7D+k@w!}IJ}+jZq6|Ey4~)w% zvK^}ZwTHdHSE_X^>$~~9&4Los0<0xahlO{$yKynmg;RqENWOg+h>JCsTM+uvgjdyW zAU*}bHVH~iWl;Rl#9xq0LDND@IE!fy;I`hoD0{wSs71CA?6In=giMzVTD!i<9ii1| zmR64y+%*>3GU}#jSyjVq6o1$ITIgaFJ12*>45RTZAY)Sjl&g<6h-||k!)7_kRob`` z8{`=zNSn|Mq?@U=3i#|At-LpGguDA;1zi(*ipF-KVA94ensPZ?>OT^dJZNX8PCQ0S w{I{t`7XM2Z2Jb`o{8GsX!7a;?5v input; + /// Permission-rule suggestions from the request (e.g. a `setMode` / + /// `localSettings` entry). Non-empty → an "allow & don't ask again" path is + /// available; echo a chosen entry back as `updatedPermissions` (D-78). + final List permissionSuggestions; + /// AskUserQuestion is answered through the same channel (D-78). bool get isQuestion => toolName == 'AskUserQuestion'; } @@ -124,11 +130,22 @@ sealed class ToolDecision { /// Allow the tool. [updatedInput] is REQUIRED by the protocol — pass the /// request's input unchanged to allow as-is, or modified to alter the call. /// For AskUserQuestion, include the `answers` map (question text → label). +/// +/// [updatedPermissions] echoes a permission suggestion back to skip future +/// prompts ("don't ask again"). [followUpNote] is NOT part of the protocol — +/// the protocol has no allow-with-message — so the session sends it as a +/// separate user message right after allowing (D-78). final class AllowTool extends ToolDecision { - const AllowTool(this.updatedInput); + const AllowTool(this.updatedInput, {this.updatedPermissions, this.followUpNote}); final Map updatedInput; + final List? updatedPermissions; + final String? followUpNote; @override - Map toJson() => {'behavior': 'allow', 'updatedInput': updatedInput}; + Map toJson() => { + 'behavior': 'allow', + 'updatedInput': updatedInput, + if (updatedPermissions != null && updatedPermissions!.isNotEmpty) 'updatedPermissions': updatedPermissions, + }; } /// Deny the tool with a user-facing [message] (required by the protocol). @@ -218,6 +235,7 @@ class StreamJsonSession { description: request['description'] as String?, toolUseId: request['tool_use_id'] as String? ?? '', input: input, + permissionSuggestions: (request['permission_suggestions'] as List?) ?? const [], )); _pendingCtl.add(pendingPrompt); return; // awaits resolvePrompt @@ -232,13 +250,27 @@ class StreamJsonSession { /// [ToolPrompt.promptId]. No-op if unknown or already resolved. Advances the /// queue so the next pending prompt (if any) surfaces. void resolvePrompt(String promptId, ToolDecision decision) { - final before = _queue.length; - _queue.removeWhere((p) => p.promptId == promptId); - if (_queue.length == before) return; // unknown / already resolved + final idx = _queue.indexWhere((p) => p.promptId == promptId); + if (idx < 0) return; // unknown / already resolved + final prompt = _queue.removeAt(idx); _proc.writeLine(jsonEncode({ 'type': 'control_response', 'response': {'subtype': 'success', 'request_id': promptId, 'response': decision.toJson()}, })); + if (decision is AllowTool) { + // The prompt card is ephemeral (it vanishes once resolved), so leave a + // compact record of an answered question in the conversation log (D-78). + if (prompt.isQuestion) { + final answers = decision.updatedInput['answers']; + if (answers is Map && answers.isNotEmpty) { + final summary = answers.entries.map((e) => '${e.key} → ${e.value}').join('; '); + _items.add(UserMessage(uuid: 'local-${_localSeq++}', timestamp: DateTime.now(), isSidechain: false, text: '✓ answered: $summary')); + } + } + // The protocol has no allow-with-message, so an allow note rides as a + // follow-up user message right after the approval (D-78). + if (decision.followUpNote?.trim().isNotEmpty ?? false) send(decision.followUpNote!.trim()); + } _pendingCtl.add(pendingPrompt); } diff --git a/test/builtin/claude/conversation_view_test.dart b/test/builtin/claude/conversation_view_test.dart index 1c07fa0f..72e80821 100644 --- a/test/builtin/claude/conversation_view_test.dart +++ b/test/builtin/claude/conversation_view_test.dart @@ -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); diff --git a/test/builtin/claude/prompt_card_test.dart b/test/builtin/claude/prompt_card_test.dart index 0f3da171..9be7aee0 100644 --- a/test/builtin/claude/prompt_card_test.dart +++ b/test/builtin/claude/prompt_card_test.dart @@ -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 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()); + }); } diff --git a/test/builtin/claude/stream_json_session_test.dart b/test/builtin/claude/stream_json_session_test.dart index b69541a2..ebd3366f 100644 --- a/test/builtin/claude/stream_json_session_test.dart +++ b/test/builtin/claude/stream_json_session_test.dart @@ -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.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.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.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.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': []}, + }, + })); + await Future.delayed(Duration.zero); + session.resolvePrompt( + 'aq', + AllowTool(const { + 'answers': {'Pet': 'Dogs'} + })); + await Future.delayed(Duration.zero); + + final echo = items.whereType().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'));