Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
cd308d0
Add beta workspace tasks
azooz2003-bit Jun 23, 2026
6b9515a
Fix workspace task socket routing
azooz2003-bit Jun 23, 2026
2282e78
Gate existing workspace task views
azooz2003-bit Jun 23, 2026
4216645
Bound workspace task mutations
azooz2003-bit Jun 23, 2026
7ea152c
Restore mobile set-font path
azooz2003-bit Jun 23, 2026
ebbf4d0
Satisfy workspace tasks policy checks
azooz2003-bit Jun 23, 2026
5b006c5
Fix workspace task review findings
azooz2003-bit Jun 23, 2026
257ebfc
Localize mobile set font socket errors
azooz2003-bit Jun 23, 2026
f611e06
Reject stale workspace task pane targets
azooz2003-bit Jun 23, 2026
6835fba
Resolve workspace task refs in CLI
azooz2003-bit Jun 23, 2026
8ecf7c7
Fix workspace task transfer and mobile font scope parsing
azooz2003-bit Jun 23, 2026
a4ffb70
Preserve mobile live font streams across reconnects
azooz2003-bit Jun 23, 2026
4e02502
Decode mobile font events at consumer
azooz2003-bit Jun 23, 2026
17bce20
Merge remote-tracking branch 'origin/main' into task-workspace-tasks
azooz2003-bit Jun 23, 2026
b82d5cf
Address workspace tasks review feedback
azooz2003-bit Jun 23, 2026
3a77384
Extract workspace tasks CLI handlers
azooz2003-bit Jun 23, 2026
e4484e9
Localize workspace tasks CLI strings
azooz2003-bit Jun 23, 2026
1f2309f
Refresh Swift length budget for workspace tasks
azooz2003-bit Jun 23, 2026
ce6ba40
Restore mobile font event decode API
azooz2003-bit Jun 23, 2026
6c301eb
Focus target workspace for task surface open
azooz2003-bit Jun 23, 2026
9e9ca04
Focus task surface from sidebar popover
azooz2003-bit Jun 23, 2026
9f22f4e
Tighten workspace task open and index handling
azooz2003-bit Jun 23, 2026
dee193f
Iterate workspace tasks UX
azooz2003-bit Jun 23, 2026
3f7ce3e
Restyle workspace tasks UI
azooz2003-bit Jun 23, 2026
34934fe
Show workspace task status in sidebar rows
azooz2003-bit Jun 30, 2026
157570f
Merge origin/main into task-workspace-tasks
azooz2003-bit Jun 30, 2026
4aeb39a
Normalize project file after merge
azooz2003-bit Jun 30, 2026
1d95d9a
Merge remote-tracking branch 'origin/main' into task-workspace-tasks
azooz2003-bit Jun 30, 2026
f235782
Merge remote-tracking branch 'origin/main' into task-workspace-tasks
azooz2003-bit Jun 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
583 changes: 576 additions & 7 deletions CLI/cmux.swift

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
public import Foundation

/// Typed decoder for a `terminal.set_font` push-event payload.
///
/// The Mac emits this event to live-resize the mirrored terminal font on
/// connected iOS device(s). The payload carries the absolute point size and an
/// optional `surface_id` / `workspace_id` scope.
public struct MobileTerminalSetFontEvent: Decodable, Sendable {
public let fontSize: Double
public let surfaceID: String?
public let workspaceID: String?

private enum CodingKeys: String, CodingKey {
case fontSize = "font_size"
case surfaceID = "surface_id"
case workspaceID = "workspace_id"
}

public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
fontSize = try container.decode(Double.self, forKey: .fontSize)
surfaceID = try container.decodeIfPresent(String.self, forKey: .surfaceID)
workspaceID = try container.decodeIfPresent(String.self, forKey: .workspaceID)
}

