From 2d454df50f9fad5754944c571f12512d4994a920 Mon Sep 17 00:00:00 2001 From: Vadim Kostin Date: Mon, 23 Feb 2026 10:40:29 +0800 Subject: [PATCH 01/12] feat(sidebar): show linked pull request metadata --- README.md | 4 +- .../cmux-bash-integration.bash | 45 ++++++ .../cmux-zsh-integration.zsh | 65 +++++++- Sources/ContentView.swift | 153 ++++++++++++++++++ Sources/TerminalController.swift | 139 ++++++++++++++++ Sources/Workspace.swift | 82 ++++++++++ Sources/cmuxApp.swift | 12 ++ tests/cmux.py | 31 ++++ tests/test_sidebar_pr.py | 94 +++++++++++ 9 files changed, 622 insertions(+), 3 deletions(-) create mode 100644 tests/test_sidebar_pr.py 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

Vertical + horizontal tabs

-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. Vertical tabs and split panes @@ -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 [--status=dirty] [--tab=X] [--panel=Y] - Report git branch clear_git_branch [--tab=X] [--panel=Y] - Clear git branch + report_pr [--state=open|merged|closed] [--tab=X] [--panel=Y] - Report pull request + clear_pr [--tab=X] [--panel=Y] - Clear pull request report_ports [port2...] [--tab=X] [--panel=Y] - Report listening ports report_tty [--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 [--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 [--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() } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index bb0345d4..e634b8b1 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -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) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 1d4f57fb..73dc6980 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -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 = "" diff --git a/tests/cmux.py b/tests/cmux.py index 23c1f4b7..aca7f4cb 100755 --- a/tests/cmux.py +++ b/tests/cmux.py @@ -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) diff --git a/tests/test_sidebar_pr.py b/tests/test_sidebar_pr.py new file mode 100644 index 00000000..e6c9cbfd --- /dev/null +++ b/tests/test_sidebar_pr.py @@ -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= 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()) From a33e231c790d7a3c26b63f3eef9477251439b43d Mon Sep 17 00:00:00 2001 From: Vadim Kostin Date: Mon, 23 Feb 2026 10:52:21 +0800 Subject: [PATCH 02/12] fix(browser): avoid unavailable symbolColorRenderingMode API --- Sources/Panels/BrowserPanelView.swift | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 65f2f25e..3b017ebd 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 } } From f2ecb4877b86d17cc7df5990c4ddf107c49138ca Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:19:38 -0800 Subject: [PATCH 03/12] feat(sidebar): add generic metadata rows and commands --- Sources/ContentView.swift | 158 +++++++++++++++++++++++------- Sources/TerminalController.swift | 159 +++++++++++++++++++++++++------ Sources/Workspace.swift | 16 ++++ Sources/cmuxApp.swift | 13 +++ tests/cmux.py | 67 ++++++++++++- tests/test_sidebar_meta.py | 126 ++++++++++++++++++++++++ 6 files changed, 473 insertions(+), 66 deletions(-) create mode 100644 tests/test_sidebar_meta.py diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index b1236f89..41893bb6 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2485,7 +2485,7 @@ private struct TabItemView: View { @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 @@ -2665,16 +2665,16 @@ 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() + if !metadataEntries.isEmpty { + SidebarMetadataRows( + entries: metadataEntries, + isActive: usesInvertedActiveForeground, + onFocus: { updateSelection() } + ) + .transition(.opacity.combined(with: .move(edge: .top))) + } } // Latest log entry @@ -3535,30 +3535,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 ? .white.opacity(0.8) : .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") { @@ -3573,21 +3562,116 @@ private struct SidebarStatusPillsRow: View { .frame(maxWidth: .infinity, alignment: .leading) } } - .help(statusText) + .help(helpText) } - 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: 10))) + } + if iconRaw.hasPrefix("text:") { + let value = String(iconRaw.dropFirst("text:".count)) + guard !value.isEmpty else { return nil } + return AnyView(Text(value).font(.system(size: 9, 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: 9, 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) + } } } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 532f9dd3..78f4e88a 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -165,10 +165,19 @@ 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 shouldReplaceProgress( @@ -716,12 +725,21 @@ class TerminalController { case "set_status": return setStatus(args) + case "report_meta": + return reportMeta(args) + case "clear_status": return clearStatus(args) + case "clear_meta": + return clearMeta(args) + case "list_status": return listStatus(args) + case "list_meta": + return listMeta(args) + case "log": return appendLog(args) @@ -7885,9 +7903,12 @@ class TerminalController { clear_notifications - Clear all notifications set_app_focus - Override app focus state simulate_app_active - Trigger app active handler - set_status [--icon=X] [--color=#hex] [--tab=X] - Set a status entry + set_status [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X] - Set a status entry + report_meta [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X] - Set sidebar metadata entry clear_status [--tab=X] - Remove a status entry + clear_meta [--tab=X] - Remove sidebar metadata entry list_status [--tab=X] - List all status entries + list_meta [--tab=X] - List sidebar metadata entries log [--level=X] [--source=X] [--tab=X] -- - Append a log entry clear_log [--tab=X] - Clear log entries list_log [--limit=N] [--tab=X] - List log entries @@ -10826,16 +10847,59 @@ class TerminalController { return tabManager.tabs.first(where: { $0.id == selectedId }) } - private func setStatus(_ args: String) -> String { + private func parseSidebarMetadataFormat(_ raw: String) -> SidebarMetadataFormat? { + switch raw.lowercased() { + case "plain": + return .plain + case "markdown", "md": + return .markdown + default: + return nil + } + } + + private func normalizedOptionValue(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private func upsertSidebarMetadata(_ args: String, missingError: String) -> String { guard tabManager != nil else { return "ERROR: TabManager not available" } let parsed = parseOptionsNoStop(args) - guard parsed.positional.count >= 2 else { - return "ERROR: Missing status key or value — usage: set_status [--icon=X] [--color=#hex] [--tab=X]" - } + guard parsed.positional.count >= 2 else { return missingError } + let key = parsed.positional[0] let value = parsed.positional[1...].joined(separator: " ") - let icon = parsed.options["icon"] - let color = parsed.options["color"] + let icon = normalizedOptionValue(parsed.options["icon"]) + let color = normalizedOptionValue(parsed.options["color"]) + + let formatRaw = normalizedOptionValue(parsed.options["format"]) ?? SidebarMetadataFormat.plain.rawValue + guard let format = parseSidebarMetadataFormat(formatRaw) else { + return "ERROR: Invalid metadata format '\(formatRaw)' — use: plain, markdown" + } + + let priority: Int + if let rawPriority = normalizedOptionValue(parsed.options["priority"]) { + guard let parsedPriority = Int(rawPriority) else { + return "ERROR: Invalid metadata priority '\(rawPriority)' — must be an integer" + } + priority = max(-9999, min(9999, parsedPriority)) + } else { + priority = 0 + } + + let parsedURL: URL? + if let rawURL = normalizedOptionValue(parsed.options["url"] ?? parsed.options["link"]) { + guard let candidate = URL(string: rawURL), + let scheme = candidate.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + return "ERROR: Invalid metadata URL '\(rawURL)' — expected http(s) URL" + } + parsedURL = candidate + } else { + parsedURL = nil + } var result = "OK" DispatchQueue.main.sync { @@ -10848,7 +10912,10 @@ class TerminalController { key: key, value: value, icon: icon, - color: color + color: color, + url: parsedURL, + priority: priority, + format: format ) else { return } @@ -10857,16 +10924,19 @@ class TerminalController { value: value, icon: icon, color: color, + url: parsedURL, + priority: priority, + format: format, timestamp: Date() ) } return result } - private func clearStatus(_ args: String) -> String { + private func clearSidebarMetadata(_ args: String, usage: String) -> String { let parsed = parseOptions(args) guard let key = parsed.positional.first, parsed.positional.count == 1 else { - return "ERROR: Missing status key — usage: clear_status [--tab=X]" + return "ERROR: Missing metadata key — usage: \(usage)" } var result = "OK" @@ -10882,28 +10952,63 @@ class TerminalController { return result } - private func listStatus(_ args: String) -> String { + private func sidebarMetadataLine(_ entry: SidebarStatusEntry) -> String { + var line = "\(entry.key)=\(entry.value)" + if let icon = entry.icon { line += " icon=\(icon)" } + if let color = entry.color { line += " color=\(color)" } + if let url = entry.url { line += " url=\(url.absoluteString)" } + if entry.priority != 0 { line += " priority=\(entry.priority)" } + if entry.format != .plain { line += " format=\(entry.format.rawValue)" } + return line + } + + private func listSidebarMetadata(_ args: String, emptyMessage: String) -> String { var result = "" DispatchQueue.main.sync { guard let tab = resolveTabForReport(args) else { result = "ERROR: Tab not found" return } - if tab.statusEntries.isEmpty { - result = "No status entries" + let entries = tab.sidebarStatusEntriesInDisplayOrder() + if entries.isEmpty { + result = emptyMessage return } - let lines = tab.statusEntries.values.sorted(by: { $0.key < $1.key }).map { entry in - var line = "\(entry.key)=\(entry.value)" - if let icon = entry.icon { line += " icon=\(icon)" } - if let color = entry.color { line += " color=\(color)" } - return line - } - result = lines.joined(separator: "\n") + result = entries.map(sidebarMetadataLine).joined(separator: "\n") } return result } + private func setStatus(_ args: String) -> String { + upsertSidebarMetadata( + args, + missingError: "ERROR: Missing status key or value — usage: set_status [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X]" + ) + } + + private func reportMeta(_ args: String) -> String { + upsertSidebarMetadata( + args, + missingError: "ERROR: Missing metadata key or value — usage: report_meta [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X]" + ) + } + + private func clearStatus(_ args: String) -> String { + clearSidebarMetadata(args, usage: "clear_status [--tab=X]") + } + + private func clearMeta(_ args: String) -> String { + clearSidebarMetadata(args, usage: "clear_meta [--tab=X]") + } + + private func listStatus(_ args: String) -> String { + listSidebarMetadata(args, emptyMessage: "No status entries") + } + + private func listMeta(_ args: String) -> String { + listSidebarMetadata(args, emptyMessage: "No metadata entries") + } + private func appendLog(_ args: String) -> String { let parsed = parseOptions(args) guard !parsed.positional.isEmpty else { @@ -11533,12 +11638,10 @@ class TerminalController { lines.append("progress=none") } - lines.append("status_count=\(tab.statusEntries.count)") - for entry in tab.statusEntries.values.sorted(by: { $0.key < $1.key }) { - var line = " \(entry.key)=\(entry.value)" - if let icon = entry.icon { line += " icon=\(icon)" } - if let color = entry.color { line += " color=\(color)" } - lines.append(line) + let statusEntries = tab.sidebarStatusEntriesInDisplayOrder() + lines.append("status_count=\(statusEntries.count)") + for entry in statusEntries { + lines.append(" \(sidebarMetadataLine(entry))") } lines.append("log_count=\(tab.logEntries.count)") diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index e634b8b1..fe49ccb5 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -9,9 +9,17 @@ struct SidebarStatusEntry { let value: String let icon: String? let color: String? + let url: URL? + let priority: Int + let format: SidebarMetadataFormat let timestamp: Date } +enum SidebarMetadataFormat: String { + case plain + case markdown +} + enum SidebarLogLevel: String { case info case progress @@ -979,6 +987,14 @@ final class Workspace: Identifiable, ObservableObject { ) } + func sidebarStatusEntriesInDisplayOrder() -> [SidebarStatusEntry] { + statusEntries.values.sorted { lhs, rhs in + if lhs.priority != rhs.priority { return lhs.priority > rhs.priority } + if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } + return lhs.key < rhs.key + } + } + // MARK: - Panel Operations /// Create a new split with a terminal panel diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 73dc6980..20d85821 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2569,6 +2569,7 @@ struct SettingsView: View { @AppStorage(SidebarActiveTabIndicatorSettings.styleKey) private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue @AppStorage("sidebarShowPullRequest") private var sidebarShowPullRequest = true + @AppStorage("sidebarShowStatusPills") private var sidebarShowMetadata = true @State private var shortcutResetToken = UUID() @State private var topBlurOpacity: Double = 0 @State private var topBlurBaselineOffset: CGFloat? @@ -2784,6 +2785,17 @@ struct SettingsView: View { .labelsHidden() .controlSize(.small) } + + SettingsCardDivider() + + SettingsCardRow( + "Show Custom Metadata in Sidebar", + subtitle: "Display metadata rows from report_meta/set_status, including icons and optional links." + ) { + Toggle("", isOn: $sidebarShowMetadata) + .labelsHidden() + .controlSize(.small) + } } SettingsSectionHeader(title: "Workspace Colors") @@ -3312,6 +3324,7 @@ struct SettingsView: View { sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue sidebarShowPullRequest = true + sidebarShowMetadata = true showOpenAccessConfirmation = false pendingOpenAccessMode = nil socketPasswordDraft = "" diff --git a/tests/cmux.py b/tests/cmux.py index aca7f4cb..2c022319 100755 --- a/tests/cmux.py +++ b/tests/cmux.py @@ -500,7 +500,17 @@ class cmux: if not response.startswith("OK"): raise cmuxError(response) - def set_status(self, key: str, value: str, icon: str = None, color: str = None, tab: str = None) -> None: + def set_status( + self, + key: str, + value: str, + icon: str = None, + color: str = None, + url: str = None, + priority: int = None, + format: str = None, + tab: str = None, + ) -> None: """Set a sidebar status entry.""" # Put options before `--` so value can contain arbitrary tokens like `--tab`. cmd = f"set_status {key}" @@ -508,6 +518,12 @@ class cmux: cmd += f" --icon={icon}" if color: cmd += f" --color={color}" + if url: + cmd += f" --url={_quote_option_value(url)}" + if priority is not None: + cmd += f" --priority={priority}" + if format: + cmd += f" --format={format}" if tab: cmd += f" --tab={tab}" cmd += f" -- {_quote_option_value(value)}" @@ -524,6 +540,55 @@ class cmux: if not response.startswith("OK"): raise cmuxError(response) + def report_meta( + self, + key: str, + value: str, + icon: str = None, + color: str = None, + url: str = None, + priority: int = None, + format: str = None, + tab: str = None, + ) -> None: + """Report a sidebar metadata entry.""" + cmd = f"report_meta {key}" + if icon: + cmd += f" --icon={icon}" + if color: + cmd += f" --color={color}" + if url: + cmd += f" --url={_quote_option_value(url)}" + if priority is not None: + cmd += f" --priority={priority}" + if format: + cmd += f" --format={format}" + if tab: + cmd += f" --tab={tab}" + cmd += f" -- {_quote_option_value(value)}" + response = self._send_command(cmd) + if not response.startswith("OK"): + raise cmuxError(response) + + def clear_meta(self, key: str, tab: str = None) -> None: + """Remove a sidebar metadata entry.""" + cmd = f"clear_meta {key}" + if tab: + cmd += f" --tab={tab}" + response = self._send_command(cmd) + if not response.startswith("OK"): + raise cmuxError(response) + + def list_meta(self, tab: str = None) -> str: + """List sidebar metadata entries.""" + cmd = "list_meta" + if tab: + cmd += f" --tab={tab}" + response = self._send_command(cmd) + if response.startswith("ERROR"): + raise cmuxError(response) + return response + def log(self, message: str, level: str = None, source: str = None, tab: str = None) -> None: """Append a sidebar log entry.""" # TerminalController.parseOptions treats any --* token as an option until diff --git a/tests/test_sidebar_meta.py b/tests/test_sidebar_meta.py new file mode 100644 index 00000000..7d5af6f0 --- /dev/null +++ b/tests/test_sidebar_meta.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +""" +End-to-end test for generic sidebar metadata commands. + +Validates: +1) report_meta stores icon/url/priority/format metadata +2) metadata list ordering follows priority +3) set_status remains compatible as an alias-style metadata writer +4) clear_meta removes metadata entries +""" + +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= when running this test to avoid socket conflicts.") + + pr_url = "https://github.com/manaflow-ai/cmux/pull/337" + + 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() + + client.report_meta( + "task", + "**Review** PR 337", + icon="sf:doc.text.magnifyingglass", + url=pr_url, + priority=50, + format="markdown", + tab=tab_id, + ) + client.report_meta( + "context", + "issue-336-sidebar-pr-metadata", + icon="text:CTX", + priority=10, + tab=tab_id, + ) + _wait_for_state_field(client, "status_count", "2") + + listed = client.list_meta(tab=tab_id).splitlines() + if len(listed) != 2: + raise AssertionError(f"Expected 2 metadata entries, got {len(listed)}: {listed}") + + if not listed[0].startswith("task="): + raise AssertionError(f"Expected first entry to be task metadata. Got: {listed[0]}") + if "priority=50" not in listed[0]: + raise AssertionError(f"Expected task entry to include priority. Got: {listed[0]}") + if "format=markdown" not in listed[0]: + raise AssertionError(f"Expected markdown format in task entry. Got: {listed[0]}") + if f"url={pr_url}" not in listed[0]: + raise AssertionError(f"Expected URL in task entry. Got: {listed[0]}") + + client.set_status("agent", "in progress", icon="text:AI", priority=80, tab=tab_id) + _wait_for_state_field(client, "status_count", "3") + + listed = client.list_meta(tab=tab_id).splitlines() + if not listed[0].startswith("agent="): + raise AssertionError(f"Expected highest-priority agent entry first. Got: {listed[0]}") + + client.clear_meta("task", tab=tab_id) + _wait_for_state_field(client, "status_count", "2") + + listed = client.list_meta(tab=tab_id).splitlines() + if any(line.startswith("task=") for line in listed): + raise AssertionError(f"Task metadata should be cleared. Got: {listed}") + + try: + client.close_tab(new_tab_id) + except Exception: + pass + + print("Sidebar metadata test passed.") + return 0 + except (cmuxError, AssertionError) as e: + print(f"Sidebar metadata test failed: {e}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) From 513e9aa607d6261174ea13c1bf8d6b2fddbe302d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:38:07 -0800 Subject: [PATCH 04/12] feat(sidebar): add markdown blocks, provider labels, and fine-grained toggles --- Sources/ContentView.swift | 186 ++++++++++++++++++++------- Sources/TerminalController.swift | 210 +++++++++++++++++++++++++++++-- Sources/Workspace.swift | 51 ++++++-- Sources/cmuxApp.swift | 56 ++++++++- tests/cmux.py | 57 +++++++++ tests/test_sidebar_meta_block.py | 100 +++++++++++++++ tests/test_sidebar_pr.py | 10 +- 7 files changed, 599 insertions(+), 71 deletions(-) create mode 100644 tests/test_sidebar_meta_block.py diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 41893bb6..577afc83 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2480,6 +2480,7 @@ 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("sidebarShowPorts") private var sidebarShowPorts = true @@ -2667,6 +2668,7 @@ private struct TabItemView: View { if sidebarShowMetadata { let metadataEntries = tab.sidebarStatusEntriesInDisplayOrder() + let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder() if !metadataEntries.isEmpty { SidebarMetadataRows( entries: metadataEntries, @@ -2675,6 +2677,14 @@ private struct TabItemView: View { ) .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 @@ -2717,54 +2727,56 @@ 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) } - Text(dirRow) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(activeSecondaryColor(0.75)) - .lineLimit(1) - .truncationMode(.tail) } } @@ -2779,7 +2791,7 @@ private struct TabItemView: View { status: pullRequest.status, color: pullRequestForegroundColor ) - Text("PR #\(pullRequest.number)") + Text("\(pullRequest.label) #\(pullRequest.number)") .underline() Text(pullRequestStatusLabel(pullRequest.status)) if pullRequest.extraCount > 0 { @@ -2792,7 +2804,7 @@ private struct TabItemView: View { .foregroundColor(pullRequestForegroundColor) } .buttonStyle(.plain) - .help("Open Pull Request #\(pullRequest.number)") + .help("Open \(pullRequest.label) #\(pullRequest.number)") } // Ports row @@ -2806,6 +2818,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( @@ -3300,6 +3313,7 @@ private struct TabItemView: View { private struct PullRequestDisplay { let number: Int + let label: String let url: URL let status: SidebarPullRequestStatus let extraCount: Int @@ -3310,6 +3324,7 @@ private struct TabItemView: View { guard let first = pullRequests.first else { return nil } return PullRequestDisplay( number: first.number, + label: first.label, url: first.url, status: first.status, extraCount: max(0, pullRequests.count - 1) @@ -3675,6 +3690,89 @@ private struct SidebarMetadataEntryRow: View { } } +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) + ) + } +} + enum SidebarDropEdge { case top case bottom diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 78f4e88a..b18d12ed 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -180,6 +180,16 @@ class TerminalController { 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( current: SidebarProgressState?, value: Double, @@ -201,11 +211,12 @@ class TerminalController { 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.url != url || current.status != status + return current.number != number || current.label != label || current.url != url || current.status != status } nonisolated static func shouldReplacePorts(current: [Int]?, next: [Int]) -> Bool { @@ -728,18 +739,27 @@ class TerminalController { 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) @@ -764,6 +784,9 @@ class TerminalController { case "report_pr": return reportPullRequest(args) + case "report_review": + return reportPullRequest(args) + case "clear_pr": return clearPullRequest(args) @@ -7905,10 +7928,13 @@ class TerminalController { simulate_app_active - Trigger app active handler set_status [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X] - Set a status entry report_meta [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X] - Set sidebar metadata entry + report_meta_block [--priority=N] [--tab=X] -- - Set freeform sidebar markdown block clear_status [--tab=X] - Remove a status entry clear_meta [--tab=X] - Remove sidebar metadata entry + clear_meta_block [--tab=X] - Remove sidebar markdown block list_status [--tab=X] - List all status entries list_meta [--tab=X] - List sidebar metadata entries + list_meta_blocks [--tab=X] - List sidebar markdown blocks log [--level=X] [--source=X] [--tab=X] -- - Append a log entry clear_log [--tab=X] - Clear log entries list_log [--limit=N] [--tab=X] - List log entries @@ -7916,7 +7942,8 @@ class TerminalController { clear_progress [--tab=X] - Clear progress bar report_git_branch [--status=dirty] [--tab=X] [--panel=Y] - Report git branch clear_git_branch [--tab=X] [--panel=Y] - Clear git branch - report_pr [--state=open|merged|closed] [--tab=X] [--panel=Y] - Report pull request + report_pr [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y] - Report pull request / review item + report_review [--label=MR] [--state=open|merged|closed] [--tab=X] [--panel=Y] - Alias for provider-specific review item clear_pr [--tab=X] [--panel=Y] - Clear pull request report_ports [port2...] [--tab=X] [--panel=Y] - Report listening ports report_tty [--tab=X] [--panel=Y] - Register TTY for batched port scanning @@ -10847,6 +10874,33 @@ class TerminalController { return tabManager.tabs.first(where: { $0.id == selectedId }) } + private func resolveTabIdForSidebarMutation( + reportArgs: String, + options: [String: String] + ) -> (tabId: UUID?, error: String?) { + var tabId: UUID? + DispatchQueue.main.sync { + if let tab = resolveTabForReport(reportArgs) { + tabId = tab.id + } + } + if let tabId { + return (tabId, nil) + } + let error = options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" + return (nil, error) + } + + private func tabForSidebarMutation(id: UUID) -> Tab? { + if let tab = tabManager?.tabs.first(where: { $0.id == id }) { + return tab + } + if let otherManager = AppDelegate.shared?.tabManagerFor(tabId: id) { + return otherManager.tabs.first(where: { $0.id == id }) + } + return nil + } + private func parseSidebarMetadataFormat(_ raw: String) -> SidebarMetadataFormat? { switch raw.lowercased() { case "plain": @@ -10901,12 +10955,13 @@ class TerminalController { parsedURL = nil } - 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 tabResolution = resolveTabIdForSidebarMutation(reportArgs: args, options: parsed.options) + guard let targetTabId = tabResolution.tabId else { + return tabResolution.error ?? "ERROR: No tab selected" + } + + DispatchQueue.main.async { [weak self] in + guard let self, let tab = self.tabForSidebarMutation(id: targetTabId) else { return } guard Self.shouldReplaceStatusEntry( current: tab.statusEntries[key], key: key, @@ -10930,7 +10985,7 @@ class TerminalController { timestamp: Date() ) } - return result + return "OK" } private func clearSidebarMetadata(_ args: String, usage: String) -> String { @@ -11009,6 +11064,115 @@ class TerminalController { listSidebarMetadata(args, emptyMessage: "No metadata entries") } + private func splitMetadataBlockArgs(_ args: String) -> (optionsPart: String, markdownPart: String?) { + guard let separatorRange = args.range(of: " -- ") else { + return (args, nil) + } + let optionsPart = String(args[.. String { + var line = "\(block.key)=\(block.markdown.replacingOccurrences(of: "\n", with: "\\n"))" + if block.priority != 0 { line += " priority=\(block.priority)" } + return line + } + + private func reportMetaBlock(_ args: String) -> String { + guard tabManager != nil else { return "ERROR: TabManager not available" } + + let parts = splitMetadataBlockArgs(args) + let parsed = parseOptionsNoStop(parts.optionsPart) + guard let key = parsed.positional.first, !key.isEmpty else { + return "ERROR: Missing metadata block key — usage: report_meta_block [--priority=N] [--tab=X] -- " + } + + let markdown: String + if let raw = parts.markdownPart { + markdown = raw + } else if parsed.positional.count >= 2 { + markdown = parsed.positional.dropFirst().joined(separator: " ") + } else { + return "ERROR: Missing metadata markdown — usage: report_meta_block [--priority=N] [--tab=X] -- " + } + + let trimmedMarkdown = markdown.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedMarkdown.isEmpty else { + return "ERROR: Missing metadata markdown — usage: report_meta_block [--priority=N] [--tab=X] -- " + } + + let priority: Int + if let rawPriority = normalizedOptionValue(parsed.options["priority"]) { + guard let parsedPriority = Int(rawPriority) else { + return "ERROR: Invalid metadata block priority '\(rawPriority)' — must be an integer" + } + priority = max(-9999, min(9999, parsedPriority)) + } else { + priority = 0 + } + + let tabResolution = resolveTabIdForSidebarMutation(reportArgs: parts.optionsPart, options: parsed.options) + guard let targetTabId = tabResolution.tabId else { + return tabResolution.error ?? "ERROR: No tab selected" + } + + DispatchQueue.main.async { [weak self] in + guard let self, let tab = self.tabForSidebarMutation(id: targetTabId) else { return } + guard Self.shouldReplaceMetadataBlock( + current: tab.metadataBlocks[key], + key: key, + markdown: markdown, + priority: priority + ) else { + return + } + tab.metadataBlocks[key] = SidebarMetadataBlock( + key: key, + markdown: markdown, + priority: priority, + timestamp: Date() + ) + } + return "OK" + } + + private func clearMetaBlock(_ args: String) -> String { + let parsed = parseOptions(args) + guard let key = parsed.positional.first, parsed.positional.count == 1 else { + return "ERROR: Missing metadata block key — usage: clear_meta_block [--tab=X]" + } + + 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 + } + if tab.metadataBlocks.removeValue(forKey: key) == nil { + result = "OK (key not found)" + } + } + return result + } + + private func listMetaBlocks(_ args: String) -> String { + var result = "" + DispatchQueue.main.sync { + guard let tab = resolveTabForReport(args) else { + result = "ERROR: Tab not found" + return + } + let blocks = tab.sidebarMetadataBlocksInDisplayOrder() + if blocks.isEmpty { + result = "No metadata blocks" + return + } + result = blocks.map(sidebarMetadataBlockLine).joined(separator: "\n") + } + return result + } + private func appendLog(_ args: String) -> String { let parsed = parseOptions(args) guard !parsed.positional.isEmpty else { @@ -11218,7 +11382,7 @@ class TerminalController { 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 [--state=open|merged|closed] [--tab=X] [--panel=Y]" + return "ERROR: Missing pull request number or URL — usage: report_pr [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]" } let rawNumber = parsed.positional[0].trimmingCharacters(in: .whitespacesAndNewlines) @@ -11239,6 +11403,12 @@ class TerminalController { return "ERROR: Invalid pull request state '\(statusRaw)' — use: open, merged, closed" } + let labelRaw = normalizedOptionValue(parsed.options["label"]) ?? "PR" + guard !labelRaw.isEmpty else { + return "ERROR: Invalid review label — usage: report_pr [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]" + } + let label = String(labelRaw.prefix(16)) + var result = "OK" DispatchQueue.main.sync { guard let tab = resolveTabForReport(args) else { @@ -11252,7 +11422,7 @@ class TerminalController { let surfaceId: UUID if let panelArg { if panelArg.isEmpty { - result = "ERROR: Missing panel id — usage: report_pr [--state=open|merged|closed] [--tab=X] [--panel=Y]" + result = "ERROR: Missing panel id — usage: report_pr [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]" return } guard let parsedId = UUID(uuidString: panelArg) else { @@ -11276,13 +11446,20 @@ class TerminalController { guard Self.shouldReplacePullRequest( current: tab.panelPullRequests[surfaceId], number: number, + label: label, url: url, status: status ) else { return } - tab.updatePanelPullRequest(panelId: surfaceId, number: number, url: url, status: status) + tab.updatePanelPullRequest( + panelId: surfaceId, + number: number, + label: label, + url: url, + status: status + ) } return result } @@ -11621,8 +11798,10 @@ class TerminalController { if let pr = tab.pullRequest { lines.append("pr=#\(pr.number) \(pr.status.rawValue) \(pr.url.absoluteString)") + lines.append("pr_label=\(pr.label)") } else { lines.append("pr=none") + lines.append("pr_label=none") } if tab.listeningPorts.isEmpty { @@ -11644,6 +11823,12 @@ class TerminalController { lines.append(" \(sidebarMetadataLine(entry))") } + let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder() + lines.append("meta_block_count=\(metadataBlocks.count)") + for block in metadataBlocks { + lines.append(" \(sidebarMetadataBlockLine(block))") + } + lines.append("log_count=\(tab.logEntries.count)") for entry in tab.logEntries.suffix(5) { lines.append(" [\(entry.level.rawValue)] \(entry.message)") @@ -11670,6 +11855,7 @@ class TerminalController { tab.panelPullRequests.removeAll() tab.surfaceListeningPorts.removeAll() tab.listeningPorts.removeAll() + tab.metadataBlocks.removeAll() } return result } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index fe49ccb5..7b8c38d3 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -15,6 +15,13 @@ struct SidebarStatusEntry { let timestamp: Date } +struct SidebarMetadataBlock { + let key: String + let markdown: String + let priority: Int + let timestamp: Date +} + enum SidebarMetadataFormat: String { case plain case markdown @@ -53,6 +60,7 @@ enum SidebarPullRequestStatus: String { struct SidebarPullRequestState: Equatable { let number: Int + let label: String let url: URL let status: SidebarPullRequestStatus } @@ -150,28 +158,32 @@ enum SidebarBranchOrdering { } } - var orderedNumbers: [Int] = [] - var pullRequestsByNumber: [Int: SidebarPullRequestState] = [:] + func reviewKey(for state: SidebarPullRequestState) -> String { + "\(state.label.lowercased())#\(state.number)" + } + + var orderedKeys: [String] = [] + var pullRequestsByKey: [String: 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 + let key = reviewKey(for: state) + if pullRequestsByKey[key] == nil { + orderedKeys.append(key) + pullRequestsByKey[key] = state continue } - guard let existing = pullRequestsByNumber[number] else { continue } + guard let existing = pullRequestsByKey[key] else { continue } if statusPriority(state.status) > statusPriority(existing.status) { - pullRequestsByNumber[number] = state + pullRequestsByKey[key] = state } } - if orderedNumbers.isEmpty, let fallbackPullRequest { + if orderedKeys.isEmpty, let fallbackPullRequest { return [fallbackPullRequest] } - return orderedNumbers.compactMap { pullRequestsByNumber[$0] } + return orderedKeys.compactMap { pullRequestsByKey[$0] } } static func orderedUniqueBranchDirectoryEntries( @@ -353,6 +365,7 @@ final class Workspace: Identifiable, ObservableObject { nonisolated private static let manualUnreadFocusGraceInterval: TimeInterval = 0.2 nonisolated private static let manualUnreadClearDelayAfterFocusFlash: TimeInterval = 0.2 @Published var statusEntries: [String: SidebarStatusEntry] = [:] + @Published var metadataBlocks: [String: SidebarMetadataBlock] = [:] @Published var logEntries: [SidebarLogEntry] = [] @Published var progress: SidebarProgressState? @Published var gitBranch: SidebarGitBranchState? @@ -862,8 +875,14 @@ final class Workspace: Identifiable, ObservableObject { } } - func updatePanelPullRequest(panelId: UUID, number: Int, url: URL, status: SidebarPullRequestStatus) { - let state = SidebarPullRequestState(number: number, url: url, status: status) + func updatePanelPullRequest( + panelId: UUID, + number: Int, + label: String, + url: URL, + status: SidebarPullRequestStatus + ) { + let state = SidebarPullRequestState(number: number, label: label, url: url, status: status) let existing = panelPullRequests[panelId] if existing != state { panelPullRequests[panelId] = state @@ -995,6 +1014,14 @@ final class Workspace: Identifiable, ObservableObject { } } + func sidebarMetadataBlocksInDisplayOrder() -> [SidebarMetadataBlock] { + metadataBlocks.values.sorted { lhs, rhs in + if lhs.priority != rhs.priority { return lhs.priority > rhs.priority } + if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } + return lhs.key < rhs.key + } + } + // MARK: - Panel Operations /// Create a new split with a terminal panel diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 20d85821..d71ae4de 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2568,7 +2568,11 @@ struct SettingsView: View { @AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout @AppStorage(SidebarActiveTabIndicatorSettings.styleKey) private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue + @AppStorage("sidebarShowBranchDirectory") private var sidebarShowBranchDirectory = true @AppStorage("sidebarShowPullRequest") private var sidebarShowPullRequest = true + @AppStorage("sidebarShowPorts") private var sidebarShowPorts = true + @AppStorage("sidebarShowLog") private var sidebarShowLog = true + @AppStorage("sidebarShowProgress") private var sidebarShowProgress = true @AppStorage("sidebarShowStatusPills") private var sidebarShowMetadata = true @State private var shortcutResetToken = UUID() @State private var topBlurOpacity: Double = 0 @@ -2777,9 +2781,20 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + "Show Branch + Directory in Sidebar", + subtitle: "Display the built-in git branch and working-directory row." + ) { + Toggle("", isOn: $sidebarShowBranchDirectory) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + SettingsCardRow( "Show Pull Requests in Sidebar", - subtitle: "Display PR status, number, and a clickable link when available." + subtitle: "Display review items (PR/MR/etc.) with status, number, and clickable link." ) { Toggle("", isOn: $sidebarShowPullRequest) .labelsHidden() @@ -2788,9 +2803,42 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + "Show Listening Ports in Sidebar", + subtitle: "Display detected listening ports for the active workspace." + ) { + Toggle("", isOn: $sidebarShowPorts) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + + SettingsCardRow( + "Show Latest Log in Sidebar", + subtitle: "Display the latest imperative log/status message." + ) { + Toggle("", isOn: $sidebarShowLog) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + + SettingsCardRow( + "Show Progress in Sidebar", + subtitle: "Display the built-in progress bar from set_progress." + ) { + Toggle("", isOn: $sidebarShowProgress) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + SettingsCardRow( "Show Custom Metadata in Sidebar", - subtitle: "Display metadata rows from report_meta/set_status, including icons and optional links." + subtitle: "Display custom metadata from report_meta/set_status and report_meta_block." ) { Toggle("", isOn: $sidebarShowMetadata) .labelsHidden() @@ -3323,7 +3371,11 @@ struct SettingsView: View { workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue + sidebarShowBranchDirectory = true sidebarShowPullRequest = true + sidebarShowPorts = true + sidebarShowLog = true + sidebarShowProgress = true sidebarShowMetadata = true showOpenAccessConfirmation = false pendingOpenAccessMode = nil diff --git a/tests/cmux.py b/tests/cmux.py index 2c022319..c4f95904 100755 --- a/tests/cmux.py +++ b/tests/cmux.py @@ -589,6 +589,37 @@ class cmux: raise cmuxError(response) return response + def report_meta_block(self, key: str, markdown: str, priority: int = None, tab: str = None) -> None: + """Report a freeform sidebar markdown metadata block.""" + cmd = f"report_meta_block {key}" + if priority is not None: + cmd += f" --priority={priority}" + if tab: + cmd += f" --tab={tab}" + cmd += f" -- {_quote_option_value(markdown)}" + response = self._send_command(cmd) + if not response.startswith("OK"): + raise cmuxError(response) + + def clear_meta_block(self, key: str, tab: str = None) -> None: + """Remove a sidebar markdown metadata block.""" + cmd = f"clear_meta_block {key}" + if tab: + cmd += f" --tab={tab}" + response = self._send_command(cmd) + if not response.startswith("OK"): + raise cmuxError(response) + + def list_meta_blocks(self, tab: str = None) -> str: + """List sidebar markdown metadata blocks.""" + cmd = "list_meta_blocks" + if tab: + cmd += f" --tab={tab}" + response = self._send_command(cmd) + if response.startswith("ERROR"): + raise cmuxError(response) + return response + def log(self, message: str, level: str = None, source: str = None, tab: str = None) -> None: """Append a sidebar log entry.""" # TerminalController.parseOptions treats any --* token as an option until @@ -641,12 +672,38 @@ class cmux: self, number: int, url: str, + label: str = None, state: str = None, tab: str = None, panel: str = None, ) -> None: """Report pull-request metadata for sidebar display.""" cmd = f"report_pr {number} {url}" + if label: + cmd += f" --label={_quote_option_value(label)}" + 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 report_review( + self, + number: int, + url: str, + label: str = None, + state: str = None, + tab: str = None, + panel: str = None, + ) -> None: + """Report provider-specific review metadata (GitLab MR, Bitbucket PR, etc.).""" + cmd = f"report_review {number} {url}" + if label: + cmd += f" --label={_quote_option_value(label)}" if state: cmd += f" --state={state}" if tab: diff --git a/tests/test_sidebar_meta_block.py b/tests/test_sidebar_meta_block.py new file mode 100644 index 00000000..1ca6ade1 --- /dev/null +++ b/tests/test_sidebar_meta_block.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +""" +End-to-end test for sidebar markdown metadata block commands. + +Validates: +1) report_meta_block stores markdown payload and priority +2) metadata block list ordering follows priority +3) clear_meta_block removes block 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= when running this test to avoid socket conflicts.") + + 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() + + summary_md = "### Agent\\n- status: in progress\\n- pr: #337" + footer_md = "_last update: now_" + + client.report_meta_block("summary", summary_md, priority=50, tab=tab_id) + client.report_meta_block("footer", footer_md, priority=10, tab=tab_id) + _wait_for_state_field(client, "meta_block_count", "2") + + listed = client.list_meta_blocks(tab=tab_id).splitlines() + if len(listed) != 2: + raise AssertionError(f"Expected 2 metadata blocks, got {len(listed)}: {listed}") + if not listed[0].startswith("summary="): + raise AssertionError(f"Expected highest-priority block first. Got: {listed[0]}") + if "priority=50" not in listed[0]: + raise AssertionError(f"Expected summary block priority in listing. Got: {listed[0]}") + + client.clear_meta_block("summary", tab=tab_id) + _wait_for_state_field(client, "meta_block_count", "1") + + listed = client.list_meta_blocks(tab=tab_id).splitlines() + if any(line.startswith("summary=") for line in listed): + raise AssertionError(f"Summary block should be cleared. Got: {listed}") + + try: + client.close_tab(new_tab_id) + except Exception: + pass + + print("Sidebar markdown metadata block test passed.") + return 0 + except (cmuxError, AssertionError) as e: + print(f"Sidebar markdown metadata block test failed: {e}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_sidebar_pr.py b/tests/test_sidebar_pr.py index e6c9cbfd..39645aaa 100644 --- a/tests/test_sidebar_pr.py +++ b/tests/test_sidebar_pr.py @@ -5,7 +5,8 @@ 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 +3) provider labels can be set via report_review/report_pr --label +4) clear_pr removes PR metadata """ from __future__ import annotations @@ -71,12 +72,19 @@ def main() -> int: 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}") + _wait_for_state_field(client, "pr_label", "PR") + + client.report_review(pr_number, pr_url, label="MR", state="open", tab=tab_id, panel=panel_id) + _wait_for_state_field(client, "pr", f"#{pr_number} open {pr_url}") + _wait_for_state_field(client, "pr_label", "MR") 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}") + _wait_for_state_field(client, "pr_label", "PR") client.clear_pr(tab=tab_id, panel=panel_id) _wait_for_state_field(client, "pr", "none") + _wait_for_state_field(client, "pr_label", "none") try: client.close_tab(new_tab_id) From e4f351027c1f688df25c8cba3e8eab9726b61a35 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:39:52 -0800 Subject: [PATCH 05/12] fix(sidebar): normalize escaped newlines in markdown blocks --- Sources/TerminalController.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index b18d12ed..89348db2 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -11097,7 +11097,12 @@ class TerminalController { return "ERROR: Missing metadata markdown — usage: report_meta_block [--priority=N] [--tab=X] -- " } - let trimmedMarkdown = markdown.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedMarkdown = markdown + .replacingOccurrences(of: "\\r\\n", with: "\n") + .replacingOccurrences(of: "\\n", with: "\n") + .replacingOccurrences(of: "\\t", with: "\t") + + let trimmedMarkdown = normalizedMarkdown.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedMarkdown.isEmpty else { return "ERROR: Missing metadata markdown — usage: report_meta_block [--priority=N] [--tab=X] -- " } @@ -11122,14 +11127,14 @@ class TerminalController { guard Self.shouldReplaceMetadataBlock( current: tab.metadataBlocks[key], key: key, - markdown: markdown, + markdown: normalizedMarkdown, priority: priority ) else { return } tab.metadataBlocks[key] = SidebarMetadataBlock( key: key, - markdown: markdown, + markdown: normalizedMarkdown, priority: priority, timestamp: Date() ) From 7d0e259b368f5ca8c4b1ae27a756892c6b0300e6 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:44:15 -0800 Subject: [PATCH 06/12] style(sidebar): reduce metadata icon sizing --- Sources/ContentView.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 577afc83..c694e228 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -3387,7 +3387,7 @@ private struct TabItemView: View { private struct PullRequestStatusIcon: View { let status: SidebarPullRequestStatus let color: Color - private static let frameSize: CGFloat = 14 + private static let frameSize: CGFloat = 13 var body: some View { switch status { @@ -3397,7 +3397,7 @@ private struct TabItemView: View { PullRequestMergedIcon(color: color) case .closed: Image(systemName: "xmark.circle") - .font(.system(size: 9, weight: .regular)) + .font(.system(size: 8, weight: .regular)) .foregroundColor(color) .frame(width: Self.frameSize, height: Self.frameSize) } @@ -3653,12 +3653,12 @@ private struct SidebarMetadataEntryRow: View { if iconRaw.hasPrefix("emoji:") { let value = String(iconRaw.dropFirst("emoji:".count)) guard !value.isEmpty else { return nil } - return AnyView(Text(value).font(.system(size: 10))) + 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: 9, weight: .semibold))) + return AnyView(Text(value).font(.system(size: 8, weight: .semibold))) } let symbolName: String if iconRaw.hasPrefix("sf:") { @@ -3667,7 +3667,7 @@ private struct SidebarMetadataEntryRow: View { symbolName = iconRaw } guard !symbolName.isEmpty else { return nil } - return AnyView(Image(systemName: symbolName).font(.system(size: 9, weight: .medium))) + return AnyView(Image(systemName: symbolName).font(.system(size: 8, weight: .medium))) } @ViewBuilder From 023d7759ace245e4dd427c9de126bf3d8dae547a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:55:44 -0800 Subject: [PATCH 07/12] sidebar: render pull requests as per-line rows --- Sources/ContentView.swift | 71 +++++----- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 121 +++++++++++++++++- 2 files changed, 156 insertions(+), 36 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 381cbc1d..e9a7e04d 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -6317,31 +6317,34 @@ 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("\(pullRequest.label) #\(pullRequest.number)") - .underline() - Text(pullRequestStatusLabel(pullRequest.status)) - if pullRequest.extraCount > 0 { - Text("+\(pullRequest.extraCount)") - .opacity(0.75) + // Pull request rows + if sidebarShowPullRequest, !pullRequestDisplays.isEmpty { + VStack(alignment: .leading, spacing: 1) { + ForEach(pullRequestDisplays) { pullRequest in + Button(action: { + updateSelection() + NSWorkspace.shared.open(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) } - Spacer(minLength: 0) + .buttonStyle(.plain) + .help("Open \(pullRequest.label) #\(pullRequest.number)") } - .font(.system(size: 10, weight: .semibold)) - .foregroundColor(pullRequestForegroundColor) } - .buttonStyle(.plain) - .help("Open \(pullRequest.label) #\(pullRequest.number)") } // Ports row @@ -6912,24 +6915,24 @@ private struct TabItemView: View { return entries.isEmpty ? nil : entries.joined(separator: " | ") } - private struct PullRequestDisplay { + private struct PullRequestDisplay: Identifiable { + let id: String let number: Int let label: String 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, - label: first.label, - url: first.url, - status: first.status, - extraCount: max(0, pullRequests.count - 1) - ) + 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 { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 9a0e10b6..d84c20a6 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -3646,6 +3646,117 @@ final class SidebarBranchOrderingTests: XCTestCase { [SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: false, directory: "/repo/default")] ) } + + func testOrderedUniquePullRequestsFollowsPanelOrderAcrossSplitsAndTabs() { + let first = UUID() + let second = UUID() + let third = UUID() + let fourth = UUID() + + let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests( + orderedPanelIds: [first, second, third, fourth], + panelPullRequests: [ + first: pullRequestState( + number: 337, + label: "PR", + url: "https://github.com/manaflow-ai/cmux/pull/337", + status: .open + ), + second: pullRequestState( + number: 18, + label: "MR", + url: "https://gitlab.com/manaflow/cmux/-/merge_requests/18", + status: .open + ), + third: pullRequestState( + number: 337, + label: "PR", + url: "https://github.com/manaflow-ai/cmux/pull/337", + status: .merged + ), + fourth: pullRequestState( + number: 92, + label: "PR", + url: "https://bitbucket.org/manaflow/cmux/pull-requests/92", + status: .closed + ) + ], + fallbackPullRequest: pullRequestState( + number: 1, + label: "PR", + url: "https://example.invalid/fallback/1", + status: .open + ) + ) + + XCTAssertEqual( + pullRequests.map { "\($0.label)#\($0.number)" }, + ["PR#337", "MR#18", "PR#92"] + ) + XCTAssertEqual( + pullRequests.map(\.status), + [.merged, .open, .closed] + ) + } + + func testOrderedUniquePullRequestsTreatsSameNumberDifferentLabelsAsDistinct() { + let first = UUID() + let second = UUID() + + let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests( + orderedPanelIds: [first, second], + panelPullRequests: [ + first: pullRequestState( + number: 42, + label: "PR", + url: "https://github.com/manaflow-ai/cmux/pull/42", + status: .open + ), + second: pullRequestState( + number: 42, + label: "MR", + url: "https://gitlab.com/manaflow/cmux/-/merge_requests/42", + status: .open + ) + ], + fallbackPullRequest: nil + ) + + XCTAssertEqual( + pullRequests.map { "\($0.label)#\($0.number)" }, + ["PR#42", "MR#42"] + ) + } + + func testOrderedUniquePullRequestsUsesFallbackWhenNoPanelPullRequestsExist() { + let fallback = pullRequestState( + number: 11, + label: "PR", + url: "https://github.com/manaflow-ai/cmux/pull/11", + status: .open + ) + let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests( + orderedPanelIds: [], + panelPullRequests: [:], + fallbackPullRequest: fallback + ) + + XCTAssertEqual(pullRequests, [fallback]) + } + + private func pullRequestState( + number: Int, + label: String, + url: String, + status: SidebarPullRequestStatus + ) -> SidebarPullRequestState { + SidebarPullRequestState( + number: number, + label: label, + url: URL(string: url)!, + status: status + ) + } } @MainActor @@ -6479,7 +6590,10 @@ final class TerminalControllerSidebarDedupeTests: XCTestCase { key: "agent", value: "idle", icon: "bolt", - color: "#ffffff" + color: "#ffffff", + url: nil, + priority: 0, + format: .plain ) ) } @@ -6498,7 +6612,10 @@ final class TerminalControllerSidebarDedupeTests: XCTestCase { key: "agent", value: "running", icon: "bolt", - color: "#ffffff" + color: "#ffffff", + url: nil, + priority: 0, + format: .plain ) ) } From 109ce43c709de33006afacf748828dd6db0bf3c9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:59:06 -0800 Subject: [PATCH 08/12] settings: control sidebar PR link open target --- Sources/ContentView.swift | 16 ++++++++++++++-- Sources/Panels/BrowserPanel.swift | 10 ++++++++++ Sources/cmuxApp.swift | 16 ++++++++++++++++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 12 ++++++++++++ 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index e9a7e04d..b5418cff 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -6016,6 +6016,8 @@ private struct TabItemView: View { @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 @@ -6322,8 +6324,7 @@ private struct TabItemView: View { VStack(alignment: .leading, spacing: 1) { ForEach(pullRequestDisplays) { pullRequest in Button(action: { - updateSelection() - NSWorkspace.shared.open(pullRequest.url) + openPullRequestLink(pullRequest.url) }) { HStack(spacing: 4) { PullRequestStatusIcon( @@ -6939,6 +6940,17 @@ private struct TabItemView: View { isActive ? .white.opacity(0.75) : .secondary } + private func openPullRequestLink(_ url: URL) { + updateSelection() + if openSidebarPullRequestLinksInCmuxBrowser { + if tabManager.openBrowser(url: url, insertAtEnd: true) == nil { + NSWorkspace.shared.open(url) + } + return + } + NSWorkspace.shared.open(url) + } + private func pullRequestStatusLabel(_ status: SidebarPullRequestStatus) -> String { switch status { case .open: return "open" 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/cmuxApp.swift b/Sources/cmuxApp.swift index 0f193ba1..63dbc3b0 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2631,6 +2631,8 @@ struct SettingsView: View { private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue @AppStorage("sidebarShowBranchDirectory") private var sidebarShowBranchDirectory = true @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 @@ -2877,6 +2879,19 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + "Open Sidebar PR Links in cmux Browser", + subtitle: openSidebarPullRequestLinksInCmuxBrowser + ? "Clicks open inside cmux browser." + : "Clicks open in your default browser." + ) { + Toggle("", isOn: $openSidebarPullRequestLinksInCmuxBrowser) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + SettingsCardRow( "Show Listening Ports in Sidebar", subtitle: "Display detected listening ports for the active workspace." @@ -3460,6 +3475,7 @@ struct SettingsView: View { sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue sidebarShowBranchDirectory = true sidebarShowPullRequest = true + openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser sidebarShowPorts = true sidebarShowLog = true sidebarShowProgress = true diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index d84c20a6..59bcab74 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -6352,6 +6352,18 @@ final class BrowserLinkOpenSettingsTests: XCTestCase { XCTAssertTrue(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser(defaults: defaults)) } + func testSidebarPullRequestLinksDefaultToCmuxBrowser() { + XCTAssertTrue(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults)) + } + + func testSidebarPullRequestLinksPreferenceUsesStoredValue() { + defaults.set(false, forKey: BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey) + XCTAssertFalse(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults)) + + defaults.set(true, forKey: BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey) + XCTAssertTrue(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults)) + } + func testOpenCommandInterceptionDefaultsToCmuxBrowser() { XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) } From a5711425df9366a481e600d698eb4062fd85364b Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:03:38 -0800 Subject: [PATCH 09/12] sidebar: add command palette action for workspace PR links --- Sources/ContentView.swift | 67 ++++++++++++++++++++++++++++++++++----- 1 file changed, 59 insertions(+), 8 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index b5418cff..9393bdf3 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 { @@ -3646,6 +3653,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", @@ -3998,6 +4017,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() @@ -4640,6 +4666,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) @@ -7008,7 +7059,7 @@ private struct TabItemView: View { private struct PullRequestStatusIcon: View { let status: SidebarPullRequestStatus let color: Color - private static let frameSize: CGFloat = 13 + private static let frameSize: CGFloat = 12 var body: some View { switch status { @@ -7018,7 +7069,7 @@ private struct TabItemView: View { PullRequestMergedIcon(color: color) case .closed: Image(systemName: "xmark.circle") - .font(.system(size: 8, weight: .regular)) + .font(.system(size: 7, weight: .regular)) .foregroundColor(color) .frame(width: Self.frameSize, height: Self.frameSize) } @@ -7027,9 +7078,9 @@ private struct TabItemView: View { 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 + 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 { @@ -7065,9 +7116,9 @@ private struct TabItemView: View { 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 + 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 { From afeec2d324d67cf557d1388ed42566b79ae4820e Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:14:57 -0800 Subject: [PATCH 10/12] sidebar: open PR links in right split for target workspace --- Sources/ContentView.swift | 7 +- Sources/TabManager.swift | 65 +++++++++++++++++-- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 48 ++++++++++++++ 3 files changed, 112 insertions(+), 8 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 9393bdf3..e3b8c37c 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -6994,7 +6994,12 @@ private struct TabItemView: View { private func openPullRequestLink(_ url: URL) { updateSelection() if openSidebarPullRequestLinksInCmuxBrowser { - if tabManager.openBrowser(url: url, insertAtEnd: true) == nil { + if tabManager.openBrowser( + inWorkspace: tab.id, + url: url, + preferSplitRight: true, + insertAtEnd: true + ) == nil { NSWorkspace.shared.open(url) } return diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 345c08d1..8efe9677 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1940,19 +1940,70 @@ 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 { + 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/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 59bcab74..70a06646 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -2868,6 +2868,54 @@ final class TabManagerSurfaceCreationTests: XCTestCase { ) XCTAssertEqual(workspace.focusedPanelId, browserPanelId, "Expected opened browser surface to be focused") } + + func testOpenBrowserInWorkspaceSplitRightSelectsTargetWorkspaceAndCreatesSplit() { + let manager = TabManager() + guard let initialWorkspace = manager.selectedWorkspace else { + XCTFail("Expected initial selected workspace") + return + } + guard let url = URL(string: "https://example.com/pull/123") else { + XCTFail("Expected test URL to be valid") + return + } + + let targetWorkspace = manager.addWorkspace(select: false) + manager.selectWorkspace(initialWorkspace) + let initialPaneCount = targetWorkspace.bonsplitController.allPaneIds.count + let initialPanelCount = targetWorkspace.panels.count + + guard let browserPanelId = manager.openBrowser( + inWorkspace: targetWorkspace.id, + url: url, + preferSplitRight: true, + insertAtEnd: true + ) else { + XCTFail("Expected browser panel to be created in target workspace") + return + } + + XCTAssertEqual(manager.selectedTabId, targetWorkspace.id, "Expected target workspace to become selected") + XCTAssertEqual( + targetWorkspace.bonsplitController.allPaneIds.count, + initialPaneCount + 1, + "Expected split-right browser open to create a new pane" + ) + XCTAssertEqual( + targetWorkspace.panels.count, + initialPanelCount + 1, + "Expected browser panel count to increase by one" + ) + XCTAssertEqual( + targetWorkspace.focusedPanelId, + browserPanelId, + "Expected created browser panel to be focused in target workspace" + ) + XCTAssertTrue( + targetWorkspace.panels[browserPanelId] is BrowserPanel, + "Expected created panel to be a browser panel" + ) + } } @MainActor From 84d07931a5f38b2f6d250bbde1af7014858e022d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:24:49 -0800 Subject: [PATCH 11/12] sidebar: dedupe PR rows by normalized URL identity --- Sources/Workspace.swift | 20 +++++++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 32 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index ff5de794..bb7d91b1 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -722,8 +722,26 @@ enum SidebarBranchOrdering { } } + func normalizedReviewURLKey(for url: URL) -> String { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return url.absoluteString + } + + // Treat URL variants that differ only by query/fragment as the same review item. + components.query = nil + components.fragment = nil + let scheme = components.scheme?.lowercased() ?? "" + let host = components.host?.lowercased() ?? "" + let port = components.port.map { ":\($0)" } ?? "" + var path = components.path + if path.hasSuffix("/"), path.count > 1 { + path.removeLast() + } + return "\(scheme)://\(host)\(port)\(path)" + } + func reviewKey(for state: SidebarPullRequestState) -> String { - "\(state.label.lowercased())#\(state.number)" + "\(state.label.lowercased())#\(state.number)|\(normalizedReviewURLKey(for: state.url))" } var orderedKeys: [String] = [] diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 70a06646..a2f727e0 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -3776,6 +3776,38 @@ final class SidebarBranchOrderingTests: XCTestCase { ) } + func testOrderedUniquePullRequestsTreatsSameNumberAndLabelDifferentUrlsAsDistinct() { + let first = UUID() + let second = UUID() + + let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests( + orderedPanelIds: [first, second], + panelPullRequests: [ + first: pullRequestState( + number: 42, + label: "PR", + url: "https://github.com/manaflow-ai/cmux/pull/42", + status: .open + ), + second: pullRequestState( + number: 42, + label: "PR", + url: "https://github.com/manaflow-ai/other-repo/pull/42", + status: .open + ) + ], + fallbackPullRequest: nil + ) + + XCTAssertEqual( + pullRequests.map(\.url.absoluteString), + [ + "https://github.com/manaflow-ai/cmux/pull/42", + "https://github.com/manaflow-ai/other-repo/pull/42" + ] + ) + } + func testOrderedUniquePullRequestsUsesFallbackWhenNoPanelPullRequestsExist() { let fallback = pullRequestState( number: 11, From b84c0539c92e65f04f87b541703498617d4133ce Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:36:10 -0800 Subject: [PATCH 12/12] browser: reuse top-right pane for sidebar PR opens --- Sources/TabManager.swift | 11 +++ Sources/Workspace.swift | 91 +++++++++++++++++++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 47 ++++++++++ 3 files changed, 149 insertions(+) diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 8efe9677..f707686c 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1954,6 +1954,17 @@ class TabManager: ObservableObject { } 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 { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index bb7d91b1..43ca9cd5 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -2184,6 +2184,49 @@ final class Workspace: Identifiable, ObservableObject { return nil } + /// Returns the top-right pane in the current split tree. + /// When a workspace is already split, sidebar PR opens should reuse an existing pane + /// instead of creating additional right splits. + func topRightBrowserReusePane() -> PaneID? { + let paneIds = bonsplitController.allPaneIds + guard paneIds.count > 1 else { return nil } + + let paneById = Dictionary(uniqueKeysWithValues: paneIds.map { ($0.id.uuidString, $0) }) + var paneBounds: [String: CGRect] = [:] + browserCollectNormalizedPaneBounds( + node: bonsplitController.treeSnapshot(), + availableRect: CGRect(x: 0, y: 0, width: 1, height: 1), + into: &paneBounds + ) + + guard !paneBounds.isEmpty else { + return paneIds.sorted { $0.id.uuidString < $1.id.uuidString }.first + } + + let epsilon = 0.000_1 + let rightMostX = paneBounds.values.map(\.maxX).max() ?? 0 + + let sortedCandidates = paneBounds + .filter { _, rect in abs(rect.maxX - rightMostX) <= epsilon } + .sorted { lhs, rhs in + if abs(lhs.value.minY - rhs.value.minY) > epsilon { + return lhs.value.minY < rhs.value.minY + } + if abs(lhs.value.minX - rhs.value.minX) > epsilon { + return lhs.value.minX > rhs.value.minX + } + return lhs.key < rhs.key + } + + for candidate in sortedCandidates { + if let pane = paneById[candidate.key] { + return pane + } + } + + return paneIds.sorted { $0.id.uuidString < $1.id.uuidString }.first + } + private enum BrowserPaneBranch { case first case second @@ -2221,6 +2264,54 @@ final class Workspace: Identifiable, ObservableObject { } } + private func browserCollectNormalizedPaneBounds( + node: ExternalTreeNode, + availableRect: CGRect, + into output: inout [String: CGRect] + ) { + switch node { + case .pane(let paneNode): + output[paneNode.id] = availableRect + case .split(let splitNode): + let divider = min(max(splitNode.dividerPosition, 0), 1) + let firstRect: CGRect + let secondRect: CGRect + + if splitNode.orientation.lowercased() == "vertical" { + // Stacked split: first = top, second = bottom + firstRect = CGRect( + x: availableRect.minX, + y: availableRect.minY, + width: availableRect.width, + height: availableRect.height * divider + ) + secondRect = CGRect( + x: availableRect.minX, + y: availableRect.minY + (availableRect.height * divider), + width: availableRect.width, + height: availableRect.height * (1 - divider) + ) + } else { + // Side-by-side split: first = left, second = right + firstRect = CGRect( + x: availableRect.minX, + y: availableRect.minY, + width: availableRect.width * divider, + height: availableRect.height + ) + secondRect = CGRect( + x: availableRect.minX + (availableRect.width * divider), + y: availableRect.minY, + width: availableRect.width * (1 - divider), + height: availableRect.height + ) + } + + browserCollectNormalizedPaneBounds(node: splitNode.first, availableRect: firstRect, into: &output) + browserCollectNormalizedPaneBounds(node: splitNode.second, availableRect: secondRect, into: &output) + } + } + private struct BrowserCloseFallbackPlan { let orientation: SplitOrientation let insertFirst: Bool diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index a2f727e0..378f142a 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -2916,6 +2916,53 @@ final class TabManagerSurfaceCreationTests: XCTestCase { "Expected created panel to be a browser panel" ) } + + func testOpenBrowserInWorkspaceSplitRightReusesTopRightPaneWhenAlreadySplit() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let topRightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal), + workspace.newTerminalSplit(from: topRightPanel.id, orientation: .vertical) != nil, + let topRightPaneId = workspace.paneId(forPanelId: topRightPanel.id), + let url = URL(string: "https://example.com/pull/456") else { + XCTFail("Expected split setup to succeed") + return + } + + let initialPaneCount = workspace.bonsplitController.allPaneIds.count + + guard let browserPanelId = manager.openBrowser( + inWorkspace: workspace.id, + url: url, + preferSplitRight: true, + insertAtEnd: true + ) else { + XCTFail("Expected browser panel to be created") + return + } + + XCTAssertEqual( + workspace.bonsplitController.allPaneIds.count, + initialPaneCount, + "Expected split-right browser open to reuse existing panes" + ) + XCTAssertEqual( + workspace.paneId(forPanelId: browserPanelId), + topRightPaneId, + "Expected browser to open in the top-right pane when multiple splits already exist" + ) + + let targetPaneTabs = workspace.bonsplitController.tabs(inPane: topRightPaneId) + guard let lastSurfaceId = targetPaneTabs.last?.id else { + XCTFail("Expected top-right pane to contain tabs") + return + } + XCTAssertEqual( + workspace.panelIdFromSurfaceId(lastSurfaceId), + browserPanelId, + "Expected browser surface to be appended at end in the reused top-right pane" + ) + } } @MainActor