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