private/Invoke-WtwWaveProject.ps1

# Wave Terminal integration for wtw.
#
# Wave (https://waveterm.dev) ships `wsh`, a CLI that talks to the running app
# over the RPC token Wave injects into its own shells (WAVETERM_JWT /
# WAVETERM_SWAPTOKEN, plus WAVETERM_TABID / WAVETERM_BLOCKID). The supported
# surface used here:
# wsh term <dir> -> "terminal block created: block:<uuid>"
# wsh blocks list --tab <id> --json -> [{blockid,tabid,view,meta:{cmd:cwd,...}}]
# wsh setmeta -b <block> key=value ... -> frame:title / frame:bordercolor
# wsh focusblock -b <block>
# wsh setconfig app:tabbar=left -> vertical tab bar
#
# wsh has no "new workspace" or "new tab" command, and it only works from a
# shell Wave itself spawned. So unlike cmux/wmux, a wtw target maps to a
# terminal block in the current Wave tab: reuse the block already rooted at
# the target, otherwise split a new one there.

function Get-WtwWshExe {
    [CmdletBinding()]
    param()

    if ($env:WTW_WSH -and (Test-Path $env:WTW_WSH)) { return $env:WTW_WSH }

    $cmd = Get-Command wsh -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
    if ($cmd -and $cmd.Source) { return $cmd.Source }

    $exeName = if ($IsWindows) { 'wsh.exe' } else { 'wsh' }
    $dirs = [System.Collections.Generic.List[string]]::new()
    # Wave sets this inside its shells, even when PATH was rewritten by a profile.
    if ($env:WAVETERM_WSHBINDIR) { $dirs.Add($env:WAVETERM_WSHBINDIR) }
    $homeDir = if ($HOME) { $HOME } else { $env:USERPROFILE }
    if ($homeDir) {
        $dirs.Add((Join-Path $homeDir '.waveterm/bin'))
        $dirs.Add((Join-Path $homeDir '.local/share/waveterm/bin'))
        # Snap (classic) keeps its data dir under ~/snap/waveterm/<rev>.
        $dirs.Add((Join-Path $homeDir 'snap/waveterm/current/.local/share/waveterm/bin'))
        $dirs.Add((Join-Path $homeDir 'Library/Application Support/waveterm/bin'))
    }
    if ($env:LOCALAPPDATA) { $dirs.Add((Join-Path $env:LOCALAPPDATA 'waveterm/bin')) }

    foreach ($dir in $dirs) {
        $candidate = Join-Path $dir $exeName
        if (Test-Path $candidate) { return $candidate }
    }

    return $null
}

function Test-WtwWaveSession {
    <#
    .SYNOPSIS
        True when this shell was spawned by Wave, so wsh can reach the app.
    #>

    [CmdletBinding()]
    param()

    return [bool](($env:WAVETERM_JWT -or $env:WAVETERM_SWAPTOKEN) -and $env:WAVETERM_TABID)
}

function Invoke-WtwWshCommand {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string[]] $ArgumentList
    )

    $exe = Get-WtwWshExe
    if (-not $exe) {
        return [PSCustomObject]@{ ExitCode = 127; Output = 'wsh not found' }
    }

    Write-Verbose "wsh command: $exe $($ArgumentList -join ' ')"
    $output = & $exe @ArgumentList 2>&1
    $exitCode = $LASTEXITCODE
    if ($null -eq $exitCode) { $exitCode = 0 }
    $text = (@($output) | ForEach-Object { "$_" }) -join [Environment]::NewLine
    # wsh prints "Error: ..." and still exits 0 for some RPC failures.
    if ($exitCode -eq 0 -and $text -match '^\s*Error:') { $exitCode = 1 }
    return [PSCustomObject]@{ ExitCode = $exitCode; Output = $text.Trim() }
}

function Get-WtwWaveTabBlocks {
    [CmdletBinding()]
    param([string] $TabId = $env:WAVETERM_TABID)

    if (-not $TabId) { return @() }
    $result = Invoke-WtwWshCommand -ArgumentList @('blocks', 'list', '--view=term', "--tab=$TabId", '--json')
    if ($result.ExitCode -ne 0 -or -not $result.Output) { return @() }

    try {
        return @($result.Output | ConvertFrom-Json -Depth 20 -ErrorAction Stop)
    } catch {
        return @()
    }
}

