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