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:42 UTC
(maka) branch feat/runtime-host-rust updated (407bbfc0d3 -> 1308b51500)
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a change to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git
from 407bbfc0d3 Merge branch 'feat/rust-tui-resume' into merge/rust-tui-goal
new 88dad0af7d fix(tui): initialize account models and restore modal mouse feedback
new 3ee7d45073 feat(tui): safely stop Host on quit with explicit force confirmation
new 1308b51500 Merge remote-tracking branch 'origin/feat/runtime-host-rust' into feat/runtime-host-rust
The 3 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
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/config/src/oauth/enrollment.rs | 36 +++
crates/config/src/oauth/enrollment/commit.rs | 22 +-
crates/config/tests/oauth_login.rs | 14 +
crates/config/tests/oauth_login/inventory.rs | 149 +++++++++++
crates/runtime-host/src/server/oauth/login.rs | 47 +++-
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 | 55 +++-
crates/tui/src/lib.rs | 75 +++++-
crates/tui/src/pages/commands.rs | 68 ++++-
crates/tui/src/pages/manage.rs | 9 +-
crates/tui/src/pages/manage/connection.rs | 33 +++
crates/tui/src/shutdown.rs | 284 +++++++++++++++++++++
crates/tui/src/view.rs | 21 +-
53 files changed, 1206 insertions(+), 85 deletions(-)
create mode 100644 crates/cli/src/deployment/shutdown.rs
create mode 100644 crates/cli/tests/integration/tui/shutdown.rs
create mode 100644 crates/config/tests/oauth_login/inventory.rs
create mode 100644 crates/tui/src/shutdown.rs
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 02/03: feat(tui): safely stop Host on quit with explicit force confirmation
Posted by wy...@apache.org.
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
(maka) 01/03: fix(tui): initialize account models and restore modal mouse feedback
Posted by wy...@apache.org.
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 88dad0af7d4f839a328abc91c1ba60e14f2828ec
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Thu Sep 24 10:42:26 2026 +0800
fix(tui): initialize account models and restore modal mouse feedback
Generated-by: Codex
---
crates/config/src/oauth/enrollment.rs | 36 +++++++
crates/config/src/oauth/enrollment/commit.rs | 22 +++-
crates/config/tests/oauth_login.rs | 14 +++
crates/config/tests/oauth_login/inventory.rs | 149 ++++++++++++++++++++++++++
crates/runtime-host/src/server/oauth/login.rs | 47 +++++++-
crates/tui/src/app.rs | 25 ++++-
crates/tui/src/pages/commands.rs | 68 ++++++++++--
crates/tui/src/pages/manage.rs | 9 +-
crates/tui/src/pages/manage/connection.rs | 33 ++++++
crates/tui/src/view.rs | 2 +-
10 files changed, 392 insertions(+), 13 deletions(-)
diff --git a/crates/config/src/oauth/enrollment.rs b/crates/config/src/oauth/enrollment.rs
index c16b25b0c7..c9535b219e 100644
--- a/crates/config/src/oauth/enrollment.rs
+++ b/crates/config/src/oauth/enrollment.rs
@@ -57,6 +57,7 @@ pub struct PreparedLogin {
identity: ConnectionIdentity,
credential: Option<CredentialVersionBasis>,
network: NetworkConfiguration,
+ catalog_revision: u64,
}
impl ConfigurationStore {
@@ -146,6 +147,7 @@ impl ConfigurationStore {
identity,
credential,
network,
+ catalog_revision: catalog.revision,
})))
})
})
@@ -162,6 +164,40 @@ impl ConfigurationStore {
}
impl PreparedLogin {
+ /// Apply authenticated inventory before publishing the login. Reauthorization
+ /// refreshes facts but never replaces the user's enabled-model selection.
+ pub fn discovered_models(&mut self, models: Vec<ModelInfo>, now: u64) -> Result<()> {
+ validation::revision(now, false).map_err(ConfigError::Invalid)?;
+ if models.is_empty() || models.len() > 2048 {
+ return Err(ConfigError::Invalid(
+ "discovery requires 1..2048 models".into(),
+ ));
+ }
+ for model in &models {
+ model.validate().map_err(ConfigError::Invalid)?;
+ }
+ let mut after = self.after.clone();
+ after.models = models;
+ after.model_source = Some(ModelDiscoverySource::Fetched);
+ after.models_fetched_at = Some(now);
+ if self.before.is_none() {
+ after.enabled_model_ids = model_catalog::resolve(&after, None)?
+ .into_iter()
+ .filter(|model| model.can_use_as_chat_default)
+ .filter(|model| after.models.iter().any(|info| info.id == model.id))
+ .map(|model| model.id)
+ .collect();
+ } else if self
+ .before
+ .as_ref()
+ .is_some_and(|before| before.revision == after.revision)
+ {
+ after.revision = catalog::next_revision(after.revision)?;
+ }
+ self.after = after;
+ Ok(())
+ }
+
pub fn identity(&self) -> &ConnectionIdentity {
&self.identity
}
diff --git a/crates/config/src/oauth/enrollment/commit.rs b/crates/config/src/oauth/enrollment/commit.rs
index 18c35adf3f..d95cba5003 100644
--- a/crates/config/src/oauth/enrollment/commit.rs
+++ b/crates/config/src/oauth/enrollment/commit.rs
@@ -34,6 +34,7 @@ impl PreparedLogin {
identity,
credential,
network: _,
+ catalog_revision,
} = self;
store
.transaction(TransactionMode::Immediate, move |tx| {
@@ -71,9 +72,26 @@ impl PreparedLogin {
});
}
if before.as_ref() != Some(&after) {
+ // Only initialize a new connection. A re-login, another
+ // default choice or an explicit concurrent clear wins.
+ let mut default = catalog.default_target.clone();
+ if before.is_none()
+ && default.is_none()
+ && catalog.revision == catalog_revision
+ {
+ default = model_catalog::resolve(&after, None)?
+ .into_iter()
+ .find(|model| {
+ model.can_use_as_chat_default
+ && after.enabled_model_ids.contains(&model.id)
+ })
+ .map(|model| ConnectionTarget {
+ connection_id: after.connection_id.clone(),
+ model_id: model.id,
+ });
+ }
catalog::write_entry(tx, &after).await?;
- catalog::advance(tx, catalog.revision, catalog.default_target.as_ref())
- .await?;
+ catalog::advance(tx, catalog.revision, default.as_ref()).await?;
}
vault::replace_secret(tx, &locator, &secret, now).await?;
vault::advance(tx).await?;
diff --git a/crates/config/tests/oauth_login.rs b/crates/config/tests/oauth_login.rs
index 73f2f36e87..bcc08a0c0f 100644
--- a/crates/config/tests/oauth_login.rs
+++ b/crates/config/tests/oauth_login.rs
@@ -160,6 +160,17 @@ async fn all_providers_publish_atomically_and_replay_bounded_receipts_after_reop
}
assert_eq!(snapshot.revision, before.revision + 1);
assert!(snapshot.connections.contains(&after));
+ if before.default_target.is_none() {
+ assert_eq!(
+ snapshot.default_target,
+ Some(ConnectionTarget {
+ connection_id: after.connection_id.clone(),
+ model_id: after.enabled_model_ids[0].clone(),
+ })
+ );
+ } else {
+ assert_eq!(snapshot.default_target, before.default_target);
+ }
let target = ConnectionCredentialTarget {
connection_id: identity.connection_id.clone(),
revision: after.revision,
@@ -252,3 +263,6 @@ mod recovery;
#[path = "oauth_login/discovery.rs"]
mod discovery;
+
+#[path = "oauth_login/inventory.rs"]
+mod inventory;
diff --git a/crates/config/tests/oauth_login/inventory.rs b/crates/config/tests/oauth_login/inventory.rs
new file mode 100644
index 0000000000..55cba23b3c
--- /dev/null
+++ b/crates/config/tests/oauth_login/inventory.rs
@@ -0,0 +1,149 @@
+/*
+ * 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::*;
+
+fn create(attempt: &str) -> LoginStart {
+ LoginStart {
+ attempt_id: attempt.into(),
+ target: Target::Create {
+ provider_type: Provider::OpenaiCodex,
+ slug: None,
+ name: None,
+ },
+ }
+}
+
+fn inventory() -> Vec<ModelInfo> {
+ vec![
+ ModelInfo {
+ capabilities: Some(ModelCapabilities {
+ chat: Some(false),
+ ..Default::default()
+ }),
+ ..ModelInfo::new("not-chat")
+ },
+ ModelInfo::new("gpt-6-sol"),
+ ModelInfo::new("gpt-6-luna"),
+ ]
+}
+
+#[tokio::test]
+async fn account_inventory_initializes_chat_choices_without_overwriting_user_intent() {
+ let temp = tempfile::tempdir().unwrap();
+ let store = open(temp.path(), true).await;
+ let mut ticket = prepare(&store, create("first")).await;
+ ticket.discovered_models(inventory(), 10).unwrap();
+ let id = ticket.identity().connection_id.clone();
+ assert!(matches!(
+ ticket.complete("grant".into(), 11).await.unwrap(),
+ LoginCompletion::Committed(_)
+ ));
+ let first = store.catalog().await.unwrap();
+ let row = &first.connections[0];
+ assert_eq!(row.models, inventory());
+ assert_eq!(row.enabled_model_ids, ["gpt-6-sol", "gpt-6-luna"]);
+ assert_eq!(row.model_source, Some(ModelDiscoverySource::Fetched));
+ assert_eq!(
+ first.default_target,
+ Some(ConnectionTarget {
+ connection_id: id.clone(),
+ model_id: "gpt-6-sol".into(),
+ })
+ );
+ assert_eq!(
+ first.revision, 1,
+ "inventory, default and login publish together"
+ );
+
+ // A user deselects a model and explicitly clears the default.
+ let edited = store
+ .update_connection(UpdateCatalogConnectionInput {
+ expected: ConnectionVersionBasis {
+ connection_id: id.clone(),
+ revision: row.revision,
+ },
+ changes: ConnectionCatalogEntryUpdate {
+ name: row.name.clone(),
+ base_url: row.base_url.clone(),
+ enabled: true,
+ enabled_model_ids: vec!["gpt-6-luna".into()],
+ model_overrides: Patch::Keep,
+ request_body_overlay: Patch::Keep,
+ },
+ })
+ .await
+ .unwrap();
+ assert!(matches!(edited, CatalogMutationResult::Committed { .. }));
+ let revision = store.catalog().await.unwrap().revision;
+ assert!(matches!(
+ store
+ .set_default_target(SetDefaultConnectionTargetInput {
+ expected_catalog_revision: revision,
+ target: None,
+ })
+ .await
+ .unwrap(),
+ CatalogMutationResult::Committed { .. }
+ ));
+ let mut ticket = prepare(&store, existing("again", &id)).await;
+ let mut models = inventory();
+ models.push(ModelInfo::new("future-model-not-in-build"));
+ ticket.discovered_models(models.clone(), 12).unwrap();
+ assert!(matches!(
+ ticket.complete("new-grant".into(), 13).await.unwrap(),
+ LoginCompletion::Committed(_)
+ ));
+ let after = store.catalog().await.unwrap();
+ assert_eq!(after.connections[0].models, models);
+ assert_eq!(after.connections[0].enabled_model_ids, ["gpt-6-luna"]);
+ assert!(
+ after.default_target.is_none(),
+ "reauthorization preserves explicit clear"
+ );
+
+ // A clear admitted while the browser is open also wins over initialization.
+ let mut ticket = prepare(&store, create("concurrent-clear")).await;
+ ticket.discovered_models(inventory(), 14).unwrap();
+ store
+ .set_default_target(SetDefaultConnectionTargetInput {
+ expected_catalog_revision: after.revision,
+ target: None,
+ })
+ .await
+ .unwrap();
+ assert!(matches!(
+ ticket.complete("other-grant".into(), 15).await.unwrap(),
+ LoginCompletion::Committed(_)
+ ));
+ assert!(store.catalog().await.unwrap().default_target.is_none());
+
+ // Invalid enrichment leaves the ticket usable with its original fallback.
+ let mut ticket = prepare(&store, create("bad-inventory")).await;
+ let before = ticket.connection().clone();
+ assert!(
+ ticket
+ .discovered_models(vec![ModelInfo::new("")], 16)
+ .is_err()
+ );
+ assert_eq!(ticket.connection(), &before);
+ assert!(ticket.discovered_models(vec![], 16).is_err());
+ assert_eq!(ticket.connection(), &before);
+ store.shutdown().await.unwrap();
+}
diff --git a/crates/runtime-host/src/server/oauth/login.rs b/crates/runtime-host/src/server/oauth/login.rs
index 7389c7d6f6..e31d48a743 100644
--- a/crates/runtime-host/src/server/oauth/login.rs
+++ b/crates/runtime-host/src/server/oauth/login.rs
@@ -30,13 +30,58 @@ use std::{sync::atomic::Ordering, time::Duration};
pub(super) async fn run(
host: &Host,
attempt: &Attempt,
- ticket: PreparedLogin,
+ mut ticket: PreparedLogin,
registration: Arc<Registration>,
) -> Phase {
let result = authorize(host, attempt, &ticket, registration).await;
+ if let Ok(tokens) = &result {
+ // Inventory is optional enrichment, not another authorization boundary.
+ // Failure must not discard an already spent grant. No database/admission
+ // lock is held during this bounded, cancellable read.
+ tokio::select! {
+ biased;
+ _ = attempt.cancellation.cancelled() => {}
+ _ = host.draining.cancelled() => {}
+ _ = discover(&mut ticket, tokens) => {}
+ }
+ }
settle(host, attempt, ticket, result).await
}
+async fn discover(ticket: &mut PreparedLogin, tokens: &maka_model::oauth::Tokens) {
+ use maka_model::connection::DiscoveryRequest;
+ let Ok(facts) = maka_config::model_catalog::provider_facts(&ticket.connection().provider_type)
+ else {
+ return;
+ };
+ let Some(kind) = facts.native_model_list() else {
+ return;
+ };
+ let Ok(client) = super::super::connection_effects::client(ticket.network_configuration())
+ else {
+ return;
+ };
+ let endpoint = ticket
+ .connection()
+ .base_url
+ .as_deref()
+ .unwrap_or(&facts.base_url);
+ let Ok(models) = client
+ .fetch(DiscoveryRequest {
+ kind,
+ base_url: endpoint,
+ credential: &tokens.access_token,
+ headers: &Default::default(),
+ })
+ .await
+ else {
+ return;
+ };
+ if let Ok(now) = super::super::configuration::now() {
+ let _ = ticket.discovered_models(models, now);
+ }
+}
+
async fn settle(
host: &Host,
attempt: &Attempt,
diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs
index 468dc1f6b6..9ef62aebc3 100644
--- a/crates/tui/src/app.rs
+++ b/crates/tui/src/app.rs
@@ -1169,7 +1169,30 @@ impl App {
if keyboard && let Some(reader) = self.chat.reader_mut() {
reader.text_selection.end_drag();
}
- let outcome = self.dispatch_input(event);
+ // Modal handlers own activation/focus, but all shared controls need the
+ // same hover feedback. Only the top overlay's rendered hits are eligible.
+ let mut hover_changed = false;
+ if self.modal_area.is_some()
+ && let Event::Mouse(mouse) = &event
+ && mouse.kind == MouseEventKind::Moved
+ {
+ let point = Position::new(mouse.column, mouse.row);
+ let hit = self.hits.iter().rev().find(|hit| {
+ self.modal_area.is_some_and(|area| area.contains(point))
+ && hit.area.contains(point)
+ && self.enabled(&hit.action)
+ });
+ let target = hit.map(|hit| hit.action.clone());
+ let area = hit.map(|hit| hit.area);
+ hover_changed = self.hover != target || self.hover_area != area;
+ if hover_changed {
+ self.hover = target;
+ self.hover_area = area;
+ self.hover_since = None; // Feedback, not an unsolicited tooltip.
+ }
+ }
+ let mut outcome = self.dispatch_input(event);
+ outcome.0 |= hover_changed;
if mouse
&& outcome.0
&& self.focus == Focus::Transcript
diff --git a/crates/tui/src/pages/commands.rs b/crates/tui/src/pages/commands.rs
index 69108ba412..024cd65d58 100644
--- a/crates/tui/src/pages/commands.rs
+++ b/crates/tui/src/pages/commands.rs
@@ -144,11 +144,17 @@ impl App {
if list.contains((mouse.column, mouse.row).into()) =>
{
state.dragging = false;
- self.palette = Some(if mouse.kind == MouseEventKind::ScrollDown {
- (selected + 1).min(last)
+ let capacity = usize::from(list.height).max(1);
+ state.top = if mouse.kind == MouseEventKind::ScrollDown {
+ (state.top + 3).min(items.len().saturating_sub(capacity))
} else {
- selected.saturating_sub(1)
- });
+ state.top.saturating_sub(3)
+ };
+ self.palette = Some(
+ selected.clamp(state.top, (state.top + capacity - 1).min(last)),
+ );
+ self.hover = None;
+ self.hover_area = None;
}
MouseEventKind::Down(MouseButton::Left)
| MouseEventKind::Drag(MouseButton::Left)
@@ -307,7 +313,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App, area: Rect, base: Style) {
.begin_symbol(None)
.end_symbol(None)
.track_symbol(Some(app.chrome.symbol("│", "|")))
- .thumb_symbol(app.chrome.symbol("┃", "#"))
+ .thumb_symbol(app.chrome.symbol("█", "#"))
.track_style(Style::default().fg(colors.subtle))
.thumb_style(Style::default().fg(colors.accent));
let mut state = ScrollbarState::new(items.len() - capacity + 1)
@@ -358,9 +364,54 @@ mod tests {
app.apply(Action::Palette);
frame(&mut app, 52, 18);
let list = app.command_palette.list.unwrap();
- assert!(!app.input(mouse(MouseEventKind::Moved, list.x, list.y)).0);
+ let selected = app.palette;
+ assert_eq!(
+ app.input(mouse(MouseEventKind::Moved, list.x, list.y)),
+ (true, None)
+ );
+ assert_eq!(
+ app.palette, selected,
+ "hover highlights without changing the keyboard choice"
+ );
+ assert!(app.hover.is_some());
+ assert!(
+ !app.input(mouse(MouseEventKind::Moved, list.x, list.y)).0,
+ "unchanged hover does not redraw"
+ );
let count = app.commands().len();
assert!(count > usize::from(list.height));
+ app.input(mouse(MouseEventKind::ScrollDown, list.x, list.y));
+ frame(&mut app, 52, 18);
+ assert_eq!(
+ app.command_palette.top, 3,
+ "wheel scrolls immediately, not after selection reaches the bottom"
+ );
+ assert!(app.hover.is_none());
+ for _ in 0..count {
+ if app.hits.iter().any(|hit| hit.action == Action::Quit) {
+ break;
+ }
+ app.input(mouse(MouseEventKind::ScrollDown, list.x, list.y));
+ frame(&mut app, 52, 18);
+ }
+ let quit = app
+ .hits
+ .iter()
+ .find(|hit| hit.action == Action::Quit)
+ .expect("quit is reachable by scrolling")
+ .area;
+ assert_eq!(
+ app.input(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ quit.x,
+ quit.y
+ ))
+ .1,
+ Some(Action::Quit)
+ );
+ assert_eq!(app.drafts["draft"].text(), "preserved draft");
+ app.apply(Action::Palette);
+ frame(&mut app, 52, 18);
assert!(
app.input(mouse(
MouseEventKind::Down(MouseButton::Left),
@@ -431,6 +482,11 @@ mod tests {
assert_eq!(app.drafts["draft"].text(), "preserved draft");
app.apply(Action::Palette);
assert!(app.command_palette.query.is_empty());
+ app.input(Event::Paste(app.i18n.text("command-quit")));
+ frame(&mut app, 52, 18);
+ assert_eq!(app.commands(), vec![(Action::Quit, "command-quit")]);
+ assert_eq!(app.input(key(KeyCode::Enter)).1, Some(Action::Quit));
+ app.apply(Action::Palette);
app.input(Event::Paste("🦀".repeat(200)));
assert!(app.command_palette.query.len() <= 512);
frame(&mut app, 20, 6);
diff --git a/crates/tui/src/pages/manage.rs b/crates/tui/src/pages/manage.rs
index 3762df47a8..201b5cc994 100644
--- a/crates/tui/src/pages/manage.rs
+++ b/crates/tui/src/pages/manage.rs
@@ -1420,8 +1420,13 @@ impl App {
}
impl Management {
- pub(crate) fn is_removal(&self) -> bool {
- self.dialog.as_ref().is_some_and(|d| d.kind == Kind::Remove)
+ pub(crate) fn destructive(&self) -> bool {
+ self.dialog.as_ref().is_some_and(|dialog| {
+ matches!(
+ dialog.kind,
+ Kind::Remove | Kind::Connection(connection::Change::Remove)
+ )
+ })
}
pub fn invalidate_geometry(&mut self) {
if let Some(dialog) = &mut self.dialog {
diff --git a/crates/tui/src/pages/manage/connection.rs b/crates/tui/src/pages/manage/connection.rs
index c812f4eef4..7dc556afdc 100644
--- a/crates/tui/src/pages/manage/connection.rs
+++ b/crates/tui/src/pages/manage/connection.rs
@@ -505,6 +505,39 @@ mod tests {
}
let mut screen = Terminal::new(TestBackend::new(80, 24)).unwrap();
screen.draw(|f| crate::view::draw(f, &mut app)).unwrap();
+ let save = app
+ .hits
+ .iter()
+ .find(|hit| hit.action == Action::Manage(Command::Save))
+ .unwrap()
+ .area;
+ let normal = screen.backend().buffer()[(save.x, save.y)].style();
+ let hover = |x, y| {
+ Event::Mouse(crossterm::event::MouseEvent {
+ kind: crossterm::event::MouseEventKind::Moved,
+ column: x,
+ row: y,
+ modifiers: KeyModifiers::NONE,
+ })
+ };
+ assert!(app.input(hover(save.x, save.y)).0);
+ assert!(app.management.pending.is_none(), "hover never submits");
+ assert_eq!(
+ app.management.dialog.as_ref().unwrap().focus,
+ 0,
+ "hover does not move keyboard focus"
+ );
+ screen.draw(|f| crate::view::draw(f, &mut app)).unwrap();
+ let highlighted = screen.backend().buffer()[(save.x, save.y)].style();
+ assert_ne!(normal.bg, highlighted.bg);
+ if change == Change::Remove {
+ assert_eq!(normal.fg, Some(app.theme.colors().error));
+ assert_eq!(highlighted.fg, normal.fg);
+ }
+ assert!(app.input(hover(0, 0)).0);
+ assert!(app.hover.is_none(), "no hover leaks to the covered page");
+ screen.draw(|f| crate::view::draw(f, &mut app)).unwrap();
+ assert_eq!(screen.backend().buffer()[(save.x, save.y)].style(), normal);
app.input(Event::Key(KeyEvent::new(
KeyCode::Enter,
KeyModifiers::NONE,
diff --git a/crates/tui/src/view.rs b/crates/tui/src/view.rs
index 342284a0df..af1695c3a8 100644
--- a/crates/tui/src/view.rs
+++ b/crates/tui/src/view.rs
@@ -887,7 +887,7 @@ fn control(
}
let enabled = app.enabled(&action);
let destructive = matches!(action, Action::Manage(crate::pages::manage::Command::Save))
- && app.management.is_removal();
+ && 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
(maka) 03/03: Merge remote-tracking branch 'origin/feat/runtime-host-rust' into feat/runtime-host-rust
Posted by wy...@apache.org.
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 1308b515008a0b70abc09f74659ea879ad8f83de
Merge: 3ee7d45073 407bbfc0d3
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Thu Sep 24 10:57:54 2026 +0800
Merge remote-tracking branch 'origin/feat/runtime-host-rust' into feat/runtime-host-rust
Generated-by: Codex
crates/client/src/operations.rs | 29 +++
crates/client/src/turn.rs | 43 ++++
crates/goal/src/plugin.rs | 4 +-
crates/goal/src/plugin/remote.rs | 2 +-
crates/goal/src/plugin/terminal.rs | 457 ++++++++++++++++++++++++++++++++++
crates/tui/locales/en.ftl | 21 ++
crates/tui/locales/zh-CN.ftl | 21 ++
crates/tui/locales/zh-TW.ftl | 21 ++
crates/tui/src/app.rs | 19 ++
crates/tui/src/lib.rs | 40 +++
crates/tui/src/pages.rs | 1 +
crates/tui/src/pages/resume.rs | 480 ++++++++++++++++++++++++++++++++++++
crates/tui/src/pages/resume/view.rs | 185 ++++++++++++++
crates/tui/src/state.rs | 60 ++++-
crates/tui/src/state/snapshot.rs | 9 +
crates/tui/src/view.rs | 6 +
crates/tui/src/view/session.rs | 1 +
17 files changed, 1396 insertions(+), 3 deletions(-)
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org