Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
75 commits
Select commit Hold shift + click to select a range
71ea935
Add sidebar active agent indicator
lawrencecchen Jun 20, 2026
3bea463
Merge remote-tracking branch 'origin/main' into feat-agent-activity-i…
lawrencecchen Jun 25, 2026
7ac7b72
Sidebar spinner: GPU-pure macOS spokes + debug spinner gallery
lawrencecchen Jun 25, 2026
13080a1
Fix vendor/bonsplit pointer dropped by merge
lawrencecchen Jun 25, 2026
edc0e29
Wire sidebar.showAgentActivity through config pipeline + localize set…
lawrencecchen Jun 25, 2026
0896f4f
Honor Hide All Sidebar Details for the agent activity indicator
lawrencecchen Jun 25, 2026
4bed4e7
Add default test stub for controlDebugShowSpinnerGallery
lawrencecchen Jun 25, 2026
d5401f7
Document debug-only verbatim localization exception for spinner gallery
lawrencecchen Jun 25, 2026
486f1b8
Import QuartzCore in sidebar spinner for explicit Core Animation deps
lawrencecchen Jun 25, 2026
8a210bd
Scope sidebar agent observation to the running-agent count
lawrencecchen Jun 25, 2026
9ca19b6
Split reusable GPUSpinner into its own file + document showAgentActiv…
lawrencecchen Jun 25, 2026
40136d3
Sidebar spinner: grey in both states + thinner spokes
lawrencecchen Jun 25, 2026
750a90a
Spinner gallery: add overlay comparison (native vs GPU spokes) for fr…
lawrencecchen Jun 25, 2026
652b94d
Spinner gallery: float window + makeKey so frame-capture targets it
lawrencecchen Jun 25, 2026
59f3a4b
Spinner gallery: size-match overlay to native intrinsic spinner size
lawrencecchen Jun 25, 2026
5b09c4b
GPU spinner: match native macOS spoke proportions (compact ring, cent…
lawrencecchen Jun 25, 2026
629a11b
Add 'cmux loading <on|off>' CLI command for the workspace loading ind…
lawrencecchen Jun 25, 2026
e36af2f
Move loading command under 'cmux workspace loading'; spinner-only on …
lawrencecchen Jun 25, 2026
87243c5
Sidebar: move loading spinner into the far-right close-button slot
lawrencecchen Jun 26, 2026
332b12d
Keep loading spinner visible under Hide All Sidebar Details
lawrencecchen Jun 26, 2026
2a0e0dc
Phase-lock all GPU spinners to a shared media-clock grid
lawrencecchen Jun 26, 2026
037a3eb
Sidebar loading spinner: left/right position setting + fade/cross-fad…
lawrencecchen Jun 26, 2026
2bdec80
Spinner: remove per-row animations (LazyVStack height jank) + default…
lawrencecchen Jun 26, 2026
63500a9
Re-add height-neutral title push animation for the leading status slot
lawrencecchen Jun 26, 2026
496b559
Add notification badge position setting + opacity fades for badge & s…
lawrencecchen Jun 26, 2026
18b192f
Workspace-scope cmux workspace loading + report before/after state
lawrencecchen Jun 26, 2026
a435c8f
Restore the title x-slide: animate leading slot width, not just opacity
lawrencecchen Jun 26, 2026
5e18577
Use an interruptible spring for the status slide/fade (Apple-style)
lawrencecchen Jun 26, 2026
7052a2d
Self-review cleanups: docs, validation, appearance, tests
lawrencecchen Jul 2, 2026
1ee34fb
Fix autoreview findings: hibernation ignores manual loaders; validate…
lawrencecchen Jul 2, 2026
1c6a4e2
Wire the two position rows into the Settings search index
lawrencecchen Jul 2, 2026
950b719
Fix autoreview findings: hibernation activity + localized workspace help
lawrencecchen Jul 2, 2026
f22821c
Localize the workspace loading CLI usage/error strings (en/ja/ko/uk)
lawrencecchen Jul 2, 2026
d64ddc9
Policy cleanups: dedicated file + DocC for new public package symbols
lawrencecchen Jul 2, 2026
be803a2
Trim comment noise; build spinner tooltip only in gated branches
lawrencecchen Jul 2, 2026
ddd8013
Note the intentional badge/spinner slot-sharing on the leading side
lawrencecchen Jul 2, 2026
93650fb
Right-positioned badge sticks flush to the row's right corner
lawrencecchen Jul 2, 2026
18e7f43
Merge origin/main into feat-agent-activity-indicator
lawrencecchen Jul 2, 2026
37952eb
Merge remote-tracking branch 'origin/main' into feat-agent-activity-i…
lawrencecchen Jul 2, 2026
f8ae21d
Address PR bot findings: spinner immediacy under context menu + toolt…
lawrencecchen Jul 2, 2026
6e45be7
Count running agents without intermediate filter allocation
lawrencecchen Jul 2, 2026
be916ad
Fix CI + review findings: Cmd+W registration, schema aliases, full lo…
lawrencecchen Jul 2, 2026
32658f8
workspace_loading on: clear the manual key from all panels before set…
lawrencecchen Jul 2, 2026
3554f26
Retrigger CI (no workflow runs were created for the previous push)
lawrencecchen Jul 2, 2026
652428b
Merge remote-tracking branch 'origin/main' into feat-agent-activity-i…
lawrencecchen Jul 2, 2026
48d4070
Refresh Swift file length budget for the loading-indicator feature gr…
lawrencecchen Jul 2, 2026
9c7f7db
Retrigger stuck Vercel deployments
lawrencecchen Jul 2, 2026
7cb9cb2
Assert the deferred-invalidation flag in the context-menu spinner test
lawrencecchen Jul 2, 2026
1ed87a6
Merge remote-tracking branch 'origin/main' into feat-agent-activity-i…
lawrencecchen Jul 2, 2026
3f45679
Bound manual loader ids and count on the socket path
lawrencecchen Jul 2, 2026
71616b4
set_agent_lifecycle rejects manual keys; workspace_loading owns the n…
lawrencecchen Jul 2, 2026
66afc78
Manual loaders survive panel lifecycle; reserve the manual namespace
lawrencecchen Jul 2, 2026
de8c56a
Refresh Swift file length budget (+24 lines, review fixes)
lawrencecchen Jul 2, 2026
46c7ef6
Fix inactive sidebar row alignment
lawrencecchen Jul 3, 2026
30b53d5
Fix workspace loading off state reporting
lawrencecchen Jul 3, 2026
e171a8d
Tighten sidebar spinner title spacing
lawrencecchen Jul 3, 2026
843734d
Resolve workspace loading refs before v1 bridge
lawrencecchen Jul 8, 2026
e6cd9a7
Address agent activity merge policy findings
lawrencecchen Jul 8, 2026
b3cdfc8
Split spinner gallery energy type
lawrencecchen Jul 8, 2026
979658e
Fix workspace loading failure reporting
lawrencecchen Jul 8, 2026
6ce6fdf
Split sidebar v1 test fake
lawrencecchen Jul 8, 2026
245a129
Reject malformed workspace loading flags
lawrencecchen Jul 8, 2026
e23775e
Gate hidden agent activity row churn
lawrencecchen Jul 8, 2026
8cd96c1
Reject blank workspace loading tab targets
lawrencecchen Jul 8, 2026
02c5784
Localize agent activity tooltips
lawrencecchen Jul 8, 2026
944a48e
Move workspace loading parser into CLI file
lawrencecchen Jul 8, 2026
f466c31
Merge origin/main into feat-agent-activity-indicator
lawrencecchen Jul 8, 2026
fe5e88b
Split agent activity indicator code
lawrencecchen Jul 8, 2026
aded6c6
Merge remote-tracking branch 'origin/main' into feat-agent-activity-i…
lawrencecchen Jul 8, 2026
c87b34b
Register spinner gallery auxiliary window
lawrencecchen Jul 8, 2026
ae6500e
Merge remote-tracking branch 'origin/main' into feat-agent-activity-i…
lawrencecchen Jul 8, 2026
d98b582
Keep sidebar snapshot policy tests below budget
lawrencecchen Jul 8, 2026
93903e8
Shorten CLI socket test home path
lawrencecchen Jul 8, 2026
28cc691
Merge remote-tracking branch 'origin/main' into feat-agent-activity-i…
lawrencecchen Jul 8, 2026
3036399
Merge origin/main into agent activity indicator
lawrencecchen Jul 8, 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
4 changes: 2 additions & 2 deletions .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
17862 Sources/AppDelegate.swift
16424 Sources/ContentView.swift
15098 Sources/TerminalController.swift
13228 Sources/Workspace.swift
13164 Sources/Workspace.swift
12501 Sources/GhosttyTerminalView.swift
12348 cmuxTests/AppDelegateShortcutRoutingTests.swift
11388 Sources/Panels/BrowserPanel.swift
Expand Down Expand Up @@ -178,10 +178,10 @@
637 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift
635 cmuxUITests/RightSidebarChromeHeightUITests.swift
630 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutWhenClause.swift
624 Sources/SettingsNavigation.swift
623 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift
620 Sources/Panels/BrowserNavigationDelegate.swift
620 cmuxTests/FinderFileDropRegressionTests.swift
618 Sources/SettingsNavigation.swift
608 cmuxUITests/FeedSidebarUITests.swift
607 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift
607 Sources/SessionIndexModels.swift
Expand Down
201 changes: 201 additions & 0 deletions CLI/WorkspaceLoadingArguments.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import Foundation

struct WorkspaceLoadingArguments {
let turnOn: Bool
let id: String?
let workspace: String?
let window: String?
}

extension CMUXCLI {
func validateWorkspaceLoadingCommandBeforeSocket(
command: String,
commandArgs: [String]
) throws {
guard command == "workspace",
commandArgs.first?.lowercased() == "loading" else {
return
}
_ = try parseWorkspaceLoadingArguments(Array(commandArgs.dropFirst()))
}

func workspaceLoadingUsage() -> String {
String(
localized: "cli.workspaceLoading.usage",
defaultValue: "Usage: cmux workspace loading <on|off> [--id <name>] [--workspace <id>] [--window <id>] [--json]"
)
}

func parseWorkspaceLoadingArguments(_ commandArgs: [String]) throws -> WorkspaceLoadingArguments {
let usage = workspaceLoadingUsage()
var idArg: String?
var wsArg: String?
var winArg: String?
var positional: [String] = []
var index = 0
var pastTerminator = false

func requireValue() throws -> String {
let valueIndex = index + 1
guard valueIndex < commandArgs.count, !commandArgs[valueIndex].hasPrefix("--") else {
throw CLIError(message: usage)
}
return commandArgs[valueIndex]
}

while index < commandArgs.count {
let arg = commandArgs[index]
if !pastTerminator, arg == "--" {
pastTerminator = true
index += 1
continue
}
if !pastTerminator, arg == "--json" {
index += 1
continue
}
if !pastTerminator, arg == "--id" {
idArg = try requireValue()
index += 2
continue
}
if !pastTerminator, arg == "--workspace" {
wsArg = try requireValue()
index += 2
continue
}
if !pastTerminator, arg == "--window" {
winArg = try requireValue()
index += 2
continue
}
if !pastTerminator, arg.hasPrefix("--id=") {
let value = String(arg.dropFirst("--id=".count))
guard !value.isEmpty else { throw CLIError(message: usage) }
idArg = value
index += 1
continue
}
if !pastTerminator, arg.hasPrefix("--workspace=") {
let value = String(arg.dropFirst("--workspace=".count))
guard !value.isEmpty else { throw CLIError(message: usage) }
wsArg = value
index += 1
continue
}
if !pastTerminator, arg.hasPrefix("--window=") {
let value = String(arg.dropFirst("--window=".count))
guard !value.isEmpty else { throw CLIError(message: usage) }
winArg = value
index += 1
continue
}
if !pastTerminator, arg.hasPrefix("--") {
throw CLIError(message: usage)
}
positional.append(arg)
index += 1
}

guard positional.count <= 1 else {
throw CLIError(message: usage)
}
guard let sub = positional.first?.lowercased() else {
throw CLIError(message: usage)
}
let turnOn: Bool
switch sub {
case "on", "start", "show", "running", "busy":
turnOn = true
case "off", "stop", "hide", "done", "idle", "finished":
turnOn = false
default:
throw CLIError(message: String(
format: String(
localized: "cli.error.workspaceLoadingInvalidState",
defaultValue: "Invalid state '%@'. Expected on or off. %@"
),
locale: .current,
sub,
usage
))
}

return WorkspaceLoadingArguments(
turnOn: turnOn,
id: idArg,
workspace: wsArg,
window: winArg
)
}

/// `cmux workspace loading <on|off> [--id <name>]` toggles the workspace's
/// loading spinner via the reserved `manual` lifecycle namespace.
func runWorkspaceLoading(
commandArgs: [String],
client: SocketClient,
windowId: String?,
jsonOutput: Bool
) throws {
let parsed = try parseWorkspaceLoadingArguments(commandArgs)
let usage = workspaceLoadingUsage()

let manual = AgentHibernationLifecycleStatusKeys.manualKey
let key: String
if let rawId = parsed.id?.trimmingCharacters(in: .whitespacesAndNewlines) {
guard !rawId.isEmpty else {
throw CLIError(message: usage)
}
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-")
guard rawId.unicodeScalars.allSatisfy(allowed.contains) else {
throw CLIError(message: String(
format: String(
localized: "cli.error.workspaceLoadingInvalidId",
defaultValue: "Invalid --id '%@'. Use letters, digits, '.', '_', or '-' (no spaces)."
),
locale: .current,
rawId
))
}
key = "\(manual):\(rawId)"
Comment thread
cursor[bot] marked this conversation as resolved.
} else {
key = manual
}

let windowRaw = parsed.window ?? windowId
let workspaceArg = parsed.workspace ?? Self.callerWorkspaceForSurfaceHandle(nil, windowRaw: windowRaw)
let winId = try normalizeWindowHandle(windowRaw, client: client)
let wsId = try resolveWorkspaceId(
workspaceArg,
client: client,
windowHandle: winId
)

let response = try sendV1Command(
"workspace_loading \(key) \(parsed.turnOn ? "on" : "off") --tab=\(wsId)",
client: client
)

if jsonOutput {
var before = false
var after = false
for part in response.split(separator: ";") {
let kv = part.split(separator: "=", maxSplits: 1)
guard kv.count == 2 else { continue }
let isOn = kv[1].trimmingCharacters(in: .whitespaces).uppercased() == "ON"
if kv[0] == "before" { before = isOn }
if kv[0] == "after" { after = isOn }
}
print(jsonString([
"ok": true,
"id": parsed.id ?? "",
"workspace_id": wsId,
"before": before,
"after": after,
"loading": after,
]))
} else {
print(response)
}
}
}
14 changes: 7 additions & 7 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3499,7 +3499,6 @@ struct CMUXCLI {
throw CLIError(message: "Unknown feed subcommand: \(sub)")
}
}

