diff --git a/PSReadLine/Cmdlets.cs b/PSReadLine/Cmdlets.cs
index 7771fa920..222185602 100644
--- a/PSReadLine/Cmdlets.cs
+++ b/PSReadLine/Cmdlets.cs
@@ -142,7 +142,7 @@ public class PSConsoleReadLineOptions
         public const int DefaultCompletionQueryItems = 100;
 
         // Default includes all characters PowerShell treats like a dash - em dash, en dash, horizontal bar
-        public const string DefaultWordDelimiters = @";:,.[]{}()/\|^&*-=+'""" + "\u2013\u2014\u2015";
+        public const string DefaultWordDelimiters = @";:,.[]{}()/\|!?^&*-=+'""" + "\u2013\u2014\u2015";
 
         /// <summary>
         /// When ringing the bell, what should be done?
diff --git a/PSReadLine/KeyBindings.vi.cs b/PSReadLine/KeyBindings.vi.cs
index 9d051ec02..5a47c6608 100644
--- a/PSReadLine/KeyBindings.vi.cs
+++ b/PSReadLine/KeyBindings.vi.cs
@@ -45,6 +45,8 @@ internal static ConsoleColor AlternateBackground(ConsoleColor bg)
         private static Dictionary<PSKeyInfo, KeyHandler> _viChordYTable;
         private static Dictionary<PSKeyInfo, KeyHandler> _viChordDGTable;
 
+        private static Dictionary<PSKeyInfo, KeyHandler> _viChordTextObjectsTable;
+
         private static Dictionary<PSKeyInfo, Dictionary<PSKeyInfo, KeyHandler>> _viCmdChordTable;
         private static Dictionary<PSKeyInfo, Dictionary<PSKeyInfo, KeyHandler>> _viInsChordTable;
 
@@ -238,6 +240,7 @@ private void SetDefaultViBindings()
                 { Keys.ucG,             MakeKeyHandler( DeleteEndOfBuffer,            "DeleteEndOfBuffer") },
                 { Keys.ucE,             MakeKeyHandler( ViDeleteEndOfGlob,            "ViDeleteEndOfGlob") },
                 { Keys.H,               MakeKeyHandler( BackwardDeleteChar,           "BackwardDeleteChar") },
+                { Keys.I,               MakeKeyHandler( ViChordDeleteTextObject,      "ChordViTextObject") },               
                 { Keys.J,               MakeKeyHandler( DeleteNextLines,              "DeleteNextLines") },
                 { Keys.K,               MakeKeyHandler( DeletePreviousLines,          "DeletePreviousLines") },
                 { Keys.L,               MakeKeyHandler( DeleteChar,                   "DeleteChar") },
@@ -296,6 +299,11 @@ private void SetDefaultViBindings()
                 { Keys.Percent,         MakeKeyHandler( ViYankPercent,         "ViYankPercent") },
             };
 
+            _viChordTextObjectsTable = new Dictionary<PSKeyInfo, KeyHandler>
+            {
+                { Keys.W,               MakeKeyHandler(ViHandleTextObject,     "WordTextObject")},
+            };
+            
             _viChordDGTable = new Dictionary<PSKeyInfo, KeyHandler>
             {
                 { Keys.G,               MakeKeyHandler( DeleteRelativeLines,   "DeleteRelativeLines") },
diff --git a/PSReadLine/Position.cs b/PSReadLine/Position.cs
index 32068da91..2aa32039c 100644
--- a/PSReadLine/Position.cs
+++ b/PSReadLine/Position.cs
@@ -102,23 +102,14 @@ private static int GetFirstNonBlankOfLogicalLinePos(int current)
             var beginningOfLine = GetBeginningOfLinePos(current);
 
             var newCurrent = beginningOfLine;
+            var buffer = _singleton._buffer;
 
-            while (newCurrent < _singleton._buffer.Length && IsVisibleBlank(newCurrent))
+            while (newCurrent < buffer.Length && buffer.IsVisibleBlank(newCurrent))
             {
                 newCurrent++;
             }
 
             return newCurrent;
         }
-
-        private static bool IsVisibleBlank(int newCurrent)
-        {
-            var c = _singleton._buffer[newCurrent];
-
-            // [:blank:] of vim's pattern matching behavior
-            // defines blanks as SPACE and TAB characters.
-
-            return c == ' ' || c == '\t';
-        }
     }
 }
