You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/09/24 03:01:44 UTC

(maka) 02/03: feat(tui): safely stop Host on quit with explicit force confirmation

This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 3ee7d4507343f8c6a148e5e66cd620d3c99a23d3
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Thu Sep 24 10:57:47 2026 +0800

    feat(tui): safely stop Host on quit with explicit force confirmation
    
    Generated-by: Codex
---
 crates/cli/src/args.rs                             |   1 +
 crates/cli/src/deployment.rs                       |   2 +
 crates/cli/src/deployment/shutdown.rs              | 110 ++++++++
 crates/cli/tests/integration/tui.rs                |  16 +-
 crates/cli/tests/integration/tui/attachments.rs    |   4 +-
 crates/cli/tests/integration/tui/branch.rs         |   4 +-
 crates/cli/tests/integration/tui/changes.rs        |   4 +-
 .../cli/tests/integration/tui/connection_test.rs   |   2 +-
 crates/cli/tests/integration/tui/connections.rs    |   4 +-
 crates/cli/tests/integration/tui/credentials.rs    |   2 +-
 crates/cli/tests/integration/tui/enabled_models.rs |   2 +-
 crates/cli/tests/integration/tui/environment.rs    |   2 +-
 crates/cli/tests/integration/tui/extensions.rs     |   6 +-
 crates/cli/tests/integration/tui/forms.rs          |   2 +-
 crates/cli/tests/integration/tui/imports.rs        |   2 +-
 crates/cli/tests/integration/tui/management.rs     |   2 +-
 crates/cli/tests/integration/tui/model_fetch.rs    |   2 +-
 crates/cli/tests/integration/tui/model_profiles.rs |   2 +-
 crates/cli/tests/integration/tui/models.rs         |   2 +-
 crates/cli/tests/integration/tui/navigation.rs     |   6 +-
 crates/cli/tests/integration/tui/oauth.rs          |   4 +-
 crates/cli/tests/integration/tui/onboarding.rs     |   2 +-
 crates/cli/tests/integration/tui/projects.rs       |   4 +-
 crates/cli/tests/integration/tui/queue.rs          |   2 +-
 crates/cli/tests/integration/tui/reading.rs        |   4 +-
 crates/cli/tests/integration/tui/recap.rs          |   4 +-
 crates/cli/tests/integration/tui/recovery.rs       |   4 +-
 crates/cli/tests/integration/tui/references.rs     |   4 +-
 crates/cli/tests/integration/tui/removal.rs        |   4 +-
 crates/cli/tests/integration/tui/revision.rs       |   6 +-
 crates/cli/tests/integration/tui/sandbox.rs        |   2 +-
 crates/cli/tests/integration/tui/scheduler.rs      |   9 +-
 .../tests/integration/tui/scheduler/recovery.rs    |   2 +-
 crates/cli/tests/integration/tui/shutdown.rs       | 178 +++++++++++++
 crates/cli/tests/integration/tui/skills.rs         |   4 +-
 crates/cli/tests/integration/tui/startup.rs        |  36 +++
 crates/cli/tests/integration/tui/stopping.rs       |   2 +-
 crates/cli/tests/integration/tui/themes.rs         |   6 +-
 crates/tui/locales/en.ftl                          |   8 +-
 crates/tui/locales/zh-CN.ftl                       |   8 +-
 crates/tui/locales/zh-TW.ftl                       |   8 +-
 crates/tui/src/app.rs                              |  30 ++-
 crates/tui/src/lib.rs                              |  75 +++++-
 crates/tui/src/shutdown.rs                         | 284 +++++++++++++++++++++
 crates/tui/src/view.rs                             |  21 +-
 45 files changed, 815 insertions(+), 73 deletions(-)

diff --git a/crates/cli/src/args.rs b/crates/cli/src/args.rs
index 7fcbab5bd5..b6f25f7120 100644
--- a/crates/cli/src/args.rs
+++ b/crates/cli/src/args.rs
@@ -94,6 +94,7 @@ impl Tui {
                 profile: self.profile.unwrap_or_else(|| "default".into()),
             },
             crate::deployment::connect_local,
+            crate::deployment::shutdown_local,
         )
         .await
     }
diff --git a/crates/cli/src/deployment.rs b/crates/cli/src/deployment.rs
index be99f263c1..1b76a05a84 100644
--- a/crates/cli/src/deployment.rs
+++ b/crates/cli/src/deployment.rs
@@ -30,6 +30,7 @@ mod policy;
 mod query;
 mod service;
 mod setup;
+mod shutdown;
 mod store;
 mod update;
 mod updates;
@@ -45,6 +46,7 @@ pub(super) use policy::{
 };
 pub(super) use query::Status;
 pub(super) use setup::Setup;
+pub(super) use shutdown::shutdown as shutdown_local;
 pub(super) use update::{Expected, Update};
 
 use clap::{Args, ValueEnum};
diff --git a/crates/cli/src/deployment/shutdown.rs b/crates/cli/src/deployment/shutdown.rs
new file mode 100644
index 0000000000..38963d0d8e
--- /dev/null
+++ b/crates/cli/src/deployment/shutdown.rs
@@ -0,0 +1,110 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::{directory, service, store};
+use crate::host_client::HostClient;
+use maka_event_log::root::{self, FileLease, RootNamespaces, RootOwner};
+use maka_protocol::{
+    Operation,
+    host::{RetirementInput, RetirementResult, decode_retirement_result},
+};
+use maka_runtime_host::server::HostError;
+use maka_tui::{ShutdownOutcome, ShutdownRequest};
+use std::{io, sync::Arc, time::Duration};
+
+pub async fn shutdown(request: ShutdownRequest) -> Result<ShutdownOutcome, HostError> {
+    crate::operation::without_progress(async {
+        tokio::time::timeout(Duration::from_secs(30), stop(request)).await?
+    })
+    .await
+}
+
+async fn stop(request: ShutdownRequest) -> Result<ShutdownOutcome, HostError> {
+    let root = root::resolve(&request.root)?;
+    if root.root_id() != request.identity.root_id {
+        return Err("State Root changed before shutdown".into());
+    }
+    let directory = directory(root.root_id())?;
+    root::private_directory(&directory)?;
+    // Startup, service control and upgrades cannot race this shutdown.
+    let lease = Arc::new(FileLease::acquire(&directory.join("executor.lock"))?);
+    let deployment = match store::read(&directory).await? {
+        store::Installation::Installed(deployment) => {
+            deployment.validate(&root, &directory)?;
+            Some(deployment)
+        }
+        store::Installation::Missing => None,
+        store::Installation::Incomplete => return Err("Host deployment is incomplete".into()),
+    };
+    let namespaces = RootNamespaces::for_current_account()?;
+    let owner = match RootOwner::open(&request.root, &namespaces) {
+        Ok(owner) => owner,
+        Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
+            let generation = deployment.as_ref().map(|d| d.generation());
+            let mut operator = HostClient::connect(&request.root, generation.as_deref()).await?;
+            if operator.root_id() != request.identity.root_id {
+                return Err("Host identity changed before shutdown".into());
+            }
+            // This is an exit, not an upgrade: do not arrange future task resumption.
+            // The Host checks work/other clients and seals admission atomically.
+            let input = RetirementInput {
+                expected_host_epoch: request.identity.host_epoch.clone(),
+                allow_interrupt_active_tasks: request.interrupt,
+                allow_cooperative_handoff: Some(false),
+                allow_idle_connections: Some(false),
+                handoff_connection_id: Some(request.identity.connection_id.clone()),
+            };
+            let result = operator
+                .request(Operation::HostUpgradePrepare, serde_json::to_value(input)?)
+                .await?;
+            if decode_retirement_result(&result)? == RetirementResult::ActiveTasks {
+                return Ok(ShutdownOutcome::Busy);
+            }
+            drop(operator);
+            // The receipt only admits shutdown. Root release proves owned work
+            // and storage have drained; do not replay an uncertain stop request.
+            loop {
+                match RootOwner::open(&request.root, &namespaces) {
+                    Ok(owner) => break owner,
+                    Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
+                        if let Ok(live) = maka_client::local::read_discovery(&request.root)
+                            && live.host_epoch != request.identity.host_epoch
+                        {
+                            return Err("Another Host started during shutdown".into());
+                        }
+                        tokio::time::sleep(Duration::from_millis(25)).await;
+                    }
+                    Err(error) => return Err(error.into()),
+                }
+            }
+        }
+        Err(error) => return Err(error.into()),
+    };
+    if owner.root_id() != request.identity.root_id
+        || owner.canonical_path() != root.canonical_path()
+    {
+        return Err("State Root changed during shutdown".into());
+    }
+    owner.validate_current()?;
+    if let Some(deployment) = deployment {
+        service::stop(deployment, lease.clone(), Arc::new(owner)).await?;
+    }
+    lease.validate()?;
+    Ok(ShutdownOutcome::Stopped)
+}
diff --git a/crates/cli/tests/integration/tui.rs b/crates/cli/tests/integration/tui.rs
index db712d46e2..20a9679138 100644
--- a/crates/cli/tests/integration/tui.rs
+++ b/crates/cli/tests/integration/tui.rs
@@ -49,6 +49,7 @@ mod removal;
 mod revision;
 mod sandbox;
 mod scheduler;
+mod shutdown;
 mod skills;
 mod startup;
 mod stopping;
