route slash commands: TUI-only builtins become local notices (T-411)

clide forwards composer input to a headless (stream-json) CLI, where the
TUI's interactive commands don't exist. A known-but-TUI-only command
errored raw ("/x isn't available in this environment", rendered as fake
claude prose); an un-advertised one (e.g. /effort on 2.1.175) was worse —
bracket-pasted to the model as literal text, burning a real turn.

Probed claude 2.1.175 for ground truth: the initialize handshake's
slash_commands advertises skills + the headless builtins only; forwarded
local-command output comes back as an assistant message with model
"<synthetic>"; set_effort is not a control subtype; /usage works headless.

- slash_commands.dart: SlashRoute routing table (owned > advertised >
  TUI-only catalog > forward) + kTuiOnlyCommands with clide-native hints
  + tuiOnlyNotice(). One source of truth replacing ad-hoc checks.
- claude_pane._send routes 'unavailable' to a local notice card; nothing
  reaches the session.
- transcript_reader: AssistantTextMessage.synthetic ("<synthetic>" model)
  so CLI-local output is distinguishable; "<synthetic>" no longer
  clobbers the tracked model in SessionStatus (latent /usage bug).
- conversation_view: synthetic output renders as a muted framed "clide"
  card (T-306 styling), never coral Claude prose.
- kFallbackSlashCommands trimmed to the genuinely-headless builtin set —
  it doubles as the router's advertised fallback, and the old list's
  TUI-only entries would have routed to a raw CLI error.

