diff --git a/src/AcDream.App/UI/UiChatInput.cs b/src/AcDream.App/UI/UiChatInput.cs
new file mode 100644
index 00000000..8bed6af0
--- /dev/null
+++ b/src/AcDream.App/UI/UiChatInput.cs
@@ -0,0 +1,158 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+
+namespace AcDream.App.UI;
+
+///
+/// Editable one-line chat input. Port of retail UIElement_Text editable
+/// one-line mode + ChatInterface's 100-entry command history. Caret is a
+/// glyph index; the caret pixel-X is Σ glyph advances (UiDatFont) to the caret.
+/// Submit (Enter / Send) fires , clears, and pushes history.
+/// Decomp: UIElement_Text MoveCursor @0x468d00, FindPixelsFromPos @0x472b40;
+/// ChatInterface ProcessCommand @0x4f5100 (history cap 100, sentinel 0xFFFFFFFF).
+///
+public sealed class UiChatInput : UiElement
+{
+ public UiDatFont? DatFont { get; set; }
+ public AcDream.App.Rendering.BitmapFont? Font { get; set; }
+ public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f);
+ public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0.35f);
+ public float Padding { get; set; } = 4f;
+ public int MaxCharacters { get; set; } = 0xFFFF;
+
+ public Action? OnSubmit { get; set; }
+
+ private string _text = "";
+ private int _caret;
+ public string Text => _text;
+ public int CaretPos => _caret;
+
+ private readonly List _history = new();
+ private int _historyIndex = -1;
+ public int HistoryCount => _history.Count;
+
+ public UiChatInput()
+ {
+ AcceptsFocus = true;
+ IsEditControl = true;
+ CapturesPointerDrag = true;
+ }
+
+ public void InsertChar(char c)
+ {
+ if (c < 0x20 || c == 0x7F) return;
+ if (_text.Length >= MaxCharacters) return;
+ _text = _text.Insert(_caret, c.ToString());
+ _caret++;
+ _historyIndex = -1;
+ }
+
+ public void Backspace()
+ {
+ if (_caret == 0) return;
+ _text = _text.Remove(_caret - 1, 1);
+ _caret--;
+ }
+
+ public void DeleteForward()
+ {
+ if (_caret >= _text.Length) return;
+ _text = _text.Remove(_caret, 1);
+ }
+
+ public void MoveCaret(int delta) => _caret = Math.Clamp(_caret + delta, 0, _text.Length);
+ public void CaretHome() => _caret = 0;
+ public void CaretEnd() => _caret = _text.Length;
+
+ public void Submit()
+ {
+ var t = _text;
+ if (t.Trim().Length == 0) { Clear(); return; }
+ OnSubmit?.Invoke(t);
+ PushHistory(t);
+ Clear();
+ }
+
+ private void Clear() { _text = ""; _caret = 0; _historyIndex = -1; }
+
+ private void PushHistory(string t)
+ {
+ _history.Add(t);
+ if (_history.Count > 100) _history.RemoveAt(0);
+ _historyIndex = -1;
+ }
+
+ public void HistoryPrev()
+ {
+ if (_history.Count == 0) return;
+ _historyIndex = _historyIndex < 0 ? _history.Count - 1 : Math.Max(0, _historyIndex - 1);
+ SetTextFromHistory();
+ }
+
+ public void HistoryNext()
+ {
+ if (_historyIndex < 0) return;
+ _historyIndex++;
+ if (_historyIndex >= _history.Count) { _historyIndex = -1; Clear(); return; }
+ SetTextFromHistory();
+ }
+
+ private void SetTextFromHistory()
+ {
+ _text = _history[_historyIndex];
+ _caret = _text.Length;
+ }
+
+ public float CaretPixelX()
+ => DatFont is { } df ? df.MeasureWidth(_text.Substring(0, _caret))
+ : Font is { } bf ? bf.MeasureWidth(_text.Substring(0, _caret)) : 0f;
+
+ private bool _focused;
+
+ protected override void OnDraw(UiRenderContext ctx)
+ {
+ ctx.DrawRect(0, 0, Width, Height, BackgroundColor);
+ float lh = DatFont?.LineHeight ?? Font?.LineHeight ?? 14f;
+ float ty = (Height - lh) * 0.5f;
+ if (DatFont is { } df) ctx.DrawStringDat(df, _text, Padding, ty, TextColor);
+ else ctx.DrawString(_text, Padding, ty, TextColor, Font);
+
+ if (_focused)
+ {
+ float cx = Padding + CaretPixelX();
+ ctx.DrawRect(cx, ty, 1f, lh, TextColor);
+ }
+ }
+
+ public override bool OnEvent(in UiEvent e)
+ {
+ switch (e.Type)
+ {
+ case UiEventType.FocusGained: _focused = true; return true;
+ case UiEventType.FocusLost: _focused = false; _historyIndex = -1; return true;
+ case UiEventType.Char:
+ InsertChar((char)e.Data0);
+ return true;
+ case UiEventType.KeyDown:
+ {
+ var key = (Silk.NET.Input.Key)e.Data0;
+ switch (key)
+ {
+ case Silk.NET.Input.Key.Enter:
+ case Silk.NET.Input.Key.KeypadEnter: Submit(); return true;
+ case Silk.NET.Input.Key.Backspace: Backspace(); return true;
+ case Silk.NET.Input.Key.Delete: DeleteForward(); return true;
+ case Silk.NET.Input.Key.Left: MoveCaret(-1); return true;
+ case Silk.NET.Input.Key.Right: MoveCaret(1); return true;
+ case Silk.NET.Input.Key.Home: CaretHome(); return true;
+ case Silk.NET.Input.Key.End: CaretEnd(); return true;
+ case Silk.NET.Input.Key.Up: HistoryPrev(); return true;
+ case Silk.NET.Input.Key.Down: HistoryNext(); return true;
+ }
+ return false;
+ }
+ }
+ return false;
+ }
+}
diff --git a/tests/AcDream.App.Tests/UI/UiChatInputTests.cs b/tests/AcDream.App.Tests/UI/UiChatInputTests.cs
new file mode 100644
index 00000000..abbb751b
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/UiChatInputTests.cs
@@ -0,0 +1,72 @@
+using AcDream.App.UI;
+using Xunit;
+
+namespace AcDream.App.Tests.UI;
+
+public class UiChatInputTests
+{
+ [Fact]
+ public void InsertChar_AdvancesCaret()
+ {
+ var input = new UiChatInput();
+ input.InsertChar('h'); input.InsertChar('i');
+ Assert.Equal("hi", input.Text);
+ Assert.Equal(2, input.CaretPos);
+ }
+
+ [Fact]
+ public void Backspace_DeletesBeforeCaret()
+ {
+ var input = new UiChatInput();
+ foreach (var c in "abc") input.InsertChar(c);
+ input.MoveCaret(-1);
+ input.Backspace();
+ Assert.Equal("ac", input.Text);
+ Assert.Equal(1, input.CaretPos);
+ }
+
+ [Fact]
+ public void Submit_FiresCallback_ClearsText_PushesHistory()
+ {
+ string? sent = null;
+ var input = new UiChatInput { OnSubmit = t => sent = t };
+ foreach (var c in "hello") input.InsertChar(c);
+ input.Submit();
+ Assert.Equal("hello", sent);
+ Assert.Equal("", input.Text);
+ Assert.Equal(0, input.CaretPos);
+ }
+
+ [Fact]
+ public void EmptySubmit_DoesNotFire()
+ {
+ int n = 0;
+ var input = new UiChatInput { OnSubmit = _ => n++ };
+ input.Submit();
+ Assert.Equal(0, n);
+ }
+
+ [Fact]
+ public void History_UpDownBrowsesPreviousSubmissions()
+ {
+ var input = new UiChatInput { OnSubmit = _ => {} };
+ foreach (var c in "first") input.InsertChar(c); input.Submit();
+ foreach (var c in "second") input.InsertChar(c); input.Submit();
+ input.HistoryPrev();
+ Assert.Equal("second", input.Text);
+ input.HistoryPrev();
+ Assert.Equal("first", input.Text);
+ input.HistoryNext();
+ Assert.Equal("second", input.Text);
+ input.HistoryNext();
+ Assert.Equal("", input.Text);
+ }
+
+ [Fact]
+ public void History_CapsAt100()
+ {
+ var input = new UiChatInput { OnSubmit = _ => {} };
+ for (int i = 0; i < 150; i++) { input.InsertChar('x'); input.Submit(); }
+ Assert.True(input.HistoryCount <= 100);
+ }
+}