From 8316ec323b0e1fa103dc37af06cdd2ade9d72786 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:02:05 +0000 Subject: [PATCH 1/6] test(quit): a person's Cmd-Q with local terminals asks how to quit, Keep Sessions the default (QUIT P0, red) --- .../CmuxNextAppTests/QuitChoiceTests.swift | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/QuitChoiceTests.swift diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/QuitChoiceTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/QuitChoiceTests.swift new file mode 100644 index 000000000000..f39813ac04d5 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/QuitChoiceTests.swift @@ -0,0 +1,50 @@ +@testable import CmuxNextApp +import CmuxNextSettings +import Testing + +/// QUIT P0 (Lawrence 2026-11-01: "cmd q needs to prompt for what type of quit"). A person's Cmd-Q with local +/// terminals asks how to quit: Keep Sessions (the default, Return), End Sessions and Keep Layout, End Everything, and +/// Cancel, with "Don't ask again" writing `app.quitBehavior`. A remembered choice applies with no question, and a +/// script, a signal or power off never asks. +struct QuitChoiceTests { + private static let twoTerminals = QuitFacts(terminals: 2, programs: [], incognitoPrograms: [], remoteSessions: false) + + @Test func cmdQWithLocalTerminalsAsksWithKeepAsTheDefault() throws { + let decision = QuitPolicy.decide(.interactive, behavior: .ask, facts: Self.twoTerminals) + guard case .ask(let prompt) = decision else { + Issue.record("Cmd-Q with 2 local terminals and app.quitBehavior \"ask\" quit with no question: \(decision)") + return + } + #expect(prompt.offersSessionChoice, "the question did not offer the session choice") + #expect(prompt.defaultChoice == .keep, "the default (Return) is not Keep Sessions") + let content = QuitAlertContent.main(prompt) + #expect(content.buttons.map(\.rawValue) == ["keep", "end-keep-layout", "end-everything", "cancel"], + "the dialog's buttons, default first") + #expect(content.showsSuppression, "no \"Don't ask again\" check box") + } + + @Test(arguments: [(QuitBehavior.keep, QuitSessionsChoice.keep), (.endKeepLayout, .endKeepLayout), (.endEverything, .endEverything)]) + func aRememberedChoiceQuitsWithNoQuestion(behavior: QuitBehavior, choice: QuitSessionsChoice) { + #expect(QuitPolicy.decide(.interactive, behavior: behavior, facts: Self.twoTerminals) == .quit(choice)) + } + + @Test func noLocalTerminalNoQuestion() { + #expect(QuitPolicy.decide(.interactive, behavior: .ask, facts: .none) == .quit(.keep)) + } + + @Test func scriptsSignalsAndPowerOffNeverAsk() { + for origin in [QuitOrigin.scripted, .signal, .powerOff] { + #expect(QuitPolicy.decide(origin, behavior: .ask, facts: Self.twoTerminals) == .quit(.keep), "\(origin) asked") + } + } + + @Test func theUnsavedQuestionIsTheQuitsOnlyQuestion() { + #expect(QuitPolicy.decide(.interactive, behavior: .ask, facts: Self.twoTerminals, alreadyAsked: true) == .quit(.keep)) + } + + @Test func eachButtonAnswersItsChoice() { + #expect(QuitPolicy.remembered(.keep) == .keep) + #expect(QuitPolicy.remembered(.endKeepLayout) == .endKeepLayout) + #expect(QuitPolicy.remembered(.endEverything) == .endEverything) + } +} From 6bfec69a87152edac0fa0351c7c11c7c213c2939 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:02:06 +0000 Subject: [PATCH 2/6] fix(quit): Cmd-Q with local terminals asks how to quit: Keep Sessions (default), End Sessions Keep Layout, End Everything, Cancel; Don't ask again writes app.quitBehavior (QUIT P0) Lawrence 2026-11-01: "cmd q needs to prompt for what type of quit". Takes back aba72a3fbae (cx-7vu, 2026-10-11, Lawrence 2026-10-07 "no confirm dialogs"), which made QuitPolicy.decide quit with Keep and no question unless incognito programs ran, so app.quitBehavior "ask" (the default) never asked. --- .../Sources/CmuxNextApp/Quit/QuitAlert.swift | 33 +++++------ .../CmuxNextApp/Quit/QuitAlertContent.swift | 14 ++--- .../Sources/CmuxNextApp/Quit/QuitPolicy.swift | 25 ++++---- .../CmuxNextApp/Quit/QuitStrings.swift | 4 +- .../CmuxNextApp/Resources/Quit.xcstrings | 58 +++++++++---------- 5 files changed, 65 insertions(+), 69 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Quit/QuitAlert.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Quit/QuitAlert.swift index efc5b88b2eb9..bf9ff86ad63f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Quit/QuitAlert.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Quit/QuitAlert.swift @@ -3,9 +3,9 @@ import CmuxNextDesign /// The quit question as one cmux dialog (R96, R138: no system alerts; /// #17501: never a second step). "Quit cmux?" keeps the terminals by -/// default (Keep Sessions Running, Return), Cancel (Escape), and Quit -/// Everything ends them (no key). "Don't ask again" is a -/// check box. The dialog blocks the window `QuitCoordinator` picks, or +/// default (Keep Sessions Running, Return), Cancel (Escape), and End +/// Sessions, Keep Layout and End Everything end them (no key). "Don't ask +/// again" is a check box that writes `app.quitBehavior`. The dialog blocks the window `QuitCoordinator` picks, or /// shows app-wide when no window is open (the app host, which activates /// the app). @MainActor @@ -67,7 +67,7 @@ final class QuitAlert { switch id { case .keep, .quit: .default case .cancel: .cancel - case .confirmQuitEverything, .endEverything: .destructive + case .endKeepLayout, .endEverything: .destructive } } @@ -87,27 +87,20 @@ final class QuitAlert { finish(.quit(.keep, remember: false)) } - /// Clicks the button `id` ("keep", "quit", "cancel", - /// "confirm-quit-everything", "end-everything"; the old "end-keep-layout" - /// and "quit-everything" press Quit Everything). "end" opened the old - /// second step: it now only reports whether Quit Everything shows, so - /// scripts that press "end" and then an end choice still work; - /// "end-everything" answers End Everything, which has no button. False - /// when the dialog shown has no such choice. + /// Clicks the button `id` ("keep", "quit", "cancel", "end-keep-layout", + /// "end-everything"; the old "confirm-quit-everything" and + /// "quit-everything" press End Sessions, Keep Layout). "end" only reports + /// whether the end choices show, so older scripts that press "end" and + /// then an end choice still work. False when the dialog shown has no + /// such choice. @discardableResult func press(_ id: String) -> Bool { guard let dialogID else { return false } let ids = buttons.map(\.id) - let ends = ids.contains(QuitAlertContent.Button.confirmQuitEverything.rawValue) - if id == "end" { return ends } - if id == QuitAlertContent.Button.endEverything.rawValue { - guard ends else { return false } - finish(.quit(.endEverything, remember: false)) - return true - } + if id == "end" { return ids.contains(QuitAlertContent.Button.endKeepLayout.rawValue) } let resolved = switch id { case "quit" where !ids.contains("quit"): "keep" - case "end-keep-layout", "quit-everything": "confirm-quit-everything" + case "confirm-quit-everything", "quit-everything": QuitAlertContent.Button.endKeepLayout.rawValue default: id } return center.press(dialogID, button: resolved) @@ -125,7 +118,7 @@ final class QuitAlert { switch QuitAlertContent.Button(rawValue: answer.button) { case .keep: finish(.quit(.keep, remember: remembers)) case .quit: finish(.quit(prompt.defaultChoice, remember: remembers)) - case .confirmQuitEverything: finish(.quit(.endKeepLayout, remember: remembers)) + case .endKeepLayout: finish(.quit(.endKeepLayout, remember: remembers)) case .endEverything: finish(.quit(.endEverything, remember: remembers)) case .cancel, nil: finish(.cancel) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Quit/QuitAlertContent.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Quit/QuitAlertContent.swift index 8d150c2cfab3..c651da4ec1a2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Quit/QuitAlertContent.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Quit/QuitAlertContent.swift @@ -1,7 +1,7 @@ /// What the quit dialog says (R96, R138: a cmux dialog, minimal and plain). -/// One step (#17501): Keep Sessions Running is the default and Quit -/// Everything sits beside it, so a quit never opens a second dialog. End -/// Everything (also delete the workspaces) is the File menu's. +/// One step (#17501): Keep Sessions Running is the default (Return), and End +/// Sessions (Keep Layout), End Everything (also delete the workspaces) and +/// Cancel sit beside it, so a quit never opens a second dialog (QUIT P0). struct QuitAlertContent: Equatable { enum Button: String, Equatable { /// Keep Sessions Running (the default). @@ -10,8 +10,8 @@ struct QuitAlertContent: Equatable { case quit case cancel /// End every local terminal, keep the layout. - case confirmQuitEverything = "confirm-quit-everything" - /// Also delete the workspaces (no button; `debug.quit` presses it). + case endKeepLayout = "end-keep-layout" + /// End every local terminal and delete the local workspaces. case endEverything = "end-everything" } @@ -38,7 +38,7 @@ struct QuitAlertContent: Equatable { if prompt.runningPrograms > 0 { lines.append(QuitStrings.programsRunning(prompt.runningPrograms)) } if prompt.agentsInTurn > 0 { lines.append(QuitStrings.agentsWorking(prompt.agentsInTurn)) } if !prompt.incognitoPrograms.isEmpty { lines.append(QuitStrings.incognitoCloses) } - return QuitAlertContent(title: QuitStrings.title, lines: lines, buttons: [.keep, .cancel, .confirmQuitEverything], + return QuitAlertContent(title: QuitStrings.title, lines: lines, buttons: [.keep, .endKeepLayout, .endEverything, .cancel], showsSuppression: true) } @@ -47,7 +47,7 @@ struct QuitAlertContent: Equatable { case .keep: QuitStrings.keepSessionsRunning case .quit: ConfirmationStrings.quit case .cancel: ConfirmationStrings.cancel - case .confirmQuitEverything: QuitStrings.quitEverything + case .endKeepLayout: QuitStrings.endKeepLayout case .endEverything: QuitStrings.endEverything } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Quit/QuitPolicy.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Quit/QuitPolicy.swift index bd6f94a01826..17f21d6ea8ca 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Quit/QuitPolicy.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Quit/QuitPolicy.swift @@ -23,14 +23,17 @@ enum QuitPolicy { /// Whether the decision needs `QuitFacts` (read from the daemon). static func needsFacts(_ origin: QuitOrigin) -> Bool { origin == .interactive } - /// Quit at once with a choice, or ask. `facts` is read only for an - /// interactive quit. Quitting never asks about sessions (Lawrence - /// 2026-10-07, no "Quit cmux?"): Keep, the default, loses no work, - /// because local terminals and agents run in their daemons and reattach - /// on the next launch; a remembered end choice applies as set. The one - /// question left is incognito windows' running programs, which end with - /// the windows and cannot come back, and it is asked at most once - /// (#17501): never after the unsaved-changes question (`alreadyAsked`). + /// Quit at once with a choice, or ask (QUIT P0, Lawrence 2026-11-01: "cmd q + /// needs to prompt for what type of quit"). A person's quit (Cmd-Q, the + /// menu, the Dock) with local terminals and `app.quitBehavior` "ask" asks + /// how to quit: Keep Sessions (the default), End Sessions and Keep Layout, + /// End Everything, or Cancel. A remembered choice applies as set, with no + /// question. With no local terminal there is nothing to choose: Keep. The + /// incognito close confirmation (their programs end with their windows + /// and cannot come back) folds into the same question, or is the only one + /// when a remembered choice applies. Scripts, signals and power off never + /// ask. A quit asks at most once (#17501): never after the unsaved-changes + /// question (`alreadyAsked`). `facts` is read only for an interactive quit. static func decide(_ origin: QuitOrigin, behavior: QuitBehavior, facts: QuitFacts, alreadyAsked: Bool = false) -> QuitDecision { let remembered: QuitSessionsChoice? = switch behavior { case .ask: nil @@ -45,15 +48,15 @@ enum QuitPolicy { case .scripted: return .quit(remembered ?? .keep) case .interactive: break } - guard !alreadyAsked, !facts.incognitoPrograms.isEmpty else { return .quit(remembered ?? .keep) } - // The incognito close confirmation: Quit applies the remembered choice (else Keep). + let offersSessionChoice = remembered == nil && facts.terminals > 0 + guard !alreadyAsked, offersSessionChoice || !facts.incognitoPrograms.isEmpty else { return .quit(remembered ?? .keep) } return .ask(QuitPrompt( terminals: facts.terminals, runningPrograms: facts.programs.count, busiest: busiest(facts.programs), incognitoPrograms: facts.incognitoPrograms, remoteSessions: facts.remoteSessions, - offersSessionChoice: false, + offersSessionChoice: offersSessionChoice, defaultChoice: remembered ?? .keep, agents: facts.agents?.live, agentsInTurn: facts.agents?.inTurn ?? 0, diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Quit/QuitStrings.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Quit/QuitStrings.swift index 13586f3fa06d..3665e5564291 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Quit/QuitStrings.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Quit/QuitStrings.swift @@ -27,8 +27,8 @@ enum QuitStrings { String(localized: "quit.button.keep", defaultValue: "Keep Sessions Running", table: "Quit", bundle: .module) } - static var quitEverything: String { - String(localized: "quit.button.quitEverything", defaultValue: "Quit Everything", table: "Quit", bundle: .module) + static var endKeepLayout: String { + String(localized: "quit.button.endKeepLayout", defaultValue: "End Sessions, Keep Layout", table: "Quit", bundle: .module) } static var endEverything: String { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/Quit.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/Quit.xcstrings index f22266086c8a..cf09ac505f09 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/Quit.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/Quit.xcstrings @@ -2159,133 +2159,133 @@ } } }, - "quit.button.quitEverything": { + "quit.button.endKeepLayout": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Quit Everything" + "value": "End Sessions, Keep Layout" } }, "ar": { "stringUnit": { - "state": "needs_review", - "value": "إنهاء كل شيء" + "state": "translated", + "value": "إنهاء الجلسات مع إبقاء التخطيط" } }, "bs": { "stringUnit": { "state": "needs_review", - "value": "Zatvori sve" + "value": "Završi sesije, zadrži raspored" } }, "da": { "stringUnit": { "state": "needs_review", - "value": "Afslut alt" + "value": "Afslut sessioner, behold layout" } }, "de": { "stringUnit": { - "state": "needs_review", - "value": "Alles beenden" + "state": "translated", + "value": "Sitzungen beenden, Layout behalten" } }, "es": { "stringUnit": { - "state": "needs_review", - "value": "Salir de todo" + "state": "translated", + "value": "Finalizar sesiones y mantener el diseño" } }, "fr": { "stringUnit": { - "state": "needs_review", - "value": "Tout quitter" + "state": "translated", + "value": "Terminer les sessions, garder la disposition" } }, "it": { "stringUnit": { "state": "needs_review", - "value": "Esci da tutto" + "value": "Termina le sessioni, mantieni il layout" } }, "ja": { "stringUnit": { "state": "translated", - "value": "すべて終了" + "value": "セッションを終了してレイアウトを維持" } }, "km": { "stringUnit": { "state": "needs_review", - "value": "ចាកចេញទាំងអស់" + "value": "បញ្ចប់វគ្គ ហើយរក្សាប្លង់" } }, "ko": { "stringUnit": { - "state": "needs_review", - "value": "모두 종료" + "state": "translated", + "value": "세션 끝내고 레이아웃 유지" } }, "nb": { "stringUnit": { "state": "needs_review", - "value": "Avslutt alt" + "value": "Avslutt økter, behold oppsett" } }, "pl": { "stringUnit": { "state": "needs_review", - "value": "Zakończ wszystko" + "value": "Zamknij sesje, zachowaj układ" } }, "pt-BR": { "stringUnit": { "state": "needs_review", - "value": "Encerrar tudo" + "value": "Encerrar sessões e manter o layout" } }, "ru": { "stringUnit": { "state": "needs_review", - "value": "Завершить всё" + "value": "Завершить сеансы, сохранить макет" } }, "th": { "stringUnit": { "state": "needs_review", - "value": "ออกทั้งหมด" + "value": "จบเซสชัน คงเลย์เอาต์ไว้" } }, "tr": { "stringUnit": { "state": "needs_review", - "value": "Her Şeyden Çık" + "value": "Oturumları sonlandır, düzeni koru" } }, "uk": { "stringUnit": { "state": "needs_review", - "value": "Завершити все" + "value": "Завершити сеанси, зберегти макет" } }, "vi": { "stringUnit": { "state": "needs_review", - "value": "Thoát tất cả" + "value": "Kết thúc phiên, giữ bố cục" } }, "zh-Hans": { "stringUnit": { - "state": "needs_review", - "value": "全部退出" + "state": "translated", + "value": "结束会话,保留布局" } }, "zh-Hant": { "stringUnit": { - "state": "needs_review", - "value": "全部結束" + "state": "translated", + "value": "終止工作階段,保留版面" } } } From 325b2a3b2a4fd3580d5d8ffa264497d8965a0569 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 1 Nov 2026 02:48:34 +0000 Subject: [PATCH 3/6] test(dialog): buttons that do not fit on one line of the dialog card stack inside it, the default on top (QUIT P0 review, red) --- .../CmuxDialogButtonFitTests.swift | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/CmuxDialogButtonFitTests.swift diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/CmuxDialogButtonFitTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/CmuxDialogButtonFitTests.swift new file mode 100644 index 000000000000..73de1c77a68d --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/CmuxDialogButtonFitTests.swift @@ -0,0 +1,44 @@ +import AppKit +@testable import CmuxNextDesign +import Testing + +/// QUIT P0 review (#29984): a dialog whose buttons do not fit on one line of the 380 pt card (the quit choice: Keep +/// Sessions Running, End Sessions, Keep Layout, End Everything, Cancel) stacks them, the default on top, each the +/// card's width, as NSAlert does; no button is clipped or pushed off the card. Buttons that fit stay on one line. +@MainActor +struct CmuxDialogButtonFitTests { + private static let quitSpec = CmuxDialogSpec(title: "Quit cmux?", buttons: [ + CmuxDialogButton(id: "end-keep-layout", title: "End Sessions, Keep Layout", role: .destructive), + CmuxDialogButton(id: "end-everything", title: "End Everything", role: .destructive), + CmuxDialogButton(id: "cancel", title: "Cancel", role: .cancel), + CmuxDialogButton(id: "keep", title: "Keep Sessions Running", role: .default), + ]) + + @Test func buttonsThatDoNotFitStackInsideTheCardWithTheDefaultOnTop() { + let view = CmuxDialogView(spec: Self.quitSpec) + view.layoutSubtreeIfNeeded() + let card = view.bounds + #expect(card.width > 0 && card.height > 0) + let frames = view.buttonViews.map { ($0.button.id, $0.convert($0.bounds, to: view)) } + for (id, frame) in frames { + #expect(card.insetBy(dx: -0.5, dy: -0.5).contains(frame), "button \(id) at \(frame) is outside the card \(card)") + #expect(frame.width >= Self.fitting(view, id) - 0.5, "button \(id) is narrower than its title and key hint") + } + let top = frames.max { $0.1.midY < $1.1.midY } // AppKit: y grows upward + #expect(top?.0 == "keep", "the default is not the top button: \(frames.map(\.0))") + } + + @Test func buttonsThatFitStayOnOneLine() { + let view = CmuxDialogView(spec: CmuxDialogSpec(title: "Close tab?", buttons: [ + CmuxDialogButton(id: "cancel", title: "Cancel", role: .cancel), + CmuxDialogButton(id: "close", title: "Close", role: .default), + ])) + view.layoutSubtreeIfNeeded() + let ys = Set(view.buttonViews.map { $0.convert($0.bounds, to: view).midY.rounded() }) + #expect(ys.count == 1, "two short buttons were stacked") + } + + private static func fitting(_ view: CmuxDialogView, _ id: String) -> CGFloat { + view.buttonViews.first { $0.button.id == id }?.intrinsicContentSize.width ?? 0 + } +} From 1ac555cf68c55cd6020d28f47ee23aa1833c39ec Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 1 Nov 2026 02:48:34 +0000 Subject: [PATCH 4/6] fix(dialog): buttons that do not fit on one line stack, the default on top, each the card's width (the quit choice's four buttons); the quit scripts answer the quit dialog's Keep (QUIT P0 review) --- .../Dialog/CmuxDialogView.swift | 36 ++++++++++++++----- plans/cmux-next/quit-persistence.md | 6 ++++ scripts/cmux-next/dialogs-undo-live.py | 10 ++++-- scripts/cmux-next/quit-launch-bound.py | 11 ++++++ scripts/fleet-quit-persistence.sh | 16 +++++---- 5 files changed, 63 insertions(+), 16 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Dialog/CmuxDialogView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Dialog/CmuxDialogView.swift index 4f9909c969c6..ff886ca95f1e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Dialog/CmuxDialogView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Dialog/CmuxDialogView.swift @@ -192,16 +192,11 @@ public final class CmuxDialogView: NSView { for field in spec.fields { for view in fieldViews(field, width: contentWidth) { stack.addArrangedSubview(view) } } - let row = NSStackView() - row.orientation = .horizontal - row.spacing = Metrics.space2 - row.addArrangedSubview(NSView()) for button in spec.buttons { - let view = CmuxDialogButtonView(button, hint: CmuxDialogKeys.hint(for: button, in: spec), target: self, action: #selector(pressed(_:))) - buttonViews.append(view) - row.addArrangedSubview(view) + buttonViews.append(CmuxDialogButtonView(button, hint: CmuxDialogKeys.hint(for: button, in: spec), target: self, + action: #selector(pressed(_:)))) } - row.translatesAutoresizingMaskIntoConstraints = false + let row = Self.buttonRow(buttonViews, width: contentWidth) stack.addArrangedSubview(row) row.widthAnchor.constraint(equalToConstant: contentWidth).isActive = true focusables += buttonViews @@ -228,6 +223,31 @@ public final class CmuxDialogView: NSView { ]) } + /// The buttons on one line, right-aligned, when they fit in `width`; else stacked, each `width` wide and the + /// default on top, as NSAlert stacks buttons that do not fit (the quit choice's four buttons, a long locale). + /// Tab order follows the spec in both. + private static func buttonRow(_ buttons: [CmuxDialogButtonView], width: CGFloat) -> NSStackView { + let row = NSStackView() + row.translatesAutoresizingMaskIntoConstraints = false + row.spacing = Metrics.space2 + let needed = buttons.reduce(0) { $0 + $1.intrinsicContentSize.width } + + row.spacing * CGFloat(max(buttons.count - 1, 0)) + guard needed > width else { + row.orientation = .horizontal + row.addArrangedSubview(NSView()) + for button in buttons { row.addArrangedSubview(button) } + return row + } + row.orientation = .vertical + row.alignment = .leading + // The spec draws the primary button last (rightmost); stacked, it goes first (top). + for button in buttons.reversed() { + row.addArrangedSubview(button) + button.widthAnchor.constraint(equalToConstant: width).isActive = true + } + return row + } + /// Tab order closes on itself: the last control's next is the first. private func chainKeyViews() { for (index, view) in focusables.enumerated() { diff --git a/plans/cmux-next/quit-persistence.md b/plans/cmux-next/quit-persistence.md index 884479f91f16..bbb2f0f8947a 100644 --- a/plans/cmux-next/quit-persistence.md +++ b/plans/cmux-next/quit-persistence.md @@ -15,6 +15,12 @@ the "quit = plain NSAlert" decision; the default choice is Keep sessions running automated quit-and-relaunch proof runs on cmux-lawrence-2. The work is split between the dialogs lead and the durable-sessions owner. +Update 2026-11-01 (QUIT P0, #29984): from 2026-10-11 (cx-7vu, aba72a3fbae, Lawrence 2026-10-07 "no confirm dialogs") +until #29984, an interactive quit asked nothing and kept the sessions. Lawrence 2026-11-01 asked for the prompt again +("cmd q needs to prompt for what type of quit"): with local terminals and `app.quitBehavior` "ask", Cmd-Q asks Keep +Sessions Running (default) / End Sessions, Keep Layout / End Everything / Cancel in one cmux dialog (the four buttons +stack when they do not fit on one line); "Don't ask again" writes the choice. Scripts, signals and power off never ask. + Paths below are relative to `Packages/macOS/CmuxNext/Sources/` unless they start with `cmux-tui/`. ## 1. What happens today diff --git a/scripts/cmux-next/dialogs-undo-live.py b/scripts/cmux-next/dialogs-undo-live.py index aefad8798271..2aeedd59e216 100755 --- a/scripts/cmux-next/dialogs-undo-live.py +++ b/scripts/cmux-next/dialogs-undo-live.py @@ -203,13 +203,19 @@ def alive(pid): check("quit-setup-program", loop_pid, loop_pid) reply = rpc("debug.quit", {"open": True}) print("quit open:", json.dumps(reply)[:200]) + # QUIT P0: with a local terminal, Cmd-Q asks how to quit; its default (Keep Sessions) keeps the program running. asked = False deadline = time.time() + 20 while app.poll() is None and time.time() < deadline: q = rpc("debug.quit", timeout=2) or {} - asked = asked or q.get("asking") is True or any(d.get("identifier") == "cmux.dialog.quit" for d in dialogs()) + if q.get("asking") is True: + asked = True + rpc("debug.quit", {"press": "keep"}, timeout=5) + break time.sleep(0.25) - check("quit-no-prompt", not asked) + while app.poll() is None and time.time() < deadline: + time.sleep(0.25) + check("quit-asks", asked) check("quit-app-exits", app.poll() is not None, app.poll()) time.sleep(5) # test harness: past the quit, before the survival read check("quit-program-survives", loop_pid and alive(loop_pid), loop_pid) diff --git a/scripts/cmux-next/quit-launch-bound.py b/scripts/cmux-next/quit-launch-bound.py index 9fc60e7f9276..937ce9380931 100755 --- a/scripts/cmux-next/quit-launch-bound.py +++ b/scripts/cmux-next/quit-launch-bound.py @@ -361,6 +361,17 @@ def quit(self, mode, pages, out, label): if not ((armed.get("result") or {}).get("wedges_engine_stop")): raise SystemExit(f"debug.quit did not arm the engine-stop wedge: {armed}") reply = self.call("debug.quit", {"open": True}) + # QUIT P0: a quit with local terminals asks how to quit; answer its default (Keep Sessions), as Return. + ask_end = time.time() + 10 + while time.time() < ask_end: + try: + asking = self.call("debug.quit", {}).get("result") or {} + except (OSError, ValueError): + break # the app is gone or going: nothing asks + if asking.get("asking"): + self.call("debug.quit", {"press": "keep"}) + break + time.sleep(0.1) elif mode == "scripted": reply = self.call("action.run", {"action": "quit", "args": {}, "origin": "script"}) else: diff --git a/scripts/fleet-quit-persistence.sh b/scripts/fleet-quit-persistence.sh index 30484c96517d..279abd53fc6d 100755 --- a/scripts/fleet-quit-persistence.sh +++ b/scripts/fleet-quit-persistence.sh @@ -160,18 +160,22 @@ cli terminal list --json >"$out/pre-term.json" rpc_ok debug.windows '{}' >"$out/pre-windows.json" snapshot pre -# 5. Cmd-Q (keep, no question: cx-7vu) ------------------------------------------- -# A quit asks nothing about sessions: terminals and agents keep running in -# their daemons, so Cmd-Q quits at once with Keep. +# 5. Cmd-Q (asks how to quit; Keep Sessions is the default: QUIT P0) -------- +# With local terminals, Cmd-Q asks Keep Sessions / End Sessions, Keep Layout / +# End Everything / Cancel. Return (the default) keeps the terminals and agents +# running in their daemons. rpc_ok debug.quit '{"open":true}' >"$out/quit-open.json" asked=no for i in $(seq 1 15); do alive "$APP_PID" || break - rpc debug.quit '{}' 2 2>/dev/null | grep -q '"asking": *true' && asked=yes - rpc debug.dialog '{}' 2 2>/dev/null | grep -q '"identifier": *"cmux.dialog.quit"' && asked=yes + if rpc debug.quit '{}' 2 2>/dev/null | grep -q '"asking": *true'; then + asked=yes + rpc debug.quit '{"press":"keep"}' 5 >/dev/null 2>&1 || true + break + fi sleep 1 done -check quit-no-prompt "$(cond test "$asked" = no)" "$(tr -d '\n' <"$out/quit-open.json" | cut -c1-200)" +check quit-asks "$(cond test "$asked" = yes)" "$(tr -d '\n' <"$out/quit-open.json" | cut -c1-200)" check app-quits-on-keep "$(cond wait_exit "$APP_PID" 15)" # 6. Survive the reap grace ----------------------------------------------------- From 132fa6e0c54aa8e9c30a816604d1a55eea537ab9 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 1 Nov 2026 03:24:32 +0000 Subject: [PATCH 5/6] fix(quit): scripts press the quit dialog's default (keep) exactly and the second-quit check waits for the dialog; stacked dialog buttons tab top to bottom (review FIX on #29984) --- .../Sources/CmuxNextDesign/Dialog/CmuxDialogView.swift | 4 ++-- .../CmuxNextDesignTests/CmuxDialogButtonFitTests.swift | 7 +++++++ scripts/cmux-next/quit-hidden-chromium-live.py | 3 ++- scripts/cmux-next/quit-launch-bound.py | 4 ++-- scripts/fleet-quit-persistence.sh | 8 +++++++- 5 files changed, 20 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Dialog/CmuxDialogView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Dialog/CmuxDialogView.swift index ff886ca95f1e..758f2c59c2f2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Dialog/CmuxDialogView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Dialog/CmuxDialogView.swift @@ -199,7 +199,8 @@ public final class CmuxDialogView: NSView { let row = Self.buttonRow(buttonViews, width: contentWidth) stack.addArrangedSubview(row) row.widthAnchor.constraint(equalToConstant: contentWidth).isActive = true - focusables += buttonViews + // Tab follows what the person sees: left to right on one line, top to bottom when stacked. + focusables += row.orientation == .vertical ? buttonViews.reversed() : buttonViews chainKeyViews() let content = NSView() @@ -225,7 +226,6 @@ public final class CmuxDialogView: NSView { /// The buttons on one line, right-aligned, when they fit in `width`; else stacked, each `width` wide and the /// default on top, as NSAlert stacks buttons that do not fit (the quit choice's four buttons, a long locale). - /// Tab order follows the spec in both. private static func buttonRow(_ buttons: [CmuxDialogButtonView], width: CGFloat) -> NSStackView { let row = NSStackView() row.translatesAutoresizingMaskIntoConstraints = false diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/CmuxDialogButtonFitTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/CmuxDialogButtonFitTests.swift index 73de1c77a68d..23eaff85f86f 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/CmuxDialogButtonFitTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/CmuxDialogButtonFitTests.swift @@ -28,6 +28,13 @@ struct CmuxDialogButtonFitTests { #expect(top?.0 == "keep", "the default is not the top button: \(frames.map(\.0))") } + @Test func tabWalksTheStackedButtonsTopToBottom() { + let view = CmuxDialogView(spec: Self.quitSpec) + view.layoutSubtreeIfNeeded() + let order = view.focusables.compactMap { ($0 as? CmuxDialogButtonView)?.button.id } + #expect(order == ["keep", "cancel", "end-everything", "end-keep-layout"], "Tab order: \(order)") + } + @Test func buttonsThatFitStayOnOneLine() { let view = CmuxDialogView(spec: CmuxDialogSpec(title: "Close tab?", buttons: [ CmuxDialogButton(id: "cancel", title: "Cancel", role: .cancel), diff --git a/scripts/cmux-next/quit-hidden-chromium-live.py b/scripts/cmux-next/quit-hidden-chromium-live.py index a4ac93337ff1..fb3239d6bb32 100644 --- a/scripts/cmux-next/quit-hidden-chromium-live.py +++ b/scripts/cmux-next/quit-hidden-chromium-live.py @@ -1112,7 +1112,8 @@ def one_run(index): sheet = wait(lambda: (lambda r: r if "error" in r or r.get("asking") else None)(rpc("debug.quit", timeout=3) or {"error": 1}), 5) if sheet and sheet.get("asking"): buttons = [b.get("id") for b in sheet.get("buttons") or []] - choice = next((b for b in buttons if "keep" in str(b)), buttons[0] if buttons else None) + # The default (Keep Sessions Running, id "keep"), never another id that merely contains "keep". + choice = (sheet.get("prompt") or {}).get("default") or ("keep" if "keep" in buttons else (buttons[0] if buttons else None)) run.note(f"quit sheet asked ({buttons}); pressing {choice}") rpc("debug.quit", {"press": choice}, timeout=5) exited = wait(lambda: not alive(pid), 60, step=0.01) diff --git a/scripts/cmux-next/quit-launch-bound.py b/scripts/cmux-next/quit-launch-bound.py index 937ce9380931..6a4b742d5cb2 100755 --- a/scripts/cmux-next/quit-launch-bound.py +++ b/scripts/cmux-next/quit-launch-bound.py @@ -366,8 +366,8 @@ def quit(self, mode, pages, out, label): while time.time() < ask_end: try: asking = self.call("debug.quit", {}).get("result") or {} - except (OSError, ValueError): - break # the app is gone or going: nothing asks + except (OSError, ValueError, SystemExit): + break # the app is gone or going (app_client exits after its own timeout): nothing asks if asking.get("asking"): self.call("debug.quit", {"press": "keep"}) break diff --git a/scripts/fleet-quit-persistence.sh b/scripts/fleet-quit-persistence.sh index 279abd53fc6d..5cbadd380845 100755 --- a/scripts/fleet-quit-persistence.sh +++ b/scripts/fleet-quit-persistence.sh @@ -213,8 +213,14 @@ rpc_ok action.run '{"id":"selectWorkspaceByNumber","args":{"index":1}}' >/dev/nu snapshot post-terminal # 9. Variants (on the live app) --------------------------------------------------- -# A second Cmd-Q while the first completes is ignored; the first already keeps. +# A second Cmd-Q while the quit dialog shows answers its default (Keep). It is +# sent once the dialog shows: before that the quit is still reading its facts. rpc_ok debug.quit '{"open":true}' >/dev/null +for i in $(seq 1 15); do + rpc debug.quit '{}' 2 2>/dev/null | grep -q '"asking": *true' && break + alive "$APP_PID" || break + sleep 1 +done rpc_ok debug.quit '{"open":true}' >/dev/null || true check second-quit-keeps "$(cond wait_exit "$APP_PID" 15)" "app quit" check second-quit-daemon-alive "$(cond alive "$TUI_PID")" "daemon $TUI_PID" From b3c6d56f9f23b6693d9da3172c3314bdb3267c9d Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:27:14 +0000 Subject: [PATCH 6/6] test(quit): QuitEndEverythingGuardTests' Ask case now expects the session question (Ask is not a remembered choice; QUIT P0), the remembered-choice cases unchanged --- .../CmuxNextAppTests/QuitEndEverythingGuardTests.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/QuitEndEverythingGuardTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/QuitEndEverythingGuardTests.swift index 64977876597c..6364651ec804 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/QuitEndEverythingGuardTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/QuitEndEverythingGuardTests.swift @@ -54,8 +54,14 @@ import Testing @Test func theOtherRememberedChoicesStillQuitAtOnce() { #expect(QuitPolicy.decide(.interactive, behavior: .endKeepLayout, facts: Self.facts) == .quit(.endKeepLayout)) #expect(QuitPolicy.decide(.interactive, behavior: .keep, facts: Self.facts) == .quit(.keep)) - #expect(QuitPolicy.decide(.interactive, behavior: .ask, facts: Self.facts) == .quit(.keep)) #expect(QuitPolicy.decide(.powerOff, behavior: .endEverything, facts: Self.facts) == .quit(.keep)) #expect(QuitPolicy.decide(.signal, behavior: .endEverything, facts: Self.facts) == .quit(.keep)) + // Ask is not a remembered choice: with local terminals a person's quit asks how to quit, Keep the + // default (QUIT P0, #29984: Lawrence 2026-11-01 "cmd q needs to prompt for what type of quit"). + guard case .ask(let prompt) = QuitPolicy.decide(.interactive, behavior: .ask, facts: Self.facts) else { + Issue.record("Ask with local terminals quit with no question") + return + } + #expect(prompt.offersSessionChoice && prompt.defaultChoice == .keep && !prompt.confirmsEndEverything) } }