public static func decode(_ data: Data) throws -> MobileTerminalSetFontEvent {
try JSONDecoder().decode(Self.self, from: data)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
var eventTopics: [String] {
switch self {
case .renderGrid:
return ["workspace.updated", "terminal.render_grid", "notification.dismissed", "notification.badge"]
return ["workspace.updated", "terminal.render_grid", "terminal.set_font", "notification.dismissed", "notification.badge"]
case .rawBytes:
return ["workspace.updated", "terminal.bytes", "notification.dismissed", "notification.badge"]
return ["workspace.updated", "terminal.bytes", "terminal.set_font", "notification.dismissed", "notification.badge"]
}
}
}
Expand Down Expand Up @@ -675,6 +675,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
var terminalScrollQueueTokensBySurfaceID: [String: UUID]
var terminalScrollQueuesBySurfaceID: [String: TerminalScrollDeliveryQueue]
var terminalScrollbackPrefetchStatesBySurfaceID: [String: TerminalScrollbackPrefetchState]
private var terminalLiveFontContinuationsBySurfaceID: [String: AsyncStream<Float32>.Continuation]
private var terminalLiveFontTokensBySurfaceID: [String: UUID]
private var rawTerminalInputBuffer: MobileTerminalInputSendBuffer
private var pairingAttemptID: UUID

Expand Down Expand Up @@ -842,6 +844,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
self.terminalScrollQueueTokensBySurfaceID = [:]
self.terminalScrollQueuesBySurfaceID = [:]
self.terminalScrollbackPrefetchStatesBySurfaceID = [:]
self.terminalLiveFontContinuationsBySurfaceID = [:]
self.terminalLiveFontTokensBySurfaceID = [:]
self.rawTerminalInputBuffer = MobileTerminalInputSendBuffer()
self.pairingAttemptID = UUID()
}
Expand Down Expand Up @@ -4773,6 +4777,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
terminalScrollQueueTokensBySurfaceID = [:]
terminalScrollQueuesBySurfaceID = [:]
terminalScrollbackPrefetchStatesBySurfaceID = [:]
terminalLiveFontContinuationsBySurfaceID = [:]
terminalLiveFontTokensBySurfaceID = [:]
terminalOutputTransport = .rawBytes
supportedHostCapabilities = []
terminalSubscriptionRefreshTask?.cancel()
Expand Down Expand Up @@ -5720,6 +5726,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
self.scheduleWorkspaceListRefreshFromEvent()
} else if event.topic == "terminal.render_grid" {
self.handleTerminalRenderGridEvent(event)
} else if event.topic == "terminal.set_font" {
self.handleTerminalSetFontEvent(event)
} else if event.topic == "terminal.bytes" {
// Raw PTY bytes coming from the Mac surface's libghostty
// pty-tee. This is the compatibility fallback when the Mac
Expand Down Expand Up @@ -6193,6 +6201,26 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
}
}

/// The Mac-pushed live font-size stream for a terminal surface.
///
/// Ending iteration detaches the font continuation. Mirrors the output-stream
/// lifecycle so the font signal never outlives the surface mount.
public func terminalLiveFontStream(surfaceID: String) -> AsyncStream<Float32> {
AsyncStream { continuation in
let token = UUID()
terminalLiveFontContinuationsBySurfaceID[surfaceID] = continuation
terminalLiveFontTokensBySurfaceID[surfaceID] = token
continuation.onTermination = { [weak self] _ in
Task { @MainActor in
guard let self else { return }
guard self.terminalLiveFontTokensBySurfaceID[surfaceID] == token else { return }
self.terminalLiveFontContinuationsBySurfaceID.removeValue(forKey: surfaceID)
self.terminalLiveFontTokensBySurfaceID.removeValue(forKey: surfaceID)
}
}
}
}

/// Report this device's natural terminal grid to the Mac and return the
/// effective grid the Mac computed (the smallest across all attached
/// devices, capped to the Mac pane). The caller pins its libghostty surface
Expand Down Expand Up @@ -6365,6 +6393,28 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
deliverAuthoritativeTerminalRenderGrid(renderGrid, source: "event")
}

