SessionSelection.ps1

function Get-CopilotResumeCandidate {
    [CmdletBinding()]
    [OutputType('CopilotSession')]
    param()

    $sessionStateDir = Join-Path (Get-CopilotHome) '.copilot' 'session-state'
    $ignoredSessionNames = @(
        'Apply context_board add/prune updates for this session. End the turn with a 2-3 sentence summary of the changes you made to the context_board.'
        'Analyze the session file and write the session insights result to the specified output file as described in the instructions.'
        'Session File Path:'
    )
    if (-not (Test-Path $sessionStateDir)) {
        return
    }

    $cwd = (Get-Location).Path
    $currentBranch = try { git symbolic-ref --short HEAD 2>$null } catch { $null }
    $sessions = @(Get-ChildItem $sessionStateDir -Directory |
        ForEach-Object {
            $sessionPath = Resolve-CopilotSessionPath -Id $_.Name -ErrorAction Stop
            if (-not $sessionPath) {
                throw "Copilot session candidate '$($_.Name)' no longer exists."
            }
            $wsFile = Join-Path $sessionPath 'workspace.yaml'
            if (Test-Path $wsFile) {
                $content = Get-Content $wsFile -Raw
                $sessionCwd = Get-CopilotWorkspaceField -Content $content -Field 'cwd'
                $updatedAt = Get-CopilotWorkspaceField -Content $content -Field 'updated_at'
                $summary = Get-CopilotWorkspaceField -Content $content -Field 'summary'
                $sessionBranch = Get-CopilotWorkspaceField -Content $content -Field 'branch'
                $sessionName = Get-CopilotWorkspaceField -Content $content -Field 'name'
                if ($sessionName) { $sessionName = ($sessionName -split '\r?\n', 2)[0].Trim() }
                $displayName = $sessionName ?? $summary ?? '(no summary)'
                if ($sessionCwd -eq $cwd -and $updatedAt -and $sessionName -notin $ignoredSessionNames) {
                    [PSCustomObject]@{
                        PSTypeName = 'CopilotSession'
                        Id         = $_.Name
                        Name       = $displayName
                        Summary    = $displayName
                        Branch     = $sessionBranch
                        UpdatedAt  = [DateTimeOffset]::Parse($updatedAt)
                        Cwd        = $sessionCwd
                    }
                }
            }
        } |
        Sort-Object UpdatedAt -Descending)

    # Prefer the current branch only when it has matches; otherwise retain all.
    if ($currentBranch -and $sessions.Count -gt 0) {
        $branchMatches = @($sessions | Where-Object { $_.Branch -and $_.Branch -eq $currentBranch })
        if ($branchMatches.Count -gt 0) {
            $sessions = $branchMatches
        }
    }
    return $sessions
}

function ConvertTo-CopilotSessionDisplayText {
    param([string]$Text)

    # Keep Unicode graphemes (including joiners) intact, but prevent terminal
    # controls and line separators from acting as UI rather than session data.
    $Text -replace '[\p{Cc}\p{Zl}\p{Zp}]', ' '
}

