lib/vscodewriter.ps1

# VS Code's integrated terminal, wired in: the file I/O, the consent, and the
# undo records that lib/vscode.ps1 deliberately has none of.
#
# lib/vscode.ps1 is pure -- text in, text out -- and stays that way: it is what
# the round-trip tests drive. Everything here is the part that touches the
# user's machine, and it keeps three rules
# (docs/superpowers/specs/2026-10-02-vscode-settings-writer-design.md):
#
# ASK ONCE, PER EDITOR. settings.json is a file people annotate by hand and
# often sync between machines, so nothing is written until the user has said
# yes for that editor. "Nobody was at the terminal to ask" is not a no, and is
# not stored as one.
#
# NO KEY WITHOUT A RECORD. The record of what an apply wrote is saved BEFORE
# settings.json is written, and put back if the write fails. A write that only
# takes ours out goes the other way round -- file first, then the record --
# for the same reason. A crash can leave a record naming keys the file does
# not hold -- a later reset finds them absent, which is harmless -- but not a
# key that no command can name. The one exception is switching from one
# style straight to another -- an apply over a style already saved, a
# preview, the picker's Enter, or Esc back to the style the picker started
# from: between the two writes the record names one style and the file holds
# the other, whichever goes first.
#
# REFUSE RATHER THAN GUESS. An editor that cannot be identified, a settings
# folder that does not exist, a file that does not parse and a state file
# that cannot be read all stop the write and say which.

function Get-VSCodeStatePath {
    # Where the per-editor consent and undo records live. One file for every
    # editor, keyed by the variant names Get-VSCodeVariant returns.
    Join-Path $script:TStylesDataRoot 'vscode-state.json'
}

function ConvertTo-VSCodeRecord {
    <#
    .SYNOPSIS
    A record read back from JSON, rebuilt in the shape the apply produced.

    .DESCRIPTION
    ConvertFrom-Json hands back PSCustomObjects, and on Windows PowerShell 5.1 a
    one-element array can arrive as its bare element. Invoke-VSCodeStyleReset
    walks Path as an array of key names, so a Path that collapsed to a string
    would no longer be the path it was. Rebuilding every Path as a [string[]]
    here means the reset never sees the difference between a record that went
    through a file and one that did not.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Parsed)

    if ($null -eq $Parsed) { return $null }

    $wrote = [System.Collections.Generic.List[object]]::new()
    foreach ($w in @($Parsed.Wrote)) {
        if ($null -eq $w) { continue }
        $had = $null
        if ($null -ne $w.Had) { $had = [string]$w.Had }
        $wrote.Add(@{
            Path    = [string[]]@(@($w.Path) | ForEach-Object { [string]$_ })
            Literal = [string]$w.Literal
            Had     = $had
        })
    }

    $created = [System.Collections.Generic.List[object]]::new()
    foreach ($c in @($Parsed.CreatedParents)) {
        if ($null -eq $c) { continue }
        $created.Add([string[]]@(@($c) | ForEach-Object { [string]$_ }))
    }

    return @{
        Style          = [string]$Parsed.Style
        Wrote          = $wrote.ToArray()
        CreatedParents = $created.ToArray()
    }
}

function Read-VSCodeState {
    <#
    .SYNOPSIS
    The per-editor state: @{ Ok; Path; Entries }.

    .DESCRIPTION
    Entries maps a variant name to @{ consent; settingsPath; record }. consent
    is 'yes', 'no' or '' (never asked). A missing file is Ok with no entries --
    nothing has been asked or written yet.

    A file that exists and cannot be read is NOT Ok, and is not quietly treated
    as empty. It holds the only record of which keys TerminalStyles put into a
    settings.json; starting a fresh one would throw that away, and the keys
    with it. Callers refuse to write while it is in that state and say so.
    #>

    [CmdletBinding()]
    param()

    $path = Get-VSCodeStatePath
    $entries = @{}
    if (-not [System.IO.File]::Exists($path)) {
        return @{ Ok = $true; Path = $path; Entries = $entries }
    }

    try {
        $json = [System.IO.File]::ReadAllText($path, [System.Text.UTF8Encoding]::new($false))
        if (-not $json.Trim()) { return @{ Ok = $true; Path = $path; Entries = $entries } }
        $obj = $json | ConvertFrom-Json -ErrorAction Stop
        if ($obj -isnot [System.Management.Automation.PSCustomObject]) {
            return @{ Ok = $false; Path = $path; Entries = @{} }
        }
        foreach ($p in $obj.PSObject.Properties) {
            $v = $p.Value
            $entries[$p.Name] = @{
                consent      = [string]$v.consent
                settingsPath = [string]$v.settingsPath
                record       = (ConvertTo-VSCodeRecord -Parsed $v.record)
            }
        }
    } catch {
        return @{ Ok = $false; Path = $path; Entries = @{} }
    }
    return @{ Ok = $true; Path = $path; Entries = $entries }
}

function Write-VSCodeState {
    # Throws on failure, on purpose: a caller about to touch settings.json has
    # to know its record did not land first. Through Write-SettingsAtomic, so a
    # crash mid-write leaves the old file or the new one, never half of either.
    #
    # A .NET exception from inside Write-SettingsAtomic is only
    # statement-terminating -- with no try/catch around the call it is printed
    # and the caller's next line still runs, record unsaved -- so any failure is
    # rethrown here as an error of our own, which stops the caller.
    [CmdletBinding()]
    param([Parameter(Mandatory)][hashtable]$Entries)

    $out = [ordered]@{}
    foreach ($name in @($Entries.Keys | Sort-Object)) {
        $e = $Entries[$name]
        $out[$name] = [ordered]@{
            consent      = $e.consent
            settingsPath = $e.settingsPath
            record       = $e.record
        }
    }
    $path = Get-VSCodeStatePath
    try {
        Write-SettingsAtomic -Path $path -Json ($out | ConvertTo-Json -Depth 10)
    } catch {
        throw ("Could not write {0}: {1}" -f $path, $_.Exception.GetBaseException().Message)
    }
}

function Set-VSCodeStateEntry {
    <#
    .SYNOPSIS
    Change one editor's entry, leaving every other editor's as it was.

    .DESCRIPTION
    Only what the caller BOUND changes: -Record $null clears a record, and an
    absent -Record leaves it alone. Throws when the state file cannot be read,
    for the reason Read-VSCodeState gives -- rewriting it would drop the other
    editors' records.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Variant,
        [string]$SettingsPath,
        [ValidateSet('yes', 'no')][string]$Consent,
        [AllowNull()]$Record
    )

    $state = Read-VSCodeState
    if (-not $state.Ok) { throw "Could not read $($state.Path)" }

    $entry = $state.Entries[$Variant]
    if (-not $entry) { $entry = @{ consent = ''; settingsPath = ''; record = $null } }
    if ($PSBoundParameters.ContainsKey('SettingsPath')) { $entry.settingsPath = $SettingsPath }
    if ($PSBoundParameters.ContainsKey('Consent'))      { $entry.consent      = $Consent }
    if ($PSBoundParameters.ContainsKey('Record'))       { $entry.record       = $Record }
    $state.Entries[$Variant] = $entry
    Write-VSCodeState -Entries $state.Entries
}

