From c30a707b414452df51af7a47c232ace1434dfe9a Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 25 May 2026 09:50:39 +0200 Subject: [PATCH] hide prompted tool-use payloads from the conversation log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A permission-gated tool or AskUserQuestion already surfaces as a prompt in the composer zone, so its raw tool-use card was redundant noise. The session now tracks which tool_use_ids surfaced as a prompt; the conversation view hides those tool-use cards. AskUserQuestion also hides its result (the chosen answer is logged separately); permission-tool results are kept — that's the useful outcome. The pane rebuilds the view on each prompt change so the payload vanishes the moment its prompt appears. T-176, T-177, D-78. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 4 ++ lib/builtin/claude/src/claude_pane.dart | 64 ++++++++++--------- lib/builtin/claude/src/conversation_view.dart | 30 ++++++++- .../claude/src/stream_json_session.dart | 9 +++ .../claude/conversation_view_test.dart | 30 ++++++++- 5 files changed, 104 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 875ac7cd..153f35c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. - 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. +- Prompted tool calls no longer duplicate their payload in the log — a + permission/AskUserQuestion request shows as the prompt, not a raw + tool-use card; the result is still kept (AskUserQuestion's is replaced by + the logged answer). - 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/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index 8c3cf3c2..6947996c 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -267,37 +267,41 @@ class _ClaudePaneState extends State { child: ClideText(_error!, muted: true), ); } else if (_conversation != null) { - body = Column( - children: [ - Expanded( - child: ConversationView( - controller: _conversation!, - emptyState: ClaudeBanner( - role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}', - workspace: _repoRoot, - statusLine: _statusLine, + // Rebuild conversation + composer zone together on each prompt change so + // the view hides a prompted tool-use card the moment its prompt appears + // (D-78), and the composer zone swaps to the prompt UI. + body = StreamBuilder( + stream: _session?.pendingPromptStream, + initialData: _session?.pendingPrompt, + builder: (context, snap) { + final prompt = snap.data; + return Column( + children: [ + Expanded( + child: ConversationView( + controller: _conversation!, + hiddenToolUseIds: _session?.promptedToolUseIds ?? const {}, + emptyState: ClaudeBanner( + role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}', + workspace: _repoRoot, + statusLine: _statusLine, + ), + ), ), - ), - ), - // The composer zone: an open prompt (permission / AskUserQuestion) - // takes this space and hides the text input until it's answered, so - // interaction stays out of the conversation stream (D-78). - StreamBuilder( - stream: _session?.pendingPromptStream, - initialData: _session?.pendingPrompt, - builder: (context, snap) { - final prompt = snap.data; - if (prompt != null && _session != null) { - return ToolPromptCard(prompt: prompt, onResolve: _session!.resolvePrompt); - } - return ClaudeComposer( - enabled: _session != null, - onSubmit: _send, - pasteResolver: () => resolveClipboardAttachment(const NativeClipboard()), - ); - }, - ), - ], + // An open prompt takes the composer's space and hides the text + // input until it's answered, so interaction stays out of the + // conversation stream (D-78). + if (prompt != null && _session != null) + ToolPromptCard(prompt: prompt, onResolve: _session!.resolvePrompt) + else + ClaudeComposer( + enabled: _session != null, + onSubmit: _send, + pasteResolver: () => resolveClipboardAttachment(const NativeClipboard()), + ), + ], + ); + }, ); } else { body = const Center(child: ClideText('starting…', muted: true)); diff --git a/lib/builtin/claude/src/conversation_view.dart b/lib/builtin/claude/src/conversation_view.dart index e86bd8fe..a3e509f6 100644 --- a/lib/builtin/claude/src/conversation_view.dart +++ b/lib/builtin/claude/src/conversation_view.dart @@ -25,10 +25,16 @@ class ConversationView extends StatefulWidget { required this.controller, this.wrapInSelectionArea = true, this.emptyState, + this.hiddenToolUseIds = const {}, }); final ConversationController controller; + /// tool_use_ids whose raw tool-use card should be hidden because the call + /// surfaced as a prompt (permission / AskUserQuestion) — D-78. The result is + /// still shown (it's the useful answer); only the request payload is hidden. + final Set hiddenToolUseIds; + /// Whether to wrap the list in its own [ClideSelectionArea]. The team /// grid sets this false and wraps all tiles in one shared area so /// selection spans tiles — nesting SelectionAreas is illegal (T-140). @@ -67,6 +73,28 @@ class _ConversationViewState extends State { super.dispose(); } + /// Hide tool-use payloads that surfaced as a prompt (D-78): AskUserQuestion + /// (its tool-use *and* result echo are noise — the prompt + the logged answer + /// cover it), and any permission-prompted tool-use (keep its result — that's + /// the useful answer). + List _visibleItems(List items) { + final hidden = widget.hiddenToolUseIds; + final auqIds = { + for (final it in items) + if (it is AssistantToolUse && it.name == 'AskUserQuestion') it.toolUseId, + }; + bool drop(ConversationItem it) { + if (it is AssistantToolUse) return it.name == 'AskUserQuestion' || hidden.contains(it.toolUseId); + if (it is ToolResultMessage) return auqIds.contains(it.toolUseId); // AUQ result only; keep permission results + return false; + } + + return [ + for (final it in items) + if (!drop(it)) it, + ]; + } + void _onChanged() { if (!mounted) return; setState(() {}); @@ -81,7 +109,7 @@ class _ConversationViewState extends State { @override Widget build(BuildContext context) { final tokens = ClideTheme.of(context).surface; - final items = widget.controller.items; + final items = _visibleItems(widget.controller.items); if (items.isEmpty) { return ColoredBox( diff --git a/lib/builtin/claude/src/stream_json_session.dart b/lib/builtin/claude/src/stream_json_session.dart index 55679217..31eb93a7 100644 --- a/lib/builtin/claude/src/stream_json_session.dart +++ b/lib/builtin/claude/src/stream_json_session.dart @@ -173,6 +173,13 @@ class StreamJsonSession { final _queue = []; final _pendingCtl = StreamController.broadcast(); + /// tool_use_ids that surfaced as a prompt — the view hides their raw + /// tool-use card (it showed as a prompt) but keeps the result (D-78). + final _promptedToolUses = {}; + + /// Read-only view of [_promptedToolUses] for the conversation view. + Set get promptedToolUseIds => _promptedToolUses; + /// The prompt currently awaiting a decision (queue head), or null. ToolPrompt? get pendingPrompt => _queue.isEmpty ? null : _queue.first; @@ -228,6 +235,8 @@ class StreamJsonSession { if (request['subtype'] == 'can_use_tool') { final toolName = request['tool_name'] as String? ?? ''; final input = (request['input'] as Map?)?.cast() ?? {}; + final tuid = request['tool_use_id'] as String? ?? ''; + if (tuid.isNotEmpty) _promptedToolUses.add(tuid); _queue.add(ToolPrompt( promptId: rid, toolName: toolName, diff --git a/test/builtin/claude/conversation_view_test.dart b/test/builtin/claude/conversation_view_test.dart index 72e80821..071b261d 100644 --- a/test/builtin/claude/conversation_view_test.dart +++ b/test/builtin/claude/conversation_view_test.dart @@ -115,7 +115,7 @@ void main() { setUp(() async => f = await KernelFixture.create()); tearDown(() => f.dispose()); - Future pumpWith(WidgetTester tester, List items) async { + Future pumpWith(WidgetTester tester, List items, {Set hiddenToolUseIds = const {}}) async { tester.view.physicalSize = const Size(900, 700); tester.view.devicePixelRatio = 1.0; addTearDown(() { @@ -125,7 +125,7 @@ void main() { final stream = StreamController.broadcast(); final c = ConversationController(stream: stream.stream); addTearDown(c.dispose); - await tester.pumpWidget(harness(f, ConversationView(controller: c))); + await tester.pumpWidget(harness(f, ConversationView(controller: c, hiddenToolUseIds: hiddenToolUseIds))); for (final it in items) { stream.add(it); } @@ -170,6 +170,32 @@ void main() { expect(find.text('error'), findsOneWidget); }); + testWidgets('AskUserQuestion tool-use and its result are hidden (it shows as a prompt)', (tester) async { + await pumpWith(tester, [ + _asst('let me ask'), + AssistantToolUse(uuid: 'au', timestamp: _t, isSidechain: false, toolUseId: 'auq1', name: 'AskUserQuestion', input: const {'questions': []}), + ToolResultMessage(uuid: 'ar', timestamp: _t, isSidechain: false, toolUseId: 'auq1', content: 'answered', isError: false), + _asst('thanks'), + ]); + expect(find.text('AskUserQuestion'), findsNothing); + expect(find.text('let me ask'), findsOneWidget); + expect(find.text('thanks'), findsOneWidget); + }); + + testWidgets('a permission-prompted tool-use is hidden but its result is kept', (tester) async { + await pumpWith( + tester, + [ + _tool('Write', {'file_path': '/tmp/x'}), + _result('done') + ], + hiddenToolUseIds: {'x1'}, // _tool + _result both use toolUseId 'x1' + ); + expect(find.text('Write'), findsNothing); // payload hidden + expect(find.text('done'), findsOneWidget); // result kept + expect(find.text('result'), 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);