feat(sidebar): add markdown blocks, provider labels, and fine-grained toggles

This commit is contained in:
Lawrence Chen 2026-02-24 20:38:07 -08:00
parent f2ecb4877b
commit 513e9aa607
7 changed files with 599 additions and 71 deletions

View file

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

View file

@ -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 <key> <value> [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X] - Set a status entry
report_meta <key> <value> [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X] - Set sidebar metadata entry
report_meta_block <key> [--priority=N] [--tab=X] -- <markdown> - Set freeform sidebar markdown block
clear_status <key> [--tab=X] - Remove a status entry
clear_meta <key> [--tab=X] - Remove sidebar metadata entry
clear_meta_block <key> [--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] -- <message> - 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 <branch> [--status=dirty] [--tab=X] [--panel=Y] - Report git branch
clear_git_branch [--tab=X] [--panel=Y] - Clear git branch
report_pr <number> <url> [--state=open|merged|closed] [--tab=X] [--panel=Y] - Report pull request
report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y] - Report pull request / review item
report_review <number> <url> [--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 <port1> [port2...] [--tab=X] [--panel=Y] - Report listening ports
report_tty <tty_name> [--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[..<separatorRange.lowerBound])
let markdownPart = String(args[separatorRange.upperBound...])
return (optionsPart, markdownPart)
}
private func sidebarMetadataBlockLine(_ block: SidebarMetadataBlock) -> 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 <key> [--priority=N] [--tab=X] -- <markdown>"
}
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 <key> [--priority=N] [--tab=X] -- <markdown>"
}
let trimmedMarkdown = markdown.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedMarkdown.isEmpty else {
return "ERROR: Missing metadata markdown — usage: report_meta_block <key> [--priority=N] [--tab=X] -- <markdown>"
}
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 <key> [--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 <number> <url> [--state=open|merged|closed] [--tab=X] [--panel=Y]"
return "ERROR: Missing pull request number or URL — usage: report_pr <number> <url> [--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 <number> <url> [--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 <number> <url> [--state=open|merged|closed] [--tab=X] [--panel=Y]"
result = "ERROR: Missing panel id — usage: report_pr <number> <url> [--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
}

View file

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

View file

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

View file

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

View file

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

View file

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