diff --git a/CosmosDBShell.Tests/Shell/HotkeyCommandTests.cs b/CosmosDBShell.Tests/Shell/HotkeyCommandTests.cs
new file mode 100644
index 0000000..069edb6
--- /dev/null
+++ b/CosmosDBShell.Tests/Shell/HotkeyCommandTests.cs
@@ -0,0 +1,590 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------
+
+namespace CosmosShell.Tests.Shell;
+
+using Azure.Data.Cosmos.Shell.Core;
+using Azure.Data.Cosmos.Shell.KeyBindings;
+using RadLine;
+using Spectre.Console;
+
+public class HotkeyCommandTests
+{
+ [Fact]
+ public void MoveToStartOfLine_PlacesCursorAtZero()
+ {
+ var context = CreateContext("hello world", position: 7);
+
+ new MoveToStartOfLineCommand().Execute(context);
+
+ Assert.Equal(0, context.Buffer.Position);
+ Assert.Equal("hello world", context.Buffer.Content);
+ }
+
+ [Fact]
+ public void MoveToEndOfLine_PlacesCursorAtLength()
+ {
+ var context = CreateContext("hello world", position: 3);
+
+ new MoveToEndOfLineCommand().Execute(context);
+
+ Assert.Equal(context.Buffer.Length, context.Buffer.Position);
+ Assert.Equal("hello world", context.Buffer.Content);
+ }
+
+ [Fact]
+ public void DeleteToStartOfLine_RemovesTextBeforeCursor()
+ {
+ var context = CreateContext("hello world", position: 6);
+
+ new DeleteToStartOfLineCommand().Execute(context);
+
+ Assert.Equal("world", context.Buffer.Content);
+ Assert.Equal(0, context.Buffer.Position);
+ }
+
+ [Fact]
+ public void DeleteToStartOfLine_AtBeginning_NoOp()
+ {
+ var context = CreateContext("hello", position: 0);
+
+ new DeleteToStartOfLineCommand().Execute(context);
+
+ Assert.Equal("hello", context.Buffer.Content);
+ Assert.Equal(0, context.Buffer.Position);
+ }
+
+ [Fact]
+ public void DeleteToEndOfLine_RemovesTextAfterCursor()
+ {
+ var context = CreateContext("hello world", position: 5);
+
+ new DeleteToEndOfLineCommand().Execute(context);
+
+ Assert.Equal("hello", context.Buffer.Content);
+ Assert.Equal(5, context.Buffer.Position);
+ }
+
+ [Fact]
+ public void DeleteToEndOfLine_AtEnd_NoOp()
+ {
+ var context = CreateContext("hello", position: 5);
+
+ new DeleteToEndOfLineCommand().Execute(context);
+
+ Assert.Equal("hello", context.Buffer.Content);
+ Assert.Equal(5, context.Buffer.Position);
+ }
+
+ [Fact]
+ public void DeletePreviousWord_RemovesLastWordBeforeCursor()
+ {
+ var context = CreateContext("hello world", position: 11);
+
+ new DeletePreviousWordCommand().Execute(context);
+
+ Assert.Equal("hello ", context.Buffer.Content);
+ Assert.Equal(6, context.Buffer.Position);
+ }
+
+ [Fact]
+ public void DeletePreviousWord_SkipsTrailingWhitespace()
+ {
+ var context = CreateContext("hello world ", position: 14);
+
+ new DeletePreviousWordCommand().Execute(context);
+
+ Assert.Equal("hello ", context.Buffer.Content);
+ Assert.Equal(6, context.Buffer.Position);
+ }
+
+ [Fact]
+ public void DeletePreviousWord_OnlyDeletesUpToCursor()
+ {
+ var context = CreateContext("hello world today", position: 11);
+
+ new DeletePreviousWordCommand().Execute(context);
+
+ Assert.Equal("hello today", context.Buffer.Content);
+ Assert.Equal(6, context.Buffer.Position);
+ }
+
+ [Fact]
+ public void DeletePreviousWord_AtBeginning_NoOp()
+ {
+ var context = CreateContext("hello", position: 0);
+
+ new DeletePreviousWordCommand().Execute(context);
+
+ Assert.Equal("hello", context.Buffer.Content);
+ Assert.Equal(0, context.Buffer.Position);
+ }
+
+ [Fact]
+ public void ExitShell_EmptyBuffer_StopsShell()
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.IsRunning = true;
+ var context = CreateContext(string.Empty, position: 0);
+
+ new ExitShellCommand(shell).Execute(context);
+
+ Assert.False(shell.IsRunning);
+ }
+
+ [Fact]
+ public void ExitShell_NonEmptyBuffer_DeletesCharAtCursor()
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.IsRunning = true;
+ var context = CreateContext("hello", position: 1);
+
+ new ExitShellCommand(shell).Execute(context);
+
+ Assert.True(shell.IsRunning);
+ Assert.Equal("hllo", context.Buffer.Content);
+ Assert.Equal(1, context.Buffer.Position);
+ }
+
+ [Fact]
+ public void ExitShell_NonEmptyBufferAtEnd_NoOp()
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.IsRunning = true;
+ var context = CreateContext("hello", position: 5);
+
+ new ExitShellCommand(shell).Execute(context);
+
+ Assert.True(shell.IsRunning);
+ Assert.Equal("hello", context.Buffer.Content);
+ }
+
+ [Fact]
+ public void MoveCursorLeft_DecrementsPosition()
+ {
+ var context = CreateContext("hello", position: 3);
+
+ new MoveCursorLeftCommand().Execute(context);
+
+ Assert.Equal(2, context.Buffer.Position);
+ }
+
+ [Fact]
+ public void MoveCursorLeft_AtBeginning_NoOp()
+ {
+ var context = CreateContext("hello", position: 0);
+
+ new MoveCursorLeftCommand().Execute(context);
+
+ Assert.Equal(0, context.Buffer.Position);
+ }
+
+ [Fact]
+ public void MoveCursorRight_IncrementsPosition()
+ {
+ var context = CreateContext("hello", position: 3);
+
+ new MoveCursorRightCommand().Execute(context);
+
+ Assert.Equal(4, context.Buffer.Position);
+ }
+
+ [Fact]
+ public void MoveCursorRight_AtEnd_NoOp()
+ {
+ var context = CreateContext("hello", position: 5);
+
+ new MoveCursorRightCommand().Execute(context);
+
+ Assert.Equal(5, context.Buffer.Position);
+ }
+
+ [Fact]
+ public void ReverseSearch_FindsNewestSubstringMatch()
+ {
+ var history = new[] { "ls", "query SELECT * FROM c", "echo hi", "query foo" };
+
+ var index = ReverseHistorySearch.FindReverseMatch(history, "query", 0);
+
+ Assert.Equal(3, index);
+ }
+
+ [Fact]
+ public void ReverseSearch_SkipsToOlderMatch()
+ {
+ var history = new[] { "ls", "query SELECT * FROM c", "echo hi", "query foo" };
+
+ var index = ReverseHistorySearch.FindReverseMatch(history, "query", 1);
+
+ Assert.Equal(1, index);
+ }
+
+ [Fact]
+ public void ReverseSearch_NoMoreMatches_ReturnsMinusOne()
+ {
+ var history = new[] { "ls", "query foo" };
+
+ var index = ReverseHistorySearch.FindReverseMatch(history, "query", 1);
+
+ Assert.Equal(-1, index);
+ }
+
+ [Fact]
+ public void ReverseSearch_FindNextReverseMatch_AdvancesToOlderMatch()
+ {
+ var history = new[] { "ls", "query SELECT * FROM c", "echo hi", "query foo" };
+
+ var result = ReverseHistorySearch.FindNextMatch(history, "query", currentSkip: 0);
+
+ Assert.True(result.HasMatch);
+ Assert.Equal(1, result.Index);
+ Assert.Equal(1, result.Skip);
+ Assert.Equal("query SELECT * FROM c", result.Match);
+ }
+
+ [Fact]
+ public void ReverseSearch_FindNextReverseMatch_WrapsToNewestMatch()
+ {
+ var history = new[] { "ls", "query SELECT * FROM c", "echo hi", "query foo" };
+
+ var result = ReverseHistorySearch.FindNextMatch(history, "query", currentSkip: 1);
+
+ Assert.True(result.HasMatch);
+ Assert.Equal(3, result.Index);
+ Assert.Equal(0, result.Skip);
+ Assert.Equal("query foo", result.Match);
+ }
+
+ [Fact]
+ public void ReverseSearch_FindNextReverseMatch_NoMatches_ReturnsMinusOne()
+ {
+ var history = new[] { "ls", "echo hi" };
+
+ var result = ReverseHistorySearch.FindNextMatch(history, "query", currentSkip: 0);
+
+ Assert.False(result.HasMatch);
+ Assert.Equal(-1, result.Index);
+ Assert.Equal(0, result.Skip);
+ Assert.Equal(string.Empty, result.Match);
+ }
+
+ [Fact]
+ public void ReverseSearch_FindInitialMatch_ReturnsNewestMatchResult()
+ {
+ var history = new[] { "query one", "ls", "query two" };
+
+ var result = ReverseHistorySearch.FindInitialMatch(history, "query");
+
+ Assert.True(result.HasMatch);
+ Assert.Equal(2, result.Index);
+ Assert.Equal(0, result.Skip);
+ Assert.Equal("query two", result.Match);
+ }
+
+ [Fact]
+ public void ReverseSearch_FindInitialMatch_NoMatch_ReturnsEmptyResult()
+ {
+ var history = new[] { "ls", "echo hi" };
+
+ var result = ReverseHistorySearch.FindInitialMatch(history, "query");
+
+ Assert.False(result.HasMatch);
+ Assert.Equal(-1, result.Index);
+ Assert.Equal(0, result.Skip);
+ Assert.Equal(string.Empty, result.Match);
+ }
+
+ [Fact]
+ public void ReverseSearch_FindInitialForwardMatch_ReturnsOldestMatchResult()
+ {
+ var history = new[] { "query one", "ls", "query two" };
+
+ var result = ReverseHistorySearch.FindInitialForwardMatch(history, "query");
+
+ Assert.True(result.HasMatch);
+ Assert.Equal(0, result.Index);
+ Assert.Equal(1, result.Skip);
+ Assert.Equal("query one", result.Match);
+ }
+
+ [Fact]
+ public void ReverseSearch_FindInitialForwardMatch_NoMatch_ReturnsEmptyResult()
+ {
+ var history = new[] { "ls", "echo hi" };
+
+ var result = ReverseHistorySearch.FindInitialForwardMatch(history, "query");
+
+ Assert.False(result.HasMatch);
+ Assert.Equal(-1, result.Index);
+ Assert.Equal(0, result.Skip);
+ Assert.Equal(string.Empty, result.Match);
+ }
+
+ [Fact]
+ public void ReverseSearch_FindNextMatch_SingleMatch_WrapsToSameResult()
+ {
+ var history = new[] { "ls", "query foo" };
+
+ var result = ReverseHistorySearch.FindNextMatch(history, "query", currentSkip: 0);
+
+ Assert.True(result.HasMatch);
+ Assert.Equal(1, result.Index);
+ Assert.Equal(0, result.Skip);
+ Assert.Equal("query foo", result.Match);
+ }
+
+ [Fact]
+ public void ReverseSearch_FindNextMatch_CurrentSkipPastEnd_WrapsToNewestMatch()
+ {
+ var history = new[] { "query one", "query two" };
+
+ var result = ReverseHistorySearch.FindNextMatch(history, "query", currentSkip: 10);
+
+ Assert.True(result.HasMatch);
+ Assert.Equal(1, result.Index);
+ Assert.Equal(0, result.Skip);
+ Assert.Equal("query two", result.Match);
+ }
+
+ [Fact]
+ public void ReverseSearch_FindPreviousMatch_AdvancesToNewerMatch()
+ {
+ var history = new[] { "ls", "query SELECT * FROM c", "echo hi", "query foo" };
+
+ var result = ReverseHistorySearch.FindPreviousMatch(history, "query", currentSkip: 1);
+
+ Assert.True(result.HasMatch);
+ Assert.Equal(3, result.Index);
+ Assert.Equal(0, result.Skip);
+ Assert.Equal("query foo", result.Match);
+ }
+
+ [Fact]
+ public void ReverseSearch_FindPreviousMatch_WrapsToOldestMatch()
+ {
+ var history = new[] { "ls", "query SELECT * FROM c", "echo hi", "query foo" };
+
+ var result = ReverseHistorySearch.FindPreviousMatch(history, "query", currentSkip: 0);
+
+ Assert.True(result.HasMatch);
+ Assert.Equal(1, result.Index);
+ Assert.Equal(1, result.Skip);
+ Assert.Equal("query SELECT * FROM c", result.Match);
+ }
+
+ [Fact]
+ public void ReverseSearch_FindPreviousMatch_SingleMatch_WrapsToSameResult()
+ {
+ var history = new[] { "ls", "query foo" };
+
+ var result = ReverseHistorySearch.FindPreviousMatch(history, "query", currentSkip: 0);
+
+ Assert.True(result.HasMatch);
+ Assert.Equal(1, result.Index);
+ Assert.Equal(0, result.Skip);
+ Assert.Equal("query foo", result.Match);
+ }
+
+ [Fact]
+ public void ReverseSearch_FindPreviousMatch_NoMatches_ReturnsEmptyResult()
+ {
+ var history = new[] { "ls", "echo hi" };
+
+ var result = ReverseHistorySearch.FindPreviousMatch(history, "query", currentSkip: 0);
+
+ Assert.False(result.HasMatch);
+ Assert.Equal(-1, result.Index);
+ Assert.Equal(0, result.Skip);
+ Assert.Equal(string.Empty, result.Match);
+ }
+
+ [Fact]
+ public void ReverseSearch_IsCaseInsensitive()
+ {
+ var history = new[] { "QUERY foo" };
+
+ var index = ReverseHistorySearch.FindReverseMatch(history, "query", 0);
+
+ Assert.Equal(0, index);
+ }
+
+ [Fact]
+ public void ReverseSearch_EmptyQuery_ReturnsMinusOne()
+ {
+ var history = new[] { "ls", "query foo" };
+
+ var index = ReverseHistorySearch.FindReverseMatch(history, string.Empty, 0);
+
+ Assert.Equal(-1, index);
+ }
+
+ [Fact]
+ public void ReverseSearch_NoMatches_ReturnsMinusOne()
+ {
+ var history = new[] { "ls", "echo hi" };
+
+ var index = ReverseHistorySearch.FindReverseMatch(history, "query", 0);
+
+ Assert.Equal(-1, index);
+ }
+
+ [Fact]
+ public void ReverseSearch_FormatSearchPrompt_WithMatch_UsesNormalState()
+ {
+ var prompt = ReverseHistorySearch.FormatSearchPrompt("que", "query foo", hasMatch: true);
+
+ Assert.Equal("(reverse-i-search)`que`: query foo", prompt);
+ }
+
+ [Fact]
+ public void ReverseSearch_FormatSearchPrompt_WithoutMatch_UsesFailedState()
+ {
+ var prompt = ReverseHistorySearch.FormatSearchPrompt("missing", string.Empty, hasMatch: false);
+
+ Assert.Equal("(failed reverse-i-search)`missing`: ", prompt);
+ }
+
+ [Fact]
+ public void ReverseSearch_FormatSearchPrompt_EmptyQuery_UsesNormalState()
+ {
+ var prompt = ReverseHistorySearch.FormatSearchPrompt(string.Empty, string.Empty, hasMatch: false);
+
+ Assert.Equal("(reverse-i-search)``: ", prompt);
+ }
+
+ [Fact]
+ public void ReverseSearch_FormatSearchPrompt_ForwardSearch_UsesForwardState()
+ {
+ var prompt = ReverseHistorySearch.FormatSearchPrompt("que", "query foo", hasMatch: true, isForwardSearch: true);
+
+ Assert.Equal("(forward-i-search)`que`: query foo", prompt);
+ }
+
+ [Fact]
+ public void ReverseSearch_FormatSearchPrompt_ForwardSearchWithoutMatch_UsesFailedForwardState()
+ {
+ var prompt = ReverseHistorySearch.FormatSearchPrompt("missing", string.Empty, hasMatch: false, isForwardSearch: true);
+
+ Assert.Equal("(failed forward-i-search)`missing`: ", prompt);
+ }
+
+ [Fact]
+ public void ReverseSearch_FormatSearchPromptMarkup_HighlightsMatchedSubstring()
+ {
+ var prompt = ReverseHistorySearch.FormatSearchPromptMarkup("que", "select query foo", hasMatch: true);
+
+ Assert.Equal("(reverse-i-search)`que`: select [underline yellow]que[/]ry foo", prompt);
+ }
+
+ [Fact]
+ public void ReverseSearch_FormatSearchPromptMarkup_IsCaseInsensitive()
+ {
+ var prompt = ReverseHistorySearch.FormatSearchPromptMarkup("que", "SELECT Query foo", hasMatch: true);
+
+ Assert.Equal("(reverse-i-search)`que`: SELECT [underline yellow]Que[/]ry foo", prompt);
+ }
+
+ [Fact]
+ public void ReverseSearch_FormatSearchPromptMarkup_HighlightsAllOccurrences()
+ {
+ var prompt = ReverseHistorySearch.FormatSearchPromptMarkup("a", "ababa", hasMatch: true);
+
+ Assert.Equal("(reverse-i-search)`a`: [underline yellow]a[/]b[underline yellow]a[/]b[underline yellow]a[/]", prompt);
+ }
+
+ [Fact]
+ public void ReverseSearch_FormatSearchPromptMarkup_FailedState_DoesNotHighlight()
+ {
+ var prompt = ReverseHistorySearch.FormatSearchPromptMarkup("missing", string.Empty, hasMatch: false);
+
+ Assert.Equal("(failed reverse-i-search)`missing`: ", prompt);
+ }
+
+ [Fact]
+ public void ReverseSearch_FormatSearchPromptMarkup_EscapesMarkupBracketsInMatch()
+ {
+ var prompt = ReverseHistorySearch.FormatSearchPromptMarkup("foo", "[foo] bar", hasMatch: true);
+
+ Assert.Equal("(reverse-i-search)`foo`: [[[underline yellow]foo[/]]] bar", prompt);
+ }
+
+ [Fact]
+ public void ReverseSearch_FormatSearchPromptMarkup_WithSyntaxHighlighter_PreservesCommandStyling()
+ {
+ var prompt = ReverseHistorySearch.FormatSearchPromptMarkup(
+ "con",
+ "connect",
+ hasMatch: true,
+ isForwardSearch: false,
+ syntaxHighlighter: ShellInterpreter.Instance);
+
+ Assert.StartsWith("(reverse-i-search)`con`: ", prompt);
+ Assert.Contains("[underline yellow]con[/]", prompt);
+ Assert.DoesNotContain("connect[/]", prompt.Replace("[underline yellow]con[/]nect[/]", string.Empty));
+ }
+
+ [Fact]
+ public void ReverseSearch_FormatSearchPromptMarkup_WithSyntaxHighlighter_RendersValidSpectreMarkup()
+ {
+ var prompt = ReverseHistorySearch.FormatSearchPromptMarkup(
+ "con",
+ "connect \"https://example.com:443/\"",
+ hasMatch: true,
+ isForwardSearch: false,
+ syntaxHighlighter: ShellInterpreter.Instance);
+
+ // The body after the prefix must render as Spectre.Console markup without throwing.
+ var body = prompt["(reverse-i-search)`con`: ".Length..];
+ var ex = Record.Exception(() => new Markup(body).GetSegments(AnsiConsole.Console).ToList());
+ Assert.Null(ex);
+ Assert.Contains("[underline yellow]con[/]", prompt);
+ }
+
+ [Fact]
+ public void ReverseSearch_FormatSearchPromptMarkup_TruncatesToMaxWidthAndKeepsMatchVisible()
+ {
+ const int MaxWidth = 45;
+ var prompt = ReverseHistorySearch.FormatSearchPromptMarkup(
+ "needle",
+ "prefix " + new string('x', 50) + " needle suffix",
+ hasMatch: true,
+ isForwardSearch: false,
+ syntaxHighlighter: ShellInterpreter.Instance,
+ maxWidth: MaxWidth);
+
+ var segments = new Markup(prompt).GetSegments(AnsiConsole.Console).ToList();
+ var text = string.Concat(segments.Select(segment => segment.Text));
+
+ Assert.True(text.Length <= MaxWidth);
+ Assert.Contains("...", text);
+ Assert.Contains("needle", text);
+ Assert.Contains("[underline yellow]needle[/]", prompt);
+ }
+
+ [Fact]
+ public void ReverseSearch_FormatSearchPromptMarkup_TruncatesLongQueryToFitMaxWidth()
+ {
+ const int MaxWidth = 40;
+ var longQuery = new string('a', 100);
+ var prompt = ReverseHistorySearch.FormatSearchPromptMarkup(
+ longQuery,
+ "match",
+ hasMatch: true,
+ isForwardSearch: false,
+ syntaxHighlighter: null,
+ maxWidth: MaxWidth);
+
+ var text = string.Concat(new Markup(prompt).GetSegments(AnsiConsole.Console).Select(s => s.Text));
+
+ Assert.True(text.Length <= MaxWidth, $"Prompt length {text.Length} exceeded max {MaxWidth}: '{text}'");
+ Assert.Contains("...", text);
+ }
+
+ private static LineEditorContext CreateContext(string content, int position)
+ {
+ var buffer = new LineBuffer(content);
+ buffer.Move(position);
+ return new LineEditorContext(buffer, null!);
+ }
+}
diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.Highlighter.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.Highlighter.cs
index 3e112da..de6db4b 100644
--- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.Highlighter.cs
+++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.Highlighter.cs
@@ -27,6 +27,15 @@ public partial class ShellInterpreter : IHighlighter
///
IRenderable IHighlighter.BuildHighlightedText(string text)
+ {
+ return new Markup(this.BuildHighlightedMarkup(text));
+ }
+
+ ///
+ /// Builds the syntax-highlighted Spectre.Console markup string for the given shell input.
+ /// Returns escaped plain text on parse errors.
+ ///
+ internal string BuildHighlightedMarkup(string text)
{
var parser = new StatementParser(text);
Statement? statement = null;
@@ -49,7 +58,8 @@ IRenderable IHighlighter.BuildHighlightedText(string text)
statement.Accept(highlighter);
var result = highlighter.GetResult();
this.oldHighlightStatement = statement;
- return new Markup(result);
+ this.oldHighlightedText = text;
+ return result;
}
catch
{
@@ -66,7 +76,7 @@ IRenderable IHighlighter.BuildHighlightedText(string text)
this.oldHighlightedText = text;
this.oldHighlightStatement.Accept(highlighter);
var result = highlighter.GetResult();
- return new Markup(result);
+ return result;
}
}
catch
@@ -77,12 +87,13 @@ IRenderable IHighlighter.BuildHighlightedText(string text)
#pragma warning restore CZ0001 // Empty Catch Clause
// fall back to non highlighted text in case of any errors.
- return new Markup(Markup.Escape(text));
+ return Markup.Escape(text);
}
private void ClearHighlightStatement()
{
this.oldHighlightStatement = null;
+ this.oldHighlightedText = null;
}
internal class HighlightingVisitor : IAstVisitor
diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs
index 09a9260..7460313 100644
--- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs
+++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs
@@ -8,6 +8,7 @@ namespace Azure.Data.Cosmos.Shell.Core;
using System.Text.Json;
using System.Text.Json.Serialization;
using Azure.Data.Cosmos.Shell.Commands;
+using Azure.Data.Cosmos.Shell.KeyBindings;
using Azure.Data.Cosmos.Shell.Parser;
using Azure.Data.Cosmos.Shell.States;
using Azure.Data.Cosmos.Shell.Util;
@@ -119,6 +120,8 @@ internal static char CSVSeparator
internal string HistoryFile { get; private set; }
+ internal IReadOnlyList History => this.history;
+
internal string? LastBuffer { get; set; }
internal string? OriginalString { get; set; }
@@ -1107,6 +1110,18 @@ private LineEditor CreateLineEditor()
lineEditor.KeyBindings.Add(ConsoleKey.Escape);
lineEditor.KeyBindings.Add(ConsoleKey.L, ConsoleModifiers.Control);
+ lineEditor.KeyBindings.Add(ConsoleKey.A, ConsoleModifiers.Control);
+ lineEditor.KeyBindings.Add(ConsoleKey.E, ConsoleModifiers.Control);
+ lineEditor.KeyBindings.Add(ConsoleKey.U, ConsoleModifiers.Control);
+ lineEditor.KeyBindings.Add(ConsoleKey.K, ConsoleModifiers.Control);
+ lineEditor.KeyBindings.Add(ConsoleKey.W, ConsoleModifiers.Control);
+ lineEditor.KeyBindings.Add(ConsoleKey.P, ConsoleModifiers.Control);
+ lineEditor.KeyBindings.Add(ConsoleKey.N, ConsoleModifiers.Control);
+ lineEditor.KeyBindings.Add(ConsoleKey.B, ConsoleModifiers.Control);
+ lineEditor.KeyBindings.Add(ConsoleKey.F, ConsoleModifiers.Control);
+ lineEditor.KeyBindings.Add(ConsoleKey.D, ConsoleModifiers.Control, () => new ExitShellCommand(this));
+ lineEditor.KeyBindings.Add(ConsoleKey.R, ConsoleModifiers.Control, () => new ReverseSearchHistoryCommand(this));
+ lineEditor.KeyBindings.Add(ConsoleKey.S, ConsoleModifiers.Control, () => new ReverseSearchHistoryCommand(this, startsForward: true));
lineEditor.KeyBindings.Add(ConsoleKey.Tab, () => new CosmosCompleteCommand(this, AutoComplete.Next));
lineEditor.KeyBindings.Add(ConsoleKey.Tab, ConsoleModifiers.Control, () => new CosmosCompleteCommand(this, AutoComplete.Previous));
foreach (var line in this.history)
diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ClearCurrentLineCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ClearCurrentLineCommand.cs
similarity index 90%
rename from CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ClearCurrentLineCommand.cs
rename to CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ClearCurrentLineCommand.cs
index bebff62..c36ff9b 100644
--- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ClearCurrentLineCommand.cs
+++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ClearCurrentLineCommand.cs
@@ -2,7 +2,7 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
//------------------------------------------------------------
-namespace Azure.Data.Cosmos.Shell.Core;
+namespace Azure.Data.Cosmos.Shell.KeyBindings;
using RadLine;
diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ClearScreenCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ClearScreenCommand.cs
similarity index 89%
rename from CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ClearScreenCommand.cs
rename to CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ClearScreenCommand.cs
index d7d96c0..fcd4426 100644
--- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ClearScreenCommand.cs
+++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ClearScreenCommand.cs
@@ -2,7 +2,7 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
//------------------------------------------------------------
-namespace Azure.Data.Cosmos.Shell.Core;
+namespace Azure.Data.Cosmos.Shell.KeyBindings;
using RadLine;
using Spectre.Console;
diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/DeletePreviousWordCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/DeletePreviousWordCommand.cs
new file mode 100644
index 0000000..4442dcf
--- /dev/null
+++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/DeletePreviousWordCommand.cs
@@ -0,0 +1,41 @@
+//------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+//------------------------------------------------------------
+
+namespace Azure.Data.Cosmos.Shell.KeyBindings;
+
+using RadLine;
+
+internal class DeletePreviousWordCommand : LineEditorCommand
+{
+ public override void Execute(LineEditorContext context)
+ {
+ var end = context.Buffer.Position;
+ if (end <= 0)
+ {
+ return;
+ }
+
+ var content = context.Buffer.Content;
+ var start = end;
+
+ while (start > 0 && char.IsWhiteSpace(content[start - 1]))
+ {
+ start--;
+ }
+
+ while (start > 0 && !char.IsWhiteSpace(content[start - 1]))
+ {
+ start--;
+ }
+
+ var length = end - start;
+ if (length <= 0)
+ {
+ return;
+ }
+
+ context.Buffer.Clear(start, length);
+ context.Buffer.Move(start);
+ }
+}
diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/DeleteToEndOfLineCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/DeleteToEndOfLineCommand.cs
new file mode 100644
index 0000000..ccff2ee
--- /dev/null
+++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/DeleteToEndOfLineCommand.cs
@@ -0,0 +1,22 @@
+//------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+//------------------------------------------------------------
+
+namespace Azure.Data.Cosmos.Shell.KeyBindings;
+
+using RadLine;
+
+internal class DeleteToEndOfLineCommand : LineEditorCommand
+{
+ public override void Execute(LineEditorContext context)
+ {
+ var position = context.Buffer.Position;
+ var length = context.Buffer.Length - position;
+ if (length <= 0)
+ {
+ return;
+ }
+
+ context.Buffer.Clear(position, length);
+ }
+}
diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/DeleteToStartOfLineCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/DeleteToStartOfLineCommand.cs
new file mode 100644
index 0000000..273c1e3
--- /dev/null
+++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/DeleteToStartOfLineCommand.cs
@@ -0,0 +1,22 @@
+//------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+//------------------------------------------------------------
+
+namespace Azure.Data.Cosmos.Shell.KeyBindings;
+
+using RadLine;
+
+internal class DeleteToStartOfLineCommand : LineEditorCommand
+{
+ public override void Execute(LineEditorContext context)
+ {
+ var position = context.Buffer.Position;
+ if (position <= 0)
+ {
+ return;
+ }
+
+ context.Buffer.Clear(0, position);
+ context.Buffer.Move(0);
+ }
+}
diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ExitShellCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ExitShellCommand.cs
new file mode 100644
index 0000000..17a4466
--- /dev/null
+++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ExitShellCommand.cs
@@ -0,0 +1,29 @@
+//------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+//------------------------------------------------------------
+
+namespace Azure.Data.Cosmos.Shell.KeyBindings;
+
+using Azure.Data.Cosmos.Shell.Core;
+using RadLine;
+
+internal class ExitShellCommand(ShellInterpreter shell) : LineEditorCommand
+{
+ private readonly ShellInterpreter shell = shell;
+
+ public override void Execute(LineEditorContext context)
+ {
+ if (context.Buffer.Length == 0)
+ {
+ this.shell.IsRunning = false;
+ context.Submit(SubmitAction.Cancel);
+ return;
+ }
+
+ var position = context.Buffer.Position;
+ if (position < context.Buffer.Length)
+ {
+ context.Buffer.Clear(position, 1);
+ }
+ }
+}
diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/MoveCursorLeftCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/MoveCursorLeftCommand.cs
new file mode 100644
index 0000000..5a28971
--- /dev/null
+++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/MoveCursorLeftCommand.cs
@@ -0,0 +1,15 @@
+//------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+//------------------------------------------------------------
+
+namespace Azure.Data.Cosmos.Shell.KeyBindings;
+
+using RadLine;
+
+internal class MoveCursorLeftCommand : LineEditorCommand
+{
+ public override void Execute(LineEditorContext context)
+ {
+ context.Buffer.MoveLeft(1);
+ }
+}
diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/MoveCursorRightCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/MoveCursorRightCommand.cs
new file mode 100644
index 0000000..4692080
--- /dev/null
+++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/MoveCursorRightCommand.cs
@@ -0,0 +1,15 @@
+//------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+//------------------------------------------------------------
+
+namespace Azure.Data.Cosmos.Shell.KeyBindings;
+
+using RadLine;
+
+internal class MoveCursorRightCommand : LineEditorCommand
+{
+ public override void Execute(LineEditorContext context)
+ {
+ context.Buffer.MoveRight(1);
+ }
+}
diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/MoveToEndOfLineCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/MoveToEndOfLineCommand.cs
new file mode 100644
index 0000000..b7474f2
--- /dev/null
+++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/MoveToEndOfLineCommand.cs
@@ -0,0 +1,15 @@
+//------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+//------------------------------------------------------------
+
+namespace Azure.Data.Cosmos.Shell.KeyBindings;
+
+using RadLine;
+
+internal class MoveToEndOfLineCommand : LineEditorCommand
+{
+ public override void Execute(LineEditorContext context)
+ {
+ context.Buffer.MoveEnd();
+ }
+}
diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/MoveToStartOfLineCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/MoveToStartOfLineCommand.cs
new file mode 100644
index 0000000..5616e20
--- /dev/null
+++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/MoveToStartOfLineCommand.cs
@@ -0,0 +1,15 @@
+//------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+//------------------------------------------------------------
+
+namespace Azure.Data.Cosmos.Shell.KeyBindings;
+
+using RadLine;
+
+internal class MoveToStartOfLineCommand : LineEditorCommand
+{
+ public override void Execute(LineEditorContext context)
+ {
+ context.Buffer.MoveHome();
+ }
+}
diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ReverseHistorySearch.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ReverseHistorySearch.cs
new file mode 100644
index 0000000..7da2258
--- /dev/null
+++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ReverseHistorySearch.cs
@@ -0,0 +1,390 @@
+//------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+//------------------------------------------------------------
+
+namespace Azure.Data.Cosmos.Shell.KeyBindings;
+
+using System;
+using System.Collections.Generic;
+using System.Text;
+using Azure.Data.Cosmos.Shell.Core;
+using Azure.Data.Cosmos.Shell.Util;
+using Spectre.Console;
+
+internal static class ReverseHistorySearch
+{
+ public static int FindReverseMatch(IReadOnlyList history, string query, int skipMatches)
+ {
+ if (string.IsNullOrEmpty(query))
+ {
+ return -1;
+ }
+
+ var matchesSeen = 0;
+ for (var i = history.Count - 1; i >= 0; i--)
+ {
+ if (history[i].Contains(query, StringComparison.OrdinalIgnoreCase))
+ {
+ if (matchesSeen == skipMatches)
+ {
+ return i;
+ }
+
+ matchesSeen++;
+ }
+ }
+
+ return -1;
+ }
+
+ public static ReverseHistorySearchResult FindInitialMatch(IReadOnlyList history, string query)
+ {
+ return CreateResult(history, query, skip: 0);
+ }
+
+ public static ReverseHistorySearchResult FindInitialForwardMatch(IReadOnlyList history, string query)
+ {
+ var matchCount = CountMatches(history, query);
+ return matchCount > 0 ? CreateResult(history, query, skip: matchCount - 1) : ReverseHistorySearchResult.None();
+ }
+
+ public static ReverseHistorySearchResult FindNextMatch(IReadOnlyList history, string query, int currentSkip)
+ {
+ var nextSkip = currentSkip + 1;
+ var next = CreateResult(history, query, nextSkip);
+ if (next.HasMatch)
+ {
+ return next;
+ }
+
+ return CreateResult(history, query, skip: 0);
+ }
+
+ public static ReverseHistorySearchResult FindPreviousMatch(IReadOnlyList history, string query, int currentSkip)
+ {
+ var matchCount = CountMatches(history, query);
+ if (matchCount <= 0)
+ {
+ return ReverseHistorySearchResult.None();
+ }
+
+ var previousSkip = currentSkip <= 0 ? matchCount - 1 : currentSkip - 1;
+ return CreateResult(history, query, previousSkip);
+ }
+
+ public static string FormatSearchPrompt(string query, string match, bool hasMatch, bool isForwardSearch = false)
+ {
+ var prefix = GetSearchPrefix(query, hasMatch, isForwardSearch);
+ return $"({prefix})`{query}`: {match}";
+ }
+
+ public static string FormatSearchPromptMarkup(string query, string match, bool hasMatch, bool isForwardSearch = false)
+ {
+ return FormatSearchPromptMarkup(query, match, hasMatch, isForwardSearch, syntaxHighlighter: null);
+ }
+
+ public static string FormatSearchPromptMarkup(string query, string match, bool hasMatch, bool isForwardSearch, ShellInterpreter? syntaxHighlighter)
+ {
+ return FormatSearchPromptMarkup(query, match, hasMatch, isForwardSearch, syntaxHighlighter, maxWidth: null);
+ }
+
+ public static string FormatSearchPromptMarkup(string query, string match, bool hasMatch, bool isForwardSearch, ShellInterpreter? syntaxHighlighter, int? maxWidth)
+ {
+ var prefix = GetSearchPrefix(query, hasMatch, isForwardSearch);
+ var displayedQuery = TruncateQueryForPrompt(query, prefix, maxWidth);
+ var plainPrefix = $"({prefix})`{displayedQuery}`: ";
+ int? maxMatchLength = maxWidth.HasValue ? Math.Max(0, maxWidth.Value - plainPrefix.Length) : null;
+ var renderedMatch = HighlightMatch(TruncateMatch(match, query, hasMatch, maxMatchLength), query, hasMatch, syntaxHighlighter);
+ return $"({prefix})`{Markup.Escape(displayedQuery)}`: {renderedMatch}";
+ }
+
+ ///
+ /// Ensures the rendered prompt prefix `({prefix})`{query}`: ` fits inside
+ /// . If the user's query is too long, the
+ /// returned string is shortened with an ellipsis so the whole prompt
+ /// stays on a single console row ('s
+ /// `ClearLine` only clears one row).
+ ///
+ private static string TruncateQueryForPrompt(string query, string prefix, int? maxWidth)
+ {
+ if (!maxWidth.HasValue)
+ {
+ return query;
+ }
+
+ // Layout: "(" + prefix + ")`" + query + "`: " minimum match budget.
+ const string Ellipsis = "...";
+ const int MinMatchBudget = 1;
+ var fixedOverhead = 1 + prefix.Length + 2 + 3; // "(", prefix, ")`", "`: "
+ var queryBudget = maxWidth.Value - fixedOverhead - MinMatchBudget;
+ if (queryBudget < 0)
+ {
+ queryBudget = 0;
+ }
+
+ if (query.Length <= queryBudget)
+ {
+ return query;
+ }
+
+ if (queryBudget <= Ellipsis.Length)
+ {
+ return query[..queryBudget];
+ }
+
+ return string.Concat(query.AsSpan(0, queryBudget - Ellipsis.Length), Ellipsis);
+ }
+
+ private static string GetSearchPrefix(string query, bool hasMatch, bool isForwardSearch)
+ {
+ if (!string.IsNullOrEmpty(query) && !hasMatch)
+ {
+ return MessageService.GetString(isForwardSearch ? "history-search-failed-forward" : "history-search-failed-reverse");
+ }
+
+ return MessageService.GetString(isForwardSearch ? "history-search-forward" : "history-search-reverse");
+ }
+
+ private static string TruncateMatch(string match, string query, bool hasMatch, int? maxLength)
+ {
+ const string Marker = "...";
+
+ if (!maxLength.HasValue || match.Length <= maxLength.Value)
+ {
+ return match;
+ }
+
+ if (maxLength.Value <= 0)
+ {
+ return string.Empty;
+ }
+
+ if (maxLength.Value <= Marker.Length)
+ {
+ return match[..maxLength.Value];
+ }
+
+ var contentLength = maxLength.Value - Marker.Length;
+ if (!hasMatch || string.IsNullOrEmpty(query))
+ {
+ return match[..contentLength] + Marker;
+ }
+
+ var hit = match.IndexOf(query, StringComparison.OrdinalIgnoreCase);
+ if (hit < 0 || hit <= contentLength)
+ {
+ return match[..contentLength] + Marker;
+ }
+
+ var start = Math.Min(hit, match.Length - contentLength);
+ return Marker + match.Substring(start, contentLength);
+ }
+
+ private static string HighlightMatch(string match, string query, bool hasMatch, ShellInterpreter? syntaxHighlighter)
+ {
+ if (string.IsNullOrEmpty(match))
+ {
+ return string.Empty;
+ }
+
+ var styledMarkup = syntaxHighlighter != null
+ ? SafeBuildHighlightedMarkup(syntaxHighlighter, match)
+ : Markup.Escape(match);
+
+ if (!hasMatch || string.IsNullOrEmpty(query))
+ {
+ return styledMarkup;
+ }
+
+ var ranges = FindAllMatches(match, query);
+ if (ranges.Count == 0)
+ {
+ return styledMarkup;
+ }
+
+ return OverlayUnderline(styledMarkup, ranges);
+ }
+
+ private static string SafeBuildHighlightedMarkup(ShellInterpreter highlighter, string text)
+ {
+ try
+ {
+ return highlighter.BuildHighlightedMarkup(text);
+ }
+ catch (Exception)
+ {
+ return Markup.Escape(text);
+ }
+ }
+
+ private static List<(int Start, int End)> FindAllMatches(string match, string query)
+ {
+ var ranges = new List<(int, int)>();
+ var index = 0;
+ while (index < match.Length)
+ {
+ var hit = match.IndexOf(query, index, StringComparison.OrdinalIgnoreCase);
+ if (hit < 0)
+ {
+ break;
+ }
+
+ ranges.Add((hit, hit + query.Length));
+ index = hit + query.Length;
+ }
+
+ return ranges;
+ }
+
+ ///
+ /// Walks a Spectre.Console markup string and wraps the plain-text positions in
+ /// with [underline yellow]...[/]. Underline is closed before any markup tag boundary and reopened on the
+ /// other side so that the output remains well-nested.
+ ///
+ private static string OverlayUnderline(string markup, IReadOnlyList<(int Start, int End)> ranges)
+ {
+ const string OpenUnderline = "[underline yellow]";
+ const string CloseUnderline = "[/]";
+
+ var sb = new StringBuilder(markup.Length + (ranges.Count * (OpenUnderline.Length + CloseUnderline.Length)));
+ var plainIndex = 0;
+ var inUnderline = false;
+ var i = 0;
+
+ bool ShouldUnderline(int p)
+ {
+ foreach (var r in ranges)
+ {
+ if (p >= r.Start && p < r.End)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ while (i < markup.Length)
+ {
+ var c = markup[i];
+
+ if (c == '[' && i + 1 < markup.Length && markup[i + 1] == '[')
+ {
+ // Escaped literal '['
+ if (!inUnderline && ShouldUnderline(plainIndex))
+ {
+ sb.Append(OpenUnderline);
+ inUnderline = true;
+ }
+
+ sb.Append("[[");
+ i += 2;
+ plainIndex++;
+ if (inUnderline && !ShouldUnderline(plainIndex))
+ {
+ sb.Append(CloseUnderline);
+ inUnderline = false;
+ }
+
+ continue;
+ }
+
+ if (c == ']' && i + 1 < markup.Length && markup[i + 1] == ']')
+ {
+ // Escaped literal ']'
+ if (!inUnderline && ShouldUnderline(plainIndex))
+ {
+ sb.Append(OpenUnderline);
+ inUnderline = true;
+ }
+
+ sb.Append("]]");
+ i += 2;
+ plainIndex++;
+ if (inUnderline && !ShouldUnderline(plainIndex))
+ {
+ sb.Append(CloseUnderline);
+ inUnderline = false;
+ }
+
+ continue;
+ }
+
+ if (c == '[')
+ {
+ // Markup tag (open or close). Close underline before the tag, copy verbatim, reopen after if still in match.
+ if (inUnderline)
+ {
+ sb.Append(CloseUnderline);
+ inUnderline = false;
+ }
+
+ var tagEnd = markup.IndexOf(']', i + 1);
+ if (tagEnd < 0)
+ {
+ // Malformed; copy rest verbatim.
+ sb.Append(markup, i, markup.Length - i);
+ return sb.ToString();
+ }
+
+ sb.Append(markup, i, tagEnd - i + 1);
+ i = tagEnd + 1;
+ if (ShouldUnderline(plainIndex))
+ {
+ sb.Append(OpenUnderline);
+ inUnderline = true;
+ }
+
+ continue;
+ }
+
+ // Plain character
+ if (!inUnderline && ShouldUnderline(plainIndex))
+ {
+ sb.Append(OpenUnderline);
+ inUnderline = true;
+ }
+
+ sb.Append(c);
+ i++;
+ plainIndex++;
+ if (inUnderline && !ShouldUnderline(plainIndex))
+ {
+ sb.Append(CloseUnderline);
+ inUnderline = false;
+ }
+ }
+
+ if (inUnderline)
+ {
+ sb.Append(CloseUnderline);
+ }
+
+ return sb.ToString();
+ }
+
+ private static int CountMatches(IReadOnlyList history, string query)
+ {
+ if (string.IsNullOrEmpty(query))
+ {
+ return 0;
+ }
+
+ var count = 0;
+ foreach (var item in history)
+ {
+ if (item.Contains(query, StringComparison.OrdinalIgnoreCase))
+ {
+ count++;
+ }
+ }
+
+ return count;
+ }
+
+ private static ReverseHistorySearchResult CreateResult(IReadOnlyList history, string query, int skip)
+ {
+ var index = FindReverseMatch(history, query, skip);
+ return index >= 0 ? new ReverseHistorySearchResult(index, skip, history[index]) : ReverseHistorySearchResult.None(skip);
+ }
+}
diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ReverseHistorySearchResult.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ReverseHistorySearchResult.cs
new file mode 100644
index 0000000..93f50ea
--- /dev/null
+++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ReverseHistorySearchResult.cs
@@ -0,0 +1,12 @@
+//------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+//------------------------------------------------------------
+
+namespace Azure.Data.Cosmos.Shell.KeyBindings;
+
+internal readonly record struct ReverseHistorySearchResult(int Index, int Skip, string Match)
+{
+ public bool HasMatch => this.Index >= 0;
+
+ public static ReverseHistorySearchResult None(int skip = 0) => new(-1, skip, string.Empty);
+}
diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ReverseSearchHistoryCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ReverseSearchHistoryCommand.cs
new file mode 100644
index 0000000..18ea7e2
--- /dev/null
+++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ReverseSearchHistoryCommand.cs
@@ -0,0 +1,210 @@
+//------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+//------------------------------------------------------------
+
+namespace Azure.Data.Cosmos.Shell.KeyBindings;
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using Azure.Data.Cosmos.Shell.Core;
+using RadLine;
+using Spectre.Console;
+
+internal class ReverseSearchHistoryCommand(ShellInterpreter shell, bool startsForward = false) : LineEditorCommand
+{
+ private readonly ShellInterpreter shell = shell;
+ private readonly bool startsForward = startsForward;
+
+ public override void Execute(LineEditorContext context)
+ {
+ var originalContent = context.Buffer.Content;
+ var originalPosition = context.Buffer.Position;
+ var query = string.Empty;
+ var result = ReverseHistorySearchResult.None();
+ var accepted = false;
+ var isForwardSearch = this.startsForward;
+ var promptRow = TryGetCursorTop();
+ var restoreControlCHandling = TrySetTreatControlCAsInput(true, out var originalTreatControlCAsInput);
+
+ try
+ {
+ Render(query, result, isForwardSearch, promptRow, this.shell);
+
+ while (true)
+ {
+ var key = Console.ReadKey(intercept: true);
+
+ if (key.Key == ConsoleKey.Enter)
+ {
+ accepted = result.HasMatch;
+ break;
+ }
+
+ if (key.Key == ConsoleKey.Escape ||
+ (key.Modifiers.HasFlag(ConsoleModifiers.Control) && key.Key == ConsoleKey.G) ||
+ (key.Modifiers.HasFlag(ConsoleModifiers.Control) && key.Key == ConsoleKey.C))
+ {
+ break;
+ }
+
+ if (key.Modifiers.HasFlag(ConsoleModifiers.Control) && key.Key == ConsoleKey.R)
+ {
+ isForwardSearch = false;
+ if (result.HasMatch)
+ {
+ result = ReverseHistorySearch.FindNextMatch(this.shell.History, query, result.Skip);
+ }
+
+ Render(query, result, isForwardSearch, promptRow, this.shell);
+ continue;
+ }
+
+ if (key.Modifiers.HasFlag(ConsoleModifiers.Control) && key.Key == ConsoleKey.S)
+ {
+ isForwardSearch = true;
+ if (result.HasMatch)
+ {
+ result = ReverseHistorySearch.FindPreviousMatch(this.shell.History, query, result.Skip);
+ }
+
+ Render(query, result, isForwardSearch, promptRow, this.shell);
+ continue;
+ }
+
+ if (key.Key == ConsoleKey.Backspace)
+ {
+ if (query.Length > 0)
+ {
+ query = query[..^1];
+ result = FindInitialMatch(this.shell.History, query, isForwardSearch);
+ }
+
+ Render(query, result, isForwardSearch, promptRow, this.shell);
+ continue;
+ }
+
+ if (!char.IsControl(key.KeyChar))
+ {
+ query += key.KeyChar;
+ result = FindInitialMatch(this.shell.History, query, isForwardSearch);
+ Render(query, result, isForwardSearch, promptRow, this.shell);
+ continue;
+ }
+ }
+ }
+ finally
+ {
+ if (restoreControlCHandling)
+ {
+ TrySetTreatControlCAsInput(originalTreatControlCAsInput, out _);
+ }
+
+ ClearLine(promptRow);
+ }
+
+ var newContent = accepted ? result.Match : originalContent;
+ context.Buffer.Clear(0, context.Buffer.Length);
+ context.Buffer.Move(0);
+ if (newContent.Length > 0)
+ {
+ context.Buffer.Insert(newContent);
+ }
+
+ context.Buffer.Move(accepted ? newContent.Length : Math.Min(originalPosition, newContent.Length));
+ }
+
+ private static ReverseHistorySearchResult FindInitialMatch(IReadOnlyList history, string query, bool isForwardSearch)
+ {
+ return isForwardSearch ? ReverseHistorySearch.FindInitialForwardMatch(history, query) : ReverseHistorySearch.FindInitialMatch(history, query);
+ }
+
+ private static void Render(string query, ReverseHistorySearchResult result, bool isForwardSearch, int? promptRow, ShellInterpreter shell)
+ {
+ ClearLine(promptRow);
+ AnsiConsole.Markup(ReverseHistorySearch.FormatSearchPromptMarkup(query, result.Match, result.HasMatch, isForwardSearch, shell, TryGetLineWidth()));
+ }
+
+ private static void ClearLine(int? promptRow)
+ {
+ try
+ {
+ var width = TryGetLineWidth();
+ if (!width.HasValue || width.Value <= 0)
+ {
+ Console.Write('\r');
+ return;
+ }
+
+ var row = promptRow ?? Console.CursorTop;
+ Console.SetCursorPosition(0, row);
+ Console.Write(new string(' ', width.Value));
+ Console.SetCursorPosition(0, row);
+ }
+ catch (IOException)
+ {
+ Console.Write('\r');
+ }
+ catch (ArgumentOutOfRangeException)
+ {
+ Console.Write('\r');
+ }
+ catch (InvalidOperationException)
+ {
+ Console.Write('\r');
+ }
+ }
+
+ private static int? TryGetCursorTop()
+ {
+ try
+ {
+ return Console.CursorTop;
+ }
+ catch (IOException)
+ {
+ return null;
+ }
+ catch (InvalidOperationException)
+ {
+ return null;
+ }
+ }
+
+ private static int? TryGetLineWidth()
+ {
+ try
+ {
+ var width = Console.WindowWidth > 0 ? Console.WindowWidth : Console.BufferWidth;
+ return width > 1 ? width - 1 : null;
+ }
+ catch (IOException)
+ {
+ return null;
+ }
+ catch (InvalidOperationException)
+ {
+ return null;
+ }
+ }
+
+ private static bool TrySetTreatControlCAsInput(bool value, out bool originalValue)
+ {
+ try
+ {
+ originalValue = Console.TreatControlCAsInput;
+ Console.TreatControlCAsInput = value;
+ return true;
+ }
+ catch (IOException)
+ {
+ originalValue = false;
+ return false;
+ }
+ catch (InvalidOperationException)
+ {
+ originalValue = false;
+ return false;
+ }
+ }
+}
diff --git a/CosmosDBShell/lang/en.ftl b/CosmosDBShell/lang/en.ftl
index 2fd2073..380d106 100644
--- a/CosmosDBShell/lang/en.ftl
+++ b/CosmosDBShell/lang/en.ftl
@@ -10,6 +10,10 @@ shell-connect-static-token-expiry = Expires in { $timespan } (expiration: { $exp
shell-connect-vscode-credential-auth = Connecting with Visual Studio Code credential...
shell-connect-vscode-credential-fallback = Visual Studio Code credential unavailable, falling back...
shell-connect-devicecode-fallback = Browser authentication failed, falling back to device code authentication...
+history-search-reverse = reverse-i-search
+history-search-forward = forward-i-search
+history-search-failed-reverse = failed reverse-i-search
+history-search-failed-forward = failed forward-i-search
yes_char = Y
no_char = N
diff --git a/docs/navigation.md b/docs/navigation.md
index 1382487..044182f 100644
--- a/docs/navigation.md
+++ b/docs/navigation.md
@@ -179,6 +179,27 @@ These commands accept and process piped JSON:
| `jq` | Filters/transforms piped JSON |
| `ftab` | Formats piped JSON as table |
+## Keyboard Shortcuts
+
+Available at the interactive prompt:
+
+| Shortcut | Action |
+| -------- | ------ |
+| `Up` / `Down` | Previous / next history entry |
+| `Ctrl+P` / `Ctrl+N` | Previous / next history entry |
+| `Ctrl+B` / `Ctrl+F` | Move cursor left / right |
+| `Tab` / `Ctrl+Tab` | Next / previous completion |
+| `Esc` | Clear current line |
+| `Ctrl+L` | Clear screen |
+| `Ctrl+A` | Move cursor to start of line |
+| `Ctrl+E` | Move cursor to end of line |
+| `Ctrl+U` | Delete text before cursor |
+| `Ctrl+K` | Delete text after cursor |
+| `Ctrl+W` | Delete previous word |
+| `Ctrl+D` | Exit shell when prompt is empty; otherwise delete character under cursor |
+| `Ctrl+R` | Reverse search history (type to filter, repeat for older matches, Enter accepts, Esc/Ctrl+G/Ctrl+C cancels) |
+| `Ctrl+S` | Forward search history (type to filter, repeat for newer matches, Enter accepts, Esc/Ctrl+G/Ctrl+C cancels) |
+
## CLI Arguments
Start the shell with options to customize behavior: