add per-agent permission-mode badge to the cockpit roster
Each roster row shows a D/A/P mode badge reflecting the session's live permission mode. Click cycles the safe trio default -> acceptEdits -> plan and sends a set_permission_mode control_request to that session (mirrors interrupt(); fire-and-forget). bypassPermissions is a footgun, so it is reachable only on Shift-click and behind an inline confirm. A clide.agent.set-permission-mode command gives the CLI parity. T-181. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -2287,3 +2287,6 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by,
|
||||
Refinement (2026-05-30, /whats-next): --fork-session CONFIRMED against the live binary (claude --help: "When resuming, create a new session ID instead of reusing the original (use with --resume or --continue)"). Fork argv = [''--resume'', <sourceSessionId>, ''--fork-session''] (no --session-id; the new branch gets its own id from the init event). Add forkSourceSessionId to SpawnSpec (session_orchestrator.dart); build argv via a forkSessionArgs helper in session_naming.dart; add ''fork'' to kClideOwnedCommands + a case in claude_pane._send; add a fork button to _memberRow in claude_meta_sidebar.dart. SEQUENCE AFTER T-171 (shares _memberRow + orchestrator).', NULL, '2026-05-30 17:12:17', '2026-05-30 17:12:17', '2026-05-30 17:12:17', NULL, '901eb461070872a8dcb61f21546382d6', 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-171', 'status', 'backlog', 'in_progress', NULL, '2026-05-30 17:12:46', '2026-05-30 17:12:46', '2026-05-30 17:12:46', NULL, '04314327ba504ef372a338ed737650a8', 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-174', 'status', 'backlog', 'in_progress', NULL, '2026-05-30 17:12:46', '2026-05-30 17:12:46', '2026-05-30 17:12:46', NULL, '940531017064b45bd962a8fe7369ec6d', 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-171', 'status', 'in_progress', 'done', NULL, '2026-05-30 21:11:26', '2026-05-30 21:11:26', '2026-05-30 21:11:26', NULL, '26cb27e63ad95ae94017ada95daef9d2', 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-174', 'status', 'in_progress', 'done', NULL, '2026-05-30 21:11:26', '2026-05-30 21:11:26', '2026-05-30 21:11:26', NULL, '4df20d29d49e7dd95dd9caafbe956cb2', 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-181', 'status', 'backlog', 'in_progress', NULL, '2026-05-30 21:14:56', '2026-05-30 21:14:56', '2026-05-30 21:14:56', NULL, '6426dc0355b68f0fd572e52d53101fc4', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -2854,3 +2854,10 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority,
|
||||
Make the actions always reachable per T-173''s acceptance: keep them in the tree always (reveal visually on hover OR focus via opacity), add Semantics onTap so assistive tech can activate them, and wire keyboard focus traversal + Enter/Space activation through clide''s keyboard-operability framework (the _tap helper uses a bare GestureDetector today, which is mouse/AT-tap only, not focus-traversable). Applies to both the action buttons and the caret.
|
||||
|
||||
Acceptance: copy + custom actions are reachable and activatable by keyboard alone (Tab to focus, Enter/Space to invoke) with no mouse; Semantics button labels present; widget test drives a copy via focus+activation (no hover); a11y gate green.', 'in_progress', 'medium', NULL, NULL, 'D-77', '2026-05-24 17:11:17', '2026-05-30 17:12:46', NULL, '5294925aae67ed738dd8f2096469f56c', 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-174', 'task', 'T-164', 'ConversationCard actions: keyboard-focusable + always-reachable (not hover-only)', 'Follow-up split out of T-173. The ConversationCard action bar (copy button + custom MessageActions) currently renders only while the card is hovered (lib/builtin/claude/src/conversation_card.dart, _header: ''if (_hover) ..._actions''). The collapse/expand caret is always visible, but the action buttons are not keyboard/AT reachable when not hovering.
|
||||
|
||||
Make the actions always reachable per T-173''s acceptance: keep them in the tree always (reveal visually on hover OR focus via opacity), add Semantics onTap so assistive tech can activate them, and wire keyboard focus traversal + Enter/Space activation through clide''s keyboard-operability framework (the _tap helper uses a bare GestureDetector today, which is mouse/AT-tap only, not focus-traversable). Applies to both the action buttons and the caret.
|
||||
|
||||
Acceptance: copy + custom actions are reachable and activatable by keyboard alone (Tab to focus, Enter/Space to invoke) with no mouse; Semantics button labels present; widget test drives a copy via focus+activation (no hover); a11y gate green.', 'done', 'medium', NULL, NULL, 'D-77', '2026-05-24 17:11:17', '2026-05-30 21:11:26', NULL, '2eb96628ffebd214dca72ffcd5f5da69', 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-171', 'task', 'T-164', 'P2: sidebar cockpit — task list + messages + roster controls', 'Evolve the Claude meta sidebar (T-141/T-157) from a read-only roster into the CONTROL surface for clide-managed agents. Because clide owns the broker, the task list and inter-agent messages are local data — so the sidebar both shows them live and lets the user ACT: spawn / show / hide / mute an agent, reassign a task, inject or redirect a message. Roster rows keep the live per-member status (T-157) but gain controls. Acceptance: from the sidebar the user can spawn/show/hide an agent, watch the shared task list update, see the message flow, and intervene (reassign/inject); widget tests for the controls wired to the orchestrator + broker. Blocked by T-169 (orchestrator) and T-170 (broker).', 'done', 'medium', NULL, NULL, 'D-77', '2026-05-24 16:27:09', '2026-05-30 21:11:26', NULL, '7d186872f15ef055161e51f2ae3f7479', 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-181', 'task', 'T-164', 'Per-agent permission-mode cycle badge in the cockpit roster', 'Per-agent permission-mode control in the cockpit roster (extends T-171). Each roster row shows a mode badge (D default / A acceptEdits / P plan). Click cycles the SAFE trio default -> acceptEdits -> plan and sends set_permission_mode to that agent session (a confirmed stream-json control subtype). bypassPermissions is a footgun, so it is reachable only on SHIFT-click (behind a confirm), and the tooltip documents both behaviours. Acceptance: clicking the badge cycles the safe modes and the session receives set_permission_mode; shift-click can reach bypass behind a confirm; the badge reflects the live mode (T-157 status); tooltip explains click vs shift-click; widget + transport tests. Wireframe: docs/design/wireframes/claude-prompts/05-team-cockpit-sidebar.png. Blocked by T-169 (orchestrator).', 'in_progress', 'low', NULL, NULL, 'D-77', '2026-05-25 09:16:05', '2026-05-30 21:14:56', NULL, '3e4c5116d77bfc451e05eb54d208e83d', 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);
|
||||
|
||||
@@ -68,6 +68,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
clide-managed agents: show/hide, mute, close, and inject-a-message per
|
||||
roster row, plus a live shared task list with reassign. Each action has a
|
||||
matching `clide` command (D-6 parity). (T-171, D-77)
|
||||
- Per-agent permission-mode badge in the cockpit roster — click cycles the
|
||||
safe trio default → acceptEdits → plan and sends `set_permission_mode` to
|
||||
that session; Shift-click reaches `bypassPermissions` behind a confirm.
|
||||
The badge reflects the live mode. (T-181, D-77)
|
||||
- Team coordination broker (T-170, D-77) — clide hosts an in-process MCP
|
||||
server (`clide-team`) for managed sessions over the stream-json control
|
||||
channel, giving agents tools to message each other, broadcast, see the
|
||||
|
||||
@@ -34,6 +34,7 @@ import 'package:clide/builtin/claude/src/transcript_publisher.dart' show ClaudeC
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart' show HardwareKeyboard;
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// The shared label-column width + row pitch the Activity and Config tables both
|
||||
@@ -313,6 +314,10 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
||||
final managed = _orchestrator?.byMemberName(name);
|
||||
if (managed != null) _orchestrator!.close(managed.id);
|
||||
},
|
||||
onSetPermissionMode: (name, mode) {
|
||||
final managed = _orchestrator?.byMemberName(name);
|
||||
managed?.session.setPermissionMode(mode);
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
@@ -440,15 +445,15 @@ class _MetaRow {
|
||||
/// A single agent roster row: color dot + name + status sub-text + controls.
|
||||
///
|
||||
/// Controls (trailing region):
|
||||
/// - permission-mode badge (T-181) — D/A/P cycles the safe trio; shift-click
|
||||
/// reaches bypassPermissions behind a confirm
|
||||
/// - eye / eye-slash — show / hide the session pane
|
||||
/// - speaker / speaker-slash — mute / unmute broker delivery
|
||||
/// - inject (chat icon) — expand the inline message input
|
||||
/// - close (×) — kill the session
|
||||
///
|
||||
/// Seam for T-181: add a permission-mode badge between the status sub-text and
|
||||
/// the trailing controls — it needs no layout changes here.
|
||||
/// Seam for T-172: add a fork button to the _buildControls row.
|
||||
class _AgentRosterRow extends StatelessWidget {
|
||||
class _AgentRosterRow extends StatefulWidget {
|
||||
const _AgentRosterRow({
|
||||
super.key,
|
||||
required this.member,
|
||||
@@ -459,6 +464,7 @@ class _AgentRosterRow extends StatelessWidget {
|
||||
required this.onToggleInject,
|
||||
required this.onInjectSubmit,
|
||||
required this.onClose,
|
||||
required this.onSetPermissionMode,
|
||||
});
|
||||
|
||||
final TeamMemberJoined member;
|
||||
@@ -475,15 +481,28 @@ class _AgentRosterRow extends StatelessWidget {
|
||||
final void Function(String memberName, String text) onInjectSubmit;
|
||||
final void Function(String memberName) onClose;
|
||||
|
||||
/// Called when the badge cycles to a new [mode] string for this member.
|
||||
/// Handles both safe-trio clicks and confirmed bypass. The parent sends
|
||||
/// the mode to the session via [StreamJsonSession.setPermissionMode].
|
||||
final void Function(String memberName, String mode) onSetPermissionMode;
|
||||
|
||||
@override
|
||||
State<_AgentRosterRow> createState() => _AgentRosterRowState();
|
||||
}
|
||||
|
||||
class _AgentRosterRowState extends State<_AgentRosterRow> {
|
||||
/// Whether the bypass-confirm inline prompt is showing.
|
||||
bool _confirmingBypass = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final managed = orchestrator?.byMemberName(member.name);
|
||||
final color = teamColor(member.color, fallback: tokens.globalForeground);
|
||||
final st = status;
|
||||
final model = st?.model ?? member.model;
|
||||
final managed = widget.orchestrator?.byMemberName(widget.member.name);
|
||||
final color = teamColor(widget.member.color, fallback: tokens.globalForeground);
|
||||
final st = widget.status;
|
||||
final model = st?.model ?? widget.member.model;
|
||||
final sub = [
|
||||
member.agentType,
|
||||
widget.member.agentType,
|
||||
if (model != null) shortModelLabel(model),
|
||||
if (st?.permissionMode != null) permissionModeLabel(st!.permissionMode!),
|
||||
if (st?.contextTokens != null) '${formatTokenCount(st!.contextTokens!)} ctx',
|
||||
@@ -491,7 +510,8 @@ class _AgentRosterRow extends StatelessWidget {
|
||||
|
||||
final isVisible = managed?.visible ?? true;
|
||||
final isMuted = managed?.muted ?? false;
|
||||
final isInjecting = injectingAgentId == member.name;
|
||||
final isInjecting = widget.injectingAgentId == widget.member.name;
|
||||
final currentMode = st?.permissionMode ?? 'default';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
@@ -512,9 +532,19 @@ class _AgentRosterRow extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(member.name, fontSize: clideFontSmall, color: tokens.globalForeground, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
ClideText(widget.member.name, fontSize: clideFontSmall, color: tokens.globalForeground, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
if (sub.isNotEmpty) ClideText(sub, muted: true, fontSize: clideFontSmall, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
// T-181 seam: add permission-mode badge here (no layout surgery needed).
|
||||
// T-181: permission-mode badge (inline below the status sub-text).
|
||||
if (managed != null)
|
||||
_PermissionModeBadge(
|
||||
mode: currentMode,
|
||||
tokens: tokens,
|
||||
onCycle: () {
|
||||
final next = _nextSafeMode(currentMode);
|
||||
widget.onSetPermissionMode(widget.member.name, next);
|
||||
},
|
||||
onBypass: () => setState(() => _confirmingBypass = true),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -524,8 +554,72 @@ class _AgentRosterRow extends StatelessWidget {
|
||||
if (managed != null) _buildControls(context, tokens, managed, isVisible, isMuted, isInjecting),
|
||||
],
|
||||
),
|
||||
// Bypass confirm: replaces inject field area when active.
|
||||
if (_confirmingBypass) _buildBypassConfirm(tokens),
|
||||
// Inline inject-message field — visible only when toggled.
|
||||
if (isInjecting) _buildInjectField(context, tokens),
|
||||
if (isInjecting && !_confirmingBypass) _buildInjectField(context, tokens),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Safe-mode cycle: default → acceptEdits → plan → default (T-181).
|
||||
static String _nextSafeMode(String current) {
|
||||
const cycle = ['default', 'acceptEdits', 'plan'];
|
||||
final idx = cycle.indexOf(current);
|
||||
return cycle[(idx + 1) % cycle.length];
|
||||
}
|
||||
|
||||
Widget _buildBypassConfirm(SurfaceTokens tokens) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
'Enable bypassPermissions? All tool calls will be auto-allowed.',
|
||||
fontSize: clideFontSmall,
|
||||
color: tokens.globalTextMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// Confirm
|
||||
Semantics(
|
||||
button: true,
|
||||
label: 'Confirm bypass',
|
||||
excludeSemantics: true,
|
||||
onTap: () {
|
||||
setState(() => _confirmingBypass = false);
|
||||
widget.onSetPermissionMode(widget.member.name, 'bypassPermissions');
|
||||
},
|
||||
child: ClideTappable(
|
||||
tooltip: 'Confirm',
|
||||
onTap: () {
|
||||
setState(() => _confirmingBypass = false);
|
||||
widget.onSetPermissionMode(widget.member.name, 'bypassPermissions');
|
||||
},
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2),
|
||||
child: ClideText('OK', fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.globalFocus),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// Cancel
|
||||
Semantics(
|
||||
button: true,
|
||||
label: 'Cancel bypass',
|
||||
excludeSemantics: true,
|
||||
onTap: () => setState(() => _confirmingBypass = false),
|
||||
child: ClideTappable(
|
||||
tooltip: 'Cancel',
|
||||
onTap: () => setState(() => _confirmingBypass = false),
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2),
|
||||
child: ClideText('Cancel', fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -547,7 +641,7 @@ class _AgentRosterRow extends StatelessWidget {
|
||||
painter: isVisible ? PhosphorIcons.eye : PhosphorIcons.eyeSlash,
|
||||
tooltip: isVisible ? 'Hide pane' : 'Show pane',
|
||||
color: tokens.globalTextMuted,
|
||||
onTap: () => isVisible ? orchestrator!.hide(managed.id) : orchestrator!.show(managed.id),
|
||||
onTap: () => isVisible ? widget.orchestrator!.hide(managed.id) : widget.orchestrator!.show(managed.id),
|
||||
),
|
||||
// Mute / unmute
|
||||
_IconButton(
|
||||
@@ -557,21 +651,21 @@ class _AgentRosterRow extends StatelessWidget {
|
||||
// The semantic tooltip still says mute/unmute so AT users are clear.
|
||||
tooltip: isMuted ? 'Unmute messages' : 'Mute messages',
|
||||
color: isMuted ? tokens.globalFocus : tokens.globalTextMuted,
|
||||
onTap: () => isMuted ? orchestrator!.unmute(managed.id) : orchestrator!.mute(managed.id),
|
||||
onTap: () => isMuted ? widget.orchestrator!.unmute(managed.id) : widget.orchestrator!.mute(managed.id),
|
||||
),
|
||||
// Inject message
|
||||
_IconButton(
|
||||
painter: PhosphorIcons.chatCircle,
|
||||
tooltip: 'Inject message',
|
||||
color: isInjecting ? tokens.globalFocus : tokens.globalTextMuted,
|
||||
onTap: () => onToggleInject(member.name),
|
||||
onTap: () => widget.onToggleInject(widget.member.name),
|
||||
),
|
||||
// Close session
|
||||
_IconButton(
|
||||
painter: PhosphorIcons.xMark,
|
||||
tooltip: 'Close session',
|
||||
color: tokens.globalTextMuted,
|
||||
onTap: () => onClose(member.name),
|
||||
onTap: () => widget.onClose(widget.member.name),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -584,10 +678,10 @@ class _AgentRosterRow extends StatelessWidget {
|
||||
children: [
|
||||
Expanded(
|
||||
child: _InjectTextField(
|
||||
controller: injectController,
|
||||
controller: widget.injectController,
|
||||
tokens: tokens,
|
||||
onSubmit: (text) {
|
||||
if (text.trim().isNotEmpty) onInjectSubmit(member.name, text.trim());
|
||||
if (text.trim().isNotEmpty) widget.onInjectSubmit(widget.member.name, text.trim());
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -596,7 +690,7 @@ class _AgentRosterRow extends StatelessWidget {
|
||||
painter: PhosphorIcons.xMark,
|
||||
tooltip: 'Cancel',
|
||||
color: tokens.globalTextMuted,
|
||||
onTap: () => onToggleInject(member.name),
|
||||
onTap: () => widget.onToggleInject(widget.member.name),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -604,6 +698,95 @@ class _AgentRosterRow extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Permission-mode badge (T-181)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Maps a permission-mode string to a single-letter badge label.
|
||||
String _permissionModeBadge(String mode) => switch (mode) {
|
||||
'acceptEdits' => 'A',
|
||||
'plan' => 'P',
|
||||
'bypassPermissions' => 'B',
|
||||
_ => 'D', // default
|
||||
};
|
||||
|
||||
/// Clickable permission-mode badge shown in each roster row (T-181).
|
||||
///
|
||||
/// - Plain click → cycles the safe trio: default → acceptEdits → plan → default.
|
||||
/// - Shift-click → shows the bypass confirm inline in the parent row.
|
||||
///
|
||||
/// The badge reflects the LIVE mode from [SessionStatus.permissionMode] (T-157).
|
||||
/// It is a custom painted label (no Material), consistent with the rendering
|
||||
/// stack rules (D-7, CLAUDE.md guardrails).
|
||||
class _PermissionModeBadge extends StatelessWidget {
|
||||
const _PermissionModeBadge({
|
||||
required this.mode,
|
||||
required this.tokens,
|
||||
required this.onCycle,
|
||||
required this.onBypass,
|
||||
});
|
||||
|
||||
final String mode;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
/// Called on a plain click — the parent cycles to the next safe mode.
|
||||
final VoidCallback onCycle;
|
||||
|
||||
/// Called on a shift-click — the parent shows the bypass confirm.
|
||||
final VoidCallback onBypass;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final label = _permissionModeBadge(mode);
|
||||
final isBypass = mode == 'bypassPermissions';
|
||||
final badgeColor = isBypass ? const Color(0xFFF06C6F) : tokens.globalFocus;
|
||||
|
||||
final tooltip = 'Permission mode: ${permissionModeLabel(mode)}. '
|
||||
'Click to cycle default/acceptEdits/plan; Shift-click for bypassPermissions.';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 3),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: 'Permission mode: $label',
|
||||
excludeSemantics: true,
|
||||
onTap: () {
|
||||
if (HardwareKeyboard.instance.isShiftPressed) {
|
||||
onBypass();
|
||||
} else {
|
||||
onCycle();
|
||||
}
|
||||
},
|
||||
child: ClideTappable(
|
||||
tooltip: tooltip,
|
||||
onTap: () {
|
||||
if (HardwareKeyboard.instance.isShiftPressed) {
|
||||
onBypass();
|
||||
} else {
|
||||
onCycle();
|
||||
}
|
||||
},
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
width: 16,
|
||||
height: 14,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: badgeColor.withAlpha(hovered ? 51 : 26),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
border: Border.all(color: badgeColor.withAlpha(hovered ? 180 : 100), width: 1),
|
||||
),
|
||||
child: ClideText(
|
||||
label,
|
||||
fontSize: 9,
|
||||
color: badgeColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A single icon-button used in the roster row controls.
|
||||
class _IconButton extends StatelessWidget {
|
||||
const _IconButton({
|
||||
|
||||
@@ -139,6 +139,28 @@ class ClaudeExtension extends ClideExtension {
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'injected'});
|
||||
},
|
||||
),
|
||||
// T-181: set permission mode for an agent session (D-6 CLI/UI parity).
|
||||
// Usage: clide claude.agent.set-permission-mode <sessionId> <mode>
|
||||
// <mode> must be one of: default, acceptEdits, plan, bypassPermissions.
|
||||
// Note: bypassPermissions is accepted via CLI — the footgun guard is the
|
||||
// UI's confirm dialog; the CLI caller is responsible for their own safety.
|
||||
CommandContribution(
|
||||
id: 'claude.agent.set-permission-mode',
|
||||
command: 'claude.agent.set-permission-mode',
|
||||
title: 'Claude: set permission mode for an agent session',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
final mode = args.length >= 2 ? args[1] : null;
|
||||
if (mode == null) return IpcResponse.ok(id: '', data: const {'error': 'missing mode (default|acceptEdits|plan|bypassPermissions)'});
|
||||
const valid = {'default', 'acceptEdits', 'plan', 'bypassPermissions'};
|
||||
if (!valid.contains(mode)) {
|
||||
return IpcResponse.ok(id: '', data: {'error': 'unknown mode "$mode"; use one of: ${valid.join(', ')}'});
|
||||
}
|
||||
_orchestrator?.byId(id)?.session.setPermissionMode(mode);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'mode': mode, 'status': 'sent'});
|
||||
},
|
||||
),
|
||||
// Usage: clide claude.task.reassign <taskId> <toSessionId>
|
||||
CommandContribution(
|
||||
id: 'claude.task.reassign',
|
||||
|
||||
@@ -592,6 +592,22 @@ class StreamJsonSession {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Set the session's permission mode (T-181, D-77). Sends a
|
||||
/// `set_permission_mode` control_request; fire-and-forget, mirroring
|
||||
/// [interrupt]. [mode] must be one of the claude-recognised strings:
|
||||
/// `default`, `acceptEdits`, `plan`, `bypassPermissions`.
|
||||
///
|
||||
/// The safe trio (default → acceptEdits → plan → default) is cycled by the
|
||||
/// cockpit badge's plain click; bypassPermissions is reachable only via a
|
||||
/// confirmed shift-click (T-181).
|
||||
void setPermissionMode(String mode) {
|
||||
_proc.writeLine(jsonEncode({
|
||||
'type': 'control_request',
|
||||
'request_id': 'set-perm-${_localSeq++}',
|
||||
'request': {'subtype': 'set_permission_mode', 'mode': mode},
|
||||
}));
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await _sub?.cancel();
|
||||
await _proc.kill();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/claude/src/claude_config.dart';
|
||||
@@ -9,6 +10,7 @@ import 'package:clide/builtin/claude/src/stream_json_session.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_publisher.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/services.dart' show LogicalKeyboardKey;
|
||||
import 'package:flutter/widgets.dart' show EditableText, SizedBox, Semantics;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
@@ -23,11 +25,18 @@ class _FakeProc implements StreamJsonProcess {
|
||||
final List<String> writes = [];
|
||||
bool killed = false;
|
||||
|
||||
/// Optional callback fired on every [writeLine] — used by T-181 tests to
|
||||
/// mirror writes into a shared list across sessions.
|
||||
void Function(String)? onWrite;
|
||||
|
||||
@override
|
||||
Stream<String> get lines => _ctl.stream;
|
||||
|
||||
@override
|
||||
void writeLine(String line) => writes.add(line);
|
||||
void writeLine(String line) {
|
||||
writes.add(line);
|
||||
onWrite?.call(line);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> kill() async => killed = true;
|
||||
@@ -448,4 +457,171 @@ void main() {
|
||||
orch.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
// T-181: permission-mode badge -----------------------------------------------
|
||||
|
||||
group('T-181 permission-mode badge', () {
|
||||
/// Build an orchestrator that captures all writes from the spawned session's
|
||||
/// stdin. The factory captures the fake proc and mirrors its writeLine calls
|
||||
/// into [writes] so tests can assert on control_requests sent.
|
||||
(ClaudeSessionOrchestrator, List<String>) orchCapturing() {
|
||||
final writes = <String>[];
|
||||
final orch = ClaudeSessionOrchestrator(
|
||||
processFactory: ({required sessionArgs, required cwd, env}) async {
|
||||
final p = _FakeProc();
|
||||
p.onWrite = writes.add;
|
||||
return p;
|
||||
},
|
||||
);
|
||||
return (orch, writes);
|
||||
}
|
||||
|
||||
Future<(ClaudeSessionOrchestrator, List<String>)> spawnAndShow(
|
||||
WidgetTester tester, {
|
||||
String name = 'Scout',
|
||||
String agentId = 'b1',
|
||||
}) async {
|
||||
final (orch, writes) = orchCapturing();
|
||||
await orch.spawn(SpawnSpec(
|
||||
id: 'teammate:$name',
|
||||
role: 'teammate',
|
||||
sessionId: '$name-uuid',
|
||||
cwd: '/repo',
|
||||
team: true,
|
||||
memberName: name,
|
||||
));
|
||||
await tester.pumpWidget(harness(f, sidebar(orchestrator: orch, initialTab: SidebarTab.team)));
|
||||
f.services.events.emit(TeamMemberJoined(
|
||||
team: 't',
|
||||
agentId: agentId,
|
||||
name: name,
|
||||
agentType: 'coder',
|
||||
paneId: '%1',
|
||||
color: 'blue',
|
||||
));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
return (orch, writes);
|
||||
}
|
||||
|
||||
testWidgets('badge renders with label D when permissionMode is null/default', (tester) async {
|
||||
final semantics = tester.ensureSemantics();
|
||||
final (orch, _) = await spawnAndShow(tester);
|
||||
|
||||
// The badge Semantics label is 'Permission mode: D' for the default mode.
|
||||
expect(find.bySemanticsLabel('Permission mode: D'), findsOneWidget);
|
||||
|
||||
semantics.dispose();
|
||||
orch.dispose();
|
||||
});
|
||||
|
||||
testWidgets('badge label reflects live permissionMode from status (A for acceptEdits)', (tester) async {
|
||||
final semantics = tester.ensureSemantics();
|
||||
final (orch, _) = await spawnAndShow(tester);
|
||||
|
||||
// Push a live status update with acceptEdits.
|
||||
f.services.messages.publish(
|
||||
ClaudeConversation.publisher,
|
||||
ClaudeConversation.memberStatusChannel,
|
||||
ClaudeConversation.memberStatusData(
|
||||
'b1',
|
||||
const SessionStatus(permissionMode: 'acceptEdits'),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.bySemanticsLabel('Permission mode: A'), findsOneWidget);
|
||||
|
||||
semantics.dispose();
|
||||
orch.dispose();
|
||||
});
|
||||
|
||||
testWidgets('plain click cycles default → acceptEdits and writes set_permission_mode', (tester) async {
|
||||
final semantics = tester.ensureSemantics();
|
||||
final (orch, writes) = await spawnAndShow(tester);
|
||||
|
||||
final preCount = writes.length;
|
||||
|
||||
await tester.tap(find.bySemanticsLabel('Permission mode: D').first);
|
||||
await tester.pump();
|
||||
|
||||
// One new write for the set_permission_mode control_request.
|
||||
expect(writes.length, preCount + 1);
|
||||
final sent = jsonDecode(writes.last) as Map<String, dynamic>;
|
||||
expect(sent['type'], 'control_request');
|
||||
expect((sent['request'] as Map)['subtype'], 'set_permission_mode');
|
||||
expect((sent['request'] as Map)['mode'], 'acceptEdits');
|
||||
|
||||
semantics.dispose();
|
||||
orch.dispose();
|
||||
});
|
||||
|
||||
testWidgets('shift-click shows the bypass confirm inline', (tester) async {
|
||||
final semantics = tester.ensureSemantics();
|
||||
final (orch, _) = await spawnAndShow(tester);
|
||||
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
|
||||
await tester.tap(find.bySemanticsLabel('Permission mode: D').first);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
|
||||
await tester.pump();
|
||||
|
||||
// The inline confirm prompt should be visible.
|
||||
expect(find.text('Enable bypassPermissions? All tool calls will be auto-allowed.'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Confirm bypass'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Cancel bypass'), findsOneWidget);
|
||||
|
||||
semantics.dispose();
|
||||
orch.dispose();
|
||||
});
|
||||
|
||||
testWidgets('bypass confirm OK sends bypassPermissions and dismisses the prompt', (tester) async {
|
||||
final semantics = tester.ensureSemantics();
|
||||
final (orch, writes) = await spawnAndShow(tester);
|
||||
|
||||
final preCount = writes.length;
|
||||
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
|
||||
await tester.tap(find.bySemanticsLabel('Permission mode: D').first);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.bySemanticsLabel('Confirm bypass').first);
|
||||
await tester.pump();
|
||||
|
||||
// Prompt is gone.
|
||||
expect(find.text('Enable bypassPermissions? All tool calls will be auto-allowed.'), findsNothing);
|
||||
|
||||
// bypassPermissions was sent to the session.
|
||||
expect(writes.length, preCount + 1);
|
||||
final sent = jsonDecode(writes.last) as Map<String, dynamic>;
|
||||
expect((sent['request'] as Map)['subtype'], 'set_permission_mode');
|
||||
expect((sent['request'] as Map)['mode'], 'bypassPermissions');
|
||||
|
||||
semantics.dispose();
|
||||
orch.dispose();
|
||||
});
|
||||
|
||||
testWidgets('bypass confirm Cancel dismisses without sending', (tester) async {
|
||||
final semantics = tester.ensureSemantics();
|
||||
final (orch, writes) = await spawnAndShow(tester);
|
||||
|
||||
final preCount = writes.length;
|
||||
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
|
||||
await tester.tap(find.bySemanticsLabel('Permission mode: D').first);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.bySemanticsLabel('Cancel bypass').first);
|
||||
await tester.pump();
|
||||
|
||||
// Prompt dismissed, no extra write.
|
||||
expect(find.text('Enable bypassPermissions? All tool calls will be auto-allowed.'), findsNothing);
|
||||
expect(writes.length, preCount);
|
||||
|
||||
semantics.dispose();
|
||||
orch.dispose();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -491,6 +491,26 @@ void main() {
|
||||
expect(sent['request_id'], isNotNull);
|
||||
});
|
||||
|
||||
test('setPermissionMode writes a set_permission_mode control_request (T-181)', () async {
|
||||
session.setPermissionMode('acceptEdits');
|
||||
expect(proc.writes, hasLength(1));
|
||||
final sent = jsonDecode(proc.writes.single) as Map<String, dynamic>;
|
||||
expect(sent['type'], 'control_request');
|
||||
expect(sent['request_id'], isNotNull);
|
||||
final req = sent['request'] as Map;
|
||||
expect(req['subtype'], 'set_permission_mode');
|
||||
expect(req['mode'], 'acceptEdits');
|
||||
});
|
||||
|
||||
test('setPermissionMode uses a unique request_id each call (T-181)', () async {
|
||||
session.setPermissionMode('plan');
|
||||
session.setPermissionMode('default');
|
||||
expect(proc.writes, hasLength(2));
|
||||
final id1 = (jsonDecode(proc.writes[0]) as Map)['request_id'] as String;
|
||||
final id2 = (jsonDecode(proc.writes[1]) as Map)['request_id'] as String;
|
||||
expect(id1, isNot(equals(id2)));
|
||||
});
|
||||
|
||||
test('busy goes true on send and false on a result event', () async {
|
||||
final busy = <bool>[];
|
||||
session.busyStream.listen(busy.add);
|
||||
|
||||
Reference in New Issue
Block a user