@@ -160,7 +161,7 @@ fn real_pty_default_entry_routes_mouse_modal_resize_and_restores_terminal() {
     tui.wait_until(|screen| !screen.contains("▤ Workspace") && screen.contains("⛭"));
     tui.click_text("◉");
     tui.wait_for("refusing to initialize a nonempty State Root");
-    tui.send(b"\x11"); // Ctrl+Q
+    tui.close_terminal();
     tui.finish();
     let mut termios = unsafe { std::mem::zeroed::<libc::termios>() };
     assert_eq!(
@@ -644,14 +645,14 @@ fn real_host_catalog_subscription_and_remote_updates_reach_clients() {
     tui.send(b"\x0e"); // Ctrl+N: create using this process's workspace.
     tui.wait_for("New conversation");
     tui.wait_for("No messages yet.");
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     assert!(
         runtime
             .block_on(client.session(&second[0].id))
             .unwrap()
             .is_some(),
-        "quitting the TUI must leave the Host running"
+        "closing a terminal must leave its separately owned Host running"
     );
     client.disconnect();
     host.retire_registered();
@@ -934,6 +935,15 @@ impl Pty {
     fn send(&mut self, bytes: &[u8]) {
         self.master.write_all(bytes).unwrap();
     }
+    fn close_terminal(&mut self) {
+        // These fixtures own their Host separately. Terminal termination saves
+        // local state without requesting whole-Host exit; startup/shutdown tests
+        // exercise the interactive Ctrl+Q and command-palette choices explicitly.
+        assert_eq!(
+            unsafe { libc::kill(self.child.id() as libc::pid_t, libc::SIGTERM) },
+            0
+        );
+    }
     fn filter_command(&mut self, label: &str) {
         self.send(b"\x10");
         self.wait_for("Search commands…");
diff --git a/crates/cli/tests/integration/tui/attachments.rs b/crates/cli/tests/integration/tui/attachments.rs
index 72c69984ad..fddec39b89 100644
--- a/crates/cli/tests/integration/tui/attachments.rs
+++ b/crates/cli/tests/integration/tui/attachments.rs
@@ -114,7 +114,7 @@ fn local_file_upload_survives_routes_and_restart_then_sends_without_text() {
     tui.wait_for("attachment 中文.txt");
     tui.send(b"\x1b[<0;1;1M\x1b[<0;1;1m");
     tui.wait_until(|s| !s.contains("Remove"));
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     let checkpoint = directory
         .path()
@@ -175,7 +175,7 @@ fn local_file_upload_survives_routes_and_restart_then_sends_without_text() {
     let screen = reopened.screen.snapshot().unwrap().screen;
     assert!(screen.contains("attachment 中文.txt"));
     assert!(screen.contains("vision.png"));
-    reopened.send(b"\x11");
+    reopened.close_terminal();
     reopened.finish();
     let body = runtime.block_on(model).unwrap();
     assert!(body.to_string().contains("attachment 中文.txt"));
diff --git a/crates/cli/tests/integration/tui/branch.rs b/crates/cli/tests/integration/tui/branch.rs
index d794093806..608bcb5696 100644
--- a/crates/cli/tests/integration/tui/branch.rs
+++ b/crates/cli/tests/integration/tui/branch.rs
@@ -92,7 +92,7 @@ fn branch_preserves_selected_history_and_draft_and_reopens_by_original_receipt()
     tui.wait_for("same working directory.");
     tui.click_text("Create branch");
     tui.wait_for("Branch is ready.");
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     let checkpoint = directory
         .path()
@@ -157,7 +157,7 @@ fn branch_preserves_selected_history_and_draft_and_reopens_by_original_receipt()
         !screen.contains("Preserved branch draft"),
         "branch does not adopt original draft"
     );
-    reopened.send(b"\x11");
+    reopened.close_terminal();
     reopened.finish();
     let saved: serde_json::Value =
         serde_json::from_slice(&std::fs::read(checkpoint).unwrap()).unwrap();
diff --git a/crates/cli/tests/integration/tui/changes.rs b/crates/cli/tests/integration/tui/changes.rs
index 8c0531a609..7ec71fb635 100644
--- a/crates/cli/tests/integration/tui/changes.rs
+++ b/crates/cli/tests/integration/tui/changes.rs
@@ -146,7 +146,7 @@ fn real_edits_show_request_diff_without_claiming_a_file_snapshot_and_copy_withou
     tui.wait_for("old_string:");
     tui.wait_for("new_string:");
     tui.wait_for("whitespace");
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     client.disconnect();
     host.retire_registered();
@@ -277,7 +277,7 @@ fn code_mode_patch_stops_after_failure_and_keeps_real_tools_visible() {
     tui.filter_command("Show execution details");
     tui.click_text("Show execution details");
     tui.wait_for("exec");
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     client.disconnect();
     host.retire_registered();
diff --git a/crates/cli/tests/integration/tui/connection_test.rs b/crates/cli/tests/integration/tui/connection_test.rs
index ad6d18a70a..5d146b55d4 100644
--- a/crates/cli/tests/integration/tui/connection_test.rs
+++ b/crates/cli/tests/integration/tui/connection_test.rs
@@ -186,7 +186,7 @@ fn connection_test_confirms_network_records_failure_preserves_configuration_and_
     tui.send(b"\x1b");
     tui.wait_until(|s| !s.contains("incur usage"));
     assert_eq!(runtime.block_on(catalog(&client)), updated);
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     client.disconnect();
     host.retire_registered();
diff --git a/crates/cli/tests/integration/tui/connections.rs b/crates/cli/tests/integration/tui/connections.rs
index 6a91f1e1a6..00ad6f46e6 100644
--- a/crates/cli/tests/integration/tui/connections.rs
+++ b/crates/cli/tests/integration/tui/connections.rs
@@ -95,7 +95,7 @@ fn connections_directory_pages_reopens_and_manages_fixed_targets_with_cas() {
     tui.wait_until(|s| s.lines().next().is_some_and(|l| l.contains("Settings")));
     tui.send(b"\x1b[1;3C"); // Alt+Right restores nested route.
     tui.wait_for("Directory 00");
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     let mut reopened = Pty::spawn(&["--root", host.root.to_str().unwrap()]);
     reopened.wait_for("Directory 00");
@@ -355,7 +355,7 @@ fn connections_directory_pages_reopens_and_manages_fixed_targets_with_cas() {
         );
     });
     assert!(!String::from_utf8_lossy(&reopened.output).contains("directory-test-secret"));
-    reopened.send(b"\x11");
+    reopened.close_terminal();
     reopened.finish();
     client.disconnect();
     host.retire_registered();
diff --git a/crates/cli/tests/integration/tui/credentials.rs b/crates/cli/tests/integration/tui/credentials.rs
index 1aca09c138..311eaf7b81 100644
--- a/crates/cli/tests/integration/tui/credentials.rs
+++ b/crates/cli/tests/integration/tui/credentials.rs
@@ -187,7 +187,7 @@ fn credential_management_rotates_with_cas_uses_the_new_key_and_clears_without_le
     open(&mut tui, false);
     tui.wait_for("No saved key");
     tui.send(b"never-persist-key-draft");
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     let checkpoint = directory
         .path()
diff --git a/crates/cli/tests/integration/tui/enabled_models.rs b/crates/cli/tests/integration/tui/enabled_models.rs
index 7e3462e3a0..45734fd9c7 100644
--- a/crates/cli/tests/integration/tui/enabled_models.rs
+++ b/crates/cli/tests/integration/tui/enabled_models.rs
@@ -163,7 +163,7 @@ fn enabled_models_searches_full_catalog_keeps_manual_ids_and_applies_only_confir
         );
         assert_eq!(items[0]["requestBodyOverlay"]["temperature"], 0.2);
     });
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     client.disconnect();
     host.retire_registered();
diff --git a/crates/cli/tests/integration/tui/environment.rs b/crates/cli/tests/integration/tui/environment.rs
index 088e7dab21..f31bc82779 100644
--- a/crates/cli/tests/integration/tui/environment.rs
+++ b/crates/cli/tests/integration/tui/environment.rs
@@ -110,7 +110,7 @@ fn host_inherits_proxy_for_model_discovery_and_tui_turn_without_manual_configura
     tui.wait_for("Inherited proxy works");
     runtime.block_on(server).unwrap();
     assert!(!String::from_utf8_lossy(&tui.output).contains("proxy-secret"));
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     client.disconnect();
     host.retire_registered();
diff --git a/crates/cli/tests/integration/tui/extensions.rs b/crates/cli/tests/integration/tui/extensions.rs
index 36e67d562e..4387ccce8b 100644
--- a/crates/cli/tests/integration/tui/extensions.rs
+++ b/crates/cli/tests/integration/tui/extensions.rs
@@ -140,7 +140,7 @@ fn plugin_form_saves_in_place_and_preserves_a_stale_draft_without_model_executio
     );
     tui.click_text("Pinned");
     tui.wait_for("━●");
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     let mut tui = Pty::spawn(&["--root", host.root.to_str().unwrap()]);
     tui.wait_for("Draft preserved");
@@ -160,7 +160,7 @@ fn plugin_form_saves_in_place_and_preserves_a_stale_draft_without_model_executio
     tui.filter_command("Discard draft and reopen");
     tui.click_text("Discard draft and reopen");
     tui.wait_for("maka.skills");
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     let mut reopened = Pty::spawn(&["--root", host.root.to_str().unwrap()]);
     reopened.wait_for("maka.skills");
@@ -174,7 +174,7 @@ fn plugin_form_saves_in_place_and_preserves_a_stale_draft_without_model_executio
     );
     reopened.click_text("Skills");
     reopened.wait_for("Next");
-    reopened.send(b"\x11");
+    reopened.close_terminal();
     reopened.finish();
     runtime.block_on(async {
         assert!(
diff --git a/crates/cli/tests/integration/tui/forms.rs b/crates/cli/tests/integration/tui/forms.rs
index b5d72b8de6..88e58c9c7a 100644
--- a/crates/cli/tests/integration/tui/forms.rs
+++ b/crates/cli/tests/integration/tui/forms.rs
@@ -168,7 +168,7 @@ fn real_host_code_mode_form_waits_without_model_progress_and_submits_from_tui()
         .block_on(fixture.client.close_subscription(&settled.subscription_id))
         .unwrap();
     fixture.client.disconnect();
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     runtime.block_on(reader.close()).unwrap();
     host.retire_registered();
diff --git a/crates/cli/tests/integration/tui/imports.rs b/crates/cli/tests/integration/tui/imports.rs
index 61db7bb4a2..092c8e4d78 100644
--- a/crates/cli/tests/integration/tui/imports.rs
+++ b/crates/cli/tests/integration/tui/imports.rs
@@ -161,7 +161,7 @@ fn imported_observations_remain_readable_without_offering_executable_turn_action
         tui.send(b"\x1b");
         tui.wait_until(|screen| !screen.contains("Commands · Esc closes"));
     }
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     host.retire_registered();
 }
diff --git a/crates/cli/tests/integration/tui/management.rs b/crates/cli/tests/integration/tui/management.rs
index a802e0a303..e84d7ad25d 100644
--- a/crates/cli/tests/integration/tui/management.rs
+++ b/crates/cli/tests/integration/tui/management.rs
@@ -263,7 +263,7 @@ fn session_management_cas_and_archive_preserve_identity_and_composer() {
         );
         assert!(moved.is_dir(), "switching must not move existing files");
     });
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     client.disconnect();
     host.retire_registered();