function Resolve-VSCodeTarget {
    <#
    .SYNOPSIS
    Which editor this terminal belongs to, and its settings.json:
    @{ Status; Variant; Path }.

    .DESCRIPTION
    Status is 'ok', 'unknownvariant' or 'nodir'.

      unknownvariant Get-VSCodeVariant could not name exactly one editor.
                      NEVER defaulted to 'Code': see that function for what
                      guessing cost.
      nodir the editor's User folder does not exist. Nothing is
                      created -- an editor that has never run has nowhere it
                      would read a file from.

    A User folder with no settings.json in it is 'ok': VS Code creates the file
    the first time a setting changes, so writing the first one is what it would
    have done.

    -Variant skips detection, for callers that already know which editor they
    mean. The rest are test seams, forwarded by what was BOUND -- and a bound
    -HomeDir also suppresses the ambient APPDATA, the way Get-VSCodeSettingsPath
    has it suppress XDG_CONFIG_HOME, so a sandboxed test on Windows cannot
    resolve to the operator's own roaming profile.
    #>

    [CmdletBinding()]
    param(
        [string]$Variant,
        [string]$HomeDir,
        [string]$AppData,
        [string]$XdgConfigHome,
        [string]$AskpassNode,
        [string]$GitAskpass
    )

    if (-not $Variant) {
        $askSplat = @{}
        if ($PSBoundParameters.ContainsKey('AskpassNode')) { $askSplat.AskpassNode = $AskpassNode }
        if ($PSBoundParameters.ContainsKey('GitAskpass'))  { $askSplat.GitAskpass  = $GitAskpass }
        $Variant = Get-VSCodeVariant @askSplat
        if (-not $Variant) { return @{ Status = 'unknownvariant'; Variant = $null; Path = $null } }
    }

    $pathSplat = @{}
    if ($PSBoundParameters.ContainsKey('HomeDir')) { $pathSplat.HomeDir = $HomeDir }
    if ($PSBoundParameters.ContainsKey('AppData')) {
        $pathSplat.AppData = $AppData
    } elseif ($PSBoundParameters.ContainsKey('HomeDir')) {
        $pathSplat.AppData = ''
    }
    if ($PSBoundParameters.ContainsKey('XdgConfigHome')) { $pathSplat.XdgConfigHome = $XdgConfigHome }

    $path = Get-VSCodeSettingsPath -Variant $Variant @pathSplat
    if (-not [System.IO.Directory]::Exists([System.IO.Path]::GetDirectoryName($path))) {
        return @{ Status = 'nodir'; Variant = $Variant; Path = $path }
    }
    return @{ Status = 'ok'; Variant = $Variant; Path = $path }
}

function Read-VSCodeSettingsText {
    # The file's text, or '' when there is no file yet. ReadAllText drops a
    # BOM; Write-SettingsAtomic puts it back, from the live file.
    #
    # A file that is there and cannot be read THROWS, as an error of our own that
    # names it. A bare .NET exception would only be printed, the caller's next
    # line would run on $null, and Test-VSCodeSettingsReadable calls that blank:
    # an unreadable settings.json would pass for "no settings yet". Refuse
    # rather than guess.
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Path)
    if (-not [System.IO.File]::Exists($Path)) { return '' }
    try {
        return [System.IO.File]::ReadAllText($Path, [System.Text.UTF8Encoding]::new($false))
    } catch {
        throw ("Could not read {0}: {1}" -f $Path, $_.Exception.GetBaseException().Message)
    }
}

function Test-VSCodeSettingsReadable {
    <#
    .SYNOPSIS
    Is this text something the JSONC editor can safely splice?

    .DESCRIPTION
    Blank is readable: it is what VS Code itself treats as "no settings yet".
    Otherwise the root must be an object, and that object must close.

    Asked BEFORE an apply or a reset rather than left to them, because the
    reset does not refuse broken text: on a file truncated mid-write it
    reported 'reset', removed 24 keys and kept one -- measured -- splicing
    against offsets that no longer meant what the record thought. Clearing the
    record after that would strand whatever it had not reached.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][AllowEmptyString()][string]$Text)

    if (-not $Text.Trim()) { return $true }
    $tokens = Get-JsoncToken -Text $Text
    $open = Find-JsoncRootObject -Tokens $tokens
    if ($open -lt 0) { return $false }
    return ((Find-JsoncCloseBrace -Tokens $tokens -OpenIndex $open) -ge 0)
}

function Get-VSCodeWriterState {
    <#
    .SYNOPSIS
    Does a VS Code apply write settings.json right now, and if not, why not.

    .DESCRIPTION
    THE one answer. Get-TerminalCapability asks it (through
    Test-VSCodeWriterActive) to decide what VS Code claims, and the publisher
    asks it to decide whether to write -- so the "can't show" notice and the
    write cannot disagree about the same apply (CLAUDE.md: two implementations
    of one rule diverge).

    Returns @{ Status; Variant; Path; Entry }. Status is one of:
      active identified editor, settings folder present, consent yes
      declined consent no
      undecided never asked, or nobody was there to answer
      unknownvariant see Resolve-VSCodeTarget
      nodir see Resolve-VSCodeTarget
      statecorrupt vscode-state.json exists and cannot be read
    #>

    [CmdletBinding()]
    param()

    $t = Resolve-VSCodeTarget
    $out = @{ Status = $t.Status; Variant = $t.Variant; Path = $t.Path; Entry = $null }
    if ($t.Status -ne 'ok') { return $out }

    $state = Read-VSCodeState
    if (-not $state.Ok) { $out.Status = 'statecorrupt'; return $out }

    $entry = $state.Entries[$t.Variant]
    $out.Entry = $entry
    $consent = ''
    if ($entry) { $consent = $entry.consent }
    switch ($consent) {
        'yes'   { $out.Status = 'active' }
        'no'    { $out.Status = 'declined' }
        default { $out.Status = 'undecided' }
    }
    return $out
}

function Test-VSCodeWriterActive {
    # What Get-TerminalCapability's VSCode arm asks. A predicate over the one
    # decider above, not a second opinion.
    [CmdletBinding()]
    param()
    return ((Get-VSCodeWriterState).Status -eq 'active')
}

