Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
35 changes: 13 additions & 22 deletions Packages/macOS/CmuxNext/Sources/CmuxNextApp/Quit/QuitAlert.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -67,7 +67,7 @@ final class QuitAlert {
switch id {
case .keep, .quit: .default
case .cancel: .cancel
case .confirmQuitEverything, .endEverything: .destructive
case .endKeepLayout, .endEverything: .destructive
}
}

Expand All @@ -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)
Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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).
Expand All @@ -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"
}

Expand Down Expand Up @@ -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)
}

Expand All @@ -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
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": "終止工作階段,保留版面"
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The horizontal row includes a leading spacer, so it needs buttons.count spacing gaps, not buttons.count - 1; near the cutoff, buttons that do not fit stay on one line and may be compressed or overflow. Include the spacer's gap in the fit calculation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Dialog/CmuxDialogView.swift, line 234:

<comment>The horizontal row includes a leading spacer, so it needs `buttons.count` spacing gaps, not `buttons.count - 1`; near the cutoff, buttons that do not fit stay on one line and may be compressed or overflow. Include the spacer's gap in the fit calculation.</comment>

<file context>
@@ -228,6 +223,31 @@ public final class CmuxDialogView: NSView {
+        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
</file context>
Suggested change
+ row.spacing * CGFloat(max(buttons.count - 1, 0))
+ + row.spacing * CGFloat(buttons.count)

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() {
Expand Down
Loading
Loading