if command == "events" {
try runEventsCommand(
commandArgs: commandArgs,
Expand Down Expand Up @@ -3527,7 +3526,7 @@ struct CMUXCLI {
command: command,
commandArgs: commandArgs
)

try validateWorkspaceLoadingCommandBeforeSocket(command: command, commandArgs: commandArgs)
let client = SocketClient(path: resolvedSocketPath)
if resolvedSocketPath != socketPath {
cliTelemetry.breadcrumb(
Expand Down Expand Up @@ -6356,7 +6355,7 @@ struct CMUXCLI {
}
throw CLIError(message: "Surface index not found")
}
private static func callerWorkspaceForSurfaceHandle(_ raw: String?, windowRaw: String?) -> String? {
static func callerWorkspaceForSurfaceHandle(_ raw: String?, windowRaw: String?) -> String? {
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return windowRaw == nil && (trimmed.isEmpty || Int(trimmed) != nil) ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil
}
Expand Down Expand Up @@ -8112,7 +8111,7 @@ struct CMUXCLI {
guard let sub = commandArgs.first?.lowercased() else {
throw CLIError(message: String(
localized: "cli.error.workspaceSubcommandRequired",
defaultValue: "workspace requires a subcommand. Try: list, create, env, close, rename, select, reconnect, disconnect, group"
defaultValue: "workspace requires a subcommand. Try: list, create, env, close, rename, select, reconnect, disconnect, loading, group"
))
}
let rest = Array(commandArgs.dropFirst())
Expand Down Expand Up @@ -8201,11 +8200,13 @@ struct CMUXCLI {
idFormat: idFormat,
windowOverride: windowOverride
)
case "loading":
try runWorkspaceLoading(commandArgs: rest, client: client, windowId: windowOverride, jsonOutput: jsonOutput)
default:
throw CLIError(message: String(
format: String(
localized: "cli.error.workspaceSubcommandUnknown",
defaultValue: "Unknown workspace subcommand: %@. Try: list, create, env, close, rename, select, reconnect, disconnect, group"
defaultValue: "Unknown workspace subcommand: %@. Try: list, create, env, close, rename, select, reconnect, disconnect, loading, group"
),
locale: .current,
sub
Expand Down Expand Up @@ -15881,12 +15882,11 @@ struct CMUXCLI {
whose automatic reconnect paused because the host
was unreachable
disconnect [workspace] Stop a remote (SSH) workspace's connection
loading <on|off> [--id <name>] Toggle the workspace loading spinner.
group <subcommand> Workspace group operations (see cmux workspace-group --help)

env/reconnect/disconnect accept a positional handle or --workspace
<id|ref|index>, defaulting to the caller's workspace, then the
selected one (of --window's window when given).

Examples:
cmux workspace list --json
cmux workspace create --name Build --cwd ~/projects/myapp
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ extension ControlCommandCoordinator {
case "list_meta_blocks": return sidebarListMetaBlocks(args, context: context)
case "set_agent_pid": return sidebarSetAgentPID(args, context: context)
case "set_agent_lifecycle": return sidebarSetAgentLifecycle(args, context: context)
case "workspace_loading": return sidebarWorkspaceLoading(args, context: context)
case "agent_hibernation": return sidebarAgentHibernation(args, context: context)
case "clear_agent_pid": return sidebarClearAgentPID(args, context: context)
case "log": return sidebarAppendLog(args, context: context)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
internal import Foundation

extension ControlCommandCoordinator {
/// `workspace_loading <key> <on|off> [--tab=<id>]` toggles a workspace-scoped
/// manual loading state and replies `before=ON;after=OFF`.
nonisolated func sidebarWorkspaceLoading(_ args: String, context: (any ControlCommandContext)?) -> String {
let parsed = sidebarParseOptions(args)
let usage = "workspace_loading <key> <on|off> [--tab=<id>]"
guard parsed.positional.count >= 2 else {
return "ERROR: Usage: \(usage)"
}
let key = parsed.positional[0]
guard key == "manual" || key.hasPrefix("manual:") else {
return "ERROR: workspace_loading only accepts manual loader keys (manual or manual:<id>); use set_agent_lifecycle for agent keys"
}
if key != "manual" {
let id = key.dropFirst("manual:".count)
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-")
guard !id.isEmpty, id.count <= 64, id.unicodeScalars.allSatisfy(allowed.contains) else {
return "ERROR: Invalid manual loader id; use 1-64 characters from letters, digits, '.', '_', '-'"
}
}
let on: Bool
switch parsed.positional[1].lowercased() {
case "on", "running", "start", "show":
on = true
case "off", "idle", "stop", "hide":
on = false
default:
return "ERROR: Usage: \(usage)"
}
let tabArg = parsed.options["tab"]
if let tabArg, tabArg.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return "ERROR: Invalid --tab; expected a workspace id, ref, or index"
}
let result = context?.controlSidebarOnMain {
$0.controlSidebarSetWorkspaceLoading(
tabArg: tabArg,
key: key,
on: on
)
}
guard let result = result ?? nil else {
return "ERROR: Workspace not found"
}
if let failureReason = result.failureReason {
return "ERROR: \(failureReason)"
}
func label(_ value: Bool) -> String { value ? "ON" : "OFF" }
return "before=\(label(result.before));after=\(label(result.after))"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,18 @@ public protocol ControlSidebarContext: AnyObject {
panelID: UUID?
)

/// Workspace-scoped manual loading toggle for `workspace_loading`. `on`
/// marks the manual loader `key` running on the workspace; `off` clears it
/// from every panel so it turns off regardless of which surface invoked it.
/// Returns this manual loader key's state before and after the change, or
/// `nil` if the workspace was not found. Other active agent lifecycles may
/// still keep the aggregate sidebar spinner visible.
func controlSidebarSetWorkspaceLoading(
tabArg: String?,
key: String,
on: Bool
) -> ControlSidebarWorkspaceLoadingState?

/// Applies the `agent_hibernation` global toggle.
nonisolated func controlSidebarSetAgentHibernation(enabled: Bool)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/// The manual loader key state before and after a `workspace_loading` toggle,
/// reported back to the caller (e.g. `before=ON;after=OFF`).
public struct ControlSidebarWorkspaceLoadingState: Sendable, Equatable {
/// Whether this manual loader key was running before the toggle.
public let before: Bool

/// Whether this manual loader key is running after the toggle.
public let after: Bool

/// A human-readable failure reason when the requested toggle could not be
/// applied. The v1 line protocol maps this to `ERROR: <reason>`.
public let failureReason: String?

/// Creates a before/after pair for one `workspace_loading` toggle.
///
/// - Parameters:
/// - before: Spinner state before the change.
/// - after: Spinner state after the change.
/// - failureReason: A failure reason when no state change was possible.
public init(before: Bool, after: Bool, failureReason: String? = nil) {
self.before = before
self.after = after
self.failureReason = failureReason
}
}
Loading
Loading