deliver slash commands typed so the Claude TUI runs them
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 -- <text> 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 -- <text> 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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ClaudePane> {
|
||||
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<ClaudePane> {
|
||||
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;
|
||||
|
||||
@@ -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<String> known) {
|
||||
final token = slashCommandToken(text);
|
||||
return token != null && known.contains(token);
|
||||
}
|
||||
@@ -80,3 +80,13 @@ Future<void> 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<void> sendCommand(String session, String text) async {
|
||||
await tmuxRunner([..._socket, 'send-keys', '-t', session, '-l', '--', text]);
|
||||
await tmuxRunner([..._socket, 'send-keys', '-t', session, 'Enter']);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user