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
@@ -115,7 +115,8 @@ void main() {
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
Future<ConversationController> pumpWith(WidgetTester tester, List<ConversationItem> items, {Set<String> hiddenToolUseIds = const {}}) async {
Future<ConversationController> pumpWith(WidgetTester tester, List<ConversationItem> items,
{Set<String> hiddenToolUseIds = const {}, Map<String, bool> toolUseOutcomes = const {}}) async {
tester.view.physicalSize = const Size(900, 700);
tester.view.devicePixelRatio = 1.0;
addTearDown(() {
@@ -125,7 +126,7 @@ void main() {
final stream = StreamController<ConversationItem>.broadcast();
final c = ConversationController(stream: stream.stream);
addTearDown(c.dispose);
await tester.pumpWidget(harness(f, ConversationView(controller: c, hiddenToolUseIds: hiddenToolUseIds)));
await tester.pumpWidget(harness(f, ConversationView(controller: c, hiddenToolUseIds: hiddenToolUseIds, toolUseOutcomes: toolUseOutcomes)));
for (final it in items) {
stream.add(it);
}
@@ -196,6 +197,29 @@ void main() {
expect(find.text('result'), findsOneWidget);
});
testWidgets('a resolved permission tool-use is shown collapsed, not hidden', (tester) async {
await pumpWith(
tester,
[
_tool('Write', {'file_path': '/tmp/x'}),
_result('done')
],
hiddenToolUseIds: {'x1'},
toolUseOutcomes: {'x1': true}, // approved
);
expect(find.text('Write'), findsOneWidget); // shown (resolved)
expect(find.byType(ClideIcon), findsOneWidget); // collapsed caret
});
testWidgets('an injected user message renders as a muted "context" card, not "you"', (tester) async {
await pumpWith(tester, [
UserMessage(uuid: 'i', timestamp: _t, isSidechain: false, text: 'Base directory for this skill: /x\n\n# pql', injected: true),
_user('a real question'),
]);
expect(find.text('context'), findsOneWidget);
expect(find.text('you'), findsOneWidget); // the real one
});
testWidgets('a one-line tool result renders inline (no collapse caret)', (tester) async {
await pumpWith(tester, [_result('hello-from-spike')]);
expect(find.text('hello-from-spike'), findsOneWidget);
@@ -1,5 +1,6 @@
import 'package:clide/builtin/claude/src/prompt_card.dart';
import 'package:clide/builtin/claude/src/stream_json_session.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -94,6 +95,12 @@ void main() {
expect((decision as AllowTool).updatedInput['content'], 'banana');
});
testWidgets('permission card shows the command being permitted', (tester) async {
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: permissionPrompt(), onResolve: (_, __) {})));
await tester.pump();
expect(find.byType(ClideCodeBlock), findsOneWidget);
});
testWidgets('permission card: Deny returns DenyTool with a message', (tester) async {
ToolDecision? decision;
await tester.pumpWidget(harness(
@@ -142,6 +142,36 @@ void main() {
expect(echoed.single.text, 'do the thing');
});
test('a synthetic user message (skill/command inject) is flagged injected', () async {
proc.emit(jsonEncode({
'type': 'user',
'isSynthetic': true,
'message': {
'role': 'user',
'content': [
{'type': 'text', 'text': 'Base directory for this skill: /x'}
],
},
}));
await Future<void>.delayed(Duration.zero);
final u = items.whereType<UserMessage>().single;
expect(u.injected, isTrue);
});
test('a plain user text event is not flagged injected', () async {
proc.emit(jsonEncode({
'type': 'user',
'message': {
'role': 'user',
'content': [
{'type': 'text', 'text': 'hello'}
],
},
}));
await Future<void>.delayed(Duration.zero);
expect(items.whereType<UserMessage>().single.injected, isFalse);
});
test('a can_use_tool control_request becomes a pending prompt (not a conversation item)', () async {
final emitted = <ToolPrompt?>[];
session.pendingPromptStream.listen(emitted.add);
@@ -223,6 +253,20 @@ void main() {
expect((follow['message'] as Map)['content'], 'use docs/ instead');
});
test('resolvePrompt records the tool outcome — allow', () async {
proc.emit(canUseTool('o1'));
await Future<void>.delayed(Duration.zero);
session.resolvePrompt('o1', AllowTool(const {}));
expect(session.toolUseOutcomes['toolu_1'], isTrue);
});
test('resolvePrompt records the tool outcome — deny', () async {
proc.emit(canUseTool('o2'));
await Future<void>.delayed(Duration.zero);
session.resolvePrompt('o2', const DenyTool('no'));
expect(session.toolUseOutcomes['toolu_1'], isFalse);
});
test('resolvePrompt(deny) writes a deny decision with a message', () async {
proc.emit(canUseTool('req-3'));
await Future<void>.delayed(Duration.zero);