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)]
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/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]
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\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)