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/23 12:35:06 UTC
(maka) branch feat/runtime-host-rust updated (2309bbb072 -> dc19862e63)
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 2309bbb072 fix(cli): match macOS PTY test FFI signatures
new 8d4efeefd4 feat(tui): branch selected turns with durable recovery
new dc19862e63 fix(tui): keep branch recovery controls stable and reachable
The 2 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/tests/integration/tui.rs | 1 +
crates/cli/tests/integration/tui/branch.rs | 177 +++++++
crates/cli/tests/integration/tui/reading.rs | 2 +-
crates/tui/locales/en.ftl | 12 +
crates/tui/locales/zh-CN.ftl | 12 +
crates/tui/locales/zh-TW.ftl | 12 +
crates/tui/src/app.rs | 19 +
crates/tui/src/lib.rs | 36 ++
crates/tui/src/pages.rs | 1 +
crates/tui/src/pages/branch.rs | 655 +++++++++++++++++++++++++
crates/tui/src/pages/branch/view.rs | 204 ++++++++
crates/tui/src/pages/chat/render/navigation.rs | 9 +
crates/tui/src/state.rs | 109 +++-
crates/tui/src/state/snapshot.rs | 18 +-
crates/tui/src/view.rs | 6 +
15 files changed, 1267 insertions(+), 6 deletions(-)
create mode 100644 crates/cli/tests/integration/tui/branch.rs
create mode 100644 crates/tui/src/pages/branch.rs
create mode 100644 crates/tui/src/pages/branch/view.rs
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 01/02: feat(tui): branch selected turns with durable recovery
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 8d4efeefd4d6fec073772eae9a8efa4d86b19d40
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Wed Sep 23 20:18:12 2026 +0800
feat(tui): branch selected turns with durable recovery
Add a focused, localized confirmation with safe mouse dismissal, checkpoint-gated creation, exact receipt reconciliation, and explicit navigation preserving source drafts. Verify native Host history boundaries and restart behavior through PTY tests.
Generated-by: Codex
---
crates/cli/tests/integration/tui.rs | 1 +
crates/cli/tests/integration/tui/branch.rs | 176 +++++++
crates/cli/tests/integration/tui/reading.rs | 2 +-
crates/tui/locales/en.ftl | 12 +
crates/tui/locales/zh-CN.ftl | 12 +
crates/tui/locales/zh-TW.ftl | 12 +
crates/tui/src/app.rs | 19 +
crates/tui/src/lib.rs | 36 ++
crates/tui/src/pages.rs | 1 +
crates/tui/src/pages/branch.rs | 623 +++++++++++++++++++++++++
crates/tui/src/pages/branch/view.rs | 212 +++++++++
crates/tui/src/pages/chat/render/navigation.rs | 9 +
crates/tui/src/state.rs | 109 ++++-
crates/tui/src/state/snapshot.rs | 18 +-
crates/tui/src/view.rs | 6 +
15 files changed, 1242 insertions(+), 6 deletions(-)
diff --git a/crates/cli/tests/integration/tui.rs b/crates/cli/tests/integration/tui.rs
index ad95f3b8f3..fb5c604b93 100644
--- a/crates/cli/tests/integration/tui.rs
+++ b/crates/cli/tests/integration/tui.rs
@@ -19,6 +19,7 @@
#![cfg(unix)]
+mod branch;
mod changes;
mod connection_test;
mod connections;
diff --git a/crates/cli/tests/integration/tui/branch.rs b/crates/cli/tests/integration/tui/branch.rs
new file mode 100644
index 0000000000..ca4f931b05
--- /dev/null
+++ b/crates/cli/tests/integration/tui/branch.rs
@@ -0,0 +1,176 @@
+/*
+ * 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::*;
+use maka_protocol::{
+ Operation,
+ session::{copy, decode_session_create_input},
+};
+use serde_json::json;
+
+#[test]
+fn branch_preserves_selected_history_and_draft_and_reopens_by_original_receipt() {
+ let directory = tempfile::tempdir().unwrap();
+ let mut host = super::super::candidate::CandidateFixture::new(directory.path().join("root"));
+ 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();
+ let runtime = tokio::runtime::Runtime::new().unwrap();
+ let client = runtime.block_on(async {
+ use tokio::{net::TcpListener, io::AsyncWriteExt};
+ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let client = support::model_client(&host.root,&format!("http://{}/v1",listener.local_addr().unwrap())).await;
+ client.create_session(decode_session_create_input(&json!({
+ "sessionId":"branch-source","name":"Branch source","workspace":{"kind":"host_path","path":directory.path()},"modelTarget":{"kind":"default"}
+ })).unwrap()).await.unwrap();
+ let model = tokio::spawn(async move {
+ for content in ["First reply.","Later reply."] {
+ let (mut stream,_) = model_request(&listener).await;
+ let frame = json!({"id":"branch-fixture","object":"chat.completion.chunk","model":"fixture-model",
+ "choices":[{"index":0,"delta":{"content":content},"finish_reason":"stop"}]});
+ stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\ndata: {frame}\n\ndata: [DONE]\n\n").as_bytes()).await.unwrap();
+ }
+ });
+ for (turn,text) in [("first-turn","Selected branch point"),("later-turn","Later excluded prompt")] {
+ client.request(Operation::TurnStart,json!({"sessionId":"branch-source","turnId":turn,"content":{"text":text},"maxSteps":1})).await.unwrap();
+ tokio::time::timeout(Duration::from_secs(15),async {
+ loop {
+ let output = client.request(Operation::TurnQuery,json!({"sessionId":"branch-source","turnId":turn})).await.unwrap();
+ match output["status"].as_str() {
+ Some("completed") => break,
+ Some("failed" | "cancelled") => panic!("fixture turn failed"),
+ _ => tokio::time::sleep(Duration::from_millis(20)).await,
+ }
+ }
+ }).await.unwrap();
+ }
+ model.await.unwrap();
+ client
+ });
+ let before = runtime
+ .block_on(client.session("branch-source"))
+ .unwrap()
+ .unwrap();
+ let mut tui = Pty::spawn(&["--root", host.root.to_str().unwrap()]);
+ tui.wait_for("Branch source");
+ tui.click_text("Branch source");
+ tui.wait_for("Later reply.");
+ tui.send(b"Preserved branch draft");
+ tui.wait_for("Preserved branch draft");
+ tui.click_text("Selected branch point");
+ tui.send(b"\x10");
+ tui.wait_for("Branch from this turn");
+ tui.click_text("Branch from this turn");
+ tui.wait_for("same working directory.");
+ tui.send(b"\r"); // Initial focus is Cancel.
+ tui.wait_until(|s| !s.contains("same working directory."));
+ tui.send(b"\x10");
+ tui.wait_for("Branch from this turn");
+ tui.click_text("Branch from this turn");
+ tui.wait_for("same working directory.");
+ tui.click_text("Create branch");
+ tui.wait_for("Branch is ready.");
+ tui.send(b"\x11");
+ tui.finish();
+ let checkpoint = directory
+ .path()
+ .join("tui-state")
+ .join(&client.identity.root_id)
+ .join("default/state.json");
+ let saved: serde_json::Value =
+ serde_json::from_slice(&std::fs::read(&checkpoint).unwrap()).unwrap();
+ let request: copy::Input = serde_json::from_value(saved["branch"]["input"].clone()).unwrap();
+ assert_eq!(request.source_session_id, "branch-source");
+ assert_eq!(
+ request.purpose,
+ copy::Purpose::Branch {
+ turn_id: Some("first-turn".into()),
+ side_conversation: false
+ }
+ );
+ runtime.block_on(async {
+ let receipt = client
+ .query_session_copy(request.clone())
+ .await
+ .unwrap()
+ .receipt
+ .unwrap();
+ assert_eq!(receipt.request, request);
+ let original = client.session("branch-source").await.unwrap().unwrap();
+ assert_eq!(
+ original.revision, before.revision,
+ "branching normal source does not rewrite it"
+ );
+ let target = client
+ .session(&request.target_session_id)
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(
+ target.workspace, original.workspace,
+ "branch is not a filesystem copy"
+ );
+ });
+ let mut reopened = Pty::spawn(&["--root", host.root.to_str().unwrap()]);
+ reopened.wait_for("Preserved branch draft");
+ reopened.send(b"\x10");
+ reopened.wait_for("Check session branch");
+ reopened.click_text("Check session branch");
+ reopened.wait_for("Result not yet confirmed.");
+ reopened.click_text("Check result");
+ reopened.wait_for("Branch is ready.");
+ reopened.click_text("Open branch");
+ reopened.wait_until(|screen| {
+ screen.contains("First reply.")
+ && !screen.contains("Branch is ready.")
+ && !screen.contains("Preserved branch draft")
+ });
+ let screen = reopened.screen.snapshot().unwrap().screen;
+ assert!(screen.contains("Selected branch point"));
+ assert!(
+ !screen.contains("Later excluded prompt") && !screen.contains("Later reply."),
+ "branch includes only selected history: {screen}"
+ );
+ assert!(
+ !screen.contains("Preserved branch draft"),
+ "branch does not adopt original draft"
+ );
+ reopened.send(b"\x11");
+ reopened.finish();
+ let saved: serde_json::Value =
+ serde_json::from_slice(&std::fs::read(checkpoint).unwrap()).unwrap();
+ assert!(saved["branch"].is_null());
+ assert_eq!(saved["route"]["session"], request.target_session_id);
+ let receipt = runtime
+ .block_on(client.query_session_copy(request))
+ .unwrap()
+ .receipt
+ .unwrap();
+ assert_eq!(receipt.request.source_session_id, "branch-source");
+ client.disconnect();
+ host.retire_registered();
+}
diff --git a/crates/cli/tests/integration/tui/reading.rs b/crates/cli/tests/integration/tui/reading.rs
index 9deb32c62f..8516afc8ed 100644
--- a/crates/cli/tests/integration/tui/reading.rs
+++ b/crates/cli/tests/integration/tui/reading.rs
@@ -151,7 +151,7 @@ fn old_window_selection_and_search_resume_across_pages_and_disk_restart() {
.unwrap(),
)
.unwrap();
- assert_eq!(saved["version"], 5);
+ assert_eq!(saved["version"], 6);
let encoded = serde_json::to_string(&saved).unwrap();
assert!(
!encoded.contains("History context line"),
diff --git a/crates/tui/locales/en.ftl b/crates/tui/locales/en.ftl
index 509ca1e89a..874102a104 100644
--- a/crates/tui/locales/en.ftl
+++ b/crates/tui/locales/en.ftl
@@ -704,3 +704,15 @@ theme-role-number = Number
theme-role-function = Function
theme-role-type = Type
theme-role-operator = Operator
+
+branch-title = Branch from this turn
+branch-resume = Check session branch
+branch-create = Create branch
+branch-query = Check result
+branch-open = Open branch
+branch-note = Keep the conversation through the selected turn. Both sessions share the same working directory.
+branch-wait = Creating branch…
+branch-unknown = Result not yet confirmed. Check this request before creating another branch.
+branch-save-failed = Could not save the recovery identity. No creation request was sent.
+branch-ready = Branch is ready. Your original conversation and draft are unchanged.
+branch-unavailable = The source changed or this branch is unavailable. Close and select the turn again.
diff --git a/crates/tui/locales/zh-CN.ftl b/crates/tui/locales/zh-CN.ftl
index 35d6673568..6b987aa862 100644
--- a/crates/tui/locales/zh-CN.ftl
+++ b/crates/tui/locales/zh-CN.ftl
@@ -704,3 +704,15 @@ theme-role-number = 数字
theme-role-function = 函数
theme-role-type = 类型
theme-role-operator = 运算符
+
+branch-title = 从此轮分支
+branch-resume = 查看会话分支
+branch-create = 创建分支
+branch-query = 查询结果
+branch-open = 打开分支
+branch-note = 保留到所选轮次的对话。两个会话共用同一个工作目录。
+branch-wait = 正在创建分支…
+branch-unknown = 结果尚未确认,请先查询本次请求,不要重复创建。
+branch-save-failed = 无法保存恢复信息,尚未发送创建请求。
+branch-ready = 分支已就绪,原对话和草稿保持不变。
+branch-unavailable = 来源已变化或分支不可用,请关闭后重新选择轮次。
diff --git a/crates/tui/locales/zh-TW.ftl b/crates/tui/locales/zh-TW.ftl
index f83de9cb7d..95e98aa249 100644
--- a/crates/tui/locales/zh-TW.ftl
+++ b/crates/tui/locales/zh-TW.ftl
@@ -704,3 +704,15 @@ theme-role-number = 數字
theme-role-function = 函式
theme-role-type = 類型
theme-role-operator = 運算子
+
+branch-title = 從此輪分支
+branch-resume = 檢視對話分支
+branch-create = 建立分支
+branch-query = 查詢結果
+branch-open = 開啟分支
+branch-note = 保留到所選輪次的對話。兩個對話共用同一個工作目錄。
+branch-wait = 正在建立分支…
+branch-unknown = 結果尚未確認,請先查詢本次請求,不要重複建立。
+branch-save-failed = 無法儲存復原資訊,尚未送出建立請求。
+branch-ready = 分支已就緒,原對話與草稿保持不變。
+branch-unavailable = 來源已變更或分支無法使用,請關閉後重新選擇輪次。
diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs
index a1403ee07e..f05eb4bab7 100644
--- a/crates/tui/src/app.rs
+++ b/crates/tui/src/app.rs
@@ -60,6 +60,7 @@ pub enum Action {
Search(crate::pages::chat::render::search::Command),
Copy(crate::pages::chat::render::selection::CopyMode),
CopyFile(String),
+ Branch(crate::pages::branch::Command),
ToggleSymbols,
ToggleMotion,
RefreshSessions,
@@ -124,6 +125,7 @@ pub struct App {
pub sessions: crate::pages::sessions::Sessions,
pub inbox: crate::pages::sessions::Sessions,
pub management: crate::pages::manage::Management,
+ pub branch: crate::pages::branch::State,
pub onboarding: crate::pages::onboarding::Onboarding,
pub projects: crate::pages::projects::Projects,
pub connections: crate::pages::connections::Connections,
@@ -167,6 +169,7 @@ impl App {
sessions: Default::default(),
inbox: crate::pages::sessions::Sessions::inbox(),
management: Default::default(),
+ branch: Default::default(),
onboarding: Default::default(),
projects: Default::default(),
connections: Default::default(),
@@ -216,6 +219,7 @@ impl App {
(Action::Quit, "command-quit"),
];
commands.extend(self.management_commands());
+ commands.extend(self.branch_commands());
commands.extend(self.oauth_commands());
if let Some(action) = self.default_model_action() {
commands.push((action, "default-model-title"));
@@ -417,6 +421,7 @@ impl App {
|| self.inbox.error.is_some())
}
pub fn begin_frame(&mut self, area: Rect) {
+ self.branch.invalidate_geometry();
for item in &self.sessions.items {
self.tabs.rename(&item.id, &item.name);
}
@@ -439,6 +444,7 @@ impl App {
}
pub fn tooltip_wait(&self) -> Option<Duration> {
if self.theme.editor.is_some()
+ || self.branch.visible
|| !self.has_tooltip()
|| self.palette.is_some()
|| self.interactions.visible
@@ -452,6 +458,7 @@ impl App {
}
pub fn selection_wait(&self, now: Instant) -> Option<Duration> {
if self.theme.editor.is_some()
+ || self.branch.visible
|| !matches!(self.navigation.current(), Route::Session(_))
|| self.palette.is_some()
|| self.chrome.details
@@ -473,6 +480,7 @@ impl App {
}
pub fn tooltip_visible(&self) -> bool {
self.theme.editor.is_none()
+ && !self.branch.visible
&& self.palette.is_none()
&& self.management.dialog.is_none()
&& self.onboarding.dialog.is_none()
@@ -543,6 +551,7 @@ impl App {
self.hover = None;
}
Action::Manage(command) => return self.management_action(command),
+ Action::Branch(command) => return self.branch_action(command),
Action::Onboard(command) => return self.onboarding_action(command),
Action::Project(command) => return self.project_action(command),
Action::Connection(command) => self.connection_action(command),
@@ -725,6 +734,9 @@ impl App {
None
}
pub fn enabled(&self, action: &Action) -> bool {
+ if let Action::Branch(command) = action {
+ return self.branch_enabled(command);
+ }
if let Action::Connection(command) = action {
return self.connection_enabled(command);
}
@@ -968,6 +980,7 @@ impl App {
}
self.management.invalidate_geometry();
self.management.oauth.invalidate_identity_geometry();
+ self.branch.invalidate_geometry();
self.onboarding.invalidate_geometry();
self.tabs.invalidate_geometry();
if let Some(reader) = self.chat.reader_mut() {
@@ -1053,6 +1066,7 @@ impl App {
if let Event::Mouse(mouse) = &event
&& mouse.kind == MouseEventKind::Down(MouseButton::Left)
&& (self.theme.editor.is_some()
+ || self.branch.visible
|| self.palette.is_some()
|| self.onboarding.dialog.is_some()
|| self.management.dialog.is_some()
@@ -1065,6 +1079,8 @@ impl App {
// Dismiss only the displayed overlay. Never forward this press to the page.
let action = if self.theme.editor.is_some() {
Some(Action::Theme(crate::theme::editor::Command::Close))
+ } else if self.branch.visible {
+ Some(Action::Branch(crate::pages::branch::Command::Close))
} else if self.onboarding.dialog.is_some() {
Some(Action::Onboard(crate::pages::onboarding::Command::Close))
} else if self.management.dialog.is_some() {
@@ -1089,6 +1105,9 @@ impl App {
if self.theme.editor.is_some() && !matches!(event, Event::Resize(_, _)) {
return self.theme_input(event);
}
+ if self.branch.visible && !matches!(event, Event::Resize(_, _)) {
+ return self.branch_input(event);
+ }
if self.onboarding.dialog.is_some() && !matches!(event, Event::Resize(_, _)) {
return self.onboarding_input(event);
}
diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs
index b091585964..cbe44bfeca 100644
--- a/crates/tui/src/lib.rs
+++ b/crates/tui/src/lib.rs
@@ -48,6 +48,10 @@ pub struct Options {
}
enum Completed {
+ Branched(
+ pages::branch::Request,
+ Result<pages::branch::Output, maka_client::RequestFailure>,
+ ),
Removal(
pages::manage::removal::Request,
Result<pages::manage::removal::Output, maka_client::RequestFailure>,
@@ -197,6 +201,23 @@ pub async fn run(options: Options) -> Result<(), Error> {
state.start(&app);
}
if let Some(client) = &client {
+ if let Some(request) = app.branch_request() {
+ if request.query {
+ let client = client.clone();
+ jobs.spawn(async move {
+ let result = pages::branch::execute(&client, &request).await;
+ Completed::Branched(request, result)
+ });
+ } else if let Some(state) = &mut state {
+ state.submit_branch(request);
+ } else {
+ app.branch_after_checkpoint(
+ &request,
+ &Err("TUI checkpoint unavailable".into()),
+ );
+ }
+ dirty = true;
+ }
if let Some(request) = app.oauth_request() {
if request.needs_checkpoint() {
if let Some(state) = &mut state {
@@ -495,6 +516,7 @@ pub async fn run(options: Options) -> Result<(), Error> {
continue;
}
Action::Quit => {
+ app.branch.disconnect();
if let Some(state) = &mut state {
app.oauth_abandon_checkpoint();
for request in state.cancel_requests() {
@@ -508,6 +530,7 @@ pub async fn run(options: Options) -> Result<(), Error> {
break;
}
Action::Connect => {
+ app.branch.disconnect();
if let Some(state) = &mut state {
state.cancel_requests();
}
@@ -677,6 +700,14 @@ pub async fn run(options: Options) -> Result<(), Error> {
None => std::future::pending().await,
}
} => {
+ if let Some(request) = written.branch
+ && app.branch_after_checkpoint(&request, &written.result)
+ && let Some(client) = client.clone() {
+ jobs.spawn(async move {
+ let result = pages::branch::execute(&client, &request).await;
+ Completed::Branched(request, result)
+ });
+ }
if let Some(request) = written.oauth
&& app.oauth_after_checkpoint(&request, &written.result)
&& let Some(client) = client.clone() {
@@ -763,6 +794,10 @@ pub async fn run(options: Options) -> Result<(), Error> {
}
completed = jobs.join_next(), if !jobs.is_empty() => {
match completed {
+ Some(Ok(Completed::Branched(request, result))) => {
+ app.branch_completed(request, result);
+ if let Some(state) = &mut state { state.changed(); }
+ }
Some(Ok(Completed::Oauth(request, result))) => {
if let Some(service) = app.oauth_completed(request, result) {
oauth_service = Some(service);
@@ -947,6 +982,7 @@ pub async fn run(options: Options) -> Result<(), Error> {
app.abandon_interaction();
app.abandon_pending_submissions();
app.abandon_management();
+ app.branch.disconnect();
app.management.oauth.abandon();
app.abandon_onboarding();
app.creating = false;
diff --git a/crates/tui/src/pages.rs b/crates/tui/src/pages.rs
index 336676c9b5..d24c707f4e 100644
--- a/crates/tui/src/pages.rs
+++ b/crates/tui/src/pages.rs
@@ -17,6 +17,7 @@
* under the License.
*/
+pub mod branch;
pub mod chat;
pub mod connections;
pub mod interactions;
diff --git a/crates/tui/src/pages/branch.rs b/crates/tui/src/pages/branch.rs
new file mode 100644
index 0000000000..35fb88ffd9
--- /dev/null
+++ b/crates/tui/src/pages/branch.rs
@@ -0,0 +1,623 @@
+/*
+ * 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.
+ */
+
+mod view;
+use crate::{
+ app::{Action, App, ConnectionState},
+ navigation::Route,
+};
+use maka_client::{Client, RequestFailure};
+use maka_protocol::{
+ Operation,
+ session::{SessionCatalogProjection, copy},
+};
+use serde::{Deserialize, Serialize};
+pub use view::draw;
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Basis {
+ root: String,
+ epoch: String,
+ source: String,
+ revision: u64,
+ turn: String,
+ name: String,
+ excerpt: String,
+}
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum Command {
+ Open(Basis),
+ Resume,
+ Confirm,
+ Query,
+ Visit,
+ Close,
+}
+impl Command {
+ pub fn label(&self) -> &'static str {
+ match self {
+ Self::Open(_) => "branch-title",
+ Self::Resume => "branch-resume",
+ Self::Confirm => "branch-create",
+ Self::Query => "branch-query",
+ Self::Visit => "branch-open",
+ Self::Close => "session-cancel",
+ }
+ }
+}
+
+/// Persist the original request, never a stale claim that the target exists.
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct Checkpoint {
+ root: String,
+ input: copy::Input,
+}
+impl Checkpoint {
+ pub fn validate(&self, root: &str) -> Result<(), String> {
+ let copy::Purpose::Branch {
+ turn_id: Some(turn),
+ side_conversation: false,
+ } = &self.input.purpose
+ else {
+ return Err("Invalid saved branch purpose".into());
+ };
+ if self.root != root {
+ return Err("Saved branch belongs to another Root".into());
+ }
+ copy::decode_input(
+ Operation::SessionBranchCreate,
+ &serde_json::json!({
+ "sourceSessionId":self.input.source_session_id,
+ "targetSessionId":self.input.target_session_id,
+ "expectedSourceRevision":self.input.expected_source_revision,
+ "sourceTurnId":turn
+ }),
+ )
+ .map_err(|e| e.to_string())?;
+ Ok(())
+ }
+}
+#[derive(Clone, Copy, Default, PartialEq, Eq)]
+enum Phase {
+ #[default]
+ Confirm,
+ Saving,
+ Pending,
+ Unknown,
+ Ready,
+ Failed,
+}
+#[derive(Default)]
+pub struct State {
+ pub visible: bool,
+ rendered: bool,
+ focus: usize,
+ basis: Option<Basis>,
+ saved: Option<Checkpoint>,
+ phase: Phase,
+ error: Option<&'static str>,
+ pending: Option<Request>,
+ requested: Option<bool>,
+ sequence: u64,
+}
+impl State {
+ pub fn checkpoint(&self) -> Option<Checkpoint> {
+ self.saved.clone()
+ }
+ pub fn restore(&mut self, saved: Checkpoint) {
+ self.saved = Some(saved);
+ self.phase = Phase::Unknown;
+ self.visible = false;
+ }
+ pub fn invalidate_geometry(&mut self) {
+ self.rendered = false;
+ }
+ pub fn disconnect(&mut self) {
+ self.pending = None;
+ self.requested = None;
+ if self.saved.is_some() {
+ self.phase = Phase::Unknown;
+ self.error = None;
+ }
+ }
+}
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Request {
+ root: String,
+ epoch: String,
+ input: copy::Input,
+ pub query: bool,
+ sequence: u64,
+}
+pub enum Output {
+ Ready(Box<SessionCatalogProjection>),
+ Conflict,
+ Missing,
+ Removed,
+}
+pub async fn execute(client: &Client, request: &Request) -> Result<Output, RequestFailure> {
+ if request.query {
+ let result = client.query_session_copy(request.input.clone()).await?;
+ if result.receipt.is_none() {
+ return Ok(Output::Missing);
+ }
+ return Ok(
+ match client.session(&request.input.target_session_id).await? {
+ Some(session) => Output::Ready(session),
+ None => Output::Removed,
+ },
+ );
+ }
+ Ok(match client.copy_session(request.input.clone()).await? {
+ copy::Output::Committed { session } => Output::Ready(session),
+ copy::Output::SourceRevisionConflict { .. } => Output::Conflict,
+ })
+}
+
+impl App {
+ pub fn branch_commands(&self) -> Vec<(Action, &'static str)> {
+ if self.branch.saved.is_some() {
+ return vec![(Action::Branch(Command::Resume), "branch-resume")];
+ }
+ let Some(basis) = self.branch_basis() else {
+ return vec![];
+ };
+ vec![(Action::Branch(Command::Open(basis)), "branch-title")]
+ }
+ fn branch_basis(&self) -> Option<Basis> {
+ let ConnectionState::Connected { root_id, epoch } = &self.connection else {
+ return None;
+ };
+ let Route::Session(id) = self.navigation.current() else {
+ return None;
+ };
+ if self.chat.removed || self.chat.session.as_ref() != Some(&id) || self.chrome.details {
+ return None;
+ }
+ // Search previews have their own transcript; use only the canonical page.
+ if self.chat.view.search.is_some() {
+ return None;
+ }
+ let (turn, text) = self.chat.view.selected_durable_message()?;
+ let super::sessions::Detail::Ready(item) = &self.sessions.detail else {
+ return None;
+ };
+ if item.id != id {
+ return None;
+ }
+ Some(Basis {
+ root: root_id.clone(),
+ epoch: epoch.clone(),
+ source: id,
+ revision: item.revision,
+ turn: turn.into(),
+ name: item.name.clone(),
+ excerpt: text.chars().take(120).collect(),
+ })
+ }
+ pub fn branch_enabled(&self, command: &Command) -> bool {
+ let state = &self.branch;
+ let connected = |root: &str, epoch: Option<&str>| {
+ matches!(&self.connection,
+ ConnectionState::Connected {root_id, epoch: actual} if root_id == root && epoch.is_none_or(|e| e == actual))
+ };
+ match command {
+ Command::Open(basis) => {
+ state.saved.is_none()
+ && !state.visible
+ && self.branch_basis().as_ref() == Some(basis)
+ }
+ Command::Resume => state.saved.is_some() && !state.visible,
+ Command::Close => state.visible,
+ Command::Confirm => {
+ state.visible
+ && state.rendered
+ && state.phase == Phase::Confirm
+ && state
+ .basis
+ .as_ref()
+ .is_some_and(|basis| connected(&basis.root, Some(&basis.epoch)))
+ && self.tabs.entries.len() < crate::navigation::tabs::LIMIT
+ && self.drafts.len() < crate::navigation::tabs::LIMIT
+ }
+ Command::Query => {
+ state.visible
+ && state.rendered
+ && state.phase == Phase::Unknown
+ && state.pending.is_none()
+ && state.requested.is_none()
+ && state
+ .saved
+ .as_ref()
+ .is_some_and(|saved| connected(&saved.root, None))
+ }
+ Command::Visit => {
+ state.visible
+ && state.rendered
+ && state.phase == Phase::Ready
+ && state.saved.as_ref().is_some_and(|saved| {
+ connected(&saved.root, None)
+ && (self.tabs.contains(&saved.input.target_session_id)
+ || self.tabs.entries.len() < crate::navigation::tabs::LIMIT)
+ && (self.drafts.contains_key(&saved.input.target_session_id)
+ || self.drafts.len() < crate::navigation::tabs::LIMIT)
+ })
+ }
+ }
+ }
+ pub fn branch_action(&mut self, command: Command) -> Option<Action> {
+ if !self.branch_enabled(&command) {
+ return None;
+ }
+ let state = &mut self.branch;
+ match command {
+ Command::Open(basis) => {
+ state.basis = Some(basis);
+ state.phase = Phase::Confirm;
+ state.error = None;
+ state.focus = 0;
+ state.visible = true;
+ state.rendered = false;
+ }
+ Command::Resume => {
+ state.visible = true;
+ state.focus = 0;
+ state.rendered = false;
+ }
+ Command::Close => {
+ state.visible = false;
+ state.rendered = false;
+ if state.saved.is_none() {
+ state.basis = None;
+ }
+ }
+ Command::Confirm => {
+ let basis = state.basis.as_ref()?;
+ state.saved = Some(Checkpoint {
+ root: basis.root.clone(),
+ input: copy::Input {
+ source_session_id: basis.source.clone(),
+ target_session_id: uuid::Uuid::new_v4().to_string(),
+ expected_source_revision: basis.revision,
+ purpose: copy::Purpose::Branch {
+ turn_id: Some(basis.turn.clone()),
+ side_conversation: false,
+ },
+ },
+ });
+ state.requested = Some(false);
+ state.phase = Phase::Saving;
+ state.focus = 0;
+ }
+ Command::Query => {
+ state.requested = Some(true);
+ state.error = None;
+ }
+ Command::Visit => {
+ let target = state.saved.take()?.input.target_session_id;
+ state.visible = false;
+ state.basis = None;
+ return self.apply(Action::Visit(Route::Session(target)));
+ }
+ }
+ None
+ }
+ pub fn branch_request(&mut self) -> Option<Request> {
+ let ConnectionState::Connected { root_id, epoch } = &self.connection else {
+ return None;
+ };
+ let state = &mut self.branch;
+ let saved = state.saved.as_ref()?;
+ if saved.root != *root_id || state.pending.is_some() || self.closing {
+ return None;
+ }
+ let query = state.requested.take()?;
+ if !query
+ && state
+ .basis
+ .as_ref()
+ .is_none_or(|basis| basis.epoch != *epoch)
+ {
+ state.phase = Phase::Unknown;
+ return None;
+ }
+ state.sequence += 1;
+ let request = Request {
+ root: root_id.clone(),
+ epoch: epoch.clone(),
+ input: saved.input.clone(),
+ query,
+ sequence: state.sequence,
+ };
+ state.pending = Some(request.clone());
+ state.phase = if query { Phase::Pending } else { Phase::Saving };
+ Some(request)
+ }
+ pub fn branch_after_checkpoint(
+ &mut self,
+ request: &Request,
+ result: &Result<(), String>,
+ ) -> bool {
+ let state = &mut self.branch;
+ if request.query || state.phase != Phase::Saving || state.pending.as_ref() != Some(request)
+ {
+ return false;
+ }
+ if result.is_ok()
+ && !self.closing
+ && matches!(&self.connection,
+ ConnectionState::Connected {root_id, epoch} if *root_id == request.root && *epoch == request.epoch)
+ {
+ state.phase = Phase::Pending;
+ return true;
+ }
+ state.pending = None;
+ // This particular attempt has not been dispatched. A later restart may
+ // still recover an older checkpoint conservatively, but this live UI
+ // knows it can let the user make a fresh explicit choice.
+ state.phase = Phase::Failed;
+ state.saved = None;
+ state.error = Some("branch-save-failed");
+ false
+ }
+ pub fn branch_completed(&mut self, request: Request, result: Result<Output, RequestFailure>) {
+ let state = &mut self.branch;
+ if state.pending.as_ref() != Some(&request) {
+ return;
+ }
+ state.pending = None;
+ if !matches!(&self.connection, ConnectionState::Connected {root_id, epoch} if *root_id == request.root && *epoch == request.epoch)
+ {
+ state.phase = Phase::Unknown;
+ return;
+ }
+ state.focus = 0;
+ match result {
+ Ok(Output::Ready(session)) => {
+ state.phase = Phase::Ready;
+ state.error = None;
+ self.sessions.updated(session);
+ self.inbox.refresh();
+ }
+ Ok(Output::Conflict | Output::Removed) => {
+ state.phase = Phase::Failed;
+ state.saved = None;
+ state.error = Some("branch-unavailable");
+ }
+ Ok(Output::Missing) => {
+ state.phase = Phase::Unknown;
+ state.error = Some("branch-unknown");
+ }
+ Err(error) => {
+ let uncertain = request.query
+ || matches!(&error, RequestFailure::Unknown(_))
+ || matches!(&error, RequestFailure::Rejected(maka_client::ClientError::Rejected(e))
+ if e.code == maka_protocol::OperationErrorCode::CommitOutcomeUnknown);
+ state.phase = if uncertain {
+ Phase::Unknown
+ } else {
+ Phase::Failed
+ };
+ if !uncertain {
+ state.saved = None;
+ }
+ state.error = Some(if uncertain {
+ "branch-unknown"
+ } else {
+ "branch-unavailable"
+ });
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{Locale, LocalePreference, i18n::I18n};
+ use crossterm::event::{
+ Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
+ };
+ use ratatui::{Terminal, backend::TestBackend};
+ use serde_json::json;
+
+ fn frame(app: &mut App, width: u16, height: u16) {
+ let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
+ terminal
+ .draw(|f| {
+ app.hits.clear();
+ draw(f, app, f.area(), app.theme.colors().base());
+ })
+ .unwrap();
+ }
+ fn fixture() -> (App, Basis) {
+ let mut app = App::new(
+ "/unused".into(),
+ I18n::new(LocalePreference::Auto, Locale::En),
+ );
+ app.connection = ConnectionState::Connected {
+ root_id: "root".into(),
+ epoch: "epoch".into(),
+ };
+ app.apply(Action::Visit(Route::Session("source".into())));
+ app.chat.select(&app.navigation.current());
+ app.sessions.detail = super::super::sessions::Detail::Ready(Box::new(
+ super::super::sessions::tests::item("source"),
+ ));
+ let rows = std::collections::BTreeMap::from([(
+ 1,
+ json!({"type":"user","id":"original","turnId":"turn","text":"Selected prompt 中文"}),
+ )]);
+ app.chat.view.sync(&rows, &[], 0, &app.i18n, false);
+ let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
+ terminal
+ .draw(|f| {
+ app.chat.view.draw(f, f.area(), false).unwrap();
+ })
+ .unwrap();
+ assert!(
+ app.branch_commands().is_empty(),
+ "implicit first-visible is not a selected boundary"
+ );
+ app.chat.view.enter();
+ let basis = app.branch_basis().unwrap();
+ (app, basis)
+ }
+ #[test]
+ fn branch_confirmation_checkpoint_and_recovery_preserve_the_exact_target_and_draft() {
+ let (mut app, basis) = fixture();
+ app.drafts
+ .get_mut("source")
+ .unwrap()
+ .insert("unsubmitted draft");
+ app.apply(Action::Branch(Command::Open(basis.clone())));
+ app.apply(Action::Branch(Command::Confirm));
+ assert!(
+ app.branch_request().is_none(),
+ "must render before confirming"
+ );
+ frame(&mut app, 80, 24);
+ app.input(Event::Key(KeyEvent::new(
+ KeyCode::Enter,
+ KeyModifiers::NONE,
+ )));
+ assert!(!app.branch.visible, "cancel is default");
+ app.apply(Action::Branch(Command::Open(basis.clone())));
+ frame(&mut app, 80, 24);
+ app.input(Event::Mouse(MouseEvent {
+ kind: MouseEventKind::Down(MouseButton::Left),
+ column: 0,
+ row: 0,
+ modifiers: KeyModifiers::NONE,
+ }));
+ assert!(!app.branch.visible, "outside click only dismisses");
+ app.apply(Action::Branch(Command::Open(basis.clone())));
+ frame(&mut app, 32, 10);
+ assert!(!app.branch_enabled(&Command::Confirm));
+ frame(&mut app, 80, 24);
+ app.apply(Action::Branch(Command::Confirm));
+ let request = app.branch_request().unwrap();
+ assert!(!request.query);
+ let saved = serde_json::to_value(app.branch.checkpoint()).unwrap();
+ let mut stale = request.clone();
+ stale.sequence += 1;
+ assert!(!app.branch_after_checkpoint(&stale, &Ok(())));
+ assert!(app.branch_after_checkpoint(&request, &Ok(())));
+ assert!(
+ !app.branch_after_checkpoint(&request, &Ok(())),
+ "never dispatch twice"
+ );
+ app.apply(Action::Branch(Command::Close));
+ app.apply(Action::Visit(Route::Settings));
+ app.branch_completed(
+ request.clone(),
+ Err(RequestFailure::Unknown(maka_client::ClientError::Protocol(
+ "lost reply".into(),
+ ))),
+ );
+ assert_eq!(app.navigation.current(), Route::Settings);
+ assert_eq!(
+ serde_json::to_value(app.branch.checkpoint()).unwrap(),
+ saved
+ );
+ let mut recovered = State::default();
+ recovered.restore(serde_json::from_value(saved.clone()).unwrap());
+ app.branch = recovered;
+ app.connection = ConnectionState::Connected {
+ root_id: "root".into(),
+ epoch: "new-epoch".into(),
+ };
+ assert!(
+ app.branch_request().is_none(),
+ "reopen neither writes nor polls"
+ );
+ app.apply(Action::Branch(Command::Resume));
+ frame(&mut app, 80, 24);
+ app.apply(Action::Branch(Command::Query));
+ let query = app.branch_request().unwrap();
+ assert!(query.query);
+ assert_eq!(query.input, request.input);
+ assert_eq!(query.epoch, "new-epoch");
+ app.branch_completed(query.clone(), Ok(Output::Missing));
+ assert!(!app.branch_enabled(&Command::Confirm));
+ assert_eq!(
+ serde_json::to_value(app.branch.checkpoint()).unwrap(),
+ saved
+ );
+ frame(&mut app, 80, 24);
+ app.apply(Action::Branch(Command::Query));
+ let retry = app.branch_request().unwrap();
+ app.branch_completed(query, Ok(Output::Removed));
+ assert!(
+ app.branch.pending.is_some(),
+ "obsolete read cannot remove the next request"
+ );
+ app.branch_completed(
+ retry,
+ Ok(Output::Ready(Box::new(
+ super::super::sessions::tests::item(&request.input.target_session_id),
+ ))),
+ );
+ assert_eq!(
+ app.navigation.current(),
+ Route::Settings,
+ "late success does not steal the route"
+ );
+ frame(&mut app, 80, 24);
+ app.apply(Action::Branch(Command::Visit));
+ assert_eq!(
+ app.navigation.current(),
+ Route::Session(request.input.target_session_id)
+ );
+ assert_eq!(app.drafts["source"].text(), "unsubmitted draft");
+ assert!(app.branch.saved.is_none());
+
+ let (mut app, basis) = fixture();
+ app.apply(Action::Branch(Command::Open(basis)));
+ frame(&mut app, 80, 24);
+ app.apply(Action::Branch(Command::Confirm));
+ let request = app.branch_request().unwrap();
+ assert!(!app.branch_after_checkpoint(&request, &Err("disk unavailable".into())));
+ assert!(
+ app.branch.saved.is_none(),
+ "known non-dispatch permits a fresh explicit selection"
+ );
+ assert!(app.branch_request().is_none());
+ let valid: Checkpoint = serde_json::from_value(saved.clone()).unwrap();
+ valid.validate("root").unwrap();
+ assert!(valid.validate("other-root").is_err());
+ for (pointer, value) in [
+ ("/input/targetSessionId", json!("source")),
+ ("/input/expectedSourceRevision", json!(0)),
+ ("/input/purpose/turnId", json!(null)),
+ ("/input/purpose/sideConversation", json!(true)),
+ ] {
+ let mut invalid = saved.clone();
+ *invalid.pointer_mut(pointer).unwrap() = value;
+ assert!(
+ serde_json::from_value::<Checkpoint>(invalid)
+ .unwrap()
+ .validate("root")
+ .is_err(),
+ "{pointer}"
+ );
+ }
+ }
+}
diff --git a/crates/tui/src/pages/branch/view.rs b/crates/tui/src/pages/branch/view.rs
new file mode 100644
index 0000000000..9456de8b7c
--- /dev/null
+++ b/crates/tui/src/pages/branch/view.rs
@@ -0,0 +1,212 @@
+/*
+ * 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::{Action, App, Command, Phase};
+use crate::{
+ pages::manage::view::note_lines,
+ view::{button, safe},
+};
+use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind};
+use ratatui::{
+ Frame,
+ layout::{Alignment, Margin, Rect},
+ style::Style,
+ widgets::{Block, BorderType, Paragraph, Wrap},
+};
+use unicode_width::UnicodeWidthStr;
+
+impl App {
+ pub fn branch_input(&mut self, event: Event) -> (bool, Option<Action>) {
+ let command = match event {
+ Event::Key(key) if key.kind != KeyEventKind::Release => match key.code {
+ KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
+ return (true, Some(Action::Quit));
+ }
+ KeyCode::Esc => Some(Command::Close),
+ KeyCode::Tab | KeyCode::BackTab | KeyCode::Left | KeyCode::Right => {
+ self.branch.focus ^= 1;
+ None
+ }
+ KeyCode::Enter => {
+ if self.branch.focus == 0 {
+ Some(Command::Close)
+ } else {
+ primary(self.branch.phase)
+ }
+ }
+ _ => None,
+ },
+ Event::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Left) => {
+ self.hits.iter().find_map(|hit| match &hit.action {
+ Action::Branch(command)
+ if hit.area.contains((mouse.column, mouse.row).into()) =>
+ {
+ Some(command.clone())
+ }
+ _ => None,
+ })
+ }
+ _ => None,
+ };
+ (
+ true,
+ command.and_then(|command| self.apply(Action::Branch(command))),
+ )
+ }
+}
+fn primary(phase: Phase) -> Option<Command> {
+ match phase {
+ Phase::Confirm => Some(Command::Confirm),
+ Phase::Unknown => Some(Command::Query),
+ Phase::Ready => Some(Command::Visit),
+ _ => None,
+ }
+}
+pub fn draw(frame: &mut Frame<'_>, app: &mut App, area: Rect, base: Style) {
+ let width = area.width.saturating_sub(2).min(62);
+ let state = &app.branch;
+ let name = state
+ .basis
+ .as_ref()
+ .map(|basis| basis.name.as_str())
+ .or_else(|| {
+ let source = &state.saved.as_ref()?.input.source_session_id;
+ app.sessions
+ .items
+ .iter()
+ .find(|item| item.id == *source)
+ .map(|item| item.name.as_str())
+ .or_else(|| {
+ app.tabs
+ .entries
+ .iter()
+ .find(|tab| tab.id == *source)
+ .and_then(|tab| tab.name.as_deref())
+ })
+ })
+ .unwrap_or("");
+ let mut heading = safe(name);
+ if let Some(basis) = &state.basis {
+ heading.push_str("\n\n");
+ heading.push_str(&safe(&basis.excerpt));
+ }
+ let title = note_lines(&heading, width.saturating_sub(4));
+ let key = state.error.unwrap_or(match state.phase {
+ Phase::Confirm => "branch-note",
+ Phase::Saving | Phase::Pending => "branch-wait",
+ Phase::Unknown => "branch-unknown",
+ Phase::Ready => "branch-ready",
+ Phase::Failed => "branch-unavailable",
+ });
+ let note = note_lines(&app.i18n.text(key), width.saturating_sub(4));
+ let height = 7 + title.len() as u16 + note.len() as u16;
+ if width < 36 || height > area.height.saturating_sub(2) {
+ app.branch.rendered = false;
+ crate::view::clear_overlay(frame, area);
+ frame.render_widget(
+ Paragraph::new(app.i18n.text("terminal-small")).wrap(Wrap { trim: false }),
+ area.inner(Margin::new(1, 1)),
+ );
+ return;
+ }
+ let phase = state.phase;
+ let focus = state.focus;
+ let error = state.error.is_some() || phase == Phase::Unknown;
+ app.branch.rendered = true;
+ let popup = Rect::new(
+ area.x + (area.width - width) / 2,
+ area.y + (area.height - height) / 2,
+ width,
+ height,
+ );
+ let block = Block::bordered()
+ .title(app.i18n.text("branch-title"))
+ .title_alignment(Alignment::Center)
+ .style(base)
+ .border_style(Style::default().fg(app.theme.colors().accent))
+ .border_type(if app.chrome.ascii {
+ BorderType::Plain
+ } else {
+ BorderType::Rounded
+ });
+ let inner = block.inner(popup).inner(Margin::new(1, 0));
+ app.modal_area = Some(popup);
+ crate::view::clear_overlay(frame, popup);
+ frame.render_widget(block, popup);
+ let count = title.len() as u16;
+ frame.render_widget(
+ Paragraph::new(title),
+ Rect::new(inner.x, inner.y + 1, inner.width, count),
+ );
+ frame.render_widget(
+ Paragraph::new(note).style(Style::default().fg(if error {
+ app.theme.colors().warning
+ } else {
+ app.theme.colors().muted
+ })),
+ Rect::new(
+ inner.x,
+ inner.y + count + 2,
+ inner.width,
+ height - count - 5,
+ ),
+ );
+ let cancel = app.i18n.text(if phase == Phase::Confirm {
+ "session-cancel"
+ } else {
+ "session-remove-close"
+ });
+ let action = primary(phase);
+ let label = action
+ .as_ref()
+ .map(|command| app.i18n.text(command.label()));
+ let primary_width = label
+ .as_ref()
+ .map_or(0, |label| label.width() as u16 + 2)
+ .min(inner.width / 2);
+ let cancel_width = (cancel.width() as u16 + 2).min(inner.width / 2);
+ button(
+ frame,
+ app,
+ Rect::new(
+ inner.right() - primary_width - cancel_width - u16::from(primary_width > 0),
+ inner.bottom() - 1,
+ cancel_width,
+ 1,
+ ),
+ &cancel,
+ Action::Branch(Command::Close),
+ focus == 0,
+ );
+ if let Some(action) = action {
+ button(
+ frame,
+ app,
+ Rect::new(
+ inner.right() - primary_width,
+ inner.bottom() - 1,
+ primary_width,
+ 1,
+ ),
+ label.as_ref().unwrap(),
+ Action::Branch(action),
+ focus == 1,
+ );
+ }
+}
diff --git a/crates/tui/src/pages/chat/render/navigation.rs b/crates/tui/src/pages/chat/render/navigation.rs
index cbdec7ec0c..1c34081aa1 100644
--- a/crates/tui/src/pages/chat/render/navigation.rs
+++ b/crates/tui/src/pages/chat/render/navigation.rs
@@ -20,6 +20,15 @@
use super::*;
impl Transcript {
+ /// A branch boundary must come from an explicitly selected durable message,
+ /// not the implicit first-visible fallback or a live-only stream block.
+ pub fn selected_durable_message(&self) -> Option<(&str, &str)> {
+ let key = self.selected.as_ref()?;
+ let block = self.blocks.get(key)?;
+ (self.order.contains(key) && matches!(block.revision, Revision::Durable(_)))
+ .then_some((key.turn.as_str(), block.text.as_str()))
+ }
+
pub fn selection(&self) -> Option<MessageKey> {
self.selected
.as_ref()
diff --git a/crates/tui/src/state.rs b/crates/tui/src/state.rs
index 69a1129dec..4344827b68 100644
--- a/crates/tui/src/state.rs
+++ b/crates/tui/src/state.rs
@@ -22,7 +22,7 @@ mod store;
use crate::{
app::App,
- pages::{manage::oauth, sending::Submission},
+ pages::{branch, manage::oauth, sending::Submission},
};
use maka_client::Error;
use snapshot::Snapshot;
@@ -38,6 +38,7 @@ pub struct State {
deadline: Option<Instant>,
requests: Vec<Submission>,
oauth: Option<oauth::Request>,
+ branch: Option<branch::Request>,
generation: u64,
job: Option<Writing>,
}
@@ -46,6 +47,7 @@ struct Writing {
task: tokio::task::JoinHandle<Result<(), String>>,
requests: Vec<Submission>,
oauth: Option<oauth::Request>,
+ branch: Option<branch::Request>,
generation: u64,
}
@@ -53,6 +55,7 @@ pub struct Written {
pub result: Result<(), String>,
pub requests: Vec<Submission>,
pub oauth: Option<oauth::Request>,
+ pub branch: Option<branch::Request>,
}
impl State {
pub async fn open(
@@ -83,6 +86,7 @@ impl State {
deadline: None,
requests: Vec::new(),
oauth: None,
+ branch: None,
generation: 0,
job: None,
},
@@ -114,6 +118,7 @@ impl State {
}
pub fn cancel_requests(&mut self) -> Vec<Submission> {
self.oauth = None;
+ self.branch = None;
let mut requests = std::mem::take(&mut self.requests);
if let Some(job) = &self.job
&& job.generation == self.generation
@@ -127,6 +132,10 @@ impl State {
self.oauth = Some(request);
self.force();
}
+ pub fn submit_branch(&mut self, request: branch::Request) {
+ self.branch = Some(request);
+ self.force();
+ }
pub fn idle(&self) -> bool {
self.job.is_none() && self.deadline.is_none()
}
@@ -148,6 +157,7 @@ impl State {
}),
requests,
oauth: self.oauth.take(),
+ branch: self.branch.take(),
generation,
});
}
@@ -172,6 +182,11 @@ impl State {
} else {
None
},
+ branch: if job.generation == self.generation {
+ job.branch
+ } else {
+ None
+ },
}
}
/// Terminal EOF/signals have no interactive loop to keep responsive.
@@ -206,6 +221,7 @@ mod tests {
deadline: None,
requests: vec![],
oauth: None,
+ branch: None,
generation: 0,
job: None,
};
@@ -244,6 +260,97 @@ mod tests {
.unwrap()
}
+ #[tokio::test]
+ async fn branch_dispatch_waits_for_its_own_snapshot_and_reopen_only_recovers_identity() {
+ use crate::pages::branch::Command;
+ let (directory, mut state, mut app) = fixture();
+ app.chat.select(&app.navigation.current());
+ app.sessions.detail = crate::pages::sessions::Detail::Ready(Box::new(
+ crate::pages::sessions::tests::item("a"),
+ ));
+ app.chat.view.sync(&std::collections::BTreeMap::from([(1,serde_json::json!({"type":"user","id":"message","turnId":"turn","text":"Branch point"}))]),&[],0,&app.i18n,false);
+ let mut terminal =
+ ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap();
+ terminal
+ .draw(|f| {
+ app.chat.view.draw(f, f.area(), false).unwrap();
+ })
+ .unwrap();
+ app.chat.view.enter();
+ let open = app.branch_commands()[0].0.clone();
+ app.apply(open);
+ terminal
+ .draw(|f| {
+ crate::pages::branch::draw(f, &mut app, f.area(), ratatui::style::Style::default())
+ })
+ .unwrap();
+ app.apply(Action::Branch(Command::Confirm));
+ let request = app.branch_request().unwrap();
+ let (release, blocked) = gate(&state).await;
+ state.submit_branch(request.clone());
+ state.start(&app);
+ assert!(
+ tokio::time::timeout(Duration::from_millis(20), state.completed())
+ .await
+ .is_err()
+ );
+ app.apply(Action::Branch(Command::Close));
+ app.apply(Action::Visit(Route::Settings));
+ state.changed();
+ release.send(()).unwrap();
+ blocked.await.unwrap();
+ let written = written(&mut state).await;
+ assert_eq!(written.branch, Some(request.clone()));
+ assert!(written.result.is_ok());
+ let bytes = read(&directory);
+ assert_eq!(
+ bytes["branch"],
+ serde_json::to_value(app.branch.checkpoint()).unwrap()
+ );
+ assert!(app.branch_after_checkpoint(&request, &written.result));
+ assert!(!app.branch_after_checkpoint(&request, &written.result));
+ let mut reopened = App::new(
+ "/unused".into(),
+ I18n::new(LocalePreference::Auto, Locale::En),
+ );
+ serde_json::from_value::<Snapshot>(bytes.clone())
+ .unwrap()
+ .restore(&mut reopened, false)
+ .unwrap();
+ assert!(!reopened.branch.visible);
+ assert!(reopened.branch_request().is_none());
+ assert_eq!(
+ serde_json::to_value(reopened.branch.checkpoint()).unwrap(),
+ bytes["branch"]
+ );
+ let mut obsolete = bytes.clone();
+ obsolete["version"] = serde_json::json!(5);
+ assert!(
+ serde_json::from_value::<Snapshot>(obsolete)
+ .unwrap()
+ .validate(ROOT)
+ .is_err(),
+ "v5 cannot silently discard branch recovery"
+ );
+ let mut foreign = bytes;
+ foreign["branch"]["root"] = serde_json::json!("foreign");
+ assert!(
+ serde_json::from_value::<Snapshot>(foreign)
+ .unwrap()
+ .validate(ROOT)
+ .is_err()
+ );
+ state.submit_branch(request);
+ state.force();
+ state.start(&app);
+ state.cancel_requests();
+ app.branch.disconnect();
+ assert!(
+ self::written(&mut state).await.branch.is_none(),
+ "cancelled IO completion cannot release a write"
+ );
+ }
+
#[tokio::test]
async fn slow_writer_keeps_input_live_coalesces_edits_and_never_overwrites_newer_state() {
let (directory, mut state, mut app) = fixture();
diff --git a/crates/tui/src/state/snapshot.rs b/crates/tui/src/state/snapshot.rs
index 0382f49405..c9476b1627 100644
--- a/crates/tui/src/state/snapshot.rs
+++ b/crates/tui/src/state/snapshot.rs
@@ -52,6 +52,8 @@ pub struct Snapshot {
pages: Vec<(Route, crate::navigation::state::Saved)>,
#[serde(default)]
oauth: Option<crate::pages::manage::oauth::saved::Checkpoint>,
+ #[serde(default)]
+ branch: Option<crate::pages::branch::Checkpoint>,
}
impl Snapshot {
@@ -64,7 +66,7 @@ impl Snapshot {
.collect();
unresolved.sort_by(|left, right| left.session.cmp(&right.session));
Self {
- version: 5,
+ version: 6,
root: root.into(),
route: app.navigation.current(),
tabs: app.tabs.entries.iter().map(|tab| tab.id.clone()).collect(),
@@ -85,6 +87,7 @@ impl Snapshot {
navigation: Some(app.navigation.clone()),
pages: app.saved_pages(),
oauth: app.management.oauth.checkpoint(),
+ branch: app.branch.checkpoint(),
}
}
@@ -92,7 +95,7 @@ impl Snapshot {
let id = |id: &str| {
!id.is_empty() && id.encode_utf16().count() <= 256 && !id.chars().any(char::is_control)
};
- if !matches!(self.version, 1..=5)
+ if !matches!(self.version, 1..=6)
|| self.root != root
|| self.tabs.len() > LIMIT
|| self.drafts.len() > LIMIT
@@ -103,7 +106,8 @@ impl Snapshot {
|| self.pages.len() > LIMIT + Route::PAGE_COUNT
|| (self.version < 4 && self.oauth.is_some())
|| (self.version < 5 && self.theme.is_some())
- || (self.version == 5 && self.theme.is_none())
+ || (self.version >= 5 && self.theme.is_none())
+ || (self.version < 6 && self.branch.is_some())
{
return Err("Unsupported or mismatched TUI checkpoint".into());
}
@@ -166,6 +170,9 @@ impl Snapshot {
if let Some(oauth) = &self.oauth {
oauth.validate()?;
}
+ if let Some(branch) = &self.branch {
+ branch.validate(root)?;
+ }
Ok(())
}
@@ -177,6 +184,9 @@ impl Snapshot {
if let Some(oauth) = self.oauth {
app.management.oauth.restore(&self.root, oauth);
}
+ if let Some(branch) = self.branch {
+ app.branch.restore(branch);
+ }
for (id, saved) in self.drafts {
app.drafts.insert(id, Editor::restore(saved)?);
}
@@ -290,7 +300,7 @@ mod tests {
assert!(restored.retry_submission().is_none());
assert!(restored.reconciliation().is_some());
let mut invalid: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
- invalid["version"] = serde_json::json!(6);
+ invalid["version"] = serde_json::json!(7);
assert!(
serde_json::from_value::<Snapshot>(invalid)
.unwrap()
diff --git a/crates/tui/src/view.rs b/crates/tui/src/view.rs
index e5d56c7d60..4fdd7bc5d6 100644
--- a/crates/tui/src/view.rs
+++ b/crates/tui/src/view.rs
@@ -67,6 +67,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
&& app.palette.is_none()
&& !app.interactions.visible
&& app.management.dialog.is_none()
+ && !app.branch.visible
&& app.onboarding.dialog.is_none()
&& app.queue.edit.is_none();
app.chrome
@@ -288,6 +289,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
let hint = if app.closing {
app.i18n.text("state-closing")
} else if app.theme.editor.is_some()
+ || app.branch.visible
|| app.onboarding.dialog.is_some()
|| app.management.dialog.is_some()
|| app.interactions.visible
@@ -379,6 +381,8 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
}
if app.theme.editor.is_some() {
crate::theme::editor::draw(frame, app, area);
+ } else if app.branch.visible {
+ crate::pages::branch::draw(frame, app, area, base);
} else if app.onboarding.dialog.is_some() {
crate::pages::onboarding::draw(frame, app, area, base);
} else if app.management.dialog.is_some() {
@@ -534,6 +538,7 @@ fn icon(app: &App, action: &Action) -> &'static str {
crate::pages::manage::Kind::Locations,
)) => ("ⓘ", "i"),
Action::Manage(_) => ("⋯", "."),
+ Action::Branch(_) => ("↳", "+"),
Action::Onboard(_) => ("⊕", "+"),
Action::Forward | Action::NextSessions => ("›", ">"),
Action::Refresh | Action::RefreshSession | Action::RefreshSessions => ("↻", "R"),
@@ -640,6 +645,7 @@ fn action_label(app: &App, action: &Action) -> String {
Action::RetrySubmission => "chat-retry-original",
Action::CreateSession => "session-create",
Action::Manage(command) => command.label(),
+ Action::Branch(command) => command.label(),
Action::Onboard(command) => command.label(),
Action::Project(command) => command.label(),
Action::Connection(command) => command.label(),
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 02/02: fix(tui): keep branch recovery controls stable and reachable
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 dc19862e63e412672cc290e012d469e8b0fb89cd
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Wed Sep 23 20:31:34 2026 +0800
fix(tui): keep branch recovery controls stable and reachable
Keep recovery geometry independent of arriving catalog names, wait for a live Host in the restart test, and keep keyboard focus on visible enabled actions.
Generated-by: Codex
---
crates/cli/tests/integration/tui/branch.rs | 1 +
crates/tui/src/pages/branch.rs | 40 +++++++++++++++++++++++++++---
crates/tui/src/pages/branch/view.rs | 24 ++++++------------
3 files changed, 45 insertions(+), 20 deletions(-)
diff --git a/crates/cli/tests/integration/tui/branch.rs b/crates/cli/tests/integration/tui/branch.rs
index ca4f931b05..8c19783e16 100644
--- a/crates/cli/tests/integration/tui/branch.rs
+++ b/crates/cli/tests/integration/tui/branch.rs
@@ -137,6 +137,7 @@ fn branch_preserves_selected_history_and_draft_and_reopens_by_original_receipt()
});
let mut reopened = Pty::spawn(&["--root", host.root.to_str().unwrap()]);
reopened.wait_for("Preserved branch draft");
+ reopened.wait_for("Later reply."); // Restored draft alone does not mean Host is connected.
reopened.send(b"\x10");
reopened.wait_for("Check session branch");
reopened.click_text("Check session branch");
diff --git a/crates/tui/src/pages/branch.rs b/crates/tui/src/pages/branch.rs
index 35fb88ffd9..cf54c38742 100644
--- a/crates/tui/src/pages/branch.rs
+++ b/crates/tui/src/pages/branch.rs
@@ -348,6 +348,7 @@ impl App {
};
state.pending = Some(request.clone());
state.phase = if query { Phase::Pending } else { Phase::Saving };
+ state.focus = 0;
Some(request)
}
pub fn branch_after_checkpoint(
@@ -515,6 +516,11 @@ mod tests {
app.apply(Action::Branch(Command::Confirm));
let request = app.branch_request().unwrap();
assert!(!request.query);
+ app.input(Event::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)));
+ assert_eq!(
+ app.branch.focus, 0,
+ "pending dialog has only one focusable action"
+ );
let saved = serde_json::to_value(app.branch.checkpoint()).unwrap();
let mut stale = request.clone();
stale.sequence += 1;
@@ -540,10 +546,7 @@ mod tests {
let mut recovered = State::default();
recovered.restore(serde_json::from_value(saved.clone()).unwrap());
app.branch = recovered;
- app.connection = ConnectionState::Connected {
- root_id: "root".into(),
- epoch: "new-epoch".into(),
- };
+ app.connection = ConnectionState::Connecting;
assert!(
app.branch_request().is_none(),
"reopen neither writes nor polls"
@@ -551,6 +554,35 @@ mod tests {
app.apply(Action::Branch(Command::Resume));
frame(&mut app, 80, 24);
app.apply(Action::Branch(Command::Query));
+ assert!(
+ app.branch_request().is_none(),
+ "restored drafts do not establish a connection"
+ );
+ app.connection = ConnectionState::Connected {
+ root_id: "root".into(),
+ epoch: "new-epoch".into(),
+ };
+ frame(&mut app, 80, 24);
+ let button = app
+ .hits
+ .iter()
+ .find(|hit| hit.action == Action::Branch(Command::Query))
+ .unwrap()
+ .area;
+ let mut source = super::super::sessions::tests::item("source");
+ source.name = "A source name arriving after the dialog was opened".repeat(4);
+ app.sessions.items.push(source);
+ frame(&mut app, 80, 24);
+ assert_eq!(
+ app.hits
+ .iter()
+ .find(|hit| hit.action == Action::Branch(Command::Query))
+ .unwrap()
+ .area,
+ button,
+ "catalog arrivals cannot move the recovery action under the mouse"
+ );
+ app.apply(Action::Branch(Command::Query));
let query = app.branch_request().unwrap();
assert!(query.query);
assert_eq!(query.input, request.input);
diff --git a/crates/tui/src/pages/branch/view.rs b/crates/tui/src/pages/branch/view.rs
index 9456de8b7c..8d588d6ce9 100644
--- a/crates/tui/src/pages/branch/view.rs
+++ b/crates/tui/src/pages/branch/view.rs
@@ -40,7 +40,14 @@ impl App {
}
KeyCode::Esc => Some(Command::Close),
KeyCode::Tab | KeyCode::BackTab | KeyCode::Left | KeyCode::Right => {
- self.branch.focus ^= 1;
+ self.branch.focus = if primary(self.branch.phase)
+ .as_ref()
+ .is_some_and(|command| self.branch_enabled(command))
+ {
+ self.branch.focus ^ 1
+ } else {
+ 0
+ };
None
}
KeyCode::Enter => {
@@ -85,21 +92,6 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App, area: Rect, base: Style) {
.basis
.as_ref()
.map(|basis| basis.name.as_str())
- .or_else(|| {
- let source = &state.saved.as_ref()?.input.source_session_id;
- app.sessions
- .items
- .iter()
- .find(|item| item.id == *source)
- .map(|item| item.name.as_str())
- .or_else(|| {
- app.tabs
- .entries
- .iter()
- .find(|tab| tab.id == *source)
- .and_then(|tab| tab.name.as_deref())
- })
- })
.unwrap_or("");
let mut heading = safe(name);
if let Some(basis) = &state.basis {
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org