diff --git a/README.md b/README.md index 9122c289..a9206e34 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 070b33e9..3a1c2428 100644
--- a/Resources/shell-integration/cmux-bash-integration.bash
+++ b/Resources/shell-integration/cmux-bash-integration.bash
@@ -40,6 +40,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:-}"
@@ -127,6 +130,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 3121788f..323ff506 100644
--- a/Resources/shell-integration/cmux-zsh-integration.zsh
+++ b/Resources/shell-integration/cmux-zsh-integration.zsh
@@ -46,6 +46,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
@@ -155,7 +159,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).
@@ -212,6 +217,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
@@ -261,6 +267,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 880280e2..57f19f42 100644
--- a/Sources/ContentView.swift
+++ b/Sources/ContentView.swift
@@ -1226,6 +1226,8 @@ struct ContentView: View {
@State private var commandPaletteUsageHistoryByCommandId: [String: CommandPaletteUsageEntry] = [:]
@AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey)
private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
+ @AppStorage(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey)
+ private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser
@FocusState private var isCommandPaletteSearchFocused: Bool
@FocusState private var isCommandPaletteRenameFocused: Bool
@@ -1368,6 +1370,7 @@ struct ContentView: View {
static let workspaceName = "workspace.name"
static let workspaceHasCustomName = "workspace.hasCustomName"
static let workspaceShouldPin = "workspace.shouldPin"
+ static let workspaceHasPullRequests = "workspace.hasPullRequests"
static let hasFocusedPanel = "panel.hasFocus"
static let panelName = "panel.name"
@@ -3339,6 +3342,10 @@ struct ContentView: View {
snapshot.setString(CommandPaletteContextKeys.workspaceName, workspaceDisplayName(workspace))
snapshot.setBool(CommandPaletteContextKeys.workspaceHasCustomName, workspace.customTitle != nil)
snapshot.setBool(CommandPaletteContextKeys.workspaceShouldPin, !workspace.isPinned)
+ snapshot.setBool(
+ CommandPaletteContextKeys.workspaceHasPullRequests,
+ !workspace.sidebarPullRequestsInDisplayOrder().isEmpty
+ )
}
if let panelContext = focusedPanelContext {
@@ -3654,6 +3661,18 @@ struct ContentView: View {
)
)
+ contributions.append(
+ CommandPaletteCommandContribution(
+ commandId: "palette.openWorkspacePullRequests",
+ title: constant("Open All Workspace PR Links"),
+ subtitle: workspaceSubtitle,
+ keywords: ["pull", "request", "review", "merge", "pr", "mr", "open", "links", "workspace"],
+ when: {
+ $0.bool(CommandPaletteContextKeys.hasWorkspace) &&
+ $0.bool(CommandPaletteContextKeys.workspaceHasPullRequests)
+ }
+ )
+ )
contributions.append(
CommandPaletteCommandContribution(
commandId: "palette.browserBack",
@@ -4019,6 +4038,13 @@ struct ContentView: View {
registry.register(commandId: "palette.previousTabInPane") {
tabManager.selectPreviousSurface()
}
+ registry.register(commandId: "palette.openWorkspacePullRequests") {
+ DispatchQueue.main.async {
+ if !openWorkspacePullRequestsInConfiguredBrowser() {
+ NSSound.beep()
+ }
+ }
+ }
registry.register(commandId: "palette.browserBack") {
tabManager.focusedBrowserPanel?.goBack()
@@ -4664,6 +4690,31 @@ struct ContentView: View {
return NSWorkspace.shared.open(url)
}
+ private func openWorkspacePullRequestsInConfiguredBrowser() -> Bool {
+ guard let workspace = tabManager.selectedWorkspace else { return false }
+ let pullRequests = workspace.sidebarPullRequestsInDisplayOrder()
+ guard !pullRequests.isEmpty else { return false }
+
+ var openedCount = 0
+ if openSidebarPullRequestLinksInCmuxBrowser {
+ for pullRequest in pullRequests {
+ if tabManager.openBrowser(url: pullRequest.url, insertAtEnd: true) != nil {
+ openedCount += 1
+ } else if NSWorkspace.shared.open(pullRequest.url) {
+ openedCount += 1
+ }
+ }
+ return openedCount > 0
+ }
+
+ for pullRequest in pullRequests {
+ if NSWorkspace.shared.open(pullRequest.url) {
+ openedCount += 1
+ }
+ }
+ return openedCount > 0
+ }
+
private func openFocusedDirectory(in target: TerminalDirectoryOpenTarget) -> Bool {
guard let directoryURL = focusedTerminalDirectoryURL() else { return false }
return openFocusedDirectory(directoryURL, in: target)
@@ -6037,11 +6088,15 @@ private struct TabItemView: View {
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
@AppStorage("sidebarShowGitBranch") private var sidebarShowGitBranch = true
@AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
+ @AppStorage("sidebarShowBranchDirectory") private var sidebarShowBranchDirectory = true
@AppStorage("sidebarShowGitBranchIcon") private var sidebarShowGitBranchIcon = false
+ @AppStorage("sidebarShowPullRequest") private var sidebarShowPullRequest = true
+ @AppStorage(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey)
+ private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser
@AppStorage("sidebarShowPorts") private var sidebarShowPorts = true
@AppStorage("sidebarShowLog") private var sidebarShowLog = true
@AppStorage("sidebarShowProgress") private var sidebarShowProgress = true
- @AppStorage("sidebarShowStatusPills") private var sidebarShowStatusPills = true
+ @AppStorage("sidebarShowStatusPills") private var sidebarShowMetadata = true
@AppStorage(SidebarActiveTabIndicatorSettings.styleKey)
private var activeTabIndicatorStyleRaw = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
@@ -6225,16 +6280,25 @@ private struct TabItemView: View {
.multilineTextAlignment(.leading)
}
- if sidebarShowStatusPills, !tab.statusEntries.isEmpty {
- SidebarStatusPillsRow(
- entries: tab.statusEntries.values.sorted(by: { (lhs, rhs) in
- if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp }
- return lhs.key < rhs.key
- }),
- isActive: usesInvertedActiveForeground,
- onFocus: { updateSelection() }
- )
- .transition(.opacity.combined(with: .move(edge: .top)))
+ if sidebarShowMetadata {
+ let metadataEntries = tab.sidebarStatusEntriesInDisplayOrder()
+ let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder()
+ if !metadataEntries.isEmpty {
+ SidebarMetadataRows(
+ entries: metadataEntries,
+ isActive: usesInvertedActiveForeground,
+ onFocus: { updateSelection() }
+ )
+ .transition(.opacity.combined(with: .move(edge: .top)))
+ }
+ if !metadataBlocks.isEmpty {
+ SidebarMetadataMarkdownBlocks(
+ blocks: metadataBlocks,
+ isActive: usesInvertedActiveForeground,
+ onFocus: { updateSelection() }
+ )
+ .transition(.opacity.combined(with: .move(edge: .top)))
+ }
}
// Latest log entry
@@ -6277,54 +6341,85 @@ private struct TabItemView: View {
}
// Branch + directory row
- if sidebarBranchVerticalLayout {
- if !verticalBranchDirectoryLines.isEmpty {
- HStack(alignment: .top, spacing: 3) {
- if sidebarShowGitBranchIcon, sidebarShowGitBranch, verticalRowsContainBranch {
- Image(systemName: "arrow.triangle.branch")
- .font(.system(size: 9))
- .foregroundColor(activeSecondaryColor(0.6))
- }
- VStack(alignment: .leading, spacing: 1) {
- ForEach(Array(verticalBranchDirectoryLines.enumerated()), id: \.offset) { _, line in
- HStack(spacing: 3) {
- if let branch = line.branch {
- Text(branch)
- .font(.system(size: 10, design: .monospaced))
- .foregroundColor(activeSecondaryColor(0.75))
- .lineLimit(1)
- .truncationMode(.tail)
- }
- if line.branch != nil, line.directory != nil {
- Image(systemName: "circle.fill")
- .font(.system(size: 3))
- .foregroundColor(activeSecondaryColor(0.6))
- .padding(.horizontal, 1)
- }
- if let directory = line.directory {
- Text(directory)
- .font(.system(size: 10, design: .monospaced))
- .foregroundColor(activeSecondaryColor(0.75))
- .lineLimit(1)
- .truncationMode(.tail)
+ if sidebarShowBranchDirectory {
+ if sidebarBranchVerticalLayout {
+ if !verticalBranchDirectoryLines.isEmpty {
+ HStack(alignment: .top, spacing: 3) {
+ if sidebarShowGitBranchIcon, sidebarShowGitBranch, verticalRowsContainBranch {
+ Image(systemName: "arrow.triangle.branch")
+ .font(.system(size: 9))
+ .foregroundColor(activeSecondaryColor(0.6))
+ }
+ VStack(alignment: .leading, spacing: 1) {
+ ForEach(Array(verticalBranchDirectoryLines.enumerated()), id: \.offset) { _, line in
+ HStack(spacing: 3) {
+ if let branch = line.branch {
+ Text(branch)
+ .font(.system(size: 10, design: .monospaced))
+ .foregroundColor(activeSecondaryColor(0.75))
+ .lineLimit(1)
+ .truncationMode(.tail)
+ }
+ if line.branch != nil, line.directory != nil {
+ Image(systemName: "circle.fill")
+ .font(.system(size: 3))
+ .foregroundColor(activeSecondaryColor(0.6))
+ .padding(.horizontal, 1)
+ }
+ if let directory = line.directory {
+ Text(directory)
+ .font(.system(size: 10, design: .monospaced))
+ .foregroundColor(activeSecondaryColor(0.75))
+ .lineLimit(1)
+ .truncationMode(.tail)
+ }
}
}
}
}
}
- }
- } else if let dirRow = branchDirectoryRow {
- HStack(spacing: 3) {
- if sidebarShowGitBranch && gitBranchSummaryText != nil && sidebarShowGitBranchIcon {
- Image(systemName: "arrow.triangle.branch")
- .font(.system(size: 9))
- .foregroundColor(activeSecondaryColor(0.6))
+ } else if let dirRow = branchDirectoryRow {
+ HStack(spacing: 3) {
+ if sidebarShowGitBranch && gitBranchSummaryText != nil && sidebarShowGitBranchIcon {
+ Image(systemName: "arrow.triangle.branch")
+ .font(.system(size: 9))
+ .foregroundColor(activeSecondaryColor(0.6))
+ }
+ Text(dirRow)
+ .font(.system(size: 10, design: .monospaced))
+ .foregroundColor(activeSecondaryColor(0.75))
+ .lineLimit(1)
+ .truncationMode(.tail)
+ }
+ }
+ }
+
+ // Pull request rows
+ if sidebarShowPullRequest, !pullRequestDisplays.isEmpty {
+ VStack(alignment: .leading, spacing: 1) {
+ ForEach(pullRequestDisplays) { pullRequest in
+ Button(action: {
+ openPullRequestLink(pullRequest.url)
+ }) {
+ HStack(spacing: 4) {
+ PullRequestStatusIcon(
+ status: pullRequest.status,
+ color: pullRequestForegroundColor
+ )
+ Text("\(pullRequest.label) #\(pullRequest.number)")
+ .underline()
+ .lineLimit(1)
+ .truncationMode(.tail)
+ Text(pullRequestStatusLabel(pullRequest.status))
+ .lineLimit(1)
+ Spacer(minLength: 0)
+ }
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundColor(pullRequestForegroundColor)
+ }
+ .buttonStyle(.plain)
+ .help("Open \(pullRequest.label) #\(pullRequest.number)")
}
- Text(dirRow)
- .font(.system(size: 10, design: .monospaced))
- .foregroundColor(activeSecondaryColor(0.75))
- .lineLimit(1)
- .truncationMode(.tail)
}
}
@@ -6339,6 +6434,7 @@ private struct TabItemView: View {
}
.animation(.easeInOut(duration: 0.2), value: tab.logEntries.count)
.animation(.easeInOut(duration: 0.2), value: tab.progress != nil)
+ .animation(.easeInOut(duration: 0.2), value: tab.metadataBlocks.count)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(
@@ -6895,6 +6991,54 @@ private struct TabItemView: View {
return entries.isEmpty ? nil : entries.joined(separator: " | ")
}
+ private struct PullRequestDisplay: Identifiable {
+ let id: String
+ let number: Int
+ let label: String
+ let url: URL
+ let status: SidebarPullRequestStatus
+ }
+
+ private var pullRequestDisplays: [PullRequestDisplay] {
+ tab.sidebarPullRequestsInDisplayOrder().map { pullRequest in
+ PullRequestDisplay(
+ id: "\(pullRequest.label.lowercased())#\(pullRequest.number)|\(pullRequest.url.absoluteString)",
+ number: pullRequest.number,
+ label: pullRequest.label,
+ url: pullRequest.url,
+ status: pullRequest.status
+ )
+ }
+ }
+
+ private var pullRequestForegroundColor: Color {
+ isActive ? .white.opacity(0.75) : .secondary
+ }
+
+ private func openPullRequestLink(_ url: URL) {
+ updateSelection()
+ if openSidebarPullRequestLinksInCmuxBrowser {
+ if tabManager.openBrowser(
+ inWorkspace: tab.id,
+ url: url,
+ preferSplitRight: true,
+ insertAtEnd: true
+ ) == nil {
+ NSWorkspace.shared.open(url)
+ }
+ return
+ }
+ NSWorkspace.shared.open(url)
+ }
+
+ 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"
@@ -6941,6 +7085,101 @@ private struct TabItemView: View {
return trimmed
}
+ private struct PullRequestStatusIcon: View {
+ let status: SidebarPullRequestStatus
+ let color: Color
+ private static let frameSize: CGFloat = 12
+
+ 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: 7, 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.2, lineCap: .round, lineJoin: .round)
+ private static let nodeDiameter: CGFloat = 3.0
+ private static let frameSize: CGFloat = 13
+
+ 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.2, lineCap: .round, lineJoin: .round)
+ private static let nodeDiameter: CGFloat = 3.0
+ private static let frameSize: CGFloat = 13
+
+ 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)
@@ -7012,30 +7251,19 @@ private struct TabItemView: View {
}
}
-private struct SidebarStatusPillsRow: View {
+private struct SidebarMetadataRows: View {
let entries: [SidebarStatusEntry]
let isActive: Bool
let onFocus: () -> Void
@State private var isExpanded: Bool = false
+ private let collapsedEntryLimit = 3
var body: some View {
VStack(alignment: .leading, spacing: 2) {
- Text(statusText)
- .font(.system(size: 10))
- .foregroundColor(isActive ? activePrimaryTextColor : .secondary)
- .lineLimit(isExpanded ? nil : 3)
- .truncationMode(.tail)
- .multilineTextAlignment(.leading)
- .frame(maxWidth: .infinity, alignment: .leading)
- .contentShape(Rectangle())
- .onTapGesture {
- onFocus()
- guard shouldShowToggle else { return }
- withAnimation(.easeInOut(duration: 0.15)) {
- isExpanded.toggle()
- }
- }
+ ForEach(visibleEntries, id: \.key) { entry in
+ SidebarMetadataEntryRow(entry: entry, isActive: isActive, onFocus: onFocus)
+ }
if shouldShowToggle {
Button(isExpanded ? "Show less" : "Show more") {
@@ -7050,29 +7278,203 @@ private struct SidebarStatusPillsRow: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
}
- .help(statusText)
- }
-
- private var activePrimaryTextColor: Color {
- Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.8))
+ .help(helpText)
}
private var activeSecondaryTextColor: Color {
Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.65))
}
- private var statusText: String {
- entries
- .map { entry in
- let value = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
- if !value.isEmpty { return value }
- return entry.key
- }
- .joined(separator: "\n")
+ private var visibleEntries: [SidebarStatusEntry] {
+ guard !isExpanded, entries.count > collapsedEntryLimit else { return entries }
+ return Array(entries.prefix(collapsedEntryLimit))
+ }
+
+ private var helpText: String {
+ entries.map { entry in
+ let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.isEmpty ? entry.key : trimmed
+ }
+ .joined(separator: "\n")
}
private var shouldShowToggle: Bool {
- entries.count > 1 || statusText.count > 120
+ entries.count > collapsedEntryLimit
+ }
+}
+
+private struct SidebarMetadataEntryRow: View {
+ let entry: SidebarStatusEntry
+ let isActive: Bool
+ let onFocus: () -> Void
+
+ var body: some View {
+ Group {
+ if let url = entry.url {
+ Button {
+ onFocus()
+ NSWorkspace.shared.open(url)
+ } label: {
+ rowContent(underlined: true)
+ }
+ .buttonStyle(.plain)
+ .help(url.absoluteString)
+ } else {
+ rowContent(underlined: false)
+ .contentShape(Rectangle())
+ .onTapGesture { onFocus() }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func rowContent(underlined: Bool) -> some View {
+ HStack(spacing: 4) {
+ if let icon = iconView {
+ icon
+ .foregroundColor(foregroundColor.opacity(0.95))
+ }
+ metadataText(underlined: underlined)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ Spacer(minLength: 0)
+ }
+ .font(.system(size: 10))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ private var foregroundColor: Color {
+ if let raw = entry.color, let explicit = Color(hex: raw) {
+ return explicit
+ }
+ return isActive ? .white.opacity(0.8) : .secondary
+ }
+
+ private var iconView: AnyView? {
+ guard let iconRaw = entry.icon?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !iconRaw.isEmpty else {
+ return nil
+ }
+ if iconRaw.hasPrefix("emoji:") {
+ let value = String(iconRaw.dropFirst("emoji:".count))
+ guard !value.isEmpty else { return nil }
+ return AnyView(Text(value).font(.system(size: 9)))
+ }
+ if iconRaw.hasPrefix("text:") {
+ let value = String(iconRaw.dropFirst("text:".count))
+ guard !value.isEmpty else { return nil }
+ return AnyView(Text(value).font(.system(size: 8, weight: .semibold)))
+ }
+ let symbolName: String
+ if iconRaw.hasPrefix("sf:") {
+ symbolName = String(iconRaw.dropFirst("sf:".count))
+ } else {
+ symbolName = iconRaw
+ }
+ guard !symbolName.isEmpty else { return nil }
+ return AnyView(Image(systemName: symbolName).font(.system(size: 8, weight: .medium)))
+ }
+
+ @ViewBuilder
+ private func metadataText(underlined: Bool) -> some View {
+ let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
+ let display = trimmed.isEmpty ? entry.key : trimmed
+ if entry.format == .markdown,
+ let attributed = try? AttributedString(
+ markdown: display,
+ options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
+ ) {
+ Text(attributed)
+ .underline(underlined)
+ .foregroundColor(foregroundColor)
+ } else {
+ Text(display)
+ .underline(underlined)
+ .foregroundColor(foregroundColor)
+ }
+ }
+}
+
+private struct SidebarMetadataMarkdownBlocks: View {
+ let blocks: [SidebarMetadataBlock]
+ let isActive: Bool
+ let onFocus: () -> Void
+
+ @State private var isExpanded: Bool = false
+ private let collapsedBlockLimit = 1
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 3) {
+ ForEach(visibleBlocks, id: \.key) { block in
+ SidebarMetadataMarkdownBlockRow(
+ block: block,
+ isActive: isActive,
+ onFocus: onFocus
+ )
+ }
+
+ if shouldShowToggle {
+ Button(isExpanded ? "Show less details" : "Show more details") {
+ onFocus()
+ withAnimation(.easeInOut(duration: 0.15)) {
+ isExpanded.toggle()
+ }
+ }
+ .buttonStyle(.plain)
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundColor(isActive ? .white.opacity(0.65) : .secondary.opacity(0.9))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ }
+
+ private var visibleBlocks: [SidebarMetadataBlock] {
+ guard !isExpanded, blocks.count > collapsedBlockLimit else { return blocks }
+ return Array(blocks.prefix(collapsedBlockLimit))
+ }
+
+ private var shouldShowToggle: Bool {
+ blocks.count > collapsedBlockLimit
+ }
+}
+
+private struct SidebarMetadataMarkdownBlockRow: View {
+ let block: SidebarMetadataBlock
+ let isActive: Bool
+ let onFocus: () -> Void
+
+ @State private var renderedMarkdown: AttributedString?
+
+ var body: some View {
+ Group {
+ if let renderedMarkdown {
+ Text(renderedMarkdown)
+ .foregroundColor(foregroundColor)
+ } else {
+ Text(block.markdown)
+ .foregroundColor(foregroundColor)
+ }
+ }
+ .font(.system(size: 10))
+ .multilineTextAlignment(.leading)
+ .fixedSize(horizontal: false, vertical: true)
+ .contentShape(Rectangle())
+ .onTapGesture { onFocus() }
+ .onAppear(perform: renderMarkdown)
+ .onChange(of: block.markdown) { _ in
+ renderMarkdown()
+ }
+ }
+
+ private var foregroundColor: Color {
+ isActive ? .white.opacity(0.8) : .secondary
+ }
+
+ private func renderMarkdown() {
+ renderedMarkdown = try? AttributedString(
+ markdown: block.markdown,
+ options: .init(interpretedSyntax: .full)
+ )
}
}
diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift
index 71f297bb..07f1fd45 100644
--- a/Sources/Panels/BrowserPanel.swift
+++ b/Sources/Panels/BrowserPanel.swift
@@ -127,6 +127,9 @@ enum BrowserLinkOpenSettings {
static let openTerminalLinksInCmuxBrowserKey = "browserOpenTerminalLinksInCmuxBrowser"
static let defaultOpenTerminalLinksInCmuxBrowser: Bool = true
+ static let openSidebarPullRequestLinksInCmuxBrowserKey = "browserOpenSidebarPullRequestLinksInCmuxBrowser"
+ static let defaultOpenSidebarPullRequestLinksInCmuxBrowser: Bool = true
+
static let interceptTerminalOpenCommandInCmuxBrowserKey = "browserInterceptTerminalOpenCommandInCmuxBrowser"
static let defaultInterceptTerminalOpenCommandInCmuxBrowser: Bool = true
@@ -140,6 +143,13 @@ enum BrowserLinkOpenSettings {
return defaults.bool(forKey: openTerminalLinksInCmuxBrowserKey)
}
+ static func openSidebarPullRequestLinksInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool {
+ if defaults.object(forKey: openSidebarPullRequestLinksInCmuxBrowserKey) == nil {
+ return defaultOpenSidebarPullRequestLinksInCmuxBrowser
+ }
+ return defaults.bool(forKey: openSidebarPullRequestLinksInCmuxBrowserKey)
+ }
+
static func interceptTerminalOpenCommandInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool {
if defaults.object(forKey: interceptTerminalOpenCommandInCmuxBrowserKey) != nil {
return defaults.bool(forKey: interceptTerminalOpenCommandInCmuxBrowserKey)
diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift
index 699b856a..ea282f33 100644
--- a/Sources/Panels/BrowserPanelView.swift
+++ b/Sources/Panels/BrowserPanelView.swift
@@ -156,13 +156,10 @@ private struct OmnibarAddressButtonStyleBody: View {
}
private extension View {
- @ViewBuilder
func cmuxFlatSymbolColorRendering() -> some View {
- if #available(macOS 26.0, *) {
- self.symbolColorRenderingMode(.flat)
- } else {
- self
- }
+ // `symbolColorRenderingMode(.flat)` is not available in the current SDK
+ // used by CI/local builds. Keep this modifier as a compatibility no-op.
+ self
}
}
diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift
index 345c08d1..f707686c 100644
--- a/Sources/TabManager.swift
+++ b/Sources/TabManager.swift
@@ -1940,19 +1940,81 @@ class TabManager: ObservableObject {
return tab.browserPanel(for: panelId)
}
+ /// Open a browser in a specific workspace, optionally preferring a split-right layout.
+ @discardableResult
+ func openBrowser(
+ inWorkspace tabId: UUID,
+ url: URL? = nil,
+ preferSplitRight: Bool = false,
+ insertAtEnd: Bool = false
+ ) -> UUID? {
+ guard let workspace = tabs.first(where: { $0.id == tabId }) else { return nil }
+ if selectedTabId != tabId {
+ selectedTabId = tabId
+ }
+
+ if preferSplitRight {
+ if let targetPaneId = workspace.topRightBrowserReusePane(),
+ let browserPanel = workspace.newBrowserSurface(
+ inPane: targetPaneId,
+ url: url,
+ focus: true,
+ insertAtEnd: insertAtEnd
+ ) {
+ rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id)
+ return browserPanel.id
+ }
+
+ let splitSourcePanelId: UUID? = {
+ if let focusedPanelId = workspace.focusedPanelId,
+ workspace.panels[focusedPanelId] != nil {
+ return focusedPanelId
+ }
+ if let rememberedPanelId = lastFocusedPanelByTab[tabId],
+ workspace.panels[rememberedPanelId] != nil {
+ return rememberedPanelId
+ }
+ if let orderedPanelId = workspace.sidebarOrderedPanelIds().first(where: { workspace.panels[$0] != nil }) {
+ return orderedPanelId
+ }
+ return workspace.panels.keys.sorted { $0.uuidString < $1.uuidString }.first
+ }()
+
+ if let splitSourcePanelId,
+ let browserPanel = workspace.newBrowserSplit(
+ from: splitSourcePanelId,
+ orientation: .horizontal,
+ url: url,
+ focus: true
+ ) {
+ rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id)
+ return browserPanel.id
+ }
+ }
+
+ guard let paneId = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first,
+ let browserPanel = workspace.newBrowserSurface(
+ inPane: paneId,
+ url: url,
+ focus: true,
+ insertAtEnd: insertAtEnd
+ ) else {
+ return nil
+ }
+ rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id)
+ return browserPanel.id
+ }
+
/// Open a browser in the currently focused pane (as a new surface)
@discardableResult
func openBrowser(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? {
- guard let tabId = selectedTabId,
- let tab = tabs.first(where: { $0.id == tabId }),
- let focusedPaneId = tab.bonsplitController.focusedPaneId else { return nil }
- let panel = tab.newBrowserSurface(
- inPane: focusedPaneId,
+ guard let tabId = selectedTabId else { return nil }
+ return openBrowser(
+ inWorkspace: tabId,
url: url,
- focus: true,
+ preferSplitRight: false,
insertAtEnd: insertAtEnd
)
- return panel?.id
}
/// Reopen the most recently closed browser panel (Cmd+Shift+T).
diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift
index e4b070f3..5899fc6f 100644
--- a/Sources/TerminalController.swift
+++ b/Sources/TerminalController.swift
@@ -166,10 +166,29 @@ class TerminalController {
key: String,
value: String,
icon: String?,
- color: String?
+ color: String?,
+ url: URL?,
+ priority: Int,
+ format: SidebarMetadataFormat
) -> Bool {
guard let current else { return true }
- return current.key != key || current.value != value || current.icon != icon || current.color != color
+ return current.key != key ||
+ current.value != value ||
+ current.icon != icon ||
+ current.color != color ||
+ current.url != url ||
+ current.priority != priority ||
+ current.format != format
+ }
+
+ nonisolated static func shouldReplaceMetadataBlock(
+ current: SidebarMetadataBlock?,
+ key: String,
+ markdown: String,
+ priority: Int
+ ) -> Bool {
+ guard let current else { return true }
+ return current.key != key || current.markdown != markdown || current.priority != priority
}
nonisolated static func shouldReplaceProgress(
@@ -190,6 +209,17 @@ class TerminalController {
return current.branch != branch || current.isDirty != isDirty
}
+ nonisolated static func shouldReplacePullRequest(
+ current: SidebarPullRequestState?,
+ number: Int,
+ label: String,
+ url: URL,
+ status: SidebarPullRequestStatus
+ ) -> Bool {
+ guard let current else { return true }
+ return current.number != number || current.label != label || 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()
@@ -707,12 +737,30 @@ class TerminalController {
case "set_status":
return setStatus(args)
+ case "report_meta":
+ return reportMeta(args)
+
+ case "report_meta_block":
+ return reportMetaBlock(args)
+
case "clear_status":
return clearStatus(args)
+ case "clear_meta":
+ return clearMeta(args)
+
+ case "clear_meta_block":
+ return clearMetaBlock(args)
+
case "list_status":
return listStatus(args)
+ case "list_meta":
+ return listMeta(args)
+
+ case "list_meta_blocks":
+ return listMetaBlocks(args)
+
case "log":
return appendLog(args)
@@ -734,6 +782,15 @@ class TerminalController {
case "clear_git_branch":
return clearGitBranch(args)
+ case "report_pr":
+ return reportPullRequest(args)
+
+ case "report_review":
+ return reportPullRequest(args)
+
+ case "clear_pr":
+ return clearPullRequest(args)
+
case "report_ports":
return reportPorts(args)
@@ -8339,9 +8396,15 @@ class TerminalController {
clear_notifications - Clear all notifications
set_app_focus