feat(ui): #17 ChatPanel input field + slash commands + reply-to-last-tell

ChatPanel gains an Enter-to-submit input field via the I.1
InputTextSubmit widget. Submitted text routes through ChatInputParser
to a SendChatCmd published on ctx.Commands; LiveCommandBus (I.3)
handles the wire send + ChatLog echo.

Recognised prefixes (ported from holtburger commands.rs):

  /say msg or no prefix  -> Say
  /t Name msg or /tell   -> Tell  (first whitespace token = target)
  /r msg                 -> Tell  (target = LastIncomingTellSender)
  /g msg                 -> General
  /f msg                 -> Fellowship
  /a msg                 -> Allegiance
  /m msg                 -> Monarch
  /p msg                 -> Patron
  /v msg                 -> Vassals
  /cv msg                -> CoVassals
  /lfg msg               -> Lfg
  /trade msg             -> Trade
  /role msg              -> Roleplay
  /society msg           -> Society
  /olthoi msg            -> Olthoi

Edge cases: empty / whitespace / cmd-without-message / /r without
prior tell -> null (no-op). Unknown /xyz prefix -> Say with literal
text (matches holtburger's Talk(command) default arm).

ChatVM.LastIncomingTellSender populated only on incoming Tell entries;
discriminated by SenderGuid != 0 (OnSelfSent echoes always carry 0).

32 new tests:
- ChatInputParserTests: 22 covering every prefix + edge case
- ChatVMLastTellSenderTests: 6 covering capture + skip rules
- ChatPanelInputTests: 6 using FakePanelRenderer + recording
  ICommandBus to assert publish behaviour

UI.Abstractions.Tests: 60 -> 92. Solution total: 934 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-25 19:44:04 +02:00
parent 8e6e5a0b61
commit f14296c75f
6 changed files with 710 additions and 10 deletions

View file

@ -0,0 +1,210 @@
namespace AcDream.UI.Abstractions.Panels.Chat;
/// <summary>
/// Phase I.4: pure-function parse of a chat-input line into the
/// <c>(channel, target?, text)</c> triple a <see cref="SendChatCmd"/>
/// needs. Ported from holtburger
/// <c>references/holtburger/apps/holtburger-cli/src/pages/game/input/commands.rs</c>
/// — the alias table around line ~457 and the <c>parse_targeted_chat_command</c>
/// / <c>parse_message_only_command</c> helpers near the top of the file.
///
/// <para>
/// Edge cases handled (each pinned by a test in
/// <c>ChatInputParserTests</c>):
/// <list type="bullet">
/// <item>empty / whitespace-only → <c>null</c></item>
/// <item><c>/say</c> with no message → <c>null</c></item>
/// <item><c>/t</c> with no target / no message → <c>null</c></item>
/// <item><c>/r</c> with no <c>lastTellSender</c> → <c>null</c></item>
/// <item>unknown <c>/xyz</c> verb → fall through to default channel
/// carrying the literal text (matches holtburger
/// <c>handle_slash_command</c> default arm at line ~744 — they
/// send <c>ClientCommand::Talk(command)</c> with the original
/// slash-prefixed string)</item>
/// <item>multi-word <c>/t</c> target: first whitespace token is target,
/// rest is message (matches Rust <c>split_once</c> semantics)</item>
/// </list>
/// </para>
/// </summary>
public static class ChatInputParser
{
/// <summary>
/// Outcome of a successful parse. Pass straight into
/// <see cref="SendChatCmd"/>'s constructor.
/// </summary>
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),
};
/// <summary>
/// Parse <paramref name="raw"/> into a <see cref="ParsedInput"/>
/// triple, or <c>null</c> if the input is empty / whitespace /
/// missing required arguments (e.g. <c>/t</c> with no target).
/// </summary>
/// <param name="raw">User input. The caller should
/// <see cref="string.Trim()"/> before passing.</param>
/// <param name="defaultChannel">Channel to use for unprefixed text
/// or unrecognised slash commands.</param>
/// <param name="lastTellSender">Sender of the most recent incoming
/// Tell, used to resolve <c>/r</c> reply target. Null if no Tell
/// has arrived this session.</param>
public static ParsedInput? Parse(string raw, ChatChannelKind defaultChannel, string? lastTellSender)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
var trimmed = raw.Trim();
// /say <msg>
if (TryParseMessageOnly(trimmed, SayAliases, out var sayMsg))
return new ParsedInput(ChatChannelKind.Say, null, sayMsg);
if (IsBareVerb(trimmed, SayAliases))
return null;
// /tell <target> <msg> or /t <target> <msg>
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 <msg> / /reply <msg> — 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 ──────────────────────────────────────────────────────
/// <summary>
/// Match holtburger's <c>parse_targeted_chat_command</c>: 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.
/// </summary>
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;
}
/// <summary>
/// Match holtburger's <c>parse_message_only_command</c>: split on
/// first whitespace, alias-match the verb, return the trimmed rest
/// as the message. Empty-message → false.
/// </summary>
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;
}
/// <summary>
/// True when <paramref name="command"/> is exactly one of the
/// alias verbs (no message, no extra whitespace beyond trimming).
/// </summary>
private static bool IsBareVerb(string command, string[] aliases)
{
foreach (var alias in aliases)
if (command == alias) return true;
return false;
}
/// <summary>
/// True when <paramref name="command"/> is "verb arg" with no
/// further whitespace — e.g. <c>/t Bestie</c> (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.
/// </summary>
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;
}
}

