From b3bd61e867b8a4b164cbb60b83134ec497a7bb84 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:44:36 +0800 Subject: [PATCH 1/2] feat(settings): let the last-window close confirmation be turned off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing the last window pops "Close Window?" every time. The prompt was only ever teaching, not protection — Cmd-Q, the tray's Quit and the palette's Quit all leave without asking, and nothing is lost either way since the panes keep running in the daemon. Once the user knows that, being asked on every quit is friction. Adds `confirm_window_close` (default true, so nothing changes for existing configs) and a Window & Tabs toggle. Off makes the last window close like any other: detach the workspace, quit. --- src/core/config.rs | 29 +++++++++++++++++++++++++++++ src/ui/app.rs | 27 ++++++++++++++++++++------- src/ui/settings.rs | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 7 deletions(-) diff --git a/src/core/config.rs b/src/core/config.rs index 02a2ee30..25f508ca 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -184,6 +184,17 @@ pub struct Config { /// `true` by that hint; there is no UI to reset it (nor a reason to). #[serde(default)] pub workspace_detach_hint_seen: bool, + /// Ask before closing the *last* window (the close that also quits the app). + /// On by default, which is the behavior every build so far has had. + /// + /// The prompt was only ever a teaching device, not a safety net: ⌘Q, the + /// tray's Quit and the palette's Quit all leave without asking, and nothing + /// is lost either way — the panes keep running in the daemon. So once the + /// user has learned that (Settings states it permanently under "How sessions + /// work"), being asked on every quit is pure friction. Off makes the last + /// window close exactly like any other: detach the workspace, quit. + #[serde(default = "default_true")] + pub confirm_window_close: bool, /// How the terminal bell (BEL / `^G`) is signalled. Defaults to a brief /// visual flash (the current behavior). #[serde(default, deserialize_with = "de_lenient")] @@ -619,6 +630,7 @@ impl Default for Config { restore_session: true, show_tray_icon: true, workspace_detach_hint_seen: false, + confirm_window_close: true, // Visual flash preserves the pre-config behavior (the bell always // flashed); opting into None/Audible is a deliberate change. bell: BellMode::Visual, @@ -1037,6 +1049,23 @@ mod tests { assert_eq!(back.ssh_profile_frecency.get(&id).unwrap().count, 4); } + /// Opt-*out*, unlike most flags here: a config written before this setting + /// existed must keep the prompt, or an update would silently take away the + /// one thing telling people their sessions survive a quit. + #[test] + fn confirm_window_close_defaults_on_and_round_trips() { + assert!(Config::default().confirm_window_close); + + let old: Config = serde_json::from_str(r#"{"font_size": 15.0}"#).unwrap(); + assert!(old.confirm_window_close); + + let off: Config = serde_json::from_str(r#"{"confirm_window_close": false}"#).unwrap(); + assert!(!off.confirm_window_close); + let json = serde_json::to_string(&off).unwrap(); + let back: Config = serde_json::from_str(&json).unwrap(); + assert!(!back.confirm_window_close); + } + #[test] fn theme_follow_system_defaults_and_round_trips() { // Old configs (no follow-system keys) must land on off + the built-in diff --git a/src/ui/app.rs b/src/ui/app.rs index 6772af2c..3948907d 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -994,10 +994,14 @@ impl Tty7App { // // The last window is different: closing it also quits the app (a // windowless process left in the Dock no longer responds to being - // clicked — #147), so that one keeps the reassuring prompt. We veto the - // immediate close (return `false`), show it, and quit only if the user - // picks "Close"; a one-shot flag lets that post-confirm close through - // instead of looping the prompt. + // clicked — #147), so that one keeps the reassuring prompt by default. + // We veto the immediate close (return `false`), show it, and quit only + // if the user picks "Close"; a one-shot flag lets that post-confirm + // close through instead of looping the prompt. + // + // `confirm_window_close` turns the prompt off for users who have learned + // the model — it is teaching, not protection (⌘Q never asked), so it has + // to be escapable. let close_confirmed = std::rc::Rc::new(std::cell::Cell::new(false)); let weak_app = cx.weak_entity(); window.on_window_should_close(cx, move |window, cx| { @@ -1009,9 +1013,11 @@ impl Tty7App { .upgrade() .is_some_and(|app| app.read(cx).tabs.is_empty()); - // Any window but the last, or an empty one with nothing to - // reassure about: detach and go. Prompting here would be friction. - if !last_window || empty { + // Any window but the last, an empty one with nothing to reassure + // about, or a user who has turned the prompt off: detach and go. + // Prompting here would be friction. + let confirm = cx.global::().confirm_window_close; + if !last_window || empty || !confirm { if let Some(app) = weak_app.upgrade() { app.update(cx, |app, cx| app.detach_workspace(cx)); } @@ -2522,6 +2528,13 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.show_tray_icon = on); } + /// Toggle the "Close Window?" prompt on the last window. The close handler + /// reads the flag when it fires, so this applies to the very next ⌘W with no + /// restart and nothing to push to open windows. + pub(crate) fn set_confirm_window_close(&mut self, on: bool, cx: &mut Context) { + self.update_config(cx, |cfg| cfg.confirm_window_close = on); + } + // ── Input / Mouse setters ─────────────────────────────────────────────── /// Takes effect on the next keystroke — the terminal reads the flag per diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 76e3aad5..f1725431 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -346,6 +346,11 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: "Restore last layout", keywords: "restore session previous tabs splits reopen launch startup layout", }, + SearchEntry { + section: WindowTabs, + title: "Confirm before closing the last window", + keywords: "close quit confirm prompt dialog ask again warn last window cmd-w", + }, SearchEntry { section: WindowTabs, title: "Show tray icon", @@ -3342,6 +3347,7 @@ impl Tty7App { let restore_session = cfg.restore_session; let remember_window_size = cfg.remember_window_size; let show_tray_icon = cfg.show_tray_icon; + let confirm_window_close = cfg.confirm_window_close; let tab_bar_idx = match cfg.tab_bar_position { TabBarPosition::Top => 0, TabBarPosition::Left => 1, @@ -3406,6 +3412,10 @@ impl Tty7App { .checked(remember_window_size) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_remember_window_size(*on, cx))) .into_any_element(); + let confirm_close_switch = Switch::new("wt-confirm-window-close") + .checked(confirm_window_close) + .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_confirm_window_close(*on, cx))) + .into_any_element(); let tray_switch = Switch::new("wt-tray-icon") .checked(show_tray_icon) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_show_tray_icon(*on, cx))) @@ -3491,6 +3501,16 @@ impl Tty7App { restore_switch, cx, )) + // Phrased around what stays true either way: the prompt is there to + // teach that closing isn't ending, so the row that turns it off is + // the last chance to say so. + .child(self.settings_row( + "Confirm before closing the last window", + "Ask first, since that close also quits tty7. Off closes straight away — \ + either way your shells keep running in the background.", + confirm_close_switch, + cx, + )) .child(self.settings_row( "Show tray icon", "Keep a status item in the system tray / menu bar: it signals when a \ @@ -4462,6 +4482,22 @@ mod tests { } } + /// The close-confirmation toggle is the one people go looking for *after* + /// the dialog has annoyed them, so it has to be reachable by what they'd + /// type in that moment — not just by its own title. + #[test] + fn close_confirmation_toggle_is_findable() { + // Not a bare "confirm": SSH's own close warning owns that word just as + // legitimately, and the nav's per-section counts are what disambiguate. + for query in ["ask again", "closing the last window", "dialog", "cmd-w"] { + assert_eq!( + best_matching_section(query).map(|s| s.profile_label()), + Some(SettingsSection::WindowTabs.profile_label()), + "query {query:?} should land on Window & Tabs" + ); + } + } + /// The index names rows, so a title that no longer matches the rendered row /// sends the user to the right page and then leaves them hunting. This /// pins the ones that had drifted (the index said "Working directory"; the @@ -4471,6 +4507,7 @@ mod tests { for title in [ "Start in", "Restore last layout", + "Confirm before closing the last window", "Terminal bell", "Report mouse to apps", "Open files with", From 64744fce61f5aac02456b5dbfe724e25eb195ffb Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 27 Jul 2026 10:50:16 +0800 Subject: [PATCH 2/2] test(config): pin that an unknown key can't silently re-enable the prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Config::load` turns any parse error into *defaults*, so a config that fails to deserialize doesn't fall back field-by-field — it comes back with `confirm_window_close: true` and nothing said. The struct has no `deny_unknown_fields` today; this pins that, since the opt-out is exactly the setting whose silent reversal nobody would notice. Also index the Windows/Linux spelling of the chord: the prompt is reached by Ctrl-W off macOS, and search only knew "cmd-w". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WCb8ZDmvdA5xbVtvs647tD --- src/core/config.rs | 8 ++++++++ src/ui/settings.rs | 13 +++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/core/config.rs b/src/core/config.rs index 25f508ca..01efaed6 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -1064,6 +1064,14 @@ mod tests { let json = serde_json::to_string(&off).unwrap(); let back: Config = serde_json::from_str(&json).unwrap(); assert!(!back.confirm_window_close); + + // ...and a key this build has never heard of — a config last written by + // a newer tty7, or hand-edited — must be ignored rather than failing the + // whole parse, which `Config::load` would swallow into *defaults*: the + // opt-out would come back on with nothing said. + let newer: Config = + serde_json::from_str(r#"{"confirm_window_close": false, "not_a_setting": 7}"#).unwrap(); + assert!(!newer.confirm_window_close); } #[test] diff --git a/src/ui/settings.rs b/src/ui/settings.rs index e463c688..5aa52d0d 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -347,7 +347,10 @@ fn settings_search_entries() -> &'static [SearchEntry] { SearchEntry { section: WindowTabs, title: "Confirm before closing the last window", - keywords: "close quit confirm prompt dialog ask again warn last window cmd-w", + // Both spellings of the chord: the prompt this turns off is reached + // by ⌘W on macOS and Ctrl-W everywhere else, and the user types + // whichever one their own keyboard just used. + keywords: "close quit confirm prompt dialog ask again warn last window cmd-w ctrl-w", }, SearchEntry { section: WindowTabs, @@ -4578,7 +4581,13 @@ mod tests { fn close_confirmation_toggle_is_findable() { // Not a bare "confirm": SSH's own close warning owns that word just as // legitimately, and the nav's per-section counts are what disambiguate. - for query in ["ask again", "closing the last window", "dialog", "cmd-w"] { + for query in [ + "ask again", + "closing the last window", + "dialog", + "cmd-w", + "ctrl-w", + ] { assert_eq!( best_matching_section(query).map(|s| s.profile_label()), Some(SettingsSection::WindowTabs.profile_label()),