Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,23 @@ tab 5 / sim (app-ios)
herdr plugin action invoke rjyo.window-title-sync.refresh
```

On Windows the action id is `refresh-win` (Herdr rejects duplicate action ids across platforms):

```powershell
herdr plugin action invoke rjyo.window-title-sync.refresh-win
```

## Privacy

The plugin runs locally and does not send data over the network. As a fallback, it reads local Codex and Claude Code session JSONL files to find a useful title when Herdr does not expose one directly. That means prompt text may appear in your terminal window/tab title.

## Requirements

- Herdr `0.7.0` or newer
- Bun available on `PATH`
- macOS or Linux
- macOS, Linux, or Windows
- On macOS/Linux: Bun available on `PATH`. On Windows: Windows PowerShell (built in) — the plugin
ships a PowerShell port (`sync-title.ps1`) that needs no Bun/Node, since Herdr's `bash` on
Windows is WSL and can't drive the unix script.

## License

Expand Down
70 changes: 69 additions & 1 deletion herdr-plugin.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,54 +3,122 @@ name = "Window Title Sync"
version = "0.1.0"
min_herdr_version = "0.7.0"
description = "Sync the outer terminal title to the focused Herdr workspace, tab, and agent session."
platforms = ["macos", "linux"]
platforms = ["macos", "linux", "windows"]

# Each item is gated by `platforms`: unix runs `bun sync-title.js`, Windows runs the PowerShell
# port `sync-title.ps1` (herdr runs no shell on Windows and its `bash` is WSL, so the unix command
# can't drive it). herdr rejects duplicate *action* ids, so the Windows refresh is `refresh-win`;
# duplicate event `on` triggers are allowed, so each event simply gets a second, Windows-gated entry.

[[actions]]
id = "refresh"
title = "Refresh terminal title"
contexts = ["workspace", "tab", "pane"]
command = ["bun", "sync-title.js"]
platforms = ["macos", "linux"]

[[actions]]
id = "refresh-win"
title = "Refresh terminal title"
contexts = ["workspace", "tab", "pane"]
command = ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "sync-title.ps1"]
platforms = ["windows"]

[[events]]
on = "workspace.focused"
command = ["bun", "sync-title.js"]
platforms = ["macos", "linux"]
[[events]]
on = "workspace.focused"
command = ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "sync-title.ps1"]
platforms = ["windows"]

[[events]]
on = "workspace.renamed"
command = ["bun", "sync-title.js"]
platforms = ["macos", "linux"]
[[events]]
on = "workspace.renamed"
command = ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "sync-title.ps1"]
platforms = ["windows"]

[[events]]
on = "workspace.updated"
command = ["bun", "sync-title.js"]
platforms = ["macos", "linux"]
[[events]]
on = "workspace.updated"
command = ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "sync-title.ps1"]
platforms = ["windows"]

[[events]]
on = "tab.focused"
command = ["bun", "sync-title.js"]
platforms = ["macos", "linux"]
[[events]]
on = "tab.focused"
command = ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "sync-title.ps1"]
platforms = ["windows"]

[[events]]
on = "tab.renamed"
command = ["bun", "sync-title.js"]
platforms = ["macos", "linux"]
[[events]]
on = "tab.renamed"
command = ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "sync-title.ps1"]
platforms = ["windows"]

[[events]]
on = "pane.focused"
command = ["bun", "sync-title.js"]
platforms = ["macos", "linux"]
[[events]]
on = "pane.focused"
command = ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "sync-title.ps1"]
platforms = ["windows"]

[[events]]
on = "pane.agent_detected"
command = ["bun", "sync-title.js"]
platforms = ["macos", "linux"]
[[events]]
on = "pane.agent_detected"
command = ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "sync-title.ps1"]
platforms = ["windows"]

[[events]]
on = "pane.agent_status_changed"
command = ["bun", "sync-title.js"]
platforms = ["macos", "linux"]
[[events]]
on = "pane.agent_status_changed"
command = ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "sync-title.ps1"]
platforms = ["windows"]

[[events]]
on = "pane.moved"
command = ["bun", "sync-title.js"]
platforms = ["macos", "linux"]
[[events]]
on = "pane.moved"
command = ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "sync-title.ps1"]
platforms = ["windows"]

[[events]]
on = "pane.created"
command = ["bun", "sync-title.js"]
platforms = ["macos", "linux"]
[[events]]
on = "pane.created"
command = ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "sync-title.ps1"]
platforms = ["windows"]

[[events]]
on = "pane.closed"
command = ["bun", "sync-title.js"]
platforms = ["macos", "linux"]
[[events]]
on = "pane.closed"
command = ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "sync-title.ps1"]
platforms = ["windows"]
206 changes: 206 additions & 0 deletions sync-title.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
# Windows port of sync-title.js — set the outer terminal (Windows Terminal, etc.) window title
# from the focused herdr workspace / pane / agent session. `herdr terminal title set` is what
# actually writes the title; this script only builds the string. JSON is parsed natively with
# ConvertFrom-Json, so no bun/node runtime is needed. Mirrors sync-title.js field-for-field.
#
# On Windows herdr runs no shell and its `bash` is WSL, so the unix `bun sync-title.js` command
# can't drive this; the manifest routes Windows to `powershell -File sync-title.ps1` instead.

$ErrorActionPreference = 'Stop'

$herdr = if ($env:HERDR_BIN_PATH) { $env:HERDR_BIN_PATH } else { 'herdr' }
$stateDir = if ($env:HERDR_PLUGIN_STATE_DIR) { $env:HERDR_PLUGIN_STATE_DIR } else { $env:TEMP }
$statePath = Join-Path $stateDir 'last-title'

function Run {
$out = & $herdr @args 2>$null
if ($LASTEXITCODE -ne 0) { throw "herdr $($args -join ' ') failed" }
($out | Out-String).Trim()
}

function Json {
$out = Run @args
if ($out) { $out | ConvertFrom-Json } else { $null }
}

function CleanPart([string]$value) {
if (-not $value) { return '' }
(($value -replace '[\x00-\x1f\x7f]', ' ') -replace '\s+', ' ').Trim()
}

function CompactTitle([string]$value) {
$v = CleanPart $value
$v = $v -replace '(?i)^<command-name>.*?</command-name>\s*', ''
$v = $v -replace '<[^>]+>', ' '
$v = $v -replace '^>\s*', ''
$v = $v -replace '^›\s*', ''
CleanPart $v
}

function HomePath([string[]]$parts) {
$root = if ($env:USERPROFILE) { $env:USERPROFILE } elseif ($env:HOME) { $env:HOME } else { '' }
if (-not $root) { return '' }
foreach ($p in $parts) { $root = Join-Path $root $p }
$root
}

function FindFirst([string]$root, [string]$filter) {
if (-not $root -or -not (Test-Path $root)) { return '' }
$f = Get-ChildItem -Path $root -Recurse -File -Filter $filter -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($f) { $f.FullName } else { '' }
}

function ReadLines([string]$path) {
try { (Get-Content -LiteralPath $path -Raw -ErrorAction Stop) -split "`n" } catch { @() }
}

