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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()},
|
||||
|
||||
Reference in New Issue
Block a user