From d29b18da00f0b0196d014965e56503fff75ba05e Mon Sep 17 00:00:00 2001 From: wismyzhizi2018 Date: Mon, 29 Jun 2026 16:03:13 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(tui):=20=E5=90=8E=E5=8F=B0=20shell=20?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E5=9C=A8=20TUI=20=E8=81=8A=E5=A4=A9=E5=8C=BA?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:后台 shell 命令完成/等待输入的 XML 通知直接注入 agent 对话流, 在 TUI 聊天区显示为原始 XML 标签,用户不可读。同时,前台小命令快速结束 也会注入后台完成通知,打断对话流。 修改内容: - background_shell.rs 新增 shell_notification_display_text() 解析 XML 通知为可读文本 - background_shell.rs 新增 xml_unescape()/truncate_chars()/unwrap_system_reminder()/extract_xml_tag() 辅助函数 - shell_command.rs poll_agent_shells() 区分前台/后台化命令:仅后台化命令注入完成通知 - agent_submit.rs 后台通知消息不污染输入历史,agent_input 包裹为 system-reminder - message_view/mod.rs from_base_message() 后台 shell 通知渲染为 SystemNote 而非原始文本 - mod.rs 导出 shell_notification_display_text 函数 - background_shell_test.rs 新增 3 个测试覆盖通知显示文本解析 - shell_command_test.rs 新增 2 个测试覆盖前台/后台化通知注入逻辑 - message_view_test.rs 新增 1 个测试覆盖后台通知渲染为 SystemNote 特性/影响: - 前台小命令完成不再打断对话流,仅后台化(Ctrl+B)命令注入通知 - XML 通知在聊天区显示为"后台 shell 已完成/失败/等待输入"等可读提示 - 后台通知消息不污染输入历史和 last_submitted_text Co-Authored-By: Claude mimo-v2.5-pro --- peri-tui/src/app/agent_submit.rs | 29 ++++-- peri-tui/src/app/background_shell.rs | 70 +++++++++++++++ peri-tui/src/app/background_shell_test.rs | 84 ++++++++++++++++- peri-tui/src/app/mod.rs | 2 +- peri-tui/src/app/shell_command.rs | 20 ++++- peri-tui/src/app/shell_command_test.rs | 89 +++++++++++++++++-- .../src/ui/message_view/message_view_test.rs | 23 +++++ peri-tui/src/ui/message_view/mod.rs | 8 ++ 8 files changed, 306 insertions(+), 19 deletions(-) diff --git a/peri-tui/src/app/agent_submit.rs b/peri-tui/src/app/agent_submit.rs index b0f4cd87..f2caae8e 100644 --- a/peri-tui/src/app/agent_submit.rs +++ b/peri-tui/src/app/agent_submit.rs @@ -4,6 +4,12 @@ impl App { pub fn submit_message(&mut self, input: String) { let display_input = input; let expanded_input = self.expand_pasted_text(&display_input); + let shell_notification_display = super::shell_notification_display_text(&display_input); + let agent_input = if shell_notification_display.is_some() { + format!("\n{}\n", expanded_input) + } else { + expanded_input.clone() + }; let had_pasted_blocks = !self.session_mgr.current().ui.pasted_text_blocks.is_empty() || self.input_contains_pasted_text_placeholder(&display_input); @@ -24,7 +30,9 @@ impl App { self.session_mgr.current_mut().metadata.pre_submit_state_len = self.session_mgr.current_mut().agent.origin_messages.len(); - self.push_input_history(expanded_input.clone()); + if shell_notification_display.is_none() { + self.push_input_history(expanded_input.clone()); + } // 消费待发送附件 let attachments = @@ -40,7 +48,9 @@ impl App { ); // 构建用于显示的文字(附件摘要追加在末尾) - let display = if active_attachments.is_empty() { + let display = if let Some(display) = shell_notification_display.clone() { + display + } else if active_attachments.is_empty() { display_input.clone() } else { self.services.lc.tr_args( @@ -61,10 +71,10 @@ impl App { // 构建发送给 LLM 的 MessageContent(含附件图片 blocks) let message_content = if active_attachments.is_empty() { - peri_agent::messages::MessageContent::text(expanded_input.clone()) + peri_agent::messages::MessageContent::text(agent_input.clone()) } else { let mut blocks = vec![peri_agent::messages::ContentBlock::text( - expanded_input.clone(), + agent_input.clone(), )]; for att in active_attachments { blocks.push(peri_agent::messages::ContentBlock::image_base64( @@ -79,7 +89,9 @@ impl App { .messages .pipeline .begin_round(); - let user_vm = if let Some(expanded) = expanded_for_detail { + let user_vm = if shell_notification_display.is_some() { + MessageViewModel::system(display.clone()) + } else if let Some(expanded) = expanded_for_detail { MessageViewModel::user_with_expanded(display.clone(), expanded) } else { MessageViewModel::user(display.clone()) @@ -90,7 +102,12 @@ impl App { self.session_mgr.current_mut().messages.round_start_vm_idx = self.session_mgr.current_mut().messages.view_messages.len(); self.session_mgr.current_mut().metadata.last_human_message = Some(display); - self.session_mgr.current_mut().messages.last_submitted_text = Some(expanded_input.clone()); + self.session_mgr.current_mut().messages.last_submitted_text = + if shell_notification_display.is_some() { + None + } else { + Some(expanded_input.clone()) + }; if had_pasted_blocks { self.clear_pasted_text_blocks(); } diff --git a/peri-tui/src/app/background_shell.rs b/peri-tui/src/app/background_shell.rs index 6c39bac7..136bd9a3 100644 --- a/peri-tui/src/app/background_shell.rs +++ b/peri-tui/src/app/background_shell.rs @@ -269,6 +269,61 @@ pub fn shell_stalled_notification(task_id: &str, command: &str, last_output: &st ) } +/// 将后台 shell 控制通知转换为 TUI 可读的一行提示。 +/// +/// 原始 XML 仍会发送给 agent;这里仅用于聊天区展示,避免内部标签泄露给用户。 +pub fn shell_notification_display_text(raw: &str) -> Option { + let notification = unwrap_system_reminder(raw.trim()); + if notification.starts_with("") { + let command = extract_xml_tag(notification, "command") + .map(xml_unescape) + .unwrap_or_else(|| "shell command".to_string()); + let status = extract_xml_tag(notification, "status") + .map(xml_unescape) + .unwrap_or_else(|| "completed".to_string()); + let verb = if status.starts_with("failed") { + "后台 shell 失败" + } else if status == "terminated" { + "后台 shell 已终止" + } else { + "后台 shell 已完成" + }; + return Some(format!( + "{}: {} ({})", + verb, + truncate_chars(&command, 80), + status + )); + } + + if notification.starts_with("") { + let command = extract_xml_tag(notification, "command") + .map(xml_unescape) + .unwrap_or_else(|| "shell command".to_string()); + return Some(format!( + "后台 shell 等待输入: {}", + truncate_chars(&command, 80) + )); + } + + None +} + +fn unwrap_system_reminder(raw: &str) -> &str { + raw.strip_prefix("") + .and_then(|s| s.strip_suffix("")) + .map(str::trim) + .unwrap_or(raw) +} + +fn extract_xml_tag<'a>(raw: &'a str, tag: &str) -> Option<&'a str> { + let start_tag = format!("<{}>", tag); + let end_tag = format!("", tag); + let start = raw.find(&start_tag)? + start_tag.len(); + let end = raw[start..].find(&end_tag)? + start; + Some(&raw[start..end]) +} + /// XML 转义(command/last_output 可能含 `<` `>` `&`,如 shell 重定向、错误信息)。 fn xml_escape(s: &str) -> String { s.replace('&', "&") @@ -276,6 +331,21 @@ fn xml_escape(s: &str) -> String { .replace('>', ">") } +fn xml_unescape(s: &str) -> String { + s.replace("<", "<") + .replace(">", ">") + .replace("&", "&") +} + +fn truncate_chars(s: &str, max_chars: usize) -> String { + if s.chars().count() <= max_chars { + return s.to_string(); + } + let mut result: String = s.chars().take(max_chars).collect(); + result.push_str("..."); + result +} + #[cfg(test)] #[path = "background_shell_test.rs"] mod tests; diff --git a/peri-tui/src/app/background_shell_test.rs b/peri-tui/src/app/background_shell_test.rs index 0ec3a105..8a3c31f9 100644 --- a/peri-tui/src/app/background_shell_test.rs +++ b/peri-tui/src/app/background_shell_test.rs @@ -69,13 +69,91 @@ fn test_shell_completion_notification_完成格式() { // Act let msg = shell_completion_notification("abc", "npm test", Some(0), path); // Assert - assert!(msg.contains(""), "缺少 XML 根标签: {}", msg); - assert!(msg.contains("abc"), "缺少 task-id: {}", msg); - assert!(msg.contains("npm test"), "缺少 command: {}", msg); + assert!( + msg.contains(""), + "缺少 XML 根标签: {}", + msg + ); + assert!( + msg.contains("abc"), + "缺少 task-id: {}", + msg + ); + assert!( + msg.contains("npm test"), + "缺少 command: {}", + msg + ); assert!(msg.contains("completed (exit 0)"), "缺少完成状态: {}", msg); assert!(msg.contains("abc.output"), "缺少 output 路径: {}", msg); } +#[test] +fn test_shell_notification_display_text_完成提示不泄露_xml() { + // Arrange + let path = Path::new("/tmp/peri/tasks/abc.output"); + let msg = shell_completion_notification("abc", "npm test", Some(0), path); + // Act + let display = shell_notification_display_text(&msg).expect("应识别后台 shell 完成通知"); + // Assert + assert!( + display.contains("后台 shell 已完成"), + "应显示完成提示: {}", + display + ); + assert!(display.contains("npm test"), "应保留命令摘要: {}", display); + assert!( + !display.contains(""), + "不应泄露 XML 标签: {}", + display + ); +} + +#[test] +fn test_shell_notification_display_text_支持_system_reminder_包裹() { + // Arrange + let path = Path::new("/tmp/peri/tasks/abc.output"); + let msg = shell_completion_notification("abc", "cargo test", Some(0), path); + let wrapped = format!("\n{}\n", msg); + // Act + let display = shell_notification_display_text(&wrapped).expect("应识别包裹后的后台 shell 通知"); + // Assert + assert!( + display.contains("后台 shell 已完成"), + "应显示完成提示: {}", + display + ); + assert!( + display.contains("cargo test"), + "应保留命令摘要: {}", + display + ); + assert!( + !display.contains(""), + "不应泄露 system-reminder 标签: {}", + display + ); +} + +#[test] +fn test_shell_notification_display_text_等待输入提示() { + // Arrange + let msg = shell_stalled_notification("t1", "npm publish", "continue?"); + // Act + let display = shell_notification_display_text(&msg).expect("应识别等待输入通知"); + // Assert + assert!( + display.contains("后台 shell 等待输入"), + "应显示等待输入: {}", + display + ); + assert!( + display.contains("npm publish"), + "应保留命令摘要: {}", + display + ); +} + #[test] fn test_shell_completion_notification_失败状态() { // Arrange diff --git a/peri-tui/src/app/mod.rs b/peri-tui/src/app/mod.rs index 5c3cd949..f22a8592 100644 --- a/peri-tui/src/app/mod.rs +++ b/peri-tui/src/app/mod.rs @@ -70,7 +70,7 @@ mod paste_ops; mod rewind_prompt; pub use rewind_prompt::{FileChangeInfo, RewindItem, RewindMode, RewindPrompt}; mod background_shell; -pub(crate) use background_shell::{BackgroundShell, ShellStatus}; +pub(crate) use background_shell::{shell_notification_display_text, BackgroundShell, ShellStatus}; mod background_tasks_panel; mod shell_command; pub(crate) use shell_command::ShellCommandPool; diff --git a/peri-tui/src/app/shell_command.rs b/peri-tui/src/app/shell_command.rs index 877af0c8..ff40941d 100644 --- a/peri-tui/src/app/shell_command.rs +++ b/peri-tui/src/app/shell_command.rs @@ -511,14 +511,18 @@ impl App { any } - /// 轮询 agent shell 退出状态:检测 ExitSignal,退出则标记 + 注入完成通知。 + /// 轮询 agent shell 退出状态:检测 ExitSignal,退出则标记。 + /// + /// 只有已经后台化(Ctrl+B)或直接后台启动的 agent shell 才注入完成通知。 + /// 普通前台命令即使很快结束,也只标记结束,避免“小命令”打断对话流。 /// /// 由主循环每帧调用(与 [`Self::poll_background_shell_events`] 平行)。 /// 返回是否有任何状态变化(用于触发重绘)。 pub fn poll_agent_shells(&mut self) -> bool { // 阶段1:收集完成通知(&mut session) - let notifications: Vec = { + let (changed, notifications): (bool, Vec) = { let session = self.session_mgr.current_mut(); + let mut changed = false; let mut notifs = Vec::new(); for slot in session.agent_shells.iter_mut() { if slot.ended { @@ -529,7 +533,12 @@ impl App { } // 退出:exit_code 未知(ExitSignal 不携带),用 -1 兜底。 // 更精确的 exit_code 由 BashTool::invoke 经 result_rx 拿到,通知里不影响 agent 判断。 + let was_backgrounded = slot.is_backgrounded; slot.mark_ended(None); + changed = true; + if !was_backgrounded { + continue; + } let n = super::background_shell::shell_completion_notification( &slot.task_id, &slot.command, @@ -538,11 +547,14 @@ impl App { ); notifs.push(n); } - notifs + (changed, notifs) }; if notifications.is_empty() { - return false; + if changed { + self.cleanup_finished_agent_shells(); + } + return changed; } // 阶段2:注入通知(复用 !command 路径的注入机制:idle 注入首个触发新轮次,其余入 pending) diff --git a/peri-tui/src/app/shell_command_test.rs b/peri-tui/src/app/shell_command_test.rs index 4167b2fd..1bff541b 100644 --- a/peri-tui/src/app/shell_command_test.rs +++ b/peri-tui/src/app/shell_command_test.rs @@ -1,5 +1,11 @@ use super::*; +use std::{path::PathBuf, sync::Arc}; + use peri_agent::messages::BaseMessage; +use peri_agent::shell::ExitSignal; +use tokio::sync::oneshot; + +use crate::app::{AgentShellRegistration, AgentShellSlot}; fn make_record( thread_id: &str, @@ -22,6 +28,27 @@ fn make_record( } } +fn make_agent_shell_slot( + direct_background: bool, + command: &str, +) -> (AgentShellSlot, Arc) { + let (bg_tx, _bg_rx) = oneshot::channel(); + let exit_signal = Arc::new(ExitSignal::new()); + let task = tokio::spawn(async {}); + let reg = AgentShellRegistration { + task_id: uuid::Uuid::now_v7().to_string(), + command: command.to_string(), + cwd: ".".to_string(), + output_path: PathBuf::from("/tmp/peri-agent-shell.output"), + exit_signal: Arc::clone(&exit_signal), + background_tx: if direct_background { None } else { Some(bg_tx) }, + kill: task.abort_handle(), + started_instant: std::time::Instant::now(), + direct_background, + }; + (AgentShellSlot::from_registration(reg), exit_signal) +} + #[tokio::test] async fn test_merge_shell_records_inserts_after_anchor_without_origin_messages() { let (app, _handle) = App::new_headless(80, 24).await; @@ -96,7 +123,12 @@ async fn test_cancel_shell_command_aborts_task_and_replaces_pending_vm() { "取消 shell 命令应 abort 后台任务" ); assert!( - !app.session_mgr.current().shell_pool.foreground.runtime.is_running(), + !app.session_mgr + .current() + .shell_pool + .foreground + .runtime + .is_running(), "取消后应清理 ShellCommandRuntime" ); assert!( @@ -117,6 +149,56 @@ async fn test_cancel_shell_command_aborts_task_and_replaces_pending_vm() { ); } +#[tokio::test] +async fn test_poll_agent_shells_前台结束不注入后台通知() { + let (mut app, _handle) = App::new_headless(80, 24).await; + app.set_loading(true); + let (slot, exit_signal) = make_agent_shell_slot(false, "echo hi"); + app.session_mgr.current_mut().agent_shells.push(slot); + + exit_signal.fire(); + let changed = app.poll_agent_shells(); + + assert!(changed, "前台 shell 退出也应产生状态变化用于重绘"); + assert!( + app.session_mgr.current().agent_shells[0].ended, + "退出后应标记 ended" + ); + assert!( + app.session_mgr + .current() + .pending_bg_shell_notifications + .is_empty(), + "未后台化的前台小命令不应注入后台完成通知" + ); +} + +#[tokio::test] +async fn test_poll_agent_shells_后台化结束才注入通知() { + let (mut app, _handle) = App::new_headless(80, 24).await; + app.set_loading(true); + let (slot, exit_signal) = make_agent_shell_slot(true, "cargo test"); + app.session_mgr.current_mut().agent_shells.push(slot); + + exit_signal.fire(); + let changed = app.poll_agent_shells(); + + assert!(changed, "后台 shell 退出应产生状态变化"); + let pending = &app.session_mgr.current().pending_bg_shell_notifications; + assert_eq!(pending.len(), 1, "后台 shell 完成应注入一条通知"); + let notification = pending.front().expect("应有后台完成通知"); + assert!( + notification.contains(""), + "通知应保留 agent 可解析的 XML: {}", + notification + ); + assert!( + notification.contains("cargo test"), + "通知应包含命令: {}", + notification + ); +} + #[tokio::test] async fn test_cleanup_finished_background_shells_超量移除最旧已完成() { use std::path::PathBuf; @@ -202,10 +284,7 @@ async fn test_cleanup_finished_background_shells_未超量不移除() { ); bg.status = ShellStatus::Completed; bg.notified = true; - app.session_mgr - .current_mut() - .background_shells - .push(bg); + app.session_mgr.current_mut().background_shells.push(bg); app.cleanup_finished_background_shells(); assert_eq!( diff --git a/peri-tui/src/ui/message_view/message_view_test.rs b/peri-tui/src/ui/message_view/message_view_test.rs index c2a12d37..4376e4a0 100644 --- a/peri-tui/src/ui/message_view/message_view_test.rs +++ b/peri-tui/src/ui/message_view/message_view_test.rs @@ -482,6 +482,29 @@ fn test_human_message_with_system_reminder_detection() { } } +#[test] +fn test_human_background_shell_notification_renders_as_system_note() { + let text = "\n\nabc\nnpm test\ncompleted (exit 0)\n/tmp/abc.output\n\n"; + let msg = BaseMessage::human(text); + let vm = MessageViewModel::from_base_message(&msg, &[]); + match vm { + MessageViewModel::SystemNote { content, .. } => { + assert!( + content.contains("后台 shell 已完成"), + "应显示可读系统提示: {}", + content + ); + assert!(content.contains("npm test"), "应保留命令摘要: {}", content); + assert!( + !content.contains(""), + "不应泄露 XML 标签: {}", + content + ); + } + _ => panic!("后台 shell 通知应渲染为 SystemNote"), + } +} + #[test] fn test_human_message_without_system_reminder() { let text = "普通用户消息"; diff --git a/peri-tui/src/ui/message_view/mod.rs b/peri-tui/src/ui/message_view/mod.rs index 488c2ad3..a38607e0 100644 --- a/peri-tui/src/ui/message_view/mod.rs +++ b/peri-tui/src/ui/message_view/mod.rs @@ -552,6 +552,14 @@ impl MessageViewModel { match msg { BaseMessage::Human { content, .. } => { let raw = content.text_content(); + if let Some(display_text) = crate::app::shell_notification_display_text(&raw) { + let mut vm = MessageViewModel::SystemNote { + content: display_text, + content_hash: 0, + }; + vm.recompute_hash(); + return vm; + } let (display_text, system_reminder) = if raw.contains("") { let cleaned = raw .replacen("\n", "", 1) From b435d6645a2cb9d092892c57dd9ba02dbdb647c5 Mon Sep 17 00:00:00 2001 From: wismyzhizi2018 Date: Mon, 29 Jun 2026 16:13:50 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(tui):=20=E4=BF=AE=E5=A4=8D=20test=5Fent?= =?UTF-8?q?er=5Fskill=5Fname=5Fsubmits=5Fmessage=20=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /review 已注册为内置 passthrough 命令,但测试仍断言其为非已知命令。 将测试中 mock skill 名称从 "review" 改为 "deploy" 避免与内置命令冲突。 Co-Authored-By: Claude mimo-v2.5-pro --- peri-tui/src/ui/headless_test.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/peri-tui/src/ui/headless_test.rs b/peri-tui/src/ui/headless_test.rs index 5a5ddba3..a9a36e71 100644 --- a/peri-tui/src/ui/headless_test.rs +++ b/peri-tui/src/ui/headless_test.rs @@ -1651,15 +1651,15 @@ async fn test_enter_skill_name_submits_message() { .current_mut() .ui .textarea - .insert_str("/review"); + .insert_str("/deploy"); app.session_mgr .current_mut() .commands .skills .push(SkillMetadata { - name: "review".into(), - description: "code review".into(), - path: "/tmp/review.md".into(), + name: "deploy".into(), + description: "deploy to production".into(), + path: "/tmp/deploy.md".into(), }); // 模拟 Enter 事件处理 @@ -1671,7 +1671,7 @@ async fn test_enter_skill_name_submits_message() { let registry = std::mem::take(&mut app.session_mgr.current_mut().commands.command_registry); let known = registry.dispatch(&mut app, &text); app.session_mgr.current_mut().commands.command_registry = registry; - assert!(!known, "review 不应是已知命令"); + assert!(!known, "deploy 不应是已知命令"); // 验证 Skill 匹配 let skill_name: String = text @@ -1679,7 +1679,7 @@ async fn test_enter_skill_name_submits_message() { .chars() .take_while(|c| c.is_alphanumeric() || *c == '-' || *c == '_') .collect(); - assert_eq!(skill_name, "review"); + assert_eq!(skill_name, "deploy"); let skill_found = app .session_mgr .current_mut() @@ -1687,7 +1687,7 @@ async fn test_enter_skill_name_submits_message() { .skills .iter() .find(|s| s.name == skill_name); - assert!(skill_found.is_some(), "应找到 review Skill"); + assert!(skill_found.is_some(), "应找到 deploy Skill"); } #[tokio::test] From 4e9e7c469089b64e33403165e1c720efaa4307e4 Mon Sep 17 00:00:00 2001 From: wismyzhizi2018 Date: Mon, 29 Jun 2026 16:25:30 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(middlewares):=20Stdio=20import=20?= =?UTF-8?q?=E5=8A=A0=20cfg(windows)=20=E6=9D=A1=E4=BB=B6=E7=BC=96=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit peri-middlewares/src/middleware/terminal.rs 的 Stdio import 在非 Windows 平台未使用导致 Clippy -D warnings 失败。加 #[cfg(windows)] 条件编译。 Co-Authored-By: Claude mimo-v2.5-pro --- peri-middlewares/src/middleware/terminal.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/peri-middlewares/src/middleware/terminal.rs b/peri-middlewares/src/middleware/terminal.rs index 14278a79..3fa1b36e 100644 --- a/peri-middlewares/src/middleware/terminal.rs +++ b/peri-middlewares/src/middleware/terminal.rs @@ -3,6 +3,7 @@ use peri_agent::{ agent::state::State, middleware::r#trait::Middleware, shell::ShellExecutor, tools::BaseTool, }; use serde_json::Value; +#[cfg(windows)] use std::process::Stdio; use std::sync::Arc; #[cfg(windows)]