You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by zh...@apache.org on 2026/09/23 19:58:52 UTC
(maka) branch feat/rust-tui-session-recap created (now 57bd828aa6)
This is an automated email from the ASF dual-hosted git repository.
me2seeks pushed a change to branch feat/rust-tui-session-recap
in repository https://gitbox.apache.org/repos/asf/maka.git
at 57bd828aa6 merge: sync native bundle and directory updates
This branch includes the following new commits:
new 777d9d62b8 feat(tui): consume durable Session recap through native Remote
new f2f64fda1d style(tui): satisfy recap lint checks
new 57bd828aa6 merge: sync native bundle and directory updates
The 3 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 01/03: feat(tui): consume durable Session recap through native Remote
Posted by zh...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
me2seeks pushed a commit to branch feat/rust-tui-session-recap
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 777d9d62b8e83876d97e6add272894d2c6a8f731
Author: me2seeks <me...@gmail.com>
AuthorDate: Thu Sep 24 03:57:14 2026 +0800
feat(tui): consume durable Session recap through native Remote
Generated-by: Codex
---
crates/cli/tests/integration/tui.rs | 1 +
crates/cli/tests/integration/tui/recap.rs | 166 ++++++++++++
crates/session-recap/README.md | 16 ++
crates/tui/locales/en.ftl | 16 ++
crates/tui/locales/zh-CN.ftl | 16 ++
crates/tui/locales/zh-TW.ftl | 16 ++
crates/tui/src/app.rs | 17 ++
crates/tui/src/lib.rs | 39 +++
crates/tui/src/pages.rs | 1 +
crates/tui/src/pages/recap.rs | 420 ++++++++++++++++++++++++++++++
crates/tui/src/pages/recap/tests.rs | 163 ++++++++++++
crates/tui/src/pages/recap/view.rs | 211 +++++++++++++++
crates/tui/src/state.rs | 50 +++-
crates/tui/src/state/snapshot.rs | 9 +
crates/tui/src/view.rs | 6 +
docs/rust-parity.md | 3 +-
docs/rust-parity.zh-CN.md | 2 +-
17 files changed, 1149 insertions(+), 3 deletions(-)
diff --git a/crates/cli/tests/integration/tui.rs b/crates/cli/tests/integration/tui.rs
index 93901621f5..38f6e572ee 100644
--- a/crates/cli/tests/integration/tui.rs
+++ b/crates/cli/tests/integration/tui.rs
@@ -41,6 +41,7 @@ mod onboarding;
mod projects;
mod queue;
mod reading;
+mod recap;
mod recovery;
mod references;
mod removal;
diff --git a/crates/cli/tests/integration/tui/recap.rs b/crates/cli/tests/integration/tui/recap.rs
new file mode 100644
index 0000000000..1a5bc6a28f
--- /dev/null
+++ b/crates/cli/tests/integration/tui/recap.rs
@@ -0,0 +1,166 @@
+/*
+ * 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::decode_session_create_input};
+use serde_json::json;
+use std::sync::{
+ Arc,
+ atomic::{AtomicUsize, Ordering},
+};
+
+#[test]
+fn recap_uses_native_remote_preserves_draft_and_reads_saved_result_after_reopen() {
+ let directory = tempfile::tempdir().unwrap();
+ assert!(
+ Command::new("git")
+ .args(["init", "--quiet"])
+ .arg(directory.path())
+ .status()
+ .unwrap()
+ .success()
+ );
+ 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 calls = Arc::new(AtomicUsize::new(0));
+ let (client,model,finish)=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":"recap-session","name":"Recap fixture",
+ "workspace":{"kind":"host_path","path":directory.path()},"modelTarget":{"kind":"default"}
+ })).unwrap()).await.unwrap();
+ let (finish,finished)=tokio::sync::oneshot::channel();
+ let count=calls.clone();
+ let model=tokio::spawn(async move {
+ let mut finished=Some(finished);
+ loop {
+ let (mut stream,body,_)=model_http_request(&listener).await;
+ let n=count.fetch_add(1,Ordering::SeqCst);
+ assert!(n<2,"opening, refreshing and restarting must never generate again");
+ let content=if n==0 {"Original verified answer."} else {
+ assert!(body.to_string().contains("Original verified answer."));
+ finished.take().unwrap().await.unwrap();
+ "Recap confirmed: original work is ready for the next step."
+ };
+ if body["stream"]==true {
+ let frame=json!({"id":"recap-fixture","object":"chat.completion.chunk","model":"fixture-model",
+ "choices":[{"index":0,"delta":{"content":content},"finish_reason":"stop"}],
+ "usage":{"prompt_tokens":10,"completion_tokens":12,"total_tokens":22}});
+ 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();
+ } else {
+ support::json_response(stream,"200 OK",json!({"id":"recap-fixture","object":"chat.completion","model":"fixture-model",
+ "choices":[{"index":0,"message":{"role":"assistant","content":content},"finish_reason":"stop"}],
+ "usage":{"prompt_tokens":10,"completion_tokens":12,"total_tokens":22}})).await;
+ }
+ }
+ });
+ client.request(Operation::TurnStart,json!({"sessionId":"recap-session","turnId":"original","content":{"text":"Prepare a verified answer"},"maxSteps":1})).await.unwrap();
+ tokio::time::timeout(Duration::from_secs(15),async {
+ loop {
+ let turn=client.request(Operation::TurnQuery,json!({"sessionId":"recap-session","turnId":"original"})).await.unwrap();
+ match turn["status"].as_str() {
+ Some("completed")=>break,
+ Some("failed"|"cancelled")=>panic!("fixture turn failed: {turn}"),
+ _=>tokio::time::sleep(Duration::from_millis(20)).await,
+ }
+ }
+ }).await.unwrap();
+ (client,model,finish)
+ });
+ let mut tui = Pty::spawn(&["--root", host.root.to_str().unwrap()]);
+ tui.wait_for("Recap fixture");
+ tui.click_text("Recap fixture");
+ tui.wait_for("Original verified answer.");
+ tui.click_text("Message…");
+ tui.send(b"Keep this unsent draft");
+ tui.wait_for("Keep this unsent draft");
+ tui.send(b"\x10");
+ tui.wait_for("Session recap");
+ tui.click_text("Session recap");
+ tui.wait_for("No recap has been saved");
+ tui.send(b"\r"); // default focus closes; it cannot charge the model.
+ tui.wait_until(|s| !s.contains("No recap has been saved"));
+ assert_eq!(calls.load(Ordering::SeqCst), 1);
+ tui.send(b"\x10");
+ tui.wait_for("Session recap");
+ tui.click_text("Session recap");
+ tui.wait_for("No recap has been saved");
+ tui.click_text("Generate new");
+ tui.wait_for("Retry original");
+ runtime.block_on(async {
+ tokio::time::timeout(Duration::from_secs(10), async {
+ while calls.load(Ordering::SeqCst) < 2 {
+ tokio::time::sleep(Duration::from_millis(10)).await;
+ }
+ })
+ .await
+ .unwrap();
+ });
+ 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();
+ assert_eq!(saved["recap"]["session"], "recap-session");
+ assert!(saved["recap"]["operation"].as_str().is_some());
+ tui.resize(32, 9);
+ tui.wait_for("enlarge the terminal");
+ tui.send(b"\t\t\r"); // hidden generation control must stay disabled.
+ tui.resize(120, 40);
+ tui.wait_for("Session recap");
+ finish.send(()).unwrap();
+ tui.wait_for("Recap confirmed:");
+ tui.click_text("Refresh recap");
+ tui.wait_for("Recap confirmed:");
+ // Outside click dismisses only the popup, preserving the underlying composer.
+ tui.send(b"\x1b[<0;2;2M\x1b[<0;2;2m");
+ tui.wait_until(|s| !s.contains("Recap confirmed:"));
+ tui.wait_for("Keep this unsent draft");
+ tui.send(b"\x11");
+ tui.finish();
+ let mut reopened = Pty::spawn(&["--root", host.root.to_str().unwrap()]);
+ reopened.wait_for("Keep this unsent draft");
+ reopened.wait_for("Original verified answer.");
+ reopened.send(b"\x10");
+ reopened.wait_for("Session recap");
+ reopened.click_text("Session recap");
+ reopened.wait_for("Recap confirmed:");
+ assert_eq!(calls.load(Ordering::SeqCst), 2);
+ reopened.send(b"\x11");
+ reopened.finish();
+ model.abort();
+ client.disconnect();
+ host.retire_registered();
+ assert!(host.wait_for_exit().success());
+}
diff --git a/crates/session-recap/README.md b/crates/session-recap/README.md
index 3d3a9d2ea2..e96e42140e 100644
--- a/crates/session-recap/README.md
+++ b/crates/session-recap/README.md
@@ -54,6 +54,22 @@ This plugin provides manual generation and durable inspection. Automatic idle
recaps, Daily review, and the old TS `session.recap.generate` protocol operation
are not implemented by this plugin. No broad background model grant is created.
+## Native terminal client
+
+In a Session, press Ctrl+P and choose **Session recap**. Opening the dialog only
+reads the saved result. **Generate new** explicitly uses the Session's selected
+model and may incur charges. Esc or an outside click closes the dialog without
+editing or sending the composer draft. Arrow keys scroll longer results.
+
+The TUI writes the original operation identity to its Root-bound checkpoint
+before generation. After disconnect or restart, **Resolve pending recap** reads
+Host state; **Retry original** explicitly reuses that identity. It does not
+silently start a replacement request. A recorded Pending receipt remains an
+unconfirmed result; generating a new recap is a separate explicit request.
+The dialog calls the existing standalone `manage` Remote on the current Client
+connection and closes its document after each request. It implements no summary,
+model, credential, or permission policy.
+
## Verification
- `cargo test -p maka-session-recap --lib`: persistence/retry, restart, concurrent
diff --git a/crates/tui/locales/en.ftl b/crates/tui/locales/en.ftl
index 44a49cee5b..c526eb9606 100644
--- a/crates/tui/locales/en.ftl
+++ b/crates/tui/locales/en.ftl
@@ -777,3 +777,19 @@ attachments-upload-failed = Upload not confirmed. Resume to check the original u
attachments-save-failed = Could not save upload identity.
attachments-path-long = Path is too long.
attachments-truncated = More entries exist. Enter a file or directory path.
+
+recap-title = Session recap
+recap-resume = Resolve pending recap
+recap-read = Refresh recap
+recap-generate = Generate new
+recap-retry = Retry original
+recap-note = Summarize this session with its selected model. Generating a new recap may incur model charges. Use arrow keys to scroll.
+recap-working = Reading or generating recap…
+recap-unresolved = The original request is unresolved. Retry original safely checks or submits that same request. Nothing is retried automatically.
+recap-pending = The request was recorded but its result is unconfirmed. Refresh to check. Generate new starts a separate request and may incur another charge.
+recap-failed = Recap generation failed.
+recap-empty = No recap has been saved for this session.
+recap-save-failed = Could not save or confirm the original request locally. Nothing new was dispatched. Retry original when storage is available.
+recap-forget = Forget retry
+recap-confirm-forget = Confirm forget
+recap-forget-note = Forgetting only removes local retry information. It does not cancel work already accepted by Host. A later new generation may incur another charge. Confirm only if you want to abandon this retry.
diff --git a/crates/tui/locales/zh-CN.ftl b/crates/tui/locales/zh-CN.ftl
index 5c32e7e44a..c7cd2187c8 100644
--- a/crates/tui/locales/zh-CN.ftl
+++ b/crates/tui/locales/zh-CN.ftl
@@ -777,3 +777,19 @@ attachments-upload-failed = 上传未确认,继续上传将先核对原上传
attachments-save-failed = 无法保存上传标识。
attachments-path-long = 路径过长。
attachments-truncated = 目录还有更多内容,可直接输入文件或目录路径。
+
+recap-title = 会话回顾
+recap-resume = 处理待确认回顾
+recap-read = 刷新回顾
+recap-generate = 生成新回顾
+recap-retry = 重试原请求
+recap-note = 使用本会话选择的模型生成摘要。生成新回顾可能产生模型费用。方向键可滚动内容。
+recap-working = 正在读取或生成回顾…
+recap-unresolved = 原请求结果待确认。重试原请求会安全地查询或提交同一个请求,不会自动重试。
+recap-pending = 请求已记录,但结果尚未确认。可刷新查询;生成新回顾会发起独立请求,可能再次收费。
+recap-failed = 回顾生成失败。
+recap-empty = 本会话尚无已保存的回顾。
+recap-save-failed = 无法保存或确认原请求的本地记录,未发出新请求。存储恢复后可重试原请求。
+recap-forget = 放弃重试
+recap-confirm-forget = 确认放弃
+recap-forget-note = 放弃只会删除本地重试信息,不会取消 Host 已接受的工作。之后重新生成可能再次收费。确认后才能放弃此次重试。
diff --git a/crates/tui/locales/zh-TW.ftl b/crates/tui/locales/zh-TW.ftl
index 3569f7eef4..b7bb746f4f 100644
--- a/crates/tui/locales/zh-TW.ftl
+++ b/crates/tui/locales/zh-TW.ftl
@@ -777,3 +777,19 @@ attachments-upload-failed = 上傳未確認,繼續上傳會先核對原上傳
attachments-save-failed = 無法儲存上傳識別。
attachments-path-long = 路徑過長。
attachments-truncated = 目錄還有更多內容,可直接輸入檔案或目錄路徑。
+
+recap-title = 工作階段回顧
+recap-resume = 處理待確認回顧
+recap-read = 重新整理
+recap-generate = 產生新回顧
+recap-retry = 重試原請求
+recap-note = 使用本工作階段選擇的模型產生摘要。產生新回顧可能產生模型費用。方向鍵可捲動內容。
+recap-working = 正在讀取或產生回顧…
+recap-unresolved = 原請求結果待確認。重試原請求會安全地查詢或提交同一個請求,不會自動重試。
+recap-pending = 請求已記錄,但結果尚未確認。可重新整理查詢;產生新回顧會發起獨立請求,可能再次收費。
+recap-failed = 回顧產生失敗。
+recap-empty = 本工作階段尚無已儲存的回顧。
+recap-save-failed = 無法儲存或確認原請求的本機記錄,未發出新請求。儲存恢復後可重試原請求。
+recap-forget = 放棄重試
+recap-confirm-forget = 確認放棄
+recap-forget-note = 放棄只會刪除本機重試資訊,不會取消 Host 已接受的工作。之後重新產生可能再次收費。確認後才能放棄此次重試。
diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs
index b9216c620f..2f3ddd2ca0 100644
--- a/crates/tui/src/app.rs
+++ b/crates/tui/src/app.rs
@@ -61,6 +61,7 @@ pub enum Action {
Copy(crate::pages::chat::render::selection::CopyMode),
CopyFile(String),
Branch(crate::pages::branch::Command),
+ Recap(crate::pages::recap::Command),
Attachment(crate::pages::attachments::Command),
References,
Revision(crate::pages::revision::Command),
@@ -129,6 +130,7 @@ pub struct App {
pub inbox: crate::pages::sessions::Sessions,
pub management: crate::pages::manage::Management,
pub branch: crate::pages::branch::State,
+ pub recap: crate::pages::recap::State,
pub attachments: crate::pages::attachments::State,
pub directories:
std::collections::BTreeMap<String, Vec<maka_protocol::turn::DirectoryReference>>,
@@ -177,6 +179,7 @@ impl App {
inbox: crate::pages::sessions::Sessions::inbox(),
management: Default::default(),
branch: Default::default(),
+ recap: Default::default(),
attachments: Default::default(),
directories: Default::default(),
revision: Default::default(),
@@ -230,6 +233,7 @@ impl App {
];
commands.extend(self.management_commands());
commands.extend(self.branch_commands());
+ commands.extend(self.recap_commands());
commands.extend(self.revision_commands());
commands.extend(self.oauth_commands());
if let Some(action) = self.default_model_action() {
@@ -440,6 +444,7 @@ impl App {
pub fn begin_frame(&mut self, area: Rect) {
self.attachments.begin_frame();
self.branch.invalidate_geometry();
+ self.recap.invalidate_geometry();
self.revision.begin_frame();
for item in &self.sessions.items {
self.tabs.rename(&item.id, &item.name);
@@ -504,6 +509,7 @@ impl App {
pub fn tooltip_visible(&self) -> bool {
self.theme.editor.is_none()
&& !self.branch.visible
+ && !self.recap.visible
&& !self.revision.visible
&& self.attachments.dialog.is_none()
&& self.palette.is_none()
@@ -579,6 +585,7 @@ impl App {
Action::Attachment(command) => return self.attachment_action(command),
Action::References => self.open_references(),
Action::Branch(command) => return self.branch_action(command),
+ Action::Recap(command) => return self.recap_action(command),
Action::Revision(command) => return self.revision_action(command),
Action::Onboard(command) => return self.onboarding_action(command),
Action::Project(command) => return self.project_action(command),
@@ -774,6 +781,9 @@ impl App {
if let Action::Revision(command) = action {
return self.revision_enabled(command);
}
+ if let Action::Recap(command) = action {
+ return self.recap_enabled(command);
+ }
if let Action::Branch(command) = action {
return self.branch_enabled(command);
}
@@ -1030,6 +1040,7 @@ impl App {
self.management.invalidate_geometry();
self.management.oauth.invalidate_identity_geometry();
self.branch.invalidate_geometry();
+ self.recap.invalidate_geometry();
self.revision.invalidate_geometry();
self.onboarding.invalidate_geometry();
self.tabs.invalidate_geometry();
@@ -1117,6 +1128,7 @@ impl App {
&& mouse.kind == MouseEventKind::Down(MouseButton::Left)
&& (self.theme.editor.is_some()
|| self.branch.visible
+ || self.recap.visible
|| self.revision.visible
|| self.attachments.dialog.is_some()
|| self.palette.is_some()
@@ -1139,6 +1151,8 @@ impl App {
Some(Action::Manage(crate::pages::manage::Command::Close))
} else if self.revision.visible {
Some(Action::Revision(crate::pages::revision::Command::Close))
+ } else if self.recap.visible {
+ Some(Action::Recap(crate::pages::recap::Command::Close))
} else if self.branch.visible {
Some(Action::Branch(crate::pages::branch::Command::Close))
} else if self.onboarding.dialog.is_some() {
@@ -1174,6 +1188,9 @@ impl App {
if self.revision.visible && !matches!(event, Event::Resize(_, _)) {
return self.revision_input(event);
}
+ if self.recap.visible && !matches!(event, Event::Resize(_, _)) {
+ return self.recap_input(event);
+ }
if self.branch.visible && !matches!(event, Event::Resize(_, _)) {
return self.branch_input(event);
}
diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs
index 859a3305c1..9cf0396770 100644
--- a/crates/tui/src/lib.rs
+++ b/crates/tui/src/lib.rs
@@ -48,6 +48,10 @@ pub struct Options {
}
enum Completed {
+ Recap(
+ pages::recap::Request,
+ Result<Option<pages::recap::Receipt>, maka_client::RequestFailure>,
+ ),
Revised(
pages::revision::Request,
Result<pages::revision::Output, maka_client::RequestFailure>,
@@ -243,6 +247,25 @@ pub async fn run(options: Options) -> Result<(), Error> {
}
dirty = true;
}
+ if let Some(request) = app.recap_request() {
+ if request.needs_checkpoint() {
+ if let Some(state) = &mut state {
+ state.submit_recap(request);
+ } else {
+ app.recap_after_checkpoint(
+ &request,
+ &Err("TUI checkpoint unavailable".into()),
+ );
+ }
+ } else {
+ let client = client.clone();
+ jobs.spawn(async move {
+ let result = pages::recap::execute(&client, &request).await;
+ Completed::Recap(request, result)
+ });
+ }
+ dirty = true;
+ }
if let Some(request) = app.branch_request() {
if request.query {
let client = client.clone();
@@ -559,6 +582,7 @@ pub async fn run(options: Options) -> Result<(), Error> {
}
Action::Quit => {
app.attachments.disconnect();
+ app.recap.disconnect();
app.branch.disconnect();
app.revision.disconnect();
if let Some(state) = &mut state {
@@ -586,6 +610,7 @@ pub async fn run(options: Options) -> Result<(), Error> {
app.abandon_interaction();
app.abandon_pending_submissions();
app.attachments.disconnect();
+ app.recap.disconnect();
app.creating = false;
jobs = JoinSet::new();
history_job = None;
@@ -746,6 +771,14 @@ pub async fn run(options: Options) -> Result<(), Error> {
None => std::future::pending().await,
}
} => {
+ if let Some(request) = written.recap
+ && app.recap_after_checkpoint(&request, &written.result)
+ && let Some(client) = client.clone() {
+ jobs.spawn(async move {
+ let result=pages::recap::execute(&client,&request).await;
+ Completed::Recap(request,result)
+ });
+ }
if let Some(ticket) = written.attachment
&& let Some((prepared, transfer)) = app.attachment_after_checkpoint(&ticket, &written.result)
&& let Some(client) = client.clone() {
@@ -887,6 +920,10 @@ pub async fn run(options: Options) -> Result<(), Error> {
}
if let Some(state) = &mut state { state.changed(); }
}
+ Some(Ok(Completed::Recap(request,result))) => {
+ app.recap_completed(request,result);
+ if let Some(state) = &mut state { state.changed(); }
+ }
Some(Ok(Completed::Managed(ticket, result))) => {
app.management_completed(*ticket, result);
if let Some(state) = &mut state { state.changed(); }
@@ -1065,6 +1102,7 @@ pub async fn run(options: Options) -> Result<(), Error> {
app.abandon_interaction();
app.abandon_pending_submissions();
app.attachments.disconnect();
+ app.recap.disconnect();
app.abandon_management();
app.branch.disconnect();
app.revision.disconnect();
@@ -1087,6 +1125,7 @@ pub async fn run(options: Options) -> Result<(), Error> {
}
}
app.attachments.disconnect();
+ app.recap.disconnect();
attachment_jobs.abort_all();
jobs.abort_all();
// Once Save was pressed, finish the bounded local write and checkpoint the
diff --git a/crates/tui/src/pages.rs b/crates/tui/src/pages.rs
index 786c81a442..41290d0216 100644
--- a/crates/tui/src/pages.rs
+++ b/crates/tui/src/pages.rs
@@ -26,6 +26,7 @@ pub mod manage;
pub mod onboarding;
pub mod projects;
pub mod queue;
+pub mod recap;
pub mod references;
pub mod revision;
pub mod sending;
diff --git a/crates/tui/src/pages/recap.rs b/crates/tui/src/pages/recap.rs
new file mode 100644
index 0000000000..1acda918dd
--- /dev/null
+++ b/crates/tui/src/pages/recap.rs
@@ -0,0 +1,420 @@
+/*
+ * 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, ClientError, RequestFailure};
+use maka_protocol::plugin::{RemoteBinding, RemoteKind, RemoteRequest, RemoteResult};
+use serde::{Deserialize, Serialize};
+use uuid::Uuid;
+pub use view::draw;
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Target {
+ root: String,
+ epoch: String,
+ session: String,
+}
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum Command {
+ Open(Target),
+ Resume,
+ Read,
+ Generate,
+ Retry,
+ Forget,
+ ConfirmForget,
+ Close,
+}
+impl Command {
+ pub fn label(&self) -> &'static str {
+ match self {
+ Self::Open(_) => "recap-title",
+ Self::Resume => "recap-resume",
+ Self::Read => "recap-read",
+ Self::Generate => "recap-generate",
+ Self::Retry => "recap-retry",
+ Self::Forget => "recap-forget",
+ Self::ConfirmForget => "recap-confirm-forget",
+ Self::Close => "session-remove-close",
+ }
+ }
+}
+/// Only unresolved operation identity is saved. Summary text remains with the plugin.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct Checkpoint {
+ root: String,
+ session: String,
+ operation: Uuid,
+}
+impl Checkpoint {
+ pub fn validate(&self, root: &str) -> Result<(), String> {
+ if self.root != root
+ || self.session.is_empty()
+ || self.session.len() > 256
+ || self.session.chars().any(char::is_control)
+ {
+ return Err("Invalid recap checkpoint destination".into());
+ }
+ Ok(())
+ }
+}
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Request {
+ target: Target,
+ operation: Option<Uuid>,
+ sequence: u64,
+}
+impl Request {
+ pub fn needs_checkpoint(&self) -> bool {
+ self.operation.is_some()
+ }
+}
+#[derive(Debug, Deserialize)]
+#[serde(
+ tag = "kind",
+ rename_all = "snake_case",
+ rename_all_fields = "camelCase"
+)]
+pub enum Receipt {
+ Pending {
+ operation_id: Uuid,
+ },
+ Ready {
+ operation_id: Uuid,
+ text: String,
+ model_id: String,
+ },
+ Failed {
+ operation_id: Uuid,
+ reason: String,
+ },
+}
+impl Receipt {
+ fn operation(&self) -> Uuid {
+ match self {
+ Self::Pending { operation_id }
+ | Self::Ready { operation_id, .. }
+ | Self::Failed { operation_id, .. } => *operation_id,
+ }
+ }
+}
+#[derive(Default)]
+pub struct State {
+ pub visible: bool,
+ rendered: bool,
+ discarding: bool,
+ focus: usize,
+ scroll: u16,
+ target: Option<Target>,
+ saved: Option<Checkpoint>,
+ pending: Option<Request>,
+ requested: Option<Option<Uuid>>,
+ sequence: u64,
+ saving: bool,
+ loaded: bool,
+ receipt: Option<Receipt>,
+ error: Option<String>,
+}
+impl State {
+ pub fn checkpoint(&self) -> Option<Checkpoint> {
+ self.saved.clone()
+ }
+ pub fn restore(&mut self, saved: Checkpoint) {
+ self.saved = Some(saved);
+ }
+ pub fn invalidate_geometry(&mut self) {
+ self.rendered = false;
+ }
+ pub fn disconnect(&mut self) {
+ self.pending = None;
+ self.requested = None;
+ self.saving = false;
+ self.loaded = false;
+ self.receipt = None;
+ self.error = None;
+ }
+}
+fn invalid(message: impl Into<String>) -> RequestFailure {
+ RequestFailure::Unknown(ClientError::Protocol(message.into()))
+}
+pub async fn execute(
+ client: &Client,
+ request: &Request,
+) -> Result<Option<Receipt>, RequestFailure> {
+ let RemoteResult::Document { document } =
+ client.plugin_remote(RemoteRequest::OpenDocument).await?
+ else {
+ return Err(invalid("Expected Remote document"));
+ };
+ let result = async {
+ let binding = RemoteBinding::Package {
+ package_id: "maka.session-recap".into(),
+ method: "manage".into(),
+ session_id: Some(request.target.session.clone()),
+ };
+ let RemoteResult::Bound {
+ target,
+ handler: RemoteKind::Method,
+ } = client
+ .plugin_remote(RemoteRequest::Bind {
+ binding: binding.clone(),
+ })
+ .await?
+ else {
+ return Err(invalid("Expected recap method"));
+ };
+ let input = match request.operation {
+ Some(id) => serde_json::json!({"kind":"generate","operationId":id}),
+ None => serde_json::json!({"kind":"read"}),
+ };
+ let RemoteResult::Value { value } = client
+ .plugin_remote(RemoteRequest::Call {
+ binding,
+ target,
+ document,
+ input,
+ })
+ .await?
+ else {
+ return Err(invalid("Expected recap response"));
+ };
+ #[derive(Deserialize)]
+ struct Response {
+ recap: Option<Receipt>,
+ }
+ let response: Response =
+ serde_json::from_value(value).map_err(|e| invalid(e.to_string()))?;
+ if request
+ .operation
+ .is_some_and(|id| response.recap.as_ref().is_none_or(|r| r.operation() != id))
+ {
+ return Err(invalid("Recap response changed operation identity"));
+ }
+ Ok(response.recap)
+ }
+ .await;
+ // A request owns its Remote document even when binding or calling fails.
+ let closed = client
+ .plugin_remote(RemoteRequest::CloseDocument { document })
+ .await;
+ match result {
+ Err(e) => Err(e),
+ Ok(value) => {
+ closed?;
+ Ok(value)
+ }
+ }
+}
+impl App {
+ fn recap_target(&self) -> Option<Target> {
+ let ConnectionState::Connected { root_id, epoch } = &self.connection else {
+ return None;
+ };
+ let Route::Session(session) = self.navigation.current() else {
+ return None;
+ };
+ if self.chat.removed {
+ return None;
+ }
+ Some(Target {
+ root: root_id.clone(),
+ epoch: epoch.clone(),
+ session,
+ })
+ }
+ pub fn recap_commands(&self) -> Vec<(Action, &'static str)> {
+ if self.recap.saved.is_some() {
+ return vec![(Action::Recap(Command::Resume), "recap-resume")];
+ }
+ self.recap_target()
+ .map(|t| vec![(Action::Recap(Command::Open(t)), "recap-title")])
+ .unwrap_or_default()
+ }
+ pub fn recap_enabled(&self, command: &Command) -> bool {
+ let state = &self.recap;
+ let connected = matches!((&self.connection,&state.target),
+ (ConnectionState::Connected {root_id,epoch},Some(target)) if *root_id==target.root && *epoch==target.epoch);
+ match command {
+ Command::Open(target)=> !state.visible && state.saved.is_none() && state.pending.is_none() && self.recap_target().as_ref()==Some(target),
+ Command::Resume=> !state.visible && state.saved.as_ref().is_some_and(|saved| matches!(&self.connection,ConnectionState::Connected{root_id,..} if *root_id==saved.root)),
+ Command::Close=>state.visible,
+ _=> state.visible && state.rendered && connected && state.pending.is_none() && state.requested.is_none() && match command {
+ Command::Read=>true,
+ Command::Generate=>state.loaded && state.saved.is_none(),
+ Command::Retry=>state.saved.is_some(),
+ Command::Forget=>state.saved.is_some() && !state.discarding,
+ Command::ConfirmForget=>state.saved.is_some() && state.discarding,
+ _=>false,
+ },
+ }
+ }
+ pub fn recap_action(&mut self, command: Command) -> Option<Action> {
+ if !self.recap_enabled(&command) {
+ return None;
+ }
+ let state = &mut self.recap;
+ if !matches!(command, Command::Forget | Command::ConfirmForget) {
+ state.discarding = false;
+ }
+ match command {
+ Command::Forget => {
+ state.discarding = true;
+ }
+ Command::ConfirmForget => {
+ state.saved = None;
+ state.visible = false;
+ state.discarding = false;
+ state.loaded = false;
+ }
+ Command::Open(target) => {
+ state.target = Some(target);
+ state.visible = true;
+ state.focus = 0;
+ state.scroll = 0;
+ state.loaded = false;
+ state.receipt = None;
+ state.error = None;
+ state.requested = Some(None);
+ state.rendered = false;
+ }
+ Command::Resume => {
+ let saved = state.saved.as_ref()?;
+ let ConnectionState::Connected { epoch, .. } = &self.connection else {
+ return None;
+ };
+ state.target = Some(Target {
+ root: saved.root.clone(),
+ epoch: epoch.clone(),
+ session: saved.session.clone(),
+ });
+ state.visible = true;
+ state.rendered = false;
+ state.focus = 0;
+ state.scroll = 0;
+ state.requested = Some(None);
+ }
+ Command::Close => {
+ state.visible = false;
+ state.rendered = false;
+ }
+ Command::Read => state.requested = Some(None),
+ Command::Generate => {
+ let target = state.target.as_ref()?;
+ let operation = Uuid::new_v4();
+ state.saved = Some(Checkpoint {
+ root: target.root.clone(),
+ session: target.session.clone(),
+ operation,
+ });
+ state.requested = Some(Some(operation));
+ }
+ Command::Retry => state.requested = Some(Some(state.saved.as_ref()?.operation)),
+ }
+ None
+ }
+ pub fn recap_request(&mut self) -> Option<Request> {
+ let state = &mut self.recap;
+ if self.closing || state.pending.is_some() {
+ return None;
+ }
+ let target = state.target.as_ref()?;
+ if !matches!(&self.connection,ConnectionState::Connected{root_id,epoch} if *root_id==target.root && *epoch==target.epoch)
+ {
+ return None;
+ }
+ let operation = state.requested.take()?;
+ state.sequence += 1;
+ let request = Request {
+ target: target.clone(),
+ operation,
+ sequence: state.sequence,
+ };
+ state.pending = Some(request.clone());
+ state.saving = request.needs_checkpoint();
+ state.error = None;
+ state.focus = 0;
+ Some(request)
+ }
+ pub fn recap_after_checkpoint(
+ &mut self,
+ request: &Request,
+ result: &Result<(), String>,
+ ) -> bool {
+ let state = &mut self.recap;
+ if !state.saving || state.pending.as_ref() != Some(request) {
+ return false;
+ }
+ state.saving = false;
+ if result.is_ok()
+ && !self.closing
+ && matches!(&self.connection,ConnectionState::Connected{root_id,epoch} if *root_id==request.target.root && *epoch==request.target.epoch)
+ {
+ return true;
+ }
+ state.pending = None;
+ // Keep the identity: an earlier retry may already have reached Host.
+ state.error = Some(self.i18n.text("recap-save-failed"));
+ false
+ }
+ pub fn recap_completed(
+ &mut self,
+ request: Request,
+ result: Result<Option<Receipt>, RequestFailure>,
+ ) {
+ let state = &mut self.recap;
+ if state.pending.as_ref() != Some(&request) {
+ return;
+ }
+ state.pending = None;
+ if !matches!(&self.connection,ConnectionState::Connected{root_id,epoch} if *root_id==request.target.root && *epoch==request.target.epoch)
+ {
+ state.loaded = false;
+ return;
+ }
+ match result {
+ Ok(receipt) => {
+ if let Some(receipt) = &receipt {
+ if state
+ .saved
+ .as_ref()
+ .is_some_and(|s| s.operation == receipt.operation())
+ {
+ state.saved = None;
+ }
+ }
+ state.loaded = true;
+ state.receipt = receipt;
+ state.error = None;
+ state.scroll = 0;
+ }
+ Err(error) => {
+ state.error = Some(error.to_string());
+ state.loaded = false;
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/tui/src/pages/recap/tests.rs b/crates/tui/src/pages/recap/tests.rs
new file mode 100644
index 0000000000..ea8863e724
--- /dev/null
+++ b/crates/tui/src/pages/recap/tests.rs
@@ -0,0 +1,163 @@
+/*
+ * 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 crate::i18n::{I18n, Locale, LocalePreference};
+use ratatui::{Terminal, backend::TestBackend};
+fn app() -> App {
+ let mut app = App::new(
+ "/unused".into(),
+ I18n::new(LocalePreference::Explicit(Locale::En), Locale::En),
+ );
+ app.connection = ConnectionState::Connected {
+ root_id: "root".into(),
+ epoch: "epoch".into(),
+ };
+ app.apply(Action::Visit(Route::Session("source".into())));
+ app
+}
+fn draw(app: &mut App, width: u16, height: u16) {
+ Terminal::new(TestBackend::new(width, height))
+ .unwrap()
+ .draw(|f| crate::view::draw(f, app))
+ .unwrap();
+}
+fn loaded(app: &mut App) {
+ let action = app.recap_commands()[0].0.clone();
+ app.apply(action);
+ let read = app.recap_request().unwrap();
+ app.recap_completed(read, Ok(None));
+ draw(app, 100, 35);
+}
+#[test]
+fn unknown_result_retries_original_after_reconnect_and_rejects_late_reply() {
+ let mut app = app();
+ loaded(&mut app);
+ app.apply(Action::Recap(Command::Generate));
+ let first = app.recap_request().unwrap();
+ assert!(first.needs_checkpoint());
+ assert!(app.recap_after_checkpoint(&first, &Ok(())));
+ assert!(
+ !app.recap_after_checkpoint(&first, &Ok(())),
+ "dispatch once per checkpoint"
+ );
+ app.recap.disconnect();
+ app.connection = ConnectionState::Connected {
+ root_id: "root".into(),
+ epoch: "new".into(),
+ };
+ app.recap_completed(
+ first.clone(),
+ Ok(Some(Receipt::Ready {
+ operation_id: first.operation.unwrap(),
+ text: "late".into(),
+ model_id: "model".into(),
+ })),
+ );
+ assert!(app.recap.receipt.is_none());
+ app.apply(Action::Recap(Command::Close));
+ app.apply(Action::Recap(Command::Resume));
+ let read = app.recap_request().unwrap();
+ app.recap_completed(read, Ok(None));
+ draw(&mut app, 100, 35);
+ assert!(!app.recap_enabled(&Command::Generate));
+ app.apply(Action::Recap(Command::Retry));
+ let retry = app.recap_request().unwrap();
+ assert_eq!(retry.operation, first.operation);
+ assert_ne!(retry.target.epoch, first.target.epoch);
+ assert!(app.recap_after_checkpoint(&retry, &Ok(())));
+ app.recap_completed(
+ retry.clone(),
+ Ok(Some(Receipt::Pending {
+ operation_id: retry.operation.unwrap(),
+ })),
+ );
+ assert!(
+ app.recap.checkpoint().is_none(),
+ "Host receipt confirms admission even if its outcome is unknown"
+ );
+ assert!(
+ app.recap_request().is_none(),
+ "receipt never starts another generation"
+ );
+}
+#[test]
+fn resize_and_failed_checkpoint_cannot_dispatch_and_foreign_checkpoint_is_rejected() {
+ let mut app = app();
+ loaded(&mut app);
+ draw(&mut app, 30, 8);
+ assert!(!app.recap_enabled(&Command::Generate));
+ app.apply(Action::Recap(Command::Generate));
+ assert!(app.recap_request().is_none());
+ draw(&mut app, 100, 35);
+ app.apply(Action::Recap(Command::Generate));
+ let request = app.recap_request().unwrap();
+ assert!(!app.recap_after_checkpoint(&request, &Err("disk full".into())));
+ let saved = app.recap.checkpoint().unwrap();
+ assert!(saved.validate("other").is_err());
+ let mut reopened = State::default();
+ reopened.restore(saved.clone());
+ assert!(!reopened.visible);
+ assert!(reopened.requested.is_none());
+ assert_eq!(reopened.checkpoint(), Some(saved));
+ assert!(!app.recap_enabled(&Command::Generate));
+}
+#[test]
+fn different_latest_receipt_does_not_erase_original_unknown_request() {
+ let mut app = app();
+ loaded(&mut app);
+ app.apply(Action::Recap(Command::Generate));
+ let original = app.recap_request().unwrap();
+ assert!(app.recap_after_checkpoint(&original, &Ok(())));
+ app.recap_completed(original.clone(), Err(invalid("lost reply")));
+ draw(&mut app, 100, 35);
+ app.apply(Action::Recap(Command::Read));
+ let read = app.recap_request().unwrap();
+ app.recap_completed(
+ read,
+ Ok(Some(Receipt::Ready {
+ operation_id: Uuid::new_v4(),
+ text: "newer from another client".into(),
+ model_id: "model".into(),
+ })),
+ );
+ assert_eq!(
+ app.recap.checkpoint().unwrap().operation,
+ original.operation.unwrap()
+ );
+ assert!(!app.recap_enabled(&Command::Generate));
+}
+
+#[test]
+fn abandoning_an_unavailable_retry_requires_explicit_confirmation_and_never_dispatches() {
+ let mut app = app();
+ loaded(&mut app);
+ app.apply(Action::Recap(Command::Generate));
+ let request = app.recap_request().unwrap();
+ assert!(app.recap_after_checkpoint(&request, &Ok(())));
+ app.recap_completed(request, Err(invalid("Session unavailable")));
+ draw(&mut app, 100, 35);
+ assert!(!app.recap_enabled(&Command::ConfirmForget));
+ app.apply(Action::Recap(Command::Forget));
+ assert!(app.recap.checkpoint().is_some());
+ app.apply(Action::Recap(Command::ConfirmForget));
+ assert!(app.recap.checkpoint().is_none());
+ assert!(app.recap_request().is_none());
+ assert!(!app.recap.visible);
+}
diff --git a/crates/tui/src/pages/recap/view.rs b/crates/tui/src/pages/recap/view.rs
new file mode 100644
index 0000000000..d2eb7f964e
--- /dev/null
+++ b/crates/tui/src/pages/recap/view.rs
@@ -0,0 +1,211 @@
+/*
+ * 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, Receipt};
+use crate::view::{button, safe};
+use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind};
+use ratatui::{
+ Frame,
+ layout::{Alignment, Margin, Rect},
+ style::Style,
+ widgets::{Block, Paragraph, Wrap},
+};
+impl App {
+ pub fn recap_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::Right => {
+ self.recap.focus = (self.recap.focus + 1) % self.recap_buttons().len();
+ None
+ }
+ KeyCode::BackTab | KeyCode::Left => {
+ self.recap.focus = (self.recap.focus + self.recap_buttons().len() - 1)
+ % self.recap_buttons().len();
+ None
+ }
+ KeyCode::Down => {
+ self.recap.scroll = self.recap.scroll.saturating_add(1);
+ None
+ }
+ KeyCode::Up => {
+ self.recap.scroll = self.recap.scroll.saturating_sub(1);
+ None
+ }
+ KeyCode::PageDown => {
+ self.recap.scroll = self.recap.scroll.saturating_add(10);
+ None
+ }
+ KeyCode::PageUp => {
+ self.recap.scroll = self.recap.scroll.saturating_sub(10);
+ None
+ }
+ KeyCode::Home => {
+ self.recap.scroll = 0;
+ None
+ }
+ KeyCode::Enter => Some(self.recap_buttons()[self.recap.focus].clone()),
+ _ => None,
+ },
+ Event::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Left) => {
+ self.hits.iter().find_map(|hit| match &hit.action {
+ Action::Recap(command)
+ if hit.area.contains((mouse.column, mouse.row).into()) =>
+ {
+ Some(command.clone())
+ }
+ _ => None,
+ })
+ }
+ Event::Mouse(mouse) if mouse.kind == MouseEventKind::ScrollDown => {
+ self.recap.scroll = self.recap.scroll.saturating_add(3);
+ None
+ }
+ Event::Mouse(mouse) if mouse.kind == MouseEventKind::ScrollUp => {
+ self.recap.scroll = self.recap.scroll.saturating_sub(3);
+ None
+ }
+ _ => None,
+ };
+ (true, command.and_then(|c| self.apply(Action::Recap(c))))
+ }
+ fn recap_buttons(&self) -> Vec<Command> {
+ let mut buttons = vec![
+ Command::Close,
+ Command::Read,
+ if self.recap.saved.is_some() {
+ Command::Retry
+ } else {
+ Command::Generate
+ },
+ ];
+ if self.recap.saved.is_some() {
+ buttons.push(if self.recap.discarding {
+ Command::ConfirmForget
+ } else {
+ Command::Forget
+ });
+ }
+ buttons
+ }
+}
+pub fn draw(frame: &mut Frame<'_>, app: &mut App, area: Rect, base: Style) {
+ let width = area.width.saturating_sub(2).min(78);
+ let height = area.height.saturating_sub(2).min(24);
+ if width < 48 || height < 12 {
+ app.recap.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;
+ }
+ app.recap.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("recap-title"))
+ .title_alignment(Alignment::Center)
+ .style(base);
+ 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);
+ app.recap.focus = app.recap.focus.min(app.recap_buttons().len() - 1);
+ let state = &app.recap;
+ let mut text = state
+ .target
+ .as_ref()
+ .map(|t| safe(&t.session))
+ .unwrap_or_default();
+ text.push_str("\n\n");
+ text.push_str(&app.i18n.text("recap-note"));
+ text.push_str("\n\n");
+ if state.discarding {
+ text.push_str(&app.i18n.text("recap-forget-note"));
+ text.push_str("\n\n");
+ }
+ if state.pending.is_some() {
+ text.push_str(&app.i18n.text("recap-working"));
+ text.push_str("\n\n");
+ }
+ if state.saved.is_some() {
+ text.push_str(&app.i18n.text("recap-unresolved"));
+ text.push_str("\n\n");
+ }
+ if let Some(error) = &state.error {
+ text.push_str(&safe(error));
+ text.push_str("\n\n");
+ }
+ match &state.receipt {
+ Some(Receipt::Ready {
+ text: summary,
+ model_id,
+ ..
+ }) => {
+ text.push_str(&safe(summary));
+ text.push_str("\n\n");
+ text.push_str(&safe(model_id));
+ }
+ Some(Receipt::Pending { .. }) => text.push_str(&app.i18n.text("recap-pending")),
+ Some(Receipt::Failed { reason, .. }) => {
+ text.push_str(&app.i18n.text("recap-failed"));
+ text.push_str("\n");
+ text.push_str(&safe(reason));
+ }
+ None if state.loaded => text.push_str(&app.i18n.text("recap-empty")),
+ None => {}
+ }
+ let lines = crate::pages::manage::view::note_lines(&text, inner.width);
+ let available = inner.height.saturating_sub(3);
+ app.recap.scroll = app
+ .recap
+ .scroll
+ .min((lines.len() as u16).saturating_sub(available));
+ frame.render_widget(
+ Paragraph::new(lines).scroll((app.recap.scroll, 0)),
+ Rect::new(inner.x, inner.y, inner.width, available),
+ );
+ let buttons = app.recap_buttons();
+ let size = inner.width / buttons.len() as u16;
+ for (i, command) in buttons.into_iter().enumerate() {
+ let label = app.i18n.text(command.label());
+ button(
+ frame,
+ app,
+ Rect::new(
+ inner.x + i as u16 * size,
+ inner.bottom() - 1,
+ size.saturating_sub(1),
+ 1,
+ ),
+ &label,
+ Action::Recap(command),
+ app.recap.focus == i,
+ );
+ }
+}
diff --git a/crates/tui/src/state.rs b/crates/tui/src/state.rs
index 63141a4fca..ad549ce166 100644
--- a/crates/tui/src/state.rs
+++ b/crates/tui/src/state.rs
@@ -22,7 +22,7 @@ mod store;
use crate::{
app::App,
- pages::{branch, manage::oauth, revision, sending::Submission},
+ pages::{branch, manage::oauth, recap, revision, sending::Submission},
};
use maka_client::Error;
use snapshot::Snapshot;
@@ -39,6 +39,7 @@ pub struct State {
requests: Vec<Submission>,
oauth: Option<oauth::Request>,
branch: Option<branch::Request>,
+ recap: Option<recap::Request>,
revision: Option<revision::Request>,
attachment: Option<crate::pages::attachments::Ticket>,
generation: u64,
@@ -50,6 +51,7 @@ struct Writing {
requests: Vec<Submission>,
oauth: Option<oauth::Request>,
branch: Option<branch::Request>,
+ recap: Option<recap::Request>,
revision: Option<revision::Request>,
attachment: Option<crate::pages::attachments::Ticket>,
generation: u64,
@@ -60,6 +62,7 @@ pub struct Written {
pub requests: Vec<Submission>,
pub oauth: Option<oauth::Request>,
pub branch: Option<branch::Request>,
+ pub recap: Option<recap::Request>,
pub revision: Option<revision::Request>,
pub attachment: Option<crate::pages::attachments::Ticket>,
}
@@ -93,6 +96,7 @@ impl State {
requests: Vec::new(),
oauth: None,
branch: None,
+ recap: None,
revision: None,
attachment: None,
generation: 0,
@@ -127,6 +131,7 @@ impl State {
pub fn cancel_requests(&mut self) -> Vec<Submission> {
self.oauth = None;
self.branch = None;
+ self.recap = None;
self.revision = None;
self.attachment = None;
let mut requests = std::mem::take(&mut self.requests);
@@ -142,6 +147,10 @@ impl State {
self.oauth = Some(request);
self.force();
}
+ pub fn submit_recap(&mut self, request: recap::Request) {
+ self.recap = Some(request);
+ self.force();
+ }
pub fn submit_branch(&mut self, request: branch::Request) {
self.branch = Some(request);
self.force();
@@ -176,6 +185,7 @@ impl State {
requests,
oauth: self.oauth.take(),
branch: self.branch.take(),
+ recap: self.recap.take(),
revision: self.revision.take(),
attachment: self.attachment.take(),
generation,
@@ -212,6 +222,11 @@ impl State {
} else {
None
},
+ recap: if job.generation == self.generation {
+ job.recap
+ } else {
+ None
+ },
branch: if job.generation == self.generation {
job.branch
} else {
@@ -252,6 +267,7 @@ mod tests {
requests: vec![],
oauth: None,
branch: None,
+ recap: None,
revision: None,
attachment: None,
generation: 0,
@@ -658,4 +674,36 @@ mod tests {
assert_eq!(read(&directory)["unresolved"][0]["id"], request.id);
assert!(state.idle());
}
+
+ #[tokio::test]
+ async fn recap_dispatch_waits_for_saved_identity_and_restore_never_replays_it() {
+ use crate::pages::recap::Command;
+ let (directory, mut state, mut app) = fixture();
+ app.apply(Action::Visit(Route::Session("recap-session".into())));
+ let action = app.recap_commands()[0].0.clone();
+ app.apply(action);
+ let query = app.recap_request().unwrap();
+ app.recap_completed(query, Ok(None));
+ ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 35))
+ .unwrap()
+ .draw(|frame| crate::view::draw(frame, &mut app))
+ .unwrap();
+ app.apply(Action::Recap(Command::Generate));
+ let request = app.recap_request().unwrap();
+ let checkpoint = app.recap.checkpoint().unwrap();
+ state.submit_recap(request.clone());
+ state.start(&app);
+ let written = state.completed().await;
+ assert!(written.result.is_ok());
+ assert_eq!(written.recap, Some(request.clone()));
+ let saved: Snapshot = serde_json::from_value(read(&directory)).unwrap();
+ let mut reopened = App::new(
+ "/unused".into(),
+ I18n::new(LocalePreference::Auto, Locale::En),
+ );
+ saved.restore(&mut reopened, false).unwrap();
+ assert_eq!(reopened.recap.checkpoint(), Some(checkpoint));
+ assert!(reopened.recap_request().is_none());
+ assert!(app.recap_after_checkpoint(&request, &written.result));
+ }
}
diff --git a/crates/tui/src/state/snapshot.rs b/crates/tui/src/state/snapshot.rs
index ae5249b373..ca59e38815 100644
--- a/crates/tui/src/state/snapshot.rs
+++ b/crates/tui/src/state/snapshot.rs
@@ -48,6 +48,8 @@ pub struct Snapshot {
pages: Vec<(Route, crate::navigation::state::Saved)>,
oauth: Option<crate::pages::manage::oauth::saved::Checkpoint>,
branch: Option<crate::pages::branch::Checkpoint>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ recap: Option<crate::pages::recap::Checkpoint>,
revision: Option<crate::pages::revision::Checkpoint>,
}
@@ -83,6 +85,7 @@ impl Snapshot {
pages: app.saved_pages(),
oauth: app.management.oauth.checkpoint(),
branch: app.branch.checkpoint(),
+ recap: app.recap.checkpoint(),
revision: app.revision.checkpoint(),
}
}
@@ -187,6 +190,9 @@ impl Snapshot {
return Err("Duplicate revision/composer upload".into());
}
}
+ if let Some(recap) = &self.recap {
+ recap.validate(root)?;
+ }
if let Some(branch) = &self.branch {
branch.validate(root)?;
}
@@ -204,6 +210,9 @@ impl Snapshot {
if let Some(revision) = self.revision {
app.revision.restore(revision);
}
+ if let Some(recap) = self.recap {
+ app.recap.restore(recap);
+ }
if let Some(branch) = self.branch {
app.branch.restore(branch);
}
diff --git a/crates/tui/src/view.rs b/crates/tui/src/view.rs
index 02cf419b77..6ddd2fc6d5 100644
--- a/crates/tui/src/view.rs
+++ b/crates/tui/src/view.rs
@@ -68,6 +68,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
&& !app.interactions.visible
&& app.management.dialog.is_none()
&& !app.branch.visible
+ && !app.recap.visible
&& !app.revision.visible
&& app.attachments.dialog.is_none()
&& app.onboarding.dialog.is_none()
@@ -292,6 +293,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
app.i18n.text("state-closing")
} else if app.theme.editor.is_some()
|| app.branch.visible
+ || app.recap.visible
|| app.revision.visible
|| app.attachments.dialog.is_some()
|| app.onboarding.dialog.is_some()
@@ -391,6 +393,8 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
crate::pages::manage::draw(frame, app, area, base);
} else if app.revision.visible {
crate::pages::revision::draw(frame, app, area, base);
+ } else if app.recap.visible {
+ crate::pages::recap::draw(frame, app, area, base);
} else if app.branch.visible {
crate::pages::branch::draw(frame, app, area, base);
} else if app.onboarding.dialog.is_some() {
@@ -550,6 +554,7 @@ fn icon(app: &App, action: &Action) -> &'static str {
Action::Manage(_) => ("⋯", "."),
Action::Attachment(_) => ("⊕", "+"),
Action::References => ("▱", "/"),
+ Action::Recap(_) => ("≡", "="),
Action::Branch(_) => ("↳", "+"),
Action::Revision(_) => ("↶", "<"),
Action::Onboard(_) => ("⊕", "+"),
@@ -673,6 +678,7 @@ fn action_label(app: &App, action: &Action) -> String {
Action::CreateSession => "session-create",
Action::Manage(command) => command.label(),
Action::Branch(command) => command.label(),
+ Action::Recap(command) => command.label(),
Action::Revision(command) => command.label(),
Action::Onboard(command) => command.label(),
Action::Project(command) => command.label(),
diff --git a/docs/rust-parity.md b/docs/rust-parity.md
index 7c69047181..4285f59ce3 100644
--- a/docs/rust-parity.md
+++ b/docs/rust-parity.md
@@ -87,7 +87,8 @@ implementations; wire adapters may retain existing client vocabulary.
| --- | --- | --- |
| Plan | State and durable receipts are complete: revision/abandonment, version and replan-source checks, frozen submissions, progress/interruption/resume/cancellation, exact retries and fixed-watermark history pages. Tools, Behavior, Remote/Desktop and live execution observation remain unwired. Non-Agent collaboration mode still fails admission. | **Plugin + Host.** The plugin owns workflow and records through public storage. Approving a plan grants no sandbox permissions; Host retains [...]
| Goal | Query/arm/control, continuation, termination, budget and recovery semantics. | **Plugin + Host.** Goal policy owns subsequent submissions; Host enforces admitted hard limits and records usage. Retiring the plugin must close future submission admission. |
-| Daily review / recap | Daily-review query/mutate, scheduled review and `session.recap.generate`. | **Plugin + Host.** Summarization/selection/output in plugins; reuse Scheduler and authorized history/model services. Host retains any canonical Session metadata commit. |
+| Session recap | `maka.session-recap` provides manual generation, durable idempotent receipts, Desktop Session Inspector and native Rust TUI access. The TUI checkpoints original operation identity before generation and explicitly retries that identity after reconnect. Automatic idle generation and the old `session.recap.generate` route remain absent. | **Plugin.** Uses authorized history and the Session model. Recaps are derived data and do not rewrite canonical history or Session metadata. |
+| Daily review | Daily-review query/mutate and scheduled review remain. | **Plugin + Host.** Reuse Scheduler and authorized history/model services. |
| External agents | Setup start/query/cancel; execution adapters, configuration, auth, conversation identity, adapter-specific attachments/interactions/resume/fork. | **Plugin + Host.** Implement concrete CLI/ACP adapters as Executor plugins over owned processes/HTTP. Generic Executor support is not a shipped adapter. Host owns authorization, cancellation and canonical external-event recording. |
| Usage / Pricing | Physical Agent and auxiliary SDK accounting, frozen valuation, public Rust/JS scoped mixed model/tool activity paging, and shared native/plugin pricing query/CAS edits are implemented. Consistent summaries include complete bounded breakdowns and missing-data coverage; The Insights plugin provides settings reports, filtering, pagination, persisted views and rate editing; Session Inspector uses the public Session-bound Client slot. | **Plugin + Host.** Reports and rebui [...]
| Background health | BackgroundTaskHealth process and endpoint checks. | **Plugin + Host.** Health interpretation and Tool in a plugin; Host exposes authorized resource observations and bounded probes. A stored PID is not resource ownership. |
diff --git a/docs/rust-parity.zh-CN.md b/docs/rust-parity.zh-CN.md
index 32c2a9d38c..fb17a7c372 100644
--- a/docs/rust-parity.zh-CN.md
+++ b/docs/rust-parity.zh-CN.md
@@ -78,7 +78,7 @@ runtime 契约不能反向依赖插件实现,协议适配层可以保留现有
| --- | --- | --- |
| Plan | 状态与持久回执层完成:修订/放弃、版本与重规划来源检查、冻结提交、进度/中断/恢复/取消、精确重试及固定水位历史分页。工具、Behavior、Remote/Desktop 和实际执行观察尚未接入,非 Agent collaboration mode 仍拒绝准入。 | **插件 + Host。** 插件通过公共存储拥有流程与记录;审批计划不授予沙箱权限,Host 保留授权和 Turn 准入。没有 Host 回执只表示待准入,不能标记正在执行。 |
| Goal | `maka.goal` 已提供 Session Inspector、Remote 查询/保存/启动/暂停/取消、持久续跑及精确操作恢复;`GoalStatus` 报告在对应 Invocation 正常结束后生效。 | **插件 + Host。** 插件拥有目标及有限轮次策略,Host 拥有授权、回执和计量。token 阈值按 Session 新增已观测用量停止后续调度,含其他会话活动,非硬 token/总步数上限。保存后需显式启动;旧 `goal.*` RPC、自动绑定用户 Turn 和独立 evaluator 尚未迁移。 |
-| Session recap | `maka.session-recap` 已提供手动生成、操作 ID 幂等、持久回执及 Desktop Session Inspector 展示;使用授权历史与本会话模型,未知结果不自动重试。 | **插件。** 回顾是插件派生数据,不改写规范历史/Session 元数据;输入为有界文本历史,尚无 TS 结构化工具结果投影。自动 idle 触发与旧协议路由未接入。 |
+| Session recap | `maka.session-recap` 已提供手动生成、操作 ID 幂等、持久回执及 Desktop Session Inspector/Rust TUI 展示;TUI 在生成前保存原操作身份,重连后显式按原身份重试;使用授权历史与本会话模型,未知结果不自动重试。 | **插件。** 回顾是插件派生数据,不改写规范历史/Session 元数据;输入为有界文本历史,尚无 TS 结构化工具结果投影。自动 idle 触发与旧协议路由未接入。 |
| Daily review | daily-review 查询/修改与定时复盘待实现。 | **插件 + Host。** 复用 Scheduler、授权历史与模型服务。 |
| 外部 agent | setup start/query/cancel;具体执行适配、配置、鉴权、对话身份,以及附件/交互/resume/fork。 | **插件 + Host。** CLI/ACP 适配作为 Executor 插件,使用受管理进程/HTTP。已有 Executor 框架不等于已有具体 adapter。Host 负责授权、取消和外部事件落盘。 |
| Usage/Pricing | 已实现 Agent 与辅助 SDK 的物理计量、冻结估价、公共 Rust/JS 作用域模型/工具混合活动分页,以及原生/插件共享的报价查询与 CAS 修改。一致快照汇总包含有界完整分组与缺失数据覆盖率;Insights 插件已提供设置报表、筛选、分页、视图持久化和报价编辑;Session Inspector 已使用公共 Session 作用域 Client 插槽。 | **插件 + Host。** 报表和可重建投影可归 Insights 领域;Host 不依赖插件存活来记录用量,并提供一致快照。缺失用量不能视为零。 |
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 03/03: merge: sync native bundle and directory updates
Posted by zh...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
me2seeks pushed a commit to branch feat/rust-tui-session-recap
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 57bd828aa601a60fc5dcdd38a703330ecdf7f3ac
Merge: f2f64fda1d 5de02c2ff9
Author: me2seeks <me...@gmail.com>
AuthorDate: Thu Sep 24 03:58:19 2026 +0800
merge: sync native bundle and directory updates
Generated-by: Codex
crates/cli/tests/integration/tui.rs | 4 +
crates/cli/tests/integration/tui/references.rs | 6 +
.../event-log/migrations/0006_session_bundles.sql | 37 +++
crates/event-log/src/bundle.rs | 14 +-
crates/event-log/src/bundle/export.rs | 3 +-
crates/event-log/src/bundle/import.rs | 176 +++++++++++++
crates/event-log/src/bundle/import/collision.rs | 148 +++++++++++
crates/event-log/src/bundle/import/publish.rs | 285 +++++++++++++++++++++
crates/event-log/src/bundle/relocation.rs | 178 +++++++++++++
crates/event-log/src/bundle/stage.rs | 75 +++++-
crates/event-log/src/bundle/validation.rs | 42 ++-
crates/event-log/src/bundle/validation/catalog.rs | 13 +
crates/event-log/src/bundle/validation/copies.rs | 20 +-
crates/event-log/src/bundle/validation/material.rs | 33 +--
crates/event-log/src/catalog_changes.rs | 2 +-
crates/event-log/src/handoff.rs | 2 +-
crates/event-log/src/sessions.rs | 2 +
crates/event-log/src/sessions/copy.rs | 11 +-
crates/event-log/src/sessions/execution.rs | 1 +
crates/event-log/src/sessions/material.rs | 12 +-
crates/event-log/src/sessions/metadata.rs | 2 +-
crates/event-log/src/sessions/removal.rs | 2 +-
crates/event-log/tests/archive/bundle.rs | 4 +
crates/event-log/tests/bundle/export.rs | 3 +
crates/event-log/tests/bundle/import.rs | 273 ++++++++++++++++++++
crates/event-log/tests/continuation.rs | 2 +
crates/event-log/tests/continuation/bundle.rs | 149 +++++++++++
crates/tui/src/pages/manage/directory.rs | 188 +++++++++++++-
crates/tui/src/pages/manage/directory/view.rs | 43 +++-
29 files changed, 1660 insertions(+), 70 deletions(-)
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 02/03: style(tui): satisfy recap lint checks
Posted by zh...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
me2seeks pushed a commit to branch feat/rust-tui-session-recap
in repository https://gitbox.apache.org/repos/asf/maka.git
commit f2f64fda1d419671f7fb3173262e2f5565524abb
Author: me2seeks <me...@gmail.com>
AuthorDate: Thu Sep 24 03:58:18 2026 +0800
style(tui): satisfy recap lint checks
Generated-by: Codex
---
crates/tui/src/pages/recap.rs | 11 +++++------
crates/tui/src/pages/recap/view.rs | 2 +-
2 files changed, 6 insertions(+), 7 deletions(-)
diff --git a/crates/tui/src/pages/recap.rs b/crates/tui/src/pages/recap.rs
index 1acda918dd..93e816573e 100644
--- a/crates/tui/src/pages/recap.rs
+++ b/crates/tui/src/pages/recap.rs
@@ -394,14 +394,13 @@ impl App {
}
match result {
Ok(receipt) => {
- if let Some(receipt) = &receipt {
- if state
+ if let Some(receipt) = &receipt
+ && state
.saved
.as_ref()
- .is_some_and(|s| s.operation == receipt.operation())
- {
- state.saved = None;
- }
+ .is_some_and(|saved| saved.operation == receipt.operation())
+ {
+ state.saved = None;
}
state.loaded = true;
state.receipt = receipt;
diff --git a/crates/tui/src/pages/recap/view.rs b/crates/tui/src/pages/recap/view.rs
index d2eb7f964e..3454356a66 100644
--- a/crates/tui/src/pages/recap/view.rs
+++ b/crates/tui/src/pages/recap/view.rs
@@ -174,7 +174,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App, area: Rect, base: Style) {
Some(Receipt::Pending { .. }) => text.push_str(&app.i18n.text("recap-pending")),
Some(Receipt::Failed { reason, .. }) => {
text.push_str(&app.i18n.text("recap-failed"));
- text.push_str("\n");
+ text.push('\n');
text.push_str(&safe(reason));
}
None if state.loaded => text.push_str(&app.i18n.text("recap-empty")),
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org