From 02c6dd4cf0a22cbb410d78c6984150dea94ad933 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 12 Jun 2026 12:25:19 +0200 Subject: [PATCH] route slash commands: TUI-only builtins become local notices (T-411) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ""; 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 ("" model) so CLI-local output is distinguishable; "" 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 --- .pql/changelog/ticket_history/2026-06.sql | 13 ++++ .pql/changelog/ticket_idmap/2026-06.sql | 1 + .pql/changelog/tickets/2026-06.sql | 18 +++++ CHANGELOG.md | 5 ++ lib/builtin/claude/src/claude_config.dart | 31 ++------ lib/builtin/claude/src/claude_pane.dart | 9 +++ lib/builtin/claude/src/conversation_view.dart | 11 +++ lib/builtin/claude/src/slash_commands.dart | 78 +++++++++++++++++++ .../claude/src/stream_json_session.dart | 7 ++ lib/builtin/claude/src/transcript_reader.dart | 18 ++++- .../claude/conversation_view_test.dart | 9 +++ test/builtin/claude/slash_commands_test.dart | 54 +++++++++++++ .../claude/stream_json_session_test.dart | 10 +++ .../claude/transcript_reader_test.dart | 45 +++++++++++ 14 files changed, 284 insertions(+), 25 deletions(-) diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index ef53e623..495f84e9 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -4311,3 +4311,16 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang 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 ('06FBN3VYR84023Z5XFEX9DS0S0', 'status', 'in_progress', 'done', NULL, '2026-06-12 06:49:24', '2026-06-12 06:49:24', '2026-06-12 06:49:24', NULL, 'ce08a8f54370d08b033552e169a8bbb9', 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 ('06FBN3VTK2MYQQ173MSJN6E1DM', 'status', 'backlog', 'in_progress', NULL, '2026-06-12 06:50:01', '2026-06-12 06:50:01', '2026-06-12 06:50:01', NULL, '801c09560f70be28365441175fb78440', 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 ('06FBN3VTK2MYQQ173MSJN6E1DM', 'status', 'in_progress', 'done', NULL, '2026-06-12 07:04:36', '2026-06-12 07:04:36', '2026-06-12 07:04:36', NULL, '09be3f51210b0815177613db912defaa', 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', 'backlog', 'in_progress', NULL, '2026-06-12 10:09:07', '2026-06-12 10:09:07', '2026-06-12 10:09:07', NULL, 'e54e34e5177b55ccbdf0da9c217d44a5', 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 ('06FBP3P8YERJ5R7ENSD675BX00', 'description', 'Turn the Claude sidebar Config tab''s read-only rows (T-183, split in T-395) into live controls — the heart of the power-panel epic. Inline: model picker (reuse the T-408 picker, anchored popover per D-ui primitive), permission-mode control (reuse T-275''s permission_mode_control), effort selector (lands with T-412; row shows ''n/a'' with a hint until supported). Each control reads live SessionStatus and writes through the same session APIs the slash commands use — one implementation, two surfaces (D-6). + +Per-session scoping: controls target the active/primary session; the Team tab''s per-member badges (T-157) stay as-is. Keep read-only rows for facts (version, transcript path, skills count). A11y: every control keyboard-reachable, semantics labels per the a11y contract; run make test-a11y. Golden for the new rows if visual.', 'Turn the Claude sidebar Config tab''s read-only rows (T-183, split in T-395) into live controls — the heart of the power-panel epic. Inline: model picker (reuse the T-408 picker, anchored popover per D-ui primitive), permission-mode control (reuse T-275''s permission_mode_control), effort selector (lands with T-412; row shows ''n/a'' with a hint until supported). Each control reads live SessionStatus and writes through the same session APIs the slash commands use — one implementation, two surfaces (D-6). + +Per-session scoping: controls target the active/primary session; the Team tab''s per-member badges (T-157) stay as-is. Keep read-only rows for facts (version, transcript path, skills count). A11y: every control keyboard-reachable, semantics labels per the a11y contract; run make test-a11y. Golden for the new rows if visual. + +STYLING PASS (user, 2026-06-12): the Claude sidepanel looks bland and the font is +small. While making the Config tab interactive, also do a visual polish pass over +the whole Claude sidebar (Activity/Team/Config): bump the row/label typography to +the panel scale used elsewhere, give sections clearer hierarchy (headers, spacing, +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; diff --git a/.pql/changelog/ticket_idmap/2026-06.sql b/.pql/changelog/ticket_idmap/2026-06.sql index 91593994..7edbc3b4 100644 --- a/.pql/changelog/ticket_idmap/2026-06.sql +++ b/.pql/changelog/ticket_idmap/2026-06.sql @@ -240,3 +240,4 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3KRWM65MD3DS251NN9YX0', 'T-413', '2026-06-12 08:59:08', '2026-06-12 08:59:08', NULL, 'b5247ea05c106500682be978a47e61ee', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3P8YERJ5R7ENSD675BX00', 'T-414', '2026-06-12 08:59:28', '2026-06-12 08:59:28', NULL, '7d973f16c99441dbba0f8df89a665b32', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3P91QQQDT5J50F52FPCKM', 'T-415', '2026-06-12 08:59:28', '2026-06-12 08:59:28', NULL, 'a2dea9268d44b9e8a1fd746bbb947c92', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); +INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPQ8QNGJFFK7G24CBWQAR2C', 'T-416', '2026-06-12 10:25:00', '2026-06-12 10:25:00', NULL, 'f8c2a125e661607d5dd0c73cd2c3f2ab', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 58d0d9ab..26ada8d3 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -4685,3 +4685,21 @@ Per-session scoping: controls target the active/primary session; the Team tab''s 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 ('06FBP3P91QQQDT5J50F52FPCKM', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Activity tab: session controls + usage block (revisit T-158)', 'Two additions to the sidebar Activity tab. (1) Session controls row: clear / compact / fork / resume as icon buttons driving the exact same handlers as the slash commands (claude_pane._send routes) — D-6 parity for the session-mutating set. Confirm-on-destructive (/clear) via the existing dialog pattern. (2) Usage/cost block: the version-keyed probes show /usage IS advertised in stream-json (init-2.1.16x.json) — probe what forwarded /usage returns in headless mode and, if parseable, render plan usage alongside the existing cost/context data from result events (T-168 fields already in SessionStatus). This revisits T-158 ''blocked on upstream'' — if the output is unusable, document that in T-158 and keep the cost/context-only block. Link T-158 either way.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 08:59:28', '2026-06-12 08:59:28', NULL, '5dc16e95609379e3360c9e59f97ab11e', 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 ('06FBP3GM6V0RZBY2PXE9ZQFR88', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Slash-command routing table + graceful TUI-only capture', 'Replace kClideOwnedCommands (a bare Set) with a declarative routing table: token → forward | owned | unavailable. ''forward'' is probe-driven (the live initialize handshake''s slash_commands — skills + headless builtins); ''owned'' dispatches to clide implementations (clear/resume/fork/model today, growing per sibling stories); ''unavailable'' is a curated catalog of known TUI-only commands (doctor, login, logout, terminal-setup, ide, exit, vim, statusline, output-style, …) that get a friendly card explaining the clide equivalent — never forwarded. + +Plus the reactive catch-all: a system/local_command event whose stdout matches "isn''t available in this environment" renders as a muted hint card (suggesting the sidebar control when the routing table maps one) instead of raw CLI error text. This auto-captures future/unknown TUI commands without catalog maintenance. + +Where: slash_commands.dart (table), claude_pane._send (dispatch), stream_json_session/transcript handling for the local_command catch, conversation card for the hint. Tests: routing per class; the catch-all renders the hint card; advertised-but-unknown forwards untouched; typeahead lists owned + advertised, not ''unavailable'' ones (or lists them greyed).', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-12 08:58:42', '2026-06-12 10:09:07', NULL, '74e76e9dbb0f23fadff3f85af53388f8', 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 ('06FBP3P8YERJ5R7ENSD675BX00', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Config tab → interactive controls (model / effort / permission inline)', 'Turn the Claude sidebar Config tab''s read-only rows (T-183, split in T-395) into live controls — the heart of the power-panel epic. Inline: model picker (reuse the T-408 picker, anchored popover per D-ui primitive), permission-mode control (reuse T-275''s permission_mode_control), effort selector (lands with T-412; row shows ''n/a'' with a hint until supported). Each control reads live SessionStatus and writes through the same session APIs the slash commands use — one implementation, two surfaces (D-6). + +Per-session scoping: controls target the active/primary session; the Team tab''s per-member badges (T-157) stay as-is. Keep read-only rows for facts (version, transcript path, skills count). A11y: every control keyboard-reachable, semantics labels per the a11y contract; run make test-a11y. Golden for the new rows if visual. + +STYLING PASS (user, 2026-06-12): the Claude sidepanel looks bland and the font is +small. While making the Config tab interactive, also do a visual polish pass over +the whole Claude sidebar (Activity/Team/Config): bump the row/label typography to +the panel scale used elsewhere, give sections clearer hierarchy (headers, spacing, +accent marks per ui-design tokens), and make the controls feel like controls. +Treat ui-design skill as the reference for token/type choices.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 08:59:28', '2026-06-12 10:24:43', NULL, '99fac60083c6408fd148b995ee85cc15', 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 ('06FBPQ8QNGJFFK7G24CBWQAR2C', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Surface Claude Code Workflow runs in the convo + status surfaces', 'Claude Code''s Workflow mechanism (multi-agent orchestration: wf_ runs, phases, agent fan-outs, live progress) currently renders in clide as ordinary tool-use cards at best. Integrate it properly: (1) CONVO PANEL — recognise a Workflow tool-use and render a dedicated run card: phase groups, per-agent rows with live status, and the run''s result summary; reuse the collapser/agent-card machinery (T-305/T-342) rather than inventing new chrome. (2) STATUS/SIDEBAR — while a workflow runs, show an aggregate indicator (run id, phase, agents active/done) in the Claude sidebar Activity tab and/or status line. + +SPIKE FIRST: capture what the stream-json wire actually emits during a Workflow run (tool_use input shape, progress/notification events, sidechain attribution for workflow-spawned agents) — same probe method as T-410''s. Scope the rendering to what the wire really carries; if progress only exists in the harness UI and not on the wire, document that limit and render what''s available (start/end + result). Filed from user request 2026-06-12.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 10:25:00', '2026-06-12 10:25:00', NULL, '579c8318c3c217ae5bbbc90661dbe917', 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 563c4085..075bbb6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- **TUI-only slash commands get a helpful notice instead of failing.** A typed + `/effort`, `/status`, `/permissions`, … no longer errors raw from the CLI or + leaks to the model as literal text — clide routes known TUI-only commands to + a muted notice card pointing at the clide-native way. CLI-local output (like + `/usage`) now renders as a "clide" card, never fake Claude prose. (T-411) - **`/model` works in the Claude pane.** With a name (`/model sonnet`) it switches the live session's model over the control channel; bare `/model` opens a picker in the interaction zone with the CLI's model list and the diff --git a/lib/builtin/claude/src/claude_config.dart b/lib/builtin/claude/src/claude_config.dart index f65f1ba5..b0735e8a 100644 --- a/lib/builtin/claude/src/claude_config.dart +++ b/lib/builtin/claude/src/claude_config.dart @@ -145,29 +145,14 @@ typedef ClaudeInitProbe = Future Function(); /// Returns a change stream for [dir] (fires on any file event under it). typedef ClaudeConfigWatch = Stream Function(Directory dir); -/// Modest version-agnostic fallback used when the probe is unavailable, so -/// the typeahead still offers the common built-ins. -const List 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 kFallbackSlashCommands = ['clear', 'compact', 'context', 'init', 'review', 'security-review', 'usage']; class ClaudeConfig extends ChangeNotifier { ClaudeConfig({ diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index f20daa8a..c313fd2d 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -398,6 +398,15 @@ class _ClaudePaneState extends State { _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); } diff --git a/lib/builtin/claude/src/conversation_view.dart b/lib/builtin/claude/src/conversation_view.dart index a924b455..6137cdf5 100644 --- a/lib/builtin/claude/src/conversation_view.dart +++ b/lib/builtin/claude/src/conversation_view.dart @@ -577,6 +577,17 @@ class _ConversationTurn extends StatelessWidget { onOpenFile: (path, line) => _openFile(context, path, line), ), ), + // CLI-local output (model "": 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. diff --git a/lib/builtin/claude/src/slash_commands.dart b/lib/builtin/claude/src/slash_commands.dart index 131d61ae..775a6b7c 100644 --- a/lib/builtin/claude/src/slash_commands.dart +++ b/lib/builtin/claude/src/slash_commands.dart @@ -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 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 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. diff --git a/lib/builtin/claude/src/stream_json_session.dart b/lib/builtin/claude/src/stream_json_session.dart index 5a0f4569..e83cc694 100644 --- a/lib/builtin/claude/src/stream_json_session.dart +++ b/lib/builtin/claude/src/stream_json_session.dart @@ -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. diff --git a/lib/builtin/claude/src/transcript_reader.dart b/lib/builtin/claude/src/transcript_reader.dart index 97f86fe5..3e17692d 100644 --- a/lib/builtin/claude/src/transcript_reader.dart +++ b/lib/builtin/claude/src/transcript_reader.dart @@ -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: ""` + /// (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 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; + // "" 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 = ''; + void _parseAssistantInto( Map 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, ), ); } diff --git a/test/builtin/claude/conversation_view_test.dart b/test/builtin/claude/conversation_view_test.dart index 79318f0c..41db3b34 100644 --- a/test/builtin/claude/conversation_view_test.dart +++ b/test/builtin/claude/conversation_view_test.dart @@ -833,6 +833,15 @@ void main() { expect(find.text('no independent source to follow'), findsOneWidget); }); + testWidgets('synthetic CLI-local output renders as a muted "clide" card, not claude prose (T-411)', (tester) async { + await pumpWith(tester, [ + AssistantTextMessage(uuid: 's1', timestamp: _t, isSidechain: false, text: "/effort isn't available in this environment.", synthetic: true), + ]); + expect(find.text('clide'), findsOneWidget); + expect(find.text('claude'), findsNothing); + expect(find.textContaining("isn't available"), findsOneWidget); + }); + testWidgets('an ordinary Bash card has no live-tail segment (T-325)', (tester) async { await pumpWith(tester, [ AssistantToolUse(uuid: 'b2', timestamp: _t, isSidechain: false, toolUseId: 'tb2', name: 'Bash', input: const {'command': 'ls -la'}), diff --git a/test/builtin/claude/slash_commands_test.dart b/test/builtin/claude/slash_commands_test.dart index 5ae39f81..b67266ff 100644 --- a/test/builtin/claude/slash_commands_test.dart +++ b/test/builtin/claude/slash_commands_test.dart @@ -136,4 +136,58 @@ void main() { expect(r.cursor, 10); }); }); + + group('routeSlashCommand (T-411)', () { + // The probed 2.1.175 shape: skills + headless builtins. + const advertised = ['compact', 'context', 'usage', 'whats-next', 'git-commit']; + + test('non-command text routes null (normal message send)', () { + expect(routeSlashCommand('hello world', advertised: advertised), isNull); + expect(routeSlashCommand('multi\n/line', advertised: advertised), isNull); + }); + + test('a path-like leading slash is an unknown token → forward (stays literal)', () { + expect(routeSlashCommand('/tmp/x.log explain', advertised: advertised), SlashRoute.forward); + }); + + test('owned beats everything', () { + for (final t in ['/clear', '/resume', '/fork', '/model opus']) { + expect(routeSlashCommand(t, advertised: advertised), SlashRoute.owned, reason: t); + } + }); + + test('advertised commands (skills + headless builtins) forward', () { + expect(routeSlashCommand('/compact', advertised: advertised), SlashRoute.forward); + expect(routeSlashCommand('/usage', advertised: advertised), SlashRoute.forward); + expect(routeSlashCommand('/whats-next', advertised: advertised), SlashRoute.forward); + }); + + test('a known TUI-only builtin routes unavailable', () { + for (final t in ['/effort high', '/status', '/permissions', '/doctor', '/login']) { + 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); + }); + + test('an unknown token forwards (stays literal text downstream)', () { + expect(routeSlashCommand('/no-such-thing', advertised: advertised), SlashRoute.forward); + }); + }); + + 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')); + expect(n, contains('Activity tab')); + }); + + test('plain notice when there is no pointer', () { + final n = tuiOnlyNotice('terminal-setup'); + expect(n, contains('/terminal-setup is a Claude Code TUI command')); + expect(n, isNot(contains('→'))); + }); + }); } diff --git a/test/builtin/claude/stream_json_session_test.dart b/test/builtin/claude/stream_json_session_test.dart index 39e2714e..c7a9fc25 100644 --- a/test/builtin/claude/stream_json_session_test.dart +++ b/test/builtin/claude/stream_json_session_test.dart @@ -640,6 +640,16 @@ void main() { expect(statuses.last.permissionMode, 'plan', reason: 'only ExitPlanMode exits plan mode'); }); + test('addLocalNotice emits a synthetic clide item and sends nothing (T-411)', () async { + final before = proc.writes.length; + session.addLocalNotice('/status is a Claude Code TUI command'); + await Future.delayed(Duration.zero); + final notice = items.whereType().single; + expect(notice.synthetic, isTrue); + expect(notice.text, contains('/status')); + expect(proc.writes.length, before); // nothing went to the CLI + }); + test('resolvePrompt(deny) writes a deny decision with a message', () async { proc.emit(canUseTool('req-3')); await Future.delayed(Duration.zero); diff --git a/test/builtin/claude/transcript_reader_test.dart b/test/builtin/claude/transcript_reader_test.dart index 68a6c73c..a802978b 100644 --- a/test/builtin/claude/transcript_reader_test.dart +++ b/test/builtin/claude/transcript_reader_test.dart @@ -524,6 +524,51 @@ void main() { }); }); + group('synthetic CLI-local output (T-411)', () { + String syntheticEvent(String text) => jsonEncode({ + 'type': 'assistant', + 'uuid': 'syn1', + 'timestamp': '2026-06-12T10:00:00.000Z', + 'message': { + 'role': 'assistant', + 'model': '', + 'content': [ + {'type': 'text', 'text': text}, + ], + }, + }); + + test('a "" assistant message parses with synthetic: true', () { + final parsed = parseTranscriptChunk(syntheticEvent("/effort isn't available in this environment.")); + final msg = parsed.items.whereType().single; + expect(msg.synthetic, isTrue); + expect(msg.text, contains("isn't available")); + }); + + test('"" never clobbers the tracked model', () { + final parsed = parseTranscriptChunk(syntheticEvent('usage text')); + expect(parsed.status.model, isNull); + }); + + test('a real assistant message stays non-synthetic', () { + final chunk = jsonEncode({ + 'type': 'assistant', + 'uuid': 'a2', + 'timestamp': '2026-06-12T10:00:00.000Z', + 'message': { + 'role': 'assistant', + 'model': 'claude-fable-5', + 'content': [ + {'type': 'text', 'text': 'hello'}, + ], + }, + }); + final parsed = parseTranscriptChunk(chunk); + expect(parsed.items.whereType().single.synthetic, isFalse); + expect(parsed.status.model, 'claude-fable-5'); + }); + }); + group('TranscriptReader — append streaming (filesystem)', () { late Directory tempBase;