diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs
index 2e41997..82aaf76 100644
--- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs
@@ -304,4 +304,21 @@ public static class ChatInputParser
foreach (var (v, _) in ChannelVerbs) set.Add(v);
return set;
}
+
+ ///
+ /// Returns true if (with leading
+ /// /) is one this parser routes — used by callers that
+ /// need to distinguish "unknown slash command" from "known
+ /// verb with bad arguments" without reproducing the alias
+ /// tables. @-prefixed verbs need to be normalized to
+ /// / before passing.
+ ///
+ public static bool IsKnownVerb(string verb) => AllKnownVerbs.Contains(verb);
+
+ ///
+ /// Pull the first whitespace-separated token (the command verb)
+ /// from . Returns the entire string if
+ /// there is no whitespace.
+ ///
+ public static string GetVerbToken(string command) => ExtractVerb(command);
}
diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs
index c60524b..fe8939f 100644
--- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs
@@ -131,6 +131,27 @@ public sealed class ChatPanel : IPanel
return;
}
+ // Phase J Tier 4: any /-prefixed input that ISN'T one of our
+ // known verbs gets a local "Unknown command" message instead
+ // of being broadcast to the server as plain speech. The
+ // user reported "/ls" / "/mp /path" leaking out as chat —
+ // a / prefix is a command, never speech. (@-prefixed unknown
+ // verbs still pass through to ACE because ACE's
+ // CommandManager intercepts @ server-side and replies with
+ // its own "Unknown command" / valid command output.)
+ if (trimmed.Length > 0 && trimmed[0] == '/')
+ {
+ string verb = ChatInputParser.GetVerbToken(trimmed);
+ if (!ChatInputParser.IsKnownVerb(verb))
+ {
+ _vm.ShowSystemMessage(
+ $"Unknown command: {verb}. Type /help for the list of supported commands.");
+ _input = string.Empty;
+ renderer.End();
+ return;
+ }
+ }
+
var parsed = ChatInputParser.Parse(
trimmed,
ChatChannelKind.Say,
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs
index 2ac4c2c..052aa6b 100644
--- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs
+++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs
@@ -228,8 +228,11 @@ public sealed class ChatInputParserTests
// Phase J added long-form aliases (/general, /allegiance,
// /patron, etc.). The exact-token rule still applies — a
// verb prefix that ISN'T one of the listed aliases falls
- // through to the default channel. "/genio" is not /g, /general,
- // or /gen — must stay as Say carrying the literal text.
+ // through. The Parse-level behaviour for unknown /-verbs is
+ // still "Say with the literal text" (matches holtburger);
+ // the ChatPanel layer is what catches unknowns and shows the
+ // local "Unknown command" line. ChatPanelInputTests cover
+ // that end-to-end behaviour.
var parsed = ChatInputParser.Parse("/genio public", ChatChannelKind.Say, lastTellSender: null);
Assert.NotNull(parsed);
@@ -237,6 +240,32 @@ public sealed class ChatInputParserTests
Assert.Equal("/genio public", parsed.Value.Text);
}
+ [Theory]
+ [InlineData("/g", true)]
+ [InlineData("/say", true)]
+ [InlineData("/tell", true)]
+ [InlineData("/retell", true)]
+ [InlineData("/allegiance", true)]
+ [InlineData("/lookingforgroup", true)]
+ [InlineData("/genio", false)]
+ [InlineData("/ls", false)]
+ [InlineData("/foo", false)]
+ [InlineData("/", false)]
+ public void IsKnownVerb_ChecksAgainstAliasTables(string verb, bool expected)
+ {
+ Assert.Equal(expected, ChatInputParser.IsKnownVerb(verb));
+ }
+
+ [Theory]
+ [InlineData("/g hello", "/g")]
+ [InlineData("/tell Bob hi", "/tell")]
+ [InlineData("/foo", "/foo")]
+ [InlineData("/", "/")]
+ public void GetVerbToken_PullsFirstWhitespaceToken(string command, string expected)
+ {
+ Assert.Equal(expected, ChatInputParser.GetVerbToken(command));
+ }
+
[Theory]
[InlineData("/general what's the deal", ChatChannelKind.General, "what's the deal")]
[InlineData("/allegiance recall", ChatChannelKind.Allegiance, "recall")]
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs
index 3496a42..07b3bf5 100644
--- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs
+++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs
@@ -112,6 +112,38 @@ public sealed class ChatPanelInputTests
Assert.Contains("(10.0, 20.0, 30.0)", entry.Text);
}
+ [Theory]
+ [InlineData("/foo")]
+ [InlineData("/ls")]
+ [InlineData("/mp /tools/script.py")]
+ [InlineData("/genio public")]
+ [InlineData("/")]
+ public void Submit_UnknownSlashCommand_ShowsUnknownAndDoesNotPublish(string raw)
+ {
+ // Phase J Tier 4: /-prefixed text is NEVER broadcast as plain
+ // speech. Filed after a 2026-04-25 trace where typing /ls (a
+ // command-style request the user wanted) was getting echoed by
+ // the server as "You say, \"/ls\"". Now we intercept and show
+ // a local "Unknown command" line; nothing goes on the wire.
+ var log = new ChatLog();
+ var vm = new ChatVM(log);
+ var panel = new ChatPanel(vm);
+ var bus = new RecordingBus();
+ var renderer = new FakePanelRenderer
+ {
+ InputTextSubmitNextSubmitted = raw,
+ InputTextSubmitNextBufferAfter = "",
+ };
+
+ panel.Render(new PanelContext(0.016f, bus), renderer);
+
+ Assert.Empty(bus.Published);
+ var entry = Assert.Single(log.Snapshot());
+ Assert.Equal(ChatKind.System, entry.Kind);
+ Assert.Contains("Unknown command", entry.Text);
+ Assert.Contains("/help", entry.Text);
+ }
+
[Fact]
public void Submit_AtAcehelp_PassesThroughToSayWithAtIntact()
{