completers/wt_completer/wt_completer.ps1

# PowerShell Argument Completer for wt.exe
# Provides tab completion for top-level wt options, subcommands, and selected subcommand arguments.

Set-StrictMode -Version Latest

function Get-WtSettingsNames {
    param([string]$Kind)

    $cache = Get-Variable -Name 'WtSettingsNames' -Scope Script -ErrorAction Ignore
    if ($null -eq $cache -or $null -eq $cache.Value) {
        # Built-in schemes shipped in the package's defaults.json (1.25/1.26); they never
        # appear in the user's settings.json 'schemes' array.
        $names = @{
            profiles = @()
            schemes = @(
                'Campbell', 'Campbell Powershell', 'Vintage', 'One Half Dark', 'One Half Light',
                'Solarized Dark', 'Solarized Light', 'Tango Dark', 'Tango Light', 'Dark+',
                'VSCode Dark Modern', 'VSCode Light Modern', 'CGA', 'IBM 5153', 'Dimidium', 'Ottosson'
            )
        }
        foreach ($package in 'Microsoft.WindowsTerminal_8wekyb3d8bbwe', 'Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe') {
            $settingsPath = Join-Path -Path $env:LOCALAPPDATA -ChildPath "Packages\$package\LocalState\settings.json"
            if (-not (Test-Path -LiteralPath $settingsPath)) {
                continue
            }

            try {
                $settings = Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json
            } catch {
                continue
            }

            if ($settings.PSObject.Properties['profiles'] -and $settings.profiles.PSObject.Properties['list']) {
                $names.profiles += @($settings.profiles.list | ForEach-Object { $_.name } | Where-Object { $_ })
            }

            if ($settings.PSObject.Properties['schemes']) {
                $names.schemes += @($settings.schemes | ForEach-Object { $_.name } | Where-Object { $_ })
            }

            # Schemes referenced by profiles: a plain name or a { light, dark } pair.
            if ($settings.PSObject.Properties['profiles']) {
                $profileEntries = @()
                if ($settings.profiles.PSObject.Properties['defaults']) { $profileEntries += $settings.profiles.defaults }
                if ($settings.profiles.PSObject.Properties['list']) { $profileEntries += @($settings.profiles.list) }
                foreach ($entry in $profileEntries) {
                    if ($null -eq $entry -or -not $entry.PSObject.Properties['colorScheme']) {
                        continue
                    }

                    $scheme = $entry.colorScheme
                    if ($scheme -is [string]) {
                        $names.schemes += $scheme
                    } elseif ($null -ne $scheme) {
                        foreach ($variant in 'light', 'dark') {
                            if ($scheme.PSObject.Properties[$variant] -and $scheme.$variant -is [string]) {
                                $names.schemes += $scheme.$variant
                            }
                        }
                    }
                }
            }
        }

        $names.profiles = @($names.profiles | Sort-Object -Unique)
        $names.schemes = @($names.schemes | Where-Object { $_ } | Sort-Object -Unique)
        Set-Variable -Name 'WtSettingsNames' -Value $names -Scope Script
        $cache = Get-Variable -Name 'WtSettingsNames' -Scope Script
    }

    @($cache.Value[$Kind])
}

function Get-WtValueCompletionData {
    param([string]$Option)

    if ($Option -cin '-w', '--window') {
        # Reserved window ids; any other integer id or window name is also accepted.
        return @(
            @{ Text = 'new'; Display = 'new'; Type = 'ParameterValue'; Tooltip = 'Always run this command in a new window' }
            @{ Text = 'last'; Display = 'last'; Type = 'ParameterValue'; Tooltip = 'Always run this command in the most recently used window' }
            @{ Text = '-1'; Display = '-1'; Type = 'ParameterValue'; Tooltip = 'Always run this command in a new window (same as new)' }
            @{ Text = '0'; Display = '0'; Type = 'ParameterValue'; Tooltip = 'Always run this command in the most recently used window (same as last)' }
        )
    }

    $kind = if ($Option -in '-p', '--profile') { 'profiles' } elseif ($Option -eq '--colorScheme') { 'schemes' } else { $null }
    if (-not $kind) {
        return @()
    }

    @(
        foreach ($name in Get-WtSettingsNames -Kind $kind) {
            @{ Text = $name; Display = $name; Type = 'ParameterValue'; Tooltip = "Windows Terminal $kind entry" }
        }
    )
}