private func handleTerminalSetFontEvent(_ event: MobileEventEnvelope) {
guard
let json = event.payloadJSON,
let payload = try? MobileTerminalSetFontEvent.decode(json)
else {
return
}
let points = Float32(payload.fontSize)
if let surfaceID = payload.surfaceID {
terminalLiveFontContinuationsBySurfaceID[surfaceID]?.yield(points)
} else if let targetWorkspaceID = payload.workspaceID {
for (surfaceID, continuation) in terminalLiveFontContinuationsBySurfaceID
where workspaceID(forTerminalID: surfaceID)?.rawValue == targetWorkspaceID {
continuation.yield(points)
}
} else {
for continuation in terminalLiveFontContinuationsBySurfaceID.values {
continuation.yield(points)
}
}
}

private func handleNotificationDismissedEvent(_ event: MobileEventEnvelope) async {
guard
let json = event.payloadJSON,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable {
weak var store: CMUXMobileShellStore?
weak var surfaceView: GhosttySurfaceView?
private var outputTask: Task<Void, Never>?
private var liveFontTask: Task<Void, Never>?
/// Hosts the SwiftUI ``TerminalComposerView`` so it can be installed into the
/// surface's composer band. Built lazily on first open and torn down on
/// dismantle; mounted/unmounted by ``setComposerMounted(_:)``.
Expand Down Expand Up @@ -144,11 +145,21 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable {
)
}
}
liveFontTask = Task { @MainActor [weak surfaceView, weak store] in
guard let store else { return }
for await points in store.terminalLiveFontStream(surfaceID: surfaceID) {
guard !Task.isCancelled else { return }
guard let surfaceView else { return }
surfaceView.setLiveFontSize(points)
}
}
}

func detach() {
outputTask?.cancel()
outputTask = nil
liveFontTask?.cancel()
liveFontTask = nil
}

// MARK: - Composer band hosting
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1995,6 +1995,13 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
return true
}

/// Drive the live terminal font to an absolute point size from outside the
/// surface, such as a Mac-pushed `terminal.set_font` event.
public func setLiveFontSize(_ points: Float32) {
applyAbsoluteFontSize(points)
zoomOverlay?.updateZoom(points: pendingFontSize ?? liveFontSize)
}

