own the open-in-clide command family (T-413)

/permissions, /status, /config, /mcp, /agents, /hooks, /memory, and
/help move from the TUI-only notice catalog to clide-owned commands
with real behavior:

- /permissions <mode> sets the mode over set_permission_mode; bare
  /permissions opens a picker in the interaction zone — the same card
  /model and /effort use (kPermissionModes, bypass last and explicit
  per T-181).
- /status → Claude sidebar Activity tab; /config, /mcp, /agents,
  /hooks → Config tab. The pane activates the claude.meta sidebar tab
  and publishes a meta.tab message; the sidebar subscribes and switches
  its sub-tab — the same MessageBus addressing `clide ui open` uses
  (D-6), so the CLI can drive it too.
- /memory opens the workspace CLAUDE.md via editor.open.
- /help renders a local summary card (clide-owned + advertised
  commands) — the CLI's TUI help doesn't exist headless.

The catalog keeps empty-hint entries for these tokens as safety nets if
they're ever removed from owned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 13:06:10 +02:00
co-authored by Claude Fable 5
parent 1bdd88f4ab
commit 52d90be730
9 changed files with 185 additions and 16 deletions
@@ -83,6 +83,7 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
StreamSubscription<TeamMemberJoined>? _joinSub;
StreamSubscription<TeamMemberLeft>? _leftSub;
StreamSubscription<Message>? _statusSub;
StreamSubscription<Message>? _tabSub;
StreamSubscription<SessionStatus>? _primarySub;
StreamSubscription<void>? _brokerChangeSub;
Timer? _timer;
@@ -163,6 +164,13 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
_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<ClaudeMetaSidebar> {
_joinSub?.cancel();
_leftSub?.cancel();
_statusSub?.cancel();
_tabSub?.cancel();
_primarySub?.cancel();
_brokerChangeSub?.cancel();
_injectCtl.dispose();
+84 -1
View File
@@ -92,6 +92,7 @@ class _ClaudePaneState extends State<ClaudePane> {
/// 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<ClaudePane> {
_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<ClaudePane> {
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<ClaudePane> {
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<ClaudePane> {
/// 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<ClaudePane> {
_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<ClaudePane> {
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<bool>(
stream: _session?.busyStream,
+27 -10
View File
@@ -34,8 +34,25 @@ bool isKnownSlashCommand(String text, Iterable<String> 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<String> 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<String> 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<String, String> 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',
@@ -174,6 +174,16 @@ const List<ModelOption> 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<ModelOption> 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.