Table of Contents

Commands and State

This module implements the new commands provided by ModalEdit. It also stores the extension state; which mode we are in, search parameters, bookmarks, quick snippets, etc.

commands.ts imports
import * as vscode from 'vscode'
import * as actions from './actions'

Command Arguments

Most commands provided by ModalEdit take arguments. Since command arguments are stored in objects by-design, we define them as interfaces.

Search Arguments

Search arguments are documented in the README.

interface SearchArgs {
    backwards?: boolean
    caseSensitive?: boolean
    wrapAround?: boolean
    acceptAfter?: number
    selectTillMatch?: boolean
    typeAfterAccept?: string
    typeBeforeNextMatch?: string
    typeAfterNextMatch?: string
    typeBeforePreviousMatch?: string
    typeAfterPreviousMatch?: string

Bookmark Arguments

Bookmark ID is just an index in an array. The actual positions are stored in an object that conforms to the Bookmark interface in the bookmarks array.

interface BookmarkArgs {
    bookmark?: number

interface Bookmark {
    document: vscode.TextDocument
    position: vscode.Position

Quick Snippet Arguments

Quick snippets are also stored in an array. So their IDs are indexes as well.

interface QuickSnippetArgs {
    snippet: number

Type Normal Keys Arguments

The typeNormalKeys command gets the entered keys as a string.

interface TypeNormalKeysArgs {
    keys: string

Select Between Arguments

The selectBetween command takes as arguments the strings/regular expressions which delimit the text to be selected. Both of them are optional, but in order for the command to do anything one of them needs to be defined. If the from argument is missing, the selection goes from the cursor position forwards to the to string. If the to is missing the selection goes backwards till the from string.

If the regex flag is on, from and to strings are treated as regular expressions in the search.

The inclusive flag tells if the delimiter strings are included in the selection or not. By default the delimiter strings are not part of the selection. Last, the caseSensitive flag makes the search case sensitive. When this flag is missing or false the search is case insensitive.

By default the search scope is the current line. If you want search inside the whole document, set the docScope flag.

interface SelectBetweenArgs {
    from: string
    to: string
    regex: boolean
    inclusive: boolean
    caseSensitive: boolean
    docScope: boolean

State Variables

The enabler for modal editing is the type event that VS Code provides. It reroutes the user's key presses to our extension. We store the handler to this event in the typeSubscription variable.

let typeSubscription: vscode.Disposable | undefined

We add an item in the status bar that shows the current mode. The reference to the status bar item is stored below.

let statusBarItem: vscode.StatusBarItem

This is the main mode flag that tells if we are in normal mode or insert mode.

let normalMode = true

The selecting flag indicates if we have initiated selection mode. Note that it is not the only indicator that tells whether a selection is active.

let selecting = false

The searching flag tells if command is in operation.

let searching = false

Search state variables.

let searchString: string
let searchStartSelections: vscode.Selection[]

Current search parameters.

let searchBackwards = false
let searchCaseSensitive = false
let searchWrapAround = false
let searchAcceptAfter = Number.POSITIVE_INFINITY
let searchSelectTillMatch = false
let searchTypeAfterAccept: string | undefined
let searchTypeBeforeNextMatch: string | undefined
let searchTypeAfterNextMatch: string | undefined
let searchTypeBeforePreviousMatch: string | undefined
let searchTypeAfterPreviousMatch: string | undefined
let searchReturnToNormal = true

Bookmarks are stored here.

let bookmarks: Bookmark[] = []

Quick snippets are simply stored in an array of strings.

let quickSnippets: string[] = []

"Repeat last change" command needs to know when text in editor has changed. It also needs to save the current and last command key sequence, as well as the last sequence that caused text to change.

let textChanged = false
let currentKeySequence: string[] = []
let lastKeySequence: string[] = []
let lastChange: string[] = []

Command Names

Since command names are easy to misspell, we define them as constants.

const toggleId = "modaledit.toggle"
const enterNormalId = "modaledit.enterNormal"
const enterInsertId = "modaledit.enterInsert"
const toggleSelectionId = "modaledit.toggleSelection"
const cancelSelectionId = "modaledit.cancelSelection"
const searchId = ""
const cancelSearchId = "modaledit.cancelSearch"
const deleteCharFromSearchId = "modaledit.deleteCharFromSearch"
const nextMatchId = "modaledit.nextMatch"
const previousMatchId = "modaledit.previousMatch"
const defineBookmarkId = "modaledit.defineBookmark"
const goToBookmarkId = "modaledit.goToBookmark"
const fillSnippetArgsId = "modaledit.fillSnippetArgs"
const defineQuickSnippetId = "modaledit.defineQuickSnippet"
const insertQuickSnippetId = "modaledit.insertQuickSnippet"
const typeNormalKeysId = "modaledit.typeNormalKeys"
const selectBetweenId = "modaledit.selectBetween"
const repeatLastChangeId = "modaledit.repeatLastChange"

Registering Commands

The commands are registered when the extension is activated (main entry point calls this function). We also create the status bar item.

export function register(context: vscode.ExtensionContext) {
        vscode.commands.registerCommand(toggleId, toggle),
        vscode.commands.registerCommand(enterNormalId, enterNormal),
        vscode.commands.registerCommand(enterInsertId, enterInsert),
        vscode.commands.registerCommand(toggleSelectionId, toggleSelection),
        vscode.commands.registerCommand(cancelSelectionId, cancelSelection),
        vscode.commands.registerCommand(searchId, search),
        vscode.commands.registerCommand(cancelSearchId, cancelSearch),
        vscode.commands.registerCommand(nextMatchId, nextMatch),
        vscode.commands.registerCommand(previousMatchId, previousMatch),
        vscode.commands.registerCommand(defineBookmarkId, defineBookmark),
        vscode.commands.registerCommand(goToBookmarkId, goToBookmark),
        vscode.commands.registerCommand(fillSnippetArgsId, fillSnippetArgs),
        vscode.commands.registerCommand(defineQuickSnippetId, defineQuickSnippet),
        vscode.commands.registerCommand(insertQuickSnippetId, insertQuickSnippet),
        vscode.commands.registerCommand(typeNormalKeysId, typeNormalKeys),
        vscode.commands.registerCommand(selectBetweenId, selectBetween),
        vscode.commands.registerCommand(repeatLastChangeId, repeatLastChange)
    statusBarItem = vscode.window.createStatusBarItem(
    statusBarItem.command = toggleId;

Keyboard Event Handler

When the user types in normal mode, onType handler gets each typed character one at a time. It calls the runActionForKey subroutine to invoke the action bound to the typed key. In addition, it updates the state variables needed by the repeatLastChange command and the status bar.

async function onType(event: { text: string }) {
    if (textChanged) {
        lastChange = lastKeySequence
        textChanged = false
    if (await runActionForKey(event.text, true)) {
        lastKeySequence = currentKeySequence
        currentKeySequence = []
    updateStatusBar(vscode.window.activeTextEditor, actions.getHelp())

Whenever text changes in an active editor, we set a flag. This flag is examined in the onType handler above, and the lastChange variable is set to indicate that the last command that changed editor text.

export function onTextChanged() {
    textChanged = true

This helper function just calls the handleKey function in the actions module. It checks if we have an active selection or search mode on, and passes that information to the function. handleKey returns true if the key actually invoked a command, or false if it was a part of incomplete key sequence that did not (yet) cause any commands to run. This information is needed to decide whether the lastKeySequence variable is updated.

async function runActionForKey(key: string, userInitiated: boolean):
    Promise<boolean> {
    return await actions.handleKey(key, isSelecting(), searching, userInitiated)

Mode Switching Commands

toggle switches between normal and insert mode.

export function toggle() {
    if (normalMode)

When entering normal mode, we:

  1. cancel the search, if it is on,
  2. subscribe to the type event,
  3. handle the rest of the mode setup with setNormalMode function, and
  4. clear the selection.
export function enterNormal() {
    if (!typeSubscription)
        typeSubscription = vscode.commands.registerCommand("type", onType)

Conversely, when entering insert mode, we:

  1. cancel the search, if it is on (yes, you can use it in insert mode, too),
  2. unsubscribe to the type event,
  3. handle the rest of the mode setup with setNormalMode function.

Note that we specifically don't clear the selection. This allows the user to easily surround selected text with hyphens ', parenthesis ( and ), brackets [ and ], etc.

export function enterInsert() {
    if (typeSubscription) {
        typeSubscription = undefined

The rest of the state handling is delegated to subroutines that do specific things. setNormalMode sets or resets the VS Code modaledit.normal context. This can be used in "standard" key bindings. Then it sets the normalMode variable and calls the next subroutine which updates cursor and status bar.

async function setNormalMode(value: boolean): Promise<void> {
    const editor = vscode.window.activeTextEditor
    if (editor) {
        await vscode.commands.executeCommand("setContext", "modaledit.normal",
        normalMode = value

This function updates the cursor shape according to the mode. It delegates updating of the status bar to the next subroutine.

export function updateCursorAndStatusBar(editor: vscode.TextEditor | undefined) {
    if (editor)
        editor.options.cursorStyle =
            searching ? actions.getSearchCursorType() :
                normalMode ? actions.getNormalCursorStyle() :

The last function updates the status bar text according to the mode. It also indicates if selection is active or if search mode on. If so, it shows the search parameters. If no editor is active, we hide the status bar item.

export function updateStatusBar(editor: vscode.TextEditor | undefined,
    help?: string) {
    if (editor) {
        let text: string
        if (searching)
            text = `SEARCH [${searchBackwards ? "B" : "F"
                }${searchCaseSensitive ? "S" : ""}]: ${searchString}`
        else {
            let sel = isSelecting() ? " [S]" : ""
            text = normalMode ? `--NORMAL${sel}--` : `--INSERT${sel}--`
        if (help)
            text = `${text}  ${help}`
        statusBarItem.text = text

Selection Commands

modaledit.cancelSelection command clears the selection using ths standard cancelSelection command, but also sets the selecting flag to false, and updates the status bar. It is advisable to use this command instead of the standard version to keep the state in sync.

async function cancelSelection(): Promise<void> {
    await vscode.commands.executeCommand("cancelSelection")
    selecting = false

modaledit.toggleSelection toggles the selection mode on and off. It sets the selection mode flag and updates the status bar, but also clears the selection.

async function toggleSelection(): Promise<void> {
    let oldSelecting = isSelecting()
    await vscode.commands.executeCommand("cancelSelection")
    selecting = !oldSelecting

The following helper function actually determines, if a selection is active. It checks not only the selecting flag but also if there is any text selected in the active editor.

function isSelecting(): boolean {
    return selecting ||
            selection => !selection.anchor.isEqual(

Search Commands

Incremental search is by far the most complicated part of this extension. Searching overrides both normal and insert modes, and captures the keyboard until it is done. The following subroutine sets the associated state variable, the VS Code modaledit.searching context, and the status bar. Since search mode also puts the editor implicitly in the normal mode, we need to check what was the state when we initiated the search. If we were in insert mode, we return also there.

async function setSearching(value: boolean) {
    searching = value
    await vscode.commands.executeCommand("setContext",
        "modaledit.searching", value)
    if (!(value || searchReturnToNormal))

This is the main command that not only initiates the search, but also handles the key presses when search is active. That is why its argument is defined as an union type. We also use the argument to detect whether we are starting a new search or adding characters to the active search.

async function search(args: SearchArgs | string): Promise<void> {
    let editor = vscode.window.activeTextEditor
    if (!editor)
    if (!args)
        args = {}
    if (typeof args == 'object') {

If we get an object as argument, we start a new search. We switch to normal mode, if necessary. Then we initialize the search string to empty, and store the current selections in the searchStartSelections array. We need an array as the command also works with multiple cursors. Finally we store the search arguments in the module level variables.

        searchReturnToNormal = normalMode
        if (!normalMode)
        searchString = ""
        searchStartSelections = editor.selections
        searchBackwards = args.backwards || false
        searchCaseSensitive = args.caseSensitive || false
        searchWrapAround = args.wrapAround || false
        searchAcceptAfter = args.acceptAfter || Number.POSITIVE_INFINITY
        searchSelectTillMatch = args.selectTillMatch || false
        searchTypeAfterAccept = args.typeAfterAccept
        searchTypeBeforeNextMatch = args.typeBeforeNextMatch
        searchTypeAfterNextMatch = args.typeAfterNextMatch
        searchTypeBeforePreviousMatch = args.typeBeforePreviousMatch
        searchTypeAfterPreviousMatch = args.typeAfterPreviousMatch
    else if (args == "\n")

If we get an enter character we accept the search.

        await acceptSearch()
    else {

Otherwise we just add the character to the search string and find the next match. If acceptAfter argument is given, and we have a sufficiently long search string, we accept the search automatically.

        searchString += args
        highlightMatches(editor, searchStartSelections)
        if (searchString.length >= searchAcceptAfter)
            await acceptSearch()

The actual search functionality is located in this helper function. It is used by the actual search command plus the commands that jump to next and previous match.

The search starts from positions specified by the selections argument. If there are multilple selections (cursors) active, multiple searches are performed. Each cursor location is considered separately, and the next match from that position is selected. The function does not make sure that found matches are unique. In case the matches overlap, the number of selections will decrease.

function highlightMatches(editor: vscode.TextEditor,
    selections: vscode.Selection[]) {
    if (searchString == "")

If search string is empty, we return to the start positions.

        editor.selections = searchStartSelections
    else {

We get the text of the active editor as string. If we have case-insensitive search, we transform the text to lower case.

        let doc = editor.document
        let docText = searchCaseSensitive ?
            doc.getText() : doc.getText().toLowerCase()

Next we determine the search target. It is also transformed to lower case, if search is case-insensitive.

        let target = searchCaseSensitive ?
            searchString : searchString.toLowerCase()
        editor.selections = => {

This is the actual search that is performed for each cursor position. The lambda function returns a new selection for each active cursor.

            let startOffs = doc.offsetAt(

Depending on the search direction we find either the first or the last match from the start offset.

            let offs = searchBackwards ?
                docText.lastIndexOf(target, startOffs - 1) :
                docText.indexOf(target, startOffs)
            if (offs < 0) {
                if (searchWrapAround)

If search string was not found but wrapAround argument was set, we try to find the search string from beginning or end of the document. If that fails too, we return the original selection and the cursor will not move.

                    offs = searchBackwards ?
                        docText.lastIndexOf(target) :
                if (offs < 0)
                    return sel

If search was successful, we return a new selection to highlight it. First, we find the start and end position of the match.

            let len = searchString.length
            let start = doc.positionAt(offs)
            let end = doc.positionAt(offs + len)

If the search direction is backwards, we flip the active and anchor positions. Normally, the anchor is set to the start and cursor to the end. Finally, we check if the selectTillMatch argument is set. If so, we move only the active cursor position and leave the selection start (anchor) as-is.

            let [active, anchor] = searchBackwards ?
                [start, end] :
                [end, start]
            if (searchSelectTillMatch)
                anchor = sel.anchor
            return new vscode.Selection(anchor, active)

Accepting the search resets the mode variables. Additionally, if typeAfterAccept argument is set we run the given normal mode commands.

async function acceptSearch() {
    await setSearching(false)
    if (searchTypeAfterAccept)
        await typeNormalKeys({ keys: searchTypeAfterAccept })

Canceling search just resets state, and moves the cursor back to the starting position.

async function cancelSearch(): Promise<void> {
    if (searching) {
        await setSearching(false)
        let editor = vscode.window.activeTextEditor
        if (editor) {
            editor.selections = searchStartSelections

Modifying Search String

Since we cannot capture the backspace character in normal mode, we have to hook it some other way. We define a command modaledit.deleteCharFromSearch which deletes the last character from the search string. This command can then be bound to backspace using the standard keybindings. We only run the command, if the modaledit.searching context is set. Below is an excerpt of the default keybindings defined in package.json.

    "key": "Backspace",
    "command": "modaledit.deleteCharFromSearch",
    "when": "editorTextFocus && modaledit.searching"

Note that we need to also update the status bar to show the modified search string. The onType callback that normally handles this is not getting called when this command is invoked.

function deleteCharFromSearch() {
    let editor = vscode.window.activeTextEditor
    if (editor && searching && searchString.length > 0) {
        searchString = searchString.slice(0, searchString.length - 1)
        highlightMatches(editor, searchStartSelections)

Finding Previous and Next Match

Using the highlightMatches function finding next and previous match is a relatively simple task. We basically just restart the search from the current cursor position(s).

We also check whether the search parameters include typeBeforeNextMatch or typeAfterNextMatch argument. If so, we invoke the user-specified commands before and/or after we jump to the next match.

async function nextMatch(): Promise<void> {
    let editor = vscode.window.activeTextEditor
    if (editor && searchString) {
        if (searchTypeBeforeNextMatch)
            await typeNormalKeys({ keys: searchTypeBeforeNextMatch })
        highlightMatches(editor, editor.selections)
        if (searchTypeAfterNextMatch)
            await typeNormalKeys({ keys: searchTypeAfterNextMatch })

When finding the previous match we flip the search direction but otherwise do the same routine as in the previous function.

async function previousMatch(): Promise<void> {
    let editor = vscode.window.activeTextEditor
    if (editor && searchString) {
        if (searchTypeBeforePreviousMatch)
            await typeNormalKeys({ keys: searchTypeBeforePreviousMatch })
        searchBackwards = !searchBackwards
        highlightMatches(editor, editor.selections)
        searchBackwards = !searchBackwards
        if (searchTypeAfterPreviousMatch)
            await typeNormalKeys({ keys: searchTypeAfterPreviousMatch })


Defining a bookmark is simple. We just store the cursor location and file in a Bookmark object, and store it in the bookmarks array.

function defineBookmark(args?: BookmarkArgs) {
    let editor = vscode.window.activeTextEditor
    if (editor) {
        let document = editor.document
        let position =
        bookmarks[args?.bookmark || 0] = { document, position }

Jumping to bookmark is also easy, just call the changeSelection function we already defined. It makes sure that selection is visible.

async function goToBookmark(args?: BookmarkArgs): Promise<void> {
    let i = args?.bookmark || 0
    let bm = bookmarks[i]
    if (bm) {
        await vscode.window.showTextDocument(bm.document)
        let editor = vscode.window.activeTextEditor
        if (editor)
            changeSelection(editor, bm.position, bm.position)

This helper function changes the selection range in the active editor. It also makes sure that the selection is visible.

function changeSelection(editor: vscode.TextEditor, anchor: vscode.Position,
    active: vscode.Position) {
    editor.selection = new vscode.Selection(anchor, active)

Quick Snippets

Supporting quick snippets is also a pleasantly simple job. First we implement the modaledit.fillSnippetArgs command, which replaces (multi-)selection ranges with $1, $2, ...

async function fillSnippetArgs(): Promise<void> {
    let editor = vscode.window.activeTextEditor
    if (editor) {
        let sel = editor.selections
        await editor.edit(eb => {
            for (let i = 0; i < sel.length; i++)
                eb.replace(sel[i], "$" + (i + 1))

Defining a snippet just puts the selection into an array.

function defineQuickSnippet(args?: QuickSnippetArgs) {
    let editor = vscode.window.activeTextEditor
    if (editor)
        quickSnippets[args?.snippet || 0] =

Inserting a snippet is done as easily with the built-in command. We enter insert mode automatically before snippet is expanded.

async function insertQuickSnippet(args?: QuickSnippetArgs): Promise<void> {
    let i = args?.snippet || 0
    let snippet = quickSnippets[i]
    if (snippet) {
        await vscode.commands.executeCommand("editor.action.insertSnippet",
            { snippet })

Invoking Commands via Key Bindings

The last command runs normal mode commands throught their key bindings. Implementing that is as easy as calling the keyboard handler.

async function typeNormalKeys(args: TypeNormalKeysArgs): Promise<void> {
    if (typeof args !== 'object' || typeof (args.keys) !== 'string')
        throw Error(`${typeNormalKeysId}: Invalid args: ${JSON.stringify(args)}`)
    for (let i = 0; i < args.keys.length; i++)
        await runActionForKey(args.keys[i], false)

Advanced Selection Command

For selecting ranges of text between two characters (inside parenthesis, for example) we add the modaledit.selectBetween command. See the instructions for the list of parameters this command provides.

function selectBetween(args: SelectBetweenArgs) {
    let editor = vscode.window.activeTextEditor
    if (!editor)
    if (typeof args !== 'object')
        throw Error(`${selectBetweenId}: Invalid args: ${JSON.stringify(args)}`)
    let doc = editor.document

Get position of cursor and anchor. These positions might be in "reverse" order (cursor lies before anchor), so we need to sort them into lowPos and highPos variables and corresponding offset variables. These are used to determine the search range later on.

Since to or from parameter might be missing, we initialize the fromOffs and toOffs variables to low and high offsets. They delimit the range to be selected at the end.

    let cursorPos =
    let anchorPos = editor.selection.anchor
    let [highPos, lowPos] = cursorPos.isAfterOrEqual(anchorPos) ?
        [cursorPos, anchorPos] : [anchorPos, cursorPos]
    let highOffs = doc.offsetAt(highPos)
    let lowOffs = doc.offsetAt(lowPos)
    let fromOffs = lowOffs
    let toOffs = highOffs

Next we determine the search range. The startOffs marks the starting offset and endOffs the end. Depending on the specified scope these variables are either set to start/end of the current line or the whole document.

In the actual search, we have two main branches: one for the case when regex search is used and another for the normal text search.

    let startPos = new vscode.Position(args.docScope ? 0 : lowPos.line, 0)
    let endPos = doc.lineAt(args.docScope ? doc.lineCount - 1 : highPos.line)
    let startOffs = doc.offsetAt(startPos)
    let endOffs = doc.offsetAt(endPos)
    if (args.regex) {
        if (args.from) {

This branch searches for regex in the from parameter starting from startPos continuing until lowPos. We need to find the last occurrence of the regex, so we have to add a global modifier g and iterate through all the matches. In case there are no matches fromOffs gets the same offset as startOffs meaning that the selection will extend to the start of the search scope.

            fromOffs = startOffs
            let text = doc.getText(new vscode.Range(startPos, lowPos))
            let re = new RegExp(args.from, args.caseSensitive ? "g" : "gi")
            let match: RegExpExecArray | null = null
            while ((match = re.exec(text)) != null)
                fromOffs = startOffs + match.index +
                    (args.inclusive ? 0 : match[0].length)
        if ( {

This block finds the regex in the to parameter starting from the range [highPos, endPos]. Since we want to find the first occurrence, we don't need to iterate over the matches in this case.

            toOffs = endOffs
            let text = doc.getText(new vscode.Range(highPos, endPos))
            let re = new RegExp(, args.caseSensitive ? undefined : "i")
            let match = re.exec(text)
            if (match)
                toOffs = highOffs + match.index +
                    (args.inclusive ? match[0].length : 0)
    else {

This branch does the regular text search. We retrieve the whole search range as string and use indexOf and lastIndexOf methods to find the strings in to and from parameters. Case insensitivity is done by converting both the search range and search string to lowercase.

        let text = doc.getText(new vscode.Range(startPos, endPos))
        if (!args.caseSensitive)
            text = text.toLowerCase()
        if (args.from) {
            fromOffs = text.lastIndexOf(args.caseSensitive ?
                args.from : args.from.toLowerCase(), lowOffs - startOffs)
            fromOffs = fromOffs < 0 ? startOffs :
                startOffs + fromOffs + (args.inclusive ? 0 : args.from.length)
        if ( {
            toOffs = text.indexOf(args.caseSensitive ?
       :, highOffs - startOffs)
            toOffs = toOffs < 0 ? endOffs :
                startOffs + toOffs + (args.inclusive ? : 0)
    if (cursorPos.isAfterOrEqual(anchorPos))

The last thing to do is to select the range from fromOffs to toOffs. We want to preserve the direction of the selection. If it was reserved when this command was called, we flip the variables.

        changeSelection(editor, doc.positionAt(fromOffs), doc.positionAt(toOffs))
        changeSelection(editor, doc.positionAt(toOffs), doc.positionAt(fromOffs))

Repeat Last Change Command

The repeatLastChange command runs the key sequence stored in lastChange variable. Since the command inevitably causes text in the editor to change (which causes the textChanged flag to go high), it has to reset the current key sequence to prevent the lastChange variable from being overwritten next time the user presses a key.

async function repeatLastChange(): Promise<void> {
    for (let i = 0; i < lastChange.length; i++)
        await runActionForKey(lastChange[i], false)
    currentKeySequence = lastChange