function ParseJson([string]$line) {
$t = $line.Trim()
if (-not $t) { return $null }
try { $t | ConvertFrom-Json } catch { $null }
}

function UsableSessionTitle([string]$title) {
if (-not $title -or $title.Length -lt 2) { return $false }
if ($title.StartsWith('/clear')) { return $false }
if ($title.StartsWith('<local-command')) { return $false }
$true
}

function TabTitle($tab, $order) {
# `order` is the 1-based switch position within the workspace (prefix + N), not the tab's
# internal number. Default labels mirror the number; drop those, keep real non-numeric labels.
$number = if ($null -ne $order) { [string]$order } else { '' }
$label = CleanPart $tab.label
if (-not $label -or $label -match '^\d+$') { if ($number) { return $number } else { return 'tab' } }
if ($number) { "$number / $label" } else { $label }
}

function FallbackTitle($tab, $order) {
$part = TabTitle $tab $order
if ($part -eq 'tab') { 'tab' } else { "tab $part" }
}

# Append " (workspace)" without letting the suffix get truncated by the title cap.
function WithWorkspace([string]$title, [string]$name, [int]$max = 80) {
if (-not $name) { return $title.Substring(0, [Math]::Min($max, $title.Length)) }
$suffix = " ($name)"
$room = $max - $suffix.Length
if ($room -le 0) { return $title.Substring(0, [Math]::Min($max, $title.Length)) }
$body = $title.Substring(0, [Math]::Min($room, $title.Length)).TrimEnd()
"$body$suffix"
}