function Invoke-CopilotSessionChoice {
    [CmdletBinding()]
    [OutputType('CopilotSession')]
    param(
        [Parameter(Mandatory)]
        [object[]]$Sessions,

        [Parameter(Mandatory)]
        [string]$Caption,

        [Parameter(Mandatory)]
        [string]$Message,

        [Parameter(Mandatory)]
        [string]$ExitLabel,

        [Parameter(Mandatory)]
        [string]$ExitHelp
    )

    $nameCounts = [System.Collections.Generic.Dictionary[string, int]]::new([StringComparer]::Ordinal)
    $displaySessions = @(
        foreach ($session in $Sessions) {
            $name = ConvertTo-CopilotSessionDisplayText $session.Summary
            $elements = [System.Globalization.StringInfo]::new($name)
            $displayName = if ($elements.LengthInTextElements -gt 80) {
                $elements.SubstringByTextElements(0, 77) + '...'
            } else {
                $name
            }
            if (-not $nameCounts.ContainsKey($displayName)) { $nameCounts[$displayName] = 0 }
            $nameCounts[$displayName]++
            [pscustomobject]@{
                Name = $name
                DisplayName = $displayName
                Branch = ConvertTo-CopilotSessionDisplayText $session.Branch
            }
        }
    )
    $choices = [System.Collections.ObjectModel.Collection[System.Management.Automation.Host.ChoiceDescription]]::new()
    $messageLines = [System.Collections.Generic.List[string]]::new()
    $messageLines.Add($Message)
    $messageLines.Add('')
    for ($i = 0; $i -lt $Sessions.Count; $i++) {
        $session = $Sessions[$i]
        $display = $displaySessions[$i]
        $number = ($i + 1).ToString([System.Globalization.CultureInfo]::InvariantCulture)
        $branchSuffix = if ($nameCounts[$display.DisplayName] -gt 1 -and -not [string]::IsNullOrWhiteSpace($display.Branch)) {
            " ($($display.Branch))"
        } else {
            ''
        }
        $messageLines.Add("$number. $($display.DisplayName)$branchSuffix")
        $updatedAt = if ($session.UpdatedAt) { $session.UpdatedAt.ToString('o') } else { '(unknown)' }
        $help = "Id: $($session.Id)`nName: $($display.Name)`nRepository: $($session.Repository)`nBranch: $($display.Branch)`nCwd: $($session.Cwd)`nUpdated: $updatedAt`nEvents: $($session.EventCount)"
        # Full numeric labels, without '&', also work beyond nine choices.
        $choices.Add([System.Management.Automation.Host.ChoiceDescription]::new($number, $help))
    }
    $choices.Add([System.Management.Automation.Host.ChoiceDescription]::new($ExitLabel, $ExitHelp))
    $messageLines.Add('')
    $messageLines.Add('Choose a number. Use choice help for full names, IDs, and workspace details.')

    # A method failure must terminate even when the caller uses Continue.
    $ErrorActionPreference = 'Stop'
    try {
        if ($null -eq $Host.UI) {
            throw [System.NotSupportedException]::new('The active host has no user interface.')
        }
        $selected = $Host.UI.PromptForChoice(
            $Caption, ($messageLines -join "`n"), $choices, -1)
    } catch {
        throw [System.InvalidOperationException]::new(
            "Session selection requires a host with working PromptForChoice input. Supply -SessionSelector or choose a session explicitly. Host error: $($_.Exception.Message)",
            $_.Exception)
    }
    if ($selected -isnot [int] -or $selected -lt 0 -or $selected -ge $choices.Count) {
        throw "The host returned an invalid session choice '$selected'. Supply -SessionSelector or choose a session explicitly."
    }
    if ($selected -eq $Sessions.Count) { return $null }
    return $Sessions[$selected]
}

function Invoke-CopilotLaunchSessionPicker {
    [CmdletBinding()]
    [OutputType('CopilotSession')]
    param(
        [Parameter(Mandatory)]
        [object[]]$Sessions,

        [scriptblock]$SessionSelector
    )

    if ($SessionSelector) {
        return Invoke-CopilotSessionSelector -Sessions $Sessions -SessionSelector $SessionSelector
    }
    $picked = Invoke-CopilotSessionChoice -Sessions $Sessions -Caption 'Choose Copilot session' `
        -Message 'Choose a session for the current folder, or New session to start without resuming.' `
        -ExitLabel '&New session' -ExitHelp 'Start a new session instead of resuming an existing one.'
    if ($picked) {
        Write-Host "Resuming session: $(ConvertTo-CopilotSessionDisplayText $picked.Summary)" -ForegroundColor Cyan
    }
    return $picked
}

function Invoke-CopilotSessionSelector {
    [CmdletBinding()]
    [OutputType('CopilotSession')]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [object[]]$Sessions,

        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [scriptblock]$SessionSelector
    )

    if ($Sessions.Count -eq 0) {
        return $null
    }

    # Keep canonical metadata separate from the objects given to caller code.
    $candidates = @($Sessions | ForEach-Object {
        if ($_.PSObject.TypeNames -notcontains 'CopilotSession') {
            throw 'Session candidates must be CopilotSession objects.'
        }
        if (-not (Resolve-CopilotSessionPath -Id $_.Id -ErrorAction Stop)) {
            throw "Copilot session candidate '$($_.Id)' no longer exists."
        }
        $_.PSObject.Copy()
    })
    $selectorSessions = @($candidates | ForEach-Object { $_.PSObject.Copy() })
    $output = @(& $SessionSelector $selectorSessions 2>&1)
    foreach ($item in $output) {
        if ($item -is [System.Management.Automation.ErrorRecord]) {
            $PSCmdlet.ThrowTerminatingError($item)
        }
    }

    if ($output.Count -eq 0 -or ($output.Count -eq 1 -and $null -eq $output[0])) {
        return $null
    }
    if ($output.Count -ne 1 -or
        $output[0].PSObject.TypeNames -notcontains 'CopilotSession' -or
        $output[0].Id -isnot [string] -or
        [string]::IsNullOrWhiteSpace($output[0].Id)) {
        throw 'SessionSelector must return exactly one CopilotSession candidate or $null (no output).'
    }

    $selectedId = $output[0].Id
    $selected = @($candidates | Where-Object { $_.Id -ceq $selectedId })
    if ($selected.Count -ne 1) {
        throw "SessionSelector returned a session that is not a candidate: '$selectedId'."
    }
    if (-not (Resolve-CopilotSessionPath -Id $selectedId -ErrorAction Stop)) {
        throw "Selected Copilot session '$selectedId' no longer exists."
    }
    return $selected[0]
}