Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions peri-middlewares/src/middleware/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
29 changes: 23 additions & 6 deletions peri-tui/src/app/agent_submit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!("<system-reminder>\n{}\n</system-reminder>", 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);

Expand All @@ -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 =
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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())
Expand All @@ -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();
}
Expand Down
70 changes: 70 additions & 0 deletions peri-tui/src/app/background_shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,13 +269,83 @@ 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<String> {
let notification = unwrap_system_reminder(raw.trim());
if notification.starts_with("<background-task-completed>") {
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("<background-task-waiting-for-input>") {
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("<system-reminder>")
.and_then(|s| s.strip_suffix("</system-reminder>"))
.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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}

fn xml_unescape(s: &str) -> String {
s.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&")
}

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;
84 changes: 81 additions & 3 deletions peri-tui/src/app/background_shell_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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("<background-task-completed>"), "缺少 XML 根标签: {}", msg);
assert!(msg.contains("<task-id>abc</task-id>"), "缺少 task-id: {}", msg);
assert!(msg.contains("<command>npm test</command>"), "缺少 command: {}", msg);
assert!(
msg.contains("<background-task-completed>"),
"缺少 XML 根标签: {}",
msg
);
assert!(
msg.contains("<task-id>abc</task-id>"),
"缺少 task-id: {}",
msg
);
assert!(
msg.contains("<command>npm test</command>"),
"缺少 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("<background-task-completed>"),
"不应泄露 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!("<system-reminder>\n{}\n</system-reminder>", 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>"),
"不应泄露 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
Expand Down
2 changes: 1 addition & 1 deletion peri-tui/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 16 additions & 4 deletions peri-tui/src/app/shell_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = {
let (changed, notifications): (bool, Vec<String>) = {
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 {
Expand All @@ -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,
Expand All @@ -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)
Expand Down
Loading
Loading