function Request-VSCodeWriterConsent {
    <#
    .SYNOPSIS
    Ask, once per editor, whether TerminalStyles may write its settings.json.
    Returns the writer status after asking (see Get-VSCodeWriterState).

    .DESCRIPTION
    Only an 'undecided' editor is asked, and only at a real console. The
    console check comes FIRST and is this function's own, not Confirm-Action's:
    that function answers $false both for "the user said no" and for "nobody
    could be asked", and storing the second as a no would turn the writer off
    for good the first time `tstyles eva` ran from a script.

    An answer that cannot be stored is reported, and the editor stays
    undecided: without a stored state the publisher could not save an undo
    record either, so honouring the yes for this run would only end in a
    failed write.
    #>

    [CmdletBinding()]
    param()

    $w = Get-VSCodeWriterState
    if ($w.Status -ne 'undecided') { return $w.Status }
    if (-not (Test-InteractiveConsole)) { return 'undecided' }

    Write-Host ""
    Write-Host (" Save styles to {0}'s settings.json, so new terminals keep them?" -f $w.Variant)
    Write-Host " This sets the terminal's colors, font and cursor there; tstyles reset" -ForegroundColor DarkGray
    Write-Host " puts back whatever was there before. Change your mind any time with" -ForegroundColor DarkGray
    Write-Host " tstyles vscode on or tstyles vscode off." -ForegroundColor DarkGray
    $answer = 'no'
    if (Confirm-Action -Question ' Save to settings.json? [y/N]') { $answer = 'yes' }

    try {
        Set-VSCodeStateEntry -Variant $w.Variant -SettingsPath $w.Path -Consent $answer
    } catch {
        Write-Host (" Could not save that answer to {0}, so nothing is written to" -f (Get-VSCodeStatePath)) -ForegroundColor Yellow
        Write-Host " settings.json this time, and you will be asked again." -ForegroundColor Yellow
        return 'undecided'
    }
    # The one decider says what the stored answer means, rather than a second
    # copy of its switch here.
    return (Get-VSCodeWriterState).Status
}

function Split-VSCodeKept {
    <#
    .SYNOPSIS
    Split the keys an undo kept by whether the style written next sets them
    again: @{ Left; Replaced }.

    .DESCRIPTION
    An undo keeps a key the user changed after the outgoing style wrote it.
    When the style written next sets that key too, the user's value is
    written over -- and kept in the new record, as the value a reset puts
    back: Replaced. Only the rest are still as the user set them: Left.

    The publisher and Esc's merge both report this split, and both get it
    from here, so the two cannot come to name the same key differently
    (CLAUDE.md: two implementations of one rule diverge). Keys are named the
    way the undo names them, Path joined with '/'. -Record is the next
    style's record; with none, every kept key is Left.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()]$Kept,
        [AllowNull()]$Record
    )

    $written = @()
    if ($Record) { $written = @(@($Record.Wrote) | ForEach-Object { @($_.Path) -join '/' }) }
    return @{
        Left     = @(@($Kept) | Where-Object { $written -notcontains $_ })
        Replaced = @(@($Kept) | Where-Object { $written -contains $_ })
    }
}

function Publish-StyleVSCodeConfig {
    <#
    .SYNOPSIS
    Write a style into this editor's settings.json, with its undo record.
    Returns a result for Show-VSCodeWriteResult, or $null off VS Code.

    .DESCRIPTION
    The fourth config writer's door, beside Publish-StyleWezTermConfig and
    called from the same places: Apply-StyleNonWT, and the picker's Enter.
    It never asks -- callers ask first, so the "can't show" notice printed
    before it already reflects the answer -- and it never prints: the picker
    computes it above a Clear-Host and shows it below one.

    -Session is the picker's preview session. While the picker runs, the file
    holds a PREVIEW, and the apply undoes it by the session's record of what
    is on disk. A write that fails here rolls the stored record back to that
    one, the record of what the file still holds -- normally the stored record
    too, but not after a preview whose write and rollback both failed. If the
    rollback fails as well, the session's RecordAhead is left set, so Esc's
    restore puts it right.

    Kept is split in two on the way out. The apply's Kept names keys the
    outgoing style's undo left alone because the user had changed them; the
    incoming style then writes over any of those it sets itself, keeping the
    user's value as the one a reset restores. "Left as you set it" is only
    true of the others. Split-VSCodeKept draws that line.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$StyleName,
        [Parameter(Mandatory)][string]$StyleDir,
        [Parameter(Mandatory)][string]$Kind,
        [AllowNull()][hashtable]$Session
    )

    if ($Kind -ne 'VSCode') { return $null }

    $w = Get-VSCodeWriterState
    $result = @{
        Status = $w.Status; Variant = $w.Variant; Path = $w.Path
        Kept = @(); Replaced = @(); Overrides = @()
        Backup = $null; FailedPath = $null; Detail = $null
    }
    if ($w.Status -ne 'active') { return $result }

    $stored = $null
    if ($w.Entry) { $stored = $w.Entry.record }
    $onDisk = $stored
    if ($Session) {
        $onDisk = $Session.OnDiskRecord
        $result.Backup = $Session.Backup
    }

    try {
        $text = Read-VSCodeSettingsText -Path $w.Path
    } catch {
        # Refused, not guessed: a file that exists and cannot be read is not
        # "no settings yet", and writing over it would replace it outright.
        $result.Status = 'wontparse'; $result.Detail = 'it could not be read'
        return $result
    }
    if (-not (Test-VSCodeSettingsReadable -Text $text)) {
        $result.Status = 'wontparse'; $result.Detail = 'it does not parse as a JSON object'
        return $result
    }

    # VS Code's own empty settings file is "{}"; a blank or missing one means
    # the same, and is the one case a reset does not return byte for byte --
    # it leaves "{}", which VS Code reads identically.
    $base = $text
    if (-not $base.Trim()) { $base = '{}' }

    $a = Invoke-VSCodeStyleApply -Text $base -StyleDir $StyleDir -StyleName $StyleName -PreviousRecord $onDisk
    if ($a.Status -ne 'applied') {
        $result.Status = 'wontparse'
        $result.Detail = switch ($a.Status) {
            'parentnotobject' { 'workbench.colorCustomizations in it is not an object' }
            default           { 'it does not parse as a JSON object' }
        }
        return $result
    }

    # FIRST touch: only while none of our keys are in the file. A copy taken
    # later would capture a file that already carries a style (CLAUDE.md,
    # "Encoding and culture, on files the user owns").
    if (-not $onDisk -and $text -and -not $result.Backup) {
        $result.Backup = Save-FirstTouchBackup -Path $w.Path -Content $text
    }

    try {
        Set-VSCodeStateEntry -Variant $w.Variant -SettingsPath $w.Path -Record $a.Record
        if ($Session) { $Session.RecordAhead = $true }
    } catch {
        $result.Status = 'writefailed'; $result.FailedPath = (Get-VSCodeStatePath)
        return $result
    }

    if ($a.Text -cne $text) {
        try {
            Write-SettingsAtomic -Path $w.Path -Json $a.Text
        } catch {
            $result.Status = 'writefailed'; $result.FailedPath = $w.Path
            try {
                # Back to the record of what the file still holds: $onDisk, not
                # $stored. Under a session they differ when a preview's write
                # failed and its record could not be put back.
                Set-VSCodeStateEntry -Variant $w.Variant -SettingsPath $w.Path -Record $onDisk
                if ($Session) { $Session.RecordAhead = $false }
            } catch {
                # Both writes failed. The record now describes keys that never
                # landed, and the keys still in the file are named by no record --
                # say so, rather than let a later reset leave them without a word.
                $result.Detail = 'its undo record could not be restored either'
            }
            return $result
        }
    }
    # The record and the file agree again: the commit landed, or wrote nothing.
    if ($Session) { $Session.RecordAhead = $false }

    $split = Split-VSCodeKept -Kept $a.Kept -Record $a.Record
    $ids = @(@($a.Record.Wrote) |
             Where-Object { @($_.Path).Count -eq 2 -and $_.Path[0] -eq 'workbench.colorCustomizations' } |
             ForEach-Object { $_.Path[1] })
    # Assigned, not wrapped: Get-VSCodeColorOverride returns ,$arr.
    $overrides = Get-VSCodeColorOverride -Text $a.Text -Ids $ids

    $result.Status    = 'written'
    $result.Kept      = $split.Left
    $result.Replaced  = $split.Replaced
    $result.Overrides = $overrides
    return $result
}

