From 5fad8dc430f6a6b66ec22256e61cfa58b9cae181 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 23 May 2026 12:35:53 +0200 Subject: [PATCH] deliver slash commands typed so the Claude TUI runs them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer routed everything through tmux paste-buffer -p (bracketed paste), and Claude's TUI deliberately doesn't parse a leading slash on pasted input — so /command and /skill arrived as literal text instead of running. Now a recognised command (single-line, leading slash, token in ClaudeConfig's slash list) is delivered via send-keys -l (typed) so the TUI fires it; everything else keeps the bracketed-paste path, which also leaves a stray leading slash (e.g. a /tmp path) as literal text rather than mis-parsing it. The slash list is warmed lazily when a Claude pane opens so custom commands are recognised. T-153. Co-Authored-By: Claude Opus 4.7 (1M context) --- .pql/changelog/ticket_history/2026-05.sql | 2 ++ .pql/changelog/tickets/2026-05.sql | 2 ++ CHANGELOG.md | 4 +++ lib/builtin/claude/src/claude_pane.dart | 16 ++++++++- lib/builtin/claude/src/slash_commands.dart | 28 +++++++++++++++ lib/builtin/claude/src/tmux_session.dart | 10 ++++++ test/builtin/claude/slash_commands_test.dart | 38 ++++++++++++++++++++ test/builtin/claude/tmux_session_test.dart | 10 ++++++ 8 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 lib/builtin/claude/src/slash_commands.dart create mode 100644 test/builtin/claude/slash_commands_test.dart diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index b70576f8..8d29b953 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -2202,3 +2202,5 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-150', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 09:04:54', '2026-05-23 09:04:54', '2026-05-23 09:04:54', NULL, 'fcc43ef1e1ae3c1dfa0e9c94ffa35bed', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-150', 'status', 'in_progress', 'done', NULL, '2026-05-23 09:33:03', '2026-05-23 09:33:03', '2026-05-23 09:33:03', NULL, '58c9b0734a70fe8c7083c3cc456a1bd6', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-151', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 09:54:31', '2026-05-23 09:54:31', '2026-05-23 09:54:31', NULL, '52df08bff6479b6a84c128f5a63ea312', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-151', 'status', 'in_progress', 'done', NULL, '2026-05-23 10:20:12', '2026-05-23 10:20:12', '2026-05-23 10:20:12', NULL, 'ed8ce28a1b0c7e995887dcfd32456aec', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-153', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 10:31:31', '2026-05-23 10:31:31', '2026-05-23 10:31:31', NULL, 'd5a1f02df5c8be27b9dc62b1ce277d67', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index cdb2318e..0cbfabbe 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -2588,3 +2588,5 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority, INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-153', 'task', 'T-132', 'Command-aware send: bypass bracketed-paste for valid slash commands', 'Fix the delivery mismatch where slash commands behave differently in clide than the CLI. tmux.sendMessage (lib/builtin/claude/src/tmux_session.dart) always uses paste-buffer -p (bracketed paste), and Claude''s TUI deliberately does not run slash-command parsing on bracketed-pasted content — so /cmd and /skill arrive as literal prompt text instead of invoking. Fix: when the submitted input is a valid slash command (per ClaudeConfig, T-151) — or single-line input generally — deliver it typed via tmux send-keys -l -- then Enter, so the TUI parses it like the CLI; keep paste-buffer -p only for multi-line content (mirrors encodeClaudeInput''s existing single-vs-multiline logic on the PTY fallback path). Acceptance: a typed or typeahead-selected slash command actually invokes the skill/command in a real make-run session (parity with the CLI); multi-line messages still arrive as one block, not a stream of submits; unit tests on the send-encoding branches (single-line vs multi-line vs recognized-command); coverage >= floor. Blocked by T-151.', 'backlog', 'medium', NULL, NULL, 'D-76', '2026-05-23 09:52:27', '2026-05-23 09:52:27', NULL, '61392ef8e182bc4be406639b9898bcac', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-154', 'task', 'T-132', 'Surface ClaudeConfig in the Claude status pane', 'The status surface shows LIVE model / permission-mode / context-tokens from the active transcript (T-145, T-150). Complement it with the CONFIGURED side from ClaudeConfig (T-151): available-skills count and/or configured permission/model defaults — static environment state alongside live session state. Reads from ClaudeConfig, not the filesystem; updates when config changes. Acceptance: the status surface reflects ClaudeConfig values, refreshes on config change, and keeps the live transcript-driven fields working; widget test; coverage >= floor. Blocked by T-151.', 'backlog', 'low', NULL, NULL, 'D-76', '2026-05-23 09:52:32', '2026-05-23 09:52:32', NULL, '407255f39712397b5e6fdde42a0cce0c', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-151', 'task', 'T-132', 'ClaudeConfig service: layered config + skills/commands + version-keyed slash probe', 'Builtin-owned (lib/builtin/claude/) app-wide source of truth for Claude Code''s environment per D-76. Loads and layers GLOBAL (~/.claude) as base with LOCAL (.claude) overriding: skills (skills/*/SKILL.md frontmatter name+description), custom slash commands (commands/*.md), settings.json, and permission rules (allow/deny/ask). Built-in slash commands (not on disk) come from a one-shot ''claude --output-format stream-json'' probe, cached keyed on the resolved claude version id so additions/deprecations re-capture on upgrade; a small static list is the fallback. FileWatcher (lib/src/files/watcher.dart) on both .claude dirs plus an explicit refresh; expose typed, listenable views. No kernel changes — builtin-owned (Claude is a non-disableable extension, but still an extension). Acceptance: empirically confirm the stream-json init message carries slash_commands for the pinned CC version (spike folded in here) before consumers rely on it; unit tests with fixture global+local .claude dirs covering layering, watcher-driven refresh, probe cache keyed on version, and static fallback; degrades gracefully on parse miss; coverage >= floor. Blocks the typeahead, command-aware send, and status-pane wiring.', 'in_progress', 'high', NULL, NULL, 'D-76', '2026-05-23 09:52:14', '2026-05-23 09:54:31', NULL, '65cd97999dfa1b15ffbf59e88478d5e2', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-151', 'task', 'T-132', 'ClaudeConfig service: layered config + skills/commands + version-keyed slash probe', 'Builtin-owned (lib/builtin/claude/) app-wide source of truth for Claude Code''s environment per D-76. Loads and layers GLOBAL (~/.claude) as base with LOCAL (.claude) overriding: skills (skills/*/SKILL.md frontmatter name+description), custom slash commands (commands/*.md), settings.json, and permission rules (allow/deny/ask). Built-in slash commands (not on disk) come from a one-shot ''claude --output-format stream-json'' probe, cached keyed on the resolved claude version id so additions/deprecations re-capture on upgrade; a small static list is the fallback. FileWatcher (lib/src/files/watcher.dart) on both .claude dirs plus an explicit refresh; expose typed, listenable views. No kernel changes — builtin-owned (Claude is a non-disableable extension, but still an extension). Acceptance: empirically confirm the stream-json init message carries slash_commands for the pinned CC version (spike folded in here) before consumers rely on it; unit tests with fixture global+local .claude dirs covering layering, watcher-driven refresh, probe cache keyed on version, and static fallback; degrades gracefully on parse miss; coverage >= floor. Blocks the typeahead, command-aware send, and status-pane wiring.', 'done', 'high', NULL, NULL, 'D-76', '2026-05-23 09:52:14', '2026-05-23 10:20:12', NULL, '3bd3ace6e31bca9f7458145fb2171425', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-153', 'task', 'T-132', 'Command-aware send: bypass bracketed-paste for valid slash commands', 'Fix the delivery mismatch where slash commands behave differently in clide than the CLI. tmux.sendMessage (lib/builtin/claude/src/tmux_session.dart) always uses paste-buffer -p (bracketed paste), and Claude''s TUI deliberately does not run slash-command parsing on bracketed-pasted content — so /cmd and /skill arrive as literal prompt text instead of invoking. Fix: when the submitted input is a valid slash command (per ClaudeConfig, T-151) — or single-line input generally — deliver it typed via tmux send-keys -l -- then Enter, so the TUI parses it like the CLI; keep paste-buffer -p only for multi-line content (mirrors encodeClaudeInput''s existing single-vs-multiline logic on the PTY fallback path). Acceptance: a typed or typeahead-selected slash command actually invokes the skill/command in a real make-run session (parity with the CLI); multi-line messages still arrive as one block, not a stream of submits; unit tests on the send-encoding branches (single-line vs multi-line vs recognized-command); coverage >= floor. Blocked by T-151.', 'in_progress', 'medium', NULL, NULL, 'D-76', '2026-05-23 09:52:27', '2026-05-23 10:31:31', NULL, 'fc5104b0410e9db5da9f85962f7f3ac9', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 45d27d15..eab1bd23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -128,6 +128,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Fixed +- Slash commands sent from the Claude composer now actually run (T-153). + Recognised commands are delivered as typed input so Claude's TUI parses + them; other input (and stray leading slashes like a `/tmp` path) stays + bracketed-pasted as literal text. - Claude secondary panes no longer flash a false "session exited" while the session is alive (a transient tmux client exit is now verified against the live session), and the tab and banner agree on the label diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index 22645395..63f2fae5 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -9,11 +9,13 @@ import 'package:flutter/widgets.dart'; import 'claude_banner.dart'; import 'claude_composer.dart'; +import 'claude_config.dart'; import 'claude_status.dart'; import 'clipboard_paste.dart'; import 'conversation_controller.dart'; import 'conversation_view.dart'; import 'session_naming.dart'; +import 'slash_commands.dart'; import 'tmux_session.dart' as tmux; import 'transcript_publisher.dart'; import 'transcript_reader.dart'; @@ -90,6 +92,9 @@ class _ClaudePaneState extends State { if (!_spawned) { _spawned = true; unawaited(_spawnWhenReady()); + // Warm the slash-command list in the background (lazy, idempotent) so + // custom commands are recognised by the time the user types one (T-153). + unawaited(activeClaudeConfig?.ensureProbe()); } } @@ -298,7 +303,16 @@ class _ClaudePaneState extends State { void _send(String text) { if (_usingTmux) { final session = _sessionName; - if (session != null) unawaited(tmux.sendMessage(session, text)); + if (session == null) return; + // Recognised slash commands go typed (so the TUI fires them); anything + // else is bracketed-pasted, keeping multi-line text and stray leading + // slashes literal (T-153). + final known = activeClaudeConfig?.slashCommands ?? kFallbackSlashCommands; + if (isKnownSlashCommand(text, known)) { + unawaited(tmux.sendCommand(session, text)); + } else { + unawaited(tmux.sendMessage(session, text)); + } return; } final id = _paneId; diff --git a/lib/builtin/claude/src/slash_commands.dart b/lib/builtin/claude/src/slash_commands.dart new file mode 100644 index 00000000..74f04413 --- /dev/null +++ b/lib/builtin/claude/src/slash_commands.dart @@ -0,0 +1,28 @@ +/// Pure helpers for recognising slash-command input in the Claude composer +/// (T-153). Kept Flutter-free so they're cheap to unit-test. +/// +/// Claude's TUI only treats a leading `/` as a command when the input is +/// *typed*, not bracketed-pasted. clide delivers recognised commands typed +/// (so they fire) and everything else bracketed-pasted (so multi-line text +/// and stray leading slashes — e.g. a path like `/tmp/x.log` — arrive as +/// literal text rather than being mis-parsed). Recognition is by the known +/// command set, so unknown `/...` input stays literal; that's intentionally +/// smarter than the CLI, which would reject it. +library; + +/// The command token of a single-line slash input — `"/model sonnet"` → +/// `"model"` — or null when [text] isn't single-line leading-slash input. +String? slashCommandToken(String text) { + if (text.contains('\n') || !text.startsWith('/')) return null; + final rest = text.substring(1); + final ws = rest.indexOf(RegExp(r'\s')); + final token = ws < 0 ? rest : rest.substring(0, ws); + return token.isEmpty ? null : token; +} + +/// Whether [text] is a recognised slash command given the [known] set, so it +/// should be delivered typed (firing the command) rather than bracketed-pasted. +bool isKnownSlashCommand(String text, Iterable known) { + final token = slashCommandToken(text); + return token != null && known.contains(token); +} diff --git a/lib/builtin/claude/src/tmux_session.dart b/lib/builtin/claude/src/tmux_session.dart index 80aaabd7..0b983976 100644 --- a/lib/builtin/claude/src/tmux_session.dart +++ b/lib/builtin/claude/src/tmux_session.dart @@ -80,3 +80,13 @@ Future sendMessage(String session, String text) async { await tmuxRunner([..._socket, 'paste-buffer', '-p', '-d', '-b', _composeBuffer, '-t', session]); await tmuxRunner([..._socket, 'send-keys', '-t', session, 'Enter']); } + +/// Submit [text] to [session] as TYPED input (literal keystrokes, no bracketed +/// paste), then Enter — so Claude's TUI parses a leading `/` as a slash +/// command, exactly as if the user had typed it (T-153). For single-line +/// slash-command input only; regular messages go through [sendMessage] so +/// bracketed paste keeps multi-line content and stray slashes literal. +Future sendCommand(String session, String text) async { + await tmuxRunner([..._socket, 'send-keys', '-t', session, '-l', '--', text]); + await tmuxRunner([..._socket, 'send-keys', '-t', session, 'Enter']); +} diff --git a/test/builtin/claude/slash_commands_test.dart b/test/builtin/claude/slash_commands_test.dart new file mode 100644 index 00000000..4094e134 --- /dev/null +++ b/test/builtin/claude/slash_commands_test.dart @@ -0,0 +1,38 @@ +import 'package:clide/builtin/claude/src/slash_commands.dart'; +import 'package:test/test.dart'; + +void main() { + group('slashCommandToken', () { + test('extracts the command word from single-line slash input', () { + expect(slashCommandToken('/model sonnet'), 'model'); + expect(slashCommandToken('/whats-next'), 'whats-next'); + expect(slashCommandToken('/foo\tbar'), 'foo'); + }); + + test('returns null for non-command input', () { + expect(slashCommandToken('hello'), isNull); + expect(slashCommandToken(' /leading-space'), isNull); + expect(slashCommandToken('/'), isNull); // empty token + expect(slashCommandToken('/foo\nbar'), isNull); // multi-line + }); + }); + + group('isKnownSlashCommand', () { + const known = {'model', 'clear', 'whats-next'}; + + test('true only when the token is a recognised command', () { + expect(isKnownSlashCommand('/model opus', known), isTrue); + expect(isKnownSlashCommand('/whats-next', known), isTrue); + expect(isKnownSlashCommand('/unknown-cmd', known), isFalse); + }); + + test('a path-like leading slash is not a command (stays literal)', () { + expect(isKnownSlashCommand('/tmp/foo.log has errors', known), isFalse); + }); + + test('non-slash and multi-line input is never a command', () { + expect(isKnownSlashCommand('hello world', known), isFalse); + expect(isKnownSlashCommand('/clear\nand more', {'clear'}), isFalse); + }); + }); +} diff --git a/test/builtin/claude/tmux_session_test.dart b/test/builtin/claude/tmux_session_test.dart index 02a3643f..907ee94c 100644 --- a/test/builtin/claude/tmux_session_test.dart +++ b/test/builtin/claude/tmux_session_test.dart @@ -124,4 +124,14 @@ void main() { ]); }); }); + + group('sendCommand', () { + test('types the text literally (no bracketed paste) then submits Enter', () async { + await tmux.sendCommand('clide-claude-foo', '/whats-next'); + expect(runner.calls, [ + ['-L', 'clide', 'send-keys', '-t', 'clide-claude-foo', '-l', '--', '/whats-next'], + ['-L', 'clide', 'send-keys', '-t', 'clide-claude-foo', 'Enter'], + ]); + }); + }); }