function AgentTitle($pane) {
$a = if ($pane.display_agent) { $pane.display_agent } else { $pane.agent }
(CleanPart $a).ToLower()
}

function AppTitle($pane) {
$agent = AgentTitle $pane
$title = CleanPart $pane.title
if ($title) { if ($agent) { return "${agent}: $title" } else { return $title } }

$status = CleanPart $pane.custom_status
if ($agent -and $status) { return "${agent}: $status" }

$sessionTitle = SessionFileTitle $pane
if ($agent -and $sessionTitle) { return "${agent}: $sessionTitle" }
if ($sessionTitle) { return $sessionTitle }
if ($agent) { return $agent }
''
}

function SessionFileTitle($pane) {
$session = $pane.agent_session
$agent = if ($session -and $session.agent) { $session.agent } else { $pane.agent }
$agent = (CleanPart $agent).ToLower()
$id = if ($session) { CleanPart $session.value } else { '' }
if (-not $agent -or -not $id) { return '' }
if ($agent -eq 'codex') { return CodexSessionTitle $id }
if ($agent -eq 'claude') { return ClaudeSessionTitle $id }
''
}

function CodexSessionTitle([string]$id) {
$root = HomePath @('.codex', 'sessions')
$path = FindFirst $root "*$id.jsonl"
if (-not $path) { return '' }
$latest = ''
foreach ($line in (ReadLines $path)) {
$entry = ParseJson $line
if (-not $entry -or $entry.type -ne 'event_msg') { continue }
$payload = $entry.payload
if (-not $payload -or $payload.type -ne 'user_message') { continue }
$message = CompactTitle $payload.message
if (UsableSessionTitle $message) { $latest = $message }
}
$latest
}

function ClaudeSessionTitle([string]$id) {
$root = HomePath @('.claude', 'projects')
$path = FindFirst $root "$id.jsonl"
if (-not $path) { return '' }
$latest = ''
foreach ($line in (ReadLines $path)) {
$entry = ParseJson $line
if (-not $entry -or $entry.type -ne 'user' -or $entry.isMeta) { continue }
$content = $entry.message.content
if ($content -is [System.Array]) {
$text = (($content | ForEach-Object { if ($_ -is [string]) { $_ } else { $_.text } }) |
Where-Object { $_ }) -join ' '
} else {
$text = [string]$content
}
$title = CompactTitle $text
if (UsableSessionTitle $title) { $latest = $title }
}
$latest
}

function BuildTitle {
$pane = (Json pane current).result.pane
if (-not $pane) { return 'herdr' }

$ws = if ($pane.workspace_id) { (Json workspace get $pane.workspace_id).result.workspace } else { $null }
$name = if ($ws) {
$number = if ($null -ne $ws.number) { [string]$ws.number } else { '' }
$label = CleanPart $ws.label
if ($label -and $label -ne $number) { $label } else { '' }
} else { '' }

$title = AppTitle $pane
if ($title) { return WithWorkspace $title $name }

$tabs = if ($pane.workspace_id) { (Json tab list --workspace $pane.workspace_id).result.tabs } else { @() }
$index = -1
for ($i = 0; $i -lt $tabs.Count; $i++) { if ($tabs[$i].tab_id -eq $pane.tab_id) { $index = $i; break } }
$tab = if ($index -ge 0) { $tabs[$index] } else { $null }
$order = if ($index -ge 0) { $index + 1 } else { $null }

WithWorkspace (FallbackTitle $tab $order) $name
}

function LastTitle {
try { (Get-Content -LiteralPath $statePath -Raw -ErrorAction Stop).Trim() } catch { '' }
}

function SaveTitle([string]$title) {
$dir = Split-Path -Parent $statePath
if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
Set-Content -LiteralPath $statePath -Value "$title`n" -NoNewline
}

try {
$title = BuildTitle
if ($title -ne (LastTitle)) {
Run terminal title set $title | Out-Null
SaveTitle $title
}
Write-Output $title
} catch {
Write-Error $_.Exception.Message
exit 1
}