Render Markdown in agent overview task details (#44752)

## Why

Task details displayed prompts as plain text and flattened agent messages, losing Markdown formatting and layout.

## What changed

Render prompt and last-message previews as Markdown at the details panel width, using the source thread's working directory. Preserve newlines and tabs within the 512-character preview limit, strip other control characters, and unwrap Markdown fences around agent messages. Keep prompts limited to two rendered lines and wrap long code lines to fit the panel.

## Testing

Add snapshots for Markdown formatting, long lines, and tables; verify cached and live messages render identically; and test preview layout preservation, control-character filtering, and Unicode character limits.

GitOrigin-RevId: b605d3f5920f0d15cc7682f45988a2e933e00602
This commit is contained in:
Eric Traut
2026-09-11 07:05:47 +00:00
committed by copyberry
parent ab95cd4dd9
commit b9934480bf
7 changed files with 254 additions and 30 deletions
+27 -11
View File
@@ -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<Line<'static>>,
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<String, Vec<&Thread>>,
) -> Vec<Line<'static>> {
) -> 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<String> {
+104 -8
View File
@@ -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::<Line>::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::<Line>::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(),
@@ -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,
});
+21 -9
View File
@@ -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<Line<'static>>,
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);
@@ -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 "
@@ -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
@@ -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