feat(sidebar): show linked pull request metadata

This commit is contained in:
Vadim Kostin 2026-02-23 10:40:29 +08:00
parent 5697f71fc6
commit 2d454df50f
9 changed files with 622 additions and 3 deletions

View file

@ -52,7 +52,7 @@ Split a browser alongside your terminal with a scriptable API ported from <a hre
<tr>
<td width="40%" valign="middle">
<h3>Vertical + horizontal tabs</h3>
Sidebar shows git branch, working directory, listening ports, and latest notification text. Split horizontally and vertically.
Sidebar shows git branch, linked PR status/number, working directory, listening ports, and latest notification text. Split horizontally and vertically.
</td>
<td width="60%">
<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="Vertical tabs and split panes" width="100%" />
@ -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.

View file

@ -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

View file

@ -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).

View file

@ -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)

View file

@ -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 <branch> [--status=dirty] [--tab=X] [--panel=Y] - Report git branch
clear_git_branch [--tab=X] [--panel=Y] - Clear git branch
report_pr <number> <url> [--state=open|merged|closed] [--tab=X] [--panel=Y] - Report pull request
clear_pr [--tab=X] [--panel=Y] - Clear pull request
report_ports <port1> [port2...] [--tab=X] [--panel=Y] - Report listening ports
report_tty <tty_name> [--tab=X] [--panel=Y] - Register TTY for batched port scanning
ports_kick [--tab=X] [--panel=Y] - Request batched port scan for panel
@ -11092,6 +11110,119 @@ class TerminalController {
return result
}
private func reportPullRequest(_ args: String) -> String {
let parsed = parseOptions(args)
guard parsed.positional.count >= 2 else {
return "ERROR: Missing pull request number or URL — usage: report_pr <number> <url> [--state=open|merged|closed] [--tab=X] [--panel=Y]"
}
let rawNumber = parsed.positional[0].trimmingCharacters(in: .whitespacesAndNewlines)
let numberToken = rawNumber.hasPrefix("#") ? String(rawNumber.dropFirst()) : rawNumber
guard let number = Int(numberToken), number > 0 else {
return "ERROR: Invalid pull request number '\(rawNumber)'"
}
let rawURL = parsed.positional[1].trimmingCharacters(in: .whitespacesAndNewlines)
guard let url = URL(string: rawURL),
let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https" else {
return "ERROR: Invalid pull request URL '\(rawURL)'"
}
let statusRaw = (parsed.options["state"] ?? "open").lowercased()
guard let status = SidebarPullRequestStatus(rawValue: statusRaw) else {
return "ERROR: Invalid pull request state '\(statusRaw)' — use: open, merged, closed"
}
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
let validSurfaceIds = Set(tab.panels.keys)
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
let surfaceId: UUID
if let panelArg {
if panelArg.isEmpty {
result = "ERROR: Missing panel id — usage: report_pr <number> <url> [--state=open|merged|closed] [--tab=X] [--panel=Y]"
return
}
guard let parsedId = UUID(uuidString: panelArg) else {
result = "ERROR: Invalid panel id '\(panelArg)'"
return
}
surfaceId = parsedId
} else {
guard let focused = tab.focusedPanelId else {
result = "ERROR: Missing panel id (no focused surface)"
return
}
surfaceId = focused
}
guard validSurfaceIds.contains(surfaceId) else {
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
return
}
guard Self.shouldReplacePullRequest(
current: tab.panelPullRequests[surfaceId],
number: number,
url: url,
status: status
) else {
return
}
tab.updatePanelPullRequest(panelId: surfaceId, number: number, url: url, status: status)
}
return result
}
private func clearPullRequest(_ args: String) -> String {
let parsed = parseOptions(args)
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
let validSurfaceIds = Set(tab.panels.keys)
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
let surfaceId: UUID
if let panelArg {
if panelArg.isEmpty {
result = "ERROR: Missing panel id — usage: clear_pr [--tab=X] [--panel=Y]"
return
}
guard let parsedId = UUID(uuidString: panelArg) else {
result = "ERROR: Invalid panel id '\(panelArg)'"
return
}
surfaceId = parsedId
} else {
guard let focused = tab.focusedPanelId else {
result = "ERROR: Missing panel id (no focused surface)"
return
}
surfaceId = focused
}
guard validSurfaceIds.contains(surfaceId) else {
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
return
}
tab.clearPanelPullRequest(panelId: surfaceId)
}
return result
}
private func reportPorts(_ args: String) -> String {
let parsed = parseOptions(args)
guard !parsed.positional.isEmpty else {
@ -11383,6 +11514,12 @@ class TerminalController {
lines.append("git_branch=none")
}
if let pr = tab.pullRequest {
lines.append("pr=#\(pr.number) \(pr.status.rawValue) \(pr.url.absoluteString)")
} else {
lines.append("pr=none")
}
if tab.listeningPorts.isEmpty {
lines.append("ports=none")
} else {
@ -11426,6 +11563,8 @@ class TerminalController {
tab.progress = nil
tab.gitBranch = nil
tab.panelGitBranches.removeAll()
tab.pullRequest = nil
tab.panelPullRequests.removeAll()
tab.surfaceListeningPorts.removeAll()
tab.listeningPorts.removeAll()
}

View file

@ -37,6 +37,18 @@ struct SidebarGitBranchState {
let isDirty: Bool
}
enum SidebarPullRequestStatus: String {
case open
case merged
case closed
}
struct SidebarPullRequestState: Equatable {
let number: Int
let url: URL
let status: SidebarPullRequestStatus
}
enum SidebarBranchOrdering {
struct BranchEntry: Equatable {
let name: String
@ -117,6 +129,43 @@ enum SidebarBranchOrdering {
}
}
static func orderedUniquePullRequests(
orderedPanelIds: [UUID],
panelPullRequests: [UUID: SidebarPullRequestState],
fallbackPullRequest: SidebarPullRequestState?
) -> [SidebarPullRequestState] {
func statusPriority(_ status: SidebarPullRequestStatus) -> Int {
switch status {
case .merged: return 3
case .open: return 2
case .closed: return 1
}
}
var orderedNumbers: [Int] = []
var pullRequestsByNumber: [Int: SidebarPullRequestState] = [:]
for panelId in orderedPanelIds {
guard let state = panelPullRequests[panelId] else { continue }
let number = state.number
if pullRequestsByNumber[number] == nil {
orderedNumbers.append(number)
pullRequestsByNumber[number] = state
continue
}
guard let existing = pullRequestsByNumber[number] else { continue }
if statusPriority(state.status) > statusPriority(existing.status) {
pullRequestsByNumber[number] = state
}
}
if orderedNumbers.isEmpty, let fallbackPullRequest {
return [fallbackPullRequest]
}
return orderedNumbers.compactMap { pullRequestsByNumber[$0] }
}
static func orderedUniqueBranchDirectoryEntries(
orderedPanelIds: [UUID],
panelBranches: [UUID: SidebarGitBranchState],
@ -300,6 +349,8 @@ final class Workspace: Identifiable, ObservableObject {
@Published var progress: SidebarProgressState?
@Published var gitBranch: SidebarGitBranchState?
@Published var panelGitBranches: [UUID: SidebarGitBranchState] = [:]
@Published var pullRequest: SidebarPullRequestState?
@Published var panelPullRequests: [UUID: SidebarPullRequestState] = [:]
@Published var surfaceListeningPorts: [UUID: [Int]] = [:]
@Published var listeningPorts: [Int] = []
var surfaceTTYNames: [UUID: String] = [:]
@ -803,6 +854,24 @@ final class Workspace: Identifiable, ObservableObject {
}
}
func updatePanelPullRequest(panelId: UUID, number: Int, url: URL, status: SidebarPullRequestStatus) {
let state = SidebarPullRequestState(number: number, url: url, status: status)
let existing = panelPullRequests[panelId]
if existing != state {
panelPullRequests[panelId] = state
}
if panelId == focusedPanelId {
pullRequest = state
}
}
func clearPanelPullRequest(panelId: UUID) {
panelPullRequests.removeValue(forKey: panelId)
if panelId == focusedPanelId {
pullRequest = nil
}
}
@discardableResult
func updatePanelTitle(panelId: UUID, title: String) -> Bool {
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
@ -851,6 +920,7 @@ final class Workspace: Identifiable, ObservableObject {
manualUnreadMarkedAt = manualUnreadMarkedAt.filter { validSurfaceIds.contains($0.key) }
surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) }
surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) }
panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) }
recomputeListeningPorts()
}
@ -901,6 +971,14 @@ final class Workspace: Identifiable, ObservableObject {
)
}
func sidebarPullRequestsInDisplayOrder() -> [SidebarPullRequestState] {
SidebarBranchOrdering.orderedUniquePullRequests(
orderedPanelIds: sidebarOrderedPanelIds(),
panelPullRequests: panelPullRequests,
fallbackPullRequest: pullRequest
)
}
// MARK: - Panel Operations
/// Create a new split with a terminal panel
@ -1797,6 +1875,7 @@ final class Workspace: Identifiable, ObservableObject {
currentDirectory = dir
}
gitBranch = panelGitBranches[targetPanelId]
pullRequest = panelPullRequests[targetPanelId]
}
/// Reconcile focus/first-responder convergence.
@ -2018,6 +2097,7 @@ extension Workspace: BonsplitDelegate {
currentDirectory = dir
}
gitBranch = panelGitBranches[panelId]
pullRequest = panelPullRequests[panelId]
// Post notification
NotificationCenter.default.post(
@ -2156,6 +2236,7 @@ extension Workspace: BonsplitDelegate {
surfaceIdToPanelId.removeValue(forKey: tabId)
panelDirectories.removeValue(forKey: panelId)
panelGitBranches.removeValue(forKey: panelId)
panelPullRequests.removeValue(forKey: panelId)
panelTitles.removeValue(forKey: panelId)
panelCustomTitles.removeValue(forKey: panelId)
pinnedPanelIds.remove(panelId)
@ -2248,6 +2329,7 @@ extension Workspace: BonsplitDelegate {
panels.removeValue(forKey: panelId)
panelDirectories.removeValue(forKey: panelId)
panelGitBranches.removeValue(forKey: panelId)
panelPullRequests.removeValue(forKey: panelId)
panelTitles.removeValue(forKey: panelId)
panelCustomTitles.removeValue(forKey: panelId)
pinnedPanelIds.remove(panelId)

View file

@ -2568,6 +2568,7 @@ struct SettingsView: View {
@AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
@AppStorage(SidebarActiveTabIndicatorSettings.styleKey)
private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
@AppStorage("sidebarShowPullRequest") private var sidebarShowPullRequest = true
@State private var shortcutResetToken = UUID()
@State private var topBlurOpacity: Double = 0
@State private var topBlurBaselineOffset: CGFloat?
@ -2773,6 +2774,16 @@ struct SettingsView: View {
.pickerStyle(.menu)
}
SettingsCardDivider()
SettingsCardRow(
"Show Pull Requests in Sidebar",
subtitle: "Display PR status, number, and a clickable link when available."
) {
Toggle("", isOn: $sidebarShowPullRequest)
.labelsHidden()
.controlSize(.small)
}
}
SettingsSectionHeader(title: "Workspace Colors")
@ -3300,6 +3311,7 @@ struct SettingsView: View {
workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
sidebarShowPullRequest = true
showOpenAccessConfirmation = false
pendingOpenAccessMode = nil
socketPasswordDraft = ""

View file

@ -572,6 +572,37 @@ class cmux:
if not response.startswith("OK"):
raise cmuxError(response)
def report_pr(
self,
number: int,
url: str,
state: str = None,
tab: str = None,
panel: str = None,
) -> None:
"""Report pull-request metadata for sidebar display."""
cmd = f"report_pr {number} {url}"
if state:
cmd += f" --state={state}"
if tab:
cmd += f" --tab={tab}"
if panel:
cmd += f" --panel={panel}"
response = self._send_command(cmd)
if not response.startswith("OK"):
raise cmuxError(response)
def clear_pr(self, tab: str = None, panel: str = None) -> None:
"""Clear pull-request metadata for sidebar display."""
cmd = "clear_pr"
if tab:
cmd += f" --tab={tab}"
if panel:
cmd += f" --panel={panel}"
response = self._send_command(cmd)
if not response.startswith("OK"):
raise cmuxError(response)
def report_ports(self, *ports: int, tab: str = None) -> None:
"""Report listening ports for sidebar display."""
port_str = " ".join(str(p) for p in ports)

94
tests/test_sidebar_pr.py Normal file
View file

@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""
End-to-end test for sidebar pull-request metadata.
Validates:
1) report_pr writes sidebar PR state
2) state transition open -> merged is reflected
3) clear_pr removes PR metadata
"""
from __future__ import annotations
import os
import sys
import time
# Add the directory containing cmux.py to the path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cmux import cmux, cmuxError # noqa: E402
def _parse_sidebar_state(text: str) -> dict[str, str]:
data: dict[str, str] = {}
for raw in (text or "").splitlines():
line = raw.rstrip("\n")
if not line or line.startswith(" "):
continue
if "=" not in line:
continue
k, v = line.split("=", 1)
data[k.strip()] = v.strip()
return data
def _wait_for_state_field(
client: cmux,
key: str,
expected: str,
timeout: float = 8.0,
interval: float = 0.1,
) -> dict[str, str]:
start = time.time()
while time.time() - start < timeout:
state = _parse_sidebar_state(client.sidebar_state())
if state.get(key) == expected:
return state
time.sleep(interval)
raise AssertionError(f"Timed out waiting for {key}={expected!r}")
def main() -> int:
tag = os.environ.get("CMUX_TAG") or ""
if not tag:
print("Tip: set CMUX_TAG=<tag> when running this test to avoid socket conflicts.")
pr_number = 123
pr_url = f"https://github.com/manaflow-ai/cmux/pull/{pr_number}"
try:
with cmux() as client:
new_tab_id = client.new_tab()
client.select_tab(new_tab_id)
time.sleep(0.6)
tab_id = client.current_workspace()
surfaces = client.list_surfaces()
if not surfaces:
raise AssertionError("No surfaces found in selected workspace")
panel_id = surfaces[0][1]
client.report_pr(pr_number, pr_url, state="open", tab=tab_id, panel=panel_id)
_wait_for_state_field(client, "pr", f"#{pr_number} open {pr_url}")
client.report_pr(pr_number, pr_url, state="merged", tab=tab_id, panel=panel_id)
_wait_for_state_field(client, "pr", f"#{pr_number} merged {pr_url}")
client.clear_pr(tab=tab_id, panel=panel_id)
_wait_for_state_field(client, "pr", "none")
try:
client.close_tab(new_tab_id)
except Exception:
pass
print("Sidebar PR metadata test passed.")
return 0
except (cmuxError, AssertionError) as e:
print(f"Sidebar PR metadata test failed: {e}")
return 1
if __name__ == "__main__":
raise SystemExit(main())