function Show-VSCodeWriteResult {
    <#
    .SYNOPSIS
    Say what Publish-StyleVSCodeConfig did, in the words for that outcome.

    .DESCRIPTION
    One arm per status and no shared "success" line: "Saved" is printed on the
    written path and nowhere else (CLAUDE.md: every message is a claim, and a
    status is not a boolean). Leading blank line, no trailing one -- the
    callers own their spacing, as with Show-UnsupportedStyleField.
    #>

    [CmdletBinding()]
    param([AllowNull()][hashtable]$Result)

    if (-not $Result) { return }
    Write-Host ""
    switch ($Result.Status) {
        'written' {
            Write-Host (" Saved to {0}'s settings.json -- new terminals keep it." -f $Result.Variant) -ForegroundColor DarkGray
            if ($Result.Backup) {
                Write-Host (" The file as it was before is kept at {0}" -f $Result.Backup) -ForegroundColor DarkGray
            }
            foreach ($k in @($Result.Kept)) {
                Write-Host (" Left {0} as you set it (you changed it after the last style wrote it)." -f $k) -ForegroundColor DarkGray
            }
            foreach ($k in @($Result.Replaced)) {
                Write-Host (" Replaced {0}, which you had changed by hand; tstyles reset puts yours back." -f $k) -ForegroundColor DarkGray
            }
            foreach ($o in @($Result.Overrides)) {
                Write-Host (" {0} in workbench.colorCustomizations also sets {1}," -f $o.Scope, (@($o.Ids) -join ', ')) -ForegroundColor Yellow
                Write-Host " and wins over this style while that color theme is active." -ForegroundColor Yellow
            }
        }
        'declined' {
            Write-Host (" Not saved to {0}'s settings.json, as you chose. tstyles vscode on changes that." -f $Result.Variant) -ForegroundColor DarkGray
        }
        'undecided' {
            Write-Host (" Not saved to {0}'s settings.json: there is no answer on record for it yet." -f $Result.Variant) -ForegroundColor DarkGray
            Write-Host " tstyles vscode on gives one." -ForegroundColor DarkGray
        }
        'unknownvariant' {
            # Not "the colors apply to this terminal only": wherever the shell
            # loader is registered, new terminals take the colors too.
            Write-Host " Couldn't tell which editor this terminal belongs to," -ForegroundColor Yellow
            Write-Host " so nothing was saved to its settings.json." -ForegroundColor Yellow
        }
        'nodir' {
            Write-Host (" Found no settings folder for {0} at {1}, so nothing was saved." -f
                        $Result.Variant, [System.IO.Path]::GetDirectoryName($Result.Path)) -ForegroundColor Yellow
        }
        'wontparse' {
            Write-Host (" Could not edit {0} safely: {1}." -f $Result.Path, $Result.Detail) -ForegroundColor Yellow
            Write-Host " Nothing was written to it. Fix the file and apply the style again." -ForegroundColor Yellow
        }
        'writefailed' {
            Write-Host (" Could not write {0}." -f $Result.FailedPath) -ForegroundColor Yellow
            Write-Host " This terminal keeps the colors; new terminals won't." -ForegroundColor Yellow
            if ($Result.Detail) {
                Write-Host (" And {0}, so a reset may leave some of the previous style's settings in place." -f $Result.Detail) -ForegroundColor Yellow
            }
        }
        'statecorrupt' {
            Write-Host (" Could not read {0}, which records what tstyles wrote to settings.json." -f (Get-VSCodeStatePath)) -ForegroundColor Yellow
            Write-Host " Nothing was written. Fix that file: deleting it lets tstyles write again, but loses" -ForegroundColor Yellow
            Write-Host " its record of any style already in settings.json." -ForegroundColor Yellow
        }
    }
}

function Reset-StyleVSCodeConfig {
    <#
    .SYNOPSIS
    Take a style back out of one editor's settings.json, by its record.

    .DESCRIPTION
    The record says what to undo, and Invoke-VSCodeStyleReset decides per key:
    restored, removed, or kept because the user changed it since. The record
    is cleared only AFTER the file is written: clearing it first and then
    failing the write would leave keys in the file that no command could name.

    -Variant names the editor; omitted, it is the one this terminal belongs
    to. Works off the RECORDED settings path, so uninstall can run it from any
    terminal. (`tstyles vscode off` calls it too, but refuses outside an
    editor terminal it can identify.)

    Returns @{ Status; Variant; Path; Kept; Restored; Removed; Detail }, Status
    one of reset, norecord, unknownvariant, statecorrupt, wontparse,
    writefailed. Detail says why for wontparse: the file could not be read, or
    does not parse. On reset it is set when the file was put back but the
    record could not be cleared afterwards.
    #>

    [CmdletBinding()]
    param([string]$Variant)

    $result = @{ Status = 'norecord'; Variant = $Variant; Path = $null; Kept = @(); Restored = @(); Removed = @(); Detail = $null }

    if (-not $Variant) {
        $t = Resolve-VSCodeTarget
        if (-not $t.Variant) { $result.Status = 'unknownvariant'; return $result }
        $Variant = $t.Variant
        $result.Variant = $Variant
    }

    $state = Read-VSCodeState
    if (-not $state.Ok) { $result.Status = 'statecorrupt'; return $result }
    $entry = $state.Entries[$Variant]
    if (-not $entry -or -not $entry.record) { return $result }
    $result.Path = $entry.settingsPath

    try {
        $text = Read-VSCodeSettingsText -Path $entry.settingsPath
    } catch {
        $result.Status = 'wontparse'; $result.Detail = 'it could not be read'
        return $result
    }
    if (-not (Test-VSCodeSettingsReadable -Text $text)) {
        $result.Status = 'wontparse'; $result.Detail = 'it does not parse as a JSON object'
        return $result
    }

    $r = Invoke-VSCodeStyleReset -Text $text -Record $entry.record
    if ($r.Text -cne $text) {
        try {
            Write-SettingsAtomic -Path $entry.settingsPath -Json $r.Text
        } catch {
            $result.Status = 'writefailed'
            return $result
        }
    }
    try {
        Set-VSCodeStateEntry -Variant $Variant -Record $null
    } catch {
        # The file is clean, but the record still names the style's values, so
        # the next reset or apply would take the user's own settings for hand
        # edits. Say so rather than leave it to be misread later.
        $result.Detail = 'its record could not be cleared'
    }

    $result.Status   = 'reset'
    $result.Kept     = @($r.Kept)
    $result.Restored = @($r.Restored)
    $result.Removed  = @($r.Removed)
    return $result
}

