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

@ -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>