diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs
new file mode 100644
index 00000000..9158d2d0
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs
@@ -0,0 +1,78 @@
+using System;
+
+namespace AcDream.UI.Abstractions.Panels.Chat;
+
+/// What a submit did, so the caller can clear its input + give feedback.
+public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped }
+
+///
+/// Shared chat-submit pipeline (retail ChatInterface::ProcessCommand @0x4f5100
+/// analogue). Both the ImGui devtools and the retail
+/// chat window route through here so command handling stays in one place.
+///
+/// Order mirrors the prior inline flow:
+/// client-command intercept → unknown-slash-verb guard →
+/// → Publish(SendChatCmd).
+///
+public static class ChatCommandRouter
+{
+ public static SubmitOutcome Submit(
+ string raw, ChatVM vm, ICommandBus bus, ChatChannelKind defaultChannel)
+ {
+ ArgumentNullException.ThrowIfNull(vm);
+ ArgumentNullException.ThrowIfNull(bus);
+ var trimmed = (raw ?? string.Empty).Trim();
+ if (trimmed.Length == 0) return SubmitOutcome.Empty;
+
+ if (TryHandleClientCommand(trimmed, vm)) return SubmitOutcome.ClientHandled;
+
+ if (trimmed[0] == '/')
+ {
+ var verb = ChatInputParser.GetVerbToken(trimmed);
+ if (!ChatInputParser.IsKnownVerb(verb))
+ {
+ vm.ShowSystemMessage(
+ $"Unknown command: {verb}. Type /help for the list of supported commands.");
+ return SubmitOutcome.UnknownCommand;
+ }
+ }
+
+ var parsed = ChatInputParser.Parse(
+ trimmed, defaultChannel, vm.LastIncomingTellSender, vm.LastOutgoingTellTarget);
+ if (parsed is { } p)
+ {
+ bus.Publish(new SendChatCmd(p.Channel, p.TargetName, p.Text));
+ return SubmitOutcome.Sent;
+ }
+ return SubmitOutcome.Dropped;
+ }
+
+ private static bool TryHandleClientCommand(string trimmed, ChatVM vm)
+ {
+ if (EqAny(trimmed, "/help", "/?", "/h", "@help", "@?", "@h"))
+ { vm.ShowSystemMessage(BuildHelpText()); return true; }
+ if (EqAny(trimmed, "/clear", "/cls", "@clear", "@cls"))
+ { vm.Clear(); return true; }
+ if (EqAny(trimmed, "/framerate", "@framerate"))
+ { vm.ShowFps(); return true; }
+ if (EqAny(trimmed, "/loc", "@loc"))
+ { vm.ShowLocation(); return true; }
+ return false;
+ }
+
+ private static bool EqAny(string s, params string[] options)
+ {
+ for (int i = 0; i < options.Length; i++)
+ if (s.Equals(options[i], StringComparison.OrdinalIgnoreCase)) return true;
+ return false;
+ }
+
+ private static string BuildHelpText() =>
+ "Note: / and @ are equivalent prefixes.\n" +
+ "Chat: /say (default), /tell , /reply, /retell\n" +
+ "Channels: /general /trade /fellowship /allegiance\n" +
+ " /patron /vassals /monarch /covassals\n" +
+ " /lfg /roleplay /society /olthoi\n" +
+ "Client: /help (this) /clear /framerate /loc\n" +
+ "Server: type @acehelp or @acecommands for ACE's full list.";
+}
diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs
index c8ece999..9cb8cb1f 100644
--- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs
@@ -191,53 +191,7 @@ public sealed class ChatPanel : IPanel
if (renderer.InputTextSubmit("##chatinput", ref _input, InputBufferMaxLen, out var submitted)
&& submitted is not null)
{
- var trimmed = submitted.Trim();
- // Phase J follow-up: client-side commands intercepted before
- // the server-bound parse path. Avoids the /help round-trip
- // that produced "Unknown command: help" duplicates from
- // ACE's command-error replies, AND gives users a discoverable
- // local cheat-sheet of acdream's own slash prefixes.
- if (TryHandleClientCommand(trimmed))
- {
- _input = string.Empty;
- renderer.EndChild(); // outer ##chatbody
- renderer.End();
- 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.EndChild(); // outer ##chatbody
- renderer.End();
- return;
- }
- }
-
- var parsed = ChatInputParser.Parse(
- trimmed,
- ChatChannelKind.Say,
- _vm.LastIncomingTellSender,
- _vm.LastOutgoingTellTarget);
- if (parsed is { } p)
- {
- ctx.Commands.Publish(new SendChatCmd(p.Channel, p.TargetName, p.Text));
- }
- // Defensive: if the backend ever forgot to clear on submit,
- // do it here. Cheap; no harm if already empty.
+ ChatCommandRouter.Submit(submitted, _vm, ctx.Commands, ChatChannelKind.Say);
_input = string.Empty;
}
@@ -258,79 +212,4 @@ public sealed class ChatPanel : IPanel
_ => new Vector4(1f, 1f, 1f, 1f),
};
- ///
- /// Phase J follow-up: handle client-side slash commands before
- /// the parser passes anything to the server bus. Returns true
- /// when the input was consumed (and the caller should clear the
- /// buffer + skip the SendChatCmd path); false otherwise.
- ///
- ///
- /// Recognised client-side commands:
- ///
- /// - /help, /?, /h — render the slash-prefix
- /// cheat-sheet locally. Avoids the server's "Unknown command"
- /// round-trip when the user just wants to know what they can
- /// type.
- /// - /clear, /cls — drain the chat log so the
- /// panel starts empty.
- ///
- ///
- private bool TryHandleClientCommand(string trimmed)
- {
- if (trimmed.Length == 0) return false;
-
- // /help, /?, /h — also @help, @?, @h per ACE's "/ ↔ @" equivalence.
- if (EqAny(trimmed, "/help", "/?", "/h", "@help", "@?", "@h"))
- {
- _vm.ShowSystemMessage(BuildHelpText());
- return true;
- }
-
- // /clear, /cls — also @clear, @cls.
- if (EqAny(trimmed, "/clear", "/cls", "@clear", "@cls"))
- {
- _vm.Clear();
- return true;
- }
-
- // /framerate — also @framerate. Prints current FPS to chat.
- if (EqAny(trimmed, "/framerate", "@framerate"))
- {
- _vm.ShowFps();
- return true;
- }
-
- // /loc — also @loc. Prints current player position to chat.
- // ACE has a server-side @loc too; client-side wins here
- // (instantaneous + uses our local interpolated position).
- if (EqAny(trimmed, "/loc", "@loc"))
- {
- _vm.ShowLocation();
- return true;
- }
-
- return false;
- }
-
- /// Case-insensitive multi-string equality test.
- private static bool EqAny(string s, params string[] options)
- {
- for (int i = 0; i < options.Length; i++)
- if (s.Equals(options[i], StringComparison.OrdinalIgnoreCase)) return true;
- return false;
- }
-
- ///
- /// Multi-line cheat-sheet text rendered by /help. ImGui's
- /// Text path flows embedded newlines naturally so this lands
- /// as one ChatLog entry that visually wraps to several lines.
- ///
- private static string BuildHelpText() =>
- "Note: / and @ are equivalent prefixes.\n" +
- "Chat: /say (default), /tell , /reply, /retell\n" +
- "Channels: /general /trade /fellowship /allegiance\n" +
- " /patron /vassals /monarch /covassals\n" +
- " /lfg /roleplay /society /olthoi\n" +
- "Client: /help (this) /clear /framerate /loc\n" +
- "Server: type @acehelp or @acecommands for ACE's full list.";
}
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs
new file mode 100644
index 00000000..e0f1daad
--- /dev/null
+++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs
@@ -0,0 +1,74 @@
+using AcDream.Core.Chat;
+using AcDream.UI.Abstractions;
+using AcDream.UI.Abstractions.Panels.Chat;
+using Xunit;
+
+namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
+
+public class ChatCommandRouterTests
+{
+ private sealed class CaptureBus : ICommandBus
+ {
+ public SendChatCmd? Last;
+ public void Publish(T command) where T : notnull
+ {
+ if (command is SendChatCmd c) Last = c;
+ }
+ }
+
+ private static (ChatVM vm, ChatLog log, CaptureBus bus) Fixture()
+ {
+ var log = new ChatLog();
+ var vm = new ChatVM(log, displayLimit: 50);
+ return (vm, log, new CaptureBus());
+ }
+
+ [Fact]
+ public void PlainText_PublishesOnDefaultChannel()
+ {
+ var (vm, _, bus) = Fixture();
+ var outcome = ChatCommandRouter.Submit("hello there", vm, bus, ChatChannelKind.Say);
+ Assert.Equal(SubmitOutcome.Sent, outcome);
+ Assert.NotNull(bus.Last);
+ Assert.Equal(ChatChannelKind.Say, bus.Last!.Channel);
+ Assert.Equal("hello there", bus.Last.Text);
+ }
+
+ [Fact]
+ public void DefaultChannel_IsHonored()
+ {
+ var (vm, _, bus) = Fixture();
+ ChatCommandRouter.Submit("hi", vm, bus, ChatChannelKind.Fellowship);
+ Assert.Equal(ChatChannelKind.Fellowship, bus.Last!.Channel);
+ }
+
+ [Fact]
+ public void ClearCommand_DrainsLog_DoesNotPublish()
+ {
+ var (vm, log, bus) = Fixture();
+ log.OnSystemMessage("x", chatType: 0);
+ var outcome = ChatCommandRouter.Submit("/clear", vm, bus, ChatChannelKind.Say);
+ Assert.Equal(SubmitOutcome.ClientHandled, outcome);
+ Assert.Null(bus.Last);
+ Assert.Empty(log.Snapshot());
+ }
+
+ [Fact]
+ public void UnknownSlashVerb_ShowsSystemMessage_DoesNotPublish()
+ {
+ var (vm, log, bus) = Fixture();
+ var outcome = ChatCommandRouter.Submit("/notacommand", vm, bus, ChatChannelKind.Say);
+ Assert.Equal(SubmitOutcome.UnknownCommand, outcome);
+ Assert.Null(bus.Last);
+ Assert.Contains(log.Snapshot(), e => e.Text.Contains("Unknown command"));
+ }
+
+ [Fact]
+ public void EmptyInput_DoesNothing()
+ {
+ var (vm, _, bus) = Fixture();
+ var outcome = ChatCommandRouter.Submit(" ", vm, bus, ChatChannelKind.Say);
+ Assert.Equal(SubmitOutcome.Empty, outcome);
+ Assert.Null(bus.Last);
+ }
+}