function Show-VSCodeResetResult {
    # Say what Reset-StyleVSCodeConfig did. Quiet on 'norecord': nothing of
    # ours was there, and saying so on every reset in every VS Code terminal
    # would be noise. No leading blank line: it is printed inside other
    # commands' blocks (reset, uninstall, `tstyles vscode off`).
    # -Uninstalling: uninstall has removed the module by then; its sign-off says what to do instead.
    [CmdletBinding()]
    param([AllowNull()][hashtable]$Result, [switch]$Uninstalling)

    if (-not $Result) { return }
    switch ($Result.Status) {
        'reset' {
            # Filtered, not counted: @() around a missing member is one $null,
            # which would claim a key was taken out when none was.
            $undone = @(@($Result.Restored) + @($Result.Removed) | Where-Object { $_ })
            if ($undone.Count -gt 0) {
                Write-Host (" Took the style back out of {0}'s settings.json." -f $Result.Variant) -ForegroundColor DarkGray
            } else {
                Write-Host (" Found none of the style left in {0}'s settings.json to take out." -f $Result.Variant) -ForegroundColor DarkGray
            }
            foreach ($k in @($Result.Kept)) {
                Write-Host (" Left {0} as you set it (you changed it after the style wrote it)." -f $k) -ForegroundColor DarkGray
            }
            if ($Result.Detail) {
                Write-Host (" But {0}, so the next apply or reset may wrongly call some of your settings hand edits." -f $Result.Detail) -ForegroundColor Yellow
            }
        }
        'unknownvariant' {
            Write-Host " Couldn't tell which editor this terminal belongs to, so no settings.json" -ForegroundColor Yellow
            Write-Host " was touched." -ForegroundColor Yellow
        }
        'statecorrupt' {
            Write-Host (" Could not read {0}, which records what tstyles wrote to settings.json," -f (Get-VSCodeStatePath)) -ForegroundColor Yellow
            if ($Uninstalling) {
                Write-Host " so nothing was taken out of it." -ForegroundColor Yellow
            } else {
                Write-Host " so nothing was taken out of it. Fix that file and reset again -- deleting it would" -ForegroundColor Yellow
                Write-Host " leave the style in settings.json with no record to take it out by." -ForegroundColor Yellow
            }
        }
        'wontparse' {
            Write-Host (" Could not edit {0} safely: {1}, so the style was left in it." -f $Result.Path, $Result.Detail) -ForegroundColor Yellow
            if (-not $Uninstalling) {
                Write-Host " Fix the file and reset again -- the record of what to take out is kept." -ForegroundColor Yellow
            }
        }
        'writefailed' {
            Write-Host (" Could not write {0}, so the style is still in it." -f $Result.Path) -ForegroundColor Yellow
            if (-not $Uninstalling) {
                Write-Host " The record of what to take out is kept; reset again once it can be written." -ForegroundColor Yellow
            }
        }
    }
}

function New-VSCodePreviewSession {
    <#
    .SYNOPSIS
    The picker's VS Code state for one run, or $null when it previews colours
    only.

    .DESCRIPTION
    The picker's body needs a console and a keypress loop no test can drive,
    so everything VS Code does in it lives here, as data a test can hold:
    the file's exact original text, the record of the style that was on when
    the picker opened, and the record of whatever preview is on disk now.

    The stored record follows the file: each preview saves its record before
    it writes (Invoke-VSCodePreview), and Esc puts the record of what it puts
    back in step with the file -- before the write when a style goes back in,
    after it when the write only takes ours out (Restore-VSCodePreview). So a
    picker killed outright -- the terminal closed, no Esc and no finally --
    leaves a preview the stored record names, and tstyles reset takes it out.
    RecordAhead marks the times the record may have got ahead of the file.

    Asks first (once per editor) when nobody has answered -- the picker calls
    this below its console guard, so there is someone to ask. $null when the
    writer is off, or when the file does not parse: previews would refuse
    anyway, and Enter reports the reason through the publisher.
    #>

    [CmdletBinding()]
    param()

    if ((Request-VSCodeWriterConsent) -ne 'active') { return $null }
    $w = Get-VSCodeWriterState
    if ($w.Status -ne 'active') { return $null }

    $existed = [System.IO.File]::Exists($w.Path)
    # A file that cannot be read gets no session: there would be nothing exact
    # to put back on Esc, and a preview written over it would replace it. Enter
    # still reaches the publisher, which says why it wrote nothing.
    try { $text = Read-VSCodeSettingsText -Path $w.Path } catch { return $null }
    if (-not (Test-VSCodeSettingsReadable -Text $text)) { return $null }

    $start = $null
    if ($w.Entry) { $start = $w.Entry.record }
    return @{
        Variant         = $w.Variant
        Path            = $w.Path
        OriginalText    = $text
        OriginalExisted = $existed
        StartRecord     = $start
        OnDiskRecord    = $start
        LastWrittenText = $null
        Cache           = @{}
        BackedUp        = $false
        Backup          = $null
        # Set while the stored record may name text the file does not hold:
        # between a preview's or a commit's record save and its write, and
        # after a failed write whose record could not be put back.
        # Restore-VSCodePreview puts the right one back.
        RecordAhead     = $false
    }
}

