refine the prompt/log UX: show commands, de-emphasize injects

Two spot-check fixes (T-178, T-179), both grounded in a boundary test of
the stream-json wire (findings folded into the spike doc):

- Harness-injected user messages (skill loads, slash-command expansions,
  system reminders) carry isSynthetic on the wire (isMeta in the
  transcript). They were rendering as blue "you" cards though the user
  never typed them; now UserMessage.injected flags them and the view
  shows a muted, collapsed "context" card instead.
- Permission prompts now show the command/input being permitted (a
  capped, scrollable code block) so you can see what you approve. Instead
  of fully hiding a prompted tool-use, once resolved it collapses to a
  one-line summary with a green (approved) or red (denied) border; the
  session tracks per-tool_use_id outcome and the view colours it. The
  result is kept.

Corrects an earlier wrong assumption: the Skill tool is auto-allowed
(no permission prompt); the inject only appears once the Skill tool is
actually invoked, which is why deny-captures missed it.

T-178, T-179, D-78.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 10:26:33 +02:00
co-authored by Claude Opus 4.7
parent c30a707b41
commit 2243237b13
12 changed files with 189 additions and 32 deletions
+56 -12
View File
@@ -26,15 +26,21 @@ class ConversationView extends StatefulWidget {
this.wrapInSelectionArea = true,
this.emptyState,
this.hiddenToolUseIds = const <String>{},
this.toolUseOutcomes = const <String, bool>{},
});
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.
/// tool_use_ids that surfaced as a prompt (permission / AskUserQuestion) —
/// D-78. While pending (not in [toolUseOutcomes]) the raw tool-use card is
/// hidden (it shows as a prompt). The result is always kept.
final Set<String> hiddenToolUseIds;
/// Resolved outcome per prompted tool_use_id (true = allowed, false = denied)
/// — a resolved permission tool-use renders collapsed with a green/red
/// border instead of being hidden (D-78).
final Map<String, bool> toolUseOutcomes;
/// 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).
@@ -83,8 +89,14 @@ class _ConversationViewState extends State<ConversationView> {
for (final it in items)
if (it is AssistantToolUse && it.name == 'AskUserQuestion') it.toolUseId,
};
final outcomes = widget.toolUseOutcomes;
bool drop(ConversationItem it) {
if (it is AssistantToolUse) return it.name == 'AskUserQuestion' || hidden.contains(it.toolUseId);
if (it is AssistantToolUse) {
if (it.name == 'AskUserQuestion') return true;
// Permission-prompted: hide only while pending; once resolved it shows
// collapsed with a green/red border.
return hidden.contains(it.toolUseId) && !outcomes.containsKey(it.toolUseId);
}
if (it is ToolResultMessage) return auqIds.contains(it.toolUseId); // AUQ result only; keep permission results
return false;
}
@@ -124,7 +136,7 @@ class _ConversationViewState extends State<ConversationView> {
controller: _scroll,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
itemCount: items.length,
itemBuilder: (context, i) => _ConversationTurn(item: items[i], tokens: tokens),
itemBuilder: (context, i) => _ConversationTurn(item: items[i], tokens: tokens, toolUseOutcomes: widget.toolUseOutcomes),
),
);
return ColoredBox(
@@ -140,21 +152,36 @@ const claudeAccent = Color(0xFFD97757);
/// One conversation item, rendered by kind.
class _ConversationTurn extends StatelessWidget {
const _ConversationTurn({required this.item, required this.tokens});
const _ConversationTurn({required this.item, required this.tokens, this.toolUseOutcomes = const <String, bool>{}});
final ConversationItem item;
final SurfaceTokens tokens;
final Map<String, bool> toolUseOutcomes;
@override
Widget build(BuildContext context) {
final i = item;
return switch (i) {
UserMessage() => ConversationCard(
accent: tokens.globalFocus,
label: 'you',
copyText: i.text,
body: ClideMarkdown(i.text),
),
UserMessage() => i.injected
// Harness-injected (skill load / command expansion / system
// reminder) — not typed by the user, so de-emphasise: a muted,
// collapsed "context" card rather than the "you" accent (D-78).
? ConversationCard(
variant: ConversationCardVariant.bare,
accent: tokens.globalTextMuted,
label: 'context',
copyText: i.text,
collapsible: true,
collapsedByDefault: true,
collapsedSummary: _firstLine(i.text),
body: ClideText(i.text, muted: true, fontSize: clideFontMeta),
)
: ConversationCard(
accent: tokens.globalFocus,
label: 'you',
copyText: i.text,
body: ClideMarkdown(i.text),
),
AssistantTextMessage() => ConversationCard(
accent: claudeAccent,
label: 'claude',
@@ -177,6 +204,23 @@ class _ConversationTurn extends StatelessWidget {
Widget _toolUse(AssistantToolUse t) {
final pretty = const JsonEncoder.withIndent(' ').convert(t.input);
// A resolved permission-prompted call: collapsed, green if approved / red
// if denied — a quiet record of what was permitted (D-78).
final outcome = toolUseOutcomes[t.toolUseId];
if (outcome != null) {
final color = outcome ? tokens.statusSuccess : tokens.statusError;
return ConversationCard(
variant: ConversationCardVariant.bordered,
accent: color,
borderColor: color,
label: t.name,
copyText: pretty,
collapsible: true,
collapsedByDefault: true,
collapsedSummary: _toolUseSummary(t),
body: ClideCodeBlock(source: pretty, language: 'json'),
);
}
// Collapse only the bulky multi-line form; a trivial one-liner just shows.
final multiline = pretty.contains('\n');
return ConversationCard(