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
12 changes: 8 additions & 4 deletions peri-tui/src/app/background_tasks_panel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@ use crate::ui::theme;
const SHELL_DETAIL_TAIL_BYTES: u64 = 8192;
/// Detail 视图 output 缓存刷新间隔(避免每帧读磁盘)
const OUTPUT_REFRESH_INTERVAL: Duration = Duration::from_millis(1000);
/// Detail 视图 output 框显示的最大行数
const DETAIL_OUTPUT_MAX_LINES: usize = 10;

/// 后台任务面板:List/Detail 双视图。
pub struct BackgroundTasksPanel {
Expand Down Expand Up @@ -522,10 +520,12 @@ fn render_detail(
let output_inner = output_block.inner(chunks[1]);
f.render_widget(output_block, chunks[1]);

// 显示末尾 N 行 + "Showing N lines of X.X KB"
// 显示末尾 N 行 + "Showing N lines of X.X KB"(N 根据实际可用高度动态计算)
let total_kb = panel.output_cache.len() as f64 / 1024.0;
let all_lines: Vec<&str> = panel.output_cache.lines().collect();
let start = all_lines.len().saturating_sub(DETAIL_OUTPUT_MAX_LINES);
// footer 占 2 行(空行 + 状态行),剩余空间全部用于显示内容
let max_content_lines = output_inner.height.saturating_sub(2) as usize;
let start = all_lines.len().saturating_sub(max_content_lines);
let showing_lines = &all_lines[start..];
let mut output_lines: Vec<Line> = showing_lines
.iter()
Expand Down Expand Up @@ -603,3 +603,7 @@ impl super::App {
));
}
}

#[cfg(test)]
#[path = "background_tasks_panel_test.rs"]
mod tests;
173 changes: 173 additions & 0 deletions peri-tui/src/app/background_tasks_panel_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
use std::path::PathBuf;
use std::time::{Duration, Instant};

use tokio::sync::oneshot;

use crate::app::panel_manager::PanelState;
use crate::app::{App, BackgroundShell};
use super::{BackgroundTasksPanel, BackgroundTaskView};
use crate::shell_exec::CommandOutput;
use peri_agent::shell::ShellAbortHandle;

/// helper:构造后台 shell 并注入 app
fn inject_bg_shell(app: &mut App, id: &str, output_path: PathBuf) {
let (_tx, rx) = oneshot::channel::<anyhow::Result<CommandOutput>>();
let bg = BackgroundShell::new(
id.to_string(),
"python kcb50.py".to_string(),
PathBuf::from("."),
output_path,
rx,
ShellAbortHandle::noop(),
Instant::now(),
);
app.session_mgr
.current_mut()
.background_shells
.push(bg);
}

/// helper:打开 BackgroundTasks 面板并直接进入 Detail 视图
fn open_detail_panel(app: &mut App, item_id: &str) {
let mut panel = BackgroundTasksPanel::new();
panel.view = BackgroundTaskView::Detail {
item_id: item_id.to_string(),
};
app.session_mgr
.current_mut()
.session_panels
.open(PanelState::BackgroundTasks(panel));
}

/// helper:生成 N 行带编号的输出文本
fn make_output_lines(n: usize) -> String {
(1..=n)
.map(|i| format!("[{:02}/{}] 13:35:{} line {}", i, n, i, i))
.collect::<Vec<_>>()
.join("\n")
}

#[tokio::test]
async fn test_detail_output_大终端显示全部行() {
// Arrange:20 行输出 + 80x40 终端(output inner 约 28 行,远超旧常量 10)
let tmp = tempfile::tempdir().unwrap();
let output_path = tmp.path().join("out.output");
tokio::fs::write(&output_path, make_output_lines(20))
.await
.unwrap();

let (mut app, mut handle) = App::new_headless(80, 40).await;
inject_bg_shell(&mut app, "task-big", output_path);
open_detail_panel(&mut app, "task-big");

// Act:渲染两次——首次触发后台 read_tail task,第二次读取 output_cache
handle
.terminal
.draw(|f| crate::ui::main_ui::render(f, &mut app))
.unwrap();
tokio::task::yield_now().await;
tokio::time::sleep(Duration::from_millis(100)).await;
handle
.terminal
.draw(|f| crate::ui::main_ui::render(f, &mut app))
.unwrap();

// Assert:应显示 > 10 行(旧常量限制为 10,修复后应更多)
let snap = handle.snapshot().join("\n");
let showing_line = snap
.lines()
.find(|l| l.contains("Showing") && l.contains("lines"))
.unwrap_or_else(|| panic!("未找到 'Showing N lines' 行:\n{}", snap));
let count: usize = showing_line
.split_whitespace()
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(0);
assert!(
count > 10,
"80x40 终端应显示超过旧常量 10 行,实际显示 {} 行:\n{}",
count,
snap
);
}