function Invoke-VSCodePreview {
    <#
    .SYNOPSIS
    Put one style's preview into settings.json. Returns written, current,
    changed, wontparse or failed.

    .DESCRIPTION
    Always computed from the ORIGINAL text, undoing the style that was on when
    the picker opened -- never from the previous preview, so previews cannot
    build on each other. Cached per style: one apply costs 0.2-1.3 s depending
    on the file (measured), and arrowing back to a style should not pay twice.

    The preview's record is saved BEFORE the preview is written, as for every
    write in this file, and put back if the write fails; a record that cannot
    be saved writes no preview. A picker can end without its finally: closing
    the terminal tab, Kill Terminal and quitting VS Code all send SIGHUP, which
    ends pwsh outright. The preview left behind is then one the stored record
    names, and tstyles reset takes it out. A preview is still not an apply --
    Enter is. Esc over an unchanged file puts the stored record back as the
    picker found it; after an outside edit it saves the record of what it
    merged (see Restore-VSCodePreview). A write that fails, with a record that
    cannot be put back either, leaves RecordAhead set for Esc to put right.

    'changed': the file no longer holds what the session last left in it, so
    somebody else wrote it while the picker was open. Nothing is written over
    that edit. Esc and Enter both start from the file as it is now, so both
    keep the edit.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$Session,
        [Parameter(Mandatory)][string]$StyleName,
        [Parameter(Mandatory)][string]$StyleDir
    )

    if (-not $Session.Cache.ContainsKey($StyleName)) {
        $base = $Session.OriginalText
        if (-not $base.Trim()) { $base = '{}' }
        $a = Invoke-VSCodeStyleApply -Text $base -StyleDir $StyleDir -StyleName $StyleName -PreviousRecord $Session.StartRecord
        if ($a.Status -ne 'applied') { return 'wontparse' }
        $Session.Cache[$StyleName] = @{ Text = $a.Text; Record = $a.Record }
    }
    $p = $Session.Cache[$StyleName]

    $onDiskText = $Session.OriginalText
    if ($null -ne $Session.LastWrittenText) { $onDiskText = $Session.LastWrittenText }
    if ($p.Text -ceq $onDiskText) {
        $Session.OnDiskRecord = $p.Record
        return 'current'
    }

    try {
        # VS Code's settings UI, Settings Sync or a hand edit can write the
        # file while the picker is open. A preview computed from the original
        # would throw that edit away.
        if ((Read-VSCodeSettingsText -Path $Session.Path) -cne $onDiskText) { return 'changed' }
        if (-not $Session.BackedUp) {
            if (-not $Session.StartRecord -and $Session.OriginalExisted -and $Session.OriginalText) {
                $Session.Backup = Save-FirstTouchBackup -Path $Session.Path -Content $Session.OriginalText
            }
            $Session.BackedUp = $true
        }
        # Record first, as for every write that puts keys in. A picker can die
        # without its finally -- closing the terminal ends pwsh on SIGHUP -- and
        # the preview left behind must still be one a reset can take out.
        Set-VSCodeStateEntry -Variant $Session.Variant -SettingsPath $Session.Path -Record $p.Record
        $Session.RecordAhead = $true
    } catch {
        return 'failed'
    }
    try {
        Write-SettingsAtomic -Path $Session.Path -Json $p.Text
    } catch {
        # The file still holds what OnDiskRecord describes, so the record must
        # too. If putting it back fails as well, RecordAhead stays set and
        # Restore-VSCodePreview tries again on Esc.
        try {
            Set-VSCodeStateEntry -Variant $Session.Variant -SettingsPath $Session.Path -Record $Session.OnDiskRecord
            $Session.RecordAhead = $false
        } catch {
            # Left to RecordAhead: nothing more can be done from here.
        }
        return 'failed'
    }
    $Session.LastWrittenText = $p.Text
    $Session.OnDiskRecord = $p.Record
    $Session.RecordAhead = $false
    return 'written'
}

function Restore-VSCodePreview {
    <#
    .SYNOPSIS
    Esc, Ctrl+C or an error in the picker: take the preview back out.
    Returns @{ Status; Path; Kept; Replaced; Reason; Detail }, Status none,
    restored, merged, failed or recordstale. Never throws.

    .DESCRIPTION
    Two cases, decided by whether the file still holds what the picker last
    wrote.

      It does -> the original text goes back, byte for byte -- or the file
                    goes away again, if there was none -- and the stored
                    record goes back to the one the picker found. With a style
                    on before the picker, that record goes back first, as the
                    publisher's does. With none, the file goes first, so a
                    terminal closed in between leaves the preview's record
                    beside the user's own file rather than a preview no record
                    names; a record that then cannot be cleared is Detail.
      It doesn't -> somebody edited settings.json while the picker was open,
                    most likely in VS Code itself. Writing the original back
                    would throw that edit away. Instead the preview is undone
                    by its own record, which leaves the edit standing, and the
                    style that was on before is put back over it. Kept and
                    Replaced split the way the publisher's do, through the
                    same Split-VSCodeKept: when the starting style sets a key
                    the user edited, that key is replaced, and the edited
                    value is what a reset restores. The record is saved
                    before the file when the starting style goes back in;
                    with none to put back, after it, as above.

    Nothing of the picker's in the file is 'none' -- unless a preview's or a
    commit's write failed and its record could not be put back (RecordAhead).
    Then the record the file matches goes back here, and 'recordstale' says so
    if even that cannot be written.

    A file that no longer parses is not undone at all. The record's offsets no
    longer mean what the record thinks, so this fails rather than splices.

    On failure the stored record is left naming the preview, because the
    preview is what the file still holds: `tstyles reset` can then take it
    out. Every preview saved its own record before it was written, so a
    failure before this has moved the record leaves that one on file. A
    failure after it moved the record puts it back, and so does a failure
    while RecordAhead says an earlier write left the record ahead of the file.
    If putting it back fails too, Detail says so, and the message does not
    promise the reset.

    After either success nothing is left written, so a second call returns
    'none' -- which is what the picker's finally needs after an Esc.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][hashtable]$Session)

    $out = @{ Status = 'none'; Path = $Session.Path; Kept = @(); Replaced = @(); Reason = $null; Detail = $null }
    if ($null -eq $Session.LastWrittenText) {
        # Nothing of the picker's is in settings.json. A preview whose write
        # failed, and whose record could not be put back either, still left the
        # record naming text that never landed: put back the one the file
        # matches.
        if ($Session.RecordAhead) {
            try {
                Set-VSCodeStateEntry -Variant $Session.Variant -SettingsPath $Session.Path -Record $Session.OnDiskRecord
                $Session.RecordAhead = $false
            } catch {
                $out.Status = 'recordstale'
                $out.Reason = "$_"
            }
        }
        return $out
    }

    # The original text goes back byte for byte -- or the file goes away again,
    # if there was none.
    $putOriginalBack = {
        if ($Session.OriginalExisted) {
            Write-SettingsAtomic -Path $Session.Path -Json $Session.OriginalText
        } elseif ([System.IO.File]::Exists($Session.Path)) {
            Remove-Item -LiteralPath $Session.Path -Force -ErrorAction Stop
        }
    }

    # Set once a record save below has landed. Until then the preview's own
    # record, saved before the preview was written, is the one on file.
    $recordMoved = $false
    try {
        $now = Read-VSCodeSettingsText -Path $Session.Path
        if ($now -ceq $Session.LastWrittenText) {
            if ($Session.StartRecord) {
                # A style was on before the picker: its record goes back first,
                # as the publisher's does. Either order leaves a moment when the
                # record and the file name different styles.
                Set-VSCodeStateEntry -Variant $Session.Variant -SettingsPath $Session.Path -Record $Session.StartRecord
                $recordMoved = $true
                & $putOriginalBack
            } else {
                # No style before the picker: this write only takes ours out, so
                # the file goes back FIRST, as in the reset. A terminal closed
                # between the two writes then leaves the preview's record beside
                # the user's own file -- a reset reads its keys as already gone,
                # or as the user's own -- rather than a preview that no record
                # names.
                & $putOriginalBack
                try {
                    Set-VSCodeStateEntry -Variant $Session.Variant -SettingsPath $Session.Path -Record $null
                } catch {
                    $out.Detail = 'its record could not be cleared'
                }
            }
            $Session.OnDiskRecord = $Session.StartRecord
            # The file is back. A record that could not then be cleared names
            # keys the file no longer holds -- the harmless kind, reported in
            # Detail -- so there is nothing for a later call to put right.
            $Session.RecordAhead = $false
            $out.Status = 'restored'
        } else {
            if (-not (Test-VSCodeSettingsReadable -Text $now)) {
                throw 'it changed while the picker was open and no longer parses as a JSON object'
            }
            $undo = Invoke-VSCodeStyleReset -Text $now -Record $Session.OnDiskRecord
            $text = $undo.Text
            $record = $null
            $startStyle = $null
            if ($Session.StartRecord) { $startStyle = $Session.StartRecord.Style }
            if ($startStyle) {
                $startDir = Get-StyleDir -StyleName $startStyle
                if ($startDir) {
                    $re = Invoke-VSCodeStyleApply -Text $text -StyleDir $startDir -StyleName $startStyle
                    if ($re.Status -eq 'applied') { $text = $re.Text; $record = $re.Record }
                }
            }
            if ($record) {
                # Record first: this write puts the starting style's keys back in.
                Set-VSCodeStateEntry -Variant $Session.Variant -SettingsPath $Session.Path -Record $record
                $recordMoved = $true
                Write-SettingsAtomic -Path $Session.Path -Json $text
            } else {
                # No style to put back: this write only takes ours out, so the
                # file goes first, as in the restored arm above.
                Write-SettingsAtomic -Path $Session.Path -Json $text
                try {
                    Set-VSCodeStateEntry -Variant $Session.Variant -SettingsPath $Session.Path -Record $null
                } catch {
                    $out.Detail = 'its record could not be cleared'
                }
            }
            $Session.OnDiskRecord = $record
            $Session.RecordAhead = $false
            $split = Split-VSCodeKept -Kept $undo.Kept -Record $record
            $out.Status   = 'merged'
            $out.Kept     = $split.Left
            $out.Replaced = $split.Replaced
        }
        $Session.LastWrittenText = $null
    } catch {
        # Reason first, before the try below can replace $_.
        $out.Status = 'failed'
        $out.Reason = "$_"
        # The record goes back to what the file still holds if this call moved
        # it, or if an earlier write left it ahead of the file (RecordAhead: a
        # preview, or a commit, whose write and rollback both failed). Left
        # alone otherwise, it is already that record.
        if ($recordMoved -or $Session.RecordAhead) {
            try {
                Set-VSCodeStateEntry -Variant $Session.Variant -SettingsPath $Session.Path -Record $Session.OnDiskRecord
                $Session.RecordAhead = $false
            } catch {
                $out.Detail = 'its undo record could not be saved either'
                $Session.RecordAhead = $true
            }
        }
    }
    return $out
}

