PaneTag.psm1

$script:Exe = Join-Path $PSScriptRoot 'panetag.exe'
$script:HookExe = Join-Path $PSScriptRoot 'ptag.exe'

function ConvertTo-B64([string]$Text) {
    [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Text))
}

function Send-PaneTagCommand([string[]]$Lines) {
    $client = New-Object System.IO.Pipes.NamedPipeClientStream('.', $env:PANETAG_PIPE, [System.IO.Pipes.PipeDirection]::Out)
    try {
        $client.Connect(2000)
        $writer = New-Object System.IO.StreamWriter($client, (New-Object System.Text.UTF8Encoding($false)))
        foreach ($line in $Lines) { $writer.WriteLine($line) }
        $writer.Flush()
    }
    finally {
        $client.Dispose()
    }
}

<#
.SYNOPSIS
Puts a persistent title bar on the current terminal pane, or changes the existing one.

.DESCRIPTION
Outside a titled pane, starts a new shell (or -Command) under panetag.exe, which reserves
one row for the bar so full-screen apps such as claude or codex cannot draw over it.
Type 'exit' to remove the bar again.

Inside a titled pane, updates the bar in place. -Status flags the bar with a label and
color until the next keypress in the pane (or until -ClearStatus with -Sticky).

.EXAMPLE
ptag API server

.EXAMPLE
ptag 'Agent 1' -Color purple -Command claude

.EXAMPLE
ptag 'Long session' -Position Bottom # keeps Windows Terminal scrollback

.EXAMPLE
ptag -Status 'Build failed' -StatusColor red -Sticky
#>

function Set-PaneTag {
    [CmdletBinding()]
    param(
        # Title; several words are joined, so quotes are optional.
        [Parameter(Position = 0, ValueFromRemainingArguments = $true)]
        [string[]]$Title,

        # Named color (blue, green, purple, orange, red, teal, pink, yellow, indigo, cyan, brown, gray, black, white) or #RRGGBB.
        [string]$Color,

        # Text color; defaults to black or white for contrast.
        [string]$Foreground,

        # Top (default) or Bottom. Bottom keeps native scrollback in Windows Terminal.
        [ValidateSet('Top', 'Bottom')]
        [string]$Position,

        # Command line to run instead of a new shell; the pane returns to this shell when it exits.
        [string]$Command,

        # Status label shown on the bar, which turns -StatusColor until the next keypress in the pane.
        [string]$Status,

        # Status color (default: orange).
        [string]$StatusColor = 'orange',

        # Keep the status until -ClearStatus instead of clearing it on the next keypress.
        [switch]$Sticky,

        # Show how long the status has been up ("· 4m") after the first minute.
        [switch]$Timer,

        # Remove the status.
        [switch]$ClearStatus
    )

    $titleText = $null
    if ($Title) { $titleText = $Title -join ' ' }
    $statusOnly = ($Status -or $ClearStatus) -and -not $titleText -and -not $Color -and -not $Foreground

    if ($env:PANETAG_PIPE) {
        if ($Position -or $Command) {
            Write-Warning 'This pane already has a title bar; -Position and -Command only apply when starting one. Type exit to remove it first.'
        }
        $lines = @()
        if ($titleText) { $lines += "title $titleText" }
        if ($Color) { $lines += "color $Color" }
        if ($Foreground) { $lines += "fg $Foreground" }
        if ($Status) {
            $lines += "statuscolor $StatusColor"
            if ($Sticky) { $lines += 'sticky 1' } else { $lines += 'sticky 0' }
            if ($Timer) { $lines += 'timer ' + [DateTime]::UtcNow.ToFileTimeUtc() } else { $lines += 'timer 0' }
            $lines += "status $Status"
        }
        elseif ($ClearStatus) { $lines += 'status ' }
        if ($lines.Count -eq 0) { return }
        try {
            Send-PaneTagCommand $lines
        }
        catch {
            Write-Error "Cannot reach this pane's title bar ($env:PANETAG_PIPE): $($_.Exception.Message)"
        }
        return
    }

    # Like ptag.exe, status updates outside a titled pane do nothing.
    if ($statusOnly) { return }

    if (-not (Test-Path $script:Exe)) {
        throw "panetag.exe not found next to the module ($script:Exe). Run build.ps1."
    }

    $cwd = (Get-Location -PSProvider FileSystem).ProviderPath
    if (-not $titleText) { $titleText = Split-Path -Leaf $cwd }
    if (-not $Command) { $Command = '"' + (Get-Process -Id $PID).Path + '" -NoLogo' }

    # Base64 sidesteps Windows PowerShell's mangling of quotes in native arguments.
    $arguments = @('--title-b64', (ConvertTo-B64 $titleText), '--cmd-b64', (ConvertTo-B64 $Command), '--cwd-b64', (ConvertTo-B64 $cwd))
    if ($Color) { $arguments += @('--color', $Color) }
    if ($Foreground) { $arguments += @('--fg', $Foreground) }
    if ($Position) { $arguments += @('--position', $Position.ToLowerInvariant()) }

    & $script:Exe @arguments
}

# ---------------------------------------------------------------- Claude Code hooks

function Test-PaneTagHandler($Handler) {
    $Handler.command -and ([string]$Handler.command) -match '[\\/]ptag\.exe$'
}