Board (rides this commit): T-414 gains the user's sidebar styling-pass
note; T-416 filed — surface Claude Code Workflow runs in convo/status.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 12:25:19 +02:00
co-authored by Claude Fable 5
parent 91c5501cf3
commit 02c6dd4cf0
14 changed files with 284 additions and 25 deletions
+8 -23
View File
@@ -145,29 +145,14 @@ typedef ClaudeInitProbe = Future<String?> Function();
/// Returns a change stream for [dir] (fires on any file event under it).
typedef ClaudeConfigWatch = Stream<void> Function(Directory dir);
/// Modest version-agnostic fallback used when the probe is unavailable, so
/// the typeahead still offers the common built-ins.
const List<String> kFallbackSlashCommands = [
'add-dir',
'agents',
'clear',
'compact',
'config',
'context',
'cost',
'doctor',
'exit',
'help',
'init',
'mcp',
'memory',
'model',
'permissions',
'resume',
'review',
'status',
'usage',
];
/// Fallback used when the probe is unavailable. Mirrors the builtins a real
/// CLI advertises in its stream-json `initialize` handshake (probed against
/// 2.1.175) — i.e. the ones that genuinely work headless. It deliberately
/// does NOT list TUI-only commands (config, permissions, status, doctor, …):
/// this list doubles as the router's "advertised" set (T-411), and a TUI-only
/// token here would be forwarded to the CLI and error. The composer unions
/// [kClideOwnedCommands] on top for the typeahead (T-162).
const List<String> kFallbackSlashCommands = ['clear', 'compact', 'context', 'init', 'review', 'security-review', 'usage'];
class ClaudeConfig extends ChangeNotifier {
ClaudeConfig({
+9
View File
@@ -398,6 +398,15 @@ class _ClaudePaneState extends State<ClaudePane> {
_modelCommand(slashCommandArg(text) ?? '');
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
// the model as literal text, burning a turn). It becomes a local notice
// card pointing at the clide-native way instead.
final advertised = activeClaudeConfig?.slashCommands ?? kFallbackSlashCommands;
if (routeSlashCommand(text, advertised: advertised) == SlashRoute.unavailable) {
_session?.addLocalNotice(tuiOnlyNotice(slashCommandToken(text)!));
return;
}
_session?.send(text);
}
@@ -577,6 +577,17 @@ class _ConversationTurn extends StatelessWidget {
onOpenFile: (path, line) => _openFile(context, path, line),
),
),
// CLI-local output (model "<synthetic>": a forwarded local command's
// response or a clide-injected notice, T-411) is not Claude speaking —
// framed + muted like the context card (T-306), attributed to clide.
AssistantTextMessage() when i.synthetic => ConversationCard(
variant: ConversationCardVariant.bordered,
accent: tokens.globalTextMuted,
label: 'clide',
copyText: i.text,
margin: _childMargin,
body: ClideText(i.text, muted: true, fontSize: clideFontMeta),
),
// Sub-agent (sidechain) prose is NOT the main Claude — attribute it to the
// agent with a muted accent, never the coral "claude" brand (T-265). The
// coral claudeAccent is reserved for the real main-thread Claude.
@@ -42,6 +42,84 @@ String? clideOwnedCommand(String text) {
return token != null && kClideOwnedCommands.contains(token) ? token : null;
}
/// Where slash input goes (T-411). One source of truth so a TUI-only command
/// neither errors raw from the CLI nor bracket-pastes to the model as text
/// (burning a real turn — observed with /effort on claude 2.1.175).
enum SlashRoute {
/// clide implements it natively ([kClideOwnedCommands]).
owned,
/// The CLI handles it headless — advertised in the `initialize` handshake's
/// `slash_commands` (skills + the headless builtins: compact, context, …).
forward,
/// A known TUI-only builtin: never forwarded; clide shows a local notice
/// with the clide-native way ([kTuiOnlyCommands]).
unavailable,
}
/// Claude Code TUI-only builtins (probed against 2.1.175: not advertised in
/// stream-json, and forwarding would either error "isn't available in this
/// environment" or — worse, for un-advertised tokens — bracket-paste to the
/// model as literal text). Value = the clide-native pointer shown in the
/// notice card. Commands clide later implements move to [kClideOwnedCommands].
const Map<String, String> kTuiOnlyCommands = {
'effort': 'the session effort level is set at spawn time; clide support is tracked in T-412',
'status': 'session status lives in the Claude sidebar (Activity tab)',
'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',
'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',
'login': 'run `claude` in a terminal and use /login there',
'logout': 'run `claude` in a terminal and use /logout there',
'exit': 'close the pane or switch sessions instead',
'vim': 'clide ships its own editor vim mode',
'add-dir': '',
'bashes': '',
'bug': '',
'export': '',
'fast': '',
'ide': "you're already in one",
'install-github-app': '',
'migrate-installer': '',
'output-style': '',
'pr-comments': '',
'privacy-settings': '',
'release-notes': '',
'rewind': '',
'statusline': '',
'terminal-setup': '',
'upgrade': '',
};
/// Route [text] (composer input). Null when it isn't slash-command input —
/// send it as a normal message. Precedence: owned > advertised > TUI-only
/// catalog > forward (unknown tokens stay literal text via bracketed paste).
SlashRoute? routeSlashCommand(String text, {required Iterable<String> advertised}) {
final token = slashCommandToken(text);
if (token == null) return null;
if (kClideOwnedCommands.contains(token)) return SlashRoute.owned;
if (advertised.contains(token)) return SlashRoute.forward;
if (kTuiOnlyCommands.containsKey(token)) return SlashRoute.unavailable;
return SlashRoute.forward;
}
/// The notice text for a TUI-only [token] — the CLI's own phrasing plus the
/// clide-native pointer when the catalog has one.
String tuiOnlyNotice(String token) {
final hint = kTuiOnlyCommands[token] ?? '';
final base = "/$token is a Claude Code TUI command — it isn't available in clide's conversation pane.";
return hint.isEmpty ? base : '$base\n$hint';
}
/// The argument text after the command token — `"/model sonnet"` → `"sonnet"`
/// — trimmed; empty when there is none (`"/model"`). Null when [text] isn't
/// single-line leading-slash input.
@@ -794,6 +794,13 @@ class StreamJsonSession {
_setBusy(true);
}
/// Inject a clide-local notice card into the conversation — nothing is sent
/// to claude. Used by the slash-command router for TUI-only commands
/// (T-411); renders as the muted synthetic "clide" card.
void addLocalNotice(String text) {
_items.add(AssistantTextMessage(uuid: 'local-${_localSeq++}', timestamp: DateTime.now(), isSidechain: false, text: text, synthetic: true));
}
/// Interrupt the running turn (the escape hatch for a runaway — D-78). Sends
/// the `interrupt` control_request; claude cancels the current turn and ends
/// it with a `result`, which clears [busy]. Safe to call when idle.
+16 -2
View File
@@ -114,12 +114,19 @@ final class AssistantTextMessage extends ConversationItem {
super.parentUuid,
super.parentToolUseId,
required this.text,
this.synthetic = false,
});
final String text;
/// CLI-local output, not the model: the wire marks it `model: "<synthetic>"`
/// (a forwarded local command's response — /usage output, "/x isn't
/// available in this environment", …). clide-injected notices use it too.
/// Rendered as a muted "clide" card, never coral Claude prose (T-411).
final bool synthetic;
@override
String toString() => 'AssistantTextMessage(${_shortId(uuid)}, ${text.length} chars)';
String toString() => 'AssistantTextMessage(${_shortId(uuid)}, ${text.length} chars${synthetic ? ', synthetic' : ''})';
}
/// Extended thinking block from an assistant turn.
@@ -582,7 +589,9 @@ void _extractAssistantStatus(Map<String, dynamic> envelope, _StatusAcc status) {
final message = envelope['message'] as Map?;
if (message == null) return;
final model = message['model'] as String?;
if (model != null && model.isNotEmpty) status.model = model;
// "<synthetic>" marks CLI-local output (a forwarded local command's
// response) — not a model switch; it must not clobber the tracked model.
if (model != null && model.isNotEmpty && model != kSyntheticModel) status.model = model;
final usage = message['usage'] as Map?;
if (usage != null) {
int n(String k) => (usage[k] as num?)?.toInt() ?? 0;
@@ -656,6 +665,9 @@ void _parseUserInto(
}
}
/// The model marker on CLI-local output (forwarded local-command responses).
const String kSyntheticModel = '<synthetic>';
void _parseAssistantInto(
Map<String, dynamic> envelope,
String uuid,
@@ -669,6 +681,7 @@ void _parseAssistantInto(
if (message == null) return;
final content = message['content'];
if (content is! List) return;
final synthetic = (message['model'] as String?) == kSyntheticModel;
for (final item in content) {
if (item is! Map) continue;
@@ -684,6 +697,7 @@ void _parseAssistantInto(
parentUuid: parentUuid,
parentToolUseId: parentToolUseId,
text: text,
synthetic: synthetic,
),
);
}