function Undo-VSCodeFailedCommit {
    <#
    .SYNOPSIS
    The picker's Enter when Publish-StyleVSCodeConfig did not write: put the
    file, and the record with it, back as the picker found them. Returns
    Restore-VSCodePreview's result.

    .DESCRIPTION
    A commit whose write and rollback both failed says so in -Result's Detail
    ("its undo record could not be restored either"). The restore here can
    put that record right, and once it has -- the session no longer marks the
    record ahead of the file -- that sentence is no longer true, so it is
    dropped before the picker prints the commit's result.

    Only on 'writefailed'. On 'wontparse' Detail is why the file was refused,
    and that stays true whatever the restore does. A record the restore could
    not clear also leaves RecordAhead false -- it names keys the file no
    longer holds -- and the restore's own Detail then says so.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$Session,
        [Parameter(Mandatory)][hashtable]$Result
    )

    $restore = Restore-VSCodePreview -Session $Session
    if ($Result.Status -eq 'writefailed' -and -not $Session.RecordAhead) { $Result.Detail = $null }
    return $restore
}

function Show-VSCodeRestoreResult {
    # What the picker's Esc did to settings.json, when that is worth a line:
    # quiet for 'none' and 'restored', which are the file exactly as it was --
    # unless the record beside it could not be put right.
    [CmdletBinding()]
    param([AllowNull()][hashtable]$Result)

    if (-not $Result) { return }
    # Filtered, not just wrapped: @() around a missing member is one $null,
    # which would print a line naming nothing.
    $replaced = @($Result.Replaced | Where-Object { $_ })
    $kept     = @($Result.Kept | Where-Object { $_ })
    switch ($Result.Status) {
        'restored' {
            if ($Result.Detail) {
                Write-Host ""
                Write-Host (" settings.json is back as it was, but {0}, so the next apply or" -f $Result.Detail) -ForegroundColor Yellow
                Write-Host " reset may wrongly call some of your settings hand edits." -ForegroundColor Yellow
            }
        }
        'recordstale' {
            Write-Host ""
            Write-Host (" {0}" -f $Result.Reason) -ForegroundColor Yellow
            Write-Host " settings.json was not changed, but the record in that file still names a style" -ForegroundColor Yellow
            Write-Host " that never reached it, so the next apply or reset may wrongly call some of your" -ForegroundColor Yellow
            Write-Host " settings hand edits." -ForegroundColor Yellow
        }
        'merged' {
            Write-Host ""
            Write-Host " settings.json changed while the picker was open, so the preview was taken" -ForegroundColor Yellow
            Write-Host " back out of it instead of the file being put back as it was." -ForegroundColor Yellow
            if ($replaced.Count -eq 0) {
                Write-Host " Your edit is still there." -ForegroundColor Yellow
            } else {
                Write-Host " What you changed is still there, except where the style you started with" -ForegroundColor Yellow
                Write-Host " sets a key again:" -ForegroundColor Yellow
                foreach ($k in $replaced) {
                    Write-Host (" Replaced {0}, which you had changed by hand; tstyles reset puts yours back." -f $k) -ForegroundColor DarkGray
                }
            }
            foreach ($k in $kept) {
                Write-Host (" Left {0} as you set it." -f $k) -ForegroundColor DarkGray
            }
            if ($Result.Detail) {
                Write-Host (" But {0}, so the next apply or reset may wrongly call some of your" -f $Result.Detail) -ForegroundColor Yellow
                Write-Host " settings hand edits." -ForegroundColor Yellow
            }
        }
        'failed' {
            Write-Host ""
            Write-Host (" Could not put {0} back: {1}" -f $Result.Path, $Result.Reason) -ForegroundColor Red
            if ($Result.Detail) {
                Write-Host (" The preview is still in it, and {0}, so tstyles reset cannot take it out." -f $Result.Detail) -ForegroundColor Red
            } else {
                Write-Host " The preview is still in it; tstyles reset takes it out once the file" -ForegroundColor Red
                Write-Host " parses and can be written." -ForegroundColor Red
            }
        }
    }
}

