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:
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user