#[tokio::test]
async fn test_detail_output_小终端按可用高度截断() {
// Arrange:30 行输出 + 80x15 终端(output inner 约 5 行,远少于 30)
let tmp = tempfile::tempdir().unwrap();
let output_path = tmp.path().join("out.output");
tokio::fs::write(&output_path, make_output_lines(30))
.await
.unwrap();

let (mut app, mut handle) = App::new_headless(80, 15).await;
inject_bg_shell(&mut app, "task-small", output_path);
open_detail_panel(&mut app, "task-small");

// Act
handle
.terminal
.draw(|f| crate::ui::main_ui::render(f, &mut app))
.unwrap();
tokio::task::yield_now().await;
tokio::time::sleep(Duration::from_millis(100)).await;
handle
.terminal
.draw(|f| crate::ui::main_ui::render(f, &mut app))
.unwrap();

// Assert:显示行数应 < 30(被终端高度截断)
let snap = handle.snapshot().join("\n");
let showing_line = snap
.lines()
.find(|l| l.contains("Showing") && l.contains("lines"))
.unwrap_or_else(|| panic!("未找到 'Showing N lines' 行:\n{}", snap));
// 提取 "Showing N" 中的 N
let count: usize = showing_line
.split_whitespace()
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(0);
assert!(
count < 30,
"80x15 终端不应显示全部 30 行,实际显示 {} 行",
count
);
assert!(
count > 0,
"应至少显示 1 行输出,实际显示 {} 行",
count
);
}

#[tokio::test]
async fn test_detail_output_空输出显示0行() {
// Arrange:空输出文件
let tmp = tempfile::tempdir().unwrap();
let output_path = tmp.path().join("out.output");
tokio::fs::write(&output_path, "").await.unwrap();

let (mut app, mut handle) = App::new_headless(80, 30).await;
inject_bg_shell(&mut app, "task-empty", output_path);
open_detail_panel(&mut app, "task-empty");

// Act
handle
.terminal
.draw(|f| crate::ui::main_ui::render(f, &mut app))
.unwrap();
tokio::task::yield_now().await;
tokio::time::sleep(Duration::from_millis(100)).await;
handle
.terminal
.draw(|f| crate::ui::main_ui::render(f, &mut app))
.unwrap();

// Assert
let snap = handle.snapshot().join("\n");
assert!(
snap.contains("Showing 0 lines"),
"空输出应显示 0 行,实际:\n{}",
snap
);
}
25 changes: 24 additions & 1 deletion peri-tui/src/ui/message_render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -597,11 +597,23 @@ fn render_shell_command(
}
}

// 2 秒阈值提示:前台 running 超 2 秒显示 "(Ctrl+B to run in background)"(对齐效果图场景 1)
// 2 秒阈值提示:前台 running 超 2 秒显示已运行时间 + "(Ctrl+B to run in background)"(对齐效果图场景 1)
if !moved_to_background
&& exit_code.is_none()
&& started_at.is_some_and(|t| t.elapsed() >= std::time::Duration::from_secs(2))
{
let elapsed = started_at.unwrap().elapsed();
let secs = elapsed.as_secs();
let elapsed_str = if secs >= 60 {
format!("({}m {:02}s)", secs / 60, secs % 60)
} else {
format!("({}s)", secs)
};
lines.push(shell_output_line(
" │ ",
&elapsed_str,
Style::default().fg(theme::MUTED),
));
lines.push(shell_output_line(
" │ ",
CONTROL_B_BACKGROUND_HINT,
Expand Down Expand Up @@ -923,6 +935,17 @@ pub fn render_view_model(
&& is_running
&& started_at.is_some_and(|t| t.elapsed() >= std::time::Duration::from_secs(2))
{
let elapsed = started_at.unwrap().elapsed();
let secs = elapsed.as_secs();
let elapsed_str = if secs >= 60 {
format!("({}m {:02}s)", secs / 60, secs % 60)
} else {
format!("({}s)", secs)
};
lines.push(Line::from(vec![
Span::styled(" ⎿ ", Style::default().fg(theme::DIM)),
Span::styled(elapsed_str, Style::default().fg(theme::MUTED)),
]));
lines.push(Line::from(vec![
Span::styled(" ⎿ ", Style::default().fg(theme::DIM)),
Span::styled(CONTROL_B_BACKGROUND_HINT, Style::default().fg(theme::MUTED)),
Expand Down
26 changes: 9 additions & 17 deletions peri-tui/src/ui/render_thread.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@ const RENDER_CHANNEL_CAPACITY: usize = 128;

use super::{
markdown::{ensure_rendered_flush, ensure_rendered_incremental},
message_render::{render_view_model, CONTROL_B_BACKGROUND_HINT},
message_render::render_view_model,
message_view::MessageViewModel,
};
#[cfg(test)]
use super::message_render::CONTROL_B_BACKGROUND_HINT;

const TOOL_INDICATOR_TICK_INTERVAL: Duration = Duration::from_millis(200);

Expand Down Expand Up @@ -467,7 +469,7 @@ impl RenderTask {
}

fn running_bash_needs_control_b_hint_rebuild(&self) -> bool {
self.last_messages.iter().enumerate().any(|(idx, vm)| {
self.last_messages.iter().any(|vm| {
let MessageViewModel::ToolBlock {
tool_name,
content,
Expand All @@ -479,21 +481,11 @@ impl RenderTask {
return false;
};

if tool_name != "Bash"
|| !content.is_empty()
|| *is_error
|| !started_at.is_some_and(|t| t.elapsed() >= Duration::from_secs(2))
{
return false;
}

self.message_lines.get(idx).is_none_or(|lines| {
!lines.iter().any(|line| {
line.spans
.iter()
.any(|span| span.content.as_ref().contains(CONTROL_B_BACKGROUND_HINT))
})
})
// running Bash 超过 2 秒时每 tick 重建(更新已运行时间显示)
tool_name == "Bash"
&& content.is_empty()
&& !*is_error
&& started_at.is_some_and(|t| t.elapsed() >= Duration::from_secs(2))
})
}

Expand Down
Loading