diff --git a/peri-tui/src/app/background_tasks_panel.rs b/peri-tui/src/app/background_tasks_panel.rs index e20bc34a..ad8c16df 100644 --- a/peri-tui/src/app/background_tasks_panel.rs +++ b/peri-tui/src/app/background_tasks_panel.rs @@ -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 { @@ -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 = showing_lines .iter() @@ -603,3 +603,7 @@ impl super::App { )); } } + +#[cfg(test)] +#[path = "background_tasks_panel_test.rs"] +mod tests; diff --git a/peri-tui/src/app/background_tasks_panel_test.rs b/peri-tui/src/app/background_tasks_panel_test.rs new file mode 100644 index 00000000..3e09cf5c --- /dev/null +++ b/peri-tui/src/app/background_tasks_panel_test.rs @@ -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::>(); + 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::>() + .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 + ); +} diff --git a/peri-tui/src/ui/message_render.rs b/peri-tui/src/ui/message_render.rs index 6631dc2f..c9e414e0 100644 --- a/peri-tui/src/ui/message_render.rs +++ b/peri-tui/src/ui/message_render.rs @@ -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, @@ -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)), diff --git a/peri-tui/src/ui/render_thread.rs b/peri-tui/src/ui/render_thread.rs index a9d0099f..633c52ba 100644 --- a/peri-tui/src/ui/render_thread.rs +++ b/peri-tui/src/ui/render_thread.rs @@ -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); @@ -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, @@ -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)) }) }