fold the Deny & simplify denial instead of a red error (T-340)

A user-initiated denial (Deny & simplify) came back as an isError
tool_result and rendered as a prominent expanded-red "Bash · error"
block — pure noise, since the user chose it. It now folds to a muted,
collapsed "denied" card.

Built as a reusable filter rather than string-matching the note: DenyTool
carries a `quiet` flag, the session collects quiet denials' tool_use_ids,
and ConversationView renders any error whose id is in that set folded +
muted. Genuine tool failures (ids not in the set) keep the expanded-red
treatment. Adding future "expected error" cases is just adding ids.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 16:29:30 +02:00
co-authored by Claude Opus 4.8
parent a948bc8a1f
commit 3b23da052e
8 changed files with 117 additions and 12 deletions
+1
View File
@@ -507,6 +507,7 @@ class _ClaudePaneState extends State<ClaudePane> {
foldLevel: foldLevelFromName(_kernel()?.settings.get<String>(kActivityFoldLevelKey)),
hiddenToolUseIds: _session?.promptedToolUseIds ?? const <String>{},
toolUseOutcomes: _session?.toolUseOutcomes ?? const <String, bool>{},
quietErrorToolUseIds: _session?.quietErrorToolUseIds ?? const <String>{},
emptyState: ClaudeBanner(
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
workspace: _repoRoot,
+36 -7
View File
@@ -34,6 +34,7 @@ class ConversationView extends StatefulWidget {
this.emptyState,
this.hiddenToolUseIds = const <String>{},
this.toolUseOutcomes = const <String, bool>{},
this.quietErrorToolUseIds = const <String>{},
this.foldLevel = FoldLevel.tools,
});
@@ -53,6 +54,13 @@ class ConversationView extends StatefulWidget {
/// border instead of being hidden (D-78).
final Map<String, bool> toolUseOutcomes;
/// tool_use_ids whose error result should render folded + muted instead of as
/// a loud red failure (T-340) — expected, user-initiated denials the user
/// already understands (Deny & simplify). Genuine tool errors (ids not in
/// here) keep the prominent expanded-red treatment (T-168). A reusable filter:
/// add ids to quiet more error kinds without string-matching their text.
final Set<String> quietErrorToolUseIds;
/// 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).
@@ -305,6 +313,7 @@ class _ConversationViewState extends State<ConversationView> {
tokens: tokens,
collapseTools: true,
toolUseOutcomes: widget.toolUseOutcomes,
quietErrorToolUseIds: widget.quietErrorToolUseIds,
toolUseById: widget.controller.toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: fold.promptsByToolUseId,
@@ -315,6 +324,7 @@ class _ConversationViewState extends State<ConversationView> {
items: items,
tokens: tokens,
toolUseOutcomes: widget.toolUseOutcomes,
quietErrorToolUseIds: widget.quietErrorToolUseIds,
toolUseById: widget.controller.toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: fold.promptsByToolUseId,
@@ -325,6 +335,7 @@ class _ConversationViewState extends State<ConversationView> {
edits: edits,
tokens: tokens,
toolUseOutcomes: widget.toolUseOutcomes,
quietErrorToolUseIds: widget.quietErrorToolUseIds,
toolUseById: widget.controller.toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: fold.promptsByToolUseId,
@@ -407,6 +418,7 @@ class _ConversationTurn extends StatelessWidget {
required this.tokens,
this.collapseTools = false,
this.toolUseOutcomes = const <String, bool>{},
this.quietErrorToolUseIds = const <String>{},
this.toolUseById = const <String, AssistantToolUse>{},
this.resultByToolUseId = const <String, ToolResultMessage>{},
this.promptsByToolUseId = const <String, List<UserMessage>>{},
@@ -427,6 +439,10 @@ class _ConversationTurn extends StatelessWidget {
EdgeInsetsGeometry get _childMargin => collapseTools ? const EdgeInsets.only(bottom: 14) : const EdgeInsets.only(bottom: kClideCardHeaderPadH);
final Map<String, bool> toolUseOutcomes;
/// tool_use_ids whose error result folds quietly instead of expanded-red
/// (T-340) — see [ConversationView.quietErrorToolUseIds].
final Set<String> quietErrorToolUseIds;
/// Index from toolUseId → AssistantToolUse, for result-card pairing (T-168).
final Map<String, AssistantToolUse> toolUseById;
@@ -627,6 +643,7 @@ class _ConversationTurn extends StatelessWidget {
item: r,
tokens: tokens,
toolUseOutcomes: toolUseOutcomes,
quietErrorToolUseIds: quietErrorToolUseIds,
toolUseById: toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: promptsByToolUseId,
@@ -717,22 +734,28 @@ class _ConversationTurn extends StatelessWidget {
// Error result: render the error message prominently (T-168). If we have
// the paired tool_use, show the tool name as a sub-label so the user can
// see what failed without expanding.
//
// Exception (T-340): an expected, user-initiated denial (Deny & simplify)
// is noise as a loud red error — the user already knows what they did. Fold
// it to a muted, collapsed card. Genuine failures keep the expanded-red look.
if (t.isError) {
final quiet = quietErrorToolUseIds.contains(t.toolUseId);
final multiline = t.content.contains('\n');
final errLabel = quiet ? 'denied' : label;
return ConversationCard(
variant: ConversationCardVariant.bordered,
accent: accent,
borderColor: tokens.statusError,
label: paired != null ? '${paired.name} · $label' : label,
accent: quiet ? tokens.globalTextMuted : accent,
borderColor: quiet ? tokens.panelBorder : tokens.statusError,
label: paired != null ? '${paired.name} · $errLabel' : errLabel,
copyText: t.content,
collapsible: multiline,
collapsedByDefault: false, // errors default expanded so they're visible
collapsedSummary: multiline ? _firstLine(t.content) : null,
collapsible: quiet || multiline,
collapsedByDefault: quiet, // genuine errors stay expanded; a denial folds
collapsedSummary: (quiet || multiline) ? _firstLine(t.content) : null,
body: ClideText(
t.content,
fontSize: clideFontMeta,
fontFamily: clideMonoFamily,
color: tokens.statusError,
color: quiet ? tokens.globalTextMuted : tokens.statusError,
),
);
}
@@ -790,6 +813,7 @@ class _ActivityCard extends StatelessWidget {
required this.items,
required this.tokens,
required this.toolUseOutcomes,
required this.quietErrorToolUseIds,
required this.toolUseById,
required this.resultByToolUseId,
required this.promptsByToolUseId,
@@ -799,6 +823,7 @@ class _ActivityCard extends StatelessWidget {
final List<ConversationItem> items;
final SurfaceTokens tokens;
final Map<String, bool> toolUseOutcomes;
final Set<String> quietErrorToolUseIds;
final Map<String, AssistantToolUse> toolUseById;
final Map<String, ToolResultMessage> resultByToolUseId;
final Map<String, List<UserMessage>> promptsByToolUseId;
@@ -819,6 +844,7 @@ class _ActivityCard extends StatelessWidget {
item: item,
tokens: tokens,
toolUseOutcomes: toolUseOutcomes,
quietErrorToolUseIds: quietErrorToolUseIds,
toolUseById: toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: promptsByToolUseId,
@@ -851,6 +877,7 @@ class _EditRunCard extends StatelessWidget {
required this.edits,
required this.tokens,
required this.toolUseOutcomes,
required this.quietErrorToolUseIds,
required this.toolUseById,
required this.resultByToolUseId,
required this.promptsByToolUseId,
@@ -860,6 +887,7 @@ class _EditRunCard extends StatelessWidget {
final List<ConversationItem> edits;
final SurfaceTokens tokens;
final Map<String, bool> toolUseOutcomes;
final Set<String> quietErrorToolUseIds;
final Map<String, AssistantToolUse> toolUseById;
final Map<String, ToolResultMessage> resultByToolUseId;
final Map<String, List<UserMessage>> promptsByToolUseId;
@@ -880,6 +908,7 @@ class _EditRunCard extends StatelessWidget {
item: item,
tokens: tokens,
toolUseOutcomes: toolUseOutcomes,
quietErrorToolUseIds: quietErrorToolUseIds,
toolUseById: toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: promptsByToolUseId,
+3 -1
View File
@@ -234,7 +234,9 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
void _permDenySimplify() {
final user = _permNote();
final note = user == null ? _kDenySimplifyNote : '$_kDenySimplifyNote\n\nUser note: $user';
widget.onResolve(widget.prompt.promptId, DenyTool(note));
// Quiet: the user deliberately chose this, so its denial folds rather than
// shouting as a red error (T-340).
widget.onResolve(widget.prompt.promptId, DenyTool(note, quiet: true));
}
(Color, String, List<Widget>) _permission(SurfaceTokens tokens) {
@@ -183,9 +183,15 @@ final class AllowTool extends ToolDecision {
}
/// Deny the tool with a user-facing [message] (required by the protocol).
///
/// [quiet] marks a deliberate, user-initiated denial that the user already
/// understands (e.g. "Deny & simplify", T-340) — the resulting error tool-result
/// should fold to a muted card rather than shout as a red failure. Off by
/// default, so a genuine/unexpected denial still renders prominently.
final class DenyTool extends ToolDecision {
const DenyTool(this.message);
const DenyTool(this.message, {this.quiet = false});
final String message;
final bool quiet;
@override
Map<String, dynamic> toJson() => {'behavior': 'deny', 'message': message};
}
@@ -246,9 +252,16 @@ class StreamJsonSession {
/// green/red border (D-78).
final _toolUseOutcome = <String, bool>{};
/// tool_use_ids whose error result should render folded + muted rather than as
/// a loud red failure (T-340): expected, user-initiated denials (Deny &
/// simplify, today) that the user already understands. The reusable extension
/// point — add an id here at the moment you know its error is non-alarming.
final _quietErrorToolUses = <String>{};
/// Read-only views for the conversation view.
Set<String> get promptedToolUseIds => _promptedToolUses;
Map<String, bool> get toolUseOutcomes => _toolUseOutcome;
Set<String> get quietErrorToolUseIds => _quietErrorToolUses;
/// Whether a turn is in flight (between a send and claude's `result`). Drives
/// the composer's Stop affordance.
@@ -542,7 +555,10 @@ class StreamJsonSession {
final idx = _queue.indexWhere((p) => p.promptId == promptId);
if (idx < 0) return; // unknown / already resolved
final prompt = _queue.removeAt(idx);
if (prompt.toolUseId.isNotEmpty) _toolUseOutcome[prompt.toolUseId] = decision is AllowTool;
if (prompt.toolUseId.isNotEmpty) {
_toolUseOutcome[prompt.toolUseId] = decision is AllowTool;
if (decision is DenyTool && decision.quiet) _quietErrorToolUses.add(prompt.toolUseId);
}
_proc.writeLine(jsonEncode({
'type': 'control_response',
'response': {'subtype': 'success', 'request_id': promptId, 'response': decision.toJson()},