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(¤t.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