diff --git a/codex-rs/tui/src/app/agents_overview_details.rs b/codex-rs/tui/src/app/agents_overview_details.rs index a7529b88b2..5b42329340 100644 --- a/codex-rs/tui/src/app/agents_overview_details.rs +++ b/codex-rs/tui/src/app/agents_overview_details.rs @@ -1,5 +1,6 @@ //! Bounded overview previews from existing reads and already-delivered events. //! Observing activity never attaches to a thread or fetches additional history. +//! Message previews retain Markdown until the details panel's width is known. use super::App; use super::ThreadBufferedEvent; @@ -14,12 +15,30 @@ use codex_app_server_protocol::ThreadActiveFlag; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadStatus; use codex_protocol::ThreadId; +use codex_utils_absolute_path::AbsolutePathBuf; use ratatui::style::Stylize; use ratatui::text::Line; use std::collections::HashMap; const PREVIEW_CHARS: usize = 512; +pub(super) fn preview_agent_message(text: &str) -> String { + preview_markdown(&crate::markdown::unwrap_markdown_fences(text)) +} + +pub(super) fn preview_markdown(text: &str) -> String { + text.chars() + .filter(|ch| !ch.is_control() || matches!(ch, '\n' | '\t')) + .take(PREVIEW_CHARS) + .collect() +} + +#[derive(Clone, Default)] +pub(super) struct AgentsOverviewDetails { + pub(super) lines: Vec>, + pub(super) last_message: Option<(String, AbsolutePathBuf)>, +} + pub(super) fn preview_text(text: &str) -> String { text.chars() .map(|ch| if ch.is_whitespace() { ' ' } else { ch }) @@ -87,7 +106,7 @@ impl App { item: ThreadItem::AgentMessage { text, .. }, .. }) => { - activity.last_message = Some(preview_text(text)); + activity.last_message = Some(preview_agent_message(text)); activity.reasoning = None; true } @@ -111,7 +130,7 @@ impl App { &self, root: &Thread, children: &HashMap>, - ) -> Vec> { + ) -> AgentsOverviewDetails { let mut lines = Vec::new(); // Urgency comes first; on ties, prefer available current detail over history. let priority = |thread: &Thread| { @@ -143,7 +162,7 @@ impl App { pending.sort_by(|left, right| right.id.cmp(&left.id)); } let Ok(thread_id) = ThreadId::from_string(&source.id) else { - return lines; + return AgentsOverviewDetails::default(); }; let channel = self.thread_event_channels.get(&thread_id); let is_child = source.id != root.id; @@ -190,18 +209,15 @@ impl App { } lines.extend(["Latest activity".dim().into(), header.clone().into()]); } - if let Some(message) = activity + let last_message = activity .and_then(|activity| activity.last_message.as_ref()) .or_else(|| self.agents_overview.last_messages.get(&thread_id)) .filter(|message| !message.trim().is_empty()) - { - lines.extend([ - Line::default(), - "Last message".dim().into(), - message.clone().into(), - ]); + .map(|message| (message.clone(), source.cwd.clone())); + AgentsOverviewDetails { + lines, + last_message, } - lines } fn agents_overview_request_preview(&self, thread_id: ThreadId) -> Option { diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index 89c3d970dc..6c99fb765a 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -1099,6 +1099,105 @@ fn reasoning_delta(thread_id: ThreadId, item_id: &str, delta: &str) -> ServerNot }) } +#[tokio::test] +async fn agents_overview_details_render_markdown() { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + let mut thread = overview_thread( + thread_id, + /*parent_thread_id*/ None, + "Review parser", + ThreadStatus::Idle, + ); + thread.preview = "Review **parser** and `token` handling.".into(); + app.agents_overview + .threads + .insert(thread_id, Some(thread.clone())); + let message = "## Findings\n\n- Fixed **parsing** and `tokens` with a long explanation that wraps.\n- Kept *compatibility*.\n\n```rust\nlet token = 1;\n```"; + app.agents_overview.last_messages.insert( + thread_id, + super::super::agents_overview_details::preview_markdown(message), + ); + let view = app.agents_overview_view(vec![thread.clone()], Some(thread_id)); + let mut terminal = Terminal::new(TestBackend::new(/*width*/ 96, /*height*/ 40)).unwrap(); + terminal + .draw(|frame| view.render(frame.area(), frame.buffer_mut())) + .unwrap(); + let cached = terminal.backend().to_string(); + let project = test_path_display("/tmp/project"); + let padding = " ".repeat(project.len().saturating_sub("/tmp/project".len())); + insta::assert_snapshot!( + "agents_overview_markdown", + cached + .replace( + &format!("{project} 1"), + &format!("/tmp/project 1{padding}") + ) + .replace(&project, &format!("/tmp/project{padding}")) + ); + + app.agents_overview.last_messages.clear(); + app.track_agents_overview_activity( + thread_id, + &ServerNotification::ItemCompleted(codex_app_server_protocol::ItemCompletedNotification { + thread_id: thread.id.clone(), + turn_id: "turn".into(), + completed_at_ms: 0, + item: ThreadItem::AgentMessage { + id: "answer".into(), + text: message.into(), + phase: None, + memory_citation: None, + delivery: None, + questions: None, + }, + }), + ); + let view = app.agents_overview_view(vec![thread.clone()], Some(thread_id)); + terminal + .draw(|frame| view.render(frame.area(), frame.buffer_mut())) + .unwrap(); + assert_eq!(terminal.backend().to_string(), cached); + + thread.preview = format!("```\n{}\n```", "long prompt ".repeat(25)); + app.agents_overview.activity.clear(); + app.agents_overview.last_messages.insert( + thread_id, + "```\nlet explanation = \"A long code line should wrap inside the task details panel.\";\n```".into(), + ); + let view = app.agents_overview_view(vec![thread.clone()], Some(thread_id)); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + let normalized_group = format!("/tmp/project 1{padding}"); + insta::assert_snapshot!( + "agents_overview_markdown_long_lines", + render_bottom_popup(&app.chat_widget, /*width*/ 96) + .replace(&format!("{project} 1"), &normalized_group) + .replace(&project, "/tmp/project") + ); + + app.agents_overview.last_messages.insert( + thread_id, + "```md\n| Check | Result |\n| --- | --- |\n| Parser | Fixed |\n```".into(), + ); + let view = app.agents_overview_view(vec![thread], Some(thread_id)); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + insta::assert_snapshot!( + "agents_overview_markdown_table", + render_bottom_popup(&app.chat_widget, /*width*/ 96) + .replace(&format!("{project} 1"), &normalized_group) + .replace(&project, "/tmp/project") + ); +} + +#[test] +fn agents_overview_markdown_preview_preserves_layout_and_bounds() { + let text = format!("a\r\n\t\u{1b}{}", "界".repeat(600)); + assert_eq!( + super::super::agents_overview_details::preview_markdown(&text), + format!("a\n\t{}", "界".repeat(509)) + ); +} + #[tokio::test] async fn agents_overview_reasoning_uses_existing_events_and_expires_with_attachment() { let mut app = make_test_app().await; @@ -1167,6 +1266,7 @@ async fn agents_overview_reasoning_uses_existing_events_and_expires_with_attachm ); assert!( details + .lines .iter() .any(|line| line.to_string().contains("Checking cold-start regressions")) ); @@ -1176,10 +1276,8 @@ async fn agents_overview_reasoning_uses_existing_events_and_expires_with_attachm for delta in [format!("**{}", "界".repeat(10_000)), "**".into()] { app.track_agents_overview_notification(&reasoning_delta(thread_id, "oversized", &delta)); } - assert_eq!( - app.agents_overview_details(&thread, &HashMap::new()), - Vec::::new() - ); + let details = app.agents_overview_details(&thread, &HashMap::new()); + assert_eq!((details.lines, details.last_message), (Vec::new(), None)); app.track_agents_overview_notification(&reasoning_delta( thread_id, "next", @@ -1189,10 +1287,8 @@ async fn agents_overview_reasoning_uses_existing_events_and_expires_with_attachm .get_mut(&thread_id) .unwrap() .mark_replay_only(); - assert_eq!( - app.agents_overview_details(&thread, &HashMap::new()), - Vec::::new() - ); + let details = app.agents_overview_details(&thread, &HashMap::new()); + assert_eq!((details.lines, details.last_message), (Vec::new(), None)); app.track_agents_overview_notification(&ServerNotification::ThreadClosed( ThreadClosedNotification { thread_id: thread_id.to_string(), diff --git a/codex-rs/tui/src/app/agents_overview_threads.rs b/codex-rs/tui/src/app/agents_overview_threads.rs index c7d69d11ea..ec2d31654f 100644 --- a/codex-rs/tui/src/app/agents_overview_threads.rs +++ b/codex-rs/tui/src/app/agents_overview_threads.rs @@ -4,7 +4,7 @@ use super::App; use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID; -use super::agents_overview_details::preview_text; +use super::agents_overview_details::preview_agent_message; use super::app_server_event_targets::ServerNotificationThreadTarget; use super::app_server_event_targets::server_notification_thread_target; use crate::AppServerTarget; @@ -324,7 +324,7 @@ impl App { last_message = turn.items.iter().rev().find_map(|item| match item { ThreadItem::AgentMessage { text, .. } => { - Some(preview_text(text)) + Some(preview_agent_message(text)) } _ => None, }); diff --git a/codex-rs/tui/src/app/agents_overview_view.rs b/codex-rs/tui/src/app/agents_overview_view.rs index 3940bca5c3..be3617d988 100644 --- a/codex-rs/tui/src/app/agents_overview_view.rs +++ b/codex-rs/tui/src/app/agents_overview_view.rs @@ -7,6 +7,7 @@ mod input; mod render; use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID; +use super::agents_overview_details::AgentsOverviewDetails; use crate::app_event::AgentsOverviewAction; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; @@ -86,7 +87,7 @@ impl AgentsOverviewGroup { #[derive(Clone)] pub(super) struct AgentsOverviewRow { - pub(super) details: Vec>, + pub(super) details: AgentsOverviewDetails, pub(super) thread: Thread, pub(super) thread_id: ThreadId, pub(super) group: AgentsOverviewGroup, @@ -477,17 +478,18 @@ impl AgentsOverviewView { lines.push("Branch".dim().into()); lines.push(branch.clone().into()); } - let preview = crate::text_formatting::truncate_text(&row.thread.preview, width * 2); + let preview = super::agents_overview_details::preview_markdown(&row.thread.preview); lines.extend([Line::default(), Line::from("Prompt".dim())]); - let mut prompt = crate::wrapping::word_wrap_lines( + let prompt = crate::markdown_render::render_markdown_text_with_width_and_cwd( match preview.as_str() { "" => "No prompt available.", preview => preview, - } - .lines() - .map(Line::from), - width, - ); + }, + Some(width), + Some(row.thread.cwd.as_path()), + ) + .lines; + let mut prompt = crate::wrapping::word_wrap_lines(prompt, width); if prompt.len() > 2 { prompt.truncate(2); prompt[1] = "…".dim().into(); @@ -496,7 +498,17 @@ impl AgentsOverviewView { let details_start = crate::wrapping::word_wrap_lines(lines[..4].to_vec(), width).len(); let mut lines = crate::wrapping::word_wrap_lines(lines, width); if self.state().connection_notice.is_none() { - let mut details = crate::wrapping::word_wrap_lines(row.details.clone(), width); + let mut details = row.details.lines.clone(); + if let Some((message, cwd)) = &row.details.last_message { + details.extend([Line::default(), "Last message".dim().into()]); + crate::markdown::append_markdown( + &crate::markdown::unwrap_markdown_fences(message), + Some(width), + Some(cwd.as_path()), + &mut details, + ); + } + let mut details = crate::wrapping::word_wrap_lines(details, width); let available = usize::from(area.height).saturating_sub(lines.len()); if details.len() > available { details.truncate(available); diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown.snap new file mode 100644 index 0000000000..8e74576624 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown.snap @@ -0,0 +1,44 @@ +--- +source: tui/src/app/agents_overview_tests.rs +expression: "cached.replace(&format!(\"{project} 1\"),\n&format!(\"/tmp/project 1{padding}\")).replace(&project,\n&format!(\"/tmp/project{padding}\"))" +--- +" Agent command center " +" 0 need input 0 working 1 ready " +" ──────────────────────────────────────────────────────────────────────────────────────────── " +" /tmp/project 1 │ Task details " +" › ○ Review parser Ready │ " +" │ Review parser " +" │ ○ Ready " +" │ " +" │ Last message " +" │ ## Findings " +" │ " +" │ - Fixed parsing and tokens with a long " +" │ explanation that wraps. " +" │ " +" │ - Kept compatibility. " +" │ " +" │ let token = 1; " +" │ " +" │ Project " +" │ /tmp/project " +" │ " +" │ Prompt " +" │ Review parser and token handling. " +" │ " +" │ " +" │ " +" │ " +" │ " +" │ " +" │ " +" │ " +" │ " +" │ " +" │ " +" │ " +" New task " +" " +"› Describe a new task " +" " +" enter create task ctrl+j newline esc tasks → open task " diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown_long_lines.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown_long_lines.snap new file mode 100644 index 0000000000..fe24d76733 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown_long_lines.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/app/agents_overview_tests.rs +expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{project} 1\"),\n&normalized_group).replace(&project, \"/tmp/project\")" +--- + Agent command center + 0 need input 0 working 1 ready + ──────────────────────────────────────────────────────────────────────────────────────────── + /tmp/project 1 │ Task details + › ○ Review parser Ready │ + │ Review parser + │ ○ Ready + │ + │ Last message + │ let explanation = "A long code line + │ should wrap inside the task details + │ panel."; + │ + │ Project + │ /tmp/project + │ + │ Prompt + │ long prompt long prompt long prompt + │ … + New task + +› Describe a new task + + enter create task ctrl+j newline esc tasks → open task diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown_table.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown_table.snap new file mode 100644 index 0000000000..4996dbacff --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown_table.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/app/agents_overview_tests.rs +expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{project} 1\"),\n&normalized_group).replace(&project, \"/tmp/project\")" +--- + Agent command center + 0 need input 0 working 1 ready + ──────────────────────────────────────────────────────────────────────────────────────────── + /tmp/project 1 │ Task details + › ○ Review parser Ready │ + │ Review parser + │ ○ Ready + │ + │ Last message + │ Check Result + │ ━━━━━━━━ ━━━━━━━━ + │ Parser Fixed + │ + │ Project + │ /tmp/project + │ + │ Prompt + │ long prompt long prompt long prompt + │ … + New task + +› Describe a new task + + enter create task ctrl+j newline esc tasks → open task