Skip to content

Commit 83f97e3

Browse files
committed
feat: 修复 AI Messages 的空白问题
1 parent 8d97e17 commit 83f97e3

9 files changed

Lines changed: 312 additions & 54 deletions

File tree

Cargo.lock

Lines changed: 6 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cross.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
[target.x86_64-unknown-linux-gnu]
2+
pre-build = [
3+
"apt-get update",
4+
"apt-get install -y libxcb1-dev libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libssl-dev pkg-config",
5+
]
6+
7+
[target.aarch64-unknown-linux-gnu]
8+
pre-build = [
9+
"apt-get update",
10+
"apt-get install -y libxcb1-dev libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libssl-dev pkg-config",
11+
]

rust-agent-tui/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ version = "0.1.0"
44
edition = "2021"
55
description = "TUI interface for Rust Agent - interactive terminal playground"
66

7+
[lib]
8+
name = "rust_agent_tui"
9+
path = "src/lib.rs"
10+
711
[[bin]]
812
name = "agent-tui"
913
path = "src/main.rs"

rust-agent-tui/src/app/agent_ops.rs

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -256,15 +256,26 @@ impl App {
256256
let _ = self.render_tx.send(RenderEvent::UpdateLastMessage(vm));
257257
}
258258
} else {
259-
match self.view_messages.last_mut() {
260-
Some(m) if m.is_assistant() => m.append_chunk(&chunk),
261-
_ => {
262-
let vm = MessageViewModel::assistant();
263-
self.view_messages.push(vm.clone());
264-
let _ = self.render_tx.send(RenderEvent::AddMessage(vm));
259+
// 如果 chunk 为空且没有现有的 assistant bubble,跳过创建空的 bubble
260+
// 避免 AI 只发起工具调用时显示空白消息
261+
if chunk.is_empty() {
262+
match self.view_messages.last_mut() {
263+
Some(m) if m.is_assistant() => m.append_chunk(&chunk),
264+
_ => {
265+
// 没有现有的 assistant bubble,chunk 为空,不创建新的空 bubble
266+
}
267+
}
268+
} else {
269+
match self.view_messages.last_mut() {
270+
Some(m) if m.is_assistant() => m.append_chunk(&chunk),
271+
_ => {
272+
let vm = MessageViewModel::assistant();
273+
self.view_messages.push(vm.clone());
274+
let _ = self.render_tx.send(RenderEvent::AddMessage(vm));
275+
}
265276
}
277+
let _ = self.render_tx.send(RenderEvent::AppendChunk(chunk));
266278
}
267-
let _ = self.render_tx.send(RenderEvent::AppendChunk(chunk));
268279
}
269280
(true, false, false)
270281
}

rust-agent-tui/src/lib.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
//! TUI interface for Rust Agent - interactive terminal playground
2+
3+
pub mod app;
4+
pub mod command;
5+
pub mod config;
6+
pub mod event;
7+
pub mod langfuse;
8+
pub mod prompt;
9+
pub mod relay_adapter;
10+
pub mod thread;
11+
pub mod ui;
12+
13+
/// CLI 参数解析结果:--remote-control [url] [--relay-token <token>] [--relay-name <name>]
14+
/// url 为空字符串表示 `--remote-control` 无参数模式(从配置读取)
15+
pub struct RelayCli {
16+
pub url: String,
17+
pub token: Option<String>,
18+
pub name: Option<String>,
19+
}
20+
21+
pub fn parse_relay_args(args: &[String]) -> Option<RelayCli> {
22+
// 查找 --remote-control 参数位置
23+
let remote_idx = args.iter().position(|a| a == "--remote-control")?;
24+
25+
// 检查是否有值(即 --remote-control <url> 格式)
26+
// 有值条件:下一个参数存在且不以 -- 开头
27+
let url = if remote_idx + 1 < args.len() && !args[remote_idx + 1].starts_with("--") {
28+
args[remote_idx + 1].clone()
29+
} else {
30+
// --remote-control 无参数,返回空字符串标记"从配置读取"
31+
String::new()
32+
};
33+
34+
let token = args.windows(2)
35+
.find(|w| w[0] == "--relay-token")
36+
.map(|w| w[1].clone());
37+
let name = args.windows(2)
38+
.find(|w| w[0] == "--relay-name")
39+
.map(|w| w[1].clone());
40+
41+
Some(RelayCli { url, token, name })
42+
}

rust-agent-tui/src/main.rs

Lines changed: 6 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,3 @@
1-
mod app;
2-
mod command;
3-
mod config;
4-
mod event;
5-
mod langfuse;
6-
mod prompt;
7-
mod relay_adapter;
8-
mod thread;
9-
mod ui;
10-
111
use anyhow::Result;
122
use ratatui::{
133
crossterm::{
@@ -19,6 +9,11 @@ use ratatui::{
199
};
2010
use std::io;
2111

12+
use rust_agent_tui::app::App;
13+
use rust_agent_tui::event;
14+
use rust_agent_tui::ui;
15+
use rust_agent_tui::{parse_relay_args, RelayCli};
16+
2217
fn main() -> Result<()> {
2318
// 加载 .env 文件(仅开发环境,文件不存在时静默忽略)
2419
let _ = dotenvy::dotenv();
@@ -71,39 +66,8 @@ fn main() -> Result<()> {
7166
Ok(())
7267
}
7368

74-
/// CLI 参数解析结果:--remote-control [url] [--relay-token <token>] [--relay-name <name>]
75-
/// url 为空字符串表示 `--remote-control` 无参数模式(从配置读取)
76-
pub struct RelayCli {
77-
pub url: String,
78-
pub token: Option<String>,
79-
pub name: Option<String>,
80-
}
81-
82-
fn parse_relay_args(args: &[String]) -> Option<RelayCli> {
83-
// 查找 --remote-control 参数位置
84-
let remote_idx = args.iter().position(|a| a == "--remote-control")?;
85-
86-
// 检查是否有值(即 --remote-control <url> 格式)
87-
// 有值条件:下一个参数存在且不以 -- 开头
88-
let url = if remote_idx + 1 < args.len() && !args[remote_idx + 1].starts_with("--") {
89-
args[remote_idx + 1].clone()
90-
} else {
91-
// --remote-control 无参数,返回空字符串标记"从配置读取"
92-
String::new()
93-
};
94-
95-
let token = args.windows(2)
96-
.find(|w| w[0] == "--relay-token")
97-
.map(|w| w[1].clone());
98-
let name = args.windows(2)
99-
.find(|w| w[0] == "--relay-name")
100-
.map(|w| w[1].clone());
101-
102-
Some(RelayCli { url, token, name })
103-
}
104-
10569
async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, relay_cli: Option<RelayCli>) -> Result<()> {
106-
let mut app = app::App::new();
70+
let mut app = App::new();
10771

10872
// 尝试连接 Relay Server(CLI 参数优先,其次读 settings.json)
10973
app.try_connect_relay(relay_cli.as_ref()).await;

rust-agent-tui/src/ui/headless.rs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,4 +493,85 @@ mod tests {
493493
let has_tool_call_text = snap.iter().any(|l| l.contains("bash") || l.contains("Bash"));
494494
assert!(has_tool_call_text, "ToolCall 创建的 ToolBlock 应在快照中可见,但实际内容为:\n{}", snap.join("\n"));
495495
}
496+
497+
#[tokio::test]
498+
async fn test_empty_assistant_chunk_no_bubble() {
499+
// 空 AssistantChunk 不应创建空白的 AssistantBubble
500+
let (mut app, _handle) = App::new_headless(120, 30);
501+
502+
// 发送空 chunk,不应创建 AssistantBubble
503+
app.push_agent_event(AgentEvent::AssistantChunk("".into()));
504+
app.process_pending_events();
505+
506+
// view_messages 应为空(没有创建空白气泡)
507+
assert!(
508+
app.view_messages.is_empty(),
509+
"空 AssistantChunk 不应创建 AssistantBubble,实际: {:?}",
510+
app.view_messages.len()
511+
);
512+
513+
// 发送多个空 chunk,仍不应创建气泡
514+
app.push_agent_event(AgentEvent::AssistantChunk("".into()));
515+
app.push_agent_event(AgentEvent::AssistantChunk("".into()));
516+
app.process_pending_events();
517+
518+
assert!(
519+
app.view_messages.is_empty(),
520+
"多个空 AssistantChunk 仍不应创建 AssistantBubble"
521+
);
522+
}
523+
524+
#[tokio::test]
525+
async fn test_empty_then_nonempty_assistant_chunk() {
526+
// 空_chunk → 非空_chunk:非空 chunk 应正常创建气泡
527+
let (mut app, mut handle) = App::new_headless(120, 30);
528+
529+
// 先发送空 chunk
530+
app.push_agent_event(AgentEvent::AssistantChunk("".into()));
531+
app.process_pending_events();
532+
533+
// 再发送非空 chunk
534+
let notify = Arc::clone(&handle.render_notify);
535+
let n1 = notify.notified();
536+
let n2 = notify.notified();
537+
app.push_agent_event(AgentEvent::AssistantChunk("Hello".into()));
538+
app.push_agent_event(AgentEvent::Done);
539+
app.process_pending_events();
540+
tokio::join!(n1, n2);
541+
542+
handle.terminal.draw(|f| main_ui::render(f, &mut app)).unwrap();
543+
544+
// 应该只有 1 个 AssistantBubble,内容为 "Hello"
545+
assert_eq!(app.view_messages.len(), 1, "应只有 1 条消息");
546+
assert!(app.view_messages[0].is_assistant(), "应为 AssistantBubble");
547+
assert!(handle.contains("Hello"), "应显示 Hello 内容");
548+
}
549+
550+
#[tokio::test]
551+
async fn test_tool_call_without_assistant_chunk_no_bubble() {
552+
// 模拟 AI 只调用工具不输出文本的场景
553+
let (mut app, mut handle) = App::new_headless(120, 30);
554+
555+
// 直接发送 ToolCall 事件(无 AssistantChunk)
556+
let notified = handle.render_notify.notified();
557+
app.push_agent_event(AgentEvent::ToolCall {
558+
tool_call_id: "tc1".into(),
559+
name: "bash".into(),
560+
display: "Bash".into(),
561+
args: Some("ls".into()),
562+
is_error: false,
563+
});
564+
app.process_pending_events();
565+
notified.await;
566+
567+
handle.terminal.draw(|f| main_ui::render(f, &mut app)).unwrap();
568+
569+
// 应该有 1 个 ToolBlock,不应有空白 AssistantBubble
570+
assert_eq!(app.view_messages.len(), 1, "应有 1 条消息(ToolBlock)");
571+
// 确保不是 AssistantBubble(空白气泡)
572+
assert!(
573+
!app.view_messages[0].is_assistant(),
574+
"不应创建 AssistantBubble,应为 ToolBlock"
575+
);
576+
}
496577
}

0 commit comments

Comments
 (0)