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:
@@ -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;
|
||||
|
||||
@@ -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 <command-name>/<command-message>/<command-args>, system reminders in <system-reminder>). 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);
|
||||
|
||||
+15
-10
@@ -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
|
||||
|
||||
@@ -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.<model>.contextWindow`** (e.g.
|
||||
`1000000`) **and `maxOutputTokens`** — i.e. the context-window *size* IS exposed
|
||||
|
||||
@@ -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,16 +152,31 @@ 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(
|
||||
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,
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user