diff --git a/README.md b/README.md index e2c10ae0..9f651d95 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Split a browser alongside your terminal with a scriptable API ported from
@@ -96,7 +96,7 @@ I run a lot of Claude Code and Codex sessions in parallel. I was using Ghostty w
I tried a few coding orchestrators but most of them were Electron/Tauri apps and the performance bugged me. I also just prefer the terminal since GUI orchestrators lock you into their workflow. So I built cmux as a native macOS app in Swift/AppKit. It uses libghostty for terminal rendering and reads your existing Ghostty config for themes, fonts, and colors.
-The main additions are the sidebar and notification system. The sidebar has vertical tabs that show git branch, working directory, listening ports, and the latest notification text for each workspace. The notification system picks up terminal sequences (OSC 9/99/777) and has a CLI (`cmux notify`) you can wire into agent hooks for Claude Code, OpenCode, etc. When an agent is waiting, its pane gets a blue ring and the tab lights up in the sidebar, so I can tell which one needs me across splits and tabs. Cmd+Shift+U jumps to the most recent unread.
+The main additions are the sidebar and notification system. The sidebar has vertical tabs that show git branch, linked PR status/number, working directory, listening ports, and the latest notification text for each workspace. The notification system picks up terminal sequences (OSC 9/99/777) and has a CLI (`cmux notify`) you can wire into agent hooks for Claude Code, OpenCode, etc. When an agent is waiting, its pane gets a blue ring and the tab lights up in the sidebar, so I can tell which one needs me across splits and tabs. Cmd+Shift+U jumps to the most recent unread.
The in-app browser has a scriptable API ported from [agent-browser](https://github.com/vercel-labs/agent-browser). Agents can snapshot the accessibility tree, get element refs, click, fill forms, and evaluate JS. You can split a browser pane next to your terminal and have Claude Code interact with your dev server directly.
diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash
index 4f8c832f..ef3c3ce7 100644
--- a/Resources/shell-integration/cmux-bash-integration.bash
+++ b/Resources/shell-integration/cmux-bash-integration.bash
@@ -28,6 +28,9 @@ _CMUX_PWD_LAST_PWD="${_CMUX_PWD_LAST_PWD:-}"
_CMUX_GIT_LAST_PWD="${_CMUX_GIT_LAST_PWD:-}"
_CMUX_GIT_LAST_RUN="${_CMUX_GIT_LAST_RUN:-0}"
_CMUX_GIT_JOB_PID="${_CMUX_GIT_JOB_PID:-}"
+_CMUX_PR_LAST_PWD="${_CMUX_PR_LAST_PWD:-}"
+_CMUX_PR_LAST_RUN="${_CMUX_PR_LAST_RUN:-0}"
+_CMUX_PR_JOB_PID="${_CMUX_PR_JOB_PID:-}"
_CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}"
_CMUX_TTY_NAME="${_CMUX_TTY_NAME:-}"
@@ -115,6 +118,48 @@ _cmux_prompt_command() {
_CMUX_GIT_JOB_PID=$!
fi
+ # Pull request metadata (number/state/url):
+ # refresh on cwd change and periodically to avoid stale status.
+ if [[ -n "$_CMUX_PR_JOB_PID" ]] && kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then
+ if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]]; then
+ kill "$_CMUX_PR_JOB_PID" >/dev/null 2>&1 || true
+ _CMUX_PR_JOB_PID=""
+ fi
+ fi
+
+ if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]] || (( now - _CMUX_PR_LAST_RUN >= 60 )); then
+ if [[ -z "$_CMUX_PR_JOB_PID" ]] || ! kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then
+ _CMUX_PR_LAST_PWD="$pwd"
+ _CMUX_PR_LAST_RUN=$now
+ {
+ local branch pr_tsv number state url status_opt=""
+ branch=$(git branch --show-current 2>/dev/null)
+ if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then
+ _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
+ else
+ pr_tsv="$(gh pr view --json number,state,url --jq '[.number, .state, .url] | @tsv' 2>/dev/null || true)"
+ if [[ -z "$pr_tsv" ]]; then
+ _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
+ else
+ IFS=$'\t' read -r number state url <<< "$pr_tsv"
+ if [[ -z "$number" || -z "$url" ]]; then
+ _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
+ else
+ case "$state" in
+ MERGED) status_opt="--state=merged" ;;
+ OPEN) status_opt="--state=open" ;;
+ CLOSED) status_opt="--state=closed" ;;
+ *) status_opt="" ;;
+ esac
+ _cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
+ fi
+ fi
+ fi
+ } >/dev/null 2>&1 &
+ _CMUX_PR_JOB_PID=$!
+ fi
+ fi
+
# Ports: lightweight kick to the app's batched scanner every ~10s.
if (( now - _CMUX_PORTS_LAST_RUN >= 10 )); then
_cmux_ports_kick
diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh
index 6c9575f0..0f5d483a 100644
--- a/Resources/shell-integration/cmux-zsh-integration.zsh
+++ b/Resources/shell-integration/cmux-zsh-integration.zsh
@@ -34,6 +34,10 @@ typeset -g _CMUX_GIT_HEAD_LAST_PWD=""
typeset -g _CMUX_GIT_HEAD_PATH=""
typeset -g _CMUX_GIT_HEAD_MTIME=0
typeset -g _CMUX_HAVE_ZSTAT=0
+typeset -g _CMUX_PR_LAST_PWD=""
+typeset -g _CMUX_PR_LAST_RUN=0
+typeset -g _CMUX_PR_JOB_PID=""
+typeset -g _CMUX_PR_FORCE=0
typeset -g _CMUX_PORTS_LAST_RUN=0
typeset -g _CMUX_CMD_START=0
@@ -143,7 +147,8 @@ _cmux_preexec() {
local cmd="${1## }"
case "$cmd" in
git\ *|git|gh\ *|lazygit|lazygit\ *|tig|tig\ *|gitui|gitui\ *|stg\ *|jj\ *)
- _CMUX_GIT_FORCE=1 ;;
+ _CMUX_GIT_FORCE=1
+ _CMUX_PR_FORCE=1 ;;
esac
# Register TTY + kick batched port scan for foreground commands (servers).
@@ -200,6 +205,7 @@ _cmux_precmd() {
# Treat HEAD file change like a git command — force-replace any
# running probe so the sidebar picks up the new branch immediately.
_CMUX_GIT_FORCE=1
+ _CMUX_PR_FORCE=1
should_git=1
fi
fi
@@ -249,6 +255,63 @@ _cmux_precmd() {
fi
fi
+ # Pull request metadata (number/state/url):
+ # - refresh on cwd change, explicit git/gh commands, and occasionally for status drift
+ # - keep this independent from the git probe cadence to avoid hitting GitHub too often
+ local should_pr=0
+ if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]]; then
+ should_pr=1
+ elif (( _CMUX_PR_FORCE )); then
+ should_pr=1
+ elif (( now - _CMUX_PR_LAST_RUN >= 60 )); then
+ should_pr=1
+ fi
+
+ if (( should_pr )); then
+ local can_launch_pr=1
+ if [[ -n "$_CMUX_PR_JOB_PID" ]] && kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then
+ if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]] || (( _CMUX_PR_FORCE )); then
+ kill "$_CMUX_PR_JOB_PID" >/dev/null 2>&1 || true
+ _CMUX_PR_JOB_PID=""
+ else
+ can_launch_pr=0
+ fi
+ fi
+
+ if (( can_launch_pr )); then
+ _CMUX_PR_FORCE=0
+ _CMUX_PR_LAST_PWD="$pwd"
+ _CMUX_PR_LAST_RUN=$now
+ {
+ local branch pr_tsv number state url status_opt=""
+ branch=$(git branch --show-current 2>/dev/null)
+ if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then
+ _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
+ else
+ pr_tsv="$(gh pr view --json number,state,url --jq '[.number, .state, .url] | @tsv' 2>/dev/null || true)"
+ if [[ -z "$pr_tsv" ]]; then
+ _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
+ else
+ local IFS=$'\t'
+ read -r number state url <<< "$pr_tsv"
+ if [[ -z "$number" ]] || [[ -z "$url" ]]; then
+ _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
+ else
+ case "$state" in
+ MERGED) status_opt="--state=merged" ;;
+ OPEN) status_opt="--state=open" ;;
+ CLOSED) status_opt="--state=closed" ;;
+ *) status_opt="" ;;
+ esac
+ _cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
+ fi
+ fi
+ fi
+ } >/dev/null 2>&1 &!
+ _CMUX_PR_JOB_PID=$!
+ fi
+ fi
+
# Ports: lightweight kick to the app's batched scanner.
# - Periodic scan to avoid stale values.
# - Forced scan when a long-running command returns to the prompt (common when stopping a server).
diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift
index 82de33b1..b1236f89 100644
--- a/Sources/ContentView.swift
+++ b/Sources/ContentView.swift
@@ -2481,6 +2481,7 @@ private struct TabItemView: View {
@AppStorage("sidebarShowGitBranch") private var sidebarShowGitBranch = true
@AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
@AppStorage("sidebarShowGitBranchIcon") private var sidebarShowGitBranchIcon = false
+ @AppStorage("sidebarShowPullRequest") private var sidebarShowPullRequest = true
@AppStorage("sidebarShowPorts") private var sidebarShowPorts = true
@AppStorage("sidebarShowLog") private var sidebarShowLog = true
@AppStorage("sidebarShowProgress") private var sidebarShowProgress = true
@@ -2767,6 +2768,33 @@ private struct TabItemView: View {
}
}
+ // Pull request row
+ if sidebarShowPullRequest, let pullRequest = primaryPullRequestDisplay {
+ Button(action: {
+ updateSelection()
+ NSWorkspace.shared.open(pullRequest.url)
+ }) {
+ HStack(spacing: 4) {
+ PullRequestStatusIcon(
+ status: pullRequest.status,
+ color: pullRequestForegroundColor
+ )
+ Text("PR #\(pullRequest.number)")
+ .underline()
+ Text(pullRequestStatusLabel(pullRequest.status))
+ if pullRequest.extraCount > 0 {
+ Text("+\(pullRequest.extraCount)")
+ .opacity(0.75)
+ }
+ Spacer(minLength: 0)
+ }
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundColor(pullRequestForegroundColor)
+ }
+ .buttonStyle(.plain)
+ .help("Open Pull Request #\(pullRequest.number)")
+ }
+
// Ports row
if sidebarShowPorts, !tab.listeningPorts.isEmpty {
Text(tab.listeningPorts.map { ":\($0)" }.joined(separator: ", "))
@@ -3270,6 +3298,36 @@ private struct TabItemView: View {
return entries.isEmpty ? nil : entries.joined(separator: " | ")
}
+ private struct PullRequestDisplay {
+ let number: Int
+ let url: URL
+ let status: SidebarPullRequestStatus
+ let extraCount: Int
+ }
+
+ private var primaryPullRequestDisplay: PullRequestDisplay? {
+ let pullRequests = tab.sidebarPullRequestsInDisplayOrder()
+ guard let first = pullRequests.first else { return nil }
+ return PullRequestDisplay(
+ number: first.number,
+ url: first.url,
+ status: first.status,
+ extraCount: max(0, pullRequests.count - 1)
+ )
+ }
+
+ private var pullRequestForegroundColor: Color {
+ isActive ? .white.opacity(0.75) : .secondary
+ }
+
+ private func pullRequestStatusLabel(_ status: SidebarPullRequestStatus) -> String {
+ switch status {
+ case .open: return "open"
+ case .merged: return "merged"
+ case .closed: return "closed"
+ }
+ }
+
private func logLevelIcon(_ level: SidebarLogLevel) -> String {
switch level {
case .info: return "circle.fill"
@@ -3311,6 +3369,101 @@ private struct TabItemView: View {
return trimmed
}
+ private struct PullRequestStatusIcon: View {
+ let status: SidebarPullRequestStatus
+ let color: Color
+ private static let frameSize: CGFloat = 14
+
+ var body: some View {
+ switch status {
+ case .open:
+ PullRequestOpenIcon(color: color)
+ case .merged:
+ PullRequestMergedIcon(color: color)
+ case .closed:
+ Image(systemName: "xmark.circle")
+ .font(.system(size: 9, weight: .regular))
+ .foregroundColor(color)
+ .frame(width: Self.frameSize, height: Self.frameSize)
+ }
+ }
+ }
+
+ private struct PullRequestOpenIcon: View {
+ let color: Color
+ private static let stroke = StrokeStyle(lineWidth: 1.35, lineCap: .round, lineJoin: .round)
+ private static let nodeDiameter: CGFloat = 3.4
+ private static let frameSize: CGFloat = 14
+
+ var body: some View {
+ ZStack {
+ Path { path in
+ path.move(to: CGPoint(x: 3.0, y: 4.8))
+ path.addLine(to: CGPoint(x: 3.0, y: 9.2))
+
+ path.move(to: CGPoint(x: 4.8, y: 3.0))
+ path.addLine(to: CGPoint(x: 9.4, y: 3.0))
+ path.addLine(to: CGPoint(x: 11.0, y: 4.6))
+ path.addLine(to: CGPoint(x: 11.0, y: 9.2))
+ }
+ .stroke(color, style: Self.stroke)
+
+ Circle()
+ .stroke(color, lineWidth: Self.stroke.lineWidth)
+ .frame(width: Self.nodeDiameter, height: Self.nodeDiameter)
+ .position(x: 3.0, y: 3.0)
+
+ Circle()
+ .stroke(color, lineWidth: Self.stroke.lineWidth)
+ .frame(width: Self.nodeDiameter, height: Self.nodeDiameter)
+ .position(x: 3.0, y: 11.0)
+
+ Circle()
+ .stroke(color, lineWidth: Self.stroke.lineWidth)
+ .frame(width: Self.nodeDiameter, height: Self.nodeDiameter)
+ .position(x: 11.0, y: 11.0)
+ }
+ .frame(width: Self.frameSize, height: Self.frameSize)
+ }
+ }
+
+ private struct PullRequestMergedIcon: View {
+ let color: Color
+ private static let stroke = StrokeStyle(lineWidth: 1.35, lineCap: .round, lineJoin: .round)
+ private static let nodeDiameter: CGFloat = 3.4
+ private static let frameSize: CGFloat = 14
+
+ var body: some View {
+ ZStack {
+ Path { path in
+ path.move(to: CGPoint(x: 4.6, y: 4.6))
+ path.addLine(to: CGPoint(x: 7.1, y: 7.0))
+ path.addLine(to: CGPoint(x: 9.2, y: 7.0))
+
+ path.move(to: CGPoint(x: 4.6, y: 9.4))
+ path.addLine(to: CGPoint(x: 7.1, y: 7.0))
+ }
+ .stroke(color, style: Self.stroke)
+
+ Circle()
+ .stroke(color, lineWidth: Self.stroke.lineWidth)
+ .frame(width: Self.nodeDiameter, height: Self.nodeDiameter)
+ .position(x: 3.0, y: 3.0)
+
+ Circle()
+ .stroke(color, lineWidth: Self.stroke.lineWidth)
+ .frame(width: Self.nodeDiameter, height: Self.nodeDiameter)
+ .position(x: 3.0, y: 11.0)
+
+ Circle()
+ .stroke(color, lineWidth: Self.stroke.lineWidth)
+ .frame(width: Self.nodeDiameter, height: Self.nodeDiameter)
+ .position(x: 11.0, y: 7.0)
+ }
+ .frame(width: Self.frameSize, height: Self.frameSize)
+ }
+ }
+
private func applyTabColor(_ hex: String?, targetIds: [UUID]) {
for targetId in targetIds {
tabManager.setTabColor(tabId: targetId, color: hex)
diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift
index 3f61f26b..532f9dd3 100644
--- a/Sources/TerminalController.swift
+++ b/Sources/TerminalController.swift
@@ -189,6 +189,16 @@ class TerminalController {
return current.branch != branch || current.isDirty != isDirty
}
+ nonisolated static func shouldReplacePullRequest(
+ current: SidebarPullRequestState?,
+ number: Int,
+ url: URL,
+ status: SidebarPullRequestStatus
+ ) -> Bool {
+ guard let current else { return true }
+ return current.number != number || current.url != url || current.status != status
+ }
+
nonisolated static func shouldReplacePorts(current: [Int]?, next: [Int]) -> Bool {
let currentSorted = Array(Set(current ?? [])).sorted()
let nextSorted = Array(Set(next)).sorted()
@@ -733,6 +743,12 @@ class TerminalController {
case "clear_git_branch":
return clearGitBranch(args)
+ case "report_pr":
+ return reportPullRequest(args)
+
+ case "clear_pr":
+ return clearPullRequest(args)
+
case "report_ports":
return reportPorts(args)
@@ -7879,6 +7895,8 @@ class TerminalController {
clear_progress [--tab=X] - Clear progress bar
report_git_branch