# Removes PaneTag's handlers from every event, dropping groups and events left empty.
function Remove-PaneTagHandlers($Hooks) {
    # Filter: on an empty object Windows PowerShell yields a single $null name.
    foreach ($event in @($Hooks.PSObject.Properties.Name | Where-Object { $_ })) {
        $groups = @()
        foreach ($group in @($Hooks.$event)) {
            $kept = @(@($group.hooks) | Where-Object { -not (Test-PaneTagHandler $_) })
            if ($kept.Count -gt 0) {
                $group.hooks = $kept
                $groups += $group
            }
        }
        if ($groups.Count -gt 0) { $Hooks.$event = $groups }
        else { $Hooks.PSObject.Properties.Remove($event) }
    }
}

function Read-ClaudeSettings([string]$Path) {
    if ((Test-Path $Path) -and (Get-Content -Raw $Path).Trim()) {
        return Get-Content -Raw $Path | ConvertFrom-Json
    }
    return New-Object PSObject
}

function Save-ClaudeSettings($Settings, [string]$Path) {
    $dir = Split-Path -Parent $Path
    if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Force $dir | Out-Null }
    if (Test-Path $Path) {
        $stamp = "$Path.bak-" + (Get-Date -Format 'yyyyMMdd-HHmmss')
        $backup = $stamp
        for ($i = 2; Test-Path $backup; $i++) { $backup = "$stamp-$i" } # never overwrite an earlier backup
        Copy-Item $Path $backup
        Write-Host "Backed up $Path to $backup"
    }
    $json = $Settings | ConvertTo-Json -Depth 32
    [IO.File]::WriteAllText($Path, $json, (New-Object System.Text.UTF8Encoding($false)))
}

<#
.SYNOPSIS
Adds Claude Code hooks that flag the pane's bar when Claude needs you.

.DESCRIPTION
Adds four hooks to Claude Code's user settings (~/.claude/settings.json by default):
  Stop -> bar shows "Your turn" (orange)
  Notification (permission_prompt) -> bar shows "Needs you" (red)
  UserPromptSubmit -> status cleared
  PreToolUse -> status cleared (Claude is working again)
Both flags show how long they've been up after the first minute ("Needs you · 4m"), so with
several flagged panes you can see which has waited longest. The status also clears on
your next keypress in the pane. The hooks run ptag.exe from this
module's folder directly (no shell) and in the background, and do nothing when Claude isn't
running in a titled pane. Existing settings are kept and backed up first. Running it again
replaces PaneTag's hooks; run it again after moving or upgrading the module.

.EXAMPLE
Enable-PaneTagClaudeHooks

.EXAMPLE
Enable-PaneTagClaudeHooks -SettingsPath .\.claude\settings.json # one project only
#>

function Enable-PaneTagClaudeHooks {
    [CmdletBinding()]
    param(
        [string]$SettingsPath = (Join-Path $HOME '.claude\settings.json'),
        [string]$YourTurnColor = 'orange',
        [string]$NeedsYouColor = 'red'
    )

    if (-not (Test-Path $script:HookExe)) {
        throw "ptag.exe not found next to the module ($script:HookExe). Run build.ps1."
    }
    $settings = Read-ClaudeSettings $SettingsPath
    if (-not $settings.PSObject.Properties['hooks']) {
        $settings | Add-Member -NotePropertyName hooks -NotePropertyValue (New-Object PSObject)
    }
    Remove-PaneTagHandlers $settings.hooks

    function New-Handler([string[]]$HandlerArgs) {
        [pscustomobject]@{ type = 'command'; command = $script:HookExe; args = $HandlerArgs; async = $true }
    }
    $add = [ordered]@{
        Stop             = [pscustomobject]@{ hooks = @(New-Handler @('--status', 'Your turn', '--status-color', $YourTurnColor, '--timer')) }
        Notification     = [pscustomobject]@{ matcher = 'permission_prompt'; hooks = @(New-Handler @('--status', 'Needs you', '--status-color', $NeedsYouColor, '--timer')) }
        UserPromptSubmit = [pscustomobject]@{ hooks = @(New-Handler @('--clear-status')) }
        # Claude also starts working without a prompt (background task done, /loop, cron).
        PreToolUse       = [pscustomobject]@{ hooks = @(New-Handler @('--clear-status')) }
    }
    foreach ($event in $add.Keys) {
        $groups = @()
        if ($settings.hooks.PSObject.Properties[$event]) { $groups = @($settings.hooks.$event) }
        $groups += $add[$event]
        $settings.hooks | Add-Member -NotePropertyName $event -NotePropertyValue $groups -Force
    }

    Save-ClaudeSettings $settings $SettingsPath
    Write-Host "PaneTag hooks enabled in $SettingsPath (Stop, Notification, UserPromptSubmit, PreToolUse)."
}

<#
.SYNOPSIS
Removes the hooks added by Enable-PaneTagClaudeHooks.
#>

function Disable-PaneTagClaudeHooks {
    [CmdletBinding()]
    param(
        [string]$SettingsPath = (Join-Path $HOME '.claude\settings.json')
    )

    if (-not (Test-Path $SettingsPath)) { Write-Host "No settings file at $SettingsPath; nothing to do."; return }
    $settings = Read-ClaudeSettings $SettingsPath
    if (-not $settings.PSObject.Properties['hooks']) { Write-Host 'No hooks configured; nothing to do.'; return }
    Remove-PaneTagHandlers $settings.hooks
    if (@($settings.hooks.PSObject.Properties).Count -eq 0) { $settings.PSObject.Properties.Remove('hooks') }
    Save-ClaudeSettings $settings $SettingsPath
    Write-Host "PaneTag hooks removed from $SettingsPath."
}

Set-Alias -Name ptag -Value Set-PaneTag

Export-ModuleMember -Function Set-PaneTag, Enable-PaneTagClaudeHooks, Disable-PaneTagClaudeHooks -Alias ptag