mirror of
https://github.com/openai/codex.git
synced 2026-10-22 08:15:54 +00:00
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:
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
+44
@@ -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 "
|
||||
+28
@@ -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
|
||||
+28
@@ -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
|
||||
Reference in New Issue
Block a user