diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index cb35fe99..2ebb9021 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -2233,3 +2233,7 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-175', 'status', 'in_progress', 'done', NULL, '2026-05-25 07:25:02', '2026-05-25 07:25:02', '2026-05-25 07:25:02', NULL, '3e63273af832240b9f782fc41ca0047d', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-177', 'status', 'in_progress', 'done', NULL, '2026-05-25 07:25:02', '2026-05-25 07:25:02', '2026-05-25 07:25:02', NULL, '73982bc82e335f2d0fd80633aef0c082', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-176', 'status', 'in_progress', 'done', NULL, '2026-05-25 07:27:57', '2026-05-25 07:27:57', '2026-05-25 07:27:57', NULL, '926d5d2694207c65d0f620b2834962aa', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-179', 'status', 'backlog', 'in_progress', NULL, '2026-05-25 08:10:25', '2026-05-25 08:10:25', '2026-05-25 08:10:25', NULL, '059ff72ce3229f45259b097721bfb08f', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-178', 'status', 'backlog', 'in_progress', NULL, '2026-05-25 08:10:25', '2026-05-25 08:10:25', '2026-05-25 08:10:25', NULL, '389b95ddaaef1a5ce318bda840b1cde5', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-178', 'status', 'in_progress', 'done', NULL, '2026-05-25 08:21:46', '2026-05-25 08:21:46', '2026-05-25 08:21:46', NULL, 'ddaad91987208966c2720dee370738ed', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-179', 'status', 'in_progress', 'done', NULL, '2026-05-25 08:26:21', '2026-05-25 08:26:21', '2026-05-25 08:26:21', NULL, '58dc0afa577b9aee651d683aa1b9bc69', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index 0bce71f3..538cf027 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -2749,3 +2749,5 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority, INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-175', 'task', 'T-164', 'Permission prompt: Allow / Allow-and-remember / Deny+note', 'Bottom-zone permission card (prompt_card.dart): replace binary Allow/Deny with three options. (1) Allow -> allow{updatedInput}. (2) Allow and do not ask again -> shown only when the can_use_tool request carries a permission_suggestions entry (session/localSettings destination); returns allow{updatedInput, updatedPermissions:[suggestion]}. AllowTool gains an optional updatedPermissions field; ToolPrompt + StreamJsonSession carry the request permission_suggestions through. (3) Deny -> reveals an optional note field -> deny{message}. Plus allow-with-note: clide sends the typed note as a follow-up user message right after allowing (protocol has no allow-with-message per D-78; closest faithful approximation). Acceptance: three options render (option 2 only when a suggestion exists); allow / deny / updatedPermissions / deny-note all flow over the control channel; allow+note sends a follow-up; widget + transport tests. Wireframe: docs/design/wireframes/claude-prompts/04-permission.png', 'done', 'high', NULL, NULL, 'D-78', '2026-05-25 07:11:09', '2026-05-25 07:25:02', NULL, 'ae547748304f0b6b388338ed501dfb60', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-177', 'task', 'T-164', 'Conversation cards: collapse tool_use/tool_result only when multi-line', 'tool_use and tool_result conversation cards start collapsed ONLY when their body is multi-line; single-line bodies render inline (collapse bulk, not chrome - if it fits on one line, just show it). Collapsed header shows a one-line summary (tool_use: tool + key arg, e.g. Write /tmp/x; tool_result: first line or byte size). thinking already collapses; user/assistant text stay expanded. Acceptance: a one-line result renders inline with no caret; a multi-line result starts collapsed with a summary header; widget tests. Relates to T-173 (ConversationCard primitive).', 'done', 'medium', NULL, NULL, 'D-78', '2026-05-25 07:11:22', '2026-05-25 07:25:02', NULL, 'f4a0e3207adf3134901fbee447e2824b', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-176', 'task', 'T-164', 'AskUserQuestion native picker: stepper + review, Other, per-choice note', 'Overhaul the AskUserQuestion bottom-zone card (prompt_card.dart). Single question -> no stepper, bare question + options. Multiple (2-4) -> stepper: top nav showing "N - Header" per step (check on answered, box on current) + prev/next arrows, one question at a time with Back/Next, then a Review screen (question -> answer, Back / Submit answers). Each question: numbered option buttons (1-4) + an Other choice that reveals a free-text field (typed text becomes the answer value, not the word Other) + a +note affordance on a picked option (answer becomes Label - note). multiSelect toggles and comma-joins labels. A chat-instead escape denies the prompt (message: user chose to chat instead) and refocuses the composer. On submit -> allow{updatedInput:{questions, answers}} -> card closes -> clide appends a compact answered echo item to the conversation log. Acceptance: single vs multi layouts; Other + note produce correct answer strings; review step; chat-instead denies + refocuses; widget tests. Wireframes: docs/design/wireframes/claude-prompts/ (01 single, 02 stepper, 03 review).', 'done', 'high', NULL, NULL, 'D-78', '2026-05-25 07:11:16', '2026-05-25 07:27:57', NULL, 'c63148c0b3d03a97b5ccc7e75133916d', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-178', 'task', 'T-164', 'De-emphasize harness-injected user messages (skill/command injects)', 'When a skill or slash-command is invoked, Claude injects its content as a user-role message (e.g. skill text begins "Base directory for this skill:", commands wrap content in //, system reminders in ). These are NOT typed by the user but render as a normal blue "you" card. Detect injected user messages (content markers; also isMeta when present) and render them de-emphasized: a collapsed, muted "context" card (bare variant), NOT the blue "you" accent. UserMessage gains an injected flag set in parseTranscriptChunk; conversation_view renders injected user messages collapsed/muted. Acceptance: a skill-inject user message renders collapsed + muted + not labelled "you"; a normal typed message still renders as "you"; widget + parser tests.', 'done', 'medium', NULL, NULL, 'D-78', '2026-05-25 08:10:10', '2026-05-25 08:21:46', NULL, '0fdbcde0168c2563152bbd862fc69d50', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-179', 'task', 'T-164', 'Permission prompt shows the command; resolved tool-use collapses with green/red border', 'Two parts. (1) The permission prompt UI must show what is being permitted: render the tool input (e.g. the Bash command / Write content) in the prompt card as a collapsible, trimmed code block, so the user can see what they approve. (2) Replace the current full-hide of a permission-prompted tool-use card: while the prompt is open, keep the payload in the prompt (hidden from the log); after resolution, show a COLLAPSED one-line summary in the log with a green border when approved and a red border when denied. Requires the session to track per-tool_use_id outcome (pending/allow/deny) and expose it; the conversation view renders prompted tool-use cards collapsed + colour-bordered by outcome (and keeps the result). Acceptance: prompt shows the command (collapsible); after allow the tool-use shows collapsed with a green border, after deny a red border; widget tests.', 'done', 'medium', NULL, NULL, 'D-78', '2026-05-25 08:10:18', '2026-05-25 08:26:21', NULL, 'b0679e5eaf9cc6cff3ccecde0ed31909', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index 153f35c6..8a8ff602 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,23 +18,28 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added -- Native permission & AskUserQuestion prompts (T-166, T-175, T-176, D-78) — - when Claude needs tool approval or asks a question, the composer is +- Native permission & AskUserQuestion prompts (T-166, T-175, T-176, T-179, + D-78) — when Claude needs tool approval or asks a question, the composer is replaced by a prompt: Allow / Allow-and-don't-ask-again / Deny for - permissions (with an optional note), and a single-question or - stepped-with-review option picker for AskUserQuestion (with "Other" - free-text and per-choice notes). Closes the prompt gap the tmux model - couldn't surface. + permissions (showing the command/input being permitted, with an optional + note), and a single-question or stepped-with-review option picker for + AskUserQuestion (with "Other" free-text and per-choice notes). Closes the + prompt gap the tmux model couldn't surface. - Conversation message cards (T-173) — every turn in the Claude pane now renders through one card template with a copy button on hover and a collapse/expand caret for tool calls, results, and thinking. - Collapsed-by-default tool cards (T-177) — multi-line tool calls and results start collapsed behind a one-line summary; one-line output stays inline so a caret never hides a single line. -- Prompted tool calls no longer duplicate their payload in the log — a - permission/AskUserQuestion request shows as the prompt, not a raw - tool-use card; the result is still kept (AskUserQuestion's is replaced by - the logged answer). +- Prompted tool calls are quieter in the log (T-179) — a permission request + shows the command in the prompt, not as a raw tool-use card; once decided + it collapses to a one-line summary with a green (approved) or red (denied) + border, and the result is kept. AskUserQuestion's tool-use + result are + replaced by the logged answer. +- Harness-injected messages are de-emphasized (T-178) — skill loads, + slash-command expansions, and system reminders (Claude's `isSynthetic` + messages) render as a muted, collapsed "context" card instead of a blue + "you" message, since they weren't typed by the user. - Claude meta sidebar (T-141, T-157) — an always-pickable left-panel tab showing Claude activity (the latest day's messages/sessions/tool-calls plus lifetime totals, from `stats-cache.json`) and, when a tmux team is diff --git a/docs/spikes/cc-stream-json-control-protocol-2.1.150.md b/docs/spikes/cc-stream-json-control-protocol-2.1.150.md index bee7e827..52cbd4a2 100644 --- a/docs/spikes/cc-stream-json-control-protocol-2.1.150.md +++ b/docs/spikes/cc-stream-json-control-protocol-2.1.150.md @@ -52,9 +52,17 @@ One JSON object per line. Types seen: transcript: `text` / `thinking` (+`signature`) / `tool_use`(`id`,`name`,`input`). `message.usage` carries `input_tokens` + `cache_read_input_tokens` + `cache_creation_input_tokens` (→ context-token count). -- `user` — tool results Claude received: `message.content:[{type:"tool_result", - tool_use_id,content,is_error}]` plus a top-level `tool_use_result` with richer - structured data (e.g. Bash `{stdout,stderr,interrupted,…}`). +- `user` — two flavours: (a) **tool results** Claude received — + `message.content:[{type:"tool_result",tool_use_id,content,is_error}]` plus a + top-level `tool_use_result`; (b) **harness-injected user messages** — a skill + load (`Skill` tool → text begins `"Base directory for this skill:"`), a + slash-command expansion, or a system reminder. Injected ones carry + **`isSynthetic: true`** at the top level (the transcript uses `isMeta` + instead). Verified by boundary test: the inject only appears once the `Skill` + tool is actually invoked (it's auto-allowed, no prompt); if Claude just runs a + command inferred from the slash text, no inject is emitted. clide flags these + (`UserMessage.injected`) and renders them as a muted, collapsed "context" + card, not a blue "you" message. - `result` — terminal turn summary: `result` (final text), `usage`, `total_cost_usd`, `permission_denials[]`, `num_turns`, **`modelUsage..contextWindow`** (e.g. `1000000`) **and `maxOutputTokens`** — i.e. the context-window *size* IS exposed diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index 6947996c..82957ea1 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -281,6 +281,7 @@ class _ClaudePaneState extends State { child: ConversationView( controller: _conversation!, hiddenToolUseIds: _session?.promptedToolUseIds ?? const {}, + toolUseOutcomes: _session?.toolUseOutcomes ?? const {}, emptyState: ClaudeBanner( role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}', workspace: _repoRoot, diff --git a/lib/builtin/claude/src/conversation_view.dart b/lib/builtin/claude/src/conversation_view.dart index a3e509f6..cdac2191 100644 --- a/lib/builtin/claude/src/conversation_view.dart +++ b/lib/builtin/claude/src/conversation_view.dart @@ -26,15 +26,21 @@ class ConversationView extends StatefulWidget { this.wrapInSelectionArea = true, this.emptyState, this.hiddenToolUseIds = const {}, + this.toolUseOutcomes = const {}, }); 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 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 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 { 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 { 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 {}}); final ConversationItem item; final SurfaceTokens tokens; + final Map 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( diff --git a/lib/builtin/claude/src/prompt_card.dart b/lib/builtin/claude/src/prompt_card.dart index 76dd327d..ade3db72 100644 Binary files a/lib/builtin/claude/src/prompt_card.dart and b/lib/builtin/claude/src/prompt_card.dart differ diff --git a/lib/builtin/claude/src/stream_json_session.dart b/lib/builtin/claude/src/stream_json_session.dart index 31eb93a7..26e3b2ee 100644 --- a/lib/builtin/claude/src/stream_json_session.dart +++ b/lib/builtin/claude/src/stream_json_session.dart @@ -174,11 +174,17 @@ class StreamJsonSession { final _pendingCtl = StreamController.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 = {}; - /// 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 = {}; + + /// Read-only views for the conversation view. Set get promptedToolUseIds => _promptedToolUses; + Map 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()}, diff --git a/lib/builtin/claude/src/transcript_reader.dart b/lib/builtin/claude/src/transcript_reader.dart index dae2a6c1..a0058330 100644 --- a/lib/builtin/claude/src/transcript_reader.dart +++ b/lib/builtin/claude/src/transcript_reader.dart @@ -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)); } } diff --git a/test/builtin/claude/conversation_view_test.dart b/test/builtin/claude/conversation_view_test.dart index 071b261d..20caddeb 100644 --- a/test/builtin/claude/conversation_view_test.dart +++ b/test/builtin/claude/conversation_view_test.dart @@ -115,7 +115,8 @@ void main() { setUp(() async => f = await KernelFixture.create()); tearDown(() => f.dispose()); - Future pumpWith(WidgetTester tester, List items, {Set hiddenToolUseIds = const {}}) async { + Future pumpWith(WidgetTester tester, List items, + {Set hiddenToolUseIds = const {}, Map 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.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); diff --git a/test/builtin/claude/prompt_card_test.dart b/test/builtin/claude/prompt_card_test.dart index 9be7aee0..15cc0c26 100644 --- a/test/builtin/claude/prompt_card_test.dart +++ b/test/builtin/claude/prompt_card_test.dart @@ -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( diff --git a/test/builtin/claude/stream_json_session_test.dart b/test/builtin/claude/stream_json_session_test.dart index ebd3366f..3d3f8f73 100644 --- a/test/builtin/claude/stream_json_session_test.dart +++ b/test/builtin/claude/stream_json_session_test.dart @@ -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.delayed(Duration.zero); + final u = items.whereType().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.delayed(Duration.zero); + expect(items.whereType().single.injected, isFalse); + }); + test('a can_use_tool control_request becomes a pending prompt (not a conversation item)', () async { final emitted = []; 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.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.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.delayed(Duration.zero);