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:
2026-05-23 12:35:53 +02:00
co-authored by Claude Opus 4.7
parent 0a781de0cd
commit 5fad8dc430
8 changed files with 109 additions and 1 deletions
+15 -1
View File
@@ -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);
}
+10
View File
@@ -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']);
}