diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs
new file mode 100644
index 0000000..1df2bab
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs
@@ -0,0 +1,210 @@
+namespace AcDream.UI.Abstractions.Panels.Chat;
+
+///
+/// Phase I.4: pure-function parse of a chat-input line into the
+/// (channel, target?, text) triple a
+/// needs. Ported from holtburger
+/// references/holtburger/apps/holtburger-cli/src/pages/game/input/commands.rs
+/// — the alias table around line ~457 and the parse_targeted_chat_command
+/// / parse_message_only_command helpers near the top of the file.
+///
+///
+/// Edge cases handled (each pinned by a test in
+/// ChatInputParserTests):
+///
+/// - empty / whitespace-only → null
+/// - /say with no message → null
+/// - /t with no target / no message → null
+/// - /r with no lastTellSender → null
+/// - unknown /xyz verb → fall through to default channel
+/// carrying the literal text (matches holtburger
+/// handle_slash_command default arm at line ~744 — they
+/// send ClientCommand::Talk(command) with the original
+/// slash-prefixed string)
+/// - multi-word /t target: first whitespace token is target,
+/// rest is message (matches Rust split_once semantics)
+///
+///
+///
+public static class ChatInputParser
+{
+ ///
+ /// Outcome of a successful parse. Pass straight into
+ /// 's constructor.
+ ///
+ public readonly record struct ParsedInput(
+ ChatChannelKind Channel,
+ string? TargetName,
+ string Text);
+
+ // Alias tables. Order matters only for error messages — verb
+ // matching is exact-token, not prefix.
+ private static readonly string[] SayAliases = { "/say" };
+ private static readonly string[] TellAliases = { "/tell", "/t" };
+ private static readonly string[] ReplyAliases = { "/reply", "/r" };
+
+ // Channel aliases. Each maps a single verb token to a channel kind.
+ // The same list drives both the verb test and the prefix-strip.
+ private static readonly (string Verb, ChatChannelKind Channel)[] ChannelVerbs =
+ {
+ ("/g", ChatChannelKind.General),
+ ("/f", ChatChannelKind.Fellowship),
+ ("/a", ChatChannelKind.Allegiance),
+ ("/m", ChatChannelKind.Monarch),
+ ("/p", ChatChannelKind.Patron),
+ ("/v", ChatChannelKind.Vassals),
+ ("/cv", ChatChannelKind.CoVassals),
+ ("/lfg", ChatChannelKind.Lfg),
+ ("/trade", ChatChannelKind.Trade),
+ ("/role", ChatChannelKind.Roleplay),
+ ("/society", ChatChannelKind.Society),
+ ("/olthoi", ChatChannelKind.Olthoi),
+ };
+
+ ///
+ /// Parse into a
+ /// triple, or null if the input is empty / whitespace /
+ /// missing required arguments (e.g. /t with no target).
+ ///
+ /// User input. The caller should
+ /// before passing.
+ /// Channel to use for unprefixed text
+ /// or unrecognised slash commands.
+ /// Sender of the most recent incoming
+ /// Tell, used to resolve /r reply target. Null if no Tell
+ /// has arrived this session.
+ public static ParsedInput? Parse(string raw, ChatChannelKind defaultChannel, string? lastTellSender)
+ {
+ if (string.IsNullOrWhiteSpace(raw)) return null;
+ var trimmed = raw.Trim();
+
+ // /say
+ if (TryParseMessageOnly(trimmed, SayAliases, out var sayMsg))
+ return new ParsedInput(ChatChannelKind.Say, null, sayMsg);
+ if (IsBareVerb(trimmed, SayAliases))
+ return null;
+
+ // /tell or /t
+ if (TryParseTargeted(trimmed, TellAliases, out var tellTarget, out var tellMsg))
+ return new ParsedInput(ChatChannelKind.Tell, tellTarget, tellMsg);
+ if (IsBareVerb(trimmed, TellAliases) || IsVerbWithSingleToken(trimmed, TellAliases))
+ return null;
+
+ // /r / /reply — needs prior incoming tell.
+ if (TryParseMessageOnly(trimmed, ReplyAliases, out var replyMsg))
+ {
+ if (string.IsNullOrEmpty(lastTellSender)) return null;
+ return new ParsedInput(ChatChannelKind.Tell, lastTellSender, replyMsg);
+ }
+ if (IsBareVerb(trimmed, ReplyAliases))
+ return null;
+
+ // Channel verbs (/g, /f, /a, ...).
+ foreach (var (verb, channel) in ChannelVerbs)
+ {
+ if (TryParseMessageOnly(trimmed, new[] { verb }, out var msg))
+ return new ParsedInput(channel, null, msg);
+ if (IsBareVerb(trimmed, new[] { verb }))
+ return null;
+ }
+
+ // Unknown slash command: holtburger falls back to Talk(literal).
+ // We mirror that — emit on the default channel with the
+ // slash-prefixed text intact so the server treats it as speech.
+ // No special-case for known-but-unimplemented verbs; the user's
+ // text round-trips so their intent isn't silently dropped.
+ return new ParsedInput(defaultChannel, null, trimmed);
+ }
+
+ // ── helpers ──────────────────────────────────────────────────────
+
+ ///
+ /// Match holtburger's parse_targeted_chat_command: split on
+ /// first whitespace into verb / rest, check verb against aliases,
+ /// then split rest into target / message. Returns false if either
+ /// the verb is wrong or target / message is empty.
+ ///
+ private static bool TryParseTargeted(string command, string[] aliases, out string target, out string message)
+ {
+ target = string.Empty;
+ message = string.Empty;
+
+ int firstWs = IndexOfWhitespace(command);
+ if (firstWs < 0) return false;
+
+ var verb = command.Substring(0, firstWs);
+ if (!ContainsExact(aliases, verb)) return false;
+
+ var rest = command.Substring(firstWs + 1).TrimStart();
+ if (rest.Length == 0) return false;
+
+ int targetEnd = IndexOfWhitespace(rest);
+ if (targetEnd < 0) return false; // target only, no message
+
+ target = rest.Substring(0, targetEnd);
+ message = rest.Substring(targetEnd + 1).TrimStart();
+ if (target.Length == 0 || message.Length == 0) return false;
+ return true;
+ }
+
+ ///
+ /// Match holtburger's parse_message_only_command: split on
+ /// first whitespace, alias-match the verb, return the trimmed rest
+ /// as the message. Empty-message → false.
+ ///
+ private static bool TryParseMessageOnly(string command, string[] aliases, out string message)
+ {
+ message = string.Empty;
+ int firstWs = IndexOfWhitespace(command);
+ if (firstWs < 0) return false;
+
+ var verb = command.Substring(0, firstWs);
+ if (!ContainsExact(aliases, verb)) return false;
+
+ message = command.Substring(firstWs + 1).TrimStart();
+ return message.Length > 0;
+ }
+
+ ///
+ /// True when is exactly one of the
+ /// alias verbs (no message, no extra whitespace beyond trimming).
+ ///
+ private static bool IsBareVerb(string command, string[] aliases)
+ {
+ foreach (var alias in aliases)
+ if (command == alias) return true;
+ return false;
+ }
+
+ ///
+ /// True when is "verb arg" with no
+ /// further whitespace — e.g. /t Bestie (target but no
+ /// message). Used to short-circuit the "fall through to default
+ /// channel" path so a half-typed tell doesn't get sent as Say.
+ ///
+ private static bool IsVerbWithSingleToken(string command, string[] aliases)
+ {
+ int firstWs = IndexOfWhitespace(command);
+ if (firstWs < 0) return false;
+ var verb = command.Substring(0, firstWs);
+ if (!ContainsExact(aliases, verb)) return false;
+
+ var rest = command.Substring(firstWs + 1).TrimStart();
+ if (rest.Length == 0) return true;
+ return IndexOfWhitespace(rest) < 0;
+ }
+
+ private static int IndexOfWhitespace(string s)
+ {
+ for (int i = 0; i < s.Length; i++)
+ if (char.IsWhiteSpace(s[i])) return i;
+ return -1;
+ }
+
+ private static bool ContainsExact(string[] aliases, string verb)
+ {
+ for (int i = 0; i < aliases.Length; i++)
+ if (aliases[i] == verb) return true;
+ return false;
+ }
+}
diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs
index 3ca9c6a..4e89c4e 100644
--- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs
@@ -1,23 +1,37 @@
namespace AcDream.UI.Abstractions.Panels.Chat;
///
-/// Second real UI panel — shows the tail of the chat log.
-/// Exercises +
-/// on a non-trivial render pattern (N lines, not a single widget) —
-/// proving the D.2a abstraction contract holds for more than the vitals
-/// HUD before we grow the panel catalog further.
+/// The chat panel. Shows the tail of + an input
+/// field at the bottom that submits on Enter.
///
///
-/// D.2a scope: show the last
-/// lines with a separator above the tail and each entry as a single
-/// Text call. No input field (outbound chat already has wire
-/// support via SendChat — a text-input widget on
-/// lands with the first panel that actually needs one, not here).
+/// Phase I.4 added the input field and slash-command parsing. Supported
+/// prefixes (alias-matched against the verb token, not by string-prefix
+/// — so /general is NOT /g):
+///
+/// - /say <msg> or no prefix → Say (default)
+/// - /t / /tell <name> <msg> → whisper
+/// - /r / /reply <msg> → reply to most recent
+/// INCOMING Tell (uses ;
+/// drops the message if no Tell has arrived yet)
+/// - /g, /f, /a, /m, /p, /v, /cv, /lfg, /trade, /role, /society,
+/// /olthoi <msg> → corresponding channel
+/// - unknown /xyz hello → Say with the literal text intact
+/// (matches holtburger fall-through)
+///
+///
+///
+///
+/// Empty / whitespace-only / target-but-no-message inputs are silently
+/// dropped — the input field clears and no command goes out.
///
///
public sealed class ChatPanel : IPanel
{
+ private const int InputBufferMaxLen = 512;
+
private readonly ChatVM _vm;
+ private string _input = string.Empty;
public ChatPanel(ChatVM vm)
{
@@ -59,6 +73,22 @@ public sealed class ChatPanel : IPanel
}
}
+ // Phase I.4: input field. Backend implementation clears _input
+ // on submit per the IPanelRenderer contract.
+ renderer.Separator();
+ if (renderer.InputTextSubmit("##chatinput", ref _input, InputBufferMaxLen, out var submitted)
+ && submitted is not null)
+ {
+ var parsed = ChatInputParser.Parse(submitted.Trim(), ChatChannelKind.Say, _vm.LastIncomingTellSender);
+ 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.
+ _input = string.Empty;
+ }
+
renderer.End();
}
}
diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs
index 636d2a6..6c506fa 100644
--- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs
@@ -28,6 +28,19 @@ public sealed class ChatVM
private readonly ChatLog _log;
private readonly int _displayLimit;
+ ///
+ /// Sender name of the most recent INCOMING Tell. Drives the
+ /// /r reply slash command in .
+ /// Null until the first Tell arrives. Outgoing self-sent Tell
+ /// echoes (which run through ) do
+ /// NOT update this — we discriminate by SenderGuid != 0;
+ /// only real inbound tells from
+ /// carry a non-zero guid. Mirrors holtburger
+ /// chat.rs::ChatState::last_incoming_tell_sender (line 74 +
+ /// the assignment at line 152).
+ ///
+ public string? LastIncomingTellSender { get; private set; }
+
///
/// Build a ChatVM bound to a instance.
///
@@ -43,6 +56,20 @@ public sealed class ChatVM
if (displayLimit < 1)
throw new ArgumentOutOfRangeException(nameof(displayLimit), displayLimit, "must be >= 1");
_displayLimit = displayLimit;
+ _log.EntryAppended += OnEntryAppended;
+ }
+
+ private void OnEntryAppended(ChatEntry entry)
+ {
+ // Only INCOMING tells update the reply target. Self-sent
+ // echoes from OnSelfSent always carry SenderGuid == 0 (we
+ // never know our own guid here), so guid == 0 is a safe
+ // discriminator for "skip this one". An incoming tell from
+ // a player always carries the sender's real guid.
+ if (entry.Kind != ChatKind.Tell) return;
+ if (entry.SenderGuid == 0) return;
+ if (string.IsNullOrEmpty(entry.Sender)) return;
+ LastIncomingTellSender = entry.Sender;
}
///
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs
new file mode 100644
index 0000000..0ca465e
--- /dev/null
+++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs
@@ -0,0 +1,212 @@
+using AcDream.UI.Abstractions.Panels.Chat;
+
+namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
+
+///
+/// TDD coverage for . Pure function:
+/// no fakes, no setup. Each prefix in the holtburger
+/// chat.rs alias table gets at least one round-trip test plus
+/// every documented edge case.
+///
+public sealed class ChatInputParserTests
+{
+ // -- Default channel (no prefix / explicit /say) --------------------
+
+ [Fact]
+ public void NoPrefix_UsesDefaultChannelLiteral()
+ {
+ var parsed = ChatInputParser.Parse("hello world", ChatChannelKind.Say, lastTellSender: null);
+
+ Assert.NotNull(parsed);
+ Assert.Equal(ChatChannelKind.Say, parsed!.Value.Channel);
+ Assert.Null(parsed.Value.TargetName);
+ Assert.Equal("hello world", parsed.Value.Text);
+ }
+
+ [Fact]
+ public void SayPrefix_StripsTheSlashSay()
+ {
+ var parsed = ChatInputParser.Parse("/say hi there", ChatChannelKind.Say, lastTellSender: null);
+
+ Assert.NotNull(parsed);
+ Assert.Equal(ChatChannelKind.Say, parsed!.Value.Channel);
+ Assert.Null(parsed.Value.TargetName);
+ Assert.Equal("hi there", parsed.Value.Text);
+ }
+
+ // -- Tell aliases ----------------------------------------------------
+
+ [Fact]
+ public void TellPrefix_FullForm_ParsesTargetAndMessage()
+ {
+ var parsed = ChatInputParser.Parse("/tell Bestie hi there", ChatChannelKind.Say, lastTellSender: null);
+
+ Assert.NotNull(parsed);
+ Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
+ Assert.Equal("Bestie", parsed.Value.TargetName);
+ Assert.Equal("hi there", parsed.Value.Text);
+ }
+
+ [Fact]
+ public void TellPrefix_ShortForm_ParsesTargetAndMessage()
+ {
+ var parsed = ChatInputParser.Parse("/t Bestie hi", ChatChannelKind.Say, lastTellSender: null);
+
+ Assert.NotNull(parsed);
+ Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
+ Assert.Equal("Bestie", parsed.Value.TargetName);
+ Assert.Equal("hi", parsed.Value.Text);
+ }
+
+ [Fact]
+ public void TellPrefix_MultiWordTarget_ChompsFirstTokenAsTarget()
+ {
+ // Holtburger split_once on whitespace: first token is target.
+ // "/t Sir Lancelot hello" -> target="Sir", text="Lancelot hello".
+ var parsed = ChatInputParser.Parse("/t Sir Lancelot hello", ChatChannelKind.Say, lastTellSender: null);
+
+ Assert.NotNull(parsed);
+ Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
+ Assert.Equal("Sir", parsed.Value.TargetName);
+ Assert.Equal("Lancelot hello", parsed.Value.Text);
+ }
+
+ // -- Reply aliases ---------------------------------------------------
+
+ [Fact]
+ public void ReplyPrefix_UsesLastIncomingTellSenderAsTarget()
+ {
+ var parsed = ChatInputParser.Parse("/r back at you", ChatChannelKind.Say, lastTellSender: "Bestie");
+
+ Assert.NotNull(parsed);
+ Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
+ Assert.Equal("Bestie", parsed.Value.TargetName);
+ Assert.Equal("back at you", parsed.Value.Text);
+ }
+
+ [Fact]
+ public void ReplyPrefix_NoLastSender_ReturnsNull()
+ {
+ var parsed = ChatInputParser.Parse("/r hi", ChatChannelKind.Say, lastTellSender: null);
+
+ Assert.Null(parsed);
+ }
+
+ // -- Channel aliases (single-message) -------------------------------
+
+ [Theory]
+ [InlineData("/g raid time", ChatChannelKind.General, "raid time")]
+ [InlineData("/f buff up", ChatChannelKind.Fellowship, "buff up")]
+ [InlineData("/a swearing in", ChatChannelKind.Allegiance, "swearing in")]
+ [InlineData("/m monarch broadcast", ChatChannelKind.Monarch, "monarch broadcast")]
+ [InlineData("/p patron only", ChatChannelKind.Patron, "patron only")]
+ [InlineData("/v vassals only", ChatChannelKind.Vassals, "vassals only")]
+ [InlineData("/cv covassals only", ChatChannelKind.CoVassals, "covassals only")]
+ [InlineData("/lfg need 3 more", ChatChannelKind.Lfg, "need 3 more")]
+ [InlineData("/trade wts gem", ChatChannelKind.Trade, "wts gem")]
+ [InlineData("/role *waves*", ChatChannelKind.Roleplay, "*waves*")]
+ [InlineData("/society olthoi raid", ChatChannelKind.Society, "olthoi raid")]
+ [InlineData("/olthoi for the queen", ChatChannelKind.Olthoi, "for the queen")]
+ public void ChannelPrefixes_RouteToTheirChannel(string raw, ChatChannelKind expectedChannel, string expectedText)
+ {
+ var parsed = ChatInputParser.Parse(raw, ChatChannelKind.Say, lastTellSender: null);
+
+ Assert.NotNull(parsed);
+ Assert.Equal(expectedChannel, parsed!.Value.Channel);
+ Assert.Null(parsed.Value.TargetName);
+ Assert.Equal(expectedText, parsed.Value.Text);
+ }
+
+ // -- Edge cases: empty / whitespace / no-message --------------------
+
+ [Fact]
+ public void Empty_ReturnsNull()
+ {
+ Assert.Null(ChatInputParser.Parse("", ChatChannelKind.Say, lastTellSender: null));
+ }
+
+ [Fact]
+ public void WhitespaceOnly_ReturnsNull()
+ {
+ Assert.Null(ChatInputParser.Parse(" \t ", ChatChannelKind.Say, lastTellSender: null));
+ }
+
+ [Fact]
+ public void SayWithNoMessage_ReturnsNull()
+ {
+ Assert.Null(ChatInputParser.Parse("/say", ChatChannelKind.Say, lastTellSender: null));
+ Assert.Null(ChatInputParser.Parse("/say ", ChatChannelKind.Say, lastTellSender: null));
+ }
+
+ [Fact]
+ public void TellWithNoTargetNoMessage_ReturnsNull()
+ {
+ Assert.Null(ChatInputParser.Parse("/t", ChatChannelKind.Say, lastTellSender: null));
+ Assert.Null(ChatInputParser.Parse("/tell", ChatChannelKind.Say, lastTellSender: null));
+ }
+
+ [Fact]
+ public void TellWithTargetButNoMessage_ReturnsNull()
+ {
+ Assert.Null(ChatInputParser.Parse("/t Bestie", ChatChannelKind.Say, lastTellSender: null));
+ Assert.Null(ChatInputParser.Parse("/tell Bestie ", ChatChannelKind.Say, lastTellSender: null));
+ }
+
+ [Fact]
+ public void ReplyWithNoMessage_ReturnsNull_EvenWithLastSender()
+ {
+ Assert.Null(ChatInputParser.Parse("/r", ChatChannelKind.Say, lastTellSender: "Bestie"));
+ Assert.Null(ChatInputParser.Parse("/r ", ChatChannelKind.Say, lastTellSender: "Bestie"));
+ }
+
+ [Fact]
+ public void ChannelPrefixWithNoMessage_ReturnsNull()
+ {
+ Assert.Null(ChatInputParser.Parse("/g", ChatChannelKind.Say, lastTellSender: null));
+ Assert.Null(ChatInputParser.Parse("/f ", ChatChannelKind.Say, lastTellSender: null));
+ Assert.Null(ChatInputParser.Parse("/a", ChatChannelKind.Say, lastTellSender: null));
+ }
+
+ // -- Unknown slash command: holtburger fall-through to Talk(literal)
+ // ("/wave hello" → Talk("/wave hello") + treat-as-Say). See
+ // chat.rs::handle_slash_command default arm at line ~744.
+ [Fact]
+ public void UnknownSlashCommand_FallsBackToDefaultChannelWithLiteralText()
+ {
+ var parsed = ChatInputParser.Parse("/xyz hello", ChatChannelKind.Say, lastTellSender: null);
+
+ Assert.NotNull(parsed);
+ Assert.Equal(ChatChannelKind.Say, parsed!.Value.Channel);
+ Assert.Null(parsed.Value.TargetName);
+ Assert.Equal("/xyz hello", parsed.Value.Text);
+ }
+
+ // -- Default-channel parameter is honoured when no prefix is given --
+
+ [Fact]
+ public void NoPrefix_HonoursAlternateDefaultChannel()
+ {
+ // If the panel ever defaults to e.g. Fellowship channel input,
+ // the parser must use that for unprefixed text.
+ var parsed = ChatInputParser.Parse("hi gang", ChatChannelKind.Fellowship, lastTellSender: null);
+
+ Assert.NotNull(parsed);
+ Assert.Equal(ChatChannelKind.Fellowship, parsed!.Value.Channel);
+ Assert.Null(parsed.Value.TargetName);
+ Assert.Equal("hi gang", parsed.Value.Text);
+ }
+
+ // -- The verb itself must be exact: prefix-of-other-word doesn't count --
+
+ [Fact]
+ public void PrefixSubstring_IsNotAVerbMatch()
+ {
+ // "/general" (no leading "/g " token) is NOT /g; it's just text.
+ // Must not be misclassified as /g + "eneral".
+ var parsed = ChatInputParser.Parse("/general public", ChatChannelKind.Say, lastTellSender: null);
+
+ Assert.NotNull(parsed);
+ Assert.Equal(ChatChannelKind.Say, parsed!.Value.Channel);
+ Assert.Equal("/general public", parsed.Value.Text);
+ }
+}
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs
new file mode 100644
index 0000000..8e32f71
--- /dev/null
+++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs
@@ -0,0 +1,139 @@
+using AcDream.Core.Chat;
+using AcDream.UI.Abstractions.Panels.Chat;
+
+namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
+
+///
+/// Phase I.4: when the user submits text via the chat input field, the
+/// panel must publish a to the command bus.
+/// We exercise the full Render path with the
+/// pre-loading a "submitted" string and a recording bus capturing the
+/// resulting command.
+///
+public sealed class ChatPanelInputTests
+{
+ private sealed class RecordingBus : ICommandBus
+ {
+ public List