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:07 UTC
(maka) 01/02: feat(tui): branch selected turns with durable recovery
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