hide prompted tool-use payloads from the conversation log

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 09:50:39 +02:00
co-authored by Claude Opus 4.7
parent de264e7141
commit c30a707b41
5 changed files with 104 additions and 33 deletions
+4
View File
@@ -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
+34 -30
View File
@@ -267,37 +267,41 @@ class _ClaudePaneState extends State<ClaudePane> {
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<ToolPrompt?>(
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 <String>{},
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<ToolPrompt?>(
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));
+29 -1
View File
@@ -25,10 +25,16 @@ class ConversationView extends StatefulWidget {
required this.controller,
this.wrapInSelectionArea = true,
this.emptyState,
this.hiddenToolUseIds = const <String>{},
});
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<String> 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<ConversationView> {
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<ConversationItem> _visibleItems(List<ConversationItem> 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<ConversationView> {
@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(
@@ -173,6 +173,13 @@ class StreamJsonSession {
final _queue = <ToolPrompt>[];
final _pendingCtl = StreamController<ToolPrompt?>.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 = <String>{};
/// Read-only view of [_promptedToolUses] for the conversation view.
Set<String> 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<String, dynamic>() ?? <String, dynamic>{};
final tuid = request['tool_use_id'] as String? ?? '';
if (tuid.isNotEmpty) _promptedToolUses.add(tuid);
_queue.add(ToolPrompt(
promptId: rid,
toolName: toolName,
@@ -115,7 +115,7 @@ void main() {
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
Future<ConversationController> pumpWith(WidgetTester tester, List<ConversationItem> items) async {
Future<ConversationController> pumpWith(WidgetTester tester, List<ConversationItem> items, {Set<String> 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<ConversationItem>.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);