diff --git a/crates/cli/tests/integration/tui/model_fetch.rs b/crates/cli/tests/integration/tui/model_fetch.rs
index 111f2bfa9c..4cc7b5b47c 100644
--- a/crates/cli/tests/integration/tui/model_fetch.rs
+++ b/crates/cli/tests/integration/tui/model_fetch.rs
@@ -193,7 +193,7 @@ fn model_fetch_confirms_network_writes_rechecks_effects_and_preserves_user_confi
             .unwrap();
         assert_eq!(credential["status"]["revision"], 2);
     });
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     assert!(!String::from_utf8_lossy(&tui.output).contains("discovery-secret"));
     client.disconnect();
diff --git a/crates/cli/tests/integration/tui/model_profiles.rs b/crates/cli/tests/integration/tui/model_profiles.rs
index 32379bef8e..affb8d842b 100644
--- a/crates/cli/tests/integration/tui/model_profiles.rs
+++ b/crates/cli/tests/integration/tui/model_profiles.rs
@@ -155,7 +155,7 @@ fn model_profiles_preserve_full_table_validate_inherited_limits_and_reject_concu
             1
         );
     });
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     client.disconnect();
     host.retire_registered();
diff --git a/crates/cli/tests/integration/tui/models.rs b/crates/cli/tests/integration/tui/models.rs
index 26a3ea4f03..2246426c83 100644
--- a/crates/cli/tests/integration/tui/models.rs
+++ b/crates/cli/tests/integration/tui/models.rs
@@ -280,7 +280,7 @@ fn model_choice_preserves_session_draft_and_default_and_uses_selected_context_wi
         }).await.unwrap();
         assert!(matches!(result,maka_protocol::configuration::CatalogMutationResult::RevisionConflict { .. }));
     });
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     client.disconnect();
     host.retire_registered();
diff --git a/crates/cli/tests/integration/tui/navigation.rs b/crates/cli/tests/integration/tui/navigation.rs
index 4746e9123f..b52ef4d0ce 100644
--- a/crates/cli/tests/integration/tui/navigation.rs
+++ b/crates/cli/tests/integration/tui/navigation.rs
@@ -42,7 +42,7 @@ fn restart_keeps_forward_history_and_keyboard_control_without_replaying_actions(
     tui.wait_for("Move focus between controls");
     tui.send(b"\x1b[1;3D"); // Alt+Left.
     tui.wait_for("Icons: Unicode");
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
 
     let mut reopened = Pty::spawn(&["--root", host.root.to_str().unwrap()]);
@@ -58,14 +58,14 @@ fn restart_keeps_forward_history_and_keyboard_control_without_replaying_actions(
     reopened.click_text("Host");
     reopened.wait_for("Host epoch:");
     reopened.send(b"\x1b[1;3C"); // New destination replaces the previous forward branch.
-    reopened.send(b"\x11");
+    reopened.close_terminal();
     reopened.finish();
 
     let mut branched = Pty::spawn(&["--root", host.root.to_str().unwrap()]);
     branched.wait_for("Host epoch:");
     branched.send(b"\x1b[1;3D");
     branched.wait_for("No sessions yet.");
-    branched.send(b"\x11");
+    branched.close_terminal();
     branched.finish();
     host.retire_registered();
 }
diff --git a/crates/cli/tests/integration/tui/oauth.rs b/crates/cli/tests/integration/tui/oauth.rs
index 574eda21c4..c4ee231afb 100644
--- a/crates/cli/tests/integration/tui/oauth.rs
+++ b/crates/cli/tests/integration/tui/oauth.rs
@@ -85,7 +85,7 @@ fn oauth_entry_uses_host_enrollment_mouse_keyboard_and_never_starts_on_dismiss()
         before,
         "enrollment and dismissal do not save credentials or connections"
     );
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     client.disconnect();
     host.child.as_mut().unwrap().kill().unwrap();
@@ -179,7 +179,7 @@ fn oauth_start_is_durable_before_dispatch_and_crash_reopens_only_the_original_qu
             requests,
             "NotFound is not permission to restart"
         );
-        reopened.send(b"\x11");
+        reopened.close_terminal();
         reopened.finish();
         assert_eq!(catalog(&client).await, before);
         drop(relay);
diff --git a/crates/cli/tests/integration/tui/onboarding.rs b/crates/cli/tests/integration/tui/onboarding.rs
index 56d9ceb7e6..5e3c6c01f1 100644
--- a/crates/cli/tests/integration/tui/onboarding.rs
+++ b/crates/cli/tests/integration/tui/onboarding.rs
@@ -101,7 +101,7 @@ fn connection_setup_verifies_without_writes_masks_keys_and_creates_first_chat()
     tui.send(b"hello after onboarding\x13");
     tui.wait_for("Onboarded model replied");
     runtime.block_on(provider).unwrap();
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     client.disconnect();
     host.retire_registered();
diff --git a/crates/cli/tests/integration/tui/projects.rs b/crates/cli/tests/integration/tui/projects.rs
index 296fdefc2c..dfd33baa8b 100644
--- a/crates/cli/tests/integration/tui/projects.rs
+++ b/crates/cli/tests/integration/tui/projects.rs
@@ -198,7 +198,7 @@ fn project_catalog_notifications_and_creation_use_host_project_identity() {
     tui.send(b"project draft");
     tui.send(b"\x1b[1;3D");
     tui.wait_for("Renamed project");
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     let mut tui = Pty::spawn(&["--root", host.root.to_str().unwrap()]);
     tui.wait_for("Renamed project"); // Restored route re-queries after connection, not a false empty state.
@@ -355,7 +355,7 @@ fn project_catalog_notifications_and_creation_use_host_project_identity() {
     tui.wait_until(|text| !text.contains("Preferred Host directory") && text.contains("TUI 项目"));
     tui.send(b"\x1b[1;3C");
     tui.wait_for("project draft");
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     client.disconnect();
     cancellation.cancel();
diff --git a/crates/cli/tests/integration/tui/queue.rs b/crates/cli/tests/integration/tui/queue.rs
index 6b2fa31b58..94dee53232 100644
--- a/crates/cli/tests/integration/tui/queue.rs
+++ b/crates/cli/tests/integration/tui/queue.rs
@@ -204,7 +204,7 @@ fn real_host_queue_edits_retracts_promotes_and_steers_at_the_model_boundary() {
             turns.push(turn_id);
         }
     });
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     client.disconnect();
     host.retire_registered();