function Find-WtwWaveBlock {
    <#
    .SYNOPSIS
        Find a terminal block in the current Wave tab rooted at the target
        directory (cmd:cwd), falling back to a block titled with the pretty name.
    #>

    [CmdletBinding()]
    param(
        [string] $PrettyName,
        [string] $ProjectPath
    )

    $blocks = @(Get-WtwWaveTabBlocks)
    if ($ProjectPath) {
        $fullPath = [System.IO.Path]::GetFullPath($ProjectPath).TrimEnd('/', '\')
        $comparison = if ($IsWindows) { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal }
        foreach ($block in $blocks) {
            $cwd = Get-WtwPropertyValue -Object $block.meta -Name 'cmd:cwd'
            if (-not $cwd) { continue }
            $blockPath = [System.IO.Path]::GetFullPath("$cwd").TrimEnd('/', '\')
            if ([string]::Equals($blockPath, $fullPath, $comparison)) { return $block }
        }
    }

    if (-not $PrettyName) { return $null }
    foreach ($block in $blocks) {
        $title = Get-WtwPropertyValue -Object $block.meta -Name 'frame:title'
        if ([string]::Equals("$title", $PrettyName, [StringComparison]::Ordinal)) { return $block }
    }

    return $null
}

function Set-WtwWaveBlockFrame {
    <#
    .SYNOPSIS
        Title the block with the wtw pretty name and tint its frame with the
        worktree's Peacock color.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string] $BlockId,
        [string] $PrettyName,
        [string] $Color
    )

    $meta = @()
    if ($PrettyName) { $meta += "frame:title=$PrettyName" }
    if ($Color) { $meta += @("frame:bordercolor=$Color", "frame:activebordercolor=$Color") }
    if ($meta.Count -eq 0) { return }

    Invoke-WtwWshCommand -ArgumentList (@('setmeta', '-b', $BlockId) + $meta) | Out-Null
}

function Get-WtwWaveSettingsPath {
    <#
    .SYNOPSIS
        Path to Wave's settings.json, or $null when no config dir is found.
    #>

    [CmdletBinding()]
    param()

    $dirs = [System.Collections.Generic.List[string]]::new()
    if ($env:WAVETERM_CONFIG_HOME) { $dirs.Add($env:WAVETERM_CONFIG_HOME) }
    $homeDir = if ($HOME) { $HOME } else { $env:USERPROFILE }
    if ($homeDir) {
        # Snap (classic) keeps its config under ~/snap/waveterm/<rev>.
        $dirs.Add((Join-Path $homeDir 'snap/waveterm/current/.config/waveterm'))
        $dirs.Add((Join-Path $homeDir '.config/waveterm'))
    }

    foreach ($dir in $dirs) {
        $candidate = Join-Path $dir 'settings.json'
        if (Test-Path $candidate) { return $candidate }
    }
    return $null
}

function Initialize-WtwWaveTabBar {
    <#
    .SYNOPSIS
        Move Wave's tab bar to the left, wtw's project-list layout, unless the
        user already chose a position (app:tabbar = top or left).
    #>

    [CmdletBinding()]
    param()

    $settingsPath = Get-WtwWaveSettingsPath
    if ($settingsPath) {
        try {
            $settings = Get-Content -Raw $settingsPath | ConvertFrom-Json -AsHashtable -ErrorAction Stop
            if ($settings -and $settings.ContainsKey('app:tabbar')) { return }
        } catch {
            # Unreadable settings: don't guess, leave Wave's layout alone.
            return
        }
    }

    Invoke-WtwWshCommand -ArgumentList @('setconfig', 'app:tabbar=left') | Out-Null
}

function Open-WtwWaveProject {
    <#
    .SYNOPSIS
        Open a wtw target as a Wave terminal block: focus the block already
        rooted at the target, otherwise create one with `wsh term <dir>`.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string] $ProjectPath,
        [Parameter(Mandatory)][string] $PrettyName,
        [string] $Color
    )

    if (-not (Get-WtwWshExe)) {
        return [PSCustomObject]@{ Success = $false; Created = $false; Reason = 'wsh not found' }
    }
    if (-not (Test-WtwWaveSession)) {
        return [PSCustomObject]@{
            Success = $false; Created = $false
            Reason  = 'not running inside a Wave terminal (WAVETERM_JWT / WAVETERM_TABID not set). Run wtw from a Wave terminal block.'
        }
    }

    Initialize-WtwWaveTabBar

    $fullPath = [System.IO.Path]::GetFullPath($ProjectPath)

    $existing = Find-WtwWaveBlock -PrettyName $PrettyName -ProjectPath $fullPath
    if ($existing -and $existing.blockid) {
        $blockId = "$($existing.blockid)"
        Set-WtwWaveBlockFrame -BlockId $blockId -PrettyName $PrettyName -Color $Color
        Invoke-WtwWshCommand -ArgumentList @('focusblock', '-b', $blockId) | Out-Null
        return [PSCustomObject]@{
            Success = $true; Created = $false; Path = $fullPath; Id = $blockId; Reason = $null
        }
    }

    $result = Invoke-WtwWshCommand -ArgumentList @('term', $fullPath)
    if ($result.ExitCode -ne 0) {
        return [PSCustomObject]@{ Success = $false; Created = $false; Path = $fullPath; Reason = $result.Output }
    }

    $blockId = $null
    if ($result.Output -match 'block:([0-9a-fA-F-]{36})') { $blockId = "block:$($Matches[1])" }
    if ($blockId) {
        Set-WtwWaveBlockFrame -BlockId $blockId -PrettyName $PrettyName -Color $Color
    }

    return [PSCustomObject]@{
        Success = $true; Created = $true; Path = $fullPath; Id = $blockId; Reason = $null
    }
}