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

@ -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();
}
}