function Show-VSCodeWriterStatus {
    # `tstyles vscode` with no action: what is on record, for this editor and
    # every other one that has been asked.
    [CmdletBinding()]
    param()

    $t = Resolve-VSCodeTarget
    $state = Read-VSCodeState
    Write-Host ""
    if (-not $state.Ok) {
        Write-Host (" Could not read {0}." -f $state.Path) -ForegroundColor Yellow
        Write-Host " It records what tstyles wrote to settings.json. Fix it: deleting it lets tstyles" -ForegroundColor Yellow
        Write-Host " write again, but loses its record of any style already saved there." -ForegroundColor Yellow
        Write-Host ""
        return
    }

    if ($t.Variant) {
        Write-Host (" This terminal belongs to {0}." -f $t.Variant)
        if ($t.Status -eq 'nodir') {
            Write-Host " Its settings folder does not exist yet, so nothing is saved there:" -ForegroundColor Yellow
            Write-Host (" {0}" -f [System.IO.Path]::GetDirectoryName($t.Path)) -ForegroundColor DarkGray
        }
    } elseif ((Get-TerminalKind) -eq 'VSCode') {
        Write-Host " This looks like a VS Code-family terminal, but tstyles can't tell which" -ForegroundColor Yellow
        Write-Host " editor it is, so it will not write any settings.json from here." -ForegroundColor Yellow
    }

    $names = @($state.Entries.Keys | Sort-Object)
    if ($t.Variant -and $names -notcontains $t.Variant) { $names = @($t.Variant) + $names }
    if ($names.Count -eq 0) {
        Write-Host " No VS Code editor has been asked yet." -ForegroundColor DarkGray
        Write-Host ""
        return
    }

    foreach ($n in $names) {
        $e = $state.Entries[$n]
        $consent = ''; $style = $null; $path = $null
        if ($e) {
            $consent = $e.consent
            $path = $e.settingsPath
            if ($e.record) { $style = $e.record.Style }
        }
        if (-not $path -and $n -eq $t.Variant) { $path = $t.Path }
        $saving = switch ($consent) {
            'yes'   { 'saving: on' }
            'no'    { 'saving: off' }
            default { 'saving: not asked yet' }
        }
        $saved = 'no style saved'
        if ($style) { $saved = "style saved: $style" }
        Write-Host (" {0,-16}{1}, {2}" -f $n, $saving, $saved)
        if ($path) { Write-Host (" {0,-16}{1}" -f '', $path) -ForegroundColor DarkGray }
    }
    Write-Host ""
}

function Invoke-TerminalStyleVSCode {
    <#
    .SYNOPSIS
    `tstyles vscode [on|off]`: the way to change an answer an apply asks for
    only once.

    .DESCRIPTION
    on records a yes for this editor. It writes nothing now -- the next apply
         does, and saying otherwise would be a claim about a write that has
         not happened.
    off takes the saved style back out FIRST, then records a no (and clears
         the record, should the reset have left it). If the style cannot be
         taken out, the answer on record is not changed and the command says
         so: a no on record beside keys still in the file would leave the user
         believing they had been removed.
    #>

    [CmdletBinding()]
    param([string]$Action)

    if (-not $Action) { Show-VSCodeWriterStatus; return }
    if ($Action -ne 'on' -and $Action -ne 'off') {
        Write-Host ""
        Write-Host (" Unknown vscode action '{0}'. Use: tstyles vscode [on|off]" -f $Action) -ForegroundColor Yellow
        Write-Host ""
        return
    }

    $t = Resolve-VSCodeTarget
    Write-Host ""
    if (-not $t.Variant) {
        Write-Host " Can't tell which editor this terminal belongs to -- VS Code, Insiders," -ForegroundColor Yellow
        Write-Host " VSCodium, Cursor or Windsurf -- so there is no settings.json to turn this" -ForegroundColor Yellow
        Write-Host (" {0} for. Run it from that editor's own terminal." -f $Action) -ForegroundColor Yellow
        Write-Host ""
        return
    }

    if ($Action -eq 'on') {
        try {
            Set-VSCodeStateEntry -Variant $t.Variant -SettingsPath $t.Path -Consent 'yes'
        } catch {
            Write-Host (" Could not save that to {0}." -f (Get-VSCodeStatePath)) -ForegroundColor Yellow
            Write-Host ""
            return
        }
        Write-Host (" From the next apply, tstyles saves styles to {0}'s settings.json:" -f $t.Variant)
        Write-Host (" {0}" -f $t.Path) -ForegroundColor DarkGray
        Write-Host " Nothing was written now." -ForegroundColor DarkGray
        if ($t.Status -eq 'nodir') {
            Write-Host " That folder does not exist yet, so an apply will say so until the editor" -ForegroundColor Yellow
            Write-Host " has created it." -ForegroundColor Yellow
        }
        Write-Host ""
        return
    }

    $r = Reset-StyleVSCodeConfig -Variant $t.Variant
    if ($r.Status -ne 'reset' -and $r.Status -ne 'norecord') {
        Show-VSCodeResetResult -Result $r
        Write-Host " Saving was not changed; the style has to come out of settings.json first." -ForegroundColor Yellow
        Write-Host ""
        return
    }
    try {
        # -Record $null too: a reset that put the file back but could not clear
        # its record gets a second chance here, so the no is not left beside a
        # record of keys the file no longer holds.
        Set-VSCodeStateEntry -Variant $t.Variant -SettingsPath $t.Path -Consent 'no' -Record $null
    } catch {
        Show-VSCodeResetResult -Result $r
        # Read back, not assumed: "still on" is only true of an editor that had
        # said yes. One never asked, or already off, is simply left as it was.
        $now = Read-VSCodeState
        if ($now.Ok -and $now.Entries[$t.Variant] -and $now.Entries[$t.Variant].consent -eq 'yes') {
            Write-Host (" Could not save that to {0}, so saving is still on." -f (Get-VSCodeStatePath)) -ForegroundColor Yellow
        } else {
            Write-Host (" Could not save that to {0}." -f (Get-VSCodeStatePath)) -ForegroundColor Yellow
        }
        Write-Host ""
        return
    }
    # Shown after the write above, not before: that write cleared any record
    # the reset could not, so a warning about one would no longer be true. On
    # 'reset', a record left uncleared is the only thing Detail ever says.
    if ($r.Status -eq 'reset') { $r.Detail = $null }
    Show-VSCodeResetResult -Result $r
    Write-Host (" tstyles will not write {0}'s settings.json again." -f $t.Variant)
    Write-Host " tstyles vscode on turns it back on."
    Write-Host ""
}

function Get-VSCodeUninstallTarget {
    # The editors whose settings.json holds a saved style: one object per
    # editor, @{ Variant; Path }, from the RECORD rather than from detection --
    # uninstall runs from any terminal, and has to reach every editor.
    [CmdletBinding()]
    param([Parameter(Mandatory)][hashtable]$State)

    if (-not $State.Ok) { return }
    foreach ($name in @($State.Entries.Keys | Sort-Object)) {
        $e = $State.Entries[$name]
        if ($e.record) { [pscustomobject]@{ Variant = $name; Path = $e.settingsPath } }
    }
}