View file

@ -1,23 +1,37 @@
namespace AcDream.UI.Abstractions.Panels.Chat;
/// <summary>
/// Second real UI panel — shows the tail of the chat log.
/// Exercises <see cref="IPanelRenderer.Text"/> + <see cref="IPanelRenderer.Separator"/>
/// 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 <see cref="ChatLog"/> + an input
/// field at the bottom that submits on Enter.
///
/// <para>
/// D.2a scope: show the last <see cref="ChatVM.DefaultDisplayLimit"/>
/// lines with a separator above the tail and each entry as a single
/// <c>Text</c> call. No input field (outbound chat already has wire
/// support via <c>SendChat</c> — a text-input widget on <see cref="IPanelRenderer"/>
/// 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 <c>/general</c> is NOT <c>/g</c>):
/// <list type="bullet">
/// <item><c>/say &lt;msg&gt;</c> or no prefix → Say (default)</item>
/// <item><c>/t</c> / <c>/tell &lt;name&gt; &lt;msg&gt;</c> → whisper</item>
/// <item><c>/r</c> / <c>/reply &lt;msg&gt;</c> → reply to most recent
/// INCOMING Tell (uses <see cref="ChatVM.LastIncomingTellSender"/>;
/// drops the message if no Tell has arrived yet)</item>
/// <item><c>/g, /f, /a, /m, /p, /v, /cv, /lfg, /trade, /role, /society,
/// /olthoi &lt;msg&gt;</c> → corresponding channel</item>
/// <item>unknown <c>/xyz hello</c> → Say with the literal text intact
/// (matches holtburger fall-through)</item>
/// </list>
/// </para>
///
/// <para>
/// Empty / whitespace-only / target-but-no-message inputs are silently
/// dropped — the input field clears and no command goes out.
/// </para>
/// </summary>
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();
}
}

View file

@ -28,6 +28,19 @@ public sealed class ChatVM
private readonly ChatLog _log;
private readonly int _displayLimit;
/// <summary>
/// Sender name of the most recent INCOMING Tell. Drives the
/// <c>/r</c> reply slash command in <see cref="ChatInputParser"/>.
/// Null until the first Tell arrives. Outgoing self-sent Tell
/// echoes (which run through <see cref="ChatLog.OnSelfSent"/>) do
/// NOT update this — we discriminate by <c>SenderGuid != 0</c>;
/// only real inbound tells from <see cref="ChatLog.OnTellReceived"/>
/// carry a non-zero guid. Mirrors holtburger
/// <c>chat.rs::ChatState::last_incoming_tell_sender</c> (line 74 +
/// the assignment at line 152).
/// </summary>
public string? LastIncomingTellSender { get; private set; }
/// <summary>
/// Build a ChatVM bound to a <see cref="ChatLog"/> instance.
/// </summary>
@ -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;
}
/// <summary>