feat(sidebar): add generic metadata rows and commands

This commit is contained in:
Lawrence Chen 2026-02-24 20:19:38 -08:00
parent a33e231c79
commit f2ecb4877b
6 changed files with 473 additions and 66 deletions

View file

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

View file

@ -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 <active|inactive|clear> - Override app focus state
simulate_app_active - Trigger app active handler
set_status <key> <value> [--icon=X] [--color=#hex] [--tab=X] - Set a status entry
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
clear_status <key> [--tab=X] - Remove a status entry
clear_meta <key> [--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] -- <message> - 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 <key> <value> [--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 <key> [--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 <key> <value> [--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 <key> <value> [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X]"
)
}
private func clearStatus(_ args: String) -> String {
clearSidebarMetadata(args, usage: "clear_status <key> [--tab=X]")
}
private func clearMeta(_ args: String) -> String {
clearSidebarMetadata(args, usage: "clear_meta <key> [--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)")

View file

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

View file

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

View file

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

126
tests/test_sidebar_meta.py Normal file
View file

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