diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Quit/QuitAlert.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Quit/QuitAlert.swift index aea7fa8e751a..1cc7ee96db64 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,29 +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 endEverything = QuitAlertContent.Button.endEverything.rawValue - let ends = ids.contains(QuitAlertContent.Button.confirmQuitEverything.rawValue) || ids.contains(endEverything) - if id == "end" { return ends } - if id == endEverything { - if ids.contains(endEverything) { return center.press(dialogID, button: id) } - guard ends else { return false } - finish(.quit(.endEverything, remember: false)) - return true - } + if id == "end" { return ids.contains(QuitAlertContent.Button.endKeepLayout.rawValue) || ids.contains("end-everything") } 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) @@ -127,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 2744cff14f80..d88585f2d86e 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,10 +10,10 @@ struct QuitAlertContent: Equatable { case quit case cancel /// End every local terminal, keep the layout. - case confirmQuitEverything = "confirm-quit-everything" - /// Also delete the workspaces: a button only when a remembered End - /// Everything asks (`QuitPrompt.confirmsEndEverything`); else - /// `debug.quit` presses it. + case endKeepLayout = "end-keep-layout" + /// End every local terminal and delete the local workspaces (its own + /// button in the session choice, and when a remembered End Everything + /// asks: `QuitPrompt.confirmsEndEverything`). case endEverything = "end-everything" } @@ -46,7 +46,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) } @@ -55,7 +55,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/QuitStrings.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Quit/QuitStrings.swift index 7bc05993216c..2e09107d323d 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 ca6230fe7737..5a84256a17b3 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": "終止工作階段,保留版面" } } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/QuitRules.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/QuitRules.swift index 1b5b78dc2cf5..eac14d706c55 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/QuitRules.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/QuitRules.swift @@ -123,14 +123,17 @@ public struct QuitRules { /// Quit at once with a choice, or ask. `remembered` is the setting's /// choice (nil for 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, except End Everything, which - /// asks first (it deletes every workspace). The one other question 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`). + /// A person's quit (Cmd-Q, the menu, the Dock) with local terminals and + /// Ask asks how to quit (QUIT P0, Lawrence 2026-11-01: "cmd q needs to + /// prompt for what type of quit"; this takes back the 2026-10-07 "no + /// Quit cmux?"): Keep Sessions Running (the default), End Sessions, Keep + /// Layout, End Everything, or Cancel. A remembered choice applies as + /// set, except End Everything, which asks first (it deletes every + /// workspace). With no local terminal there is nothing to choose. The + /// incognito close confirmation (their programs end with their windows) + /// folds into the same question, or is the only one when a remembered + /// choice applies. A quit asks at most once (#17501): never after the + /// unsaved-changes question (`alreadyAsked`). public static func decide(_ origin: QuitOrigin, remembered: QuitSessionsChoice?, facts: QuitFacts, alreadyAsked: Bool = false) -> QuitDecision { switch origin { @@ -147,9 +150,11 @@ public struct QuitRules { guard !alreadyAsked else { return .quit(.endKeepLayout) } return .ask(prompt(facts, offersSessionChoice: true, defaultChoice: .keep, confirmsEndEverything: true)) } - guard !alreadyAsked, !facts.incognitoPrograms.isEmpty else { return .quit(remembered ?? .keep) } - // The incognito close confirmation: Quit applies the remembered choice (else Keep). - return .ask(prompt(facts, offersSessionChoice: false, defaultChoice: remembered ?? .keep, confirmsEndEverything: false)) + let offersSessionChoice = remembered == nil && facts.terminals > 0 + guard !alreadyAsked, offersSessionChoice || !facts.incognitoPrograms.isEmpty else { return .quit(remembered ?? .keep) } + // The session choice, or (a remembered choice) the incognito close confirmation: Quit applies it. + return .ask(prompt(facts, offersSessionChoice: offersSessionChoice, defaultChoice: remembered ?? .keep, + confirmsEndEverything: false)) } /// A remembered choice for a quit that cannot ask (`cmux app quit` with diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Dialog/CmuxDialogView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Dialog/CmuxDialogView.swift index 4f9909c969c6..758f2c59c2f2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Dialog/CmuxDialogView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Dialog/CmuxDialogView.swift @@ -192,19 +192,15 @@ 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 + // 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() @@ -228,6 +224,30 @@ 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). + 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/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/QuitChoiceTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/QuitChoiceTests.swift new file mode 100644 index 000000000000..e9c474d7fed7 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/QuitChoiceTests.swift @@ -0,0 +1,52 @@ +@testable import CmuxNextApp +import CmuxNextDaemonService +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 Keep or End Sessions applies with no +/// question (a remembered End Everything asks first: QuitEndEverythingGuardTests), 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)]) + 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) + } +} 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) } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/CmuxDialogButtonFitTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/CmuxDialogButtonFitTests.swift new file mode 100644 index 000000000000..23eaff85f86f --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/CmuxDialogButtonFitTests.swift @@ -0,0 +1,51 @@ +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 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), + 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 + } +} 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-hidden-chromium-live.py b/scripts/cmux-next/quit-hidden-chromium-live.py index a4ac93337ff1..f1dcfc872060 100644 --- a/scripts/cmux-next/quit-hidden-chromium-live.py +++ b/scripts/cmux-next/quit-hidden-chromium-live.py @@ -1112,7 +1112,9 @@ 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) + # Keep Sessions Running (id "keep") when shown, else the primary button (drawn last: Quit on the incognito + # confirmation); never another id that merely contains "keep". + choice = "keep" if "keep" in buttons else (buttons[-1] 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 9fc60e7f9276..6a4b742d5cb2 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, 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 + 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..5cbadd380845 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 ----------------------------------------------------- @@ -209,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"