/// Set the live zoom to an absolute size (clamped to the font range),
/// driving the same coalesced apply path as a pinch step. Used by the
/// zoom-control overlay's reset / restore-built-in actions.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,23 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable {
"browser.profiles.delete",
"browser.import.cookies",
"mobile.attach_ticket.create",
// `mobile.terminal.set_font` only validates params and emits a
// `terminal.set_font` push event via thread-safe MobileHostService
// statics (no main-actor UI access), so it runs on the socket worker
// like the other mobile data-plane verbs.
"mobile.terminal.set_font",
"system.top",
"system.memory",
// `workspace.env` is a read that resolves a workspace and copies its
// env dictionary behind a `v2MainSync` hop, so it runs on the worker
// lane like the other workspace reads below.
"workspace.env",
"workspace.tasks.list",
"workspace.tasks.add",
"workspace.tasks.archive",
"workspace.tasks.remove",
"workspace.tasks.move",
"workspace.tasks.open",
"workspace.remote.pty_sessions",
"workspace.remote.pty_close",
"workspace.remote.pty_detach",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ struct ControlCommandExecutionPolicyTests {
"feed.push", "browser.download.wait", "system.top", "system.memory",
"workspace.remote.pty_bridge", "workspace.env", "sidebar.custom.reload",
"sidebar.custom.open",
// Workspace task commands are worker-lane methods whose handlers
// resolve workspace state through v2MainSync.
"workspace.tasks.list", "workspace.tasks.add",
"workspace.tasks.archive", "workspace.tasks.remove",
"workspace.tasks.move", "workspace.tasks.open",
"debug.sidebar.simulate_drag", "mobile.attach_ticket.create",
"mobile.terminal.set_font",
// JavaScript-evaluating browser methods block on page JS and must
// not hold the main actor (see socketWorkerMethods rationale).
"browser.eval", "browser.wait", "browser.snapshot", "browser.click",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,5 +60,23 @@ public struct BetaFeaturesCatalogSection: SettingCatalogSection {
userDefaultsKey: "remoteTmux.beta.enabled"
)

/// Workspace Tasks: per-workspace task lists surfaced as a native panel,
/// sidebar-row popover, and CLI/socket command set. Defaults off while the
/// data model and UI are in beta.
public let workspaceTasks = DefaultsKey<Bool>(
id: "workspaceTasks.beta.enabled",
defaultValue: false,
userDefaultsKey: "workspaceTasks.beta.enabled"
)

/// Workspace Controls: an experimental sidebar-row hover control chooser.
/// Defaults off so the sidebar keeps the existing close-only row chrome
/// unless users opt into the beta.
public let workspaceControls = DefaultsKey<Bool>(
id: "workspaceControls.beta.enabled",
defaultValue: false,
userDefaultsKey: "workspaceControls.beta.enabled"
)

public init() {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,13 @@ public struct SidebarCatalogSection: SettingCatalogSection {
userDefaultsKey: RightSidebarWidthSettings.maxWidthKey
)

/// Built-in hover controls exposed on each sidebar workspace row.
public let workspaceControls = DefaultsKey<[WorkspaceRowControlOption]>(
id: "sidebar.workspaceControls",
defaultValue: WorkspaceRowControlOption.defaultControls,
userDefaultsKey: "sidebarWorkspaceControls"
)

public let rememberedRightMaxWidth = DefaultsKey<Double>(
id: "sidebar.rightMaxWidth.remembered",
defaultValue: RightSidebarWidthSettings.defaultConfiguredMaximumWidth,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Foundation

/// Built-in hover controls that cmux can show on a sidebar workspace row.
///
/// Stored under ``SidebarCatalogSection/workspaceControls`` as raw strings in
/// `sidebar.workspaceControls`. The close control is always included so users
/// cannot strand themselves without the existing hover close affordance.
public enum WorkspaceRowControlOption: String, CaseIterable, Sendable, SettingCodable {
/// Close the workspace.
case close
/// Open the workspace-scoped task list.
case tasks

/// Maximum number of built-in controls a workspace row may expose.
public static let maximumVisibleControls = 3

/// Controls shown when no preference is stored.
public static let defaultControls: [WorkspaceRowControlOption] = [.close]

/// Returns a duplicate-free, capped list that always includes ``close``.
///
/// Unknown raw values are dropped by callers before reaching this method.
/// The user's order is otherwise preserved, with ``close`` inserted first
/// only when it was absent.
public static func sanitized(_ controls: [WorkspaceRowControlOption]) -> [WorkspaceRowControlOption] {
var output: [WorkspaceRowControlOption] = []
var seen = Set<WorkspaceRowControlOption>()

func append(_ option: WorkspaceRowControlOption) {
guard output.count < maximumVisibleControls, seen.insert(option).inserted else { return }
output.append(option)
}

if !controls.contains(.close) {
append(.close)
}
for option in controls {
append(option)
}
if output.isEmpty {
return defaultControls
}
return output
}

/// Decodes a raw string array from `cmux.json` / `UserDefaults`, dropping
/// unknown entries before applying the close-control invariant and cap.
public static func sanitizedRawValues(_ rawValues: [String]) -> [WorkspaceRowControlOption] {
sanitized(rawValues.compactMap { WorkspaceRowControlOption(rawValue: $0) })
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Foundation
import Testing
@testable import CmuxSettings

@Suite("sidebar.workspaceControls")
struct WorkspaceRowControlOptionTests {
private func makeStore() -> (UserDefaultsSettingsStore, String, SettingCatalog) {
let suiteName = "cmux.workspaceControls.tests.\(UUID().uuidString)"
let store = UserDefaultsSettingsStore(defaults: UserDefaults(suiteName: suiteName)!)
return (store, suiteName, SettingCatalog())
}

@Test func sanitizedAlwaysIncludesCloseAndCapsControls() {
let controls = WorkspaceRowControlOption.sanitized([.tasks, .tasks, .close])
#expect(controls == [.tasks, .close])

let missingClose = WorkspaceRowControlOption.sanitized([.tasks])
#expect(missingClose == [.close, .tasks])
}

@Test func sanitizedRawValuesDropsUnknownEntries() {
let controls = WorkspaceRowControlOption.sanitizedRawValues(["tasks", "unknown", "close", "tasks"])
#expect(controls == [.tasks, .close])
}

@Test func defaultsToCloseWhenUnset() async {
let (store, _, catalog) = makeStore()
let value = await store.value(for: catalog.sidebar.workspaceControls)
#expect(value == [.close])
}

@Test func roundTripsThroughTheStore() async {
let (store, suiteName, catalog) = makeStore()
await store.set([.close, .tasks], for: catalog.sidebar.workspaceControls)
let value = await store.value(for: catalog.sidebar.workspaceControls)
#expect(value == [.close, .tasks])
let defaults = UserDefaults(suiteName: suiteName)!
#expect(defaults.stringArray(forKey: catalog.sidebar.workspaceControls.userDefaultsKey) == ["close", "tasks"])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ extension Array where Element == CuratedSettingEntry {
.init(section: .sidebarAppearance, id: "show-progress", title: "Show Progress in Sidebar", synonyms: "sidebar.showProgress progress bar percent status set_progress"),
.init(section: .sidebarAppearance, id: "show-metadata", title: "Show Custom Metadata in Sidebar", synonyms: "sidebar.showCustomMetadata metadata meta report_meta status custom block"),
.init(section: .sidebarAppearance, id: "right-max-width", title: "Dock Max Width", synonyms: "sidebar.rightMaxWidth dock right sidebar max width terminal reservation cap logs lazygit"),
.init(section: .sidebarAppearance, id: "workspace-controls", title: String(localized: "settings.sidebar.workspaceControls", defaultValue: "Workspace Controls"), synonyms: "sidebar.workspaceControls hover controls close tasks workspace row beta"),

// Mobile
.init(section: .mobile, id: "pairDevice", title: "Pair a Device", synonyms: "pair pairing add device qr qr code scan iphone ipad ios mobile tailscale connect onboarding sign in"),
Expand All @@ -154,6 +155,8 @@ extension Array where Element == CuratedSettingEntry {
.init(section: .betaFeatures, id: "dock", title: "Dock", synonyms: "dock right sidebar terminal controls tui beta unstable"),
.init(section: .betaFeatures, id: "customSidebars", title: "Custom Sidebars", synonyms: "custom sidebars swift json interpreted vibe beta unstable"),
.init(section: .betaFeatures, id: "remoteTmux", title: "Remote tmux", synonyms: "remote tmux ssh control mode -CC mirror session window pane sidebar workspace beta unstable"),
.init(section: .betaFeatures, id: "workspaceTasks", title: String(localized: "settings.betaFeatures.workspaceTasks", defaultValue: "Workspace Tasks"), synonyms: "workspace tasks todos goals deliverables per workspace beta unstable"),
.init(section: .betaFeatures, id: "workspaceControls", title: String(localized: "settings.betaFeatures.workspaceControls", defaultValue: "Workspace Controls"), synonyms: "workspace controls sidebar row hover close tasks beta unstable"),

// Automation
.init(section: .automation, id: "socket-mode", title: "Socket Control Mode", synonyms: "automation.socketControlMode api socket unix domain control server auth allow password disabled"),
Expand Down
Loading