diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index ab4479f..f1e6444 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -895,7 +895,14 @@ public sealed class GameWindow : IDisposable
_panelHost.Register(
new AcDream.UI.Abstractions.Panels.Vitals.VitalsPanel(_vitalsVm));
- Console.WriteLine("devtools: ImGui panel host ready (VitalsPanel registered)");
+ // ChatPanel: reads the tail of the shared ChatLog. No GUID
+ // dependency — works pre-login (empty) and post-login (live
+ // tail of received speech/tells/channels/system msgs).
+ var chatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat);
+ _panelHost.Register(
+ new AcDream.UI.Abstractions.Panels.Chat.ChatPanel(chatVm));
+
+ Console.WriteLine("devtools: ImGui panel host ready (VitalsPanel + ChatPanel registered)");
}
catch (Exception ex)
{
diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs
new file mode 100644
index 0000000..3ca9c6a
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs
@@ -0,0 +1,64 @@
+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.
+///
+///
+/// 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).
+///
+///
+public sealed class ChatPanel : IPanel
+{
+ private readonly ChatVM _vm;
+
+ public ChatPanel(ChatVM vm)
+ {
+ _vm = vm ?? throw new ArgumentNullException(nameof(vm));
+ }
+
+ ///
+ public string Id => "acdream.chat";
+
+ ///
+ public string Title => "Chat";
+
+ ///
+ public bool IsVisible { get; set; } = true;
+
+ ///
+ public void Render(PanelContext ctx, IPanelRenderer renderer)
+ {
+ if (!renderer.Begin(Title))
+ {
+ renderer.End();
+ return;
+ }
+
+ var lines = _vm.RecentLines();
+ if (lines.Count == 0)
+ {
+ renderer.Text("(no messages yet)");
+ }
+ else
+ {
+ // Header separator so the reader always sees the tail start;
+ // the IPanel contract promises pressing Begin opens the window
+ // at a stable anchor.
+ renderer.Separator();
+ for (int i = 0; i < lines.Count; i++)
+ {
+ renderer.Text(lines[i]);
+ }
+ }
+
+ renderer.End();
+ }
+}
diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs
new file mode 100644
index 0000000..fa05d27
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs
@@ -0,0 +1,82 @@
+using AcDream.Core.Chat;
+
+namespace AcDream.UI.Abstractions.Panels.Chat;
+
+///
+/// ViewModel for the chat panel. Reads the tail of
+/// and formats each into a single display line.
+///
+///
+/// Formatting lives here (not in the panel) so the same rendering logic
+/// survives the Phase D.2b backend swap — under the custom retail-look
+/// toolkit we'll want different per- styling, but
+/// the plain-text form is the fallback and the starting point.
+///
+///
+///
+/// D.2a snapshots the log every frame. Cheap: the default 500-entry cap
+/// keeps it < 1 ms. A future iteration can subscribe to
+/// for incremental updates once we
+/// add virtualized scrolling in .
+///
+///
+public sealed class ChatVM
+{
+ /// Default number of tail entries rendered.
+ public const int DefaultDisplayLimit = 20;
+
+ private readonly ChatLog _log;
+ private readonly int _displayLimit;
+
+ ///
+ /// Build a ChatVM bound to a instance.
+ ///
+ /// Live chat log. Never null.
+ ///
+ /// Maximum number of tail entries to surface per
+ /// call. Must be >= 1. Defaults to
+ /// .
+ ///
+ public ChatVM(ChatLog log, int displayLimit = DefaultDisplayLimit)
+ {
+ _log = log ?? throw new ArgumentNullException(nameof(log));
+ if (displayLimit < 1)
+ throw new ArgumentOutOfRangeException(nameof(displayLimit), displayLimit, "must be >= 1");
+ _displayLimit = displayLimit;
+ }
+
+ ///
+ /// Snapshot the tail of the chat log, formatted as display strings,
+ /// oldest-first. Never returns null; returns an empty array if the
+ /// log is empty.
+ ///
+ public IReadOnlyList RecentLines()
+ {
+ var snap = _log.Snapshot();
+ int start = Math.Max(0, snap.Length - _displayLimit);
+ int count = snap.Length - start;
+ if (count <= 0) return Array.Empty();
+
+ var lines = new string[count];
+ for (int i = 0; i < count; i++)
+ {
+ lines[i] = FormatEntry(snap[start + i]);
+ }
+ return lines;
+ }
+
+ ///
+ /// Format a single for display. Public so tests
+ /// can assert the per-kind formatting without touching a full log.
+ ///
+ public static string FormatEntry(ChatEntry entry) => entry.Kind switch
+ {
+ ChatKind.LocalSpeech => $"{entry.Sender}: {entry.Text}",
+ ChatKind.RangedSpeech => $"{entry.Sender} says distantly: {entry.Text}",
+ ChatKind.Channel => $"[ch {entry.ChannelId}] {entry.Sender}: {entry.Text}",
+ ChatKind.Tell => $"[Tell] {entry.Sender}: {entry.Text}",
+ ChatKind.System => $"[System] {entry.Text}",
+ ChatKind.Popup => $"[Popup] {entry.Text}",
+ _ => entry.Text,
+ };
+}
diff --git a/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsPanel.cs b/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsPanel.cs
index 5040c7e..0b0b58a 100644
--- a/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsPanel.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsPanel.cs
@@ -3,7 +3,7 @@ namespace AcDream.UI.Abstractions.Panels.Vitals;
///
/// First real UI panel — shows the local player's vitals as progress bars.
/// Backend-agnostic; renders exclusively through
-/// so the same file works under Hexa.NET.ImGui (D.2a) and the future custom
+/// so the same file works under ImGui.NET (D.2a) and the future custom
/// retail-look toolkit (D.2b).
///
///
diff --git a/tests/AcDream.UI.Abstractions.Tests/ChatVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/ChatVMTests.cs
new file mode 100644
index 0000000..199ab94
--- /dev/null
+++ b/tests/AcDream.UI.Abstractions.Tests/ChatVMTests.cs
@@ -0,0 +1,118 @@
+using AcDream.Core.Chat;
+using AcDream.UI.Abstractions.Panels.Chat;
+
+namespace AcDream.UI.Abstractions.Tests;
+
+public sealed class ChatVMTests
+{
+ [Fact]
+ public void RecentLines_ReturnsEmpty_ForEmptyLog()
+ {
+ var log = new ChatLog();
+ var vm = new ChatVM(log);
+
+ Assert.Empty(vm.RecentLines());
+ }
+
+ [Fact]
+ public void RecentLines_ReturnsAllEntries_WhenBelowLimit()
+ {
+ var log = new ChatLog();
+ log.OnLocalSpeech(sender: "Caith", text: "hello", senderGuid: 0x5000_0001u, isRanged: false);
+ log.OnLocalSpeech(sender: "Regal", text: "world", senderGuid: 0x5000_0002u, isRanged: false);
+
+ var vm = new ChatVM(log, displayLimit: 20);
+ var lines = vm.RecentLines();
+
+ Assert.Equal(2, lines.Count);
+ Assert.Equal("Caith: hello", lines[0]);
+ Assert.Equal("Regal: world", lines[1]);
+ }
+
+ [Fact]
+ public void RecentLines_ReturnsTail_WhenAboveLimit_InOldestFirstOrder()
+ {
+ var log = new ChatLog();
+ for (int i = 0; i < 30; i++)
+ log.OnLocalSpeech(sender: "A", text: $"msg{i}", senderGuid: 0x5000_0001u, isRanged: false);
+
+ var vm = new ChatVM(log, displayLimit: 5);
+ var lines = vm.RecentLines();
+
+ // Tail = msg25..msg29 (5 entries, oldest first).
+ Assert.Equal(5, lines.Count);
+ Assert.Equal("A: msg25", lines[0]);
+ Assert.Equal("A: msg29", lines[4]);
+ }
+
+ [Fact]
+ public void FormatEntry_LocalSpeech_SenderColonText()
+ {
+ var entry = new ChatEntry(ChatKind.LocalSpeech, "Caith", "hello", 0x5000_0001u, 0);
+ Assert.Equal("Caith: hello", ChatVM.FormatEntry(entry));
+ }
+
+ [Fact]
+ public void FormatEntry_RangedSpeech_IncludesDistanceHint()
+ {
+ var entry = new ChatEntry(ChatKind.RangedSpeech, "Caith", "hello", 0x5000_0001u, 0);
+ Assert.Equal("Caith says distantly: hello", ChatVM.FormatEntry(entry));
+ }
+
+ [Fact]
+ public void FormatEntry_Channel_IncludesChannelId()
+ {
+ var entry = new ChatEntry(ChatKind.Channel, "Caith", "g'day", 0x5000_0001u, 7u);
+ Assert.Equal("[ch 7] Caith: g'day", ChatVM.FormatEntry(entry));
+ }
+
+ [Fact]
+ public void FormatEntry_Tell_PrefixedWithTellTag()
+ {
+ var entry = new ChatEntry(ChatKind.Tell, "Regal", "psst", 0x5000_0002u, 0);
+ Assert.Equal("[Tell] Regal: psst", ChatVM.FormatEntry(entry));
+ }
+
+ [Fact]
+ public void FormatEntry_System_NoSenderShown()
+ {
+ var entry = new ChatEntry(ChatKind.System, Sender: "", "Your spell fizzled!", 0, 0);
+ Assert.Equal("[System] Your spell fizzled!", ChatVM.FormatEntry(entry));
+ }
+
+ [Fact]
+ public void FormatEntry_Popup_Prefixed()
+ {
+ var entry = new ChatEntry(ChatKind.Popup, Sender: "", "A door stands before you.", 0, 0);
+ Assert.Equal("[Popup] A door stands before you.", ChatVM.FormatEntry(entry));
+ }
+
+ [Fact]
+ public void Constructor_ThrowsOnNullLog()
+ {
+ Assert.Throws(() => new ChatVM(null!));
+ }
+
+ [Fact]
+ public void Constructor_ThrowsOnZeroOrNegativeLimit()
+ {
+ var log = new ChatLog();
+ Assert.Throws(() => new ChatVM(log, displayLimit: 0));
+ Assert.Throws(() => new ChatVM(log, displayLimit: -1));
+ }
+
+ [Fact]
+ public void RecentLines_ReturnsNewLineData_AfterSubsequentAppend()
+ {
+ // Confirm the VM isn't caching — each call re-snapshots the log.
+ var log = new ChatLog();
+ var vm = new ChatVM(log);
+
+ Assert.Empty(vm.RecentLines());
+
+ log.OnLocalSpeech("Caith", "hello", 0x5000_0001u, false);
+ var after = vm.RecentLines();
+ Assert.Single(after);
+ Assert.Equal("Caith: hello", after[0]);
+ }
+}