diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 2b6146e5..d6756a35 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -4326,3 +4326,5 @@ accent marks per ui-design tokens), and make the controls feel like controls. Treat ui-design skill as the reference for token/type choices.', NULL, '2026-06-12 10:24:43', '2026-06-12 10:24:43', '2026-06-12 10:24:43', NULL, '5e15c4d2cdabba62bbaba701a93f6e79', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3GM6V0RZBY2PXE9ZQFR88', 'status', 'in_progress', 'done', NULL, '2026-06-12 10:25:53', '2026-06-12 10:25:53', '2026-06-12 10:25:53', NULL, 'f65cf1d99c1d22ced8d18e1579b048ad', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3J7TXMG0F9E2WQDENPVJG', 'status', 'backlog', 'in_progress', NULL, '2026-06-12 10:25:53', '2026-06-12 10:25:53', '2026-06-12 10:25:53', NULL, 'dca846f23cb4353cc826ddd79a6c14f0', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3J7TXMG0F9E2WQDENPVJG', 'status', 'in_progress', 'done', NULL, '2026-06-12 10:56:52', '2026-06-12 10:56:52', '2026-06-12 10:56:52', NULL, 'fd7c4c5f0b0de9144c6220a4ce43476b', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3KRWM65MD3DS251NN9YX0', 'status', 'backlog', 'in_progress', NULL, '2026-06-12 10:56:52', '2026-06-12 10:56:52', '2026-06-12 10:56:52', NULL, 'd1eb3f87ceb5508e4dac1f191750f852', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 9bdd9a10..092b1de8 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -4711,3 +4711,9 @@ Where: slash_commands.dart (table), claude_pane._send (dispatch), stream_json_se INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3J7TXMG0F9E2WQDENPVJG', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Own /effort: control-request spike + arg/picker UX', 'Mirror T-408''s /model treatment for /effort (thinking-effort level). SPIKE FIRST (same method as the T-408 set_model spike against claude 2.1.175): does the stream-json control channel accept a set_effort-style control_request subtype? Probe candidate subtypes alongside set_permission_mode/set_model; document the result in the ticket. If no control subtype exists, decide a fallback explicitly — settings-file write + session restart, or declare it unavailable-with-explanation — don''t fake it. UX when supported: ''/effort '' sets directly (optimistic status merge, toast rollback on rejection, exactly the T-408 pattern); bare ''/effort'' swaps a picker card into the interaction zone (D-78). Surface the current effort in SessionStatus + the sidebar config row, and as an inline selector per the power-panel story. Levels per CLI docs (e.g. low/medium/high; verify against the CLI version).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-12 08:58:55', '2026-06-12 10:25:53', NULL, 'd0b0dbfec1edcdd4ffe78372f440cf1b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3J7TXMG0F9E2WQDENPVJG', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Own /effort: control-request spike + arg/picker UX', 'Mirror T-408''s /model treatment for /effort (thinking-effort level). SPIKE FIRST (same method as the T-408 set_model spike against claude 2.1.175): does the stream-json control channel accept a set_effort-style control_request subtype? Probe candidate subtypes alongside set_permission_mode/set_model; document the result in the ticket. If no control subtype exists, decide a fallback explicitly — settings-file write + session restart, or declare it unavailable-with-explanation — don''t fake it. + +UX when supported: ''/effort '' sets directly (optimistic status merge, toast rollback on rejection, exactly the T-408 pattern); bare ''/effort'' swaps a picker card into the interaction zone (D-78). Surface the current effort in SessionStatus + the sidebar config row, and as an inline selector per the power-panel story. Levels per CLI docs (e.g. low/medium/high; verify against the CLI version).', 'done', 'medium', NULL, NULL, NULL, '2026-06-12 08:58:55', '2026-06-12 10:56:52', NULL, '448346b2ab034e4560d9b9589e318344', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3KRWM65MD3DS251NN9YX0', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Open-in-clide command family: /permissions /config /memory /mcp /agents /todos /status /help', 'The ''owned'' routes that map a TUI command to an existing clide surface — thin per-command glue, one story. /permissions → open the permission-mode picker (T-275 control). /config → focus the Claude sidebar Config tab (T-183). /memory → open CLAUDE.md (and memory files) in the editor. /mcp, /agents, /hooks → their Config-tab sections. /todos → focus the task dock (T-308). /status → focus the sidebar Activity tab. /help → a clide help card listing owned commands + advertised skills (NOT the CLI''s TUI help). + +Each lands in the routing table (T-411) as ''owned''; D-6 parity notes: these are also the navigation targets the catch-all hint card (T-411) points at. Keep each handler a few lines — navigation/publish on the existing MessageBus + panel APIs, no new surfaces. Tests: each command routes to its surface (message published / tab activated), none forwards to the session.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-12 08:59:08', '2026-06-12 10:56:52', NULL, '395cba57154e147b6a35212e23e2d17e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 942223f3..70ac875e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- **The TUI command family opens clide surfaces.** `/permissions` sets the mode + directly or opens a picker; `/status`, `/config`, `/mcp`, `/agents`, `/hooks` + jump to the matching Claude sidebar tab; `/memory` opens CLAUDE.md in the + editor; `/help` shows clide's own command summary. (T-413) - **`/effort` works in the Claude pane.** With a level (`/effort xhigh`) the session restarts in place carrying `--effort` — resume keeps the conversation; bare `/effort` opens a picker with the five levels and the diff --git a/lib/builtin/claude/src/claude_meta_sidebar.dart b/lib/builtin/claude/src/claude_meta_sidebar.dart index 0a76adb0..00d1a5c5 100644 --- a/lib/builtin/claude/src/claude_meta_sidebar.dart +++ b/lib/builtin/claude/src/claude_meta_sidebar.dart @@ -83,6 +83,7 @@ class _ClaudeMetaSidebarState extends State { StreamSubscription? _joinSub; StreamSubscription? _leftSub; StreamSubscription? _statusSub; + StreamSubscription? _tabSub; StreamSubscription? _primarySub; StreamSubscription? _brokerChangeSub; Timer? _timer; @@ -163,6 +164,13 @@ class _ClaudeMetaSidebarState extends State { _memberStatus.remove(m.agentId); }); }); + // Slash-command navigation (T-413): /status, /config, /mcp, … publish a + // meta.tab message; switch the sub-tab to match. + _tabSub = kernel.messages.subscribe(publisher: 'builtin.claude', channel: 'meta.tab').listen((msg) { + final name = msg.data['tab'] as String?; + final tab = SidebarTab.values.where((t) => t.name == name).firstOrNull; + if (tab != null && mounted) setState(() => _tab = tab); + }); // Live per-member status forwarded by the observer (T-157). _statusSub = kernel.messages.subscribe(channel: ClaudeConversation.memberStatusChannel).listen((msg) { final agentId = msg.data['agentId'] as String?; @@ -247,6 +255,7 @@ class _ClaudeMetaSidebarState extends State { _joinSub?.cancel(); _leftSub?.cancel(); _statusSub?.cancel(); + _tabSub?.cancel(); _primarySub?.cancel(); _brokerChangeSub?.cancel(); _injectCtl.dispose(); diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index 69e4bfb7..cd888481 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -92,6 +92,7 @@ class _ClaudePaneState extends State { /// resolves. bool _modelPickerOpen = false; bool _effortPickerOpen = false; + bool _permissionPickerOpen = false; /// Effort level this pane's session runs at (`--effort`, T-412). Null = /// the CLI default. Set by /effort; carried by every respawn. @@ -255,6 +256,7 @@ class _ClaudePaneState extends State { _modelErrorSub = null; _modelPickerOpen = false; _effortPickerOpen = false; + _permissionPickerOpen = false; await activeSessionOrchestrator?.close(_orchId); // kills the old repo's session _conversation = null; _session = null; @@ -417,6 +419,24 @@ class _ClaudePaneState extends State { case 'effort': _effortCommand(slashCommandArg(text) ?? ''); return; + case 'permissions': + _permissionsCommand(slashCommandArg(text) ?? ''); + return; + case 'status': + _openMetaTab('activity'); + return; + case 'config': + case 'mcp': + case 'agents': + case 'hooks': + _openMetaTab('config'); + return; + case 'memory': + _openMemory(); + return; + case 'help': + _helpCommand(); + return; } // Route the rest (T-411): a known TUI-only builtin never reaches the // session — forwarded it would error (or, un-advertised, bracket-paste to @@ -486,6 +506,59 @@ class _ClaudePaneState extends State { unawaited(_respawnWithSession(sid)); } + /// clide-owned `/permissions` (T-413): with a mode, set it directly over + /// set_permission_mode; bare, open a picker — the same interaction-zone + /// pattern as /model and /effort. + void _permissionsCommand(String arg) { + final s = _session; + if (s == null) return; + if (arg.isEmpty) { + setState(() => _permissionPickerOpen = true); + return; + } + if (!kPermissionModes.any((m) => m.value == arg)) { + s.addLocalNotice('unknown permission mode "$arg" — modes: ${kPermissionModes.map((m) => m.value).join(', ')}'); + return; + } + s.setPermissionMode(arg); + } + + void _pickPermissionMode(String value) { + _closePermissionPicker(); + _session?.setPermissionMode(value); + } + + void _closePermissionPicker() { + setState(() => _permissionPickerOpen = false); + _composerFocus.requestFocus(); + } + + /// Navigate to the Claude sidebar and select a sub-tab (T-413): the + /// /status//config//mcp//agents//hooks commands land here. + void _openMetaTab(String tab) { + final k = _kernel; + if (k == null) return; + k.panels.activateTab(Slots.sidebar, 'claude.meta'); + k.messages.publish('builtin.claude', 'meta.tab', {'tab': tab}); + } + + /// clide-owned `/memory` (T-413): open the workspace CLAUDE.md in the editor. + void _openMemory() { + final root = _repoRoot; + if (root == null) return; + unawaited(_ipc()?.request('editor.open', args: {'path': '$root/CLAUDE.md'})); + } + + /// clide-owned `/help` (T-413): a local summary card — never the CLI's TUI + /// help, which doesn't exist headless. + void _helpCommand() { + final advertised = (activeClaudeConfig?.slashCommands ?? kFallbackSlashCommands).where((c) => !kClideOwnedCommands.contains(c)).toList()..sort(); + _session?.addLocalNotice( + 'clide commands: ${(kClideOwnedCommands.toList()..sort()).map((c) => '/$c').join(' ')}\n' + 'claude commands & skills: ${advertised.map((c) => '/$c').join(' ')}', + ); + } + /// Record a submitted prompt in the active session's history (T-163), /// de-duping immediate repeats. Empty/whitespace prompts are skipped. void _appendHistory(String text) { @@ -510,7 +583,7 @@ class _ClaudePaneState extends State { /// background tap must never pull focus from (or resurrect) the composer /// over an open prompt. void _focusComposerOnTap() { - if (_session?.pendingPrompt != null || _modelPickerOpen || _effortPickerOpen) return; + if (_session?.pendingPrompt != null || _modelPickerOpen || _effortPickerOpen || _permissionPickerOpen) return; _composerFocus.requestFocus(); } @@ -586,6 +659,7 @@ class _ClaudePaneState extends State { _modelErrorSub = null; _modelPickerOpen = false; _effortPickerOpen = false; + _permissionPickerOpen = false; await activeSessionOrchestrator?.close(_orchId); // kills the old session // Erase only after the process is dead, so claude isn't mid-write. final root = _repoRoot; @@ -678,6 +752,15 @@ class _ClaudePaneState extends State { onPick: _pickEffort, onCancel: _closeEffortPicker, ) + else if (_permissionPickerOpen && _session != null) + ModelPickerCard( + title: 'permissions', + models: kPermissionModes, + currentModel: _status.permissionMode, + isCurrent: (o, c) => c != null && o.value == c, + onPick: _pickPermissionMode, + onCancel: _closePermissionPicker, + ) else StreamBuilder( stream: _session?.busyStream, diff --git a/lib/builtin/claude/src/slash_commands.dart b/lib/builtin/claude/src/slash_commands.dart index d81b9acc..f981654f 100644 --- a/lib/builtin/claude/src/slash_commands.dart +++ b/lib/builtin/claude/src/slash_commands.dart @@ -34,8 +34,25 @@ bool isKnownSlashCommand(String text, Iterable known) { /// is interactive in the CLI's TUI only — forwarded it does nothing — so /// clide owns it as a set_model control request / picker (T-408). `/effort` /// has no control subtype, so clide owns it as a respawn-with-resume -/// carrying `--effort` (T-412). -const Set kClideOwnedCommands = {'clear', 'resume', 'fork', 'model', 'effort'}; +/// carrying `--effort` (T-412). `/permissions` is a picker over +/// set_permission_mode; the rest navigate to clide surfaces (T-413): +/// /status//config//mcp//agents//hooks → the Claude sidebar tabs, +/// /memory → CLAUDE.md in the editor, /help → a local command summary. +const Set kClideOwnedCommands = { + 'clear', + 'resume', + 'fork', + 'model', + 'effort', + 'permissions', + 'status', + 'config', + 'mcp', + 'agents', + 'hooks', + 'memory', + 'help', +}; /// The clide-owned command in [text] (a single-line leading-slash token in /// [kClideOwnedCommands]), or null. @@ -67,16 +84,16 @@ enum SlashRoute { /// notice card. Commands clide later implements move to [kClideOwnedCommands]. const Map kTuiOnlyCommands = { 'effort': '', // owned (T-412) — only routes here if ever removed from owned - 'status': 'session status lives in the Claude sidebar (Activity tab)', + 'status': '', // owned (T-413) 'cost': 'cost and context usage live in the Claude sidebar (Activity tab)', 'context': '', // advertised on current CLIs — only routes here on older ones - 'help': 'type / to browse commands; clide owns /clear /resume /fork /model', - 'config': 'open the Claude sidebar Config tab', - 'permissions': 'use the permission-mode control beside the composer', - 'memory': 'open CLAUDE.md in the editor', - 'mcp': 'MCP servers are listed in the Claude sidebar Config tab', - 'agents': 'agents are listed in the Claude sidebar Config tab', - 'hooks': 'hooks are listed in the Claude sidebar Config tab', + 'help': '', // owned (T-413) + 'config': '', // owned (T-413) + 'permissions': '', // owned (T-413) + 'memory': '', // owned (T-413) + 'mcp': '', // owned (T-413) + 'agents': '', // owned (T-413) + 'hooks': '', // owned (T-413) 'todos': "Claude's task list docks above the composer", 'model': '', // owned (T-408) — only routes here if ever removed from owned 'doctor': 'run `claude doctor` in a terminal', diff --git a/lib/builtin/claude/src/stream_json_session.dart b/lib/builtin/claude/src/stream_json_session.dart index 353fbd27..ee3f71dd 100644 --- a/lib/builtin/claude/src/stream_json_session.dart +++ b/lib/builtin/claude/src/stream_json_session.dart @@ -174,6 +174,16 @@ const List kEffortLevels = [ ModelOption(value: 'max', displayName: 'max', description: 'maximum thinking budget'), ]; +/// Permission modes for the /permissions picker (T-413), set over the +/// set_permission_mode control request. Bypass is last and explicit — the +/// footgun stays visible but never the default reach (T-181). +const List kPermissionModes = [ + ModelOption(value: 'default', displayName: 'default', description: 'ask before sensitive tools'), + ModelOption(value: 'acceptEdits', displayName: 'acceptEdits', description: 'auto-approve file edits'), + ModelOption(value: 'plan', displayName: 'plan', description: 'read-only planning mode'), + ModelOption(value: 'bypassPermissions', displayName: 'bypassPermissions', description: 'no prompts at all — careful'), +]; + /// Fallback picker entries for when the `initialize` response hasn't arrived /// (or carried no models): the stable aliases every claude build accepts /// (T-408). `default` resets to the CLI's configured model. diff --git a/test/builtin/claude/claude_meta_sidebar_test.dart b/test/builtin/claude/claude_meta_sidebar_test.dart index c4f2ee90..b48e9d18 100644 --- a/test/builtin/claude/claude_meta_sidebar_test.dart +++ b/test/builtin/claude/claude_meta_sidebar_test.dart @@ -133,6 +133,29 @@ void main() { expect(find.text('Claude environment not loaded.'), findsOneWidget); }); + testWidgets('a meta.tab message switches the sub-tab (T-413 slash navigation)', (tester) async { + await tester.pumpWidget(harness(f, sidebar(stats: stats))); + await tester.pumpAndSettle(); + expect(find.text('TODAY'), findsOneWidget); // starts on Activity + + // /config (and /mcp, /agents, /hooks) publish this from the Claude pane. + f.services.messages.publish('builtin.claude', 'meta.tab', {'tab': 'config'}); + await tester.pump(); + await tester.pump(); + expect(find.text('Claude environment not loaded.'), findsOneWidget); // Config tab (no env in fixture) + + f.services.messages.publish('builtin.claude', 'meta.tab', {'tab': 'activity'}); + await tester.pump(); + await tester.pump(); + expect(find.text('TODAY'), findsOneWidget); // back on Activity + + // An unknown tab name is ignored. + f.services.messages.publish('builtin.claude', 'meta.tab', {'tab': 'bogus'}); + await tester.pump(); + await tester.pump(); + expect(find.text('TODAY'), findsOneWidget); + }); + testWidgets('a team spawn auto-fronts the Team tab', (tester) async { await tester.pumpWidget(harness(f, sidebar(stats: stats))); await tester.pumpAndSettle(); diff --git a/test/builtin/claude/slash_commands_test.dart b/test/builtin/claude/slash_commands_test.dart index 5cbbcb1a..6c35cfb6 100644 --- a/test/builtin/claude/slash_commands_test.dart +++ b/test/builtin/claude/slash_commands_test.dart @@ -151,7 +151,21 @@ void main() { }); test('owned beats everything', () { - for (final t in ['/clear', '/resume', '/fork', '/model opus', '/effort high']) { + for (final t in [ + '/clear', + '/resume', + '/fork', + '/model opus', + '/effort high', + '/permissions plan', + '/status', + '/config', + '/mcp', + '/agents', + '/hooks', + '/memory', + '/help', + ]) { expect(routeSlashCommand(t, advertised: advertised), SlashRoute.owned, reason: t); } }); @@ -163,13 +177,14 @@ void main() { }); test('a known TUI-only builtin routes unavailable', () { - for (final t in ['/status', '/permissions', '/doctor', '/login']) { + for (final t in ['/cost', '/doctor', '/login', '/rewind', '/output-style']) { expect(routeSlashCommand(t, advertised: advertised), SlashRoute.unavailable, reason: t); } }); test('an advertised name shadows the TUI-only catalog (a skill named like a builtin forwards)', () { - expect(routeSlashCommand('/status', advertised: ['status']), SlashRoute.forward); + // 'cost' is in the catalog but not owned — advertising it wins. + expect(routeSlashCommand('/cost', advertised: ['cost']), SlashRoute.forward); }); test('an unknown token forwards (stays literal text downstream)', () { @@ -179,8 +194,8 @@ void main() { group('tuiOnlyNotice (T-411)', () { test('carries the clide-native pointer when the catalog has one', () { - final n = tuiOnlyNotice('status'); - expect(n, contains('/status is a Claude Code TUI command')); + final n = tuiOnlyNotice('cost'); + expect(n, contains('/cost is a Claude Code TUI command')); expect(n, contains('Activity tab')); });