diff --git a/PSReadLine.build.ps1 b/PSReadLine.build.ps1
index 4d54be20..2e3467a4 100644
--- a/PSReadLine.build.ps1
+++ b/PSReadLine.build.ps1
@@ -57,6 +57,23 @@ Synopsis: Build main binary module
#>
task BuildMainModule @binaryModuleParams {
exec { dotnet publish -c $Configuration PSReadLine\PSReadLine.csproj }
+
+ # Restructure native SQLite DLLs from NuGet runtimes/{rid}/native/ into flat {rid}/ layout
+ # that PowerShell's CorePsAssemblyLoadContext.NativeDllHandler expects.
+ $publishPath = "PSReadLine/bin/$Configuration/$targetFramework/publish"
+ $runtimesDir = Join-Path $publishPath 'runtimes'
+ if (Test-Path $runtimesDir) {
+ Get-ChildItem $runtimesDir -Directory | ForEach-Object {
+ $rid = $_.Name
+ $nativeDir = Join-Path $_.FullName 'native'
+ if (Test-Path $nativeDir) {
+ $dest = Join-Path $publishPath $rid
+ New-Item -Path $dest -ItemType Directory -Force > $null
+ Copy-Item (Join-Path $nativeDir '*') $dest -Force
+ }
+ }
+ Remove-Item $runtimesDir -Recurse -Force
+ }
}
<#
@@ -110,6 +127,31 @@ task LayoutModule BuildMainModule, {
Copy-Item $binPath/Microsoft.PowerShell.PSReadLine.dll $targetDir
Copy-Item $binPath/Microsoft.PowerShell.Pager.dll $targetDir
+ # Copy SQLite managed DLLs
+ foreach ($dll in @(
+ 'Microsoft.Data.Sqlite.dll',
+ 'SQLitePCLRaw.core.dll',
+ 'SQLitePCLRaw.batteries_v2.dll',
+ 'SQLitePCLRaw.provider.e_sqlite3.dll'
+ )) {
+ $dllPath = Join-Path $binPath $dll
+ if (Test-Path $dllPath) {
+ Copy-Item $dllPath $targetDir
+ }
+ }
+
+ # Copy native SQLite DLLs in flat {rid}/ layout for PowerShell's NativeDllHandler
+ foreach ($rid in @('win-x64','win-x86','win-arm','win-arm64','linux-x64','linux-x86','linux-arm','linux-arm64','linux-musl-x64','linux-musl-arm64','osx-x64','osx-arm64')) {
+ $ridSource = Join-Path $binPath $rid
+ if (Test-Path $ridSource) {
+ $ridDest = Join-Path $targetDir $rid
+ if (-not (Test-Path $ridDest)) {
+ New-Item $ridDest -ItemType Directory -Force > $null
+ }
+ Copy-Item "$ridSource/*" $ridDest -Force
+ }
+ }
+
if ($Configuration -eq 'Debug') {
Copy-Item $binPath/*.pdb $targetDir
}
diff --git a/PSReadLine/Cmdlets.cs b/PSReadLine/Cmdlets.cs
index 596b1548..a27e7125 100644
--- a/PSReadLine/Cmdlets.cs
+++ b/PSReadLine/Cmdlets.cs
@@ -62,7 +62,14 @@ public enum AddToHistoryOption
{
SkipAdding,
MemoryOnly,
- MemoryAndFile
+ MemoryAndFile,
+ SQLite
+ }
+
+ public enum HistoryType
+ {
+ Text,
+ SQLite
}
public enum PredictionSource
@@ -107,6 +114,12 @@ public class PSConsoleReadLineOptions
public const string DefaultContinuationPrompt = ">> ";
+ ///
+ /// The default history type is text-based history.
+ /// Users can change default behavior by setting this to SQLite in their profile.
+ ///
+ public const HistoryType DefaultHistoryType = HistoryType.Text;
+
///
/// The maximum number of commands to store in the history.
///
@@ -151,6 +164,14 @@ public class PSConsoleReadLineOptions
public const HistorySaveStyle DefaultHistorySaveStyle = HistorySaveStyle.SaveIncrementally;
+ ///
+ /// When enabled, the SQLite-history-awareness UX (F2 list-view stats tooltip and
+ /// the in-prompt history navigation indicator) renders plain-text labels instead of
+ /// emoji icons, so screen readers don't verbalize Unicode character names like
+ /// "clockwise gapped circle arrow" or "card index dividers".
+ ///
+ public const bool DefaultAccessibleHistoryDisplay = false;
+
public const PredictionViewStyle DefaultPredictionViewStyle = PredictionViewStyle.InlineView;
///
@@ -198,6 +219,12 @@ public PSConsoleReadLineOptions(string hostName, bool usingLegacyConsole)
ResetColors();
EditMode = DefaultEditMode;
ScreenReaderModeEnabled = Accessibility.IsScreenReaderActive();
+ // Seed the accessible-history-display flag from the screen-reader state at
+ // construction time so screen-reader users get plain-text labels by default.
+ // After construction the two options are independent — toggling
+ // EnableScreenReaderMode later does NOT auto-flip AccessibleHistoryDisplay.
+ AccessibleHistoryDisplay = ScreenReaderModeEnabled;
+ HistoryType = DefaultHistoryType;
ContinuationPrompt = DefaultContinuationPrompt;
ContinuationPromptColor = Console.ForegroundColor;
ExtraPromptLineCount = DefaultExtraPromptLineCount;
@@ -225,13 +252,20 @@ public PSConsoleReadLineOptions(string hostName, bool usingLegacyConsole)
var historyFileName = hostName + "_history.txt";
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
- HistorySavePath = System.IO.Path.Combine(
+ HistorySavePathText = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"Microsoft",
"Windows",
"PowerShell",
"PSReadLine",
historyFileName);
+ HistorySavePathSQLite = System.IO.Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "Microsoft",
+ "Windows",
+ "PowerShell",
+ "PSReadLine",
+ hostName + "_history.db");
}
else
{
@@ -240,11 +274,16 @@ public PSConsoleReadLineOptions(string hostName, bool usingLegacyConsole)
if (!String.IsNullOrEmpty(historyPath))
{
- HistorySavePath = System.IO.Path.Combine(
+ HistorySavePathText = System.IO.Path.Combine(
historyPath,
"powershell",
"PSReadLine",
historyFileName);
+ HistorySavePathSQLite = System.IO.Path.Combine(
+ historyPath,
+ "powershell",
+ "PSReadLine",
+ hostName + "_history.db");
}
else
{
@@ -253,18 +292,26 @@ public PSConsoleReadLineOptions(string hostName, bool usingLegacyConsole)
if (!String.IsNullOrEmpty(home))
{
- HistorySavePath = System.IO.Path.Combine(
+ HistorySavePathText = System.IO.Path.Combine(
home,
".local",
"share",
"powershell",
"PSReadLine",
historyFileName);
+ HistorySavePathSQLite = System.IO.Path.Combine(
+ home,
+ ".local",
+ "share",
+ "powershell",
+ "PSReadLine",
+ hostName + "_history.db");
}
else
{
// No HOME, then don't save anything
- HistorySavePath = "/dev/null";
+ HistorySavePathText = "/dev/null";
+ HistorySavePathSQLite = "/dev/null";
}
}
}
@@ -333,6 +380,7 @@ public object ContinuationPromptColor
/// that do invoke the script block - this covers the most useful cases.
///
public HashSet CommandsToValidateScriptBlockArguments { get; set; }
+ public HistoryType HistoryType { get; set; } = HistoryType.Text;
///
/// When true, duplicates will not be recalled from history more than once.
@@ -369,9 +417,23 @@ public object ContinuationPromptColor
public ScriptBlock ViModeChangeHandler { get; set; }
///
- /// The path to the saved history.
+ /// The path to the text history file.
+ ///
+ public string HistorySavePathText { get; set; }
+
+ ///
+ /// The path to the SQLite history database.
///
- public string HistorySavePath { get; set; }
+ public string HistorySavePathSQLite { get; set; }
+
+ ///
+ /// Returns the active history save path based on the current .
+ ///
+ public string HistorySavePath => HistoryType switch
+ {
+ HistoryType.SQLite => HistorySavePathSQLite,
+ _ => HistorySavePathText,
+ };
public HistorySaveStyle HistorySaveStyle { get; set; }
///
@@ -532,6 +594,16 @@ public object ListPredictionTooltipColor
public bool ScreenReaderModeEnabled { get; set; }
+ ///
+ /// When true, the SQLite-history-awareness UX renders plain-text labels
+ /// (e.g., Runs N | Last 2m ago | Dir <path> in the F2 stats tooltip
+ /// and [History 3/15] / [Location 2/5] in the navigation indicator)
+ /// instead of emoji icons so screen readers can read them clearly.
+ /// Initialized at startup from ; thereafter
+ /// it is independent of the screen-reader option.
+ ///
+ public bool AccessibleHistoryDisplay { get; set; }
+
internal string _defaultTokenColor;
internal string _commentColor;
internal string _keywordColor;
@@ -655,6 +727,20 @@ public EditMode EditMode
[AllowEmptyString]
public string ContinuationPrompt { get; set; }
+ [Parameter]
+ public HistoryType HistoryType
+ {
+ get => _historyType.GetValueOrDefault();
+ set
+ {
+ _historyType = value;
+ _historyTypeSpecified = true;
+ }
+ }
+
+ public HistoryType? _historyType = HistoryType.Text;
+ internal bool _historyTypeSpecified;
+
[Parameter]
public SwitchParameter HistoryNoDuplicates
{
@@ -785,15 +871,27 @@ public HistorySaveStyle HistorySaveStyle
[Parameter]
[ValidateNotNullOrEmpty]
- public string HistorySavePath
+ public string HistorySavePathText
{
- get => _historySavePath;
+ get => _historySavePathText;
set
{
- _historySavePath = GetUnresolvedProviderPathFromPSPath(value);
+ _historySavePathText = GetUnresolvedProviderPathFromPSPath(value);
}
}
- private string _historySavePath;
+ private string _historySavePathText;
+
+ [Parameter]
+ [ValidateNotNullOrEmpty]
+ public string HistorySavePathSQLite
+ {
+ get => _historySavePathSQLite;
+ set
+ {
+ _historySavePathSQLite = GetUnresolvedProviderPathFromPSPath(value);
+ }
+ }
+ private string _historySavePathSQLite;
[Parameter]
[ValidateRange(25, 1000)]
@@ -854,6 +952,14 @@ public SwitchParameter EnableScreenReaderMode
}
internal SwitchParameter? _enableScreenReaderMode;
+ [Parameter]
+ public SwitchParameter AccessibleHistoryDisplay
+ {
+ get => _accessibleHistoryDisplay.GetValueOrDefault();
+ set => _accessibleHistoryDisplay = value;
+ }
+ internal SwitchParameter? _accessibleHistoryDisplay;
+
[ExcludeFromCodeCoverage]
protected override void EndProcessing()
{
diff --git a/PSReadLine/History.cs b/PSReadLine/History.cs
index c1490a23..60d55f8c 100644
--- a/PSReadLine/History.cs
+++ b/PSReadLine/History.cs
@@ -13,7 +13,9 @@
using System.Text.RegularExpressions;
using System.Threading;
using System.Management.Automation.Language;
+using System.Management.Automation.Runspaces;
using Microsoft.PowerShell.PSReadLine;
+using Microsoft.Data.Sqlite;
namespace Microsoft.PowerShell
{
@@ -82,6 +84,17 @@ public class HistoryItem
///
public bool FromHistoryFile { get; internal set; }
+ ///
+ /// The location where the command was run, if available.
+ ///
+ public string Location { get; internal set; }
+
+ ///
+ /// The number of times this command has been executed (from SQLite history).
+ /// Defaults to 1 for text-based or in-session history.
+ ///
+ public int ExecutionCount { get; internal set; } = 1;
+
internal bool _saved;
internal bool _sensitive;
internal List _edits;
@@ -98,6 +111,16 @@ public class HistoryItem
private int _getNextHistoryIndex;
private int _searchHistoryCommandCount;
private int _recallHistoryCommandCount;
+ private int _locationHistoryCommandCount;
+ private List _locationSortedIndices;
+ private int _locationSortedPosition;
+ // True while location-mode (Alt+Up/Down) is "sticky" — plain Up/Down should
+ // continue to navigate the same sorted list. Cleared when the user does any
+ // non-history action (handled in the ReadLine main loop's anyHistory reset branch).
+ private bool _locationHistoryActive;
+ // True while we are showing the "[BOOK N/M]" history navigation status line.
+ // Used so the main loop knows to clear the status when the user stops navigating.
+ private bool _historyNavStatusActive;
private int _anyHistoryCommandCount;
private string _searchHistoryPrefix;
// When cycling through history, the current line (not yet added to history)
@@ -112,6 +135,11 @@ public class HistoryItem
private const string _failedForwardISearchPrompt = "failed-fwd-i-search: ";
private const string _failedBackwardISearchPrompt = "failed-bck-i-search: ";
+ private const string _forwardLocationISearchPrompt = "fwd-i-search (location): ";
+ private const string _backwardLocationISearchPrompt = "bck-i-search (location): ";
+ private const string _failedForwardLocationISearchPrompt = "failed-fwd-i-search (location): ";
+ private const string _failedBackwardLocationISearchPrompt = "failed-bck-i-search (location): ";
+
// Pattern used to check for sensitive inputs.
private static readonly Regex s_sensitivePattern = new Regex(
"password|asplaintext|token|apikey|secret",
@@ -155,6 +183,11 @@ private AddToHistoryOption GetAddToHistoryOption(string line, bool fromHistoryFi
return AddToHistoryOption.SkipAdding;
}
+ if (Options.HistoryType is HistoryType.SQLite)
+ {
+ return AddToHistoryOption.SQLite;
+ }
+
if (!fromHistoryFile && Options.AddToHistoryHandler != null)
{
if (Options.AddToHistoryHandler == PSConsoleReadLineOptions.DefaultAddToHistoryHandler)
@@ -197,10 +230,217 @@ private AddToHistoryOption GetAddToHistoryOption(string line, bool fromHistoryFi
return AddToHistoryOption.MemoryAndFile;
}
+ private void InitializeSQLiteDatabase(bool migrateTextHistory = false)
+ {
+ string baseConnectionString = $"Data Source={_options.HistorySavePath}";
+ var connectionString = new SqliteConnectionStringBuilder(baseConnectionString)
+ {
+ Mode = SqliteOpenMode.ReadWriteCreate
+ }.ToString();
+
+ try
+ {
+ using var connection = new SqliteConnection(connectionString);
+ connection.Open();
+
+ // Check if the "Commands" table exists (our primary table for new schema)
+ using var command = connection.CreateCommand();
+ command.CommandText = @"
+SELECT name
+FROM sqlite_master
+WHERE type='table' AND name=@TableName";
+ command.Parameters.AddWithValue("@TableName", "Commands");
+
+ var result = command.ExecuteScalar();
+ bool isNewDatabase = result == null;
+
+ // If the table doesn't exist, create the normalized schema
+ if (isNewDatabase)
+ {
+ using var createTablesCommand = connection.CreateCommand();
+ createTablesCommand.CommandText = @"
+-- Table for storing unique command lines
+CREATE TABLE Commands (
+ Id INTEGER PRIMARY KEY AUTOINCREMENT,
+ CommandLine TEXT NOT NULL UNIQUE,
+ CommandHash TEXT NOT NULL UNIQUE
+);
+
+-- Table for storing unique locations
+CREATE TABLE Locations (
+ Id INTEGER PRIMARY KEY AUTOINCREMENT,
+ Path TEXT NOT NULL UNIQUE
+);
+
+-- Table for storing execution history with foreign keys
+CREATE TABLE ExecutionHistory (
+ Id INTEGER PRIMARY KEY AUTOINCREMENT,
+ CommandId INTEGER NOT NULL,
+ LocationId INTEGER NOT NULL,
+ StartTime INTEGER NOT NULL,
+ ElapsedTime INTEGER NOT NULL,
+ ExecutionCount INTEGER DEFAULT 1,
+ LastExecuted INTEGER NOT NULL,
+ FOREIGN KEY (CommandId) REFERENCES Commands(Id),
+ FOREIGN KEY (LocationId) REFERENCES Locations(Id),
+ UNIQUE(CommandId, LocationId)
+);
+
+-- Create indexes for optimal performance
+CREATE INDEX idx_commands_hash ON Commands(CommandHash);
+CREATE INDEX idx_locations_path ON Locations(Path);
+CREATE INDEX idx_execution_last_executed ON ExecutionHistory(LastExecuted DESC);
+CREATE INDEX idx_execution_count ON ExecutionHistory(ExecutionCount DESC);
+CREATE INDEX idx_execution_location_time ON ExecutionHistory(LocationId, LastExecuted DESC);
+
+-- Create a view for easy querying (mimics the old single-table structure)
+CREATE VIEW HistoryView AS
+SELECT
+ eh.Id,
+ c.CommandLine,
+ c.CommandHash,
+ l.Path as Location,
+ eh.StartTime,
+ eh.ElapsedTime,
+ eh.ExecutionCount,
+ eh.LastExecuted
+FROM ExecutionHistory eh
+JOIN Commands c ON eh.CommandId = c.Id
+JOIN Locations l ON eh.LocationId = l.Id;";
+ createTablesCommand.ExecuteNonQuery();
+
+ // Only migrate text history on initial Text -> SQLite switch,
+ // not when relocating an existing SQLite database.
+ if (migrateTextHistory)
+ {
+ MigrateTextHistoryToSQLite(connection);
+ }
+ }
+ }
+ catch (SqliteException ex)
+ {
+ Console.WriteLine($"SQLite error initializing database: {ex.Message}");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error initializing SQLite database: {ex.Message}");
+ }
+ }
+
+ private void MigrateTextHistoryToSQLite(SqliteConnection connection)
+ {
+ // Use the dedicated text history path — no need to derive it from the SQLite path.
+ string textHistoryPath = _options.HistorySavePathText;
+ if (string.IsNullOrEmpty(textHistoryPath) || !File.Exists(textHistoryPath))
+ {
+ return; // No text history to migrate
+ }
+
+ try
+ {
+ // Read existing text history using the existing connection (don't open a new one)
+ var historyLines = ReadHistoryLinesImpl(textHistoryPath, int.MaxValue);
+ var historyItems = new List();
+
+ // Convert text lines to HistoryItems
+ var sb = new StringBuilder();
+ foreach (var line in historyLines)
+ {
+ if (line.EndsWith("`", StringComparison.Ordinal))
+ {
+ sb.Append(line, 0, line.Length - 1);
+ sb.Append('\n');
+ }
+ else if (sb.Length > 0)
+ {
+ sb.Append(line);
+ historyItems.Add(new HistoryItem
+ {
+ CommandLine = sb.ToString(),
+ ApproximateElapsedTime = TimeSpan.Zero,
+ Location = "Unknown"
+ });
+ sb.Clear();
+ }
+ else
+ {
+ historyItems.Add(new HistoryItem
+ {
+ CommandLine = line,
+ ApproximateElapsedTime = TimeSpan.Zero,
+ Location = "Unknown"
+ });
+ }
+ }
+
+ // Assign timestamps so that:
+ // 1. All migrated items are older than any future SQLite entry
+ // 2. The first text line (oldest) gets the earliest timestamp
+ // 3. The last text line (newest) gets the latest migrated timestamp
+ // Each item is spaced 1 minute apart, ending 2 minutes before "now".
+ var migrationBase = DateTime.UtcNow.AddMinutes(-(historyItems.Count + 1));
+ for (int idx = 0; idx < historyItems.Count; idx++)
+ {
+ historyItems[idx].StartTime = migrationBase.AddMinutes(idx);
+ }
+
+ // Insert into SQLite database using the new normalized schema
+ using var transaction = connection.BeginTransaction();
+
+ foreach (var item in historyItems)
+ {
+ try
+ {
+ // Generate command hash using SHA256
+ string commandHash = ComputeCommandHash(item.CommandLine);
+ string location = item.Location ?? "Unknown";
+
+ // Get or create command and location IDs
+ long commandId = GetOrCreateCommandId(connection, item.CommandLine, commandHash);
+ long locationId = GetOrCreateLocationId(connection, location);
+
+ // Convert DateTime to Unix timestamp (INTEGER)
+ long startTimeUnix = ((DateTimeOffset)item.StartTime).ToUnixTimeSeconds();
+ long lastExecutedUnix = startTimeUnix;
+
+ // Insert or update execution history using the new schema
+ using var command = connection.CreateCommand();
+ command.Transaction = transaction;
+ command.CommandText = @"
+INSERT INTO ExecutionHistory (CommandId, LocationId, StartTime, ElapsedTime, ExecutionCount, LastExecuted)
+VALUES (@CommandId, @LocationId, @StartTime, @ElapsedTime, 1, @LastExecuted)
+ON CONFLICT(CommandId, LocationId) DO UPDATE SET
+ ExecutionCount = ExecutionCount + 1,
+ LastExecuted = excluded.LastExecuted";
+
+ command.Parameters.AddWithValue("@CommandId", commandId);
+ command.Parameters.AddWithValue("@LocationId", locationId);
+ command.Parameters.AddWithValue("@StartTime", startTimeUnix);
+ command.Parameters.AddWithValue("@ElapsedTime", item.ApproximateElapsedTime.Ticks);
+ command.Parameters.AddWithValue("@LastExecuted", lastExecutedUnix);
+ command.ExecuteNonQuery();
+ }
+ catch (Exception itemEx)
+ {
+ Console.WriteLine($"Error migrating history item: {itemEx.Message}");
+ // Continue with next item
+ }
+ }
+
+ transaction.Commit();
+ Console.WriteLine($"Migrated {historyItems.Count} history items from text file to SQLite");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error migrating text history: {ex.Message}");
+ }
+ }
+
private string MaybeAddToHistory(
string result,
List edits,
int undoEditIndex,
+ string location = null,
bool fromDifferentSession = false,
bool fromInitialRead = false)
{
@@ -216,6 +456,7 @@ private string MaybeAddToHistory(
_undoEditIndex = undoEditIndex,
_editGroupStart = -1,
_saved = fromHistoryFile,
+ Location = location ?? _engineIntrinsics?.SessionState?.Path?.CurrentLocation?.Path ?? "Unknown",
FromOtherSession = fromDifferentSession,
FromHistoryFile = fromInitialRead,
};
@@ -277,12 +518,170 @@ private void IncrementalHistoryWrite()
i -= 1;
}
- WriteHistoryRange(i + 1, _history.Count - 1, overwritten: false);
+ if (_options.HistoryType == HistoryType.Text)
+ {
+ WriteHistoryRange(i + 1, _history.Count - 1, overwritten: false);
+ }
+
+ if (_options.HistoryType == HistoryType.SQLite)
+ {
+ WriteHistoryToSQLite(i + 1, _history.Count - 1);
+ }
+ }
+
+ // Helper method to get or create a command ID
+ private long GetOrCreateCommandId(SqliteConnection connection, string commandLine, string commandHash)
+ {
+ // First try to get existing command
+ using var selectCommand = connection.CreateCommand();
+ selectCommand.CommandText = "SELECT Id FROM Commands WHERE CommandHash = @CommandHash";
+ selectCommand.Parameters.AddWithValue("@CommandHash", commandHash);
+
+ var existingId = selectCommand.ExecuteScalar();
+ if (existingId != null)
+ {
+ return Convert.ToInt64(existingId);
+ }
+
+ // Insert new command
+ using var insertCommand = connection.CreateCommand();
+ insertCommand.CommandText = @"
+INSERT INTO Commands (CommandLine, CommandHash)
+VALUES (@CommandLine, @CommandHash)";
+ insertCommand.Parameters.AddWithValue("@CommandLine", commandLine);
+ insertCommand.Parameters.AddWithValue("@CommandHash", commandHash);
+ insertCommand.ExecuteNonQuery();
+
+ // Get the inserted row ID
+ using var lastIdCommand = connection.CreateCommand();
+ lastIdCommand.CommandText = "SELECT last_insert_rowid()";
+ return Convert.ToInt64(lastIdCommand.ExecuteScalar());
+ }
+
+ // Helper method to get or create a location ID
+ private long GetOrCreateLocationId(SqliteConnection connection, string location)
+ {
+ // First try to get existing location
+ using var selectCommand = connection.CreateCommand();
+ selectCommand.CommandText = "SELECT Id FROM Locations WHERE Path = @Path";
+ selectCommand.Parameters.AddWithValue("@Path", location);
+
+ var existingId = selectCommand.ExecuteScalar();
+ if (existingId != null)
+ {
+ return Convert.ToInt64(existingId);
+ }
+
+ // Insert new location
+ using var insertCommand = connection.CreateCommand();
+ insertCommand.CommandText = @"
+INSERT INTO Locations (Path)
+VALUES (@Path)";
+ insertCommand.Parameters.AddWithValue("@Path", location);
+ insertCommand.ExecuteNonQuery();
+
+ // Get the inserted row ID
+ using var lastIdCommand = connection.CreateCommand();
+ lastIdCommand.CommandText = "SELECT last_insert_rowid()";
+ return Convert.ToInt64(lastIdCommand.ExecuteScalar());
+ }
+
+ private void WriteHistoryToSQLite(int start, int end)
+ {
+ _historyFileMutex ??= new Mutex(false, GetHistorySaveFileMutexName());
+
+ WithHistoryFileMutexDo(1000, () =>
+ {
+ try
+ {
+ string baseConnectionString = $"Data Source={_options.HistorySavePath}";
+ var connectionString = new SqliteConnectionStringBuilder(baseConnectionString)
+ {
+ Mode = SqliteOpenMode.ReadWrite
+ }.ToString();
+
+ using var connection = new SqliteConnection(connectionString);
+ connection.Open();
+
+ using var transaction = connection.BeginTransaction();
+
+ for (var i = start; i <= end; i++)
+ {
+ var item = _history[i];
+ item._saved = true;
+
+ if (item._sensitive)
+ {
+ continue;
+ }
+
+ // Generate command hash using SHA256
+ string commandHash = ComputeCommandHash(item.CommandLine);
+ string location = item.Location ?? _engineIntrinsics?.SessionState?.Path?.CurrentLocation?.Path ?? "Unknown";
+
+ // Get or create command and location IDs
+ long commandId = GetOrCreateCommandId(connection, item.CommandLine, commandHash);
+ long locationId = GetOrCreateLocationId(connection, location);
+
+ // Convert DateTime to Unix timestamp (INTEGER)
+ long startTimeUnix = ((DateTimeOffset)item.StartTime).ToUnixTimeSeconds();
+ long lastExecutedUnix = ((DateTimeOffset)DateTime.UtcNow).ToUnixTimeSeconds();
+
+ // Insert or update execution history
+ using var command = connection.CreateCommand();
+ command.CommandText = @"
+INSERT INTO ExecutionHistory (CommandId, LocationId, StartTime, ElapsedTime, ExecutionCount, LastExecuted)
+VALUES (@CommandId, @LocationId, @StartTime, @ElapsedTime, 1, @LastExecuted)
+ON CONFLICT(CommandId, LocationId) DO UPDATE SET
+ ExecutionCount = ExecutionCount + 1,
+ LastExecuted = excluded.LastExecuted,
+ ElapsedTime = excluded.ElapsedTime";
+
+ command.Parameters.AddWithValue("@CommandId", commandId);
+ command.Parameters.AddWithValue("@LocationId", locationId);
+ command.Parameters.AddWithValue("@StartTime", startTimeUnix);
+ command.Parameters.AddWithValue("@ElapsedTime", item.ApproximateElapsedTime.Ticks);
+ command.Parameters.AddWithValue("@LastExecuted", lastExecutedUnix);
+ command.ExecuteNonQuery();
+
+ // Read back the total ExecutionCount across all locations so the in-memory item stays in sync
+ using var countCmd = connection.CreateCommand();
+ countCmd.Transaction = transaction;
+ countCmd.CommandText = "SELECT SUM(ExecutionCount) FROM ExecutionHistory WHERE CommandId = @CommandId";
+ countCmd.Parameters.AddWithValue("@CommandId", commandId);
+ var count = countCmd.ExecuteScalar();
+ if (count != null)
+ {
+ item.ExecutionCount = Convert.ToInt32(count);
+ }
+ }
+
+ transaction.Commit();
+ }
+ catch (Exception e)
+ {
+ ReportHistoryFileError(e);
+ }
+ });
+ }
+
+ private static string ComputeCommandHash(string command)
+ {
+ using var sha256 = System.Security.Cryptography.SHA256.Create();
+ byte[] hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(command));
+ return BitConverter.ToString(hashBytes).Replace("-", "");
}
private void SaveHistoryAtExit()
{
- WriteHistoryRange(0, _history.Count - 1, overwritten: true);
+ if (_options.HistoryType == HistoryType.SQLite)
+ {
+ WriteHistoryToSQLite(0, _history.Count - 1);
+ }
+ else
+ {
+ WriteHistoryRange(0, _history.Count - 1, overwritten: true);
+ }
}
private int historyErrorReportedCount;
@@ -411,6 +810,7 @@ private void WriteHistoryRange(int start, int end, bool overwritten)
///
private List ReadHistoryFileIncrementally()
{
+ // Read history from a text file
var fileInfo = new FileInfo(Options.HistorySavePath);
if (fileInfo.Exists && fileInfo.Length != _historyFileLastSavedSize)
{
@@ -433,22 +833,209 @@ private List ReadHistoryFileIncrementally()
return null;
}
+ private List ReadHistorySQLiteIncrementally()
+ {
+ var historyItems = new List();
+ try
+ {
+ string baseConnectionString = $"Data Source={_options.HistorySavePath}";
+ var connectionString = new SqliteConnectionStringBuilder(baseConnectionString)
+ {
+ Mode = SqliteOpenMode.ReadOnly
+ }.ToString();
+
+ using var connection = new SqliteConnection(connectionString);
+ connection.Open();
+
+ using (var command = connection.CreateCommand())
+ {
+ // Use the HistoryView to get all the joined data, filtering by ExecutionHistory.Id.
+ // ExecutionCount is the SUM across all locations (total runs).
+ command.CommandText = @"
+SELECT hv.CommandLine, hv.StartTime, hv.ElapsedTime, hv.Location,
+ (SELECT SUM(eh2.ExecutionCount) FROM ExecutionHistory eh2
+ JOIN Commands c2 ON eh2.CommandId = c2.Id
+ WHERE c2.CommandLine = hv.CommandLine) AS TotalExecutionCount
+FROM HistoryView hv
+WHERE hv.Id > @LastId
+ORDER BY hv.Id ASC";
+ command.Parameters.AddWithValue("@LastId", _historyFileLastSavedSize);
+
+ using var reader = command.ExecuteReader();
+ while (reader.Read())
+ {
+ var item = new HistoryItem
+ {
+ CommandLine = reader.GetString(0),
+ StartTime = DateTimeOffset.FromUnixTimeSeconds(reader.GetInt64(1)).DateTime,
+ ApproximateElapsedTime = TimeSpan.FromTicks(reader.GetInt64(2)),
+ Location = reader.GetString(3),
+ ExecutionCount = reader.GetInt32(4),
+ FromHistoryFile = true,
+ FromOtherSession = true,
+ _saved = true,
+ _edits = new List { EditItemInsertString.Create(reader.GetString(0), 0) },
+ _undoEditIndex = 1,
+ _editGroupStart = -1
+ };
+ historyItems.Add(item);
+ }
+ }
+
+ // Update the last saved size to the latest ID in the ExecutionHistory table
+ using (var command = connection.CreateCommand())
+ {
+ command.CommandText = "SELECT MAX(Id) FROM ExecutionHistory";
+ var result = command.ExecuteScalar();
+ if (result != DBNull.Value)
+ {
+ _historyFileLastSavedSize = Convert.ToInt64(result);
+ }
+ }
+ }
+ catch (SqliteException ex)
+ {
+ Console.WriteLine($"SQLite error reading history: {ex.Message}");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error reading history from SQLite: {ex.Message}");
+ }
+
+ return historyItems.Count > 0 ? historyItems : null;
+ }
+
private bool MaybeReadHistoryFile()
{
if (Options.HistorySaveStyle == HistorySaveStyle.SaveIncrementally)
{
return WithHistoryFileMutexDo(1000, () =>
{
- List historyLines = ReadHistoryFileIncrementally();
- if (historyLines != null)
+ if (_options.HistoryType == HistoryType.SQLite)
{
- UpdateHistoryFromFile(historyLines, fromDifferentSession: true, fromInitialRead: false);
+ List historyItems = ReadHistorySQLiteIncrementally();
+ if (historyItems != null)
+ {
+ foreach (var item in historyItems)
+ {
+ _history.Enqueue(item);
+ _currentHistoryIndex = _history.Count;
+ }
+ }
+ }
+ else
+ {
+ List historyLines = ReadHistoryFileIncrementally();
+ if (historyLines != null)
+ {
+ UpdateHistoryFromFile(historyLines, fromDifferentSession: true, fromInitialRead: false);
+ }
}
});
}
// true means no errors, not that we actually read the file
return true;
+}
+
+ private void ReadSQLiteHistory(bool fromOtherSession)
+ {
+ _historyFileMutex ??= new Mutex(false, GetHistorySaveFileMutexName());
+
+ WithHistoryFileMutexDo(1000, () =>
+ {
+ try
+ {
+ string baseConnectionString = $"Data Source={_options.HistorySavePath}";
+ var connectionString = new SqliteConnectionStringBuilder(baseConnectionString)
+ {
+ Mode = SqliteOpenMode.ReadOnly
+ }.ToString();
+
+ using var connection = new SqliteConnection(connectionString);
+ connection.Open();
+
+ using var command = connection.CreateCommand();
+
+ int limit = Options.MaximumHistoryCount switch
+ {
+ <= 10000 => 10000, // Similar to 0.5MB text optimization
+ <= 20000 => 20000, // Similar to 1MB text optimization
+ _ => Options.MaximumHistoryCount
+ };
+
+ // Load history in chronological order, deduplicated by CommandLine.
+ // When the same command exists at multiple locations, keep the most
+ // recently executed entry so that basic Up/Down recall is simple
+ // reverse-chronological navigation with no duplicates.
+ // ExecutionCount is the SUM across all locations (total runs).
+ command.CommandText = @"
+WITH Ranked AS (
+ SELECT CommandLine, StartTime, ElapsedTime, Location, ExecutionCount, LastExecuted,
+ ROW_NUMBER() OVER (PARTITION BY CommandLine ORDER BY LastExecuted DESC) AS rn
+ FROM HistoryView
+),
+TotalCounts AS (
+ SELECT c.CommandLine, SUM(eh.ExecutionCount) AS TotalExecutionCount
+ FROM ExecutionHistory eh
+ JOIN Commands c ON eh.CommandId = c.Id
+ GROUP BY c.CommandLine
+)
+SELECT r.CommandLine, r.StartTime, r.ElapsedTime, r.Location, tc.TotalExecutionCount
+FROM Ranked r
+JOIN TotalCounts tc ON r.CommandLine = tc.CommandLine
+WHERE r.rn = 1
+ORDER BY r.LastExecuted DESC
+LIMIT @Limit";
+ command.Parameters.AddWithValue("@Limit", limit);
+
+ var historyItems = new List();
+ using var reader = command.ExecuteReader();
+ while (reader.Read())
+ {
+ var item = new HistoryItem
+ {
+ CommandLine = reader.GetString(0),
+ StartTime = DateTimeOffset.FromUnixTimeSeconds(reader.GetInt64(1)).DateTime,
+ ApproximateElapsedTime = TimeSpan.FromTicks(reader.GetInt64(2)),
+ Location = reader.GetString(3),
+ ExecutionCount = reader.GetInt32(4),
+ FromHistoryFile = true,
+ FromOtherSession = fromOtherSession,
+ _saved = true,
+ _edits = new List { EditItemInsertString.Create(reader.GetString(0), 0) },
+ _undoEditIndex = 1,
+ _editGroupStart = -1
+ };
+
+ historyItems.Add(item);
+ }
+
+ historyItems.Reverse();
+
+ foreach (var item in historyItems)
+ {
+ _history.Enqueue(item);
+ }
+
+ // Update the last saved size to the latest ID in the database
+ using var idCommand = connection.CreateCommand();
+ idCommand.CommandText = "SELECT MAX(Id) FROM ExecutionHistory";
+ var result = idCommand.ExecuteScalar();
+ if (result != DBNull.Value)
+ {
+ _historyFileLastSavedSize = Convert.ToInt64(result);
+ }
+ }
+ catch (SqliteException ex)
+ {
+ ReportHistoryFileError(ex);
+ }
+ catch (Exception ex)
+ {
+ ReportHistoryFileError(ex);
+ }
+ });
}
private void ReadHistoryFile()
@@ -463,54 +1050,54 @@ private void ReadHistoryFile()
_historyFileLastSavedSize = fileInfo.Length;
});
}
+ }
- static IEnumerable ReadHistoryLinesImpl(string path, int historyCount)
+ private IEnumerable ReadHistoryLinesImpl(string path, int historyCount)
+ {
+ const long offset_1mb = 1048576;
+ const long offset_05mb = 524288;
+
+ // 1mb content contains more than 34,000 history lines for a typical usage, which should be
+ // more than enough to cover 20,000 history records (a history record could be a multi-line
+ // command). Similarly, 0.5mb content should be enough to cover 10,000 history records.
+ // We optimize the file reading when the history count falls in those ranges. If the history
+ // count is even larger, which should be very rare, we just read all lines.
+ long offset = historyCount switch
{
- const long offset_1mb = 1048576;
- const long offset_05mb = 524288;
+ <= 10000 => offset_05mb,
+ <= 20000 => offset_1mb,
+ _ => 0,
+ };
- // 1mb content contains more than 34,000 history lines for a typical usage, which should be
- // more than enough to cover 20,000 history records (a history record could be a multi-line
- // command). Similarly, 0.5mb content should be enough to cover 10,000 history records.
- // We optimize the file reading when the history count falls in those ranges. If the history
- // count is even larger, which should be very rare, we just read all lines.
- long offset = historyCount switch
- {
- <= 10000 => offset_05mb,
- <= 20000 => offset_1mb,
- _ => 0,
- };
+ using var fs = new FileStream(path, FileMode.Open);
+ using var sr = new StreamReader(fs);
- using var fs = new FileStream(path, FileMode.Open);
- using var sr = new StreamReader(fs);
-
- if (offset > 0 && fs.Length > offset)
- {
- // When the file size is larger than the offset, we only read that amount of content from the end.
- fs.Seek(-offset, SeekOrigin.End);
+ if (offset > 0 && fs.Length > offset)
+ {
+ // When the file size is larger than the offset, we only read that amount of content from the end.
+ fs.Seek(-offset, SeekOrigin.End);
- // After seeking, the current position may point at the middle of a history record, or even at a
- // byte within a UTF-8 character (history file is saved with UTF-8 encoding). So, let's ignore the
- // first line read from that position.
- sr.ReadLine();
+ // After seeking, the current position may point at the middle of a history record, or even at a
+ // byte within a UTF-8 character (history file is saved with UTF-8 encoding). So, let's ignore the
+ // first line read from that position.
+ sr.ReadLine();
- string line;
- while ((line = sr.ReadLine()) is not null)
+ string line;
+ while ((line = sr.ReadLine()) is not null)
+ {
+ if (!line.EndsWith("`", StringComparison.Ordinal))
{
- if (!line.EndsWith("`", StringComparison.Ordinal))
- {
- // A complete history record is guaranteed to start from the next line.
- break;
- }
+ // A complete history record is guaranteed to start from the next line.
+ break;
}
}
+ }
- // Read lines in the streaming way, so it won't consume to much memory even if we have to
- // read all lines from a large history file.
- while (!sr.EndOfStream)
- {
- yield return sr.ReadLine();
- }
+ // Read lines in the streaming way, so it won't consume to much memory even if we have to
+ // read all lines from a large history file.
+ while (!sr.EndOfStream)
+ {
+ yield return sr.ReadLine();
}
}
@@ -529,13 +1116,13 @@ void UpdateHistoryFromFile(IEnumerable historyLines, bool fromDifferentS
sb.Append(line);
var l = sb.ToString();
var editItems = new List {EditItemInsertString.Create(l, 0)};
- MaybeAddToHistory(l, editItems, 1, fromDifferentSession, fromInitialRead);
+ MaybeAddToHistory(l, editItems, 1, null, fromDifferentSession, fromInitialRead);
sb.Clear();
}
else
{
var editItems = new List {EditItemInsertString.Create(line, 0)};
- MaybeAddToHistory(line, editItems, 1, fromDifferentSession, fromInitialRead);
+ MaybeAddToHistory(line, editItems, 1, null, fromDifferentSession, fromInitialRead);
}
}
}
@@ -808,172 +1395,899 @@ public static void AddToHistory(string command)
}
///
- /// Clears history in PSReadLine. This does not affect PowerShell history.
+ /// Add a command to the history with a specified location.
///
- public static void ClearHistory(ConsoleKeyInfo? key = null, object arg = null)
+ internal static void AddToHistory(string command, string location)
{
- _singleton._history?.Clear();
- _singleton._recentHistory?.Clear();
- _singleton._currentHistoryIndex = 0;
+ command = command.Replace("\r\n", "\n");
+ var editItems = new List {EditItemInsertString.Create(command, 0)};
+ _singleton.MaybeAddToHistory(command, editItems, 1, location: location);
}
///
- /// Return a collection of history items.
+ /// Remove a specific command from history (both in-memory and SQLite if applicable).
+ /// Returns true if any items were removed.
///
- public static HistoryItem[] GetHistoryItems()
+ public static bool RemoveHistoryItem(string commandLine)
{
- return _singleton._history.ToArray();
- }
+ if (string.IsNullOrEmpty(commandLine))
+ return false;
- enum HistoryMoveCursor { ToEnd, ToBeginning, DontMove }
+ bool removed = false;
- private void UpdateFromHistory(HistoryMoveCursor moveCursor)
- {
- string line;
- if (_currentHistoryIndex == _history.Count)
- {
- line = _savedCurrentLine.CommandLine;
- _edits = new List(_savedCurrentLine._edits);
- _undoEditIndex = _savedCurrentLine._undoEditIndex;
- _editGroupStart = _savedCurrentLine._editGroupStart;
- }
- else
+ // Remove from in-memory history
+ var history = _singleton._history;
+ if (history != null)
{
- line = _history[_currentHistoryIndex].CommandLine;
- _edits = new List(_history[_currentHistoryIndex]._edits);
- _undoEditIndex = _history[_currentHistoryIndex]._undoEditIndex;
- _editGroupStart = _history[_currentHistoryIndex]._editGroupStart;
- }
- _buffer.Clear();
- _buffer.Append(line);
+ var itemsToKeep = new List();
+ for (int i = 0; i < history.Count; i++)
+ {
+ if (!string.Equals(history[i].CommandLine, commandLine, StringComparison.Ordinal))
+ {
+ itemsToKeep.Add(history[i]);
+ }
+ else
+ {
+ removed = true;
+ }
+ }
- switch (moveCursor)
- {
- case HistoryMoveCursor.ToEnd:
- _current = Math.Max(0, _buffer.Length + ViEndOfLineFactor);
- break;
- case HistoryMoveCursor.ToBeginning:
- _current = 0;
- break;
- default:
- if (_current > _buffer.Length)
+ if (removed)
+ {
+ history.Clear();
+ foreach (var item in itemsToKeep)
{
- _current = Math.Max(0, _buffer.Length + ViEndOfLineFactor);
+ history.Enqueue(item);
}
- break;
+ _singleton._currentHistoryIndex = history.Count;
+ }
}
- using var _ = _prediction.DisableScoped();
- Render();
+ // Remove from SQLite database if using SQLite history
+ if (_singleton._options?.HistoryType == HistoryType.SQLite &&
+ !string.IsNullOrEmpty(_singleton._options.HistorySavePath))
+ {
+ removed |= _singleton.RemoveFromSQLiteHistory(commandLine);
+ }
+
+ return removed;
}
- private void SaveCurrentLine()
+ private bool RemoveFromSQLiteHistory(string commandLine)
{
- // We're called before any history operation - so it's convenient
- // to check if we need to load history from another sessions now.
- MaybeReadHistoryFile();
-
- _anyHistoryCommandCount += 1;
- if (_savedCurrentLine.CommandLine == null)
+ try
{
- _savedCurrentLine.CommandLine = _buffer.ToString();
- _savedCurrentLine._edits = _edits;
- _savedCurrentLine._undoEditIndex = _undoEditIndex;
+ string baseConnectionString = $"Data Source={_options.HistorySavePath}";
+ var connectionString = new SqliteConnectionStringBuilder(baseConnectionString)
+ {
+ Mode = SqliteOpenMode.ReadWrite
+ }.ToString();
+
+ using var connection = new SqliteConnection(connectionString);
+ connection.Open();
+
+ // Find the command ID
+ using var findCmd = connection.CreateCommand();
+ findCmd.CommandText = "SELECT Id FROM Commands WHERE CommandLine = @CommandLine";
+ findCmd.Parameters.AddWithValue("@CommandLine", commandLine);
+ var commandIdObj = findCmd.ExecuteScalar();
+
+ if (commandIdObj == null)
+ return false;
+
+ long commandId = Convert.ToInt64(commandIdObj);
+
+ using var transaction = connection.BeginTransaction();
+
+ // Delete execution history entries first (foreign key constraint)
+ using var deleteEH = connection.CreateCommand();
+ deleteEH.CommandText = "DELETE FROM ExecutionHistory WHERE CommandId = @CommandId";
+ deleteEH.Parameters.AddWithValue("@CommandId", commandId);
+ deleteEH.ExecuteNonQuery();
+
+ // Delete the command itself
+ using var deleteCmd = connection.CreateCommand();
+ deleteCmd.CommandText = "DELETE FROM Commands WHERE Id = @CommandId";
+ deleteCmd.Parameters.AddWithValue("@CommandId", commandId);
+ int deletedRows = deleteCmd.ExecuteNonQuery();
+
+ transaction.Commit();
+ return deletedRows > 0;
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Remove a specific command from history scoped to a single location (both in-memory and SQLite if applicable).
+ /// In-memory items are removed only when both CommandLine and Location match; in SQLite the
+ /// ExecutionHistory row for the matching (Command, Location) pair is deleted, and the
+ /// Commands row is dropped only when no other location still references it.
+ /// Returns true if any items were removed.
+ ///
+ public static bool RemoveHistoryItemAtLocation(string commandLine, string location)
+ {
+ if (string.IsNullOrEmpty(commandLine) || string.IsNullOrEmpty(location))
+ return false;
+
+ bool removed = false;
+
+ // Remove from in-memory history — only items whose Location matches.
+ var history = _singleton._history;
+ if (history != null)
+ {
+ var itemsToKeep = new List();
+ for (int i = 0; i < history.Count; i++)
+ {
+ var item = history[i];
+ if (string.Equals(item.CommandLine, commandLine, StringComparison.Ordinal) &&
+ string.Equals(item.Location, location, StringComparison.Ordinal))
+ {
+ removed = true;
+ }
+ else
+ {
+ itemsToKeep.Add(item);
+ }
+ }
+
+ if (removed)
+ {
+ history.Clear();
+ foreach (var item in itemsToKeep)
+ {
+ history.Enqueue(item);
+ }
+ _singleton._currentHistoryIndex = history.Count;
+ }
+ }
+
+ // Remove from SQLite database if using SQLite history
+ if (_singleton._options?.HistoryType == HistoryType.SQLite &&
+ !string.IsNullOrEmpty(_singleton._options.HistorySavePath))
+ {
+ removed |= _singleton.RemoveFromSQLiteHistoryAtLocation(commandLine, location);
+ }
+
+ return removed;
+ }
+
+ private bool RemoveFromSQLiteHistoryAtLocation(string commandLine, string location)
+ {
+ try
+ {
+ string baseConnectionString = $"Data Source={_options.HistorySavePath}";
+ var connectionString = new SqliteConnectionStringBuilder(baseConnectionString)
+ {
+ Mode = SqliteOpenMode.ReadWrite
+ }.ToString();
+
+ using var connection = new SqliteConnection(connectionString);
+ connection.Open();
+
+ // Find the command ID
+ using var findCmd = connection.CreateCommand();
+ findCmd.CommandText = "SELECT Id FROM Commands WHERE CommandLine = @CommandLine";
+ findCmd.Parameters.AddWithValue("@CommandLine", commandLine);
+ var commandIdObj = findCmd.ExecuteScalar();
+ if (commandIdObj == null)
+ return false;
+ long commandId = Convert.ToInt64(commandIdObj);
+
+ // Find the location ID. If the location doesn't exist in the DB, there's nothing to delete.
+ using var findLoc = connection.CreateCommand();
+ findLoc.CommandText = "SELECT Id FROM Locations WHERE Path = @Path";
+ findLoc.Parameters.AddWithValue("@Path", location);
+ var locationIdObj = findLoc.ExecuteScalar();
+ if (locationIdObj == null)
+ return false;
+ long locationId = Convert.ToInt64(locationIdObj);
+
+ using var transaction = connection.BeginTransaction();
+
+ // Delete the ExecutionHistory row for this (Command, Location) only.
+ using var deleteEH = connection.CreateCommand();
+ deleteEH.CommandText = "DELETE FROM ExecutionHistory WHERE CommandId = @CommandId AND LocationId = @LocationId";
+ deleteEH.Parameters.AddWithValue("@CommandId", commandId);
+ deleteEH.Parameters.AddWithValue("@LocationId", locationId);
+ int deletedRows = deleteEH.ExecuteNonQuery();
+
+ if (deletedRows == 0)
+ {
+ transaction.Rollback();
+ return false;
+ }
+
+ // If no other location still references this command, drop the Commands row too
+ // so it stops appearing in global queries.
+ using var orphanCheck = connection.CreateCommand();
+ orphanCheck.CommandText = "SELECT COUNT(*) FROM ExecutionHistory WHERE CommandId = @CommandId";
+ orphanCheck.Parameters.AddWithValue("@CommandId", commandId);
+ long remaining = Convert.ToInt64(orphanCheck.ExecuteScalar());
+ if (remaining == 0)
+ {
+ using var deleteCmd = connection.CreateCommand();
+ deleteCmd.CommandText = "DELETE FROM Commands WHERE Id = @CommandId";
+ deleteCmd.Parameters.AddWithValue("@CommandId", commandId);
+ deleteCmd.ExecuteNonQuery();
+ }
+
+ transaction.Commit();
+ return true;
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Query per-location execution counts for all commands at the given location.
+ /// Returns a dictionary mapping CommandLine -> ExecutionCount for that location.
+ ///
+ private Dictionary GetLocationExecutionCounts(string location)
+ {
+ var counts = new Dictionary(StringComparer.Ordinal);
+ try
+ {
+ string baseConnectionString = $"Data Source={_options.HistorySavePath}";
+ var connectionString = new SqliteConnectionStringBuilder(baseConnectionString)
+ {
+ Mode = SqliteOpenMode.ReadOnly
+ }.ToString();
+
+ using var connection = new SqliteConnection(connectionString);
+ connection.Open();
+
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = @"
+ SELECT c.CommandLine, eh.ExecutionCount
+ FROM ExecutionHistory eh
+ JOIN Commands c ON eh.CommandId = c.Id
+ JOIN Locations l ON eh.LocationId = l.Id
+ WHERE l.Path = @Location";
+ cmd.Parameters.AddWithValue("@Location", location);
+
+ using var reader = cmd.ExecuteReader();
+ while (reader.Read())
+ {
+ counts[reader.GetString(0)] = reader.GetInt64(1);
+ }
+ }
+ catch (Exception)
+ {
+ // On failure, return empty — caller falls back to total ExecutionCount
+ }
+ return counts;
+ }
+
+ ///
+ /// Clears history in PSReadLine. This does not affect PowerShell history.
+ ///
+ public static void ClearHistory(ConsoleKeyInfo? key = null, object arg = null)
+ {
+ _singleton._history?.Clear();
+ _singleton._recentHistory?.Clear();
+ _singleton._currentHistoryIndex = 0;
+ }
+
+ ///
+ /// Return a collection of history items.
+ ///
+ public static HistoryItem[] GetHistoryItems()
+ {
+ return _singleton._history.ToArray();
+ }
+
+ enum HistoryMoveCursor { ToEnd, ToBeginning, DontMove }
+
+ private void UpdateFromHistory(HistoryMoveCursor moveCursor)
+ {
+ string line;
+ if (_currentHistoryIndex == _history.Count)
+ {
+ line = _savedCurrentLine.CommandLine;
+ _edits = new List(_savedCurrentLine._edits);
+ _undoEditIndex = _savedCurrentLine._undoEditIndex;
+ _editGroupStart = _savedCurrentLine._editGroupStart;
+ }
+ else
+ {
+ line = _history[_currentHistoryIndex].CommandLine;
+ _edits = new List(_history[_currentHistoryIndex]._edits);
+ _undoEditIndex = _history[_currentHistoryIndex]._undoEditIndex;
+ _editGroupStart = _history[_currentHistoryIndex]._editGroupStart;
+ }
+ _buffer.Clear();
+ _buffer.Append(line);
+
+ switch (moveCursor)
+ {
+ case HistoryMoveCursor.ToEnd:
+ _current = Math.Max(0, _buffer.Length + ViEndOfLineFactor);
+ break;
+ case HistoryMoveCursor.ToBeginning:
+ _current = 0;
+ break;
+ default:
+ if (_current > _buffer.Length)
+ {
+ _current = Math.Max(0, _buffer.Length + ViEndOfLineFactor);
+ }
+ break;
+ }
+
+ using var _ = _prediction.DisableScoped();
+ Render();
+ }
+
+ private void SaveCurrentLine()
+ {
+ // We're called before any history operation - so it's convenient
+ // to check if we need to load history from another sessions now.
+ MaybeReadHistoryFile();
+
+ _anyHistoryCommandCount += 1;
+ if (_savedCurrentLine.CommandLine == null)
+ {
+ _savedCurrentLine.CommandLine = _buffer.ToString();
+ _savedCurrentLine._edits = _edits;
+ _savedCurrentLine._undoEditIndex = _undoEditIndex;
_savedCurrentLine._editGroupStart = _editGroupStart;
}
}
- private void HistoryRecall(int direction)
+ private void HistoryRecall(int direction)
+ {
+ if (_recallHistoryCommandCount == 0 && LineIsMultiLine())
+ {
+ MoveToLine(direction);
+ return;
+ }
+
+ if (Options.HistoryNoDuplicates && _hashedHistory == null)
+ {
+ _hashedHistory = new Dictionary();
+ }
+
+ int count = Math.Abs(direction);
+ direction = direction < 0 ? -1 : +1;
+ int newHistoryIndex = _currentHistoryIndex;
+ while (count > 0)
+ {
+ newHistoryIndex += direction;
+
+ if (newHistoryIndex < 0 || newHistoryIndex >= _history.Count)
+ {
+ break;
+ }
+
+ if (_history[newHistoryIndex].FromOtherSession)
+ {
+ continue;
+ }
+
+ if (Options.HistoryNoDuplicates)
+ {
+ var line = _history[newHistoryIndex].CommandLine;
+ if (!_hashedHistory.TryGetValue(line, out var index))
+ {
+ _hashedHistory.Add(line, newHistoryIndex);
+ --count;
+ }
+ else if (newHistoryIndex == index)
+ {
+ --count;
+ }
+ }
+ else
+ {
+ --count;
+ }
+ }
+ _recallHistoryCommandCount += 1;
+
+ if (newHistoryIndex >= 0 && newHistoryIndex <= _history.Count)
+ {
+ _currentHistoryIndex = newHistoryIndex;
+ var moveCursor = InViCommandMode() && !_options.HistorySearchCursorMovesToEnd
+ ? HistoryMoveCursor.ToBeginning
+ : HistoryMoveCursor.ToEnd;
+ UpdateFromHistory(moveCursor);
+ }
+
+ // Show position indicator while navigating chronological history.
+ // Position 1 = newest navigable item; total = number of items reachable
+ // by Up/Down (i.e., excluding FromOtherSession entries, which HistoryRecall
+ // skips above). Counting raw _history slots would make the indicator jump
+ // by hundreds when many cross-session items sit between two in-session
+ // commands.
+ if (_history.Count > 0 && _currentHistoryIndex < _history.Count)
+ {
+ int navigableTotal = 0;
+ int navigableFromOldest = 0;
+ for (int i = 0; i < _history.Count; i++)
+ {
+ if (_history[i].FromOtherSession)
+ {
+ continue;
+ }
+ navigableTotal++;
+ if (i <= _currentHistoryIndex)
+ {
+ navigableFromOldest++;
+ }
+ }
+
+ if (navigableTotal > 0 && navigableFromOldest > 0)
+ {
+ int positionFromNewest = navigableTotal - navigableFromOldest + 1;
+ ShowHistoryNavStatus(positionFromNewest, navigableTotal, locationMode: false);
+ }
+ }
+ }
+
+ // Renders a small "[ pos/total]" status line below the prompt while
+ // the user is navigating history. Cleared by the ReadLine main loop once
+ // any non-history key is pressed.
+ private void ShowHistoryNavStatus(int position, int total, bool locationMode)
+ {
+ if (total <= 0)
+ {
+ return;
+ }
+
+ // ⏱ (U+23F1, BMP, 1 char / 1 cell) for chronological recency-based recall,
+ // 📂 (U+1F4C2, surrogate pair = 2 chars / 2 cells) for location-filtered
+ // recall. Both fit the buffer-width math in Render.cs.
+ // Brackets stay in the status line's default color; inner text uses the
+ // ListPredictionColor (gold/yellow by default — matches F2 list metadata).
+ // When AccessibleHistoryDisplay is enabled, render plain-text labels so screen
+ // readers don't verbalize Unicode emoji names. ASCII labels keep cell-count ==
+ // char-count, so the buffer-width math in GetStatusLineCount() still works.
+ var color = _options?._listPredictionColor ?? "\x1b[33m";
+ bool accessible = _options?.AccessibleHistoryDisplay ?? false;
+ string innerText;
+ if (accessible)
+ {
+ innerText = locationMode
+ ? $"Location {position}/{total}"
+ : $"History {position}/{total}";
+ }
+ else
+ {
+ innerText = locationMode
+ ? $"\uD83D\uDCC2 {position}/{total}"
+ : $"\u23F1 {position}/{total}";
+ }
+ _statusLinePrompt = $"[{color}{innerText}\x1b[0m]";
+ _statusBuffer.Clear();
+ _statusIsErrorMessage = false;
+ _historyNavStatusActive = true;
+ RenderWithPredictionQueryPaused();
+ }
+
+ ///
+ /// Remove the currently displayed history item from history (both in-memory and SQLite if applicable).
+ /// Works when browsing history with Up/Down arrows or when an item is selected in the F2 list view.
+ ///
+ public static void RemoveFromHistory(ConsoleKeyInfo? key = null, object arg = null)
+ {
+ var history = _singleton._history;
+ if (history == null || history.Count == 0)
+ {
+ Ding();
+ return;
+ }
+
+ // Check if we're in the F2 list prediction view with a selected item.
+ // Handle this path separately — no history recall counters needed since
+ // we stay in the list view, and incrementing them would leave _hashedHistory
+ // null for the next HistoryRecall call (causing NullReferenceException).
+ if (_singleton._prediction.ActiveView is PredictionListView listView
+ && listView.HasActiveSuggestion
+ && listView.SelectedItemIndex >= 0)
+ {
+ string commandToRemove = listView.SelectedItemText;
+ if (commandToRemove == null)
+ {
+ Ding();
+ return;
+ }
+
+ RemoveHistoryItem(commandToRemove);
+
+ if (!listView.RemoveSelectedItem())
+ {
+ // List became empty — close the list view
+ RevertLine();
+ }
+ else
+ {
+ // Re-render so the user sees the item disappear immediately.
+ ReplaceSelection(listView.SelectedItemText);
+ }
+
+ return;
+ }
+
+ // Normal history browsing path (Up/Down arrows).
+ // Signal to the main ReadLine loop that this is a history command,
+ // so it doesn't reset _currentHistoryIndex after we set it.
+ _singleton._recallHistoryCommandCount += 1;
+ _singleton._anyHistoryCommandCount += 1;
+
+ string commandLine = null;
+ if (_singleton._currentHistoryIndex < history.Count)
+ {
+ commandLine = history[_singleton._currentHistoryIndex].CommandLine;
+ }
+
+ if (commandLine == null)
+ {
+ Ding();
+ return;
+ }
+
+ // Save position before RemoveHistoryItem resets _currentHistoryIndex to Count
+ int savedIndex = _singleton._currentHistoryIndex;
+
+ RemoveHistoryItem(commandLine);
+
+ // In normal history browsing: advance to the next older item
+ // (same direction as Up arrow) so the user can keep deleting
+ // consecutive items without bouncing back to the top.
+ if (history.Count == 0)
+ {
+ _singleton._currentHistoryIndex = 0;
+ RevertLine();
+ }
+ else
+ {
+ // Items below savedIndex didn't move, so the next older item
+ // is at savedIndex - 1. If we were at the oldest item already,
+ // show whatever is now at index 0 (the former next-newer item).
+ _singleton._currentHistoryIndex = Math.Max(savedIndex - 1, 0);
+ _singleton.UpdateFromHistory(HistoryMoveCursor.ToEnd);
+ }
+ }
+
+ ///
+ /// Remove the currently displayed history item from history at the *current location only*.
+ /// In SQLite mode this deletes only the ExecutionHistory row for the current directory
+ /// (and the Commands row only if no other location still references it). In-memory items
+ /// run at other locations are preserved. In Text mode this falls back to a global removal because
+ /// per-location data isn't tracked.
+ ///
+ public static void RemoveFromHistoryAtCurrentLocation(ConsoleKeyInfo? key = null, object arg = null)
+ {
+ var history = _singleton._history;
+ if (history == null || history.Count == 0)
+ {
+ Ding();
+ return;
+ }
+
+ string currentLocation = _singleton.GetCurrentLocation();
+
+ // Text mode (or no current location available): fall back to the global removal so the user
+ // still gets a useful action. Per-location semantics require SQLite.
+ if (string.IsNullOrEmpty(currentLocation) ||
+ _singleton._options?.HistoryType != HistoryType.SQLite)
+ {
+ RemoveFromHistory(key, arg);
+ return;
+ }
+
+ // F2 list view path — same handling as RemoveFromHistory but scoped to current location.
+ if (_singleton._prediction.ActiveView is PredictionListView listView
+ && listView.HasActiveSuggestion
+ && listView.SelectedItemIndex >= 0)
+ {
+ string commandToRemove = listView.SelectedItemText;
+ if (commandToRemove == null)
+ {
+ Ding();
+ return;
+ }
+
+ if (!RemoveHistoryItemAtLocation(commandToRemove, currentLocation))
+ {
+ // Nothing was removed (item isn't recorded at this location). Don't disturb the list.
+ Ding();
+ return;
+ }
+
+ if (!listView.RemoveSelectedItem())
+ {
+ RevertLine();
+ }
+ else
+ {
+ ReplaceSelection(listView.SelectedItemText);
+ }
+
+ return;
+ }
+
+ // Normal history browsing path. See RemoveFromHistory for the counter-increment rationale.
+ _singleton._recallHistoryCommandCount += 1;
+ _singleton._anyHistoryCommandCount += 1;
+
+ string commandLine = null;
+ if (_singleton._currentHistoryIndex < history.Count)
+ {
+ commandLine = history[_singleton._currentHistoryIndex].CommandLine;
+ }
+
+ if (commandLine == null)
+ {
+ Ding();
+ return;
+ }
+
+ int savedIndex = _singleton._currentHistoryIndex;
+ bool wasInLocationMode = _singleton._locationHistoryActive;
+ int savedLocationPos = _singleton._locationSortedPosition;
+
+ if (!RemoveHistoryItemAtLocation(commandLine, currentLocation))
+ {
+ // The displayed item wasn't run at this location, so location-scoped delete is a no-op.
+ // Ding to signal "nothing happened" without falling through to a destructive global delete.
+ Ding();
+ return;
+ }
+
+ if (history.Count == 0)
+ {
+ _singleton._currentHistoryIndex = 0;
+ _singleton._locationSortedIndices = null;
+ _singleton._locationSortedPosition = -1;
+ RevertLine();
+ return;
+ }
+
+ if (wasInLocationMode)
+ {
+ // RemoveHistoryItemAtLocation rebuilt _history (Clear + Enqueue), so every
+ // index in _locationSortedIndices is now stale. Rebuild the sorted list against
+ // the new _history and reposition to the next item in the location list (clamped).
+ // Bump _locationHistoryCommandCount so the main loop's sticky-mode teardown
+ // doesn't fire on the next key press.
+ _singleton._locationHistoryCommandCount += 1;
+ _singleton._locationSortedIndices = null;
+ _singleton.BuildLocationSortedIndices(currentLocation);
+
+ if (_singleton._locationSortedIndices.Count == 0)
+ {
+ // No more items at this location — exit location mode and clear the line.
+ _singleton._locationSortedPosition = -1;
+ _singleton._locationHistoryActive = false;
+ _singleton._currentHistoryIndex = history.Count;
+ RevertLine();
+ _singleton.ClearStatusMessage(render: true);
+ return;
+ }
+
+ // The item at savedLocationPos was just removed; whatever was at savedLocationPos+1
+ // now sits at savedLocationPos. Stay on that slot, clamped to the new end.
+ int newPos = Math.Min(Math.Max(savedLocationPos, 0), _singleton._locationSortedIndices.Count - 1);
+ _singleton._locationSortedPosition = newPos;
+ _singleton._currentHistoryIndex = _singleton._locationSortedIndices[newPos];
+ _singleton.UpdateFromHistory(HistoryMoveCursor.ToEnd);
+ _singleton.ShowHistoryNavStatus(newPos + 1, _singleton._locationSortedIndices.Count, locationMode: true);
+ return;
+ }
+
+ _singleton._currentHistoryIndex = Math.Max(savedIndex - 1, 0);
+ _singleton.UpdateFromHistory(HistoryMoveCursor.ToEnd);
+ }
+
+ ///
+ /// Replace the current input with the 'previous' item from PSReadLine history.
+ ///
+ public static void PreviousHistory(ConsoleKeyInfo? key = null, object arg = null)
+ {
+ TryGetArgAsInt(arg, out var numericArg, -1);
+ if (numericArg > 0)
+ {
+ numericArg = -numericArg;
+ }
+
+ if (UpdateListSelection(numericArg))
+ {
+ return;
+ }
+
+ _singleton.SaveCurrentLine();
+ // Sticky location mode: if the user entered location-filtered navigation
+ // (Alt+Up), keep filtering by location even when they release Alt and
+ // press plain Up/Down. They exit by editing or doing any non-history op.
+ if (_singleton._locationHistoryActive)
+ {
+ _singleton.LocationHistoryRecall(numericArg);
+ }
+ else
+ {
+ _singleton.HistoryRecall(numericArg);
+ }
+ }
+
+ ///
+ /// Replace the current input with the 'next' item from PSReadLine history.
+ ///
+ public static void NextHistory(ConsoleKeyInfo? key = null, object arg = null)
+ {
+ TryGetArgAsInt(arg, out var numericArg, +1);
+ if (UpdateListSelection(numericArg))
+ {
+ return;
+ }
+
+ _singleton.SaveCurrentLine();
+ if (_singleton._locationHistoryActive)
+ {
+ _singleton.LocationHistoryRecall(numericArg);
+ }
+ else
+ {
+ _singleton.HistoryRecall(numericArg);
+ }
+ }
+
+ ///
+ /// Replace the current input with the 'previous' item from PSReadLine history
+ /// that was executed from the same location (directory).
+ ///
+ public static void PreviousLocationHistory(ConsoleKeyInfo? key = null, object arg = null)
+ {
+ TryGetArgAsInt(arg, out var numericArg, -1);
+ if (numericArg > 0)
+ {
+ numericArg = -numericArg;
+ }
+
+ if (UpdateListSelection(numericArg))
+ {
+ return;
+ }
+
+ _singleton.SaveCurrentLine();
+ _singleton.LocationHistoryRecall(numericArg);
+ }
+
+ ///
+ /// Replace the current input with the 'next' item from PSReadLine history
+ /// that was executed from the same location (directory).
+ ///
+ public static void NextLocationHistory(ConsoleKeyInfo? key = null, object arg = null)
+ {
+ TryGetArgAsInt(arg, out var numericArg, +1);
+ if (UpdateListSelection(numericArg))
+ {
+ return;
+ }
+
+ _singleton.SaveCurrentLine();
+ _singleton.LocationHistoryRecall(numericArg);
+ }
+
+ private string GetCurrentLocation()
{
- if (_recallHistoryCommandCount == 0 && LineIsMultiLine())
+ return _testCurrentLocation ?? _engineIntrinsics?.SessionState?.Path?.CurrentLocation?.Path;
+ }
+
+ // For unit testing: allows tests to simulate a current directory
+ internal static string _testCurrentLocation;
+
+ private void LocationHistoryRecall(int direction)
+ {
+ if (_locationHistoryCommandCount == 0 && !_locationHistoryActive && LineIsMultiLine())
{
MoveToLine(direction);
return;
}
- if (Options.HistoryNoDuplicates && _recallHistoryCommandCount == 0)
+ var currentLocation = GetCurrentLocation();
+ if (string.IsNullOrEmpty(currentLocation))
{
- _hashedHistory = new Dictionary();
+ // Fall back to normal recall if we can't determine location
+ HistoryRecall(direction);
+ return;
+ }
+
+ // First entry into location mode (or after sorted list was cleared by exit):
+ // build a weighted index of location-matching history items.
+ // Ordering: location match (primary), then frequency DESC, then recency DESC.
+ if (_locationSortedIndices == null)
+ {
+ BuildLocationSortedIndices(currentLocation);
+ _locationSortedPosition = -1;
}
+ _locationHistoryActive = true;
+ _locationHistoryCommandCount += 1;
+
+ // Navigate: Alt+Up (direction < 0) advances forward through sorted list,
+ // Alt+Down (direction > 0) goes back.
int count = Math.Abs(direction);
- direction = direction < 0 ? -1 : +1;
- int newHistoryIndex = _currentHistoryIndex;
+ int step = direction < 0 ? 1 : -1;
+ int newPosition = _locationSortedPosition;
+
while (count > 0)
{
- newHistoryIndex += direction;
- if (newHistoryIndex < 0 || newHistoryIndex >= _history.Count)
+ newPosition += step;
+ if (newPosition < 0 || newPosition >= _locationSortedIndices.Count)
{
break;
}
-
- if (_history[newHistoryIndex].FromOtherSession)
- {
- continue;
- }
-
- if (Options.HistoryNoDuplicates)
- {
- var line = _history[newHistoryIndex].CommandLine;
- if (!_hashedHistory.TryGetValue(line, out var index))
- {
- _hashedHistory.Add(line, newHistoryIndex);
- --count;
- }
- else if (newHistoryIndex == index)
- {
- --count;
- }
- }
- else
- {
- --count;
- }
+ --count;
}
- _recallHistoryCommandCount += 1;
- if (newHistoryIndex >= 0 && newHistoryIndex <= _history.Count)
+
+ if (newPosition >= 0 && newPosition < _locationSortedIndices.Count)
{
- _currentHistoryIndex = newHistoryIndex;
+ _locationSortedPosition = newPosition;
+ _currentHistoryIndex = _locationSortedIndices[newPosition];
var moveCursor = InViCommandMode() && !_options.HistorySearchCursorMovesToEnd
? HistoryMoveCursor.ToBeginning
: HistoryMoveCursor.ToEnd;
UpdateFromHistory(moveCursor);
}
+
+ // Show position indicator: [BOOK pos/total] (location-filtered).
+ ShowHistoryNavStatus(_locationSortedPosition + 1, _locationSortedIndices.Count, locationMode: true);
}
- ///
- /// Replace the current input with the 'previous' item from PSReadLine history.
- ///
- public static void PreviousHistory(ConsoleKeyInfo? key = null, object arg = null)
+ // Builds _locationSortedIndices for the given location, applying the same
+ // dedup + frecency sort used by LocationHistoryRecall. Caller is responsible
+ // for resetting _locationSortedPosition.
+ private void BuildLocationSortedIndices(string currentLocation)
{
- TryGetArgAsInt(arg, out var numericArg, -1);
- if (numericArg > 0)
- {
- numericArg = -numericArg;
- }
+ var seen = new HashSet(StringComparer.Ordinal);
+ _locationSortedIndices = new List();
- if (UpdateListSelection(numericArg))
+ for (int i = 0; i < _history.Count; i++)
{
- return;
+ if (string.Equals(_history[i].Location, currentLocation, StringComparison.OrdinalIgnoreCase))
+ {
+ if (seen.Add(_history[i].CommandLine))
+ {
+ _locationSortedIndices.Add(i);
+ }
+ }
}
- _singleton.SaveCurrentLine();
- _singleton.HistoryRecall(numericArg);
- }
-
- ///
- /// Replace the current input with the 'next' item from PSReadLine history.
- ///
- public static void NextHistory(ConsoleKeyInfo? key = null, object arg = null)
- {
- TryGetArgAsInt(arg, out var numericArg, +1);
- if (UpdateListSelection(numericArg))
+ // In SQLite mode, query per-location execution counts so that sorting
+ // reflects how often each command was run *in this directory* rather than
+ // the total across all locations (which inflates commands like "code ."
+ // that were run once here but many times elsewhere).
+ Dictionary localCounts = null;
+ if (_options.HistoryType == HistoryType.SQLite && !string.IsNullOrEmpty(_options.HistorySavePath))
{
- return;
+ localCounts = GetLocationExecutionCounts(currentLocation);
}
- _singleton.SaveCurrentLine();
- _singleton.HistoryRecall(numericArg);
+ // Sort by per-location frequency DESC (SQLite) or total frequency DESC (Text),
+ // then by position DESC (more recent first).
+ _locationSortedIndices.Sort((a, b) =>
+ {
+ long countA, countB;
+ if (localCounts != null)
+ {
+ localCounts.TryGetValue(_history[a].CommandLine, out countA);
+ localCounts.TryGetValue(_history[b].CommandLine, out countB);
+ }
+ else
+ {
+ countA = _history[a].ExecutionCount;
+ countB = _history[b].ExecutionCount;
+ }
+ int freqCmp = countB.CompareTo(countA);
+ if (freqCmp != 0) return freqCmp;
+ return b.CompareTo(a);
+ });
}
private void HistorySearch(int direction)
@@ -1302,5 +2616,201 @@ public static void ReverseSearchHistory(ConsoleKeyInfo? key = null, object arg =
{
_singleton.InteractiveHistorySearch(-1);
}
+
+ private void UpdateLocationHistoryDuringInteractiveSearch(string toMatch, int direction, string currentLocation, ref int searchFromPoint)
+ {
+ searchFromPoint += direction;
+ for (; searchFromPoint >= 0 && searchFromPoint < _history.Count; searchFromPoint += direction)
+ {
+ // Filter by location
+ if (!string.Equals(_history[searchFromPoint].Location, currentLocation, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var line = _history[searchFromPoint].CommandLine;
+ var startIndex = line.IndexOf(toMatch, Options.HistoryStringComparison);
+ if (startIndex >= 0)
+ {
+ if (Options.HistoryNoDuplicates)
+ {
+ if (!_hashedHistory.TryGetValue(line, out var index))
+ {
+ _hashedHistory.Add(line, searchFromPoint);
+ }
+ else if (index != searchFromPoint)
+ {
+ continue;
+ }
+ }
+ _statusLinePrompt = direction > 0 ? _forwardLocationISearchPrompt : _backwardLocationISearchPrompt;
+ _current = startIndex;
+ _emphasisStart = startIndex;
+ _emphasisLength = toMatch.Length;
+ _currentHistoryIndex = searchFromPoint;
+ var moveCursor = Options.HistorySearchCursorMovesToEnd
+ ? HistoryMoveCursor.ToEnd
+ : HistoryMoveCursor.DontMove;
+ UpdateFromHistory(moveCursor);
+ return;
+ }
+ }
+
+ if (searchFromPoint < 0)
+ searchFromPoint = -1;
+ else if (searchFromPoint >= _history.Count)
+ searchFromPoint = _history.Count;
+
+ _emphasisStart = -1;
+ _emphasisLength = 0;
+ _statusLinePrompt = direction > 0 ? _failedForwardLocationISearchPrompt : _failedBackwardLocationISearchPrompt;
+ Render();
+ }
+
+ private void InteractiveLocationHistorySearchLoop(int direction, string currentLocation)
+ {
+ var searchFromPoint = _currentHistoryIndex;
+ var searchPositions = new Stack();
+ searchPositions.Push(_currentHistoryIndex);
+
+ if (Options.HistoryNoDuplicates)
+ {
+ _hashedHistory = new Dictionary();
+ }
+
+ var toMatch = new StringBuilder(64);
+ while (true)
+ {
+ var key = ReadKey();
+ _dispatchTable.TryGetValue(key, out var handler);
+ var function = handler?.Action;
+ if (function == ReverseLocationSearchHistory)
+ {
+ UpdateLocationHistoryDuringInteractiveSearch(toMatch.ToString(), -1, currentLocation, ref searchFromPoint);
+ }
+ else if (function == ForwardLocationSearchHistory)
+ {
+ UpdateLocationHistoryDuringInteractiveSearch(toMatch.ToString(), +1, currentLocation, ref searchFromPoint);
+ }
+ else if (function == BackwardDeleteChar
+ || key == Keys.Backspace
+ || key == Keys.CtrlH)
+ {
+ if (toMatch.Length > 0)
+ {
+ toMatch.Remove(toMatch.Length - 1, 1);
+ _statusBuffer.Remove(_statusBuffer.Length - 2, 1);
+ searchPositions.Pop();
+ searchFromPoint = _currentHistoryIndex = searchPositions.Peek();
+ var moveCursor = Options.HistorySearchCursorMovesToEnd
+ ? HistoryMoveCursor.ToEnd
+ : HistoryMoveCursor.DontMove;
+ UpdateFromHistory(moveCursor);
+
+ if (_hashedHistory != null)
+ {
+ foreach (var pair in _hashedHistory.ToArray())
+ {
+ if (pair.Value < searchFromPoint)
+ {
+ _hashedHistory.Remove(pair.Key);
+ }
+ }
+ }
+
+ var toMatchStr = toMatch.ToString();
+ var startIndex = _buffer.ToString().IndexOf(toMatchStr, Options.HistoryStringComparison);
+ if (startIndex >= 0)
+ {
+ _statusLinePrompt = direction > 0 ? _forwardLocationISearchPrompt : _backwardLocationISearchPrompt;
+ _current = startIndex;
+ _emphasisStart = startIndex;
+ _emphasisLength = toMatch.Length;
+ Render();
+ }
+ }
+ else
+ {
+ Ding();
+ }
+ }
+ else if (key == Keys.Escape)
+ {
+ break;
+ }
+ else if (function == Abort)
+ {
+ GoToEndOfHistory();
+ break;
+ }
+ else
+ {
+ char toAppend = key.KeyChar;
+ if (char.IsControl(toAppend))
+ {
+ PrependQueuedKeys(key);
+ break;
+ }
+ toMatch.Append(toAppend);
+ _statusBuffer.Insert(_statusBuffer.Length - 1, toAppend);
+
+ var toMatchStr = toMatch.ToString();
+ var startIndex = _buffer.ToString().IndexOf(toMatchStr, Options.HistoryStringComparison);
+ if (startIndex < 0)
+ {
+ UpdateLocationHistoryDuringInteractiveSearch(toMatchStr, direction, currentLocation, ref searchFromPoint);
+ }
+ else
+ {
+ _current = startIndex;
+ _emphasisStart = startIndex;
+ _emphasisLength = toMatch.Length;
+ Render();
+ }
+ searchPositions.Push(_currentHistoryIndex);
+ }
+ }
+ }
+
+ private void InteractiveLocationHistorySearch(int direction)
+ {
+ var currentLocation = GetCurrentLocation();
+ if (string.IsNullOrEmpty(currentLocation))
+ {
+ // Fall back to regular interactive search if location unavailable
+ InteractiveHistorySearch(direction);
+ return;
+ }
+
+ using var _ = _prediction.DisableScoped();
+ SaveCurrentLine();
+
+ _statusLinePrompt = direction > 0 ? _forwardLocationISearchPrompt : _backwardLocationISearchPrompt;
+ _statusBuffer.Append("_");
+
+ Render();
+ InteractiveLocationHistorySearchLoop(direction, currentLocation);
+
+ _emphasisStart = -1;
+ _emphasisLength = 0;
+
+ ClearStatusMessage(render: true);
+ }
+
+ ///
+ /// Perform an incremental forward search through history, filtered to commands executed from the current location.
+ ///
+ public static void ForwardLocationSearchHistory(ConsoleKeyInfo? key = null, object arg = null)
+ {
+ _singleton.InteractiveLocationHistorySearch(+1);
+ }
+
+ ///
+ /// Perform an incremental backward search through history, filtered to commands executed from the current location.
+ ///
+ public static void ReverseLocationSearchHistory(ConsoleKeyInfo? key = null, object arg = null)
+ {
+ _singleton.InteractiveLocationHistorySearch(-1);
+ }
}
}
diff --git a/PSReadLine/KeyBindings.cs b/PSReadLine/KeyBindings.cs
index 89564228..9b307af2 100644
--- a/PSReadLine/KeyBindings.cs
+++ b/PSReadLine/KeyBindings.cs
@@ -207,6 +207,7 @@ void SetDefaultWindowsBindings()
{ Keys.CtrlL, MakeKeyHandler(ClearScreen, "ClearScreen") },
{ Keys.CtrlR, MakeKeyHandler(ReverseSearchHistory, "ReverseSearchHistory") },
{ Keys.CtrlS, MakeKeyHandler(ForwardSearchHistory, "ForwardSearchHistory") },
+ { Keys.CtrlAltR, MakeKeyHandler(ReverseLocationSearchHistory, "ReverseLocationSearchHistory") },
{ Keys.CtrlV, MakeKeyHandler(Paste, "Paste") },
{ Keys.ShiftInsert, MakeKeyHandler(Paste, "Paste") },
{ Keys.CtrlX, MakeKeyHandler(Cut, "Cut") },
@@ -238,8 +239,11 @@ void SetDefaultWindowsBindings()
{ Keys.F4, MakeKeyHandler(ShowFullPredictionTooltip, "ShowFullPredictionTooltip") },
{ Keys.F8, MakeKeyHandler(HistorySearchBackward, "HistorySearchBackward") },
{ Keys.ShiftF8, MakeKeyHandler(HistorySearchForward, "HistorySearchForward") },
+ { Keys.AltUpArrow, MakeKeyHandler(PreviousLocationHistory, "PreviousLocationHistory") },
+ { Keys.AltDownArrow, MakeKeyHandler(NextLocationHistory, "NextLocationHistory") },
// Added for xtermjs-based terminals that send different key combinations.
{ Keys.AltD, MakeKeyHandler(KillWord, "KillWord") },
+ { Keys.AltDelete, MakeKeyHandler(RemoveFromHistoryAtCurrentLocation, "RemoveFromHistoryAtCurrentLocation") },
{ Keys.CtrlAt, MakeKeyHandler(MenuComplete, "MenuComplete") },
{ Keys.CtrlW, MakeKeyHandler(BackwardKillWord, "BackwardKillWord") },
};
@@ -247,9 +251,10 @@ void SetDefaultWindowsBindings()
// Some bindings are not available on certain platforms
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
- _dispatchTable.Add(Keys.CtrlSpace, MakeKeyHandler(MenuComplete, "MenuComplete"));
- _dispatchTable.Add(Keys.AltF7, MakeKeyHandler(ClearHistory, "ClearHistory"));
- _dispatchTable.Add(Keys.CtrlDelete, MakeKeyHandler(KillWord, "KillWord"));
+ _dispatchTable.Add(Keys.CtrlSpace, MakeKeyHandler(MenuComplete, "MenuComplete"));
+ _dispatchTable.Add(Keys.AltF7, MakeKeyHandler(ClearHistory, "ClearHistory"));
+ _dispatchTable.Add(Keys.CtrlDelete, MakeKeyHandler(KillWord, "KillWord"));
+ _dispatchTable.Add(Keys.CtrlShiftDelete, MakeKeyHandler(RemoveFromHistory, "RemoveFromHistory"));
_dispatchTable.Add(Keys.CtrlEnd, MakeKeyHandler(ForwardDeleteInput, "ForwardDeleteInput"));
_dispatchTable.Add(Keys.CtrlH, MakeKeyHandler(BackwardDeleteChar,"BackwardDeleteChar"));
@@ -279,6 +284,8 @@ void SetDefaultEmacsBindings()
{ Keys.DownArrow, MakeKeyHandler(NextHistory, "NextHistory") },
{ Keys.AltLess, MakeKeyHandler(BeginningOfHistory, "BeginningOfHistory") },
{ Keys.AltGreater, MakeKeyHandler(EndOfHistory, "EndOfHistory") },
+ { Keys.AltUpArrow, MakeKeyHandler(PreviousLocationHistory, "PreviousLocationHistory") },
+ { Keys.AltDownArrow, MakeKeyHandler(NextLocationHistory, "NextLocationHistory") },
{ Keys.Home, MakeKeyHandler(BeginningOfLine, "BeginningOfLine") },
{ Keys.End, MakeKeyHandler(EndOfLine, "EndOfLine") },
{ Keys.ShiftHome, MakeKeyHandler(SelectBackwardsLine, "SelectBackwardsLine") },
@@ -301,6 +308,7 @@ void SetDefaultEmacsBindings()
{ Keys.CtrlP, MakeKeyHandler(PreviousHistory, "PreviousHistory") },
{ Keys.CtrlR, MakeKeyHandler(ReverseSearchHistory, "ReverseSearchHistory") },
{ Keys.CtrlS, MakeKeyHandler(ForwardSearchHistory, "ForwardSearchHistory") },
+ { Keys.CtrlAltR, MakeKeyHandler(ReverseLocationSearchHistory, "ReverseLocationSearchHistory") },
{ Keys.CtrlT, MakeKeyHandler(SwapCharacters, "SwapCharacters") },
{ Keys.CtrlU, MakeKeyHandler(BackwardKillInput, "BackwardKillInput") },
{ Keys.CtrlX, MakeKeyHandler(Chord, "ChordFirstKey") },
@@ -325,6 +333,7 @@ void SetDefaultEmacsBindings()
{ Keys.AltB, MakeKeyHandler(BackwardWord, "BackwardWord") },
{ Keys.AltShiftB, MakeKeyHandler(SelectBackwardWord, "SelectBackwardWord") },
{ Keys.AltD, MakeKeyHandler(KillWord, "KillWord") },
+ { Keys.AltDelete, MakeKeyHandler(RemoveFromHistoryAtCurrentLocation, "RemoveFromHistoryAtCurrentLocation") },
{ Keys.AltF, MakeKeyHandler(ForwardWord, "ForwardWord") },
{ Keys.AltShiftF, MakeKeyHandler(SelectForwardWord, "SelectForwardWord") },
{ Keys.AltR, MakeKeyHandler(RevertLine, "RevertLine") },
@@ -349,10 +358,11 @@ void SetDefaultEmacsBindings()
// Some bindings are not available on certain platforms
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
- _dispatchTable.Add(Keys.CtrlH, MakeKeyHandler(BackwardDeleteChar, "BackwardDeleteChar"));
- _dispatchTable.Add(Keys.CtrlSpace, MakeKeyHandler(MenuComplete, "MenuComplete"));
- _dispatchTable.Add(Keys.CtrlEnd, MakeKeyHandler(ScrollDisplayToCursor, "ScrollDisplayToCursor"));
- _dispatchTable.Add(Keys.CtrlHome, MakeKeyHandler(ScrollDisplayTop, "ScrollDisplayTop"));
+ _dispatchTable.Add(Keys.CtrlH, MakeKeyHandler(BackwardDeleteChar, "BackwardDeleteChar"));
+ _dispatchTable.Add(Keys.CtrlSpace, MakeKeyHandler(MenuComplete, "MenuComplete"));
+ _dispatchTable.Add(Keys.CtrlEnd, MakeKeyHandler(ScrollDisplayToCursor, "ScrollDisplayToCursor"));
+ _dispatchTable.Add(Keys.CtrlHome, MakeKeyHandler(ScrollDisplayTop, "ScrollDisplayTop"));
+ _dispatchTable.Add(Keys.CtrlShiftDelete, MakeKeyHandler(RemoveFromHistory, "RemoveFromHistory"));
// PageUp/PageDown and CtrlPageUp/CtrlPageDown bindings are supported on Windows only because they depend on the
// API 'Console.SetWindowPosition', which throws 'PlatformNotSupportedException' on unix platforms.
@@ -549,9 +559,15 @@ public static KeyHandlerGroup GetDisplayGrouping(string function)
case nameof(HistorySearchBackward):
case nameof(HistorySearchForward):
case nameof(NextHistory):
+ case nameof(NextLocationHistory):
case nameof(PreviousHistory):
+ case nameof(PreviousLocationHistory):
case nameof(ReverseSearchHistory):
+ case nameof(ReverseLocationSearchHistory):
+ case nameof(ForwardLocationSearchHistory):
case nameof(ViSearchHistoryBackward):
+ case nameof(RemoveFromHistory):
+ case nameof(RemoveFromHistoryAtCurrentLocation):
return KeyHandlerGroup.History;
case nameof(Complete):
diff --git a/PSReadLine/Keys.cs b/PSReadLine/Keys.cs
index 70cd80e4..bf72725c 100644
--- a/PSReadLine/Keys.cs
+++ b/PSReadLine/Keys.cs
@@ -457,6 +457,7 @@ internal static class Keys
public static PSKeyInfo Space = Key(ConsoleKey.Spacebar);
public static PSKeyInfo Backspace = Key(ConsoleKey.Backspace);
public static PSKeyInfo Delete = Key(ConsoleKey.Delete);
+ public static PSKeyInfo AltDelete = Alt(ConsoleKey.Delete);
public static PSKeyInfo DownArrow = Key(ConsoleKey.DownArrow);
public static PSKeyInfo End = Key(ConsoleKey.End);
public static PSKeyInfo Enter = Key(ConsoleKey.Enter);
@@ -581,6 +582,7 @@ internal static class Keys
public static PSKeyInfo CtrlUnderbar = Ctrl('_');
public static PSKeyInfo CtrlBackspace = Ctrl(ConsoleKey.Backspace);
public static PSKeyInfo CtrlDelete = Ctrl(ConsoleKey.Delete); // !Linux
+ public static PSKeyInfo CtrlShiftDelete = CtrlShift(ConsoleKey.Delete); // !Linux
public static PSKeyInfo CtrlEnd = Ctrl(ConsoleKey.End); // !Linux
public static PSKeyInfo CtrlHome = Ctrl(ConsoleKey.Home); // !Linux
public static PSKeyInfo CtrlPageUp = Ctrl(ConsoleKey.PageUp); // !Linux
@@ -598,6 +600,8 @@ internal static class Keys
public static PSKeyInfo ShiftPageDown = Shift(ConsoleKey.PageDown);
public static PSKeyInfo ShiftLeftArrow = Shift(ConsoleKey.LeftArrow);
public static PSKeyInfo ShiftRightArrow = Shift(ConsoleKey.RightArrow);
+ public static PSKeyInfo AltUpArrow = Alt(ConsoleKey.UpArrow);
+ public static PSKeyInfo AltDownArrow = Alt(ConsoleKey.DownArrow);
public static PSKeyInfo ShiftUpArrow = Shift(ConsoleKey.UpArrow);
public static PSKeyInfo ShiftDownArrow = Shift(ConsoleKey.DownArrow);
public static PSKeyInfo ShiftTab = Shift(ConsoleKey.Tab); // !Linux, same as Tab
@@ -608,6 +612,7 @@ internal static class Keys
public static PSKeyInfo CtrlShiftLeftArrow = CtrlShift(ConsoleKey.LeftArrow);
public static PSKeyInfo CtrlShiftRightArrow = CtrlShift(ConsoleKey.RightArrow);
+ public static PSKeyInfo CtrlAltR = CtrlAlt('r');
public static PSKeyInfo CtrlAltY = CtrlAlt('y');
public static PSKeyInfo CtrlAltRBracket = CtrlAlt(']');
public static PSKeyInfo CtrlAltQuestion = CtrlAlt('?');
diff --git a/PSReadLine/Options.cs b/PSReadLine/Options.cs
index ffdf0241..1d633fb7 100644
--- a/PSReadLine/Options.cs
+++ b/PSReadLine/Options.cs
@@ -26,6 +26,33 @@ private void SetOptionsInternal(SetPSReadLineOption options)
{
Options.ContinuationPrompt = options.ContinuationPrompt;
}
+ if (options._historyTypeSpecified)
+ {
+ Options.HistoryType = options.HistoryType;
+ if (Options.HistoryType is HistoryType.SQLite)
+ {
+ // HistorySavePath is now computed from HistoryType, so it already
+ // points at HistorySavePathSQLite after the type switch above.
+ if (!string.IsNullOrEmpty(Options.HistorySavePath) && !System.IO.File.Exists(Options.HistorySavePath))
+ {
+ _historyFileMutex?.Dispose();
+ _historyFileMutex = new Mutex(false, GetHistorySaveFileMutexName());
+ InitializeSQLiteDatabase(migrateTextHistory: true);
+ _historyFileLastSavedSize = 0;
+ }
+
+ // _history is null when Set-PSReadLineOption runs from the profile before
+ // the first ReadLine() call. In that case, skip loading here — ReadLine()
+ // initialization will create _history and load SQLite history itself.
+ // When _history already exists (interactive switch), reload immediately.
+ if (_singleton._history != null)
+ {
+ _singleton._history.Clear();
+ _singleton._currentHistoryIndex = 0;
+ ReadSQLiteHistory(fromOtherSession: false);
+ }
+ }
+ }
if (options._historyNoDuplicates.HasValue)
{
Options.HistoryNoDuplicates = options.HistoryNoDuplicates;
@@ -122,12 +149,41 @@ private void SetOptionsInternal(SetPSReadLineOption options)
}
Options.ViModeChangeHandler = options.ViModeChangeHandler;
}
- if (options.HistorySavePath != null)
+ if (options.HistorySavePathText != null)
{
- Options.HistorySavePath = options.HistorySavePath;
- _historyFileMutex?.Dispose();
- _historyFileMutex = new Mutex(false, GetHistorySaveFileMutexName());
- _historyFileLastSavedSize = 0;
+ Options.HistorySavePathText = options.HistorySavePathText;
+
+ // If currently in Text mode, reset the mutex for the new active path.
+ if (Options.HistoryType is HistoryType.Text)
+ {
+ _historyFileMutex?.Dispose();
+ _historyFileMutex = new Mutex(false, GetHistorySaveFileMutexName());
+ _historyFileLastSavedSize = 0;
+ }
+ }
+ if (options.HistorySavePathSQLite != null)
+ {
+ Options.HistorySavePathSQLite = options.HistorySavePathSQLite;
+
+ // If currently in SQLite mode, reconnect to the new database.
+ if (Options.HistoryType is HistoryType.SQLite)
+ {
+ _historyFileMutex?.Dispose();
+ _historyFileMutex = new Mutex(false, GetHistorySaveFileMutexName());
+ _historyFileLastSavedSize = 0;
+
+ if (!System.IO.File.Exists(Options.HistorySavePath))
+ {
+ InitializeSQLiteDatabase();
+ }
+
+ if (_singleton._history != null)
+ {
+ _singleton._history.Clear();
+ _singleton._currentHistoryIndex = 0;
+ ReadSQLiteHistory(fromOtherSession: false);
+ }
+ }
}
if (options._ansiEscapeTimeout.HasValue)
{
@@ -189,6 +245,10 @@ private void SetOptionsInternal(SetPSReadLineOption options)
{
Options.ScreenReaderModeEnabled = options.EnableScreenReaderMode;
}
+ if (options._accessibleHistoryDisplay.HasValue)
+ {
+ Options.AccessibleHistoryDisplay = options.AccessibleHistoryDisplay;
+ }
}
private void SetKeyHandlerInternal(string[] keys, Action handler, string briefDescription, string longDescription, ScriptBlock scriptBlock)
diff --git a/PSReadLine/PSReadLine.csproj b/PSReadLine/PSReadLine.csproj
index edb39c91..2718d0da 100644
--- a/PSReadLine/PSReadLine.csproj
+++ b/PSReadLine/PSReadLine.csproj
@@ -13,6 +13,7 @@
true
false
9.0
+ true
@@ -20,7 +21,9 @@
contentFiles
All
+
+
@@ -30,4 +33,4 @@
-
+
\ No newline at end of file
diff --git a/PSReadLine/PSReadLine.format.ps1xml b/PSReadLine/PSReadLine.format.ps1xml
index 62867e76..e172fa36 100644
--- a/PSReadLine/PSReadLine.format.ps1xml
+++ b/PSReadLine/PSReadLine.format.ps1xml
@@ -90,6 +90,9 @@ $d = [Microsoft.PowerShell.KeyHandler]::GetGroupingDescription($_.Group)
EditMode
+
+ HistoryType
+
AddToHistoryHandler
@@ -97,7 +100,10 @@ $d = [Microsoft.PowerShell.KeyHandler]::GetGroupingDescription($_.Group)
HistoryNoDuplicates
- HistorySavePath
+ HistorySavePathText
+
+
+ HistorySavePathSQLite
HistorySaveStyle
diff --git a/PSReadLine/PSReadLineResources.Designer.cs b/PSReadLine/PSReadLineResources.Designer.cs
index ac672d3f..af4d4866 100644
--- a/PSReadLine/PSReadLineResources.Designer.cs
+++ b/PSReadLine/PSReadLineResources.Designer.cs
@@ -841,6 +841,15 @@ internal static string NextHistoryDescription {
}
}
+ ///
+ /// Looks up a localized string similar to Replace the input with the next item in the history that was executed from the current location.
+ ///
+ internal static string NextLocationHistoryDescription {
+ get {
+ return ResourceManager.GetString("NextLocationHistoryDescription", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Move the cursor to the next line if the input has multiple lines..
///
@@ -978,6 +987,33 @@ internal static string PreviousHistoryDescription {
}
}
+ ///
+ /// Looks up a localized string similar to Replace the input with the previous item in the history that was executed from the current location.
+ ///
+ internal static string PreviousLocationHistoryDescription {
+ get {
+ return ResourceManager.GetString("PreviousLocationHistoryDescription", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Perform an incremental backward search through history, filtered to commands executed from the current location.
+ ///
+ internal static string ReverseLocationSearchHistoryDescription {
+ get {
+ return ResourceManager.GetString("ReverseLocationSearchHistoryDescription", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Perform an incremental forward search through history, filtered to commands executed from the current location.
+ ///
+ internal static string ForwardLocationSearchHistoryDescription {
+ get {
+ return ResourceManager.GetString("ForwardLocationSearchHistoryDescription", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Move the cursor to the previous line if the input has multiple lines..
///
diff --git a/PSReadLine/PSReadLineResources.resx b/PSReadLine/PSReadLineResources.resx
index e619e7c0..93451aa4 100644
--- a/PSReadLine/PSReadLineResources.resx
+++ b/PSReadLine/PSReadLineResources.resx
@@ -216,6 +216,9 @@
Replace the input with the next item in the history
+
+ Replace the input with the next item in the history that was executed from the current location
+
Paste text from the system clipboard
@@ -225,6 +228,15 @@
Replace the input with the previous item in the history
+
+ Replace the input with the previous item in the history that was executed from the current location
+
+
+ Perform an incremental backward search through history, filtered to commands executed from the current location
+
+
+ Perform an incremental forward search through history, filtered to commands executed from the current location
+
Redo an undo
diff --git a/PSReadLine/Prediction.Entry.cs b/PSReadLine/Prediction.Entry.cs
index 643afa5d..026e00b6 100644
--- a/PSReadLine/Prediction.Entry.cs
+++ b/PSReadLine/Prediction.Entry.cs
@@ -50,6 +50,7 @@ private struct SuggestionEntry
internal readonly string ToolTip;
internal readonly string SuggestionText;
internal readonly int InputMatchIndex;
+ internal readonly PSConsoleReadLine.HistoryItem HistoryItemRef;
private string _listItemTextRegular;
private string _listItemTextSelected;
@@ -59,6 +60,19 @@ internal SuggestionEntry(string suggestion, int matchIndex)
{
}
+ internal SuggestionEntry(string suggestion, string tooltip, int matchIndex, PSConsoleReadLine.HistoryItem historyItem = null)
+ {
+ Source = HistorySource;
+ PredictorId = Guid.Empty;
+ PredictorSession = null;
+ SuggestionText = suggestion;
+ ToolTip = tooltip;
+ InputMatchIndex = matchIndex;
+ HistoryItemRef = historyItem;
+
+ _listItemTextRegular = _listItemTextSelected = null;
+ }
+
internal SuggestionEntry(string source, Guid predictorId, uint? predictorSession, string suggestion, string tooltip, int matchIndex)
{
Source = source;
@@ -67,6 +81,7 @@ internal SuggestionEntry(string source, Guid predictorId, uint? predictorSession
SuggestionText = suggestion;
ToolTip = tooltip;
InputMatchIndex = matchIndex;
+ HistoryItemRef = null;
_listItemTextRegular = _listItemTextSelected = null;
}
diff --git a/PSReadLine/Prediction.Views.cs b/PSReadLine/Prediction.Views.cs
index a9145c43..ca8b872f 100644
--- a/PSReadLine/Prediction.Views.cs
+++ b/PSReadLine/Prediction.Views.cs
@@ -116,21 +116,62 @@ protected string GetOneHistorySuggestion(string text)
///
/// User input.
/// Maximum number of results to return.
- protected List GetHistorySuggestions(string input, int count)
+ ///
+ /// Generate a tooltip string with statistics for a history item.
+ /// This returns a plain-text version used as the ToolTip value (non-null triggers tooltip rendering).
+ /// The actual colored rendering is done by .
+ ///
+ private static string FormatHistoryStatsTooltip(HistoryItem item)
{
- List results = null;
- int remainingCount = count;
+ var sb = new StringBuilder();
+ sb.Append("Runs: ").Append(item.ExecutionCount);
+
+ if (item.StartTime != default)
+ {
+ var ago = DateTime.UtcNow - item.StartTime;
+ if (ago.TotalMinutes < 1)
+ sb.Append(" \u2502 Last: just now");
+ else if (ago.TotalHours < 1)
+ sb.Append(" \u2502 Last: ").Append((int)ago.TotalMinutes).Append("m ago");
+ else if (ago.TotalDays < 1)
+ sb.Append(" \u2502 Last: ").Append((int)ago.TotalHours).Append("h ago");
+ else
+ sb.Append(" \u2502 Last: ").Append((int)ago.TotalDays).Append("d ago");
+ }
+ if (!string.IsNullOrEmpty(item.Location) && !item.Location.Equals("Unknown", StringComparison.OrdinalIgnoreCase))
+ {
+ sb.Append(" \u2502 Dir: ").Append(item.Location);
+ }
+
+ return sb.ToString();
+ }
+
+ protected List GetHistorySuggestions(string input, int count)
+ {
var history = _singleton._history;
var comparison = _singleton._options.HistoryStringComparison;
var comparer = _singleton._options.HistoryStringComparer;
+ bool isSQLite = _singleton._options.HistoryType is HistoryType.SQLite;
_cacheHistorySet ??= new HashSet(comparer);
_cacheHistoryList ??= new List();
+ // In SQLite mode, use frecency-based ordering with optional location partitioning.
+ // In text mode, use existing recency-based logic (pure reverse-chronological).
+ if (isSQLite)
+ {
+ return GetHistorySuggestionsSQLite(input, count, history, comparison, comparer);
+ }
+
+ // --- Text mode: original recency-based logic (unchanged) ---
+ List results = null;
+ int remainingCount = count;
+
for (int historyIndex = history.Count - 1; historyIndex >= 0; historyIndex--)
{
- var line = history[historyIndex].CommandLine.TrimEnd();
+ var historyItem = history[historyIndex];
+ var line = historyItem.CommandLine.TrimEnd();
// Skip the history command lines that are smaller in length than the user input,
// or contain multiple logical lines.
@@ -175,6 +216,245 @@ protected List GetHistorySuggestions(string input, int count)
return results;
}
+ ///
+ /// SQLite mode: collects matching history candidates, sorts by frecency
+ /// (frequency DESC, then recency DESC), and optionally partitions by location.
+ /// When plugins are active (few history slots), no location split is applied.
+ /// When plugins are not active, results are partitioned: top half from commands
+ /// matching the current directory, bottom half from global commands.
+ ///
+ private List GetHistorySuggestionsSQLite(
+ string input, int count,
+ HistoryQueue history,
+ StringComparison comparison,
+ StringComparer comparer)
+ {
+ // Collect all matching candidates with their history index (for recency sorting).
+ var prefixMatches = new List<(int historyIndex, HistoryItem item, string line)>();
+ var substringMatches = new List<(int historyIndex, HistoryItem item, string line)>();
+ var seen = new HashSet(comparer);
+
+ for (int historyIndex = history.Count - 1; historyIndex >= 0; historyIndex--)
+ {
+ var historyItem = history[historyIndex];
+ var line = historyItem.CommandLine.TrimEnd();
+
+ if (line.Length <= input.Length || seen.Contains(line) || line.IndexOf('\n') != -1)
+ {
+ continue;
+ }
+
+ int matchIndex = line.IndexOf(input, comparison);
+ if (matchIndex == -1)
+ {
+ continue;
+ }
+
+ seen.Add(line);
+
+ if (matchIndex == 0)
+ {
+ prefixMatches.Add((historyIndex, historyItem, line));
+ }
+ else
+ {
+ substringMatches.Add((historyIndex, historyItem, line));
+ }
+ }
+
+ _cacheHistorySet.Clear();
+ _cacheHistoryList.Clear();
+
+ if (prefixMatches.Count == 0 && substringMatches.Count == 0)
+ {
+ return null;
+ }
+
+ // Frecency comparer: frequency DESC (ExecutionCount), then recency DESC (historyIndex).
+ static int FrecencyCompare(
+ (int historyIndex, HistoryItem item, string line) a,
+ (int historyIndex, HistoryItem item, string line) b)
+ {
+ int freqCmp = b.item.ExecutionCount.CompareTo(a.item.ExecutionCount);
+ if (freqCmp != 0) return freqCmp;
+ return b.historyIndex.CompareTo(a.historyIndex);
+ }
+
+ // Determine whether to apply location partitioning.
+ // With plugins active, history gets at most 3 slots — too few to split.
+ bool partitionByLocation = !UsePlugin;
+ string currentLocation = partitionByLocation ? _singleton.GetCurrentLocation() : null;
+ partitionByLocation = partitionByLocation
+ && !string.IsNullOrEmpty(currentLocation)
+ && !currentLocation.Equals("Unknown", StringComparison.OrdinalIgnoreCase);
+
+ var results = new List(capacity: count);
+
+ if (partitionByLocation)
+ {
+ // Query per-location execution counts so local sorting reflects how
+ // often each command was run *in this directory*, not globally.
+ Dictionary localCounts = null;
+ if (_singleton._options.HistoryType == HistoryType.SQLite
+ && !string.IsNullOrEmpty(_singleton._options.HistorySavePath))
+ {
+ localCounts = _singleton.GetLocationExecutionCounts(currentLocation);
+ }
+
+ int LocalFrecencyCompare(
+ (int historyIndex, HistoryItem item, string line) a,
+ (int historyIndex, HistoryItem item, string line) b)
+ {
+ long countA, countB;
+ if (localCounts != null)
+ {
+ localCounts.TryGetValue(a.item.CommandLine, out countA);
+ localCounts.TryGetValue(b.item.CommandLine, out countB);
+ }
+ else
+ {
+ countA = a.item.ExecutionCount;
+ countB = b.item.ExecutionCount;
+ }
+ int freqCmp = countB.CompareTo(countA);
+ if (freqCmp != 0) return freqCmp;
+ return b.historyIndex.CompareTo(a.historyIndex);
+ }
+
+ // Split prefix matches into local (matching current dir) and global.
+ var localPrefix = new List<(int historyIndex, HistoryItem item, string line)>();
+ var globalPrefix = new List<(int historyIndex, HistoryItem item, string line)>();
+
+ foreach (var m in prefixMatches)
+ {
+ if (string.Equals(m.item.Location, currentLocation, StringComparison.OrdinalIgnoreCase))
+ localPrefix.Add(m);
+ else
+ globalPrefix.Add(m);
+ }
+
+ localPrefix.Sort(LocalFrecencyCompare);
+ globalPrefix.Sort(FrecencyCompare);
+
+ // Top half for local, bottom half for global. Backfill if local has fewer.
+ int halfCount = count / 2;
+ int localSlots = Math.Min(halfCount, localPrefix.Count);
+ int globalSlots = count - localSlots;
+
+ // Add local prefix matches (top half).
+ for (int i = 0; i < localSlots; i++)
+ {
+ var m = localPrefix[i];
+ results.Add(MakeSQLiteEntry(m.item, m.line, matchIndex: 0));
+ }
+
+ // Track how many local items were added for dedup.
+ var addedLines = new HashSet(comparer);
+ foreach (var entry in results)
+ {
+ addedLines.Add(entry.SuggestionText);
+ }
+
+ // Add global prefix matches (bottom half), excluding already-added items.
+ int globalAdded = 0;
+ for (int i = 0; i < globalPrefix.Count && globalAdded < globalSlots; i++)
+ {
+ var m = globalPrefix[i];
+ if (!addedLines.Contains(m.line))
+ {
+ results.Add(MakeSQLiteEntry(m.item, m.line, matchIndex: 0));
+ addedLines.Add(m.line);
+ globalAdded++;
+ }
+ }
+
+ // If still room, backfill from remaining local prefix matches.
+ for (int i = localSlots; i < localPrefix.Count && results.Count < count; i++)
+ {
+ var m = localPrefix[i];
+ if (!addedLines.Contains(m.line))
+ {
+ results.Add(MakeSQLiteEntry(m.item, m.line, matchIndex: 0));
+ addedLines.Add(m.line);
+ }
+ }
+
+ // Fill remaining slots with substring matches sorted by frecency,
+ // local first then global.
+ if (results.Count < count && substringMatches.Count > 0)
+ {
+ var localSub = new List<(int historyIndex, HistoryItem item, string line)>();
+ var globalSub = new List<(int historyIndex, HistoryItem item, string line)>();
+
+ foreach (var m in substringMatches)
+ {
+ if (!addedLines.Contains(m.line))
+ {
+ if (string.Equals(m.item.Location, currentLocation, StringComparison.OrdinalIgnoreCase))
+ localSub.Add(m);
+ else
+ globalSub.Add(m);
+ }
+ }
+
+ localSub.Sort(LocalFrecencyCompare);
+ globalSub.Sort(FrecencyCompare);
+
+ int subRemaining = count - results.Count;
+ int subHalf = subRemaining / 2;
+ int localSubSlots = Math.Min(subHalf, localSub.Count);
+
+ for (int i = 0; i < localSubSlots && results.Count < count; i++)
+ {
+ var m = localSub[i];
+ results.Add(MakeSQLiteEntry(m.item, m.line, input.Length > 0 ? m.line.IndexOf(input, comparison) : 0));
+ }
+
+ for (int i = 0; i < globalSub.Count && results.Count < count; i++)
+ {
+ var m = globalSub[i];
+ results.Add(MakeSQLiteEntry(m.item, m.line, input.Length > 0 ? m.line.IndexOf(input, comparison) : 0));
+ }
+
+ // Backfill from remaining local substring matches.
+ for (int i = localSubSlots; i < localSub.Count && results.Count < count; i++)
+ {
+ var m = localSub[i];
+ results.Add(MakeSQLiteEntry(m.item, m.line, input.Length > 0 ? m.line.IndexOf(input, comparison) : 0));
+ }
+ }
+ }
+ else
+ {
+ // No location partitioning: sort all candidates by frecency.
+ prefixMatches.Sort(FrecencyCompare);
+ substringMatches.Sort(FrecencyCompare);
+
+ for (int i = 0; i < prefixMatches.Count && results.Count < count; i++)
+ {
+ var m = prefixMatches[i];
+ results.Add(MakeSQLiteEntry(m.item, m.line, matchIndex: 0));
+ }
+
+ for (int i = 0; i < substringMatches.Count && results.Count < count; i++)
+ {
+ var m = substringMatches[i];
+ results.Add(MakeSQLiteEntry(m.item, m.line, input.Length > 0 ? m.line.IndexOf(input, comparison) : 0));
+ }
+ }
+
+ return results.Count > 0 ? results : null;
+ }
+
+ ///
+ /// Creates a SuggestionEntry for a SQLite history item with stats tooltip.
+ ///
+ private static SuggestionEntry MakeSQLiteEntry(HistoryItem item, string line, int matchIndex)
+ {
+ string tooltip = FormatHistoryStatsTooltip(item);
+ return new SuggestionEntry(line, tooltip, matchIndex, item);
+ }
+
///
/// Calls to the prediction API for suggestion results.
///
@@ -686,6 +966,16 @@ internal override void RenderSuggestion(List consoleBufferLines,
_tooltipHeight = 0;
}
+ // Clamp view window to the current list size.
+ // After item removal, _listViewEnd may exceed _listItems.Count when
+ // _selectedIndex is -1 (original input) and the recalculation above was skipped.
+ if (_listViewEnd > _listItems.Count)
+ {
+ _listViewEnd = _listItems.Count;
+ _listViewTop = Math.Max(0, _listViewEnd - _maxViewHeight);
+ _listViewHeight = _listViewEnd - _listViewTop;
+ }
+
for (int i = _listViewTop; i < _listViewEnd; i++)
{
bool itemSelected = i == _selectedIndex;
@@ -700,7 +990,14 @@ internal override void RenderSuggestion(List consoleBufferLines,
if (_singleton._options.ShowToolTips && itemSelected && !string.IsNullOrWhiteSpace(entry.ToolTip))
{
- _tooltipHeight = RenderTooltip(entry.ToolTip, consoleBufferLines, ref currentLogicalLine);
+ if (entry.HistoryItemRef != null)
+ {
+ _tooltipHeight = RenderHistoryStatsTooltip(entry.HistoryItemRef, consoleBufferLines, ref currentLogicalLine);
+ }
+ else
+ {
+ _tooltipHeight = RenderTooltip(entry.ToolTip, consoleBufferLines, ref currentLogicalLine);
+ }
}
}
}
@@ -1057,6 +1354,69 @@ private int RenderTooltip(string tooltip, List consoleBufferLines
return _maxTooltipHeight - (linesLeft > 0 ? linesLeft : 0);
}
+ ///
+ /// Render a colored history stats tooltip for a history item.
+ /// Uses icons with short labels for accessibility, values in the highlight color, separators dimmed.
+ /// When is enabled,
+ /// emoji icons are omitted so screen readers read only the human-readable labels.
+ ///
+ private int RenderHistoryStatsTooltip(HistoryItem item, List consoleBufferLines, ref int currentLogicalLine)
+ {
+ string tooltipColor = _singleton._options._listPredictionTooltipColor;
+ string dimItalicStyle = tooltipColor + "\x1b[2;3m";
+ // Icons must NOT be italic (causes emoji to lean/slant).
+ // Use explicit italic-off (\x1b[23m] to cancel italic inherited from tooltipColor.
+ const string italicOff = "\x1b[23m";
+ string valueStyle = _singleton._options._listPredictionColor;
+ bool accessible = _singleton._options.AccessibleHistoryDisplay;
+
+ var buff = NextBufferLine(consoleBufferLines, ref currentLogicalLine);
+ buff.Append(' ', 6);
+
+ // ⟳ Runs N
+ buff.Append(dimItalicStyle).Append(italicOff);
+ if (!accessible) buff.Append("\u27f3 ");
+ buff.Append("\x1b[3m").Append("Runs ")
+ .Append(VTColorUtils.AnsiReset).Append(valueStyle).Append(item.ExecutionCount)
+ .Append(VTColorUtils.AnsiReset);
+
+ // ⏱ Last relative-time
+ if (item.StartTime != default)
+ {
+ var ago = DateTime.UtcNow - item.StartTime;
+ string relativeTime;
+ if (ago.TotalMinutes < 1)
+ relativeTime = "just now";
+ else if (ago.TotalHours < 1)
+ relativeTime = $"{(int)ago.TotalMinutes}m ago";
+ else if (ago.TotalDays < 1)
+ relativeTime = $"{(int)ago.TotalHours}h ago";
+ else if (ago.TotalDays < 30)
+ relativeTime = $"{(int)ago.TotalDays}d ago";
+ else
+ relativeTime = item.StartTime.ToLocalTime().ToString("MMM d");
+
+ buff.Append(dimItalicStyle).Append(" \u2502 ").Append(italicOff);
+ if (!accessible) buff.Append("\u23f1 ");
+ buff.Append("\x1b[3m").Append("Last ")
+ .Append(VTColorUtils.AnsiReset).Append(valueStyle).Append(relativeTime)
+ .Append(VTColorUtils.AnsiReset);
+ }
+
+ // 📂 Dir path
+ if (!string.IsNullOrEmpty(item.Location) && !item.Location.Equals("Unknown", StringComparison.OrdinalIgnoreCase))
+ {
+ buff.Append(dimItalicStyle).Append(" \u2502 ").Append(italicOff);
+ if (!accessible) buff.Append("\U0001F4C2 ");
+ buff.Append("\x1b[3m").Append("Dir ")
+ .Append(VTColorUtils.AnsiReset).Append(valueStyle).Append(item.Location)
+ .Append(VTColorUtils.AnsiReset);
+ }
+
+ buff.Append(VTColorUtils.AnsiReset);
+ return 1;
+ }
+
///
/// Trigger the feedback about a suggestion was accepted.
///
@@ -1117,6 +1477,94 @@ internal override void Reset()
_warnAboutSize = _checkOnHeight = _updatePending = _renderFromSelected = false;
}
+ ///
+ /// Remove the currently selected item from the list view and re-query
+ /// history suggestions so the list repopulates to full capacity.
+ ///
+ /// True if the list still has items; false if it became empty (caller should close the view).
+ internal bool RemoveSelectedItem()
+ {
+ if (_listItems == null || _selectedIndex < 0 || _selectedIndex >= _listItems.Count)
+ return false;
+
+ int savedPosition = _selectedIndex;
+
+ // Collect non-history (plugin) items to preserve them.
+ List pluginItems = null;
+ for (int i = 0; i < _listItems.Count; i++)
+ {
+ if (_listItems[i].Source != SuggestionEntry.HistorySource)
+ {
+ pluginItems ??= new List();
+ pluginItems.Add(_listItems[i]);
+ }
+ }
+
+ // Rebuild the list from scratch — the deleted command is already
+ // gone from _history, so fresh results naturally exclude it.
+ _listItems.Clear();
+ _sources?.Clear();
+
+ if (UseHistory)
+ {
+ var freshHistory = GetHistorySuggestions(_inputText, HistoryMaxCount);
+ if (freshHistory != null)
+ {
+ _listItems.AddRange(freshHistory);
+ }
+ }
+
+ if (pluginItems != null)
+ {
+ _listItems.AddRange(pluginItems);
+ }
+
+ if (_listItems.Count == 0)
+ {
+ Reset();
+ return false;
+ }
+
+ // Rebuild _sources to reflect the updated indices.
+ RebuildSources();
+
+ // Restore selection at the same position, or move to the last item.
+ _selectedIndex = Math.Min(savedPosition, _listItems.Count - 1);
+
+ // Re-initialize view window from the selected item.
+ _listViewTop = 0;
+ _listViewEnd = Math.Min(_listItems.Count, _maxViewHeight);
+ _listViewHeight = _listViewEnd - _listViewTop;
+ _tooltipHeight = 0;
+ _renderFromSelected = true;
+ _updatePending = true;
+ return true;
+ }
+
+ ///
+ /// Rebuild the list from the current .
+ ///
+ private void RebuildSources()
+ {
+ _sources ??= new List();
+ _sources.Clear();
+
+ if (_listItems == null || _listItems.Count == 0)
+ return;
+
+ int prevEndIndex = -1;
+ int segStart = 0;
+ for (int i = 1; i <= _listItems.Count; i++)
+ {
+ if (i == _listItems.Count || _listItems[i].Source != _listItems[segStart].Source)
+ {
+ _sources.Add(new SourceInfo(_listItems[segStart].Source, i - 1, prevEndIndex));
+ prevEndIndex = i - 1;
+ segStart = i;
+ }
+ }
+ }
+
///
/// Update the index of the selected item based on .
///
diff --git a/PSReadLine/Prediction.cs b/PSReadLine/Prediction.cs
index bd1448b9..179c01d5 100644
--- a/PSReadLine/Prediction.cs
+++ b/PSReadLine/Prediction.cs
@@ -57,6 +57,11 @@ private static void UpdatePredictionClient(Runspace runspace, EngineIntrinsics e
}
}
+ private static string GetCurrentLocation(EngineIntrinsics engineIntrinsics)
+ {
+ return engineIntrinsics?.SessionState?.Path?.CurrentLocation?.Path ?? "Unknown";
+ }
+
// Stub helper methods so prediction can be mocked
[ExcludeFromCodeCoverage]
Task> IPSConsoleReadLineMockableMethods.PredictInputAsync(Ast ast, Token[] tokens)
diff --git a/PSReadLine/ReadLine.cs b/PSReadLine/ReadLine.cs
index da890bbb..67787907 100644
--- a/PSReadLine/ReadLine.cs
+++ b/PSReadLine/ReadLine.cs
@@ -544,6 +544,7 @@ private string InputLoop()
var tabCommandCount = _tabCommandCount;
var searchHistoryCommandCount = _searchHistoryCommandCount;
var recallHistoryCommandCount = _recallHistoryCommandCount;
+ var locationHistoryCommandCount = _locationHistoryCommandCount;
var anyHistoryCommandCount = _anyHistoryCommandCount;
var yankLastArgCommandCount = _yankLastArgCommandCount;
var visualSelectionCommandCount = _visualSelectionCommandCount;
@@ -567,7 +568,15 @@ private string InputLoop()
if (_inputAccepted)
{
_acceptedCommandLine = _buffer.ToString();
- MaybeAddToHistory(_acceptedCommandLine, _edits, _undoEditIndex);
+ if (_options.HistoryType == HistoryType.SQLite)
+ {
+ string location = _engineIntrinsics?.SessionState?.Path?.CurrentLocation?.Path;
+ MaybeAddToHistory(_acceptedCommandLine, _edits, _undoEditIndex, location);
+ }
+ else
+ {
+ MaybeAddToHistory(_acceptedCommandLine, _edits, _undoEditIndex);
+ }
_prediction.OnCommandLineAccepted(_acceptedCommandLine);
return _acceptedCommandLine;
@@ -610,6 +619,15 @@ private string InputLoop()
{
_recallHistoryCommandCount = 0;
}
+ if (locationHistoryCommandCount == _locationHistoryCommandCount)
+ {
+ // Reset only the per-keystroke counter. Keep _locationSortedIndices /
+ // _locationSortedPosition / _locationHistoryActive alive so plain
+ // Up/Down can stay in sticky location mode after the user releases Alt.
+ // Full teardown happens below in the anyHistoryCommandCount branch
+ // when the user actually does a non-history operation.
+ _locationHistoryCommandCount = 0;
+ }
if (anyHistoryCommandCount == _anyHistoryCommandCount)
{
if (_anyHistoryCommandCount > 0)
@@ -617,6 +635,16 @@ private string InputLoop()
ClearSavedCurrentLine();
_hashedHistory = null;
_currentHistoryIndex = _history.Count;
+ // User did something other than history navigation — exit sticky
+ // location mode and clear the position indicator.
+ _locationHistoryActive = false;
+ _locationSortedIndices = null;
+ _locationSortedPosition = -1;
+ if (_historyNavStatusActive)
+ {
+ _historyNavStatusActive = false;
+ ClearStatusMessage(render: true);
+ }
}
_anyHistoryCommandCount = 0;
}
@@ -812,6 +840,18 @@ private void Initialize(Runspace runspace, EngineIntrinsics engineIntrinsics)
_yankLastArgCommandCount = 0;
_tabCommandCount = 0;
_recallHistoryCommandCount = 0;
+ _locationHistoryCommandCount = 0;
+ _locationSortedIndices = null;
+ _locationSortedPosition = -1;
+ _locationHistoryActive = false;
+ // Clear any leftover history-nav status indicator from the previous
+ // ReadLine() invocation so it doesn't shift cursor/render math.
+ if (_historyNavStatusActive)
+ {
+ _statusLinePrompt = null;
+ _statusBuffer.Clear();
+ _historyNavStatusActive = false;
+ }
_anyHistoryCommandCount = 0;
_visualSelectionCommandCount = 0;
_hashedHistory = null;
@@ -915,11 +955,16 @@ private void DelayedOneTimeInitialize()
{
}
- if (readHistoryFile)
+ if (readHistoryFile && _options.HistoryType == HistoryType.Text)
{
ReadHistoryFile();
}
+ if (readHistoryFile && _options.HistoryType == HistoryType.SQLite)
+ {
+ ReadSQLiteHistory(fromOtherSession: false);
+ }
+
_killIndex = -1; // So first add indexes 0.
_killRing = new List(Options.MaximumKillRingCount);
diff --git a/README.md b/README.md
index d1ab3d11..beccaf82 100644
--- a/README.md
+++ b/README.md
@@ -79,6 +79,8 @@ Get-PSReadLineKeyHandler
There are many configuration options, see the options to `Set-PSReadLineOption`.
`PSReadLine` has help for its cmdlets as well as an `about_PSReadLine` topic - see those topics for more detailed help.
+For the new SQLite-backed history available in PSReadLine 3.0+ — including the location-aware key chords (`Alt+Up` / `Alt+Down`, `Ctrl+Alt+R`, `Alt+Delete`), the F2 stats tooltip, and the `-AccessibleHistoryDisplay` accessibility option — see [docs/sqlite-history.md](docs/sqlite-history.md).
+
To set your own custom keybindings, use the cmdlet `Set-PSReadLineKeyHandler`.
For example, for a better history experience, try:
diff --git a/docs/sqlite-history.md b/docs/sqlite-history.md
new file mode 100644
index 00000000..3a18203d
--- /dev/null
+++ b/docs/sqlite-history.md
@@ -0,0 +1,347 @@
+# SQLite History (PSReadLine 3.0+)
+
+PSReadLine 3.0 introduces a SQLite-backed command history as an alternative to
+the traditional text-file history. The text history remains the default; SQLite
+mode unlocks **per-directory recall**, **frequency-aware suggestions**, **fast
+indexed queries**, and **richer F2 list view metadata**.
+
+| | |
+|---|---|
+| **Module version** | `3.0.0` |
+| **Required PowerShell** | `7.4` (LTS) or later |
+| **Target framework** | `net8.0` |
+| **Backwards compatibility** | Users on PowerShell < 7.4 must stay on PSReadLine 2.x |
+
+> ⚠️ **Breaking change.** PSReadLine 3.0 drops support for `netstandard2.0` and
+> for PowerShell 5.1 / 7.0 / 7.2 because `Microsoft.Data.Sqlite` requires
+> .NET 6+ APIs (`NativeLibrary.SetDllImportResolver`, populated `runtimeTargets`
+> in `deps.json`) for cross-platform native deployment.
+
+---
+
+## Quick start
+
+```powershell
+# One-time switch to SQLite (also auto-migrates your existing text history)
+Set-PSReadLineOption -HistoryType SQLite
+
+# Verify
+(Get-PSReadLineOption).HistoryType # SQLite
+(Get-PSReadLineOption).HistorySavePath # …\PSReadLine\_history.db
+```
+
+To make it permanent, add `Set-PSReadLineOption -HistoryType SQLite` to your
+PowerShell profile (`$PROFILE`).
+
+To revert to text history at any time:
+
+```powershell
+Set-PSReadLineOption -HistoryType Text
+```
+
+The `.db` file is preserved when switching back; switching to SQLite again
+later picks up where you left off.
+
+---
+
+## Configuration options
+
+Set via `Set-PSReadLineOption`, inspect via `Get-PSReadLineOption`.
+
+| Option | Type | Default | Purpose |
+|---|---|---|---|
+| `-HistoryType` | `HistoryType` enum | `Text` | `Text` | `SQLite`. Switches the active history backend. |
+| `-HistorySavePathText` | `string` | platform-default `.txt` | Path to the text history file. Set independently of the active mode. |
+| `-HistorySavePathSQLite` | `string` | platform-default `.db` | Path to the SQLite database. Set independently of the active mode. |
+| `-AccessibleHistoryDisplay` | `switch` | `false` (or `true` if a screen reader is detected at startup) | Replaces emoji icons in the F2 stats tooltip and history navigation indicator with plain text labels (`Runs`, `Last`, `Dir`, `History`, `Location`). |
+
+The read-only computed property `(Get-PSReadLineOption).HistorySavePath` always
+returns the path of the *currently active* backend — it's `HistorySavePathText`
+when `HistoryType=Text`, `HistorySavePathSQLite` when `HistoryType=SQLite`.
+
+### `AddToHistoryOption.SQLite`
+
+Custom `AddToHistoryHandler` script blocks may now return the new
+`AddToHistoryOption.SQLite` value alongside the existing `SkipAdding`,
+`MemoryOnly`, and `MemoryAndFile` options. When SQLite mode is active, the
+default handler treats it interchangeably with `MemoryAndFile`.
+
+### Default file paths
+
+| Platform | Path |
+|---|---|
+| Windows | `%APPDATA%\Microsoft\Windows\PowerShell\PSReadLine\_history.db` |
+| Linux/macOS (XDG) | `$XDG_DATA_HOME/powershell/PSReadLine/_history.db` |
+| Linux/macOS (HOME) | `~/.local/share/powershell/PSReadLine/_history.db` |
+| Fallback | `/dev/null` |
+
+`` is the PowerShell host name (typically `ConsoleHost`). The text history
+file uses the same path with a `.txt` extension.
+
+---
+
+## Key chords
+
+The SQLite feature adds five new key bindings (and re-binds `Alt+Delete`).
+All are available in **Windows** and **Emacs** key modes; **Vi** mode is
+unchanged.
+
+### Windows mode (default)
+
+| Chord | Action | Notes |
+|---|---|---|
+| `Alt+UpArrow` | `PreviousLocationHistory` | Recall the previous command run from the **current directory**, sorted by frequency × recency. Falls back to chronological recall if the location is unknown. |
+| `Alt+DownArrow` | `NextLocationHistory` | Forward direction of `PreviousLocationHistory`. |
+| `Alt+Delete` | `RemoveFromHistoryAtCurrentLocation` | Delete the currently displayed history item from the **current directory only**. Other locations that ran the same command are preserved. Also works in the F2 list. **Previously bound to `KillWord`.** |
+| `Ctrl+Shift+Delete` | `RemoveFromHistory` | Delete the currently displayed history item from **all locations**. Windows-only. |
+| `Alt+F7` | `ClearHistory` | (Pre-existing.) Now also drops every row from the SQLite database. Windows-only. |
+
+`KillWord` remains available on `Alt+D` and `Ctrl+Delete` (Windows).
+
+### Emacs mode
+
+| Chord | Action | Notes |
+|---|---|---|
+| `Alt+UpArrow` | `PreviousLocationHistory` | Same as Windows mode. |
+| `Alt+DownArrow` | `NextLocationHistory` | Same as Windows mode. |
+| `Alt+Delete` | `RemoveFromHistoryAtCurrentLocation` | Same as Windows mode. |
+| `Ctrl+Alt+R` | `ReverseLocationSearchHistory` | Incremental backward search restricted to commands run from the current directory. (`Ctrl+R` continues to search all history.) |
+| `Ctrl+Shift+Delete` | `RemoveFromHistory` | Same as Windows mode. Windows-only. |
+
+### Up / Down arrow (`PreviousHistory` / `NextHistory`)
+
+The plain Up / Down arrow recall is **chronological** in both modes — same
+behavior as text mode. SQLite-side deduplication via `ROW_NUMBER()` means an
+identical command appears only once even if executed many times.
+
+### F2 (`SwitchPredictionView`)
+
+Pre-existing key. In SQLite mode, the F2 list view now shows a colored
+**stats tooltip** (`Runs`, `Last`, `Dir`) for the selected entry, and the
+top half of the list is filtered to commands run from the current directory.
+See [F2 list view enhancements](#f2-list-view-enhancements).
+
+### Known terminal collision
+
+`Alt+UpArrow` / `Alt+DownArrow` collide with **VS Code's** integrated terminal
+selection mode. Use Windows Terminal, iTerm2, or a standalone `pwsh.exe`
+window for these chords. They work fine everywhere else.
+
+---
+
+## Public methods (programmatic API)
+
+These `[Microsoft.PowerShell.PSConsoleReadLine]` static methods can be invoked
+from scripts or bound to custom keys via `Set-PSReadLineKeyHandler -Function`.
+
+| Method | Description |
+|---|---|
+| `RemoveHistoryItem(string commandLine)` → `bool` | Removes every occurrence of the given command from in-memory history and (when in SQLite mode) drops the matching `Commands` row plus all its `ExecutionHistory` entries. Returns `true` if anything was removed. |
+| `RemoveHistoryItemAtLocation(string commandLine, string location)` → `bool` | Like `RemoveHistoryItem` but scoped to a single directory. In SQLite mode, only the matching `(Command, Location)` row in `ExecutionHistory` is deleted; the `Commands` row is dropped only when no other location still references it. |
+| `RemoveFromHistory()` | Key-handler entry point. Removes the currently displayed history item globally and advances to the next older item. Also handles deletion when an item is selected in the F2 list view. |
+| `RemoveFromHistoryAtCurrentLocation()` | Key-handler entry point. Removes the current item from the current location only (in SQLite mode). In text mode, falls back to a global remove because per-location data isn't tracked. |
+| `PreviousLocationHistory()` / `NextLocationHistory()` | Key-handler entry points for `Alt+Up` / `Alt+Down` location-aware recall. |
+| `ReverseLocationSearchHistory()` / `ForwardLocationSearchHistory()` | Incremental search filtered to the current directory. |
+| `ClearHistory()` | Pre-existing. Now also wipes the SQLite database when in SQLite mode. |
+
+---
+
+## F2 list view enhancements
+
+### Stats tooltip
+
+When `HistoryType=SQLite`, selecting a history item in the F2 prediction list
+view shows a colored stats line below the selection:
+
+```
+↻ Runs 47 │ ⏱ Last 2m ago │ 📂 Dir ~/repos/PSReadline
+```
+
+| Field | Source |
+|---|---|
+| `Runs` | `SUM(ExecutionCount)` across all locations — the total number of times this command has been executed anywhere. |
+| `Last` | Relative time derived from `StartTime` (`just now` / `Nm ago` / `Nh ago` / `Nd ago` / `MMM d`). |
+| `Dir` | `Location` — the directory where this command was last executed. Omitted when location is unknown. |
+
+The tooltip uses the existing `ShowToolTips` infrastructure (default `true`)
+and the `ListPredictionTooltip` color. In text history mode the tooltip
+remains `null` (no per-command stats are tracked).
+
+### Ordering
+
+| Position | Sort key | Notes |
+|---|---|---|
+| Top half | **Per-location** frecency (`ExecutionCount` at this directory + recency) | Filtered to commands matching `$PWD`. |
+| Bottom half | **Total** frecency (sum across all locations + recency) | Excludes items already shown in the top half. |
+| Backfill | Bottom-half items | Used when fewer than half-capacity local matches exist. |
+| Plugins active (3 slots) | Total frecency only | Too few slots to split into local / global partitions. |
+
+In **text mode** the F2 list ordering is unchanged — pure recency.
+
+---
+
+## History navigation indicator
+
+While you're stepping through history with Up / Down or `Alt+Up` / `Alt+Down`,
+PSReadLine renders a small status indicator below the prompt:
+
+| Mode | Indicator | Source |
+|---|---|---|
+| Chronological recall | `[⏱ 3/15]` | `Up` / `Down`, `Ctrl+P` / `Ctrl+N` |
+| Location-filtered recall | `[📂 2/5]` | `Alt+Up` / `Alt+Down` |
+
+The indicator is cleared as soon as you press a non-history key (typing
+characters, Enter, Escape, etc.). The position counter only counts
+**navigable** items — cross-session entries marked `FromOtherSession` are
+skipped, so the count won't jump unexpectedly.
+
+This indicator is rendered in both Text and SQLite modes whenever the
+respective recall functions are invoked.
+
+---
+
+## Accessibility
+
+`-AccessibleHistoryDisplay` swaps the emoji icons used by the SQLite history
+UX for plain-text labels so screen readers don't verbalize Unicode character
+names ("clockwise gapped circle arrow", "card index dividers").
+
+```powershell
+Set-PSReadLineOption -AccessibleHistoryDisplay
+```
+
+| Surface | Default rendering | With `-AccessibleHistoryDisplay` |
+|---|---|---|
+| F2 stats tooltip | `↻ Runs 47 │ ⏱ Last 2m ago │ 📂 Dir ` | `Runs 47 │ Last 2m ago │ Dir ` |
+| Chronological nav indicator | `[⏱ 3/15]` | `[History 3/15]` |
+| Location-filtered nav indicator | `[📂 2/5]` | `[Location 2/5]` |
+
+### Default value
+
+The option is **seeded once at startup** from the value of
+`ScreenReaderModeEnabled`, so users running with a screen reader active get
+plain-text labels automatically.
+
+After construction the two options are independent — toggling
+`-EnableScreenReaderMode` later does **not** auto-flip
+`-AccessibleHistoryDisplay`. This is intentional: it lets sighted users
+opt in to plain text (e.g. on terminals without emoji-font support) without
+touching the broader screen-reader rendering mode.
+
+---
+
+## Migration from text history
+
+When you switch to SQLite mode for the first time and the configured
+`HistorySavePathSQLite` file does **not** exist, PSReadLine automatically
+imports your existing text history (`HistorySavePathText`) into the new
+database.
+
+### How timestamps are assigned
+
+Migrated lines have **no original timestamps** in the text file, so PSReadLine
+assigns synthetic ones such that:
+
+1. All migrated entries are stamped **older than "now"**, so any new SQLite
+ entries sort after them.
+2. The original chronological order in the `.txt` file is preserved.
+
+Concretely: the oldest line gets the earliest timestamp; each subsequent line
+gets one minute later; the newest text-history line is still at least one
+minute older than the first command you run after the switch.
+
+### What happens to the `.txt` file
+
+The text file is **not** deleted or modified — migration only reads from it.
+You can switch back to text history at any time and resume using it. If you
+want to keep both backends in sync, switch to SQLite mode after each session;
+the most recent commands are picked up from the text file on each migration.
+
+To migrate explicitly later (e.g. after editing the text file by hand), point
+`HistorySavePathSQLite` at a path that doesn't exist yet and switch modes:
+
+```powershell
+Set-PSReadLineOption -HistorySavePathSQLite "$HOME\new-history.db"
+Set-PSReadLineOption -HistoryType SQLite # triggers migration
+```
+
+---
+
+## Text vs SQLite — feature comparison
+
+| | Text (default) | SQLite |
+|---|---|---|
+| Backend | Plain text file | Indexed SQLite database |
+| Up / Down recall | Chronological | Chronological (DB-side dedup) |
+| `Alt+Up` / `Alt+Down` | Same as Up / Down | **Per-directory** frequency × recency |
+| F2 list ordering | Pure recency | Local frecency + global frecency split |
+| F2 stats tooltip | Not shown | Runs / Last / Dir |
+| History navigation indicator | Shown | Shown |
+| `Alt+Delete` removal scope | Global (no per-directory data) | Current directory only |
+| `Ctrl+Shift+Delete` removal scope | Global | Global (all directories) |
+| `Ctrl+Alt+R` (Emacs) | All-history search | Current-directory search |
+| Cross-session merging | Append-on-save | Incremental DB reads |
+| Lookup complexity | O(n) linear scan | O(log n) indexed |
+| Human-readable on disk | Yes | No (binary `.db`) |
+| Native dependency | None | `e_sqlite3` (auto-deployed per RID) |
+
+---
+
+## Database schema (for advanced users)
+
+The SQLite database uses a normalized three-table schema. You can query it
+directly with the `sqlite3` CLI or any SQLite client.
+
+| Table | Columns (key fields) |
+|---|---|
+| `Commands` | `Id`, `CommandLine`, `CommandHash` (indexed) |
+| `Locations` | `Id`, `Path` (normalized) |
+| `ExecutionHistory` | `Id`, `CommandId` (FK), `LocationId` (FK), `StartTime` (Unix seconds), `LastExecuted` (Unix seconds, indexed), `ElapsedTime`, `ExecutionCount` (indexed) |
+
+A convenience view `HistoryView` joins the three tables and exposes
+denormalized `CommandLine`, `Location`, `LastExecuted`, and `ExecutionCount`
+columns, e.g.:
+
+```sql
+SELECT CommandLine, Location, datetime(LastExecuted, 'unixepoch'), ExecutionCount
+FROM HistoryView
+ORDER BY LastExecuted DESC
+LIMIT 20;
+```
+
+Timestamps are stored as **Unix seconds** (`ToUnixTimeSeconds()`). Use
+`datetime(col, 'unixepoch')` in SQL queries — do **not** treat them as .NET ticks.
+
+---
+
+## Limitations and known issues
+
+1. **Same command at different directories, back-to-back.** `HistoryNoDuplicates`
+ (default `true`) drops a new entry whose `CommandLine` exactly matches the
+ *previous in-memory item*, regardless of `Location`. Typing `git status`
+ in `~/repos/A`, then `cd ~/repos/B`, then `git status` again will record
+ only the first occurrence.
+
+2. **Cross-session live merge.** When multiple `pwsh` sessions are open at the
+ same time, each session sees commands from other sessions only on its own
+ incremental read. Items from other sessions are flagged `FromOtherSession`
+ and are skipped by `Up` / `Down` recall (so you don't accidentally rerun a
+ colleague's command from a different terminal).
+
+3. **VS Code terminal `Alt+Up` collision.** As noted above, VS Code intercepts
+ `Alt+Up` for terminal-selection mode. Use a different host or rebind.
+
+4. **PowerShell < 7.4 unsupported.** Trying to load PSReadLine 3.0 on an
+ older PowerShell will fail at module-import time. Stay on PSReadLine 2.x
+ if you need 5.1 / 7.0 / 7.2 support.
+
+---
+
+## Related skill notes
+
+The implementation details (recall ordering algorithm, native library
+deployment, framework-migration rationale, F2 list rendering pitfalls) are
+documented separately in the maintainer-facing skill files under
+`~/.copilot/skills/sqlite-history/` — see `SKILL.md` and the four
+`references/*.md` files for architecture, schema-and-history logic,
+API surface, and testing patterns.
diff --git a/nuget.config b/nuget.config
index a10ce9b3..a079cf1b 100644
--- a/nuget.config
+++ b/nuget.config
@@ -1,8 +1,7 @@
-
-
+
diff --git a/test/HistoryTest.cs b/test/HistoryTest.cs
index ad6b95f0..8066d10e 100644
--- a/test/HistoryTest.cs
+++ b/test/HistoryTest.cs
@@ -40,13 +40,14 @@ public void ParallelHistorySaving()
TestSetup(KeyMode.Cmd);
string historySavingFile = Path.GetTempFileName();
- var options = new SetPSReadLineOption {
+ var options = new SetPSReadLineOption
+ {
HistorySaveStyle = HistorySaveStyle.SaveIncrementally,
MaximumHistoryCount = 3,
};
typeof(SetPSReadLineOption)
- .GetField("_historySavePath", BindingFlags.Instance | BindingFlags.NonPublic)
+ .GetField("_historySavePathText", BindingFlags.Instance | BindingFlags.NonPublic)
.SetValue(options, historySavingFile);
PSConsoleReadLine.SetOptions(options);
@@ -98,7 +99,7 @@ public void SensitiveHistoryDefaultBehavior_One()
Test("", Keys(_.UpArrow, _.DownArrow));
var options = PSConsoleReadLine.GetOptions();
- var oldHistoryFilePath = options.HistorySavePath;
+ var oldHistoryFilePath = options.HistorySavePathText;
var oldHistorySaveStyle = options.HistorySaveStyle;
// AddToHistoryHandler should be set to the default handler.
@@ -135,7 +136,7 @@ public void SensitiveHistoryDefaultBehavior_One()
try
{
- options.HistorySavePath = newHistoryFilePath;
+ options.HistorySavePathText = newHistoryFilePath;
options.HistorySaveStyle = newHistorySaveStyle;
SetHistory(expectedHistoryItems);
@@ -157,7 +158,7 @@ public void SensitiveHistoryDefaultBehavior_One()
}
finally
{
- options.HistorySavePath = oldHistoryFilePath;
+ options.HistorySavePathText = oldHistoryFilePath;
options.HistorySaveStyle = oldHistorySaveStyle;
File.Delete(newHistoryFilePath);
}
@@ -172,7 +173,7 @@ public void SensitiveHistoryDefaultBehavior_Two()
SetHistory();
var options = PSConsoleReadLine.GetOptions();
- var oldHistoryFilePath = options.HistorySavePath;
+ var oldHistoryFilePath = options.HistorySavePathText;
var oldHistorySaveStyle = options.HistorySaveStyle;
// AddToHistoryHandler should be set to the default handler.
@@ -252,7 +253,7 @@ public void SensitiveHistoryDefaultBehavior_Two()
try
{
- options.HistorySavePath = newHistoryFilePath;
+ options.HistorySavePathText = newHistoryFilePath;
options.HistorySaveStyle = newHistorySaveStyle;
SetHistory(expectedHistoryItems);
@@ -274,7 +275,7 @@ public void SensitiveHistoryDefaultBehavior_Two()
}
finally
{
- options.HistorySavePath = oldHistoryFilePath;
+ options.HistorySavePathText = oldHistoryFilePath;
options.HistorySaveStyle = oldHistorySaveStyle;
File.Delete(newHistoryFilePath);
}
@@ -290,7 +291,7 @@ public void SensitiveHistoryOptionalBehavior()
Test("", Keys(_.UpArrow, _.DownArrow));
var options = PSConsoleReadLine.GetOptions();
- var oldHistoryFilePath = options.HistorySavePath;
+ var oldHistoryFilePath = options.HistorySavePathText;
var oldHistorySaveStyle = options.HistorySaveStyle;
// AddToHistoryHandler should be set to the default handler.
@@ -327,7 +328,7 @@ public void SensitiveHistoryOptionalBehavior()
try
{
- options.HistorySavePath = newHistoryFilePath;
+ options.HistorySavePathText = newHistoryFilePath;
options.HistorySaveStyle = newHistorySaveStyle;
//
@@ -416,7 +417,7 @@ public void SensitiveHistoryOptionalBehavior()
}
finally
{
- options.HistorySavePath = oldHistoryFilePath;
+ options.HistorySavePathText = oldHistoryFilePath;
options.HistorySaveStyle = oldHistorySaveStyle;
options.AddToHistoryHandler = PSConsoleReadLineOptions.DefaultAddToHistoryHandler;
File.Delete(newHistoryFilePath);
@@ -433,7 +434,7 @@ public void SensitiveHistoryOptionalBehaviorWithScriptBlock()
Test("", Keys(_.UpArrow, _.DownArrow));
var options = PSConsoleReadLine.GetOptions();
- var oldHistoryFilePath = options.HistorySavePath;
+ var oldHistoryFilePath = options.HistorySavePathText;
var oldHistorySaveStyle = options.HistorySaveStyle;
// AddToHistoryHandler should be set to the default handler.
@@ -480,7 +481,7 @@ public void SensitiveHistoryOptionalBehaviorWithScriptBlock()
try
{
- options.HistorySavePath = newHistoryFilePath;
+ options.HistorySavePathText = newHistoryFilePath;
options.HistorySaveStyle = newHistorySaveStyle;
//
@@ -569,7 +570,7 @@ public void SensitiveHistoryOptionalBehaviorWithScriptBlock()
}
finally
{
- options.HistorySavePath = oldHistoryFilePath;
+ options.HistorySavePathText = oldHistoryFilePath;
options.HistorySaveStyle = oldHistorySaveStyle;
options.AddToHistoryHandler = PSConsoleReadLineOptions.DefaultAddToHistoryHandler;
File.Delete(newHistoryFilePath);
@@ -756,48 +757,53 @@ public void SearchHistory()
SetHistory();
Test(" ", Keys(' ', _.UpArrow, _.DownArrow));
- PSConsoleReadLine.SetOptions(new SetPSReadLineOption {HistorySearchCursorMovesToEnd = false});
+ PSConsoleReadLine.SetOptions(new SetPSReadLineOption { HistorySearchCursorMovesToEnd = false });
var emphasisColors = Tuple.Create(PSConsoleReadLineOptions.DefaultEmphasisColor, _console.BackgroundColor);
SetHistory("dosomething", "ps p*", "dir", "echo zzz");
Test("dosomething", Keys(
"d",
- _.UpArrow, CheckThat(() => {
+ _.UpArrow, CheckThat(() =>
+ {
AssertScreenIs(1,
emphasisColors, 'd',
TokenClassification.Command, "ir");
AssertCursorLeftIs(1);
}),
- _.UpArrow, CheckThat(() => {
+ _.UpArrow, CheckThat(() =>
+ {
AssertScreenIs(1,
emphasisColors, 'd',
TokenClassification.Command, "osomething");
AssertCursorLeftIs(1);
- })));
+ })));
- PSConsoleReadLine.SetOptions(new SetPSReadLineOption {HistorySearchCursorMovesToEnd = true});
+ PSConsoleReadLine.SetOptions(new SetPSReadLineOption { HistorySearchCursorMovesToEnd = true });
SetHistory("dosomething", "ps p*", "dir", "echo zzz");
Test("dosomething", Keys(
"d",
- _.UpArrow, CheckThat(() => {
+ _.UpArrow, CheckThat(() =>
+ {
AssertScreenIs(1,
emphasisColors, 'd',
TokenClassification.Command, "ir");
AssertCursorLeftIs(3);
}),
- _.UpArrow, CheckThat(() => {
+ _.UpArrow, CheckThat(() =>
+ {
AssertScreenIs(1,
emphasisColors, 'd',
TokenClassification.Command, "osomething");
AssertCursorLeftIs(11);
}),
- _.DownArrow, CheckThat(() => {
+ _.DownArrow, CheckThat(() =>
+ {
AssertScreenIs(1,
emphasisColors, 'd',
TokenClassification.Command, "ir");
AssertCursorLeftIs(3);
}),
- _.UpArrow, CheckThat(() =>
+ _.UpArrow, CheckThat(() =>
{
AssertScreenIs(1,
emphasisColors, 'd',
@@ -813,31 +819,34 @@ public void HistorySearchCursorMovesToEnd()
new KeyHandler("UpArrow", PSConsoleReadLine.HistorySearchBackward),
new KeyHandler("DownArrow", PSConsoleReadLine.HistorySearchForward));
- PSConsoleReadLine.SetOptions(new SetPSReadLineOption {HistorySearchCursorMovesToEnd = true});
+ PSConsoleReadLine.SetOptions(new SetPSReadLineOption { HistorySearchCursorMovesToEnd = true });
var emphasisColors = Tuple.Create(PSConsoleReadLineOptions.DefaultEmphasisColor, _console.BackgroundColor);
SetHistory("dosomething", "ps p*", "dir", "echo zzz");
Test("dosomething", Keys(
"d",
- _.UpArrow, CheckThat(() => {
+ _.UpArrow, CheckThat(() =>
+ {
AssertScreenIs(1,
emphasisColors, 'd',
TokenClassification.Command, "ir");
AssertCursorLeftIs(3);
}),
- _.UpArrow, CheckThat(() => {
+ _.UpArrow, CheckThat(() =>
+ {
AssertScreenIs(1,
emphasisColors, 'd',
TokenClassification.Command, "osomething");
AssertCursorLeftIs(11);
}),
- _.DownArrow, CheckThat(() => {
+ _.DownArrow, CheckThat(() =>
+ {
AssertScreenIs(1,
emphasisColors, 'd',
TokenClassification.Command, "ir");
AssertCursorLeftIs(3);
}),
- _.UpArrow, CheckThat(() =>
+ _.UpArrow, CheckThat(() =>
{
AssertScreenIs(1,
emphasisColors, 'd',
@@ -1122,7 +1131,7 @@ public void InteractiveHistorySearch()
public void AddToHistoryHandler()
{
TestSetup(KeyMode.Cmd);
- PSConsoleReadLine.SetOptions(new SetPSReadLineOption {AddToHistoryHandler = s => s.StartsWith("z")});
+ PSConsoleReadLine.SetOptions(new SetPSReadLineOption { AddToHistoryHandler = s => s.StartsWith("z") });
SetHistory("zzzz", "azzz");
Test("zzzz", Keys(_.UpArrow));
@@ -1132,14 +1141,14 @@ public void AddToHistoryHandler()
public void HistoryNoDuplicates()
{
TestSetup(KeyMode.Cmd);
- PSConsoleReadLine.SetOptions(new SetPSReadLineOption {HistoryNoDuplicates = false});
+ PSConsoleReadLine.SetOptions(new SetPSReadLineOption { HistoryNoDuplicates = false });
SetHistory("zzzz", "aaaa", "bbbb", "bbbb", "cccc");
Assert.Equal(5, PSConsoleReadLine.GetHistoryItems().Length);
Test("aaaa", Keys(Enumerable.Repeat(_.UpArrow, 4)));
// Changing the option should affect existing history.
- PSConsoleReadLine.SetOptions(new SetPSReadLineOption {HistoryNoDuplicates = true});
+ PSConsoleReadLine.SetOptions(new SetPSReadLineOption { HistoryNoDuplicates = true });
Test("zzzz", Keys(Enumerable.Repeat(_.UpArrow, 4)));
SetHistory("aaaa", "bbbb", "bbbb", "cccc");
@@ -1164,7 +1173,7 @@ public void HistorySearchNoDuplicates()
new KeyHandler("UpArrow", PSConsoleReadLine.HistorySearchBackward),
new KeyHandler("DownArrow", PSConsoleReadLine.HistorySearchForward));
- PSConsoleReadLine.SetOptions(new SetPSReadLineOption {HistoryNoDuplicates = true});
+ PSConsoleReadLine.SetOptions(new SetPSReadLineOption { HistoryNoDuplicates = true });
SetHistory("0000", "echo aaaa", "1111", "echo bbbb", "2222", "echo bbbb", "3333", "echo cccc", "4444");
Test("echo aaaa", Keys("echo", Enumerable.Repeat(_.UpArrow, 3)));
@@ -1180,7 +1189,7 @@ public void InteractiveHistorySearchNoDuplicates()
{
TestSetup(KeyMode.Emacs);
- PSConsoleReadLine.SetOptions(new SetPSReadLineOption {HistoryNoDuplicates = true});
+ PSConsoleReadLine.SetOptions(new SetPSReadLineOption { HistoryNoDuplicates = true });
SetHistory("0000", "echo aaaa", "1111", "echo bbbb", "2222", "echo bbbb", "3333", "echo cccc", "4444");
Test("echo aaaa", Keys(
_.Ctrl_r, "echo", _.Ctrl_r, _.Ctrl_r));
@@ -1203,7 +1212,7 @@ public void HistoryCount()
// There should be 4 items in history, the following should remove the
// oldest history item.
- PSConsoleReadLine.SetOptions(new SetPSReadLineOption {MaximumHistoryCount = 3});
+ PSConsoleReadLine.SetOptions(new SetPSReadLineOption { MaximumHistoryCount = 3 });
Test("aaaa", Keys(Enumerable.Repeat(_.UpArrow, 4)));
Test("zzzz", Keys("zzzz"));
diff --git a/test/KeyInfo-en-US-linux.json b/test/KeyInfo-en-US-linux.json
index c107b691..8f9c8867 100644
--- a/test/KeyInfo-en-US-linux.json
+++ b/test/KeyInfo-en-US-linux.json
@@ -881,6 +881,13 @@
"Modifiers": "0",
"Investigate": false
},
+ {
+ "Key": "Alt+Delete",
+ "KeyChar": "\u0000",
+ "ConsoleKey": "Delete",
+ "Modifiers": "Alt",
+ "Investigate": false
+ },
{
"Key": "Spacebar",
"KeyChar": " ",
diff --git a/test/KeyInfo-en-US-windows.json b/test/KeyInfo-en-US-windows.json
index c0fcc65d..90785717 100644
--- a/test/KeyInfo-en-US-windows.json
+++ b/test/KeyInfo-en-US-windows.json
@@ -725,6 +725,12 @@
"ConsoleKey": "Delete",
"Modifiers": "Control"
},
+ {
+ "Key": "Alt+Delete",
+ "KeyChar": "\u0000",
+ "ConsoleKey": "Delete",
+ "Modifiers": "Alt"
+ },
{
"Key": "Ctrl+e",
"KeyChar": "\u0005",
diff --git a/test/KeyInfo-fr-FR-windows.json b/test/KeyInfo-fr-FR-windows.json
index 0aad3a2b..39ee7b61 100644
--- a/test/KeyInfo-fr-FR-windows.json
+++ b/test/KeyInfo-fr-FR-windows.json
@@ -832,6 +832,13 @@
"Modifiers": "Control",
"Investigate": false
},
+ {
+ "Key": "Alt+Delete",
+ "KeyChar": "\u0000",
+ "ConsoleKey": "Delete",
+ "Modifiers": "Alt",
+ "Investigate": false
+ },
{
"Key": "Ctrl+e",
"KeyChar": "\u0005",
diff --git a/test/KeyInfo-pl-PL-linux.json b/test/KeyInfo-pl-PL-linux.json
index 1fdaea89..d15fa9fd 100644
--- a/test/KeyInfo-pl-PL-linux.json
+++ b/test/KeyInfo-pl-PL-linux.json
@@ -860,6 +860,13 @@
"Modifiers": "Control",
"Investigate": false
},
+ {
+ "Key": "Alt+Delete",
+ "KeyChar": "\u0000",
+ "ConsoleKey": "Delete",
+ "Modifiers": "Alt",
+ "Investigate": false
+ },
{
"Key": "Shift+PageDown",
"KeyChar": "~",
diff --git a/test/KeyInfo-pl-PL-windows.json b/test/KeyInfo-pl-PL-windows.json
index 3bbf757f..fb29627d 100644
--- a/test/KeyInfo-pl-PL-windows.json
+++ b/test/KeyInfo-pl-PL-windows.json
@@ -1168,6 +1168,13 @@
"Modifiers": "Control",
"Investigate": false
},
+ {
+ "Key": "Alt+Delete",
+ "KeyChar": "\u0000",
+ "ConsoleKey": "Delete",
+ "Modifiers": "Alt",
+ "Investigate": false
+ },
{
"Key": "\u003c",
"KeyChar": "\u003c",
diff --git a/test/KeyInfo-ru-RU-windows.json b/test/KeyInfo-ru-RU-windows.json
index 13d6c4ae..7f8c6cb1 100644
--- a/test/KeyInfo-ru-RU-windows.json
+++ b/test/KeyInfo-ru-RU-windows.json
@@ -678,6 +678,13 @@
"Modifiers": "Control",
"Investigate": false
},
+ {
+ "Key": "Alt+Delete",
+ "KeyChar": "\u0000",
+ "ConsoleKey": "Delete",
+ "Modifiers": "Alt",
+ "Investigate": false
+ },
{
"Key": "a",
"KeyChar": "a",
diff --git a/test/KillYankTest.cs b/test/KillYankTest.cs
index 381e7889..4ca2906c 100644
--- a/test/KillYankTest.cs
+++ b/test/KillYankTest.cs
@@ -21,6 +21,103 @@ public void KillWord()
_.End, _.Ctrl_y)); // Yank 'abc' at end of line
}
+ [SkippableFact]
+ public void AltDeleteBoundToRemoveFromHistory_Emacs()
+ {
+ TestSetup(KeyMode.Emacs);
+
+ // Alt+Delete is bound to RemoveFromHistory in Emacs mode.
+ // After deletion it advances to the next older item automatically.
+ SetHistory("echo first", "echo second");
+ Test("echo first", Keys(
+ _.UpArrow, // recall "echo second"
+ _.Alt_Delete)); // remove "echo second", auto-shows "echo first"
+ }
+
+ [SkippableFact]
+ public void AltDeleteBoundToRemoveFromHistory_Windows()
+ {
+ TestSetup(KeyMode.Cmd);
+
+ // Alt+Delete is bound to RemoveFromHistory in Windows mode.
+ // After deletion it advances to the next older item automatically.
+ SetHistory("echo first", "echo second");
+ Test("echo first", Keys(
+ _.UpArrow, // recall "echo second"
+ _.Alt_Delete)); // remove "echo second", auto-shows "echo first"
+ }
+
+ [SkippableFact]
+ public void AltDeleteAdvancesToNextOlderItem()
+ {
+ TestSetup(KeyMode.Cmd);
+
+ // After Alt+Delete, the next older history item should be displayed
+ // so the user can continue navigating deeper into history.
+ SetHistory("cmd1", "cmd2", "cmd3", "cmd4");
+ Test("cmd2", Keys(
+ _.UpArrow, // recall "cmd4"
+ CheckThat(() => AssertLineIs("cmd4")),
+ _.Alt_Delete, // delete "cmd4", shows "cmd3"
+ CheckThat(() => AssertLineIs("cmd3")),
+ _.UpArrow, // continue to "cmd2"
+ CheckThat(() => AssertLineIs("cmd2"))
+ ));
+ }
+
+ [SkippableFact]
+ public void AltDeleteConsecutiveDeletes()
+ {
+ TestSetup(KeyMode.Cmd);
+
+ // User should be able to press Alt+Delete multiple times in a row
+ // to delete consecutive history items, each time advancing to the
+ // next older item.
+ SetHistory("cmd1", "cmd2", "cmd3", "cmd4");
+ Test("cmd1", Keys(
+ _.UpArrow, // recall "cmd4"
+ CheckThat(() => AssertLineIs("cmd4")),
+ _.Alt_Delete, // delete "cmd4", shows "cmd3"
+ CheckThat(() => AssertLineIs("cmd3")),
+ _.Alt_Delete, // delete "cmd3", shows "cmd2"
+ CheckThat(() => AssertLineIs("cmd2")),
+ _.Alt_Delete, // delete "cmd2", shows "cmd1"
+ CheckThat(() => AssertLineIs("cmd1"))
+ ));
+ }
+
+ [SkippableFact]
+ public void AltDeleteLastRemainingItem()
+ {
+ TestSetup(KeyMode.Cmd);
+
+ // Deleting the only remaining history item should revert to empty line.
+ SetHistory("only-item");
+ Test("", Keys(
+ _.UpArrow, // recall "only-item"
+ CheckThat(() => AssertLineIs("only-item")),
+ _.Alt_Delete // delete it, reverts to empty
+ ));
+ }
+
+ [SkippableFact]
+ public void AltDeleteOldestItem()
+ {
+ TestSetup(KeyMode.Cmd);
+
+ // When at the oldest item, deleting it should show the next remaining
+ // item (which is now the new oldest, at index 0).
+ SetHistory("cmd1", "cmd2", "cmd3");
+ Test("cmd2", Keys(
+ _.UpArrow, // "cmd3"
+ _.UpArrow, // "cmd2"
+ _.UpArrow, // "cmd1" (oldest)
+ CheckThat(() => AssertLineIs("cmd1")),
+ _.Alt_Delete, // delete "cmd1", shows "cmd2" (new oldest)
+ CheckThat(() => AssertLineIs("cmd2"))
+ ));
+ }
+
[SkippableFact]
public void BackwardKillWord()
{
diff --git a/test/SQLiteHistoryTest.cs b/test/SQLiteHistoryTest.cs
new file mode 100644
index 00000000..fb4089a9
--- /dev/null
+++ b/test/SQLiteHistoryTest.cs
@@ -0,0 +1,2055 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using Microsoft.Data.Sqlite;
+using Microsoft.PowerShell;
+using Xunit;
+
+namespace Test
+{
+ public partial class ReadLine
+ {
+ ///
+ /// Helper to configure SQLite history mode for tests.
+ /// Sets up a temp .db file, switches HistoryType to SQLite with SaveIncrementally,
+ /// and returns a disposable that restores original settings and cleans up.
+ ///
+ private SQLiteTestContext SetupSQLiteHistory()
+ {
+ var options = PSConsoleReadLine.GetOptions();
+ var ctx = new SQLiteTestContext
+ {
+ OriginalHistorySavePathText = options.HistorySavePathText,
+ OriginalHistorySavePathSQLite = options.HistorySavePathSQLite,
+ OriginalHistorySaveStyle = options.HistorySaveStyle,
+ OriginalHistoryType = options.HistoryType,
+ TempDbPath = Path.Combine(Path.GetTempPath(), $"PSReadLineTest_{Guid.NewGuid():N}.db"),
+ };
+
+ // Point HistorySavePathSQLite to our temp DB before switching mode,
+ // because SetOptionsInternal reads HistorySavePathSQLite when HistoryType is set to SQLite.
+ options.HistorySavePathSQLite = ctx.TempDbPath;
+
+ // Point HistorySavePathText to a non-existent file so migration doesn't
+ // accidentally pull from the real production text history.
+ options.HistorySavePathText = Path.ChangeExtension(ctx.TempDbPath, ".txt");
+
+ // Switch to SQLite mode — this creates the DB and clears in-memory history.
+ var setOptions = new SetPSReadLineOption
+ {
+ HistoryType = HistoryType.SQLite,
+ HistorySaveStyle = HistorySaveStyle.SaveIncrementally,
+ };
+ PSConsoleReadLine.SetOptions(setOptions);
+
+ return ctx;
+ }
+
+ ///
+ /// Holds the original settings so they can be restored after a SQLite test.
+ ///
+ private class SQLiteTestContext : IDisposable
+ {
+ public string OriginalHistorySavePathText;
+ public string OriginalHistorySavePathSQLite;
+ public HistorySaveStyle OriginalHistorySaveStyle;
+ public HistoryType OriginalHistoryType;
+ public string TempDbPath;
+
+ public void Dispose()
+ {
+ var options = PSConsoleReadLine.GetOptions();
+
+ // Restore original settings
+ options.HistorySavePathSQLite = OriginalHistorySavePathSQLite;
+ options.HistorySavePathText = OriginalHistorySavePathText;
+ options.HistorySaveStyle = OriginalHistorySaveStyle;
+ options.HistoryType = OriginalHistoryType;
+
+ PSConsoleReadLine.ClearHistory();
+
+ // Clean up temp DB file
+ try { if (File.Exists(TempDbPath)) File.Delete(TempDbPath); }
+ catch { /* best effort */ }
+ }
+ }
+
+ ///
+ /// Helper to count rows in a SQLite table.
+ ///
+ private long CountSQLiteRows(string dbPath, string tableName)
+ {
+ var connectionString = new SqliteConnectionStringBuilder($"Data Source={dbPath}")
+ {
+ Mode = SqliteOpenMode.ReadOnly
+ }.ToString();
+
+ using var connection = new SqliteConnection(connectionString);
+ connection.Open();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = $"SELECT COUNT(*) FROM {tableName}";
+ return (long)cmd.ExecuteScalar();
+ }
+
+ ///
+ /// Helper to query command lines from the SQLite database.
+ ///
+ private string[] QuerySQLiteCommandLines(string dbPath)
+ {
+ var connectionString = new SqliteConnectionStringBuilder($"Data Source={dbPath}")
+ {
+ Mode = SqliteOpenMode.ReadOnly
+ }.ToString();
+
+ using var connection = new SqliteConnection(connectionString);
+ connection.Open();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = "SELECT CommandLine FROM HistoryView ORDER BY LastExecuted ASC";
+ using var reader = cmd.ExecuteReader();
+ var results = new System.Collections.Generic.List();
+ while (reader.Read())
+ {
+ results.Add(reader.GetString(0));
+ }
+ return results.ToArray();
+ }
+
+ // =====================================================================
+ // SQLite Database Persistence Tests
+ // =====================================================================
+
+ [SkippableFact]
+ public void SQLiteHistory_WritesToDatabase()
+ {
+ TestSetup(KeyMode.Cmd);
+ using var ctx = SetupSQLiteHistory();
+
+ // Run commands that get saved to history
+ Test("echo hello", Keys("echo hello"));
+ Test("dir", Keys("dir"));
+ Test("ps", Keys("ps"));
+
+ // Verify the database has the expected commands
+ var commands = QuerySQLiteCommandLines(ctx.TempDbPath);
+ Assert.Contains("echo hello", commands);
+ Assert.Contains("dir", commands);
+ Assert.Contains("ps", commands);
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_DatabaseSchemaCreated()
+ {
+ TestSetup(KeyMode.Cmd);
+ using var ctx = SetupSQLiteHistory();
+
+ // Verify the database file was created
+ Assert.True(File.Exists(ctx.TempDbPath), "SQLite database file should exist");
+
+ // Verify the schema by checking tables exist
+ var connectionString = new SqliteConnectionStringBuilder($"Data Source={ctx.TempDbPath}")
+ {
+ Mode = SqliteOpenMode.ReadOnly
+ }.ToString();
+
+ using var connection = new SqliteConnection(connectionString);
+ connection.Open();
+
+ // Check all three tables exist
+ foreach (var table in new[] { "Commands", "Locations", "ExecutionHistory" })
+ {
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table' AND name=@Name";
+ cmd.Parameters.AddWithValue("@Name", table);
+ Assert.NotNull(cmd.ExecuteScalar());
+ }
+
+ // Check the view exists
+ using var viewCmd = connection.CreateCommand();
+ viewCmd.CommandText = "SELECT name FROM sqlite_master WHERE type='view' AND name='HistoryView'";
+ Assert.NotNull(viewCmd.ExecuteScalar());
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_DeduplicatesCommands()
+ {
+ TestSetup(KeyMode.Cmd);
+ using var ctx = SetupSQLiteHistory();
+
+ // Need to disable in-memory no-duplicates to ensure both reach SQLite
+ PSConsoleReadLine.SetOptions(new SetPSReadLineOption { HistoryNoDuplicates = false });
+
+ // Run the same command multiple times
+ Test("echo hello", Keys("echo hello"));
+ Test("echo hello", Keys("echo hello"));
+ Test("echo hello", Keys("echo hello"));
+
+ // The Commands table should only have one row for "echo hello"
+ long commandCount = CountSQLiteRows(ctx.TempDbPath, "Commands");
+ Assert.Equal(1, commandCount);
+
+ // But ExecutionHistory should track the execution count
+ var connectionString = new SqliteConnectionStringBuilder($"Data Source={ctx.TempDbPath}")
+ {
+ Mode = SqliteOpenMode.ReadOnly
+ }.ToString();
+
+ using var connection = new SqliteConnection(connectionString);
+ connection.Open();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = "SELECT ExecutionCount FROM ExecutionHistory";
+ var executionCount = Convert.ToInt64(cmd.ExecuteScalar());
+ Assert.True(executionCount >= 3, $"ExecutionCount should be >= 3, was {executionCount}");
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_StoresLocation()
+ {
+ TestSetup(KeyMode.Cmd);
+ using var ctx = SetupSQLiteHistory();
+
+ // Run a command - location defaults to "Unknown" in test harness
+ // because _engineIntrinsics is null
+ Test("echo hello", Keys("echo hello"));
+
+ // Verify location was stored
+ long locationCount = CountSQLiteRows(ctx.TempDbPath, "Locations");
+ Assert.True(locationCount >= 1, "Should have at least one location");
+ }
+
+ // =====================================================================
+ // SQLite Migration Tests
+ // =====================================================================
+
+ [SkippableFact]
+ public void SQLiteHistory_MigratesFromTextFile()
+ {
+ TestSetup(KeyMode.Cmd);
+
+ var options = PSConsoleReadLine.GetOptions();
+ var originalHistorySavePathText = options.HistorySavePathText;
+ var originalHistorySavePathSQLite = options.HistorySavePathSQLite;
+ var originalHistorySaveStyle = options.HistorySaveStyle;
+ var originalHistoryType = options.HistoryType;
+
+ var tempDbPath = Path.Combine(Path.GetTempPath(), $"PSReadLineTest_{Guid.NewGuid():N}.db");
+ var tempTxtPath = Path.ChangeExtension(tempDbPath, ".txt");
+
+ try
+ {
+ // Create a text history file with known content
+ File.WriteAllLines(tempTxtPath, new[]
+ {
+ "get-process",
+ "cd /tmp",
+ "echo hello"
+ });
+
+ // Point both paths so migration can find the text file
+ options.HistorySavePathText = tempTxtPath;
+ options.HistorySavePathSQLite = tempDbPath;
+
+ var setOptions = new SetPSReadLineOption
+ {
+ HistoryType = HistoryType.SQLite,
+ HistorySaveStyle = HistorySaveStyle.SaveIncrementally,
+ };
+ PSConsoleReadLine.SetOptions(setOptions);
+
+ // The migration should have imported the text history
+ long commandCount = CountSQLiteRows(tempDbPath, "Commands");
+ Assert.Equal(3, commandCount);
+
+ // Verify the specific commands were migrated
+ var commands = QuerySQLiteCommandLines(tempDbPath);
+ Assert.Contains("get-process", commands);
+ Assert.Contains("cd /tmp", commands);
+ Assert.Contains("echo hello", commands);
+ }
+ finally
+ {
+ // Restore original settings
+ options.HistorySavePathSQLite = originalHistorySavePathSQLite;
+ options.HistorySavePathText = originalHistorySavePathText;
+ options.HistorySaveStyle = originalHistorySaveStyle;
+ options.HistoryType = originalHistoryType;
+ PSConsoleReadLine.ClearHistory();
+
+ try { if (File.Exists(tempDbPath)) File.Delete(tempDbPath); } catch { }
+ try { if (File.Exists(tempTxtPath)) File.Delete(tempTxtPath); } catch { }
+ }
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_MigrationSkippedWhenNoTextFile()
+ {
+ TestSetup(KeyMode.Cmd);
+
+ var options = PSConsoleReadLine.GetOptions();
+ var originalHistorySavePathText = options.HistorySavePathText;
+ var originalHistorySavePathSQLite = options.HistorySavePathSQLite;
+ var originalHistorySaveStyle = options.HistorySaveStyle;
+ var originalHistoryType = options.HistoryType;
+
+ // Use a temp path where no .txt file exists
+ var tempDbPath = Path.Combine(Path.GetTempPath(), $"PSReadLineTest_{Guid.NewGuid():N}.db");
+
+ try
+ {
+ options.HistorySavePathSQLite = tempDbPath;
+ // Point text path to a non-existent file so migration won't import from the real history.
+ options.HistorySavePathText = Path.ChangeExtension(tempDbPath, ".txt");
+
+ var setOptions = new SetPSReadLineOption
+ {
+ HistoryType = HistoryType.SQLite,
+ HistorySaveStyle = HistorySaveStyle.SaveIncrementally,
+ };
+
+ // Should not throw even without a text file
+ Exception ex = Record.Exception(() => PSConsoleReadLine.SetOptions(setOptions));
+ Assert.Null(ex);
+
+ // Database should exist but with no command rows
+ Assert.True(File.Exists(tempDbPath));
+ long commandCount = CountSQLiteRows(tempDbPath, "Commands");
+ Assert.Equal(0, commandCount);
+ }
+ finally
+ {
+ options.HistorySavePathSQLite = originalHistorySavePathSQLite;
+ options.HistorySavePathText = originalHistorySavePathText;
+ options.HistorySaveStyle = originalHistorySaveStyle;
+ options.HistoryType = originalHistoryType;
+ PSConsoleReadLine.ClearHistory();
+
+ try { if (File.Exists(tempDbPath)) File.Delete(tempDbPath); } catch { }
+ }
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_MigrationOnlyHappensOnce()
+ {
+ TestSetup(KeyMode.Cmd);
+
+ var options = PSConsoleReadLine.GetOptions();
+ var originalHistorySavePathText = options.HistorySavePathText;
+ var originalHistorySavePathSQLite = options.HistorySavePathSQLite;
+ var originalHistorySaveStyle = options.HistorySaveStyle;
+ var originalHistoryType = options.HistoryType;
+
+ var tempDbPath = Path.Combine(Path.GetTempPath(), $"PSReadLineTest_{Guid.NewGuid():N}.db");
+ var tempTxtPath = Path.ChangeExtension(tempDbPath, ".txt");
+
+ try
+ {
+ // Create a text history file
+ File.WriteAllLines(tempTxtPath, new[] { "cmd1", "cmd2" });
+
+ // First switch to SQLite - should migrate
+ options.HistorySavePathText = tempTxtPath;
+ options.HistorySavePathSQLite = tempDbPath;
+ var setOptions = new SetPSReadLineOption
+ {
+ HistoryType = HistoryType.SQLite,
+ HistorySaveStyle = HistorySaveStyle.SaveIncrementally,
+ };
+ PSConsoleReadLine.SetOptions(setOptions);
+
+ long countAfterFirstMigration = CountSQLiteRows(tempDbPath, "Commands");
+ Assert.Equal(2, countAfterFirstMigration);
+
+ // Add another command to the text file (simulating continued text use)
+ File.AppendAllText(tempTxtPath, "cmd3\n");
+
+ // Switch back to Text, then back to SQLite again
+ // Since the DB already exists, migration should NOT run again
+ options.HistoryType = originalHistoryType;
+ PSConsoleReadLine.ClearHistory();
+
+ PSConsoleReadLine.SetOptions(setOptions);
+
+ long countAfterSecondSwitch = CountSQLiteRows(tempDbPath, "Commands");
+ // Should still be 2, not 3 — the migration didn't re-run
+ Assert.Equal(2, countAfterSecondSwitch);
+ }
+ finally
+ {
+ options.HistorySavePathSQLite = originalHistorySavePathSQLite;
+ options.HistorySavePathText = originalHistorySavePathText;
+ options.HistorySaveStyle = originalHistorySaveStyle;
+ options.HistoryType = originalHistoryType;
+ PSConsoleReadLine.ClearHistory();
+
+ try { if (File.Exists(tempDbPath)) File.Delete(tempDbPath); } catch { }
+ try { if (File.Exists(tempTxtPath)) File.Delete(tempTxtPath); } catch { }
+ }
+ }
+
+ // =====================================================================
+ // SQLite Location-Aware History Recall (new feature)
+ // =====================================================================
+
+ [SkippableFact]
+ public void SQLiteHistory_LocationIsStoredPerCommand()
+ {
+ TestSetup(KeyMode.Cmd);
+ using var ctx = SetupSQLiteHistory();
+
+ // Run commands — in test harness, Location defaults to "Unknown"
+ Test("echo hello", Keys("echo hello"));
+ Test("dir", Keys("dir"));
+
+ // Verify that different commands share the same location ("Unknown")
+ long locationCount = CountSQLiteRows(ctx.TempDbPath, "Locations");
+ Assert.Equal(1, locationCount);
+
+ // Verify both commands appear in ExecutionHistory
+ long ehCount = CountSQLiteRows(ctx.TempDbPath, "ExecutionHistory");
+ Assert.Equal(2, ehCount);
+ }
+
+ // =====================================================================
+ // Type Acceptance Test
+ // =====================================================================
+
+ [SkippableFact]
+ public void SQLiteHistory_SetPSReadLineOptionAcceptsSQLite()
+ {
+ TestSetup(KeyMode.Cmd);
+
+ var psrlOptions = PSConsoleReadLine.GetOptions();
+ var originalSqlitePath = psrlOptions.HistorySavePathSQLite;
+ var originalHistorySavePathText = psrlOptions.HistorySavePathText;
+ var originalHistoryType = psrlOptions.HistoryType;
+ var tempDbPath = Path.Combine(Path.GetTempPath(), $"PSReadLineTest_{Guid.NewGuid():N}.db");
+
+ try
+ {
+ psrlOptions.HistorySavePathSQLite = tempDbPath;
+
+ var optionsType = typeof(SetPSReadLineOption);
+ var historyTypeProperty = optionsType.GetProperty("HistoryType");
+ Assert.NotNull(historyTypeProperty);
+
+ var options = new SetPSReadLineOption();
+ historyTypeProperty.SetValue(options, HistoryType.SQLite);
+
+ Exception ex = Record.Exception(() => PSConsoleReadLine.SetOptions(options));
+ Assert.Null(ex);
+
+ Assert.Equal(HistoryType.SQLite, historyTypeProperty.GetValue(options));
+
+ // Restore to default
+ historyTypeProperty.SetValue(options, HistoryType.Text);
+ PSConsoleReadLine.SetOptions(options);
+ }
+ finally
+ {
+ psrlOptions.HistorySavePathSQLite = originalSqlitePath;
+ psrlOptions.HistorySavePathText = originalHistorySavePathText;
+ psrlOptions.HistoryType = originalHistoryType;
+ PSConsoleReadLine.ClearHistory();
+ try { if (File.Exists(tempDbPath)) File.Delete(tempDbPath); } catch { }
+ }
+ }
+
+ // =====================================================================
+ // Location-Based History Recall Tests
+ // =====================================================================
+
+ ///
+ /// Helper to add history items with specific locations.
+ /// Uses the internal AddToHistory(string, string) overload via reflection.
+ ///
+ private void SetHistoryWithLocations(params (string command, string location)[] items)
+ {
+ PSConsoleReadLine.ClearHistory();
+ foreach (var (command, location) in items)
+ {
+ typeof(PSConsoleReadLine)
+ .GetMethod("AddToHistory", BindingFlags.Static | BindingFlags.NonPublic,
+ null, new[] { typeof(string), typeof(string) }, null)
+ .Invoke(null, new object[] { command, location });
+ }
+ }
+
+ ///
+ /// Sets the mock current location for test purposes.
+ ///
+ private IDisposable SetTestLocation(string location)
+ {
+ typeof(PSConsoleReadLine)
+ .GetField("_testCurrentLocation", BindingFlags.Static | BindingFlags.NonPublic)
+ .SetValue(null, location);
+ return new TestLocationGuard();
+ }
+
+ private class TestLocationGuard : IDisposable
+ {
+ public void Dispose()
+ {
+ typeof(PSConsoleReadLine)
+ .GetField("_testCurrentLocation", BindingFlags.Static | BindingFlags.NonPublic)
+ .SetValue(null, null);
+ }
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_LocationRecall_MultipleItemsSameLocation()
+ {
+ TestSetup(KeyMode.Cmd,
+ new KeyHandler("UpArrow", PSConsoleReadLine.PreviousLocationHistory),
+ new KeyHandler("DownArrow", PSConsoleReadLine.NextLocationHistory));
+
+ using var location = SetTestLocation(@"C:\Projects\MyRepo");
+
+ // Add 8 commands at the target location and 3 at another location
+ SetHistoryWithLocations(
+ ("git status", @"C:\Projects\MyRepo"),
+ ("dotnet build", @"C:\Projects\MyRepo"),
+ ("ls -la", @"C:\Other"),
+ ("git log --oneline", @"C:\Projects\MyRepo"),
+ ("dotnet test", @"C:\Projects\MyRepo"),
+ ("cd ..", @"C:\Other"),
+ ("git diff", @"C:\Projects\MyRepo"),
+ ("dotnet publish", @"C:\Projects\MyRepo"),
+ ("pwd", @"C:\Other"),
+ ("git push", @"C:\Projects\MyRepo"),
+ ("git pull --rebase", @"C:\Projects\MyRepo")
+ );
+
+ // Alt+Up should walk through ALL 8 commands at C:\Projects\MyRepo,
+ // most recent first, skipping non-matching locations.
+ // We verify all 8 are reachable, then navigate back and submit.
+ Test("dotnet build", Keys(
+ _.UpArrow, CheckThat(() => AssertLineIs("git pull --rebase")),
+ _.UpArrow, CheckThat(() => AssertLineIs("git push")),
+ _.UpArrow, CheckThat(() => AssertLineIs("dotnet publish")),
+ _.UpArrow, CheckThat(() => AssertLineIs("git diff")),
+ _.UpArrow, CheckThat(() => AssertLineIs("dotnet test")),
+ _.UpArrow, CheckThat(() => AssertLineIs("git log --oneline")),
+ _.UpArrow, CheckThat(() => AssertLineIs("dotnet build")),
+ _.UpArrow, CheckThat(() => AssertLineIs("git status")),
+ // Should stay at the oldest item when pressing Up again
+ _.UpArrow, CheckThat(() => AssertLineIs("git status")),
+ // Navigate forward to verify Down also works correctly
+ _.DownArrow, CheckThat(() => AssertLineIs("dotnet build"))
+ ));
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_LocationRecall_NoLocationFallsBackToNormalRecall()
+ {
+ TestSetup(KeyMode.Cmd,
+ new KeyHandler("UpArrow", PSConsoleReadLine.PreviousLocationHistory),
+ new KeyHandler("DownArrow", PSConsoleReadLine.NextLocationHistory));
+
+ // Do NOT set test location - _testCurrentLocation is null, _engineIntrinsics is null
+ // This should fall back to normal HistoryRecall
+
+ SetHistory("cmd1", "cmd2", "cmd3");
+
+ // PreviousLocationHistory falls back to normal HistoryRecall when
+ // no location is available, so all 3 items should be reachable.
+ Test("cmd1", Keys(
+ _.UpArrow, CheckThat(() => AssertLineIs("cmd3")),
+ _.UpArrow, CheckThat(() => AssertLineIs("cmd2")),
+ _.UpArrow, CheckThat(() => AssertLineIs("cmd1"))
+ ));
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_LocationRecall_CaseInsensitivePaths()
+ {
+ TestSetup(KeyMode.Cmd,
+ new KeyHandler("UpArrow", PSConsoleReadLine.PreviousLocationHistory),
+ new KeyHandler("DownArrow", PSConsoleReadLine.NextLocationHistory));
+
+ using var location = SetTestLocation(@"C:\PROJECTS\myrepo");
+
+ // Add commands with different path casings - should all match
+ SetHistoryWithLocations(
+ ("git status", @"c:\projects\myrepo"),
+ ("git log", @"C:\Projects\MyRepo"),
+ ("git diff", @"C:\PROJECTS\MYREPO"),
+ ("unrelated", @"C:\Other")
+ );
+
+ // All three git commands should be accessible via location recall
+ // regardless of path casing
+ // All three git commands should be accessible via location recall
+ // regardless of path casing
+ Test("git status", Keys(
+ _.UpArrow, CheckThat(() => AssertLineIs("git diff")),
+ _.UpArrow, CheckThat(() => AssertLineIs("git log")),
+ _.UpArrow, CheckThat(() => AssertLineIs("git status")),
+ // Should stay at the oldest when pressing Up again
+ _.UpArrow, CheckThat(() => AssertLineIs("git status"))
+ ));
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_LocationRecall_DifferentLocationsFiltered()
+ {
+ TestSetup(KeyMode.Cmd,
+ new KeyHandler("UpArrow", PSConsoleReadLine.PreviousLocationHistory),
+ new KeyHandler("DownArrow", PSConsoleReadLine.NextLocationHistory));
+
+ using var location = SetTestLocation(@"C:\Projects\RepoA");
+
+ SetHistoryWithLocations(
+ ("cmd-a1", @"C:\Projects\RepoA"),
+ ("cmd-b1", @"C:\Projects\RepoB"),
+ ("cmd-a2", @"C:\Projects\RepoA"),
+ ("cmd-b2", @"C:\Projects\RepoB"),
+ ("cmd-a3", @"C:\Projects\RepoA")
+ );
+
+ // Only commands from RepoA should appear
+ // Only commands from RepoA should appear
+ Test("cmd-a1", Keys(
+ _.UpArrow, CheckThat(() => AssertLineIs("cmd-a3")),
+ _.UpArrow, CheckThat(() => AssertLineIs("cmd-a2")),
+ _.UpArrow, CheckThat(() => AssertLineIs("cmd-a1")),
+ // Should stay at the oldest when pressing Up again
+ _.UpArrow, CheckThat(() => AssertLineIs("cmd-a1"))
+ ));
+ }
+
+ // =====================================================================
+ // Frequency-Weighted History Ordering Tests
+ // =====================================================================
+
+ [SkippableFact]
+ public void SQLiteHistory_FrequentCommandRanksHigher()
+ {
+ TestSetup(KeyMode.Cmd);
+ using var ctx = SetupSQLiteHistory();
+
+ // Disable in-memory dedup so all writes reach SQLite
+ PSConsoleReadLine.SetOptions(new SetPSReadLineOption { HistoryNoDuplicates = false });
+
+ // Run an erroneous command once
+ Test("git log --one-line", Keys("git log --one-line"));
+
+ // Run the correct command many times to boost its ExecutionCount
+ for (int i = 0; i < 10; i++)
+ {
+ Test("git log --oneline", Keys("git log --oneline"));
+ }
+
+ // Verify ExecutionCount in the database
+ var connectionString = new SqliteConnectionStringBuilder($"Data Source={ctx.TempDbPath}")
+ {
+ Mode = SqliteOpenMode.ReadOnly
+ }.ToString();
+
+ using var connection = new SqliteConnection(connectionString);
+ connection.Open();
+
+ // Check ExecutionCount for the correct command
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = @"
+SELECT ExecutionCount FROM HistoryView
+WHERE CommandLine = 'git log --oneline'";
+ var correctCount = Convert.ToInt64(cmd.ExecuteScalar());
+
+ using var cmd2 = connection.CreateCommand();
+ cmd2.CommandText = @"
+SELECT ExecutionCount FROM HistoryView
+WHERE CommandLine = 'git log --one-line'";
+ var wrongCount = Convert.ToInt64(cmd2.ExecuteScalar());
+
+ Assert.True(correctCount >= 10,
+ $"Expected 'git log --oneline' ExecutionCount >= 10, got {correctCount}");
+ Assert.True(wrongCount <= 1,
+ $"Expected 'git log --one-line' ExecutionCount <= 1, got {wrongCount}");
+
+ // Verify weighted ordering: the frequent command should appear first
+ // (highest weighted score) so it's found first during backward search
+ using var orderCmd = connection.CreateCommand();
+ orderCmd.CommandText = @"
+SELECT CommandLine FROM HistoryView
+WHERE CommandLine IN ('git log --oneline', 'git log --one-line')
+ORDER BY (LastExecuted + MIN(ExecutionCount, 100) * 1800) DESC";
+ using var reader = orderCmd.ExecuteReader();
+ reader.Read();
+ string firstResult = reader.GetString(0);
+ Assert.Equal("git log --oneline", firstResult);
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_ExecutionCountStoredOnHistoryItem()
+ {
+ TestSetup(KeyMode.Cmd);
+ using var ctx = SetupSQLiteHistory();
+
+ // Disable in-memory dedup
+ PSConsoleReadLine.SetOptions(new SetPSReadLineOption { HistoryNoDuplicates = false });
+
+ // Run a command 5 times
+ for (int i = 0; i < 5; i++)
+ {
+ Test("echo repeated", Keys("echo repeated"));
+ }
+
+ // Verify ExecutionCount is stored in the database
+ var connectionString = new SqliteConnectionStringBuilder($"Data Source={ctx.TempDbPath}")
+ {
+ Mode = SqliteOpenMode.ReadOnly
+ }.ToString();
+
+ using var connection = new SqliteConnection(connectionString);
+ connection.Open();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = @"
+SELECT ExecutionCount FROM HistoryView
+WHERE CommandLine = 'echo repeated'";
+ var count = Convert.ToInt64(cmd.ExecuteScalar());
+ Assert.True(count >= 5, $"Expected ExecutionCount >= 5, got {count}");
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_WeightedOrderPreservesChronologyForSingleUse()
+ {
+ TestSetup(KeyMode.Cmd);
+ using var ctx = SetupSQLiteHistory();
+
+ // When all commands have ExecutionCount=1, ordering should be purely chronological
+ Test("cmd1", Keys("cmd1"));
+ Test("cmd2", Keys("cmd2"));
+ Test("cmd3", Keys("cmd3"));
+
+ // Verify chronological order in DB
+ var connectionString = new SqliteConnectionStringBuilder($"Data Source={ctx.TempDbPath}")
+ {
+ Mode = SqliteOpenMode.ReadOnly
+ }.ToString();
+
+ using var connection = new SqliteConnection(connectionString);
+ connection.Open();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = @"
+SELECT CommandLine FROM HistoryView
+ORDER BY (LastExecuted + MIN(ExecutionCount, 100) * 1800) ASC";
+ using var reader = cmd.ExecuteReader();
+
+ var results = new System.Collections.Generic.List();
+ while (reader.Read())
+ results.Add(reader.GetString(0));
+
+ Assert.Equal("cmd1", results[0]);
+ Assert.Equal("cmd2", results[1]);
+ Assert.Equal("cmd3", results[2]);
+ }
+
+ // =====================================================================
+ // History Item Removal Tests
+ // =====================================================================
+
+ [SkippableFact]
+ public void SQLiteHistory_RemoveHistoryItem_FromMemory()
+ {
+ TestSetup(KeyMode.Cmd);
+
+ SetHistory("good-command", "bad-typo", "another-good");
+
+ // Remove the typo from memory
+ bool removed = PSConsoleReadLine.RemoveHistoryItem("bad-typo");
+ Assert.True(removed, "RemoveHistoryItem should return true");
+
+ var items = PSConsoleReadLine.GetHistoryItems();
+ Assert.Equal(2, items.Length);
+ Assert.DoesNotContain(items, i => i.CommandLine == "bad-typo");
+ Assert.Contains(items, i => i.CommandLine == "good-command");
+ Assert.Contains(items, i => i.CommandLine == "another-good");
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_RemoveHistoryItem_FromSQLite()
+ {
+ TestSetup(KeyMode.Cmd);
+ using var ctx = SetupSQLiteHistory();
+
+ // Add commands
+ Test("git log --oneline", Keys("git log --oneline"));
+ Test("git log --one-line", Keys("git log --one-line"));
+ Test("git status", Keys("git status"));
+
+ // Verify all three are in the database
+ var commandsBefore = QuerySQLiteCommandLines(ctx.TempDbPath);
+ Assert.Contains("git log --one-line", commandsBefore);
+
+ // Remove the erroneous command
+ bool removed = PSConsoleReadLine.RemoveHistoryItem("git log --one-line");
+ Assert.True(removed, "RemoveHistoryItem should return true for SQLite removal");
+
+ // Verify it's removed from the database
+ var commandsAfter = QuerySQLiteCommandLines(ctx.TempDbPath);
+ Assert.DoesNotContain("git log --one-line", commandsAfter);
+ Assert.Contains("git log --oneline", commandsAfter);
+ Assert.Contains("git status", commandsAfter);
+
+ // Verify it's removed from memory too
+ var historyItems = PSConsoleReadLine.GetHistoryItems();
+ Assert.DoesNotContain(historyItems, i => i.CommandLine == "git log --one-line");
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_RemoveHistoryItem_NonExistent()
+ {
+ TestSetup(KeyMode.Cmd);
+
+ SetHistory("cmd1", "cmd2");
+
+ // Removing a non-existent command should return false
+ bool removed = PSConsoleReadLine.RemoveHistoryItem("does-not-exist");
+ Assert.False(removed);
+
+ // History should be unchanged
+ var items = PSConsoleReadLine.GetHistoryItems();
+ Assert.Equal(2, items.Length);
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_RemoveHistoryItem_NullOrEmpty()
+ {
+ TestSetup(KeyMode.Cmd);
+
+ SetHistory("cmd1");
+
+ Assert.False(PSConsoleReadLine.RemoveHistoryItem(null));
+ Assert.False(PSConsoleReadLine.RemoveHistoryItem(""));
+
+ // History should be unchanged
+ var items = PSConsoleReadLine.GetHistoryItems();
+ Assert.Single(items);
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_RemoveHistoryItem_ThenRecallWorks()
+ {
+ TestSetup(KeyMode.Cmd);
+
+ SetHistory("cmd1", "bad-command", "cmd3");
+
+ // Remove the bad command
+ PSConsoleReadLine.RemoveHistoryItem("bad-command");
+
+ // History recall should skip the removed item:
+ // Up → cmd3, Up → cmd1 (no bad-command in between)
+ Test("cmd1", Keys(
+ _.UpArrow, CheckThat(() => AssertLineIs("cmd3")),
+ _.UpArrow, CheckThat(() => AssertLineIs("cmd1"))
+ ));
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_RemoveHistoryItem_SQLitePersistence()
+ {
+ TestSetup(KeyMode.Cmd);
+ using var ctx = SetupSQLiteHistory();
+
+ // Add and then remove a command
+ Test("keep-this", Keys("keep-this"));
+ Test("remove-this", Keys("remove-this"));
+
+ PSConsoleReadLine.RemoveHistoryItem("remove-this");
+
+ // Verify the ExecutionHistory entry is also removed (not just Commands)
+ var connectionString = new SqliteConnectionStringBuilder($"Data Source={ctx.TempDbPath}")
+ {
+ Mode = SqliteOpenMode.ReadOnly
+ }.ToString();
+
+ using var connection = new SqliteConnection(connectionString);
+ connection.Open();
+
+ using var ehCmd = connection.CreateCommand();
+ ehCmd.CommandText = @"
+SELECT COUNT(*) FROM ExecutionHistory eh
+JOIN Commands c ON eh.CommandId = c.Id
+WHERE c.CommandLine = 'remove-this'";
+ long ehCount = (long)ehCmd.ExecuteScalar();
+ Assert.Equal(0, ehCount);
+
+ // But the kept command should still exist
+ using var keepCmd = connection.CreateCommand();
+ keepCmd.CommandText = @"
+SELECT COUNT(*) FROM ExecutionHistory eh
+JOIN Commands c ON eh.CommandId = c.Id
+WHERE c.CommandLine = 'keep-this'";
+ long keepCount = (long)keepCmd.ExecuteScalar();
+ Assert.True(keepCount >= 1);
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_MigrationTimestampsAreChronologicalAndOlderThanNow()
+ {
+ TestSetup(KeyMode.Cmd);
+
+ var options = PSConsoleReadLine.GetOptions();
+ var originalHistorySavePathText = options.HistorySavePathText;
+ var originalHistorySavePathSQLite = options.HistorySavePathSQLite;
+ var originalHistorySaveStyle = options.HistorySaveStyle;
+ var originalHistoryType = options.HistoryType;
+
+ var tempDbPath = Path.Combine(Path.GetTempPath(), $"PSReadLineTest_{Guid.NewGuid():N}.db");
+ var tempTxtPath = Path.ChangeExtension(tempDbPath, ".txt");
+
+ try
+ {
+ // Create a text history file with known content (oldest first)
+ File.WriteAllLines(tempTxtPath, new[]
+ {
+ "cmd-oldest",
+ "cmd-middle",
+ "cmd-newest"
+ });
+
+ var beforeMigration = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+
+ options.HistorySavePathText = tempTxtPath;
+ options.HistorySavePathSQLite = tempDbPath;
+
+ var setOptions = new SetPSReadLineOption
+ {
+ HistoryType = HistoryType.SQLite,
+ HistorySaveStyle = HistorySaveStyle.SaveIncrementally,
+ };
+ PSConsoleReadLine.SetOptions(setOptions);
+
+ // Query timestamps from the database in insertion order
+ var connectionString = new SqliteConnectionStringBuilder($"Data Source={tempDbPath}")
+ {
+ Mode = SqliteOpenMode.ReadOnly
+ }.ToString();
+
+ using var connection = new SqliteConnection(connectionString);
+ connection.Open();
+
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = "SELECT CommandLine, LastExecuted FROM HistoryView ORDER BY LastExecuted ASC";
+ using var reader = cmd.ExecuteReader();
+
+ var entries = new System.Collections.Generic.List<(string command, long lastExecuted)>();
+ while (reader.Read())
+ {
+ entries.Add((reader.GetString(0), reader.GetInt64(1)));
+ }
+
+ Assert.Equal(3, entries.Count);
+
+ // 1. Chronological order preserved: oldest text line has smallest timestamp
+ Assert.Equal("cmd-oldest", entries[0].command);
+ Assert.Equal("cmd-middle", entries[1].command);
+ Assert.Equal("cmd-newest", entries[2].command);
+
+ // 2. Timestamps are strictly increasing
+ Assert.True(entries[0].lastExecuted < entries[1].lastExecuted,
+ "oldest should have smaller timestamp than middle");
+ Assert.True(entries[1].lastExecuted < entries[2].lastExecuted,
+ "middle should have smaller timestamp than newest");
+
+ // 3. All migrated timestamps are older than "now"
+ foreach (var entry in entries)
+ {
+ Assert.True(entry.lastExecuted < beforeMigration,
+ $"Migrated entry '{entry.command}' has timestamp {entry.lastExecuted} which is not older than migration time {beforeMigration}");
+ }
+ }
+ finally
+ {
+ options.HistorySavePathSQLite = originalHistorySavePathSQLite;
+ options.HistorySavePathText = originalHistorySavePathText;
+ options.HistorySaveStyle = originalHistorySaveStyle;
+ options.HistoryType = originalHistoryType;
+ PSConsoleReadLine.ClearHistory();
+
+ try { if (File.Exists(tempDbPath)) File.Delete(tempDbPath); } catch { }
+ try { if (File.Exists(tempTxtPath)) File.Delete(tempTxtPath); } catch { }
+ }
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_MigratedTextHistoryOlderThanNewSQLiteEntries()
+ {
+ TestSetup(KeyMode.Cmd);
+
+ var options = PSConsoleReadLine.GetOptions();
+ var originalHistorySavePathText = options.HistorySavePathText;
+ var originalHistorySavePathSQLite = options.HistorySavePathSQLite;
+ var originalHistorySaveStyle = options.HistorySaveStyle;
+ var originalHistoryType = options.HistoryType;
+
+ var tempDbPath = Path.Combine(Path.GetTempPath(), $"PSReadLineTest_{Guid.NewGuid():N}.db");
+ var tempTxtPath = Path.ChangeExtension(tempDbPath, ".txt");
+
+ try
+ {
+ // Create text history (these are "old" commands)
+ File.WriteAllLines(tempTxtPath, new[]
+ {
+ "old-cmd-1",
+ "old-cmd-2"
+ });
+
+ options.HistorySavePathText = tempTxtPath;
+ options.HistorySavePathSQLite = tempDbPath;
+
+ var setOptions = new SetPSReadLineOption
+ {
+ HistoryType = HistoryType.SQLite,
+ HistorySaveStyle = HistorySaveStyle.SaveIncrementally,
+ };
+ PSConsoleReadLine.SetOptions(setOptions);
+
+ // Now add a new command via the normal path (simulates running a command after migration)
+ Test("new-cmd-after-migration", Keys("new-cmd-after-migration"));
+
+ // Query all entries ordered by LastExecuted
+ var connectionString = new SqliteConnectionStringBuilder($"Data Source={tempDbPath}")
+ {
+ Mode = SqliteOpenMode.ReadOnly
+ }.ToString();
+
+ using var connection = new SqliteConnection(connectionString);
+ connection.Open();
+
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = "SELECT CommandLine, LastExecuted FROM HistoryView ORDER BY LastExecuted ASC";
+ using var reader = cmd.ExecuteReader();
+
+ var entries = new System.Collections.Generic.List<(string command, long lastExecuted)>();
+ while (reader.Read())
+ {
+ entries.Add((reader.GetString(0), reader.GetInt64(1)));
+ }
+
+ Assert.Equal(3, entries.Count);
+
+ // Migrated items should be first (oldest), new item should be last (newest)
+ Assert.Equal("old-cmd-1", entries[0].command);
+ Assert.Equal("old-cmd-2", entries[1].command);
+ Assert.Equal("new-cmd-after-migration", entries[2].command);
+
+ // The new entry's timestamp must be strictly greater than all migrated ones
+ Assert.True(entries[2].lastExecuted > entries[1].lastExecuted,
+ "New SQLite entry must be newer than migrated text history");
+ Assert.True(entries[2].lastExecuted > entries[0].lastExecuted,
+ "New SQLite entry must be newer than migrated text history");
+ }
+ finally
+ {
+ options.HistorySavePathSQLite = originalHistorySavePathSQLite;
+ options.HistorySavePathText = originalHistorySavePathText;
+ options.HistorySaveStyle = originalHistorySaveStyle;
+ options.HistoryType = originalHistoryType;
+ PSConsoleReadLine.ClearHistory();
+
+ try { if (File.Exists(tempDbPath)) File.Delete(tempDbPath); } catch { }
+ try { if (File.Exists(tempTxtPath)) File.Delete(tempTxtPath); } catch { }
+ }
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_UpArrowShowsNewestFirstAfterMigration()
+ {
+ TestSetup(KeyMode.Cmd);
+
+ var options = PSConsoleReadLine.GetOptions();
+ var originalHistorySavePathText = options.HistorySavePathText;
+ var originalHistorySavePathSQLite = options.HistorySavePathSQLite;
+ var originalHistorySaveStyle = options.HistorySaveStyle;
+ var originalHistoryType = options.HistoryType;
+
+ var tempDbPath = Path.Combine(Path.GetTempPath(), $"PSReadLineTest_{Guid.NewGuid():N}.db");
+ var tempTxtPath = Path.ChangeExtension(tempDbPath, ".txt");
+
+ try
+ {
+ // Create text history (oldest first in the file)
+ File.WriteAllLines(tempTxtPath, new[]
+ {
+ "old-text-cmd-1",
+ "old-text-cmd-2",
+ "old-text-cmd-3"
+ });
+
+ options.HistorySavePathText = tempTxtPath;
+ options.HistorySavePathSQLite = tempDbPath;
+
+ var setOptions = new SetPSReadLineOption
+ {
+ HistoryType = HistoryType.SQLite,
+ HistorySaveStyle = HistorySaveStyle.SaveIncrementally,
+ };
+ PSConsoleReadLine.SetOptions(setOptions);
+
+ // Add a new entry after migration
+ Test("new-sqlite-cmd", Keys("new-sqlite-cmd"));
+
+ // Up arrow should show entries newest-first:
+ // new-sqlite-cmd → old-text-cmd-3 → old-text-cmd-2 → old-text-cmd-1
+ Test("old-text-cmd-1", Keys(
+ _.UpArrow, CheckThat(() => AssertLineIs("new-sqlite-cmd")),
+ _.UpArrow, CheckThat(() => AssertLineIs("old-text-cmd-3")),
+ _.UpArrow, CheckThat(() => AssertLineIs("old-text-cmd-2")),
+ _.UpArrow, CheckThat(() => AssertLineIs("old-text-cmd-1"))
+ ));
+ }
+ finally
+ {
+ options.HistorySavePathSQLite = originalHistorySavePathSQLite;
+ options.HistorySavePathText = originalHistorySavePathText;
+ options.HistorySaveStyle = originalHistorySaveStyle;
+ options.HistoryType = originalHistoryType;
+ PSConsoleReadLine.ClearHistory();
+
+ try { if (File.Exists(tempDbPath)) File.Delete(tempDbPath); } catch { }
+ try { if (File.Exists(tempTxtPath)) File.Delete(tempTxtPath); } catch { }
+ }
+ }
+
+ // =====================================================================
+ // Location-Scoped History Removal Tests (RemoveHistoryItemAtLocation
+ // and the Alt+Delete -> RemoveFromHistoryAtCurrentLocation handler)
+ // =====================================================================
+
+ ///
+ /// Helper to count ExecutionHistory rows for a given (CommandLine, Location) pair.
+ ///
+ private long CountExecutionHistoryAt(string dbPath, string commandLine, string location)
+ {
+ var connectionString = new SqliteConnectionStringBuilder($"Data Source={dbPath}")
+ {
+ Mode = SqliteOpenMode.ReadOnly
+ }.ToString();
+
+ using var connection = new SqliteConnection(connectionString);
+ connection.Open();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = @"
+SELECT COUNT(*) FROM ExecutionHistory eh
+JOIN Commands c ON eh.CommandId = c.Id
+JOIN Locations l ON eh.LocationId = l.Id
+WHERE c.CommandLine = @CommandLine AND l.Path = @Location";
+ cmd.Parameters.AddWithValue("@CommandLine", commandLine);
+ cmd.Parameters.AddWithValue("@Location", location);
+ return (long)cmd.ExecuteScalar();
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_RemoveHistoryItemAtLocation_RemovesOnlyMatchingLocation()
+ {
+ TestSetup(KeyMode.Cmd);
+ using var ctx = SetupSQLiteHistory();
+ using var loc = SetTestLocation(@"C:\Projects\A");
+
+ // Same command run at two locations. An interleaved different command
+ // is required so HistoryNoDuplicates (on by default) doesn't skip the
+ // second "git status" entry as a consecutive dup.
+ SetHistoryWithLocations(
+ ("git status", @"C:\Projects\A"),
+ ("unrelated", @"C:\Projects\A"),
+ ("git status", @"C:\Projects\B"));
+
+ // Sanity: both ExecutionHistory rows exist.
+ Assert.Equal(1, CountExecutionHistoryAt(ctx.TempDbPath, "git status", @"C:\Projects\A"));
+ Assert.Equal(1, CountExecutionHistoryAt(ctx.TempDbPath, "git status", @"C:\Projects\B"));
+
+ bool removed = PSConsoleReadLine.RemoveHistoryItemAtLocation("git status", @"C:\Projects\A");
+ Assert.True(removed);
+
+ // Only the A-location row should be gone; B-location row stays.
+ Assert.Equal(0, CountExecutionHistoryAt(ctx.TempDbPath, "git status", @"C:\Projects\A"));
+ Assert.Equal(1, CountExecutionHistoryAt(ctx.TempDbPath, "git status", @"C:\Projects\B"));
+
+ // Commands row must still exist because location B still references it.
+ var commands = QuerySQLiteCommandLines(ctx.TempDbPath);
+ Assert.Contains("git status", commands);
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_RemoveHistoryItemAtLocation_DropsOrphanedCommandRow()
+ {
+ TestSetup(KeyMode.Cmd);
+ using var ctx = SetupSQLiteHistory();
+ using var loc = SetTestLocation(@"C:\Only");
+
+ // Command run at exactly one location.
+ SetHistoryWithLocations(("only-here", @"C:\Only"));
+ Assert.Contains("only-here", QuerySQLiteCommandLines(ctx.TempDbPath));
+
+ bool removed = PSConsoleReadLine.RemoveHistoryItemAtLocation("only-here", @"C:\Only");
+ Assert.True(removed);
+
+ // Both ExecutionHistory and Commands rows should be gone (orphan cleanup).
+ Assert.Equal(0, CountExecutionHistoryAt(ctx.TempDbPath, "only-here", @"C:\Only"));
+ Assert.DoesNotContain("only-here", QuerySQLiteCommandLines(ctx.TempDbPath));
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_RemoveHistoryItemAtLocation_InMemoryRespectsLocation()
+ {
+ TestSetup(KeyMode.Cmd);
+ using var ctx = SetupSQLiteHistory();
+ using var loc = SetTestLocation(@"C:\Projects\A");
+
+ // Two in-memory items with the same command line but different locations.
+ // Interleave a different command so HistoryNoDuplicates doesn't skip
+ // the second "git pull" as a consecutive dup of the first.
+ SetHistoryWithLocations(
+ ("git pull", @"C:\Projects\A"),
+ ("sep", @"C:\Projects\A"),
+ ("git pull", @"C:\Projects\B"));
+
+ PSConsoleReadLine.RemoveHistoryItemAtLocation("git pull", @"C:\Projects\A");
+
+ // The B-location "git pull" should still be in memory; the
+ // separator "sep" stays too. The A-location "git pull" is gone.
+ var items = PSConsoleReadLine.GetHistoryItems();
+ Assert.Equal(2, items.Length);
+ var pullItem = Assert.Single(items, i => i.CommandLine == "git pull");
+ Assert.Equal(@"C:\Projects\B", pullItem.Location);
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_RemoveHistoryItemAtLocation_NoMatchReturnsFalse()
+ {
+ TestSetup(KeyMode.Cmd);
+ using var ctx = SetupSQLiteHistory();
+ using var loc = SetTestLocation(@"C:\Projects\A");
+
+ SetHistoryWithLocations(("git status", @"C:\Projects\A"));
+
+ // Wrong location — should be a no-op.
+ bool removed = PSConsoleReadLine.RemoveHistoryItemAtLocation("git status", @"C:\Nowhere");
+ Assert.False(removed);
+
+ // Original row still intact.
+ Assert.Equal(1, CountExecutionHistoryAt(ctx.TempDbPath, "git status", @"C:\Projects\A"));
+ Assert.Single(PSConsoleReadLine.GetHistoryItems());
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_RemoveHistoryItemAtLocation_NullOrEmpty()
+ {
+ TestSetup(KeyMode.Cmd);
+
+ SetHistory("cmd1");
+
+ Assert.False(PSConsoleReadLine.RemoveHistoryItemAtLocation(null, "loc"));
+ Assert.False(PSConsoleReadLine.RemoveHistoryItemAtLocation("", "loc"));
+ Assert.False(PSConsoleReadLine.RemoveHistoryItemAtLocation("cmd1", null));
+ Assert.False(PSConsoleReadLine.RemoveHistoryItemAtLocation("cmd1", ""));
+
+ Assert.Single(PSConsoleReadLine.GetHistoryItems());
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_AltDelete_RemovesAtCurrentLocationOnly()
+ {
+ TestSetup(KeyMode.Cmd);
+ using var ctx = SetupSQLiteHistory();
+ using var loc = SetTestLocation(@"C:\Projects\A");
+
+ // Same command at current location and elsewhere; an interleaved entry
+ // is needed so HistoryNoDuplicates doesn't drop the second "git status".
+ // Up arrow surfaces the most-recently-added item first ("git status" at A).
+ SetHistoryWithLocations(
+ ("git status", @"C:\Projects\B"),
+ ("sep", @"C:\Projects\B"),
+ ("git status", @"C:\Projects\A"));
+
+ // Up arrow surfaces "git status" (the A-location entry, most recent).
+ // Alt+Delete should remove only the A entry. After deletion, the
+ // separator "sep" is now the most recent in-memory item; the B-location
+ // "git status" is still further back. RemoveFromHistoryAtCurrentLocation
+ // advances to savedIndex-1 which is now the "sep" entry.
+ Test("sep", Keys(
+ _.UpArrow,
+ CheckThat(() => AssertLineIs("git status")),
+ _.Alt_Delete,
+ CheckThat(() => AssertLineIs("sep"))
+ ));
+
+ // DB: A-row deleted, B-row preserved, Commands row still present.
+ Assert.Equal(0, CountExecutionHistoryAt(ctx.TempDbPath, "git status", @"C:\Projects\A"));
+ Assert.Equal(1, CountExecutionHistoryAt(ctx.TempDbPath, "git status", @"C:\Projects\B"));
+ Assert.Contains("git status", QuerySQLiteCommandLines(ctx.TempDbPath));
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_AltDelete_DingsWhenItemNotAtCurrentLocation()
+ {
+ TestSetup(KeyMode.Cmd);
+ using var ctx = SetupSQLiteHistory();
+ using var loc = SetTestLocation(@"C:\Projects\Current");
+
+ // Item exists in history but was run at a different location.
+ SetHistoryWithLocations(("git status", @"C:\Projects\Other"));
+
+ // Up arrow shows it; Alt+Delete should NOT delete (not run here),
+ // and the line should remain unchanged.
+ Test("git status", Keys(
+ _.UpArrow,
+ CheckThat(() => AssertLineIs("git status")),
+ _.Alt_Delete,
+ CheckThat(() => AssertLineIs("git status"))
+ ));
+
+ // DB row at the other location must still exist.
+ Assert.Equal(1, CountExecutionHistoryAt(ctx.TempDbPath, "git status", @"C:\Projects\Other"));
+ Assert.Contains("git status", QuerySQLiteCommandLines(ctx.TempDbPath));
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_AltDelete_FallsBackToGlobalInTextMode()
+ {
+ // Text mode: no SQLite, no current location concept.
+ // Alt+Delete must still do something useful — falls back to global removal.
+ TestSetup(KeyMode.Cmd);
+
+ SetHistory("cmd1", "cmd2", "cmd3");
+
+ Test("cmd1", Keys(
+ _.UpArrow, // recall cmd3
+ CheckThat(() => AssertLineIs("cmd3")),
+ _.Alt_Delete, // global fallback removes cmd3
+ CheckThat(() => AssertLineIs("cmd2")),
+ _.Alt_Delete, // and cmd2
+ CheckThat(() => AssertLineIs("cmd1"))
+ ));
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_GlobalRemove_StillWipesAllLocations()
+ {
+ // The global RemoveHistoryItem path (used by Ctrl+Shift+Delete) must
+ // continue to wipe every (Command, Location) pair — regression check.
+ TestSetup(KeyMode.Cmd);
+ using var ctx = SetupSQLiteHistory();
+ using var loc = SetTestLocation(@"C:\Projects\A");
+
+ SetHistoryWithLocations(
+ ("git status", @"C:\Projects\A"),
+ ("sep1", @"C:\Projects\A"),
+ ("git status", @"C:\Projects\B"),
+ ("sep2", @"C:\Projects\B"),
+ ("git status", @"C:\Projects\C"));
+
+ bool removed = PSConsoleReadLine.RemoveHistoryItem("git status");
+ Assert.True(removed);
+
+ Assert.Equal(0, CountExecutionHistoryAt(ctx.TempDbPath, "git status", @"C:\Projects\A"));
+ Assert.Equal(0, CountExecutionHistoryAt(ctx.TempDbPath, "git status", @"C:\Projects\B"));
+ Assert.Equal(0, CountExecutionHistoryAt(ctx.TempDbPath, "git status", @"C:\Projects\C"));
+ Assert.DoesNotContain("git status", QuerySQLiteCommandLines(ctx.TempDbPath));
+ }
+
+ // =====================================================================
+ // Sticky Location Mode Tests
+ //
+ // When the user enters location-filtered navigation via Alt+Up
+ // (PreviousLocationHistory), the sorted list and current position should
+ // remain "sticky" so that plain Up / Down (PreviousHistory / NextHistory)
+ // continue to navigate the same location-filtered set, instead of dropping
+ // the user back into raw chronological history at an unrelated index.
+ //
+ // The tests below bind:
+ // UpArrow -> PreviousLocationHistory (simulates Alt+Up "enter mode")
+ // DownArrow -> NextLocationHistory (simulates Alt+Down)
+ // Ctrl+P -> PreviousHistory (simulates plain Up after Alt release)
+ // Ctrl+N -> NextHistory (simulates plain Down after Alt release)
+ // Ctrl+G -> CancelLine (used to break the sticky chain)
+ // =====================================================================
+
+ [SkippableFact]
+ public void SQLiteHistory_LocationRecall_StickyMode_PlainUpContinuesLocationList()
+ {
+ TestSetup(KeyMode.Cmd,
+ new KeyHandler("UpArrow", PSConsoleReadLine.PreviousLocationHistory),
+ new KeyHandler("DownArrow", PSConsoleReadLine.NextLocationHistory),
+ new KeyHandler("Ctrl+p", PSConsoleReadLine.PreviousHistory),
+ new KeyHandler("Ctrl+n", PSConsoleReadLine.NextHistory));
+
+ using var loc = SetTestLocation(@"C:\Projects\Sticky");
+
+ SetHistoryWithLocations(
+ ("loc-cmd-1", @"C:\Projects\Sticky"),
+ ("other-1", @"C:\Other"),
+ ("loc-cmd-2", @"C:\Projects\Sticky"),
+ ("other-2", @"C:\Other"),
+ ("loc-cmd-3", @"C:\Projects\Sticky"));
+
+ // UpArrow (location mode) lands on newest local entry, then plain
+ // Ctrl+P (regular Up) should KEEP filtering by location instead of
+ // jumping to "other-2" or some unrelated chronological neighbor.
+ Test("loc-cmd-3", Keys(
+ _.UpArrow, CheckThat(() => AssertLineIs("loc-cmd-3")),
+ _.Ctrl_p, CheckThat(() => AssertLineIs("loc-cmd-2")),
+ _.Ctrl_p, CheckThat(() => AssertLineIs("loc-cmd-1")),
+ // Plain Ctrl+N (Down) also stays in sticky location mode.
+ _.Ctrl_n, CheckThat(() => AssertLineIs("loc-cmd-2")),
+ _.Ctrl_n, CheckThat(() => AssertLineIs("loc-cmd-3"))
+ ));
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_LocationRecall_StickyMode_ClearedOnNonHistoryAction()
+ {
+ TestSetup(KeyMode.Cmd,
+ new KeyHandler("UpArrow", PSConsoleReadLine.PreviousLocationHistory),
+ new KeyHandler("DownArrow", PSConsoleReadLine.NextLocationHistory),
+ new KeyHandler("Ctrl+p", PSConsoleReadLine.PreviousHistory),
+ new KeyHandler("Ctrl+n", PSConsoleReadLine.NextHistory));
+
+ using var loc = SetTestLocation(@"C:\Projects\Sticky");
+
+ SetHistoryWithLocations(
+ ("loc-cmd-1", @"C:\Projects\Sticky"),
+ ("other-1", @"C:\Other"),
+ ("loc-cmd-2", @"C:\Projects\Sticky"),
+ ("other-2", @"C:\Other"));
+
+ // Enter location mode, then perform a non-history action (typing a
+ // character). After that, plain Ctrl+P should walk regular
+ // chronological history (which includes "other-*" entries),
+ // NOT the location-filtered list.
+ // After 'x' is typed the main loop's anyHistoryCommandCount reset branch
+ // fires (no history command was issued for that key), which exits sticky
+ // mode AND resets _currentHistoryIndex back to _history.Count. The next
+ // Ctrl+P therefore replaces the buffer with the most-recent chronological
+ // entry ("other-2") — losing the typed 'x'. That's the same behavior the
+ // text-mode HistoryRecall has always had after editing a recalled line.
+ Test("other-2", Keys(
+ _.UpArrow, CheckThat(() => AssertLineIs("loc-cmd-2")),
+ 'x', CheckThat(() => AssertLineIs("loc-cmd-2x")),
+ _.Ctrl_p, CheckThat(() => AssertLineIs("other-2"))
+ ));
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_LocationRecall_StickyMode_AltUpStillAdvances()
+ {
+ // After plain Up has been used inside sticky mode, pressing Alt+Up
+ // (PreviousLocationHistory) again should keep advancing through the
+ // same sorted location list — not rebuild it from scratch.
+ TestSetup(KeyMode.Cmd,
+ new KeyHandler("UpArrow", PSConsoleReadLine.PreviousLocationHistory),
+ new KeyHandler("DownArrow", PSConsoleReadLine.NextLocationHistory),
+ new KeyHandler("Ctrl+p", PSConsoleReadLine.PreviousHistory),
+ new KeyHandler("Ctrl+n", PSConsoleReadLine.NextHistory));
+
+ using var loc = SetTestLocation(@"C:\Projects\Sticky");
+
+ SetHistoryWithLocations(
+ ("loc-a", @"C:\Projects\Sticky"),
+ ("nope", @"C:\Other"),
+ ("loc-b", @"C:\Projects\Sticky"),
+ ("loc-c", @"C:\Projects\Sticky"));
+
+ Test("loc-b", Keys(
+ _.UpArrow, CheckThat(() => AssertLineIs("loc-c")),
+ _.Ctrl_p, CheckThat(() => AssertLineIs("loc-b")),
+ _.UpArrow, CheckThat(() => AssertLineIs("loc-a")),
+ _.Ctrl_n, CheckThat(() => AssertLineIs("loc-b"))
+ ));
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_LocationRecall_StickyMode_NotEnteredWhenJustPlainUp()
+ {
+ // Plain Up alone (without ever pressing Alt+Up) must not behave like
+ // location mode — it walks raw chronological history including items
+ // from other directories.
+ TestSetup(KeyMode.Cmd,
+ new KeyHandler("Ctrl+p", PSConsoleReadLine.PreviousHistory),
+ new KeyHandler("Ctrl+n", PSConsoleReadLine.NextHistory));
+
+ using var loc = SetTestLocation(@"C:\Projects\Sticky");
+
+ SetHistoryWithLocations(
+ ("loc-1", @"C:\Projects\Sticky"),
+ ("other-1", @"C:\Other"),
+ ("loc-2", @"C:\Projects\Sticky"),
+ ("other-2", @"C:\Other"));
+
+ Test("loc-1", Keys(
+ _.Ctrl_p, CheckThat(() => AssertLineIs("other-2")),
+ _.Ctrl_p, CheckThat(() => AssertLineIs("loc-2")),
+ _.Ctrl_p, CheckThat(() => AssertLineIs("other-1")),
+ _.Ctrl_p, CheckThat(() => AssertLineIs("loc-1"))
+ ));
+ }
+
+ // =====================================================================
+ // History Navigation Position Indicator Tests
+ //
+ // The "[BOOK pos/total]" indicator is rendered into _statusLinePrompt
+ // while the user is navigating history (chronological or location-mode).
+ // It is cleared once the user does any non-history action.
+ // =====================================================================
+
+ private static string GetStatusLinePromptForTest()
+ {
+ var fld = typeof(PSConsoleReadLine).GetField(
+ "_statusLinePrompt",
+ BindingFlags.Instance | BindingFlags.NonPublic);
+ var singletonFld = typeof(PSConsoleReadLine).GetField(
+ "_singleton",
+ BindingFlags.Static | BindingFlags.NonPublic);
+ var singleton = singletonFld.GetValue(null);
+ return (string)fld.GetValue(singleton);
+ }
+
+ private static string ExpectedNavStatus(int pos, int total, bool locationMode)
+ {
+ // Mirror ShowHistoryNavStatus: brackets default-colored, inner uses ListPredictionColor.
+ var color = (string)typeof(PSConsoleReadLineOptions)
+ .GetField("_listPredictionColor", BindingFlags.Instance | BindingFlags.NonPublic)
+ .GetValue(PSConsoleReadLine.GetOptions());
+ var inner = locationMode
+ ? $"\uD83D\uDCC2 {pos}/{total}"
+ : $"\u23F1 {pos}/{total}";
+ return $"[{color}{inner}\x1b[0m]";
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_HistoryNavStatus_ShownDuringChronologicalRecall()
+ {
+ TestSetup(KeyMode.Cmd,
+ new KeyHandler("Ctrl+p", PSConsoleReadLine.PreviousHistory),
+ new KeyHandler("Ctrl+n", PSConsoleReadLine.NextHistory));
+
+ SetHistory("a", "b", "c");
+
+ Test("a", Keys(
+ _.Ctrl_p, CheckThat(() => AssertLineIs("c")),
+ CheckThat(() => Assert.Equal(ExpectedNavStatus(1, 3, false), GetStatusLinePromptForTest())),
+ _.Ctrl_p, CheckThat(() => AssertLineIs("b")),
+ CheckThat(() => Assert.Equal(ExpectedNavStatus(2, 3, false), GetStatusLinePromptForTest())),
+ _.Ctrl_p, CheckThat(() => AssertLineIs("a")),
+ CheckThat(() => Assert.Equal(ExpectedNavStatus(3, 3, false), GetStatusLinePromptForTest()))
+ ));
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_HistoryNavStatus_ShownDuringLocationRecallWithLocLabel()
+ {
+ TestSetup(KeyMode.Cmd,
+ new KeyHandler("UpArrow", PSConsoleReadLine.PreviousLocationHistory),
+ new KeyHandler("DownArrow", PSConsoleReadLine.NextLocationHistory));
+
+ using var loc = SetTestLocation(@"C:\Projects\Status");
+
+ SetHistoryWithLocations(
+ ("local-1", @"C:\Projects\Status"),
+ ("other", @"C:\Other"),
+ ("local-2", @"C:\Projects\Status"),
+ ("local-3", @"C:\Projects\Status"));
+
+ Test("local-1", Keys(
+ _.UpArrow,
+ CheckThat(() => AssertLineIs("local-3")),
+ CheckThat(() => Assert.Equal(ExpectedNavStatus(1, 3, true), GetStatusLinePromptForTest())),
+ _.UpArrow,
+ CheckThat(() => AssertLineIs("local-2")),
+ CheckThat(() => Assert.Equal(ExpectedNavStatus(2, 3, true), GetStatusLinePromptForTest())),
+ _.UpArrow,
+ CheckThat(() => AssertLineIs("local-1")),
+ CheckThat(() => Assert.Equal(ExpectedNavStatus(3, 3, true), GetStatusLinePromptForTest()))
+ ));
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_HistoryNavStatus_ClearedAfterEditing()
+ {
+ TestSetup(KeyMode.Cmd,
+ new KeyHandler("Ctrl+p", PSConsoleReadLine.PreviousHistory));
+
+ SetHistory("alpha", "bravo");
+
+ Test("bravox", Keys(
+ _.Ctrl_p,
+ CheckThat(() => AssertLineIs("bravo")),
+ CheckThat(() => Assert.Equal(ExpectedNavStatus(1, 2, false), GetStatusLinePromptForTest())),
+ 'x',
+ // Typing a character is a non-history action: the indicator must clear.
+ CheckThat(() => Assert.Null(GetStatusLinePromptForTest()))
+ ));
+ }
+
+ ///
+ /// Marks the in-memory _history items at the given indices as FromOtherSession
+ /// so they get skipped by HistoryRecall (mirroring cross-session SQLite items).
+ ///
+ private static void MarkHistoryItemsFromOtherSession(params int[] indices)
+ {
+ var singletonFld = typeof(PSConsoleReadLine).GetField(
+ "_singleton", BindingFlags.Static | BindingFlags.NonPublic);
+ var singleton = singletonFld.GetValue(null);
+ var historyFld = typeof(PSConsoleReadLine).GetField(
+ "_history", BindingFlags.Instance | BindingFlags.NonPublic);
+ var history = historyFld.GetValue(singleton);
+ // HistoryQueue exposes an indexer; use reflection to call it.
+ var indexer = history.GetType().GetProperty("Item");
+ var historyItemType = typeof(PSConsoleReadLine).GetNestedType(
+ "HistoryItem", BindingFlags.Public | BindingFlags.NonPublic);
+ var fromOtherProp = historyItemType.GetProperty(
+ "FromOtherSession", BindingFlags.Public | BindingFlags.Instance);
+ foreach (var i in indices)
+ {
+ var item = indexer.GetValue(history, new object[] { i });
+ fromOtherProp.SetValue(item, true);
+ }
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_HistoryNavStatus_CountsOnlyNavigableItems() {
+ // Regression test: previously the indicator used the raw _history slot,
+ // so cross-session items between two in-session items caused the
+ // displayed position to "jump" by hundreds even though Up only moved
+ // by one navigable item.
+ TestSetup(KeyMode.Cmd,
+ new KeyHandler("Ctrl+p", PSConsoleReadLine.PreviousHistory));
+
+ // History layout (oldest -> newest):
+ // [0] mine-old (this session)
+ // [1] other-1 (other session, skipped)
+ // [2] other-2 (other session, skipped)
+ // [3] other-3 (other session, skipped)
+ // [4] mine-new (this session)
+ // Navigable total = 2. Up #1 lands on "mine-new" => 1/2.
+ // Up #2 must skip the three other-session items and land on
+ // "mine-old" => 2/2 (NOT 5/5 or 4/5).
+ SetHistory("mine-old", "other-1", "other-2", "other-3", "mine-new");
+ MarkHistoryItemsFromOtherSession(1, 2, 3);
+
+ Test("", Keys(
+ _.Ctrl_p,
+ CheckThat(() => AssertLineIs("mine-new")),
+ CheckThat(() => Assert.Equal(ExpectedNavStatus(1, 2, false), GetStatusLinePromptForTest())),
+ _.Ctrl_p,
+ CheckThat(() => AssertLineIs("mine-old")),
+ CheckThat(() => Assert.Equal(ExpectedNavStatus(2, 2, false), GetStatusLinePromptForTest())),
+ _.Escape
+ ));
+ }
+
+ [SkippableFact]
+ public void SQLiteHistory_AltDelete_InLocationMode_RefreshesIndicatorAndAdvances()
+ {
+ // Regression test: previously Alt+Delete while navigating location-filtered
+ // history left _locationSortedIndices stale (built against pre-deletion
+ // _history) and never updated _locationSortedPosition. Result was the
+ // indicator stuck at e.g. 1/3 instead of 1/2, and the next Alt+Up landed
+ // on the wrong item because the cached indices were shifted.
+ TestSetup(KeyMode.Cmd,
+ new KeyHandler("UpArrow", PSConsoleReadLine.PreviousLocationHistory),
+ new KeyHandler("DownArrow", PSConsoleReadLine.NextLocationHistory));
+ using var ctx = SetupSQLiteHistory();
+ using var loc = SetTestLocation(@"C:\Projects\AltDel");
+
+ // Three unique commands at the current location, equal frequency.
+ // Sort = recency DESC: pos 0 = "gamma", pos 1 = "beta", pos 2 = "alpha".
+ SetHistoryWithLocations(
+ ("alpha", @"C:\Projects\AltDel"),
+ ("beta", @"C:\Projects\AltDel"),
+ ("gamma", @"C:\Projects\AltDel"));
+
+ Test("beta", Keys(
+ _.UpArrow, // pos 0 -> "gamma" (1/3)
+ CheckThat(() => AssertLineIs("gamma")),
+ CheckThat(() => Assert.Equal(ExpectedNavStatus(1, 3, true), GetStatusLinePromptForTest())),
+ _.Alt_Delete, // remove "gamma"
+ // Indicator must refresh: 1/2, line must advance to next location item.
+ CheckThat(() => AssertLineIs("beta")),
+ CheckThat(() => Assert.Equal(ExpectedNavStatus(1, 2, true), GetStatusLinePromptForTest())),
+ // Subsequent Alt+Up must use the rebuilt sorted list, not the stale one.
+ _.UpArrow, // advance to next older
+ CheckThat(() => AssertLineIs("alpha")),
+ CheckThat(() => Assert.Equal(ExpectedNavStatus(2, 2, true), GetStatusLinePromptForTest())),
+ _.DownArrow, // back to "beta"
+ CheckThat(() => AssertLineIs("beta")),
+ CheckThat(() => Assert.Equal(ExpectedNavStatus(1, 2, true), GetStatusLinePromptForTest()))
+ ));
+
+ // DB sanity: "gamma" gone at this location, others intact.
+ Assert.Equal(0, CountExecutionHistoryAt(ctx.TempDbPath, "gamma", @"C:\Projects\AltDel"));
+ Assert.Equal(1, CountExecutionHistoryAt(ctx.TempDbPath, "beta", @"C:\Projects\AltDel"));
+ Assert.Equal(1, CountExecutionHistoryAt(ctx.TempDbPath, "alpha", @"C:\Projects\AltDel"));
+ }
+
+ // =====================================================================
+ // AccessibleHistoryDisplay option
+ //
+ // The SQLite-history-awareness UX uses emojis (⟳, ⏱, 📂) in two places:
+ // 1) The F2 list-view stats tooltip rendered by RenderHistoryStatsTooltip
+ // 2) The in-prompt navigation indicator rendered by ShowHistoryNavStatus
+ //
+ // Screen readers verbalize emoji as Unicode names ("clockwise gapped circle
+ // arrow", "card index dividers"). The AccessibleHistoryDisplay option, when
+ // enabled, swaps emoji for plain-text labels:
+ // * Tooltip: "⟳ Runs N | ⏱ Last 2m ago | 📂 Dir "
+ // -> "Runs N | Last 2m ago | Dir "
+ // * Indicator: "[⏱ 3/15]" / "[📂 2/5]"
+ // -> "[History 3/15]" / "[Location 2/5]"
+ //
+ // Default value is seeded once at construction from ScreenReaderModeEnabled
+ // and is independent thereafter.
+ // =====================================================================
+
+ // Emoji code points used by the SQLite history awareness UX.
+ private const string TooltipRunsEmoji = "\u27f3"; // ⟳
+ private const string TooltipLastEmoji = "\u23f1"; // ⏱
+ private const string TooltipDirEmoji = "\U0001F4C2"; // 📂
+ private const string NavStatusChronoEmoji = "\u23F1"; // ⏱ (BMP, used by ShowHistoryNavStatus)
+ private const string NavStatusLocEmoji = "\uD83D\uDCC2"; // 📂 (surrogate pair)
+
+ ///
+ /// Renders the F2 stats tooltip into a fresh buffer and returns the resulting
+ /// line as a string. RenderHistoryStatsTooltip is a private method on the
+ /// nested PredictionListView class — invoke it via reflection.
+ ///
+ private static string CaptureRenderedHistoryStatsTooltip(string commandLine, int executionCount, DateTime startTime, string location)
+ {
+ // Build a HistoryItem with the requested fields. Setters are internal so
+ // we go through reflection to keep this resilient.
+ var historyItemType = typeof(PSConsoleReadLine).GetNestedType(
+ "HistoryItem", BindingFlags.Public | BindingFlags.NonPublic);
+ var historyItem = Activator.CreateInstance(historyItemType);
+ historyItemType.GetProperty("CommandLine").SetValue(historyItem, commandLine);
+ historyItemType.GetProperty("ExecutionCount").SetValue(historyItem, executionCount);
+ historyItemType.GetProperty("StartTime").SetValue(historyItem, startTime);
+ historyItemType.GetProperty("Location").SetValue(historyItem, location);
+
+ // Get the singleton and instantiate PredictionListView via its internal ctor.
+ var singletonFld = typeof(PSConsoleReadLine).GetField(
+ "_singleton", BindingFlags.Static | BindingFlags.NonPublic);
+ var singleton = singletonFld.GetValue(null);
+ var listViewType = typeof(PSConsoleReadLine).GetNestedType(
+ "PredictionListView", BindingFlags.NonPublic);
+ var listView = Activator.CreateInstance(
+ listViewType,
+ BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.CreateInstance,
+ binder: null,
+ args: new[] { singleton },
+ culture: null);
+
+ var renderMethod = listViewType.GetMethod(
+ "RenderHistoryStatsTooltip",
+ BindingFlags.Instance | BindingFlags.NonPublic);
+
+ // NextBufferLine pre-increments `current`, then creates a new StringBuilder
+ // when current == consoleBufferLines.Count. Start with an empty list and
+ // current = -1 so the first call advances to index 0 and writes there.
+ var buffer = new System.Collections.Generic.List();
+ object[] args = new object[] { historyItem, buffer, -1 };
+ renderMethod.Invoke(listView, args);
+
+ return buffer.Count > 0 ? buffer[0].ToString() : string.Empty;
+ }
+
+ /// Mirror ExpectedNavStatus for the accessible-display variant.
+ private static string ExpectedAccessibleNavStatus(int pos, int total, bool locationMode)
+ {
+ var color = (string)typeof(PSConsoleReadLineOptions)
+ .GetField("_listPredictionColor", BindingFlags.Instance | BindingFlags.NonPublic)
+ .GetValue(PSConsoleReadLine.GetOptions());
+ var inner = locationMode
+ ? $"Location {pos}/{total}"
+ : $"History {pos}/{total}";
+ return $"[{color}{inner}\x1b[0m]";
+ }
+
+ [SkippableFact]
+ public void AccessibleHistoryDisplay_DefaultMatchesScreenReaderModeAtConstruction()
+ {
+ // Construct a fresh options object directly — the constructor seeds
+ // AccessibleHistoryDisplay from the screen-reader detection result.
+ // Verify the seeded value matches whatever ScreenReaderModeEnabled was set to.
+ var opts = new PSConsoleReadLineOptions("AccessibleDefaultHost", usingLegacyConsole: false);
+ Assert.Equal(opts.ScreenReaderModeEnabled, opts.AccessibleHistoryDisplay);
+ }
+
+ [SkippableFact]
+ public void AccessibleHistoryDisplay_RoundTripsViaSetPSReadLineOption()
+ {
+ TestSetup(KeyMode.Cmd);
+ var originalAccessibleValue = PSConsoleReadLine.GetOptions().AccessibleHistoryDisplay;
+ try
+ {
+ PSConsoleReadLine.SetOptions(new SetPSReadLineOption { AccessibleHistoryDisplay = true });
+ Assert.True(PSConsoleReadLine.GetOptions().AccessibleHistoryDisplay);
+
+ PSConsoleReadLine.SetOptions(new SetPSReadLineOption { AccessibleHistoryDisplay = false });
+ Assert.False(PSConsoleReadLine.GetOptions().AccessibleHistoryDisplay);
+ }
+ finally
+ {
+ PSConsoleReadLine.GetOptions().AccessibleHistoryDisplay = originalAccessibleValue;
+ }
+ }
+
+ [SkippableFact]
+ public void AccessibleHistoryDisplay_IndependentFromScreenReaderModeAfterInit()
+ {
+ // Toggling EnableScreenReaderMode after construction must NOT auto-flip
+ // AccessibleHistoryDisplay. Per design, the flag is only seeded once at
+ // construction and is independent thereafter.
+ TestSetup(KeyMode.Cmd);
+ var opts = PSConsoleReadLine.GetOptions();
+ var originalAccessible = opts.AccessibleHistoryDisplay;
+ var originalScreenReader = opts.ScreenReaderModeEnabled;
+
+ try
+ {
+ // Force AccessibleHistoryDisplay to a known-false state.
+ PSConsoleReadLine.SetOptions(new SetPSReadLineOption { AccessibleHistoryDisplay = false });
+ Assert.False(opts.AccessibleHistoryDisplay);
+
+ // Flip the screen-reader option — accessible flag must NOT follow.
+ PSConsoleReadLine.SetOptions(new SetPSReadLineOption { EnableScreenReaderMode = true });
+ Assert.True(opts.ScreenReaderModeEnabled);
+ Assert.False(opts.AccessibleHistoryDisplay);
+
+ // And the reverse — flipping screen reader off must not auto-disable.
+ PSConsoleReadLine.SetOptions(new SetPSReadLineOption { AccessibleHistoryDisplay = true });
+ PSConsoleReadLine.SetOptions(new SetPSReadLineOption { EnableScreenReaderMode = false });
+ Assert.False(opts.ScreenReaderModeEnabled);
+ Assert.True(opts.AccessibleHistoryDisplay);
+ }
+ finally
+ {
+ opts.AccessibleHistoryDisplay = originalAccessible;
+ opts.ScreenReaderModeEnabled = originalScreenReader;
+ }
+ }
+
+ [SkippableFact]
+ public void AccessibleHistoryDisplay_TooltipOmitsEmojiWhenEnabled()
+ {
+ TestSetup(KeyMode.Cmd);
+ var opts = PSConsoleReadLine.GetOptions();
+ var original = opts.AccessibleHistoryDisplay;
+ try
+ {
+ opts.AccessibleHistoryDisplay = true;
+ var rendered = CaptureRenderedHistoryStatsTooltip(
+ commandLine: "git status",
+ executionCount: 47,
+ startTime: DateTime.UtcNow.AddMinutes(-2),
+ location: @"C:\repos\PSReadline");
+
+ // Emoji icons must be absent.
+ Assert.DoesNotContain(TooltipRunsEmoji, rendered);
+ Assert.DoesNotContain(TooltipLastEmoji, rendered);
+ Assert.DoesNotContain(TooltipDirEmoji, rendered);
+
+ // Plain-text labels and value must remain.
+ Assert.Contains("Runs ", rendered);
+ Assert.Contains("47", rendered);
+ Assert.Contains("Last ", rendered);
+ Assert.Contains("Dir ", rendered);
+ Assert.Contains(@"C:\repos\PSReadline", rendered);
+ }
+ finally
+ {
+ opts.AccessibleHistoryDisplay = original;
+ }
+ }
+
+ [SkippableFact]
+ public void AccessibleHistoryDisplay_TooltipKeepsEmojiWhenDisabled()
+ {
+ // Regression: with the option off, the existing emoji-decorated tooltip
+ // must be unchanged.
+ TestSetup(KeyMode.Cmd);
+ var opts = PSConsoleReadLine.GetOptions();
+ var original = opts.AccessibleHistoryDisplay;
+ try
+ {
+ opts.AccessibleHistoryDisplay = false;
+ var rendered = CaptureRenderedHistoryStatsTooltip(
+ commandLine: "git status",
+ executionCount: 5,
+ startTime: DateTime.UtcNow.AddMinutes(-3),
+ location: @"C:\repos\PSReadline");
+
+ Assert.Contains(TooltipRunsEmoji, rendered);
+ Assert.Contains(TooltipLastEmoji, rendered);
+ Assert.Contains(TooltipDirEmoji, rendered);
+ Assert.Contains("Runs ", rendered);
+ Assert.Contains("Last ", rendered);
+ Assert.Contains("Dir ", rendered);
+ }
+ finally
+ {
+ opts.AccessibleHistoryDisplay = original;
+ }
+ }
+
+ [SkippableFact]
+ public void AccessibleHistoryDisplay_TooltipOmitsAbsentFieldsWithoutStraySeparators()
+ {
+ // Edge case: an item with no StartTime and no Location must not leave
+ // separator characters or stray emoji in the rendered output.
+ TestSetup(KeyMode.Cmd);
+ var opts = PSConsoleReadLine.GetOptions();
+ var original = opts.AccessibleHistoryDisplay;
+ try
+ {
+ opts.AccessibleHistoryDisplay = true;
+ var rendered = CaptureRenderedHistoryStatsTooltip(
+ commandLine: "alone",
+ executionCount: 1,
+ startTime: default,
+ location: null);
+
+ Assert.Contains("Runs ", rendered);
+ Assert.Contains("1", rendered);
+ // No Last / Dir labels.
+ Assert.DoesNotContain("Last ", rendered);
+ Assert.DoesNotContain("Dir ", rendered);
+ // No separator characters between sections.
+ Assert.DoesNotContain("\u2502", rendered);
+ // No emoji at all.
+ Assert.DoesNotContain(TooltipRunsEmoji, rendered);
+ Assert.DoesNotContain(TooltipLastEmoji, rendered);
+ Assert.DoesNotContain(TooltipDirEmoji, rendered);
+ }
+ finally
+ {
+ opts.AccessibleHistoryDisplay = original;
+ }
+ }
+
+ [SkippableFact]
+ public void AccessibleHistoryDisplay_TooltipOmitsLocationWhenUnknown()
+ {
+ // "Unknown" location entries (legacy text-history migrations) must not
+ // produce a Dir segment regardless of AccessibleHistoryDisplay.
+ TestSetup(KeyMode.Cmd);
+ var opts = PSConsoleReadLine.GetOptions();
+ var original = opts.AccessibleHistoryDisplay;
+ try
+ {
+ opts.AccessibleHistoryDisplay = true;
+ var rendered = CaptureRenderedHistoryStatsTooltip(
+ commandLine: "legacy",
+ executionCount: 3,
+ startTime: DateTime.UtcNow.AddHours(-2),
+ location: "Unknown");
+
+ Assert.Contains("Runs ", rendered);
+ Assert.Contains("Last ", rendered);
+ Assert.DoesNotContain("Dir ", rendered);
+ }
+ finally
+ {
+ opts.AccessibleHistoryDisplay = original;
+ }
+ }
+
+ [SkippableFact]
+ public void AccessibleHistoryDisplay_NavStatusUsesTextLabelsForChronologicalRecall()
+ {
+ TestSetup(KeyMode.Cmd,
+ new KeyHandler("Ctrl+p", PSConsoleReadLine.PreviousHistory));
+
+ var opts = PSConsoleReadLine.GetOptions();
+ var original = opts.AccessibleHistoryDisplay;
+ try
+ {
+ opts.AccessibleHistoryDisplay = true;
+
+ SetHistory("a", "b", "c");
+
+ Test("", Keys(
+ _.Ctrl_p,
+ CheckThat(() => AssertLineIs("c")),
+ CheckThat(() => Assert.Equal(
+ ExpectedAccessibleNavStatus(1, 3, locationMode: false),
+ GetStatusLinePromptForTest())),
+ // Status line must NOT contain the chronological emoji.
+ CheckThat(() => Assert.DoesNotContain(NavStatusChronoEmoji, GetStatusLinePromptForTest())),
+ _.Escape
+ ));
+ }
+ finally
+ {
+ opts.AccessibleHistoryDisplay = original;
+ }
+ }
+
+ [SkippableFact]
+ public void AccessibleHistoryDisplay_NavStatusUsesTextLabelsForLocationRecall()
+ {
+ TestSetup(KeyMode.Cmd,
+ new KeyHandler("UpArrow", PSConsoleReadLine.PreviousLocationHistory),
+ new KeyHandler("DownArrow", PSConsoleReadLine.NextLocationHistory));
+
+ var opts = PSConsoleReadLine.GetOptions();
+ var original = opts.AccessibleHistoryDisplay;
+ try
+ {
+ opts.AccessibleHistoryDisplay = true;
+
+ using var loc = SetTestLocation(@"C:\Projects\Accessible");
+
+ SetHistoryWithLocations(
+ ("local-1", @"C:\Projects\Accessible"),
+ ("other", @"C:\Other"),
+ ("local-2", @"C:\Projects\Accessible"));
+
+ Test("", Keys(
+ _.UpArrow,
+ CheckThat(() => AssertLineIs("local-2")),
+ CheckThat(() => Assert.Equal(
+ ExpectedAccessibleNavStatus(1, 2, locationMode: true),
+ GetStatusLinePromptForTest())),
+ // Status line must NOT contain the location emoji surrogate pair.
+ CheckThat(() => Assert.DoesNotContain(NavStatusLocEmoji, GetStatusLinePromptForTest())),
+ _.Escape
+ ));
+ }
+ finally
+ {
+ opts.AccessibleHistoryDisplay = original;
+ }
+ }
+
+ [SkippableFact]
+ public void AccessibleHistoryDisplay_NavStatusKeepsEmojiWhenDisabled()
+ {
+ // Regression: with the option off, the existing emoji indicator must
+ // continue to render as before.
+ TestSetup(KeyMode.Cmd,
+ new KeyHandler("Ctrl+p", PSConsoleReadLine.PreviousHistory));
+
+ var opts = PSConsoleReadLine.GetOptions();
+ var original = opts.AccessibleHistoryDisplay;
+ try
+ {
+ opts.AccessibleHistoryDisplay = false;
+
+ SetHistory("a", "b");
+
+ Test("", Keys(
+ _.Ctrl_p,
+ CheckThat(() => AssertLineIs("b")),
+ CheckThat(() => Assert.Contains(NavStatusChronoEmoji, GetStatusLinePromptForTest())),
+ CheckThat(() => Assert.DoesNotContain("History ", GetStatusLinePromptForTest())),
+ _.Escape
+ ));
+ }
+ finally
+ {
+ opts.AccessibleHistoryDisplay = original;
+ }
+ }
+ }
+}