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
+1
View File
@@ -281,6 +281,7 @@ class _ClaudePaneState extends State<ClaudePane> {
child: ConversationView(
controller: _conversation!,
hiddenToolUseIds: _session?.promptedToolUseIds ?? const <String>{},
toolUseOutcomes: _session?.toolUseOutcomes ?? const <String, bool>{},
emptyState: ClaudeBanner(
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
workspace: _repoRoot,
+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(
Binary file not shown.
@@ -174,11 +174,17 @@ class StreamJsonSession {
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).
/// tool-use card while pending (it shows as a prompt) but keeps the result.
final _promptedToolUses = <String>{};
/// Read-only view of [_promptedToolUses] for the conversation view.
/// Resolved outcome per prompted tool_use_id: true = allowed, false = denied.
/// Absent = still pending. The view shows resolved tool-uses collapsed with a
/// green/red border (D-78).
final _toolUseOutcome = <String, bool>{};
/// Read-only views for the conversation view.
Set<String> get promptedToolUseIds => _promptedToolUses;
Map<String, bool> get toolUseOutcomes => _toolUseOutcome;
/// The prompt currently awaiting a decision (queue head), or null.
ToolPrompt? get pendingPrompt => _queue.isEmpty ? null : _queue.first;
@@ -262,6 +268,7 @@ 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;
_proc.writeLine(jsonEncode({
'type': 'control_response',
'response': {'subtype': 'success', 'request_id': promptId, 'response': decision.toJson()},
+14 -3
View File
@@ -53,13 +53,19 @@ final class UserMessage extends ConversationItem {
required super.timestamp,
required super.isSidechain,
required this.text,
this.injected = false,
});
/// The concatenated text of all `text` parts in the content array.
final String text;
/// True when this "user" message was injected by the harness (a skill load,
/// a slash-command expansion, a system reminder) rather than typed by the
/// user — the view de-emphasises these (D-78).
final bool injected;
@override
String toString() => 'UserMessage(${_shortId(uuid)}, ${text.length} chars)';
String toString() => 'UserMessage(${_shortId(uuid)}, ${text.length} chars${injected ? ', injected' : ''})';
}
/// A tool-result delivered from the host back to Claude as a user message.
@@ -520,9 +526,14 @@ void _parseUserInto(
if (message == null) return;
final content = message['content'];
// Harness-injected user messages (skill loads, slash-command expansions,
// system reminders) — `isSynthetic` on the stream-json wire, `isMeta` in the
// transcript. The view de-emphasises these (D-78).
final injected = envelope['isSynthetic'] == true || envelope['isMeta'] == true;
if (content is String) {
if (content.isNotEmpty) {
out.add(UserMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, text: content));
out.add(UserMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, text: content, injected: injected));
}
return;
}
@@ -550,7 +561,7 @@ void _parseUserInto(
}
}
if (textParts.isNotEmpty) {
out.add(UserMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, text: textParts.join('\n')));
out.add(UserMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, text: textParts.join('\n'), injected: injected));
}
}