diff --git a/PSReadLine/Prediction.Views.cs b/PSReadLine/Prediction.Views.cs
index a2770eca4..a9145c432 100644
--- a/PSReadLine/Prediction.Views.cs
+++ b/PSReadLine/Prediction.Views.cs
@@ -1513,12 +1513,12 @@ internal int FindForwardSuggestionWordPoint(int currentIndex, string wordDelimit
                 }
 
                 int i = currentIndex;
-                if (!_singleton.InWord(_suggestionText[i], wordDelimiters))
+                if (!Character.IsInWord(_suggestionText[i], wordDelimiters))
                 {
                     // Scan to end of current non-word region
                     while (++i < _suggestionText.Length)
                     {
-                        if (_singleton.InWord(_suggestionText[i], wordDelimiters))
+                        if (Character.IsInWord(_suggestionText[i], wordDelimiters))
                         {
                             break;
                         }
@@ -1529,7 +1529,7 @@ internal int FindForwardSuggestionWordPoint(int currentIndex, string wordDelimit
                 {
                     while (++i < _suggestionText.Length)
                     {
-                        if (!_singleton.InWord(_suggestionText[i], wordDelimiters))
+                        if (!Character.IsInWord(_suggestionText[i], wordDelimiters))
                         {
                             if (_suggestionText[i] == ' ')
                             {
diff --git a/PSReadLine/StringBuilderCharacterExtensions.cs b/PSReadLine/StringBuilderCharacterExtensions.cs
new file mode 100644
index 000000000..ab3faaea3
--- /dev/null
+++ b/PSReadLine/StringBuilderCharacterExtensions.cs
@@ -0,0 +1,78 @@
+using System.Text;
+
+namespace Microsoft.PowerShell
+{
+    internal static class StringBuilderCharacterExtensions
+    {
+        /// <summary>
+        /// Returns true if the character at the specified position is a visible whitespace character.
+        /// A blank character is defined as a SPACE or a TAB.
+        /// </summary>
+        /// <param name="buffer"></param>
+        /// <param name="i"></param>
+        /// <returns></returns>
+        public static bool IsVisibleBlank(this StringBuilder buffer, int i)
+        {
+            var c = buffer[i];
+
+            // [:blank:] of vim's pattern matching behavior
+            // defines blanks as SPACE and TAB characters.
+
+            return c == ' ' || c == '\t';
+        }
+
+        /// <summary>
+        /// Returns true if the character at the specified position is
+        /// not present in a list of word-delimiter characters.
+        /// </summary>
+        /// <param name="buffer"></param>
+        /// <param name="i"></param>
+        /// <param name="wordDelimiters"></param>
+        /// <returns></returns>
+        public static bool InWord(this StringBuilder buffer, int i, string wordDelimiters)
+        {
+            return Character.IsInWord(buffer[i], wordDelimiters);
+        }
+
+        /// <summary>
+        /// Returns true if the character at the specified position is
+        /// at the end of the buffer
+        /// </summary>
+        /// <param name="buffer"></param>
+        /// <param name="i"></param>
+        /// <returns></returns>
+        public static bool IsAtEndOfBuffer(this StringBuilder buffer, int i)
+        {
+            return i >= (buffer.Length - 1);
+        }
+
+        /// <summary>
+        /// Returns true if the character at the specified position is
+        /// a unicode whitespace character.
+        /// </summary>
+        /// <param name="buffer"></param>
+        /// <param name="i"></param>
+        /// <returns></returns>
+        public static bool IsWhiteSpace(this StringBuilder buffer, int i)
+        {
+            // Treat just beyond the end of buffer as whitespace because
+            // it looks like whitespace to the user even though they haven't
+            // entered a character yet.
+            return i >= buffer.Length || char.IsWhiteSpace(buffer[i]);
+        }
+    }
+
+    public static class Character
+    {
+        /// <summary>
+        /// Returns true if the character not present in a list of word-delimiter characters.
+        /// </summary>
+        /// <param name="c"></param>
+        /// <param name="wordDelimiters"></param>
+        /// <returns></returns>
+        public static bool IsInWord(char c, string wordDelimiters)
+        {
+            return !char.IsWhiteSpace(c) && wordDelimiters.IndexOf(c) < 0;
+        }
+    }
+}
diff --git a/PSReadLine/StringBuilderExtensions.cs b/PSReadLine/StringBuilderLinewiseExtensions.cs
similarity index 72%
rename from PSReadLine/StringBuilderExtensions.cs
rename to PSReadLine/StringBuilderLinewiseExtensions.cs
index 08deef333..40320a97d 100644
--- a/PSReadLine/StringBuilderExtensions.cs
+++ b/PSReadLine/StringBuilderLinewiseExtensions.cs
@@ -72,6 +72,26 @@ internal static Range GetRange(this StringBuilder buffer, int lineIndex, int lin
                 endPosition - startPosition + 1
                 );
         }
+
+        /// <summary>
+        /// Returns true if the specified position is on an empty logical line.
+        /// </summary>
+        /// <param name="buffer"></param>
+        /// <param name="cursor"></param>
+        /// <returns></returns>
+        public static bool IsLogigalLineEmpty(this StringBuilder buffer, int cursor)
+        {
+            // the cursor is on a logical line considered empty if...
+            return 
+                // the entire buffer is empty (by definition),
+                buffer.Length == 0 ||
+                // or the cursor sits at the start of the empty last line,
+                // meaning that it is past the end of the buffer and the
+                // last character in the buffer is a newline character,
+                (cursor == buffer.Length && buffer[cursor - 1] == '\n') ||
+                // or if the cursor is on a newline character.
+                (cursor > 0 && buffer[cursor] == '\n');
+        }
     }
 
     internal static class StringBuilderPredictionExtensions
diff --git a/PSReadLine/StringBuilderTextObjectExtensions.cs b/PSReadLine/StringBuilderTextObjectExtensions.cs
new file mode 100644
index 000000000..421ab3454
--- /dev/null
+++ b/PSReadLine/StringBuilderTextObjectExtensions.cs
@@ -0,0 +1,113 @@
+using System;
+using System.Text;
+
+namespace Microsoft.PowerShell
+{
+    internal static class StringBuilderTextObjectExtensions
+    {
+        private const string WhiteSpace = " \n\t";
+
+        /// <summary>
+        /// Returns the position of the beginning of the current word as delimited by white space and delimiters
+        /// This method differs from <see cref="ViFindPreviousWordPoint(string)"/>:
+        /// - When the cursor location is on the first character of a word, <see cref="ViFindPreviousWordPoint(string)"/>
+        ///   returns the position of the previous word, whereas this method returns the cursor location.
+        /// - When the cursor location is in a word, both methods return the same result.
+        /// This method supports VI "iw" text object.
+        /// </summary>
+        public static int ViFindBeginningOfWordObjectBoundary(this StringBuilder buffer, int position, string wordDelimiters)
+        {
+            // Cursor may be past the end of the buffer when calling this method
+            // this may happen if the cursor is at the beginning of a new line.
+            var i = Math.Min(position, buffer.Length - 1);
+
+            // If starting on a word consider a text object as a sequence of characters excluding the delimiters,
+            // otherwise, consider a word as a sequence of delimiters.
+            var delimiters = wordDelimiters;
+            var isInWord = buffer.InWord(i, wordDelimiters);
+
+            if (isInWord)
+            {
+                // For the purpose of this method, whitespace character is considered a delimiter.
+                delimiters += WhiteSpace;
+            }
+            else
+            {
+                char c = buffer[i];
+                if ((wordDelimiters + '\n').IndexOf(c) == -1 && char.IsWhiteSpace(c))
+                {
+                    // Current position points to a whitespace that is not a newline.
+                    delimiters = WhiteSpace;
+                }
+                else
+                {
+                    delimiters += '\n';
+                }
+            }
+
+            var isTextObjectChar = isInWord
+                ? (Func<char, bool>)(c => delimiters.IndexOf(c) == -1)
+                : c => delimiters.IndexOf(c) != -1;
+
+            var beginning = i;
+            while (i >= 0 && isTextObjectChar(buffer[i]))
+            {
+                beginning = i--;
+            }
+
+            return beginning;
+        }
+
+        /// <summary>
+        /// Finds the position of the beginning of the next word object starting from the specified position.
+        /// If positioned on the last word in the buffer, returns buffer length + 1.
+        /// This method supports VI "iw" text-object.
+        /// iw: "inner word", select words. White space between words is counted too.
+        /// </summary>
+        public static int ViFindBeginningOfNextWordObjectBoundary(this StringBuilder buffer, int position, string wordDelimiters)
+        {
+            // Cursor may be past the end of the buffer when calling this method
+            // this may happen if the cursor is at the beginning of a new line.
+            var i = Math.Min(position, buffer.Length - 1);
+
+            // Always skip the first newline character.
+            if (buffer[i] == '\n' && i < buffer.Length - 1)
+            {
+                ++i;
+            }
+
+            // If starting on a word consider a text object as a sequence of characters excluding the delimiters,
+            // otherwise, consider a word as a sequence of delimiters.
+            var delimiters = wordDelimiters;
+            var isInWord = buffer.InWord(i, wordDelimiters);
+
+            if (isInWord)
+            {
+                delimiters += WhiteSpace;
+            }
+            else if (char.IsWhiteSpace(buffer[i]))
+            {
+                delimiters = " \t";
+            }
+
+            var isTextObjectChar = isInWord
+                ? (Func<char, bool>)(c => delimiters.IndexOf(c) == -1)
+                : c => delimiters.IndexOf(c) != -1;
+
+            // Try to skip a second newline characters to replicate vim behaviour.
+            if (buffer[i] == '\n' && i < buffer.Length - 1)
+            {
+                ++i;
+            }
+
+            // Skip to next non-word characters.
+            while (i < buffer.Length && isTextObjectChar(buffer[i]))
+            {
+                ++i;
+            }
+
+            // Make sure end includes the starting position.
+            return Math.Max(i, position);
+        }
+    }
+}
diff --git a/PSReadLine/TextObjects.Vi.cs b/PSReadLine/TextObjects.Vi.cs
new file mode 100644
index 000000000..ea9810fbc
--- /dev/null
+++ b/PSReadLine/TextObjects.Vi.cs
@@ -0,0 +1,181 @@
+using System;
+using System.Collections.Generic;
+
+namespace Microsoft.PowerShell
+{
+    public partial class PSConsoleReadLine
+    {
+        internal enum TextObjectOperation
+        {
+            None,
+            Change,
+            Delete,
+        }
+
+        internal enum TextObjectSpan
+        {
+            None,
+            Around,
+            Inner,
+        }
+
+        private TextObjectOperation _textObjectOperation = TextObjectOperation.None;
+        private TextObjectSpan _textObjectSpan = TextObjectSpan.None;
+
+        private readonly Dictionary<TextObjectOperation, Dictionary<TextObjectSpan, KeyHandler>> _textObjectHandlers = new()
+        {
+            [TextObjectOperation.Delete] = new() { [TextObjectSpan.Inner] = MakeKeyHandler(ViDeleteInnerWord, "ViDeleteInnerWord") },
+        };
+
+        private void ViChordDeleteTextObject(ConsoleKeyInfo? key = null, object arg = null)
+        {
+            _textObjectOperation = TextObjectOperation.Delete;
+            ViChordTextObject(key, arg);
+        }
+
+        private void ViChordTextObject(ConsoleKeyInfo? key = null, object arg = null)
+        {
+            if (!key.HasValue)
+            {
+                ResetTextObjectState();
+                throw new ArgumentNullException(nameof(key));
+            }
+
+            _textObjectSpan = GetRequestedTextObjectSpan(key.Value);
+
+            // Handle text object
+            var textObjectKey = ReadKey();
+            if (_viChordTextObjectsTable.TryGetValue(textObjectKey, out _))
+            {
+                _singleton.ProcessOneKey(textObjectKey, _viChordTextObjectsTable, ignoreIfNoAction: true, arg: arg);
+            }
+            else
+            {
+                ResetTextObjectState();
+                Ding();
+            }
+        }
+
+        private TextObjectSpan GetRequestedTextObjectSpan(ConsoleKeyInfo key)
+        {
+            if (key.KeyChar == 'i')
+            {
+                return TextObjectSpan.Inner;
+            }
+            else if (key.KeyChar == 'a')
+            {
+                return TextObjectSpan.Around;
+            }
+            else
+            {
+                System.Diagnostics.Debug.Assert(false);
+                throw new NotSupportedException();
+            }
+        }
+
+        private static void ViHandleTextObject(ConsoleKeyInfo? key = null, object arg = null)
+        {
+            if (!_singleton._textObjectHandlers.TryGetValue(_singleton._textObjectOperation, out var textObjectHandler) ||
+                !textObjectHandler.TryGetValue(_singleton._textObjectSpan, out var handler))
+            {
+                ResetTextObjectState();
+                Ding();
+                return;
+            }
+
+            handler.Action(key, arg);
+        }
+
+        private static void ResetTextObjectState()
+        {
+            _singleton._textObjectOperation = TextObjectOperation.None;
+            _singleton._textObjectSpan = TextObjectSpan.None;
+        }
+
+        private static void ViDeleteInnerWord(ConsoleKeyInfo? key = null, object arg = null)
+        {
+            var delimiters = _singleton.Options.WordDelimiters;
+
+            if (!TryGetArgAsInt(arg, out var numericArg, 1))
+            {
+                return;
+            }
+
+            if (_singleton._buffer.Length == 0)
+            {
+                if (numericArg > 1)
+                {
+                    Ding();
+                }
+                return;
+            }
+
+            // Unless at the end of the buffer a single delete word should not delete backwards
+            // so if the cursor is on an empty line, do nothing.
+            if (numericArg == 1 &&
+                _singleton._current < _singleton._buffer.Length &&
+                _singleton._buffer.IsLogigalLineEmpty(_singleton._current))
+            {
+                return;
+            }
+
+            var start = _singleton._buffer.ViFindBeginningOfWordObjectBoundary(_singleton._current, delimiters);
+            var end = _singleton._current;
+
+            // Attempting to find a valid position for multiple words.
+            // If no valid position is found, this is a no-op
+            {
+                while (numericArg-- > 0 && end < _singleton._buffer.Length)
+                {
+                    end = _singleton._buffer.ViFindBeginningOfNextWordObjectBoundary(end, delimiters);
+                }
+
+                // Attempting to delete too many words should ding.
+                if (numericArg > 0)
+                {
+                    Ding();
+                    return;
+                }
+            }
+
+            if (end > 0 && _singleton._buffer.IsAtEndOfBuffer(end - 1) && _singleton._buffer.InWord(end - 1, delimiters))
+            {
+                _singleton._shouldAppend = true;
+            }
+
+            _singleton.RemoveTextToViRegister(start, end - start);
+            _singleton.AdjustCursorPosition(start);
+            _singleton.Render();
+        }
+
+        /// <summary>
+        /// Attempt to set the cursor at the specified position.
+        /// </summary>
+        /// <param name="position"></param>
+        /// <returns></returns>
+        private int AdjustCursorPosition(int position)
+        {
+            // This method might prove useful in a more general case.
+            if (_buffer.Length == 0)
+            {
+                _current = 0;
+                return 0;
+            }
+
+            var maxPosition = _buffer[_buffer.Length - 1] == '\n'
+                ? _buffer.Length
+                : _buffer.Length - 1;
+
+            var newCurrent = Math.Min(position, maxPosition);
+            var beginning = GetBeginningOfLinePos(newCurrent);
+
+            if (newCurrent < _buffer.Length && _buffer[newCurrent] == '\n' && (newCurrent + ViEndOfLineFactor > beginning))
+            {
+                newCurrent += ViEndOfLineFactor;
+            }
+
+            _current = newCurrent;
+            return newCurrent;
+        }
+    }
+}
diff --git a/PSReadLine/Words.cs b/PSReadLine/Words.cs
index 5c4c09f67..7bdc34a88 100644
--- a/PSReadLine/Words.cs
+++ b/PSReadLine/Words.cs
@@ -90,13 +90,7 @@ private Token FindToken(int current, FindTokenMode mode)
 
         private bool InWord(int index, string wordDelimiters)
         {
-            char c = _buffer[index];
-            return InWord(c, wordDelimiters);
-        }
-
-        private bool InWord(char c, string wordDelimiters)
-        {
-            return !char.IsWhiteSpace(c) && wordDelimiters.IndexOf(c) < 0;
+            return _buffer.InWord(index, wordDelimiters);
         }
 
         /// <summary>
diff --git a/PSReadLine/Words.vi.cs b/PSReadLine/Words.vi.cs
index 8ba987bae..5a475c19f 100644
--- a/PSReadLine/Words.vi.cs
+++ b/PSReadLine/Words.vi.cs
@@ -2,6 +2,8 @@
 Copyright (c) Microsoft Corporation.  All rights reserved.
 --********************************************************************/
 
+using System;
+
 namespace Microsoft.PowerShell
 {
     public partial class PSConsoleReadLine
@@ -106,10 +108,7 @@ private int ViFindNextWordFromWord(int i, string wordDelimiters)
         /// </summary>
         private bool IsWhiteSpace(int i)
         {
-            // Treat just beyond the end of buffer as whitespace because
-            // it looks like whitespace to the user even though they haven't
-            // entered a character yet.
-            return i >= _buffer.Length || char.IsWhiteSpace(_buffer[i]);
+            return _buffer.IsWhiteSpace(i);
         }
 
         /// <summary>
diff --git a/test/StringBuilderCharacterExtensionsTests.cs b/test/StringBuilderCharacterExtensionsTests.cs
new file mode 100644
index 000000000..064477a93
--- /dev/null
+++ b/test/StringBuilderCharacterExtensionsTests.cs
@@ -0,0 +1,46 @@
+using Microsoft.PowerShell;
+using System.Text;
+using Xunit;
+
+namespace Test
+{
+    public sealed class StringBuilderCharacterExtensionsTests
+    {
+        [Fact]
+        public void StringBuilderCharacterExtensions_IsVisibleBlank()
+        {
+            var buffer = new StringBuilder(" \tn");
+
+            // system under test
+
+            Assert.True(buffer.IsVisibleBlank(0));
+            Assert.True(buffer.IsVisibleBlank(1));
+            Assert.False(buffer.IsVisibleBlank(2));
+        }
+
+        [Fact]
+        public void StringBuilderCharacterExtensions_InWord()
+        {
+            var buffer = new StringBuilder("hello, world!");
+            const string wordDelimiters = " ";
+
+            // system under test
+
+            Assert.True(buffer.InWord(2, wordDelimiters));
+            Assert.True(buffer.InWord(5, wordDelimiters));
+        }
+
+        [Fact]
+        public void StringBuilderCharacterExtensions_IsWhiteSpace()
+        {
+            var buffer = new StringBuilder("a c");
+
+
+            // system under test
+
+            Assert.False(buffer.IsWhiteSpace(0));
+            Assert.True(buffer.IsWhiteSpace(1));
+            Assert.False(buffer.IsWhiteSpace(2));
+        }
+    }
+}
diff --git a/test/StringBuilderTextObjectExtensionsTests.cs b/test/StringBuilderTextObjectExtensionsTests.cs
new file mode 100644
index 000000000..66bd590de
--- /dev/null
+++ b/test/StringBuilderTextObjectExtensionsTests.cs
@@ -0,0 +1,77 @@
+using Microsoft.PowerShell;
+using System.Text;
+using Xunit;
+
+namespace Test
+{
+    public sealed class StringBuilderTextObjectExtensionsTests
+    {
+        [Fact]
+        public void StringBuilderTextObjectExtensions_ViFindBeginningOfWordObjectBoundary()
+        {
+            const string wordDelimiters = PSConsoleReadLineOptions.DefaultWordDelimiters;
+
+            var buffer = new StringBuilder("Hello, world!\ncruel   world.\none\n\n\n\n\ntwo\n three four.");
+            Assert.Equal(0, buffer.ViFindBeginningOfWordObjectBoundary(1, wordDelimiters));
+        }
+
+        [Fact]
+        public void StringBuilderTextObjectExtensions_ViFindBeginningOfWordObjectBoundary_whitespace()
+        {
+            const string wordDelimiters = PSConsoleReadLineOptions.DefaultWordDelimiters;
+
+            var buffer = new StringBuilder("Hello,   world!");
+            Assert.Equal(6, buffer.ViFindBeginningOfWordObjectBoundary(7, wordDelimiters));
+        }
+
+        [Fact]
+        public void StringBuilderTextObjectExtensions_ViFindBeginningOfWordObjectBoundary_backwards()
+        {
+            const string wordDelimiters = PSConsoleReadLineOptions.DefaultWordDelimiters;
+
+            var buffer = new StringBuilder("Hello!\nworld!");
+            Assert.Equal(5, buffer.ViFindBeginningOfWordObjectBoundary(6, wordDelimiters));
+        }
+
+        [Fact]
+        public void StringBuilderTextObjectExtensions_ViFindBeginningOfWordObjectBoundary_end_of_buffer()
+        {
+            const string wordDelimiters = PSConsoleReadLineOptions.DefaultWordDelimiters;
+
+            var buffer = new StringBuilder("Hello, world!");
+            Assert.Equal(12, buffer.ViFindBeginningOfWordObjectBoundary(buffer.Length, wordDelimiters));
+        }
+
+        [Fact]
+        public void StringBuilderTextObjectExtensions_ViFindBeginningOfNextWordObjectBoundary()
+        {
+            const string wordDelimiters = PSConsoleReadLineOptions.DefaultWordDelimiters;
+
+            var buffer = new StringBuilder("Hello, world!\ncruel world.\none\n\n\n\n\ntwo\n three four.");
+
+            // Words |Hello|,| |world|!|\n|cruel |world|.|\n|one\n\n|\n\n|\n|two|\n |three| |four|.|
+            // Pos    01234 5 6 78901 2 _3 456789 01234 5 _6 789_0_1 _2_3 _4 567 _89 01234 5 6789 0
+            // Pos    0            1              2              3                   4            5
+
+            // system under test
+
+            Assert.Equal(5, buffer.ViFindBeginningOfNextWordObjectBoundary(0, wordDelimiters));
+            Assert.Equal(6, buffer.ViFindBeginningOfNextWordObjectBoundary(5, wordDelimiters));
+            Assert.Equal(7, buffer.ViFindBeginningOfNextWordObjectBoundary(6, wordDelimiters));
+            Assert.Equal(12, buffer.ViFindBeginningOfNextWordObjectBoundary(7, wordDelimiters));
+            Assert.Equal(13, buffer.ViFindBeginningOfNextWordObjectBoundary(12, wordDelimiters));
+            Assert.Equal(19, buffer.ViFindBeginningOfNextWordObjectBoundary(13, wordDelimiters));
+            Assert.Equal(20, buffer.ViFindBeginningOfNextWordObjectBoundary(19, wordDelimiters));
+            Assert.Equal(25, buffer.ViFindBeginningOfNextWordObjectBoundary(20, wordDelimiters));
+            Assert.Equal(26, buffer.ViFindBeginningOfNextWordObjectBoundary(25, wordDelimiters));
+            Assert.Equal(30, buffer.ViFindBeginningOfNextWordObjectBoundary(26, wordDelimiters));
+            Assert.Equal(32, buffer.ViFindBeginningOfNextWordObjectBoundary(30, wordDelimiters));
+            Assert.Equal(34, buffer.ViFindBeginningOfNextWordObjectBoundary(32, wordDelimiters));
+            Assert.Equal(38, buffer.ViFindBeginningOfNextWordObjectBoundary(34, wordDelimiters));
+            Assert.Equal(40, buffer.ViFindBeginningOfNextWordObjectBoundary(38, wordDelimiters));
+            Assert.Equal(45, buffer.ViFindBeginningOfNextWordObjectBoundary(40, wordDelimiters));
+            Assert.Equal(46, buffer.ViFindBeginningOfNextWordObjectBoundary(45, wordDelimiters));
+            Assert.Equal(50, buffer.ViFindBeginningOfNextWordObjectBoundary(46, wordDelimiters));
+        }
+    }
+}
diff --git a/test/TextObjects.Vi.Tests.cs b/test/TextObjects.Vi.Tests.cs
new file mode 100644
index 000000000..f819b3879
--- /dev/null
+++ b/test/TextObjects.Vi.Tests.cs
@@ -0,0 +1,176 @@
+using Microsoft.PowerShell;
+using Xunit;
+
+namespace Test
+{
+    public partial class ReadLine
+    {
+        [SkippableFact]
+        public void ViTextObject_diw()
+        {
+            TestSetup(KeyMode.Vi);
+
+            Test("\"hello, \ncruel world!\"", Keys(
+                _.DQuote, 
+                "hello, world!", _.Enter,
+                "cruel world!", _.DQuote,
+                _.Escape,
+
+                // move cursor to the 'o' in 'world'
+                "gg9l",
+
+                // delete text object
+                "diw",
+                CheckThat(() => AssertLineIs("\"hello, !\ncruel world!\"")),
+                CheckThat(() => AssertCursorLeftIs(8)),
+
+                // delete
+                "diw",
+                CheckThat(() => AssertLineIs("\"hello, \ncruel world!\"")),
+                CheckThat(() => AssertCursorLeftIs(7))
+            ));
+        }
+
+        [SkippableFact]
+        public void ViTextObject_diw_digit_arguments()
+        {
+            TestSetup(KeyMode.Vi);
+
+            Test("\"hello, world!\"", Keys(
+                _.DQuote, 
+                "hello, world!", _.Enter,
+                "cruel world!", _.DQuote,
+                _.Escape,
+
+                // move cursor to the 'o' in 'world'
+                "gg9l",
+
+                // delete text object
+                "diw",
+                CheckThat(() => AssertLineIs("\"hello, !\ncruel world!\"")),
+                CheckThat(() => AssertCursorLeftIs(8)),
+
+                // delete multiple text objects (spans multiple lines)
+                "3diw",
+                CheckThat(() => AssertLineIs("\"hello, world!\"")),
+                CheckThat(() => AssertCursorLeftIs(8))
+            ));
+        }
+
+
+        [SkippableFact]
+        public void ViTextObject_diw_noop()
+        {
+            TestSetup(KeyMode.Vi);
+
+            TestMustDing("\"hello, world!\ncruel world!\"", Keys(
+                _.DQuote, 
+                "hello, world!", _.Enter,
+                "cruel world!", _.DQuote,
+                _.Escape,
+
+                // move cursor to the 'o' in 'world'
+                "gg9l",
+
+                // attempting to delete too many words must ding
+                "1274diw"
+            ));
+        }
+
+        [SkippableFact]
+        public void ViTextObject_diw_empty_line()
+        {
+            TestSetup(KeyMode.Vi);
+
+            var continuationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length;
+
+            Test("\"\nhello, world!\n\noh, bitter world!\n\"", Keys(
+                _.DQuote, _.Enter, 
+                "hello, world!", _.Enter,
+                _.Enter,
+                "oh, bitter world!", _.Enter,
+                _.DQuote, _.Escape,
+
+                // move cursor to the second line
+                "ggjj",
+
+                // deleting single word cannot move backwards to previous line (noop)
+                "diw", 
+                CheckThat(() => AssertLineIs("\"\nhello, world!\n\noh, bitter world!\n\""))
+            ));
+        }
+
+        [SkippableFact]
+        public void ViTextObject_diw_end_of_buffer()
+        {
+            TestSetup(KeyMode.Vi);
+
+            var continuationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length;
+
+            Test("", Keys(
+                _.DQuote, 
+                "hello, world!", _.Enter,
+                "cruel world!", _.DQuote,
+                _.Escape,
+
+                // move to end of buffer
+                "G$",
+
+                // delete text object (deletes backwards)
+                "diw", CheckThat(() => AssertLineIs("\"hello, world!\ncruel world")),
+                "diw", CheckThat(() => AssertLineIs("\"hello, world!\ncruel ")),
+                "diw", CheckThat(() => AssertLineIs("\"hello, world!\ncruel")),
+                "diw", CheckThat(() => AssertLineIs("\"hello, world!\n")),
+                "diw", CheckThat(() => AssertLineIs("\"hello, world")),
+                "diw", CheckThat(() => AssertLineIs("\"hello, ")),
+                "diw", CheckThat(() => AssertLineIs("\"hello,")),
+                "diw", CheckThat(() => AssertLineIs("\"hello")),
+                "diw", CheckThat(() => AssertLineIs("\"")),
+                "diw", CheckThat(() => AssertLineIs(""))
+            ));
+        }
+
+        [SkippableFact]
+        public void ViTextObject_diw_empty_buffer()
+        {
+            TestSetup(KeyMode.Vi);
+            Test("", Keys(_.Escape, "diw"));
+            TestMustDing("", Keys(_.Escape, "d2iw"));
+        }
+
+        [SkippableFact]
+        public void ViTextObject_diw_new_lines()
+        {
+            TestSetup(KeyMode.Vi);
+
+            var continuationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length;
+
+            Test("\"\ntwo\n\"", Keys(
+                _.DQuote, _.Enter,
+                "one", _.Enter,
+                _.Enter, _.Enter,
+                _.Enter, _.Enter,
+                _.Enter,
+                "two", _.Enter, _.DQuote,
+                _.Escape,
+
+                // move to the beginning of 'one'
+                "gg0j",
+
+                // delete text object
+                "2diw",
+                CheckThat(() => AssertLineIs("\"\n\n\n\n\ntwo\n\"")),
+
+                "ugg0j", // currently undo does not move the cursor to the correct position
+                // delete multiple text objects (spans multiple lines)
+                "3diw",
+                CheckThat(() => AssertLineIs("\"\n\n\ntwo\n\"")),
+
+                "ugg0j", // currently undo does not move the cursor to the correct position
+                // delete multiple text objects (spans multiple lines)
+                "4diw",
+                CheckThat(() => AssertLineIs("\"\ntwo\n\""))
+            ));
+        }
+    }
+}