diff --git a/crates/cli/tests/integration/tui/reading.rs b/crates/cli/tests/integration/tui/reading.rs
index 810e1e7a3b..4d86c5cc9f 100644
--- a/crates/cli/tests/integration/tui/reading.rs
+++ b/crates/cli/tests/integration/tui/reading.rs
@@ -126,7 +126,7 @@ fn old_window_selection_and_search_resume_across_pages_and_disk_restart() {
     tui.click_text("Reading resume");
     tui.wait_for("1/1");
     tui.wait_for("Bookmark target 中文🦀");
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     let mut reopened = Pty::spawn(&["--root", host.root.to_str().unwrap()]);
     reopened.wait_for("1/1");
@@ -158,7 +158,7 @@ fn old_window_selection_and_search_resume_across_pages_and_disk_restart() {
         "checkpoint must not duplicate transcript text"
     );
     assert!(!encoded.contains("subscriptionId"));
-    reopened.send(b"\x11");
+    reopened.close_terminal();
     reopened.finish();
     client.disconnect();
     host.retire_registered();
diff --git a/crates/cli/tests/integration/tui/recap.rs b/crates/cli/tests/integration/tui/recap.rs
index 536de33ef9..ed2dc64644 100644
--- a/crates/cli/tests/integration/tui/recap.rs
+++ b/crates/cli/tests/integration/tui/recap.rs
@@ -145,7 +145,7 @@ fn recap_uses_native_remote_preserves_draft_and_reads_saved_result_after_reopen(
     tui.send(b"\x1b[<0;2;2M\x1b[<0;2;2m");
     tui.wait_until(|s| !s.contains("Recap confirmed:"));
     tui.wait_for("Keep this unsent draft");
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     let mut reopened = Pty::spawn(&["--root", host.root.to_str().unwrap()]);
     reopened.wait_for("Keep this unsent draft");
@@ -154,7 +154,7 @@ fn recap_uses_native_remote_preserves_draft_and_reads_saved_result_after_reopen(
     reopened.click_text("Session recap");
     reopened.wait_for("Recap confirmed:");
     assert_eq!(calls.load(Ordering::SeqCst), 2);
-    reopened.send(b"\x11");
+    reopened.close_terminal();
     reopened.finish();
     model.abort();
     client.disconnect();
diff --git a/crates/cli/tests/integration/tui/recovery.rs b/crates/cli/tests/integration/tui/recovery.rs
index 02e5b00ca1..93d561e07a 100644
--- a/crates/cli/tests/integration/tui/recovery.rs
+++ b/crates/cli/tests/integration/tui/recovery.rs
@@ -85,7 +85,7 @@ fn recover(after_acceptance: bool) {
         tui.send(b"\x1b[200~ plus new edits\x1b[201~");
         tui.wait_for("plus new edits");
         if after_acceptance {
-            tui.send(b"\x11");
+            tui.close_terminal();
             tui.finish();
         } else {
             let deadline = Instant::now() + Duration::from_secs(5);
@@ -117,7 +117,7 @@ fn recover(after_acceptance: bool) {
         let input: SubmitInput = serde_json::from_value(requests[0].clone()).unwrap();
         assert_eq!(input.origin_host_epoch, client.identity.host_epoch);
         assert_eq!(input.content.text, "original 中文🦀");
-        tui.send(b"\x11");
+        tui.close_terminal();
         tui.finish();
         drop(proxy);
         let first = client.submit_message(input.clone()).await.unwrap();
diff --git a/crates/cli/tests/integration/tui/references.rs b/crates/cli/tests/integration/tui/references.rs
index df4491faa4..1d9524e0ec 100644
--- a/crates/cli/tests/integration/tui/references.rs
+++ b/crates/cli/tests/integration/tui/references.rs
@@ -95,7 +95,7 @@ fn host_directory_reference_survives_restart_and_sends_without_text_or_registrat
     tui.wait_for(selected.to_str().unwrap());
     tui.send(b"\x1b[<0;1;1M\x1b[<0;1;1m");
     tui.wait_until(|s| !s.contains("Reference this directory"));
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     let state = directory
         .path()
@@ -115,7 +115,7 @@ fn host_directory_reference_survives_restart_and_sends_without_text_or_registrat
     reopened.send(b"\x13");
     reopened.wait_for("Directory received.");
     reopened.wait_for(selected.to_str().unwrap());
-    reopened.send(b"\x11");
+    reopened.close_terminal();
     reopened.finish();
     let body = runtime.block_on(model).unwrap();
     assert!(
diff --git a/crates/cli/tests/integration/tui/removal.rs b/crates/cli/tests/integration/tui/removal.rs
index 9ff81831a5..956e9f7b00 100644
--- a/crates/cli/tests/integration/tui/removal.rs
+++ b/crates/cli/tests/integration/tui/removal.rs
@@ -152,7 +152,7 @@ fn deletion_confirms_cas_and_remote_retirement_preserves_recoverable_draft() {
             .screen
             .contains("recover this local draft")
     );
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     let mut reopened = Pty::spawn(&["--root", host.root.to_str().unwrap()]);
     reopened.wait_for("This session is no longer available.");
@@ -172,7 +172,7 @@ fn deletion_confirms_cas_and_remote_retirement_preserves_recoverable_draft() {
             .revision,
         1
     );
-    reopened.send(b"\x11");
+    reopened.close_terminal();
     reopened.finish();
     client.disconnect();
     host.retire_registered();
diff --git a/crates/cli/tests/integration/tui/revision.rs b/crates/cli/tests/integration/tui/revision.rs
index b31042365f..d57b189b35 100644
--- a/crates/cli/tests/integration/tui/revision.rs
+++ b/crates/cli/tests/integration/tui/revision.rs
@@ -146,7 +146,7 @@ fn revision_edits_ordered_inputs_preserves_attachments_and_reopens_without_resub
     tui.wait_for("edited second original");
     tui.send(b"\x1b[<0;1;1M\x1b[<0;1;1m");
     tui.wait_until(|s| !s.contains("Revise this turn"));
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     let saved: Value = serde_json::from_slice(&std::fs::read(&checkpoint).unwrap()).unwrap();
     assert_eq!(saved["version"], 15);
@@ -187,7 +187,7 @@ fn revision_edits_ordered_inputs_preserves_attachments_and_reopens_without_resub
     reopened.wait_for("revised second original");
     reopened.click_text("Run revision");
     reopened.wait_for("Revision accepted.");
-    reopened.send(b"\x11");
+    reopened.close_terminal();
     reopened.finish();
     runtime.block_on(wait_completed(&client, &request.target_session_id, &turn));
     let bodies = runtime.block_on(model).unwrap();
@@ -292,7 +292,7 @@ fn revision_edits_ordered_inputs_preserves_attachments_and_reopens_without_resub
             .screen
             .contains("Composer stays here")
     );
-    recovered.send(b"\x11");
+    recovered.close_terminal();
     recovered.finish();
     let saved: Value = serde_json::from_slice(&std::fs::read(checkpoint).unwrap()).unwrap();
     assert!(saved["revision"].is_null());
diff --git a/crates/cli/tests/integration/tui/sandbox.rs b/crates/cli/tests/integration/tui/sandbox.rs
index 428338a184..b16633b6f7 100644
--- a/crates/cli/tests/integration/tui/sandbox.rs
+++ b/crates/cli/tests/integration/tui/sandbox.rs
@@ -265,7 +265,7 @@ fn composer_sandbox_choice_requires_confirmation_and_preserves_approval_and_conc
     );
     tui.send(b"\x1b");
     tui.wait_until(|screen| !screen.contains("Cancel"));
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     client.disconnect();
     host.retire_registered();
diff --git a/crates/cli/tests/integration/tui/scheduler.rs b/crates/cli/tests/integration/tui/scheduler.rs
index 2ee12014fd..a28404ed74 100644
--- a/crates/cli/tests/integration/tui/scheduler.rs
+++ b/crates/cli/tests/integration/tui/scheduler.rs
@@ -146,7 +146,7 @@ fn scheduler_form_edits_multiline_and_fences_stale_writes_without_running_a_mode
     assert_eq!(actual["task"]["title"], "Changed elsewhere");
     assert_eq!(actual["task"]["intent"]["body"], "Changed remote note");
     assert_eq!(actual["task"]["fireCount"], 0);
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     let mut tui = Pty::spawn(&["--root", host.root.to_str().unwrap()]);
     tui.wait_for("Draft preserved");
@@ -177,7 +177,7 @@ fn scheduler_form_edits_multiline_and_fences_stale_writes_without_running_a_mode
     let merged = runtime.block_on(remote(&client, "request", query()));
     assert_eq!(merged["task"]["intent"]["body"], "Keep this draft");
     assert_eq!(merged["task"]["title"], "Changed elsewhere");
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     runtime.block_on(async {
         assert!(
@@ -256,7 +256,8 @@ fn scheduler_creation_requires_consent_then_reuses_only_a_live_grant() {
     tui.send(b"\x1b[200~Consent fixture\x1b[201~");
     tui.click_text("Content");
     tui.send(b"\x1b[200~First reminder\x1b[201~");
-    tui.send(b"\x11");
+    tui.wait_for("First reminder");
+    tui.close_terminal();
     tui.finish();
     let checkpoint = directory
         .path()
@@ -333,7 +334,7 @@ fn scheduler_creation_requires_consent_then_reuses_only_a_live_grant() {
         runtime.block_on(remote(&client, "request", json!({"kind":"grants"}))),
         grants
     );
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     runtime.block_on(async {
         let binding = RemoteBinding::Package { package_id: "maka.scheduler".into(), method: "terminal".into(), session_id: None };
diff --git a/crates/cli/tests/integration/tui/scheduler/recovery.rs b/crates/cli/tests/integration/tui/scheduler/recovery.rs
index 30d869877a..0398cd5052 100644
--- a/crates/cli/tests/integration/tui/scheduler/recovery.rs
+++ b/crates/cli/tests/integration/tui/scheduler/recovery.rs
@@ -103,7 +103,7 @@ fn terminal_creation_reopens_queries_and_retries_without_duplicating_or_resurrec
             }
             let tasks = remote(&client, "request", json!({"kind":"query","query":{"kind":"list"}})).await;
             assert_eq!(tasks["tasks"].as_array().unwrap().len(), usize::from(!accepted));
-            reopened.send(b"\x11");
+            reopened.close_terminal();
             reopened.finish();
             for task in tasks["tasks"].as_array().unwrap() {
                 assert_eq!(task["title"], "Saved original reminder");
diff --git a/crates/cli/tests/integration/tui/shutdown.rs b/crates/cli/tests/integration/tui/shutdown.rs
new file mode 100644
index 0000000000..e44b1d74be
--- /dev/null
+++ b/crates/cli/tests/integration/tui/shutdown.rs
@@ -0,0 +1,178 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::super::candidate::CandidateFixture;
+use super::*;
+use maka_event_log::root::{RootNamespaces, RootOwner};
+use maka_protocol::{Operation, session::decode_session_create_input};
+use serde_json::json;
+
+fn serve(host: &mut CandidateFixture) {
+    host.child = Some(
+        Command::new(env!("CARGO_BIN_EXE_maka"))
+            .args(["host", "serve", "--root"])
+            .arg(&host.root)
+            .stdin(Stdio::null())
+            .stdout(Stdio::null())
+            .stderr(Stdio::inherit())
+            .spawn()
+            .unwrap(),
+    );
+    host.wait_for_registration();
+}
+
+#[test]
+fn force_quit_cancels_active_turn_and_preserves_history_and_unsent_draft() {
+    let directory = tempfile::tempdir().unwrap();
+    let mut host = CandidateFixture::new(directory.path().join("root"));
+    serve(&mut host);
+    let runtime = tokio::runtime::Runtime::new().unwrap();
+    let (url, _finish, model) = runtime.block_on(streaming_model(directory.path().join("unused")));
+    let client = runtime.block_on(async {
+        let client = support::model_client(&host.root, &url).await;
+        client
+            .create_session(
+                decode_session_create_input(&json!({
+                    "sessionId":"exit-session", "name":"Shutdown fixture",
+                    "workspace":{"kind":"host_path","path":directory.path()},
+                    "sandboxMode":"read-only", "modelTarget":{"kind":"default"}
+                }))
+                .unwrap(),
+            )
+            .await
+            .unwrap();
+        client
+    });
+    let mut tui = Pty::spawn(&["--root", host.root.to_str().unwrap()]);
+    tui.wait_for("Shutdown fixture");
+    tui.click_text("Shutdown fixture");
+    tui.wait_for("Message…");
+    tui.send("草稿 first\x13".as_bytes());
+    tui.wait_for("Streamed 中文🦀");
+    let turn = runtime.block_on(async {
+        let snapshot = client
+            .open_subscription(maka_protocol::subscription::SubscriptionOpenInput {
+                session_id: "exit-session".into(),
+                transcript: maka_protocol::subscription::TranscriptPolicy::None,
+            })
+            .await
+            .unwrap();
+        client
+            .close_subscription(&snapshot.subscription_id)
+            .await
+            .unwrap();
+        snapshot.snapshot.root_turn.unwrap()
+    });
+    assert!(matches!(
+        turn.state,
+        maka_protocol::turn::TurnState::Running(_)
+    ));
+    // No other client remains: the active turn itself must block safe shutdown.
+    client.disconnect();
+    runtime.block_on(client.closed());
+    tui.click_text("Message…");
+    tui.send("unsent 中文🦀".as_bytes());
+    tui.wait_for("unsent 中文🦀");
+    tui.send(b"\x11");
+    tui.wait_for("Force quit");
+    tui.send(b"\x1b[<0;1;1M\x1b[<0;1;1m");
+    tui.wait_until(|screen| !screen.contains("Force quit"));
+    tui.wait_for("■");
+    tui.send(b"\x11");
+    tui.wait_for("Force quit");
+    tui.click_text("Force quit");
+    tui.finish();
+    assert!(host.wait_for_exit().success());
+    let owner =
+        RootOwner::open(&host.root, &RootNamespaces::for_current_account().unwrap()).unwrap();
+    owner.validate_current().unwrap();
+    drop(owner);
+    model.abort();
+    assert!(runtime.block_on(model).unwrap_err().is_cancelled());
+
+    serve(&mut host);
+    let client = runtime.block_on(support::client(&host.root));
+    let saved = runtime
+        .block_on(client.request(
+            Operation::TurnQuery,
+            json!({"sessionId":turn.session_id,"turnId":turn.turn_id}),
+        ))
+        .unwrap();
+    assert_eq!(saved["status"], "cancelled");
+    assert_eq!(saved["runId"], turn.run_id);
+    let mut reopened = Pty::spawn(&["--root", host.root.to_str().unwrap()]);
+    reopened.wait_for("unsent 中文🦀");
+    reopened.wait_for("Streamed 中文🦀");
+    reopened.filter_command("Close interface only");
+    reopened.click_text("Close interface only");
+    reopened.finish();
+    assert!(
+        runtime
+            .block_on(client.session("exit-session"))
+            .unwrap()
+            .is_some()
+    );
+    client.disconnect();
+    host.retire_registered();
+    assert!(host.wait_for_exit().success());
+}
+
+#[test]
+fn force_confirmation_never_retargets_a_replacement_host() {
+    let directory = tempfile::tempdir().unwrap();
+    let mut host = CandidateFixture::new(directory.path().join("root"));
+    serve(&mut host);
+    let runtime = tokio::runtime::Runtime::new().unwrap();
+    let observer = runtime.block_on(support::client(&host.root));
+    let original_epoch = observer.identity.host_epoch.clone();
+    let mut tui = Pty::spawn(&["--root", host.root.to_str().unwrap()]);
+    tui.wait_for("No sessions yet.");
+    tui.click_text("◉ Host");
+    tui.wait_for("State: \"ready\"");
+    tui.send(b"\x11");
+    tui.wait_for("Force quit");
+    runtime
+        .block_on(observer.request(
+            Operation::HostUpgradePrepare,
+            json!({
+                "expectedHostEpoch": original_epoch,
+                "allowInterruptActiveTasks": true,
+                "allowCooperativeHandoff": false
+            }),
+        ))
+        .unwrap();
+    assert!(host.wait_for_exit().success());
+    serve(&mut host);
+    let replacement = runtime.block_on(support::client(&host.root));
+    assert_ne!(replacement.identity.host_epoch, original_epoch);
+    tui.click_text("Force quit");
+    tui.wait_for("Could not confirm that the Host stopped.");
+    assert!(!tui.screen.snapshot().unwrap().screen.contains("Force quit"));
+    runtime
+        .block_on(replacement.request(Operation::HostStatus, json!({})))
+        .unwrap();
+    tui.click_text("Close interface only");
+    tui.finish();
+    runtime
+        .block_on(replacement.request(Operation::HostStatus, json!({})))
+        .unwrap();
+    replacement.disconnect();
+    host.retire_registered();
+    assert!(host.wait_for_exit().success());
+}
diff --git a/crates/cli/tests/integration/tui/skills.rs b/crates/cli/tests/integration/tui/skills.rs
index 28aad573a5..cf0a01a35a 100644
--- a/crates/cli/tests/integration/tui/skills.rs
+++ b/crates/cli/tests/integration/tui/skills.rs
@@ -90,7 +90,7 @@ fn skills_candidates_page_without_execution_and_restore_exact_selection_before_s
     tui.wait_for("Selected · 1");
     tui.send(b"\x1b[<0;1;1M\x1b[<0;1;1m");
     tui.wait_until(|s| !s.contains("Selected · 1") && s.contains("Candidate 128"));
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     assert_eq!(
         calls.load(Ordering::SeqCst),
@@ -114,7 +114,7 @@ fn skills_candidates_page_without_execution_and_restore_exact_selection_before_s
     reopened.wait_for("fixture-model");
     reopened.send(b"\x13");
     reopened.wait_for("Skill accepted.");
-    reopened.send(b"\x11");
+    reopened.close_terminal();
     reopened.finish();
     let body = runtime.block_on(model).unwrap();
     assert!(body.to_string().contains("chosen-instructions-128"));
diff --git a/crates/cli/tests/integration/tui/startup.rs b/crates/cli/tests/integration/tui/startup.rs
index 22d244f902..63973aa48d 100644
--- a/crates/cli/tests/integration/tui/startup.rs
+++ b/crates/cli/tests/integration/tui/startup.rs
@@ -52,6 +52,11 @@ fn fresh_terminals_share_one_on_demand_host_and_reopen_the_same_root() {
     let runtime = tokio::runtime::Runtime::new().unwrap();
     let client = runtime.block_on(super::support::client(&root));
     first.send(b"\x11");
+    first.wait_for("Force quit");
+    first.send(b"\x1b");
+    first.wait_until(|screen| !screen.contains("Force quit"));
+    first.filter_command("Close interface only");
+    first.click_text("Close interface only");
     first.finish();
     runtime
         .block_on(client.request(maka_protocol::Operation::HostStatus, serde_json::json!({})))
@@ -61,7 +66,18 @@ fn fresh_terminals_share_one_on_demand_host_and_reopen_the_same_root() {
         original.host_epoch
     );
     second.send(b"\x11");
+    second.wait_for("Force quit");
+    // Enter defaults to cancel; forcing requires an explicit choice.
+    second.send(b"\r");
+    second.wait_until(|screen| !screen.contains("Force quit"));
+    runtime
+        .block_on(client.request(maka_protocol::Operation::HostStatus, serde_json::json!({})))
+        .unwrap();
+    second.send(b"\x11");
+    second.wait_for("Force quit");
+    second.click_text("Force quit");
     second.finish();
+    assert!(RootOwner::open(&root, &RootNamespaces::for_current_account().unwrap()).is_ok());
     client.disconnect();
     drop(client);
     wait_for_idle(&root);
@@ -82,8 +98,28 @@ fn fresh_terminals_share_one_on_demand_host_and_reopen_the_same_root() {
     let current = read_discovery(&root).unwrap();
     assert_eq!(current.root_id, original.root_id);
     assert_ne!(current.host_epoch, original.host_epoch);
+    // The Host must not be retired until the local checkpoint is durable.
+    let checkpoint = directory
+        .path()
+        .join("tui-state")
+        .join(&current.root_id)
+        .join("first/state.json");
+    std::fs::remove_file(&checkpoint).unwrap();
+    std::fs::create_dir(&checkpoint).unwrap();
+    reopened.send(b"\x11");
+    reopened.wait_for("Local changes not saved");
+    assert_eq!(
+        read_discovery(&root).unwrap().host_epoch,
+        current.host_epoch
+    );
+    assert!(RootOwner::open(&root, &namespaces).is_err());
+    std::fs::remove_dir(&checkpoint).unwrap();
     reopened.send(b"\x11");
     reopened.finish();
+    assert!(
+        RootOwner::open(&root, &namespaces).is_ok(),
+        "quit must await root release, not the idle timer"
+    );
     wait_for_idle(&root);
 }
 
diff --git a/crates/cli/tests/integration/tui/stopping.rs b/crates/cli/tests/integration/tui/stopping.rs
index 705906309c..a7179bd882 100644
--- a/crates/cli/tests/integration/tui/stopping.rs
+++ b/crates/cli/tests/integration/tui/stopping.rs
@@ -107,7 +107,7 @@ fn composer_stop_cancels_the_observed_run_and_keeps_unsent_text() {
         .unwrap();
     model_task.abort();
     assert!(runtime.block_on(model_task).unwrap_err().is_cancelled());
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
     client.disconnect();
     host.retire_registered();
diff --git a/crates/cli/tests/integration/tui/themes.rs b/crates/cli/tests/integration/tui/themes.rs
index ac141149f9..3f3e72bb0c 100644
--- a/crates/cli/tests/integration/tui/themes.rs
+++ b/crates/cli/tests/integration/tui/themes.rs
@@ -49,7 +49,7 @@ fn visual_custom_theme_saves_reopens_and_rejects_external_changes_without_losing
     let saved = std::fs::read(&path).unwrap();
     let json: serde_json::Value = serde_json::from_slice(&saved).unwrap();
     assert_eq!(json["colors"]["accent"], "#89abcd");
-    tui.send(b"\x11");
+    tui.close_terminal();
     tui.finish();
 
     let mut reopened = Pty::spawn(&["--root", host.root.to_str().unwrap()]);
@@ -66,7 +66,7 @@ fn visual_custom_theme_saves_reopens_and_rejects_external_changes_without_losing
     reopened.wait_for("#123456");
     reopened.click_text("Save & apply");
     reopened.wait_for("Palette: External");
-    reopened.send(b"\x11");
+    reopened.close_terminal();
     reopened.finish();
 
     std::fs::write(&path, b"not-json").unwrap();
@@ -75,7 +75,7 @@ fn visual_custom_theme_saves_reopens_and_rejects_external_changes_without_losing
     invalid.wait_for("Invalid theme");
     invalid.click_text("◐");
     invalid.wait_for("Palette: Maka dark");
-    invalid.send(b"\x11");
+    invalid.close_terminal();
     invalid.finish();
     assert_eq!(std::fs::read(&path).unwrap(), b"not-json");
     host.retire_registered();
diff --git a/crates/tui/locales/en.ftl b/crates/tui/locales/en.ftl
index ce8bb89a6e..1e146cbd84 100644
--- a/crates/tui/locales/en.ftl
+++ b/crates/tui/locales/en.ftl
@@ -133,7 +133,13 @@ symbols-unicode = Icons: Unicode
 motion-on = Motion: subtle activity
 motion-off = Motion: reduced
 shell-shortcuts = Tab Focus · Ctrl+P Commands · Ctrl+B Navigation · Ctrl+Q Quit
-command-quit = Quit Maka (leave Host running)
+command-quit = Quit Maka
+command-detach = Close interface only
+shutdown-cancel = Cancel
+shutdown-force = Force quit
+shutdown-busy = Tasks are still running or other clients are connected. Quitting will interrupt them.
+shutdown-failed = Could not confirm that the Host stopped. You can stay here or close this interface.
+shutdown-working = Stopping Host and saving…
 button-theme = Toggle palette
 button-language = Change language: { $language }
 footer-commands = Ctrl+P Commands
diff --git a/crates/tui/locales/zh-CN.ftl b/crates/tui/locales/zh-CN.ftl
index d26ae1d9b3..0c621848be 100644
--- a/crates/tui/locales/zh-CN.ftl
+++ b/crates/tui/locales/zh-CN.ftl
@@ -133,7 +133,13 @@ symbols-unicode = 图标:Unicode
 motion-on = 动态效果:轻柔活动
 motion-off = 动态效果:已减少
 shell-shortcuts = Tab 焦点 · Ctrl+P 命令 · Ctrl+B 导航 · Ctrl+Q 退出
-command-quit = 退出 Maka(保留 Host 运行)
+command-quit = 退出 Maka
+command-detach = 仅关闭界面
+shutdown-cancel = 取消
+shutdown-force = 强制退出
+shutdown-busy = 还有任务在运行或其他客户端已连接。退出会中断它们。
+shutdown-failed = 无法确认 Host 已停止。你可以留在此处,或仅关闭界面。
+shutdown-working = 正在停止 Host 并保存…
 button-theme = 切换配色
 button-language = 切换语言:{ $language }
 footer-commands = Ctrl+P 命令
diff --git a/crates/tui/locales/zh-TW.ftl b/crates/tui/locales/zh-TW.ftl
index b098cc2ac5..b46b0782b4 100644
--- a/crates/tui/locales/zh-TW.ftl
+++ b/crates/tui/locales/zh-TW.ftl
@@ -133,7 +133,13 @@ symbols-unicode = 圖示:Unicode
 motion-on = 動態效果:輕柔活動
 motion-off = 動態效果:已減少
 shell-shortcuts = Tab 焦點 · Ctrl+P 命令 · Ctrl+B 導覽 · Ctrl+Q 離開
-command-quit = 離開 Maka(保留 Host 執行)
+command-quit = 離開 Maka
+command-detach = 僅關閉介面
+shutdown-cancel = 取消
+shutdown-force = 強制離開
+shutdown-busy = 還有任務在執行或其他用戶端已連線。離開會中斷它們。
+shutdown-failed = 無法確認 Host 已停止。你可以留在此處,或僅關閉介面。
+shutdown-working = 正在停止 Host 並儲存…
 button-theme = 切換配色
 button-language = 切換語言:{ $language }
 footer-commands = Ctrl+P 命令
diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs
index 9ef62aebc3..7a97b6025e 100644
--- a/crates/tui/src/app.rs
+++ b/crates/tui/src/app.rs
@@ -91,6 +91,9 @@ pub enum Action {
     OpenInteraction,
     Interaction(crate::pages::interactions::Command),
     Quit,
+    Detach,
+    ConfirmQuit,
+    CancelQuit,
     NextTab,
     PreviousTab,
     CloseTab(String),
@@ -161,6 +164,7 @@ pub struct App {
     pub notice: Option<Notice>,
     pub state_error: Option<String>,
     pub closing: bool,
+    pub shutdown: crate::shutdown::State,
     pub theme: crate::theme::Theme,
     pub refreshing: bool,
     pub creating: bool,
@@ -210,6 +214,7 @@ impl App {
             notice: None,
             state_error: None,
             closing: false,
+            shutdown: Default::default(),
             theme: crate::theme::Theme::default(),
             refreshing: false,
             creating: false,
@@ -240,6 +245,7 @@ impl App {
             (Action::Connect, "command-connect"),
             (Action::ToggleSidebar, "command-sidebar"),
             (Action::Quit, "command-quit"),
+            (Action::Detach, "command-detach"),
         ];
         if self.navigation.current() == Route::Extensions {
             commands.extend(self.page_actions().into_iter().filter_map(|action| {
@@ -474,6 +480,7 @@ impl App {
                 || self.inbox.error.is_some())
     }
     pub fn begin_frame(&mut self, area: Rect) {
+        self.shutdown.visible = false;
         self.attachments.begin_frame();
         self.branch.invalidate_geometry();
         self.recap.invalidate_geometry();
@@ -808,12 +815,21 @@ impl App {
                 self.refreshing = true;
                 return Some(action);
             }
-            Action::Quit => return Some(action),
+            Action::Quit | Action::Detach | Action::ConfirmQuit => return Some(action),
+            Action::CancelQuit => {
+                self.shutdown = Default::default();
+                self.hits.clear();
+                self.hover = None;
+            }
             _ => {}
         }
         None
     }
     pub fn enabled(&self, action: &Action) -> bool {
+        if *action == Action::ConfirmQuit {
+            return matches!(self.shutdown.prompt, Some(crate::shutdown::Prompt::Busy))
+                && !self.shutdown.stopping;
+        }
         if let Action::Extension(command) = action {
             return self.extensions_enabled(command);
         }
@@ -1094,6 +1110,7 @@ impl App {
     }
 
     pub fn invalidate_editor_geometry(&mut self) {
+        self.shutdown.visible = false;
         self.extensions.invalidate_geometry();
         self.modal_area = None;
         if let Some(editor) = &mut self.theme.editor {
@@ -1139,6 +1156,14 @@ impl App {
     /// Only the displayed frame contributes hit regions. Overlay rendering
     /// replaces that list, so a mouse event cannot reach a covered page.
     pub fn input(&mut self, event: Event) -> (bool, Option<Action>) {
+        if self.shutdown.stopping
+            && !matches!(
+                event,
+                Event::Resize(_, _) | Event::FocusLost | Event::FocusGained
+            )
+        {
+            return (false, None);
+        }
         if self.closing {
             match &event {
                 Event::Key(key)
@@ -1211,6 +1236,9 @@ impl App {
     }
 
     fn dispatch_input(&mut self, event: Event) -> (bool, Option<Action>) {
+        if self.shutdown.prompt.is_some() && !matches!(event, Event::Resize(_, _)) {
+            return self.shutdown_input(event);
+        }
         if let Event::Mouse(mouse) = &event
             && mouse.kind == MouseEventKind::Down(MouseButton::Left)
             && (self.extensions.consent_visible()
diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs
index da5ab98bf2..0cf3964959 100644
--- a/crates/tui/src/lib.rs
+++ b/crates/tui/src/lib.rs
@@ -26,6 +26,7 @@ mod i18n;
 mod motion;
 mod navigation;
 mod pages;
+mod shutdown;
 mod state;
 mod terminal;
 mod theme;
@@ -38,6 +39,7 @@ pub use i18n::{Locale, LocalePreference};
 use maka_client::{Client, Error, Notification};
 use maka_protocol::Operation;
 use serde_json::{Value, json};
+pub use shutdown::{ShutdownOutcome, ShutdownRequest};
 use std::{path::PathBuf, time::Duration};
 use tokio::{sync::mpsc, task::JoinSet};
 
@@ -174,12 +176,14 @@ enum Completed {
     ),
 }
 
-pub async fn run<F, C>(options: Options, connect: F) -> Result<(), Error>
+pub async fn run<F, C, S, D>(options: Options, connect: F, shutdown: S) -> Result<(), Error>
 where
     F: Fn(PathBuf) -> C,
     C: std::future::Future<Output = Result<(Client, mpsc::Receiver<Notification>), Error>>
         + Send
         + 'static,
+    S: Fn(ShutdownRequest) -> D,
+    D: std::future::Future<Output = Result<ShutdownOutcome, Error>> + Send + 'static,
 {
     let i18n = i18n::I18n::from_environment(options.locale)?;
     let (_guard, mut screen) = terminal::Guard::enter(&i18n)?;
@@ -218,9 +222,25 @@ where
     let mut effect = app.apply(Action::Connect);
     let mut dirty = true;
     let mut flushed = false;
+    let mut shutdown_target: Option<ShutdownRequest> = None;
+    // Host disconnection is expected during shutdown; this task must outlive
+    // the connection-scoped jobs and wait for actual storage/root release.
+    let mut shutdown_job = None;
     #[cfg(unix)]
     let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
     loop {
+        if !app.closing && app.shutdown.prompt.is_none() {
+            shutdown_target = None;
+        }
+        if app.closing && flushed && shutdown_job.is_none() {
+            if let Some(target) = shutdown_target.clone() {
+                app.shutdown.stopping = true;
+                shutdown_job = Some(tokio::spawn(shutdown(target)));
+                dirty = true;
+            } else {
+                break;
+            }
+        }
         app.advance_revision_uploads();
         if !app.closing
             && let Some(request) = app.attachment_browse_request()
@@ -236,13 +256,17 @@ where
                 (request, result)
             });
         }
-        if let Some(state) = &mut state {
+        if let Some(state) = &mut state
+            && !(app.closing && flushed)
+        {
             if app.closing && state.wait().is_some() {
                 state.force();
             }
             state.start(&app);
         }
-        if let Some(client) = &client {
+        if let Some(client) = &client
+            && !app.closing
+        {
             if let Some((ticket, saved, transfer)) = app.attachment_read_request() {
                 let client = client.clone();
                 attachment_jobs.spawn(async move {
@@ -539,6 +563,7 @@ where
         // Paging can depend on the just-measured viewport. Dispatch before
         // waiting for input, including when motion is disabled and the app is idle.
         if let Some(client) = &client
+            && !app.closing
             && let Some(request) = app.chat.page_query()
         {
             let client = client.clone();
@@ -637,7 +662,28 @@ where
                     dirty = true;
                     continue;
                 }
-                Action::Quit => {
+                Action::Quit | Action::Detach | Action::ConfirmQuit => {
+                    match action {
+                        Action::Quit => {
+                            shutdown_target = client.as_ref().map(|client| ShutdownRequest {
+                                root: app.root.clone(),
+                                identity: client.identity.clone(),
+                                interrupt: false,
+                            });
+                        }
+                        Action::ConfirmQuit => {
+                            if !matches!(app.shutdown.prompt, Some(shutdown::Prompt::Busy)) {
+                                continue;
+                            }
+                            if let Some(target) = &mut shutdown_target {
+                                target.interrupt = true;
+                            }
+                        }
+                        Action::Detach => shutdown_target = None,
+                        _ => unreachable!(),
+                    }
+                    app.shutdown = shutdown::State::default();
+                    flushed = false;
                     app.attachments.disconnect();
                     app.skills.disconnect();
                     app.extensions.disconnect();
@@ -654,7 +700,9 @@ where
                         dirty = true;
                         continue;
                     }
-                    break;
+                    app.closing = true;
+                    flushed = true;
+                    continue;
                 }
                 Action::Connect => {
                     app.branch.disconnect();
@@ -804,6 +852,22 @@ where
         let state_wait = state.as_ref().and_then(state::State::wait);
         let oauth_wait = app.oauth_wait();
         tokio::select! {
+            result = async {
+                match &mut shutdown_job {
+                    Some(job) => job.await,
+                    None => std::future::pending().await,
+                }
+            } => {
+                shutdown_job = None;
+                app.closing = false;
+                match result {
+                    Ok(Ok(ShutdownOutcome::Stopped)) => break,
+                    Ok(Ok(ShutdownOutcome::Busy)) => app.shutdown.show(shutdown::Prompt::Busy),
+                    Ok(Err(error)) => app.shutdown.show(shutdown::Prompt::Failed(error.to_string())),
+                    Err(error) => app.shutdown.show(shutdown::Prompt::Failed(error.to_string())),
+                }
+                dirty = true;
+            }
             _ = async {
                 match oauth_wait {
                     Some(wait) => tokio::time::sleep(wait).await,
@@ -893,7 +957,6 @@ where
                 if app.state_error.is_some() { app.closing = false; }
                 if app.closing && state.as_ref().is_some_and(state::State::idle) {
                     flushed = true;
-                    break;
                 }
                 dirty = true;
             }
diff --git a/crates/tui/src/shutdown.rs b/crates/tui/src/shutdown.rs
new file mode 100644
index 0000000000..a15055c9fb
--- /dev/null
+++ b/crates/tui/src/shutdown.rs
@@ -0,0 +1,284 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use crate::{
+    app::{Action, App},
+    pages::manage::view::note_lines,
+};
+use crossterm::event::{Event, KeyCode, KeyEventKind, MouseButton, MouseEventKind};
+use ratatui::{
+    Frame,
+    layout::{Alignment, Margin, Rect},
+    style::Style,
+    widgets::{Block, BorderType, Paragraph},
+};
+use std::path::PathBuf;
+
+/// A frozen local Host lifetime. Never stop a replacement discovered later.
+#[derive(Clone)]
+pub struct ShutdownRequest {
+    pub root: PathBuf,
+    pub identity: maka_client::HostIdentity,
+    pub interrupt: bool,
+}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ShutdownOutcome {
+    Stopped,
+    Busy,
+}
+#[derive(Default)]
+pub(crate) struct State {
+    pub stopping: bool,
+    pub prompt: Option<Prompt>,
+    focus: usize,
+    pub visible: bool,
+}
+pub(crate) enum Prompt {
+    Busy,
+    Failed(String),
+}
+impl State {
+    pub fn show(&mut self, prompt: Prompt) {
+        self.stopping = false;
+        self.prompt = Some(prompt);
+        self.focus = 0;
+        self.visible = false;
+    }
+    fn actions(&self) -> Vec<(Action, &'static str)> {
+        let mut actions = vec![
+            (Action::CancelQuit, "shutdown-cancel"),
+            (Action::Detach, "command-detach"),
+        ];
+        if matches!(self.prompt, Some(Prompt::Busy)) {
+            actions.push((Action::ConfirmQuit, "shutdown-force"));
+        }
+        actions
+    }
+}
+
+impl App {
+    pub(crate) fn shutdown_input(&mut self, event: Event) -> (bool, Option<Action>) {
+        let actions = self.shutdown.actions();
+        let action = match event {
+            Event::Key(key) if key.kind != KeyEventKind::Release => match key.code {
+                KeyCode::Esc => Some(Action::CancelQuit),
+                _ if !self.shutdown.visible => None,
+                KeyCode::Tab | KeyCode::Right | KeyCode::Down => {
+                    self.shutdown.focus = (self.shutdown.focus + 1) % actions.len();
+                    return (true, None);
+                }
+                KeyCode::BackTab | KeyCode::Left | KeyCode::Up => {
+                    self.shutdown.focus = (self.shutdown.focus + actions.len() - 1) % actions.len();
+                    return (true, None);
+                }
+                KeyCode::Enter => actions
+                    .get(self.shutdown.focus)
+                    .map(|(action, _)| action.clone()),
+                _ => None,
+            },
+            Event::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Left) => {
+                let point = (mouse.column, mouse.row).into();
+                if self.modal_area.is_some_and(|area| !area.contains(point)) {
+                    Some(Action::CancelQuit)
+                } else if self.shutdown.visible {
+                    self.hits
+                        .iter()
+                        .rev()
+                        .find(|hit| hit.area.contains(point))
+                        .map(|hit| hit.action.clone())
+                        .filter(|action| actions.iter().any(|(candidate, _)| candidate == action))
+                } else {
+                    None
+                }
+            }
+            _ => None,
+        };
+        (
+            action.is_some(),
+            action.and_then(|action| self.apply(action)),
+        )
+    }
+}
+
+pub(crate) fn draw(frame: &mut Frame<'_>, app: &mut App, area: Rect, base: Style) {
+    app.hits.clear();
+    let Some(prompt) = &app.shutdown.prompt else {
+        return;
+    };
+    let width = area.width.saturating_sub(2).min(64);
+    let mut message = app.i18n.text(match prompt {
+        Prompt::Busy => "shutdown-busy",
+        Prompt::Failed(_) => "shutdown-failed",
+    });
+    if let Prompt::Failed(error) = prompt {
+        message.push_str("\n\n");
+        message.push_str(&crate::view::safe(
+            &error.chars().take(256).collect::<String>(),
+        ));
+    }
+    let lines = note_lines(&message, width.saturating_sub(4));
+    let height = lines.len() as u16 + 6;
+    if width < 36 || height > area.height.saturating_sub(2) {
+        crate::view::clear_overlay(frame, area);
+        frame.render_widget(
+            Paragraph::new(app.i18n.text("terminal-small"))
+                .alignment(Alignment::Center)
+                .style(base),
+            area,
+        );
+        return;
+    }
+    let popup = Rect::new(
+        area.x + (area.width - width) / 2,
+        area.y + (area.height - height) / 2,
+        width,
+        height,
+    );
+    app.modal_area = Some(popup);
+    app.shutdown.visible = true;
+    crate::view::clear_overlay(frame, popup);
+    let block = Block::bordered()
+        .title(app.i18n.text("command-quit"))
+        .title_alignment(Alignment::Center)
+        .border_type(if app.chrome.ascii {
+            BorderType::Plain
+        } else {
+            BorderType::Rounded
+        })
+        .border_style(Style::default().fg(app.theme.colors().warning))
+        .style(base);
+    let inner = block.inner(popup).inner(Margin::new(1, 0));
+    frame.render_widget(block, popup);
+    frame.render_widget(
+        Paragraph::new(lines),
+        Rect::new(inner.x, inner.y + 1, inner.width, height - 5),
+    );
+    let actions = app.shutdown.actions();
+    let cell = inner.width / actions.len() as u16;
+    for (index, (action, label)) in actions.into_iter().enumerate() {
+        crate::view::button(
+            frame,
+            app,
+            Rect::new(inner.x + index as u16 * cell, inner.bottom() - 1, cell, 1),
+            &app.i18n.text(label),
+            action,
+            app.shutdown.focus == index,
+        );
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::{
+        i18n::{I18n, Locale, LocalePreference},
+        navigation::Route,
+    };
+    use crossterm::event::{KeyEvent, KeyModifiers, MouseEvent};
+    use ratatui::{Terminal, backend::TestBackend};
+
+    fn frame(app: &mut App, width: u16, height: u16) {
+        Terminal::new(TestBackend::new(width, height))
+            .unwrap()
+            .draw(|frame| crate::view::draw(frame, app))
+            .unwrap();
+    }
+    fn key(code: KeyCode) -> Event {
+        Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
+    }
+    fn mouse(kind: MouseEventKind, x: u16, y: u16) -> Event {
+        Event::Mouse(MouseEvent {
+            kind,
+            column: x,
+            row: y,
+            modifiers: KeyModifiers::NONE,
+        })
+    }
+
+    #[test]
+    fn confirmation_defaults_to_cancel_and_never_uses_hidden_or_stale_controls() {
+        for locale in [Locale::En, Locale::ZhCn, Locale::ZhTw] {
+            let mut app = App::new(
+                "/unused".into(),
+                I18n::new(LocalePreference::Explicit(locale), locale),
+            );
+            app.apply(Action::Visit(Route::Session("draft".into())));
+            app.drafts.get_mut("draft").unwrap().insert("unsent draft");
+            app.shutdown.show(Prompt::Busy);
+            frame(&mut app, 80, 24);
+            assert!(app.shutdown.visible);
+            app.input(key(KeyCode::Enter));
+            assert!(app.shutdown.prompt.is_none());
+            app.shutdown.show(Prompt::Busy);
+            frame(&mut app, 80, 24);
+            let force = app
+                .hits
+                .iter()
+                .find(|hit| hit.action == Action::ConfirmQuit)
+                .unwrap()
+                .area;
+            assert!(app.input(mouse(MouseEventKind::Moved, force.x, force.y)).0);
+            assert_eq!(app.hover, Some(Action::ConfirmQuit));
+            assert_eq!(app.shutdown.focus, 0, "hover must not arm Enter");
+            assert_eq!(
+                app.input(mouse(
+                    MouseEventKind::Down(MouseButton::Left),
+                    force.x,
+                    force.y
+                ))
+                .1,
+                Some(Action::ConfirmQuit)
+            );
+            app.input(Event::Resize(24, 6));
+            assert!(app.input(key(KeyCode::Enter)).1.is_none());
+            frame(&mut app, 24, 6);
+            assert!(!app.shutdown.visible);
+            assert!(app.input(key(KeyCode::Enter)).1.is_none());
+            assert!(
+                app.input(mouse(
+                    MouseEventKind::Down(MouseButton::Left),
+                    force.x,
+                    force.y
+                ))
+                .1
+                .is_none()
+            );
+            frame(&mut app, 80, 24);
+            app.input(mouse(MouseEventKind::Down(MouseButton::Left), 0, 0));
+            assert!(app.shutdown.prompt.is_none());
+            assert_eq!(app.drafts["draft"].text(), "unsent draft");
+            app.shutdown.show(Prompt::Failed("uncertain result".into()));
+            frame(&mut app, 80, 24);
+            assert!(!app.hits.iter().any(|hit| hit.action == Action::ConfirmQuit));
+            assert!(!app.enabled(&Action::ConfirmQuit));
+            app.input(key(KeyCode::Tab));
+            assert_eq!(app.input(key(KeyCode::Enter)).1, Some(Action::Detach));
+            app.shutdown = State {
+                stopping: true,
+                ..Default::default()
+            };
+            app.closing = true;
+            app.input(key(KeyCode::Esc));
+            assert!(
+                app.closing,
+                "an admitted shutdown cannot be cancelled by hiding its UI"
+            );
+        }
+    }
+}
diff --git a/crates/tui/src/view.rs b/crates/tui/src/view.rs
index af1695c3a8..25e1d6b0b7 100644
--- a/crates/tui/src/view.rs
+++ b/crates/tui/src/view.rs
@@ -64,6 +64,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
     let animated = app.chrome.motion
         && app.chrome.window_focused
         && !app.closing
+        && app.shutdown.prompt.is_none()
         && app.palette.is_none()
         && !app.interactions.visible
         && app.management.dialog.is_none()
@@ -292,7 +293,11 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
         Focus::Page => app.page_actions().get(app.selected_control).cloned(),
         _ => None,
     };
-    let hint = if app.closing {
+    let hint = if app.shutdown.stopping {
+        app.i18n.text("shutdown-working")
+    } else if app.shutdown.prompt.is_some() {
+        String::new()
+    } else if app.closing {
         app.i18n.text("state-closing")
     } else if app.extensions.consent_visible()
         || app.theme.editor.is_some()
@@ -395,7 +400,9 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
             action: Action::Visit(Route::Host),
         });
     }
-    if app.extensions.consent_visible() {
+    if app.shutdown.prompt.is_some() {
+        crate::shutdown::draw(frame, app, area, base);
+    } else if app.extensions.consent_visible() {
         crate::pages::extensions::draw_consent(frame, app, area, base);
     } else if app.theme.editor.is_some() {
         crate::theme::editor::draw(frame, app, area);
@@ -613,7 +620,7 @@ fn icon(app: &App, action: &Action) -> &'static str {
         Action::CycleLocale => ("文", "L"),
         Action::ToggleSymbols => ("◇", "A"),
         Action::ToggleMotion => ("≈", "M"),
-        Action::Quit => ("×", "X"),
+        Action::Quit | Action::Detach | Action::ConfirmQuit | Action::CancelQuit => ("×", "X"),
     };
     app.chrome.symbol(unicode, ascii)
 }
@@ -752,6 +759,9 @@ fn action_label(app: &App, action: &Action) -> String {
         Action::ToggleSymbols => "command-symbols",
         Action::ToggleMotion => "command-motion",
         Action::Quit => "footer-quit",
+        Action::Detach => "command-detach",
+        Action::ConfirmQuit => "shutdown-force",
+        Action::CancelQuit => "shutdown-cancel",
     };
     app.i18n.text(key)
 }
@@ -886,8 +896,9 @@ fn control(
         return;
     }
     let enabled = app.enabled(&action);
-    let destructive = matches!(action, Action::Manage(crate::pages::manage::Command::Save))
-        && app.management.destructive();
+    let destructive = action == Action::ConfirmQuit
+        || (matches!(action, Action::Manage(crate::pages::manage::Command::Save))
+            && app.management.destructive());
     let caution = matches!(action, Action::Manage(crate::pages::manage::Command::Save))
         && app.sandbox_disabling();
     let style = if !enabled {


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org