function ConvertTo-WtQuotedValue {
    param([string]$Value, [string]$Quote)

    if ($Quote -eq '"') {
        return '"' + $Value.Replace('`', '``').Replace('"', '`"').Replace('$', '`$') + '"'
    }

    "'" + $Value.Replace("'", "''") + "'"
}

function Get-WtUnquotedWord {
    param([string]$Text)

    # An unterminated quote runs to the cursor; strip the opening quote (and a matching
    # closing one) and report which quote character the user typed.
    if ($Text.Length -gt 0 -and ($Text[0] -eq [char]"'" -or $Text[0] -eq [char]'"')) {
        $quote = [string]$Text[0]
        $inner = $Text.Substring(1)
        if ($inner.Length -gt 0 -and $inner.EndsWith($quote)) {
            $inner = $inner.Substring(0, $inner.Length - 1)
        }

        return @{ Word = $inner; Quote = $quote }
    }

    @{ Word = $Text; Quote = '' }
}

function Complete-WtNative {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'wordToComplete', Justification = 'The word is cut from the CommandAst element at the cursor; wordToComplete drops quotes and ignores the cursor.')]
    param($wordToComplete, $commandAst, $cursorPosition)

    if (-not (Get-Command wt.exe -ErrorAction Ignore) -and
        -not (Get-Command wt -ErrorAction Ignore)) {
        return
    }

    $topLevelOptionData = @(
        @{ Text = '-h'; Display = '-h'; Type = 'ParameterName'; Tooltip = 'Print this help message and exit' }
        @{ Text = '--help'; Display = '--help'; Type = 'ParameterName'; Tooltip = 'Print this help message and exit' }
        @{ Text = '-v'; Display = '-v'; Type = 'ParameterName'; Tooltip = 'Display the application version' }
        @{ Text = '--version'; Display = '--version'; Type = 'ParameterName'; Tooltip = 'Display the application version' }
        @{ Text = '-M'; Display = '-M'; Type = 'ParameterName'; Tooltip = 'Launch the window maximized' }
        @{ Text = '--maximized'; Display = '--maximized'; Type = 'ParameterName'; Tooltip = 'Launch the window maximized' }
        @{ Text = '-F'; Display = '-F'; Type = 'ParameterName'; Tooltip = 'Launch the window in fullscreen mode' }
        @{ Text = '--fullscreen'; Display = '--fullscreen'; Type = 'ParameterName'; Tooltip = 'Launch the window in fullscreen mode' }
        @{ Text = '-f'; Display = '-f'; Type = 'ParameterName'; Tooltip = 'Launch the window in focus mode' }
        @{ Text = '--focus'; Display = '--focus'; Type = 'ParameterName'; Tooltip = 'Launch the window in focus mode' }
        @{ Text = '--pos'; Display = '--pos'; Type = 'ParameterName'; Tooltip = 'Specify the position for the terminal, in ''x,y'' format' }
        @{ Text = '--size'; Display = '--size'; Type = 'ParameterName'; Tooltip = 'Specify the number of columns and rows for the terminal, in ''c,r'' format' }
        @{ Text = '-w'; Display = '-w'; Type = 'ParameterName'; Tooltip = 'Specify a terminal window to run the given commandline in' }
        @{ Text = '--window'; Display = '--window'; Type = 'ParameterName'; Tooltip = 'Specify a terminal window to run the given commandline in' }
        @{ Text = '-s'; Display = '-s'; Type = 'ParameterName'; Tooltip = 'Internal parameter for saved command lines' }
        @{ Text = '--saved'; Display = '--saved'; Type = 'ParameterName'; Tooltip = 'Internal parameter for saved command lines' }
    )

    $newTerminalOptionData = @(
        @{ Text = '-p'; Display = '-p'; Type = 'ParameterName'; Tooltip = 'Use the specified profile' }
        @{ Text = '--profile'; Display = '--profile'; Type = 'ParameterName'; Tooltip = 'Use the specified profile' }
        @{ Text = '--sessionId'; Display = '--sessionId'; Type = 'ParameterName'; Tooltip = 'Reuse or assign the specified session ID' }
        @{ Text = '-d'; Display = '-d'; Type = 'ParameterName'; Tooltip = 'Set the starting directory' }
        @{ Text = '--startingDirectory'; Display = '--startingDirectory'; Type = 'ParameterName'; Tooltip = 'Set the starting directory' }
        @{ Text = '--title'; Display = '--title'; Type = 'ParameterName'; Tooltip = 'Set the starting tab title' }
        @{ Text = '--tabColor'; Display = '--tabColor'; Type = 'ParameterName'; Tooltip = 'Set the starting tab color' }
        @{ Text = '--suppressApplicationTitle'; Display = '--suppressApplicationTitle'; Type = 'ParameterName'; Tooltip = 'Prevent the terminal application from changing the tab title' }
        @{ Text = '--useApplicationTitle'; Display = '--useApplicationTitle'; Type = 'ParameterName'; Tooltip = 'Allow the terminal application to change the tab title' }
        @{ Text = '--colorScheme'; Display = '--colorScheme'; Type = 'ParameterName'; Tooltip = 'Set the color scheme' }
        @{ Text = '--appendCommandLine'; Display = '--appendCommandLine'; Type = 'ParameterName'; Tooltip = 'Append the provided command line to the profile command line' }
        @{ Text = '--inheritEnvironment'; Display = '--inheritEnvironment'; Type = 'ParameterName'; Tooltip = 'Inherit environment variables into the new terminal' }
        @{ Text = '--reloadEnvironment'; Display = '--reloadEnvironment'; Type = 'ParameterName'; Tooltip = 'Reload the environment instead of inheriting it' }
    )

    $splitPaneOptionData = @(
        @{ Text = '-H'; Display = '-H'; Type = 'ParameterName'; Tooltip = 'Split horizontally' }
        @{ Text = '--horizontal'; Display = '--horizontal'; Type = 'ParameterName'; Tooltip = 'Split horizontally' }
        @{ Text = '-V'; Display = '-V'; Type = 'ParameterName'; Tooltip = 'Split vertically' }
        @{ Text = '--vertical'; Display = '--vertical'; Type = 'ParameterName'; Tooltip = 'Split vertically' }
        @{ Text = '-s'; Display = '-s'; Type = 'ParameterName'; Tooltip = 'Set the pane size ratio between 0.01 and 0.99' }
        @{ Text = '--size'; Display = '--size'; Type = 'ParameterName'; Tooltip = 'Set the pane size ratio between 0.01 and 0.99' }
        @{ Text = '-D'; Display = '-D'; Type = 'ParameterName'; Tooltip = 'Duplicate the focused pane instead of launching a new terminal' }
        @{ Text = '--duplicate'; Display = '--duplicate'; Type = 'ParameterName'; Tooltip = 'Duplicate the focused pane instead of launching a new terminal' }
    )

    $focusTabOptionData = @(
        @{ Text = '-t'; Display = '-t'; Type = 'ParameterName'; Tooltip = 'Target the specified tab index' }
        @{ Text = '--target'; Display = '--target'; Type = 'ParameterName'; Tooltip = 'Target the specified tab index' }
        @{ Text = '-n'; Display = '-n'; Type = 'ParameterName'; Tooltip = 'Move focus to the next tab' }
        @{ Text = '--next'; Display = '--next'; Type = 'ParameterName'; Tooltip = 'Move focus to the next tab' }
        @{ Text = '-p'; Display = '-p'; Type = 'ParameterName'; Tooltip = 'Move focus to the previous tab' }
        @{ Text = '--previous'; Display = '--previous'; Type = 'ParameterName'; Tooltip = 'Move focus to the previous tab' }
    )

    $movePaneOptionData = @(
        @{ Text = '-t'; Display = '-t'; Type = 'ParameterName'; Tooltip = 'Move the focused pane to the specified tab index' }
        @{ Text = '--tab'; Display = '--tab'; Type = 'ParameterName'; Tooltip = 'Move the focused pane to the specified tab index' }
    )

    $focusPaneOptionData = @(
        @{ Text = '-t'; Display = '-t'; Type = 'ParameterName'; Tooltip = 'Focus the specified pane index' }
        @{ Text = '--target'; Display = '--target'; Type = 'ParameterName'; Tooltip = 'Focus the specified pane index' }
    )

    $directionValueData = @(
        @{ Text = 'left'; Display = 'left'; Type = 'ParameterValue'; Tooltip = 'Direction: left' }
        @{ Text = 'right'; Display = 'right'; Type = 'ParameterValue'; Tooltip = 'Direction: right' }
        @{ Text = 'up'; Display = 'up'; Type = 'ParameterValue'; Tooltip = 'Direction: up' }
        @{ Text = 'down'; Display = 'down'; Type = 'ParameterValue'; Tooltip = 'Direction: down' }
        @{ Text = 'previous'; Display = 'previous'; Type = 'ParameterValue'; Tooltip = 'Direction: previous focused pane' }
        @{ Text = 'nextInOrder'; Display = 'nextInOrder'; Type = 'ParameterValue'; Tooltip = 'Direction: next pane in order' }
        @{ Text = 'previousInOrder'; Display = 'previousInOrder'; Type = 'ParameterValue'; Tooltip = 'Direction: previous pane in order' }
        @{ Text = 'first'; Display = 'first'; Type = 'ParameterValue'; Tooltip = 'Direction: first pane' }
    )

    $subcommandData = @(
        @{ Text = 'new-tab'; Display = 'new-tab'; Type = 'ParameterValue'; Tooltip = 'Create a new tab' }
        @{ Text = 'nt'; Display = 'nt'; Type = 'ParameterValue'; Tooltip = 'Alias for new-tab' }
        @{ Text = 'split-pane'; Display = 'split-pane'; Type = 'ParameterValue'; Tooltip = 'Create a new split pane' }
        @{ Text = 'sp'; Display = 'sp'; Type = 'ParameterValue'; Tooltip = 'Alias for split-pane' }
        @{ Text = 'focus-tab'; Display = 'focus-tab'; Type = 'ParameterValue'; Tooltip = 'Move focus to another tab' }
        @{ Text = 'ft'; Display = 'ft'; Type = 'ParameterValue'; Tooltip = 'Alias for focus-tab' }
        @{ Text = 'move-focus'; Display = 'move-focus'; Type = 'ParameterValue'; Tooltip = 'Move focus to the adjacent pane in the specified direction' }
        @{ Text = 'mf'; Display = 'mf'; Type = 'ParameterValue'; Tooltip = 'Alias for move-focus' }
        @{ Text = 'move-pane'; Display = 'move-pane'; Type = 'ParameterValue'; Tooltip = 'Move focused pane to another tab' }
        @{ Text = 'mp'; Display = 'mp'; Type = 'ParameterValue'; Tooltip = 'Alias for move-pane' }
        @{ Text = 'swap-pane'; Display = 'swap-pane'; Type = 'ParameterValue'; Tooltip = 'Swap the focused pane with the adjacent pane in the specified direction' }
        @{ Text = 'focus-pane'; Display = 'focus-pane'; Type = 'ParameterValue'; Tooltip = 'Move focus to another pane' }
        @{ Text = 'fp'; Display = 'fp'; Type = 'ParameterValue'; Tooltip = 'Alias for focus-pane' }
        @{ Text = 'x-save'; Display = 'x-save'; Type = 'ParameterValue'; Tooltip = 'Save command line as input action' }
    )

    $subcommandMap = @{
        'new-tab' = 'new-tab'
        'nt' = 'new-tab'
        'split-pane' = 'split-pane'
        'sp' = 'split-pane'
        'focus-tab' = 'focus-tab'
        'ft' = 'focus-tab'
        'move-focus' = 'move-focus'
        'mf' = 'move-focus'
        'move-pane' = 'move-pane'
        'mp' = 'move-pane'
        'swap-pane' = 'swap-pane'
        'focus-pane' = 'focus-pane'
        'fp' = 'focus-pane'
        'x-save' = 'x-save'
    }

    $topLevelValueOptions = @('--pos', '--size', '-w', '--window', '-s', '--saved')
    $newTerminalValueOptions = @('-p', '--profile', '--sessionId', '-d', '--startingDirectory', '--title', '--tabColor', '--colorScheme')
    $splitPaneValueOptions = $newTerminalValueOptions + @('-s', '--size')
    $focusTabValueOptions = @('-t', '--target')
    $movePaneValueOptions = @('-t', '--tab')
    $focusPaneValueOptions = @('-t', '--target')

    $subcommandOptionData = @{
        'new-tab' = $newTerminalOptionData
        'split-pane' = $newTerminalOptionData + $splitPaneOptionData
        'focus-tab' = $focusTabOptionData
        'move-focus' = @()
        'move-pane' = $movePaneOptionData
        'swap-pane' = @()
        'focus-pane' = $focusPaneOptionData
        'x-save' = @()
    }

    $subcommandValueOptions = @{
        'new-tab' = $newTerminalValueOptions
        'split-pane' = $splitPaneValueOptions
        'focus-tab' = $focusTabValueOptions
        'move-focus' = @()
        'move-pane' = $movePaneValueOptions
        'swap-pane' = @()
        'focus-pane' = $focusPaneValueOptions
        'x-save' = @()
    }

    # Read words from the parser: quoted values stay one element, and the element that
    # contains the cursor (cut at the cursor) is the word being completed.
    $currentText = ''
    $argumentTokens = New-Object System.Collections.Generic.List[string]
    foreach ($element in @($commandAst.CommandElements | Select-Object -Skip 1)) {
        $start = $element.Extent.StartOffset
        if ($start -gt $cursorPosition) {
            break
        }

        if ($element.Extent.EndOffset -ge $cursorPosition) {
            $currentText = $element.Extent.Text.Substring(0, $cursorPosition - $start)
            break
        }

        $argumentTokens.Add($(if ($element -is [System.Management.Automation.Language.StringConstantExpressionAst]) { $element.Value } else { $element.Extent.Text }))
    }

    $currentWord = Get-WtUnquotedWord -Text $currentText
    $matchPrefix = $currentWord.Word
    $outerQuote = $currentWord.Quote
    $previousToken = if ($argumentTokens.Count -gt 0) { $argumentTokens[$argumentTokens.Count - 1] } else { '' }

    $expandedTokens = New-Object System.Collections.Generic.List[string]
    foreach ($token in $argumentTokens) {
        if ($token -eq ';' -or $token -eq '`;') {
            $expandedTokens.Add(';')
            continue
        }

        if ($token -match '^(.+?)`?;$') {
            $expandedTokens.Add($Matches[1])
            $expandedTokens.Add(';')
            continue
        }

        $expandedTokens.Add($token)
    }
    $completedTokens = @($expandedTokens.ToArray())

    $selectedSubcommand = $null
    $expectingValueOption = $null
    # new-tab/split-pane (and the implicit top-level new-tab) pass everything from the first
    # positional up to the next ';' to the child commandline.
    $inCommandline = $false
    foreach ($token in $completedTokens) {
        if ($token -eq ';') {
            $selectedSubcommand = $null
            $expectingValueOption = $null
            $inCommandline = $false
            continue
        }

        if ($inCommandline) {
            continue
        }

        if ($expectingValueOption) {
            $expectingValueOption = $null
            continue
        }

        if (-not $selectedSubcommand) {
            if (($topLevelValueOptions + $newTerminalValueOptions) -contains $token) {
                $expectingValueOption = $token
                continue
            }

            if ($subcommandMap.ContainsKey($token)) {
                $selectedSubcommand = $subcommandMap[$token]
                continue
            }

            if (-not $token.StartsWith('-')) {
                $inCommandline = $true
            }

            continue
        }

        $isNewTerminal = $selectedSubcommand -in 'new-tab', 'split-pane'
        # A top-level value option repeated after new-tab/split-pane still consumes its value,
        # so '1,1' in 'nt --pos 1,1' is not the start of the child commandline.
        if ($subcommandValueOptions[$selectedSubcommand] -contains $token -or ($isNewTerminal -and $topLevelValueOptions -contains $token)) {
            $expectingValueOption = $token
        }
        elseif ($isNewTerminal -and -not $token.StartsWith('-')) {
            $inCommandline = $true
        }
    }

    if ($inCommandline) {
        return
    }

    # Attached --opt=value: the option selects the value slot, the rest is the prefix.
    $attachedPrefix = ''
    $valueQuote = ''
    $slotValueOptions = if ($selectedSubcommand) { $subcommandValueOptions[$selectedSubcommand] } else { $topLevelValueOptions + $newTerminalValueOptions }
    if (-not $expectingValueOption -and $matchPrefix -match '^(--[^=]+)=(.*)$' -and $slotValueOptions -ccontains $Matches[1]) {
        $previousToken = $Matches[1]
        $attachedPrefix = $Matches[1] + '='
        $attachedValue = Get-WtUnquotedWord -Text $Matches[2]
        $matchPrefix = $attachedValue.Word
        $valueQuote = $attachedValue.Quote
    }

    [object[]]$completionData = @()
    if (-not $selectedSubcommand) {
        if (($topLevelValueOptions + $newTerminalValueOptions) -contains $previousToken) {
            $completionData = Get-WtValueCompletionData -Option $previousToken
        }
        elseif ($matchPrefix -like '-*') {
            $completionData = $topLevelOptionData + $newTerminalOptionData
        }
        else {
            $completionData = $topLevelOptionData + $newTerminalOptionData + $subcommandData
        }
    }
    else {
        switch ($selectedSubcommand) {
            'move-focus' {
                if ($matchPrefix -like '-*') {
                    $completionData = @()
                }
                else {
                    $completionData = $directionValueData
                }
            }
            'swap-pane' {
                if ($matchPrefix -like '-*') {
                    $completionData = @()
                }
                else {
                    $completionData = $directionValueData
                }
            }
            default {
                if ($subcommandValueOptions[$selectedSubcommand] -contains $previousToken) {
                    $completionData = Get-WtValueCompletionData -Option $previousToken
                }
                elseif ($matchPrefix -like '-*') {
                    $completionData = $subcommandOptionData[$selectedSubcommand]
                }
                else {
                    $completionData = @()
                }
            }
        }
    }

    # Whitespace or an argument-mode metacharacter would split or end a bare word.
    $quotePattern = '[\s{}();,|&<>''"`$]|^[@#]'
    foreach ($item in $completionData) {
        if ($item.Text -notlike "$matchPrefix*" -and $item.Display -notlike "$matchPrefix*") {
            continue
        }

        $completionText = [string]$item.Text
        if ($attachedPrefix -and -not $outerQuote) {
            if ($valueQuote -or $completionText -match $quotePattern) {
                $completionText = ConvertTo-WtQuotedValue -Value $completionText -Quote $valueQuote
            }

            $completionText = $attachedPrefix + $completionText
        }
        else {
            $completionText = $attachedPrefix + $completionText
            if ($outerQuote -or $completionText -match $quotePattern) {
                $completionText = ConvertTo-WtQuotedValue -Value $completionText -Quote $outerQuote
            }
        }

        [System.Management.Automation.CompletionResult]::new(
            $completionText,
            $item.Display,
            [System.Management.Automation.CompletionResultType]::$($item.Type),
            $item.Tooltip
        )
    }
}

Register-ArgumentCompleter -Native -CommandName @('wt', 'wt.exe') -ScriptBlock {
    param($wordToComplete, $commandAst, $cursorPosition)
    Complete-WtNative -wordToComplete $wordToComplete -commandAst $commandAst -cursorPosition $cursorPosition
}