diff --git a/PolyPilot.Tests/CommandHistoryTests.cs b/PolyPilot.Tests/CommandHistoryTests.cs index 03a7712cb..bf96b4745 100644 --- a/PolyPilot.Tests/CommandHistoryTests.cs +++ b/PolyPilot.Tests/CommandHistoryTests.cs @@ -9,7 +9,7 @@ public class CommandHistoryTests public void Navigate_EmptyHistory_ReturnsNull() { var history = new CommandHistory(); - Assert.Null(history.Navigate(up: true)); + Assert.Null(history.Navigate(up: true, currentText: "draft")); Assert.Null(history.Navigate(up: false)); } @@ -58,19 +58,19 @@ public void Navigate_Up_CyclesThroughAllEntries() } [Fact] - public void Navigate_UpThenDown_NavigatesCorrectly() + public void Navigate_UpThenDown_RestoresDraft() { var history = new CommandHistory(); history.Add("first"); history.Add("second"); history.Add("third"); - Assert.Equal("third", history.Navigate(up: true)!.Value.Text); + Assert.Equal("third", history.Navigate(up: true, currentText: "my draft")!.Value.Text); Assert.Equal("second", history.Navigate(up: true)!.Value.Text); // Down goes back toward newest Assert.Equal("third", history.Navigate(up: false)!.Value.Text); - // Past end clears to empty - Assert.Equal("", history.Navigate(up: false)!.Value.Text); + // Past end restores the original draft + Assert.Equal("my draft", history.Navigate(up: false)!.Value.Text); } [Fact] @@ -148,14 +148,58 @@ public void IsNavigating_TrueAfterUpFalseAfterReturningToEnd() } [Fact] - public void Navigate_Down_PastEnd_ReturnsEmpty() + public void Navigate_Down_PastEnd_RestoresDraftOrEmpty() { var history = new CommandHistory(); history.Add("cmd"); - // Already past end, navigate down + // Already past end, navigate down — no draft saved, returns empty var result = history.Navigate(up: false); Assert.NotNull(result); Assert.Equal("", result!.Value.Text); } + + [Fact] + public void Navigate_DraftPreservedThroughFullCycle() + { + var history = new CommandHistory(); + history.Add("old1"); + history.Add("old2"); + + // User is typing "work in progress", presses up + Assert.Equal("old2", history.Navigate(up: true, currentText: "work in progress")!.Value.Text); + Assert.Equal("old1", history.Navigate(up: true)!.Value.Text); + // All the way back down + Assert.Equal("old2", history.Navigate(up: false)!.Value.Text); + Assert.Equal("work in progress", history.Navigate(up: false)!.Value.Text); + Assert.False(history.IsNavigating); + } + + [Fact] + public void Navigate_EmptyDraftPreserved() + { + var history = new CommandHistory(); + history.Add("cmd"); + + // User presses up with empty input + Assert.Equal("cmd", history.Navigate(up: true, currentText: "")!.Value.Text); + // Down restores empty draft + Assert.Equal("", history.Navigate(up: false)!.Value.Text); + } + + [Fact] + public void Add_ClearsDraft() + { + var history = new CommandHistory(); + history.Add("old"); + + // Navigate up with a draft + history.Navigate(up: true, currentText: "my draft"); + // Send a message — this should clear the draft + history.Add("new message"); + + // Navigate up then down — draft should be gone (empty, not "my draft") + Assert.Equal("new message", history.Navigate(up: true, currentText: "")!.Value.Text); + Assert.Equal("", history.Navigate(up: false)!.Value.Text); + } } diff --git a/PolyPilot/Components/Pages/Dashboard.razor b/PolyPilot/Components/Pages/Dashboard.razor index c55414fc3..5373f2cc5 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor +++ b/PolyPilot/Components/Pages/Dashboard.razor @@ -1000,7 +1000,7 @@ if ((e.key === 'ArrowUp' && atStart) || (e.key === 'ArrowDown' && (atEnd || histNavActive))) { if (sessionName && window.__dashRef) { e.preventDefault(); - window.__dashRef.invokeMethodAsync('JsNavigateHistory', sessionName, e.key === 'ArrowUp').then(function(isNav) { + window.__dashRef.invokeMethodAsync('JsNavigateHistory', sessionName, e.key === 'ArrowUp', ta.value || '').then(function(isNav) { if (!window.__histNavActive) window.__histNavActive = {}; window.__histNavActive[sessionName] = isNav; }).catch(function() { @@ -3640,12 +3640,12 @@ } [JSInvokable] - public async Task JsNavigateHistory(string sessionName, bool up) + public async Task JsNavigateHistory(string sessionName, bool up, string? currentText = null) { if (!commandHistoryBySession.TryGetValue(sessionName, out var hist)) return false; - var result = hist.Navigate(up); + var result = hist.Navigate(up, currentText); if (result == null) return false; var (text, cursorAtStart) = result.Value; diff --git a/PolyPilot/Models/CommandHistory.cs b/PolyPilot/Models/CommandHistory.cs index 654cbe238..1a993decd 100644 --- a/PolyPilot/Models/CommandHistory.cs +++ b/PolyPilot/Models/CommandHistory.cs @@ -2,11 +2,14 @@ namespace PolyPilot.Models; /// /// Manages per-session command history with up/down navigation. +/// Preserves the user's in-progress draft when entering history mode +/// and restores it when navigating back down past the newest entry. /// public class CommandHistory { private readonly List _entries = new(); private int _index; + private string? _draft; private const int MaxEntries = 50; public int Count => _entries.Count; @@ -23,6 +26,7 @@ public void Add(string command) if (_entries.Count > MaxEntries) _entries.RemoveAt(0); } _index = _entries.Count; // past the end = "no selection" + _draft = null; // draft consumed — message was sent } /// @@ -31,16 +35,22 @@ public void Add(string command) /// false when navigating down (so next ArrowDown fires immediately). /// Returns null if history is empty. /// - public (string Text, bool CursorAtStart)? Navigate(bool up) + /// True for ArrowUp (older), false for ArrowDown (newer). + /// The current input text — saved as draft on first up-navigation. + public (string Text, bool CursorAtStart)? Navigate(bool up, string? currentText = null) { if (_entries.Count == 0) return null; + // Stash the draft when first entering history mode + if (up && !IsNavigating) + _draft = currentText ?? ""; + if (up) _index = Math.Max(0, _index - 1); else _index = Math.Min(_entries.Count, _index + 1); - var text = _index < _entries.Count ? _entries[_index] : ""; + var text = _index < _entries.Count ? _entries[_index] : (_draft ?? ""); return (text, up); } }