Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
54f1b21
test(next): route chat size keys to font action
teamleaderleo Oct 28, 2026
e4e182f
fix(next): route chat font shortcuts to text size
teamleaderleo Oct 28, 2026
62576a3
fix(next): serialize repeated chat font shortcuts
teamleaderleo Oct 28, 2026
3da8d50
fix(next): keep chat font write queue per app
teamleaderleo Oct 28, 2026
b420c74
docs(next): refresh chat shortcut catalog copy
teamleaderleo Oct 28, 2026
964e1ae
fix(next): disambiguate chat setting value type
teamleaderleo Oct 28, 2026
798bc6d
Merge remote-tracking branch 'origin/feat-cmux-next' into nx-chat-fon…
teamleaderleo Oct 28, 2026
b513dbf
test(next): await the chat font limit write
teamleaderleo Oct 28, 2026
dd020bc
test(next): report failed chat font shortcut writes
teamleaderleo Oct 28, 2026
da70c56
fix(next): surface chat font write failures and refresh docs
teamleaderleo Oct 28, 2026
d13ffcf
fix(next): respect focused surface for chat font shortcuts
teamleaderleo Oct 30, 2026
043dce8
Merge remote-tracking branch 'origin/feat-cmux-next' into nx-chat-fon…
teamleaderleo Oct 31, 2026
30a208c
Merge remote-tracking branch 'origin/feat-cmux-next' into nx-chat-fon…
teamleaderleo Oct 31, 2026
74021e0
Merge remote-tracking branch 'origin/feat-cmux-next' into nx-chat-fon…
teamleaderleo Oct 31, 2026
9421825
docs: refresh CLI reference after base merge
teamleaderleo Oct 31, 2026
e51db59
Merge feat-cmux-next and fix font shortcut test import
teamleaderleo Oct 31, 2026
903b7a7
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/nx-com…
teamleaderleo Oct 31, 2026
48e2ee8
test(chat): select a terminal before checking shortcut isolation
teamleaderleo Oct 31, 2026
aeab59d
fix(chat): keep automated workspace font actions on terminals
teamleaderleo Oct 31, 2026
3d1a535
Merge feat-cmux-next into lane/nx-composer-font-shortcuts
teamleaderleo Nov 1, 2026
3dc5bd2
Merge feat-cmux-next into lane/nx-composer-font-shortcuts
inter-milan-vs-tottenham-spurs Nov 1, 2026
6ae5738
Merge feat-cmux-next into lane/nx-composer-font-shortcuts
inter-milan-vs-tottenham-spurs Nov 1, 2026
a022759
Merge feat-cmux-next into lane/nx-composer-font-shortcuts
inter-milan-vs-tottenham-spurs Nov 1, 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
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,21 @@ nonisolated enum AgentActionCatalog: ActionCatalogGroup {
ActionDescriptor(
id: "agentPaneZoomIn",
title: String(localized: "action.agentPaneZoomIn", defaultValue: "Agent Chat: Zoom In", bundle: .module),
keywords: ["agent", "chat", "zoom", "bigger"], defaultShortcut: Shortcut("=", modifiers: [.command]),
keywords: ["agent", "chat", "zoom", "bigger"],
category: .agents, symbol: "plus.magnifyingglass", surfaces: [.palette, .keyboard, .menu, .contextMenu],
requires: [.agentPaneFocused], targets: [.pane], cliName: "agent zoom-in", mainMenu: .view
),
ActionDescriptor(
id: "agentPaneZoomOut",
title: String(localized: "action.agentPaneZoomOut", defaultValue: "Agent Chat: Zoom Out", bundle: .module),
keywords: ["agent", "chat", "zoom", "smaller"], defaultShortcut: Shortcut("-", modifiers: [.command]),
keywords: ["agent", "chat", "zoom", "smaller"],
category: .agents, symbol: "minus.magnifyingglass", surfaces: [.palette, .keyboard, .menu, .contextMenu],
requires: [.agentPaneFocused], targets: [.pane], cliName: "agent zoom-out", mainMenu: .view
),
ActionDescriptor(
id: "agentPaneZoomReset",
title: String(localized: "action.agentPaneZoomReset", defaultValue: "Agent Chat: Actual Size", bundle: .module),
keywords: ["agent", "chat", "zoom", "reset"], defaultShortcut: Shortcut("0", modifiers: [.command]),
keywords: ["agent", "chat", "zoom", "reset"],
category: .agents, symbol: "1.magnifyingglass", surfaces: [.palette, .keyboard, .menu, .contextMenu],
requires: [.agentPaneFocused], targets: [.pane], cliName: "agent actual-size", mainMenu: .view
),
Expand Down
6 changes: 6 additions & 0 deletions Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,12 @@ final class AppServices {
let actionBudget: ActionBudgetMonitor
/// Every action; timed by the always-on `action.*` signposts and `actionBudget`, in Debug and Release.
let registry: ActionRegistry
/// Serializes Cmd +/− chat font writes so key repeats cannot lose a step.
@MainActor final class ChatFontSizeWriteQueue {
var latest: Task<ActionWorkFailure?, Never>?
var generation = 0
}
let agentChatFontSizeWrites = ChatFontSizeWriteQueue()
/// The main-thread blocking detector's handle (threading-model.md): the Debug menu, `debug.blocking`, and its log sink (``BlockingLogSink``).
let blocking = BlockingDetector.forProcess()
/// Sparkle updates (release builds) or read-only feed probes (DEV).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import CmuxNextDaemon
import CmuxNextDaemonService
import CmuxNextDesign
import CmuxNextLayout
import CmuxNextSettings

// Swap, close, rename, flash and workspace font size.
extension PaneHandlers {
Expand Down Expand Up @@ -139,6 +140,21 @@ extension PaneHandlers {
]
for (id, binding) in bindings {
registry.bind(id, invoke: { invocation in
// The same keys change the chat's text size when its composer or transcript
// owns focus. The page-zoom actions remain available from the View menu/palette.
// A selected chat is not enough to claim a keyboard shortcut: the key router
// records the window's actual focused surface on the invocation. Keep direct
// palette/menu runs (which have no key context) target-scoped, but let a sidebar
// or native control retain the workspace-terminal action while a chat remains
// selected.
let inAppUser = invocation.origin == .user && !invocation.viaControlSocket
let chatOwnsKeyboard = inAppUser && invocation.keyContext?.contains(.agentPaneFocused) == true
let targetIsChat = inAppUser && invocation.keyContext == nil
&& ctx.scope(invocation).pane.map { if case .agent = $0.currentContent { true } else { false } } == true
if chatOwnsKeyboard || targetIsChat {
changeChatFontSize(id, ctx)
return
}
guard let content = ctx.content(invocation) else { return }
var applied = 0
for pane in content.panes.values {
Expand All @@ -150,4 +166,49 @@ extension PaneHandlers {
})
}
}

// Key repeats read after the preceding write, or two quick Cmd-Plus presses
// both read 14 and save 15. Keep the chain only until its last write settles.

/// One point per key press, within the Settings row's range. An absent value is its 14 pt
/// default; reset removes the override so later default changes still apply.
private static func changeChatFontSize(_ id: ActionID, _ ctx: AppActionContext) {
guard let settings = ctx.services.settings else { return ctx.refuse(RefusalStrings.settingsNotLoaded) }
let path = AgentPaneDisplaySetting.fontSizePath
if let managed = settings.managedKey(forPath: path) {
return ctx.refuse(RefusalStrings.settingManaged(managed.key))
}
let writer = SettingWriter.currentRun()
let queue = ctx.services.agentChatFontSizeWrites
let previous = queue.latest
queue.generation += 1
let generation = queue.generation
let task: Task<ActionWorkFailure?, Never> = Task { @MainActor in
_ = await previous?.value
defer {
if queue.generation == generation { queue.latest = nil }
}
let value: CmuxNextSettings.JSONValue?
if id == "resetWorkspaceTerminalFontSize" {
value = nil
} else {
let current = AgentPaneDisplaySetting(fileRoot: settings.fileRoot)
.values[path.joined(separator: ".")]?.doubleValue ?? AgentPaneDisplaySetting.fontSizeFallback
let delta = id == "increaseWorkspaceTerminalFontSize" ? 1.0 : -1.0
let range = AgentPaneDisplaySetting.fontSizeRange
let next = min(max(current + delta, range.lowerBound), range.upperBound)
guard next != current else { return nil }
value = .number(next)
}
do {
try await settings.setSetting(at: path, to: value, by: writer)
return nil
} catch {
await settings.reload()
return ActionWorkFailure(String(describing: error))
}
}
queue.latest = task
ctx.report { await task.value }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ public nonisolated struct AgentPaneDisplaySetting: Sendable, Hashable {
/// Show Feature Tips (``UpdatesSettings/showFeatureTipsPath``; webviews featureTips.tsx).
public static let followedAppPaths: [[String]] = [SidebarSectionsSetting.minimalPath, UpdatesSettings.showFeatureTipsPath]

/// The Chat Font Size setting read by both the page and the app's Cmd +/− actions.
public static let fontSizePath = ["agentPane", "display", "font", "size"]
public static var fontSizeRange: ClosedRange<Double> {
Double(AgentPaneDisplaySettingsSchema.fontSizeRange.lowerBound)...Double(AgentPaneDisplaySettingsSchema.fontSizeRange.upperBound)
}
public static var fontSizeFallback: Double { AgentPaneDisplaySettingsSchema.fontSizeFallback }

/// The accepted values by dotted key.
public var values: [String: JSONValue]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import CmuxNextActions
import Testing

/// Cmd-= (and Cmd-+), Cmd-- and Cmd-0 on Home zoom the Chief chat (`home.zoomIn`, `home.zoomOut`,
/// `home.zoomReset`); elsewhere the keys keep their owner (an agent chat's zoom, a terminal's font
/// `home.zoomReset`); elsewhere the keys keep their owner (an agent chat's text size, a terminal's font
/// size). The binding table resolves each key in the contexts the key router builds.
@MainActor @Suite struct HomeZoomKeyTests {
static let keys: [(Shortcut, String)] = [
Expand Down Expand Up @@ -38,9 +38,9 @@ import Testing
@Test func zoomKeysStayWithTheAgentChatAndTheTerminal() {
var agent = KeyContext(bits: [.agentPaneFocused])
agent[KeyContext.surfaceKind] = .string("agent")
#expect(Self.winner(Shortcut("=", modifiers: [.command]), in: agent) == "agentPaneZoomIn")
#expect(Self.winner(Shortcut("-", modifiers: [.command]), in: agent) == "agentPaneZoomOut")
#expect(Self.winner(Shortcut("0", modifiers: [.command]), in: agent) == "agentPaneZoomReset")
#expect(Self.winner(Shortcut("=", modifiers: [.command]), in: agent) == "increaseWorkspaceTerminalFontSize")
#expect(Self.winner(Shortcut("-", modifiers: [.command]), in: agent) == "decreaseWorkspaceTerminalFontSize")
#expect(Self.winner(Shortcut("0", modifiers: [.command]), in: agent) == "resetWorkspaceTerminalFontSize")
var terminal = KeyContext(bits: [.terminalFocused])
terminal[KeyContext.surfaceKind] = .string("terminal")
for (key, _) in Self.keys {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import AppKit
import CmuxNextActions
@testable import CmuxNextApp
import CmuxNextAgentPane
import CmuxNextDaemon
import CmuxNextDesign
@testable import CmuxNextSettings
import Foundation
import Testing

/// Lawrence via Leo: "make sure font size everywhere (like text input area) is customizable".
/// The same action bound to Cmd +/− scales chat text when a chat owns focus, and keeps the
/// workspace terminal path when it does not.
@MainActor @Suite(.serialized, .timeLimit(.minutes(3)), .enabled(if: SameTreeDaemonBinary.url != nil, "needs the same-tree cmux-tui"))
struct AgentChatFontSizeShortcutTests {
@Test func fontActionsWriteTheChatSettingOnlyInAChat() async throws {
try await withSettings { services, settings, _ in
let key = AgentPaneDisplaySetting.fontSizePath

// Startup may land on a chat. Select a real terminal before testing its shortcut,
// or an asynchronous chat-font write can leak into the two-press assertion below.
let pane = try #require(services.windows.active?.focusedPane)
pane.newTerminalTab()
try await PasswordManagerRouteTests.until({
guard let focused = services.windows.active?.focusedPane,
case .terminal = focused.currentContent else { return false }
return true
})
#expect(services.registry.perform("increaseWorkspaceTerminalFontSize", invocation: ActionInvocation(origin: .user)))
#expect(services.agentChatFontSizeWrites.latest == nil)
#expect(settings.fileRoot.value(at: key) == nil)

try await openChat(services)

#expect(services.registry.perform("increaseWorkspaceTerminalFontSize", invocation: ActionInvocation(origin: .user)))
#expect(services.registry.perform("increaseWorkspaceTerminalFontSize", invocation: ActionInvocation(origin: .user)))
try await PasswordManagerRouteTests.until({ settings.fileRoot.value(at: key) == .number(16) },
report: { "two quick increases saved \(String(describing: settings.fileRoot.value(at: key)))" })
#expect(services.registry.perform("decreaseWorkspaceTerminalFontSize", invocation: ActionInvocation(origin: .user)))
try await PasswordManagerRouteTests.until({ settings.fileRoot.value(at: key) == .number(15) })

try await settings.set(.number(28), at: key)
#expect(services.registry.perform("increaseWorkspaceTerminalFontSize", invocation: ActionInvocation(origin: .user)))
#expect(await services.agentChatFontSizeWrites.latest?.value == nil)
#expect(settings.fileRoot.value(at: key) == .number(28), "the upper Settings limit is kept")
#expect(services.registry.perform("resetWorkspaceTerminalFontSize", invocation: ActionInvocation(origin: .user)))
try await PasswordManagerRouteTests.until({ settings.fileRoot.value(at: key) == nil })
}
}

@Test func managedFontActionsRefuseWithoutWriting() async throws {
let key = AgentPaneDisplaySetting.fontSizePath
let dotted = key.joined(separator: ".")
try await withSettings(managed: ManagedPreferences(forced: [dotted: .number(22)])) { services, settings, url in
try await openChat(services)
#expect(settings.managedKey(forPath: key) != nil)
var notices: [(String, Bool)] = []
let previous = services.registry.refusalObserver
services.registry.refusalObserver = { notices.append(($0, $1)) }
defer { services.registry.refusalObserver = previous }
for id: ActionID in ["increaseWorkspaceTerminalFontSize", "decreaseWorkspaceTerminalFontSize", "resetWorkspaceTerminalFontSize"] {
#expect(services.registry.perform(id, invocation: ActionInvocation(origin: .user)))
}
#expect(notices.count == 3)
#expect(notices.allSatisfy { $0.0.contains(dotted) && !$0.1 })
#expect(services.agentChatFontSizeWrites.latest == nil)
#expect(settings.fileRoot.value(at: key) == nil)
#expect(try String(contentsOf: url, encoding: .utf8) == "{}")
}
}

@Test func aNonChatKeySurfaceDoesNotBorrowTheSelectedChat() async throws {
try await withSettings { services, settings, _ in
try await openChat(services)

// The pane is still selected, but a sidebar/native control owns the key event.
// The workspace-font action must stay on its terminal path instead of changing the
// selected chat's setting.
var invocation = ActionInvocation(origin: .user)
invocation.keyContext = [.rightSidebarFocused]
#expect(services.registry.perform("increaseWorkspaceTerminalFontSize", invocation: invocation))
try await Task.sleep(for: .milliseconds(50))
#expect(settings.fileRoot.value(at: AgentPaneDisplaySetting.fontSizePath) == nil)

invocation.keyContext = [.agentPaneFocused]
#expect(services.registry.perform("increaseWorkspaceTerminalFontSize", invocation: invocation))
try await PasswordManagerRouteTests.until({
settings.fileRoot.value(at: AgentPaneDisplaySetting.fontSizePath) == .number(15)
})
}
}

@Test func automationKeepsTheWorkspaceFontActionWhileAChatIsSelected() async throws {
try await withSettings { services, settings, _ in
try await openChat(services)
let key = AgentPaneDisplaySetting.fontSizePath
try await settings.set(.number(20), at: key)
for origin in ActionOrigin.allCases {
var invocation = ActionInvocation(origin: origin)
// A socket caller can claim user origin; it still is not an in-app gesture.
invocation.viaControlSocket = origin == .user || origin == .cli || origin == .mcp
for id: ActionID in ["increaseWorkspaceTerminalFontSize", "decreaseWorkspaceTerminalFontSize", "resetWorkspaceTerminalFontSize"] {
#expect(services.registry.perform(id, invocation: invocation))
#expect(services.agentChatFontSizeWrites.latest == nil,
"\(origin) must retain the workspace-terminal action")
#expect(settings.fileRoot.value(at: key) == .number(20))
}
}
}
}

@Test(arguments: [false, true]) func failedFontWritesReachTheKeyboardOrWaitingCaller(captured: Bool) async throws {
try await withSettings { services, settings, url in
try await openChat(services)
// A broken user file must remain untouched, and the failed write must not be silent.
try Data("{".utf8).write(to: url)
var notices: [(String, Bool)] = []
let previous = services.registry.refusalObserver
services.registry.refusalObserver = { notices.append(($0, $1)) }
defer { services.registry.refusalObserver = previous }
if captured {
let work = services.registry.capturingWork {
#expect(services.registry.perform("increaseWorkspaceTerminalFontSize", invocation: ActionInvocation(origin: .user)))
}
#expect(work.count == 1)
let task = try #require(work.first)
let failure = try #require(await task.value)
#expect(!failure.message.isEmpty)
#expect(notices.isEmpty, "the waiting caller receives the failure without a duplicate notice")
} else {
#expect(services.registry.perform("increaseWorkspaceTerminalFontSize", invocation: ActionInvocation(origin: .user)))
let task = try #require(services.agentChatFontSizeWrites.latest)
#expect(await task.value != nil)
try await PasswordManagerRouteTests.until({ notices.count == 1 }, report: { "a failed keyboard write showed \(notices.count) refusals" })
#expect(notices.first?.0.isEmpty == false && notices.first?.1 == false)
}
#expect(settings.fileRoot.value(at: AgentPaneDisplaySetting.fontSizePath) == nil)
#expect(try String(contentsOf: url, encoding: .utf8) == "{")
}
}

private func withSettings(managed: ManagedPreferences = ManagedPreferences(),
_ body: (AppServices, SettingsController, URL) async throws -> Void) async throws {
try await PasswordManagerRouteTests.withApp { services, _ in
services.agentTabs.localHost = AgentSessionRef.host(installID: "cn-chat-font-key")
try #require(services.agentTabs.canHostChat, "this build has no agent page")
let root = FileManager.default.temporaryDirectory.appending(path: "cn-chat-font-key-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let url = root.appending(path: "cmux.json")
try Data("{}".utf8).write(to: url)
let settings = SettingsController(registry: services.registry, design: DesignSettings(), fileURL: url,
managedReader: FixedManagedPreferenceReader(managed), managedWatchFiles: [])
await settings.reload()
services.settings = settings
try await body(services, settings, url)
}
}

private func openChat(_ services: AppServices) async throws {
let pane = try #require(services.windows.active?.focusedPane)
pane.newAgentTab()
try await PasswordManagerRouteTests.until({
guard let focused = services.windows.active?.focusedPane,
case .agent = focused.currentContent else { return false }
return true
}, report: { "focused content: \(String(describing: services.windows.active?.focusedPane?.currentContent))" })
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import Testing
})
}

static let actualSizes = ["browserZoomReset", "agentPaneZoomReset", "markdownZoomReset", "fileEditorZoomReset",
static let actualSizes = ["browserZoomReset", "markdownZoomReset", "fileEditorZoomReset",
"canvasZoomReset", "terminal.resetFontSize", "resetWorkspaceTerminalFontSize"]

/// Shipped defaults share keys by design (the Chrome tab keys and Cmd-1…9, Home's chat keys,
Expand Down
Loading
Loading