Render async questions and replies with Markdown in the TUI (#57292)

## Why

Async question replies appeared as plain user text, leaving Markdown formatting literal and repeating the full question above each answer.

## What changed

- Accent question titles with a `?` prefix while preserving Markdown in titles and suggestions.
- Render replies with a `↪ Re:` question caption and a Markdown answer. Limit captions to two rows with an ellipsis on overflow, while keeping answers complete.
- Apply the same rendering to live messages and loaded transcripts, preserving inline styles, hyperlinks, and Markdown when copying selections.

## Testing

Add snapshots and assertions for matching live and loaded transcript rendering, plus tests for caption clipping, hyperlink bounds, Markdown copying, partial selections, and whitespace-only or nonprinting answers.

GitOrigin-RevId: 0290d8a2ebfb42bd5d7374c7037cde5ae8a13b5d
This commit is contained in:
Ian MacLeod
2026-10-26 22:12:07 +00:00
committed by copyberry
parent a014908c50
commit b52a272cff
17 changed files with 599 additions and 116 deletions
+16 -13
View File
@@ -2,14 +2,17 @@
//! Only complete envelopes, optionally following the standard IDE context prefix, are interpreted.
use codex_app_server_protocol::UserInput;
use codex_context_fragments::AnsweredQuestion;
use codex_context_fragments::ContextualUserFragment;
use itertools::Itertools;
use serde::Deserialize;
#[derive(Debug, Deserialize, PartialEq)]
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AsyncQuestionReply {
pub(crate) question_item_id: String,
question: String,
answer: String,
pub(crate) question: String,
pub(crate) answer: String,
}
pub(crate) fn parse(text: &str) -> Option<Vec<AsyncQuestionReply>> {
@@ -26,9 +29,8 @@ pub(crate) fn parse(text: &str) -> Option<Vec<AsyncQuestionReply>> {
} else {
text
};
let json = text
.strip_prefix("<send_user_message_question_reply>")?
.strip_suffix("</send_user_message_question_reply>")?;
let (start, end) = AnsweredQuestion::type_markers();
let json = text.strip_prefix(start)?.strip_suffix(end)?;
let replies = match serde_json::from_str::<Replies>(json).ok()? {
Replies::Many(replies) => replies,
Replies::One(reply) => vec![reply],
@@ -37,13 +39,14 @@ pub(crate) fn parse(text: &str) -> Option<Vec<AsyncQuestionReply>> {
}
pub(crate) fn display_text(text: &str) -> Option<String> {
Some(
parse(text)?
.iter()
.map(|reply| format!("> {}\n\n{}", reply.question, reply.answer))
.collect::<Vec<_>>()
.join("\n\n"),
)
Some(display_replies(&parse(text)?))
}
pub(crate) fn display_replies(replies: &[AsyncQuestionReply]) -> String {
replies
.iter()
.map(|reply| format!("> {}\n\n{}", reply.question, reply.answer))
.join("\n\n")
}
pub(crate) fn parse_input(input: &[UserInput]) -> Option<Vec<AsyncQuestionReply>> {
@@ -233,10 +233,7 @@ pub(super) fn detail_wrapped_lines(
.collect::<String>();
let visible_end = prefix_width + crate::width::display_width(content.trim_end_matches('…'));
last_span.content = content.into();
last_line.hyperlinks.retain_mut(|link| {
link.columns.end = link.columns.end.min(visible_end);
!link.columns.is_empty()
});
last_line.clip_hyperlinks_to_width(visible_end);
}
lines
}
+9 -6
View File
@@ -1413,12 +1413,15 @@ impl ChatWidget {
|| !display.local_images.is_empty()
|| !display.remote_image_urls.is_empty()
{
self.add_to_history(history_cell::new_user_prompt(
display.message,
display.text_elements,
display.local_images,
display.remote_image_urls,
));
self.add_to_history(
history_cell::new_user_prompt(
display.message,
display.text_elements,
display.local_images,
display.remote_image_urls,
)
.with_async_replies(display.replies, self.config.cwd.as_path()),
);
}
}
+3 -1
View File
@@ -455,7 +455,8 @@ impl ChatWidget {
}
}
let parsed = parse_assistant_markdown(&message, self.config.cwd.as_path());
if from_replay
let questions = item.questions.as_deref().unwrap_or_default();
if (from_replay || !questions.is_empty())
&& self.stream_controller.is_none()
&& !parsed.visible_markdown.is_empty()
&& !self.completed_agent_message_was_flushed(&message)
@@ -475,6 +476,7 @@ impl ChatWidget {
self.config.cwd.as_path(),
context,
)
.with_questions(questions)
.with_copy_source(Some(message.clone())),
);
self.handle_stream_finished();
@@ -982,7 +982,7 @@ prompt_stack_back = ["ctrl-x down", "shift-right"]
#[tokio::test]
async fn desktop_async_answer_dismisses_only_its_question_and_preserves_the_other_draft() {
for replay_kind in [None, Some(ReplayKind::ThreadSnapshot)] {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let questions = vec![
question("Same title?", /*options*/ None),
question("Same title?", /*options*/ None),
@@ -997,7 +997,7 @@ async fn desktop_async_answer_dismisses_only_its_question_and_preserves_the_othe
chat.bottom_pane
.handle_paste("My draft for the second question".into());
let reply = r#"<send_user_message_question_reply>
[{"questionItemId":"[\"request_user_input_async\",\"questions\",0]","question":"Same title?","answer":"Answer from desktop"}]
[{"questionItemId":"[\"request_user_input_async\",\"questions\",0]","question":"Same title?","answer":"Answer from **desktop**"}]
</send_user_message_question_reply>"#;
let reply = format!(
"# Context from my IDE setup:\n\n## Open tabs:\n- lib.rs: src/lib.rs\n\n## My request for Codex:\n{reply}"
@@ -1010,6 +1010,7 @@ async fn desktop_async_answer_dismisses_only_its_question_and_preserves_the_othe
text_elements: Vec::new(),
}],
};
drain_insert_history(&mut rx);
chat.handle_server_notification(
ServerNotification::ItemCompleted(ItemCompletedNotification::new(
"thread".into(),
@@ -1028,9 +1029,16 @@ async fn desktop_async_answer_dismisses_only_its_question_and_preserves_the_othe
crate::thread_transcript::RawReasoningVisibility::Hidden,
/*config*/ None,
);
insta::assert_snapshot!(
"desktop_async_question_reply",
lines_to_single_string(&cells[0].transcript_lines(/*width*/ 80))
crate::terminal_palette::with_test_default_colors(
crate::terminal_probe::DefaultColors {
fg: (220, 220, 220),
bg: (20, 20, 20),
},
|| {
let persisted = cells[0].transcript_lines(/*width*/ 80);
insta::assert_debug_snapshot!("desktop_async_question_reply", persisted);
assert_eq!(drain_insert_history(&mut rx), [persisted]);
},
);
insta::assert_snapshot!(
"async_question_answered_on_another_client",
@@ -1054,6 +1062,46 @@ async fn desktop_async_answer_dismisses_only_its_question_and_preserves_the_othe
}
}
#[tokio::test]
async fn async_question_markdown_matches_loaded_history() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
let title = "Keep `--verbose` for **failed** runs?";
let item = AppServerThreadItemPayload::AgentMessage {
id: "ask".into(),
text: format!("{title}\n- Only for **CI**\n\nSee the [guide](https://example.test/guide)?"),
phase: None,
memory_citation: None,
delivery: None,
questions: Some(vec![
question(title, Some(vec!["Only for **CI**".into()])),
question(
"See the [guide](https://example.test/guide)?",
/*options*/ None,
),
]),
};
drain_insert_history(&mut rx);
crate::terminal_palette::with_test_default_colors(
crate::terminal_probe::DefaultColors {
fg: (220, 220, 220),
bg: (20, 20, 20),
},
|| {
chat.handle_thread_item(item.clone(), "turn-1".into(), ThreadItemRenderSource::Live);
let cells = drain_insert_history(&mut rx);
let paged = crate::thread_transcript::thread_items_to_transcript_cells(
/*thread_id*/ None,
&chat.config.cwd,
[item],
crate::thread_transcript::RawReasoningVisibility::Hidden,
/*config*/ None,
);
assert_eq!(cells, [paged[0].transcript_lines(/*width*/ 80)]);
insta::assert_debug_snapshot!("async_question_transcript", cells[0]);
},
);
}
#[tokio::test]
async fn distinct_async_question_replies_with_identical_text_both_render() {
use codex_context_fragments::AnsweredQuestion;
@@ -0,0 +1,31 @@
---
source: tui/src/chatwidget/tests/questions_tests.rs
expression: "cells[0]"
---
[
Line::from_iter([
Span::from("? ").fg(Color::Rgb(99, 168, 248)),
Span::from("Keep ").fg(Color::Rgb(99, 168, 248)),
Span::from("--verbose").fg(Color::Rgb(166, 227, 161)),
Span::from(" for ").fg(Color::Rgb(99, 168, 248)),
Span::from("failed").fg(Color::Rgb(243, 139, 168)).bold(),
Span::from(" runs?").fg(Color::Rgb(99, 168, 248)),
]),
Line::default(),
Line::from_iter([
Span::from(" "),
Span::from("• ").fg(Color::Rgb(148, 226, 213)),
Span::from("Only for "),
Span::from("CI").fg(Color::Rgb(243, 139, 168)).bold(),
]),
Line::default(),
Line::from_iter([
Span::from("? ").fg(Color::Rgb(99, 168, 248)),
Span::from("See the ").fg(Color::Rgb(99, 168, 248)),
Span::from("guide").fg(Color::Rgb(137, 180, 250)).underlined(),
Span::from(" (").fg(Color::Rgb(99, 168, 248)),
Span::from("https://example.test/guide").fg(Color::Rgb(137, 180, 250)).underlined(),
Span::from(")").fg(Color::Rgb(99, 168, 248)),
Span::from("?").fg(Color::Rgb(99, 168, 248)),
]),
]
@@ -1,9 +1,19 @@
---
source: tui/src/chatwidget/tests/questions_tests.rs
assertion_line: 856
expression: "lines_to_single_string(&cells[0].transcript_lines(80))"
expression: persisted
---
› > Same title?
Answer from desktop
[
Line::default().bg(Color::Rgb(57, 57, 57)),
Line::from_iter([
Span::from("↪ ").fg(Color::Rgb(99, 168, 248)).bg(Color::Rgb(57, 57, 57)).not_dim(),
Span::from("Re: ").fg(Color::Rgb(99, 168, 248)).bg(Color::Rgb(57, 57, 57)).dim(),
Span::from("Same title?").fg(Color::Rgb(99, 168, 248)).bg(Color::Rgb(57, 57, 57)).dim(),
]).bg(Color::Rgb(57, 57, 57)),
Line::default().bg(Color::Rgb(57, 57, 57)),
Line::from_iter([
Span::from(" "),
Span::from("Answer from "),
Span::from("desktop").fg(Color::Rgb(243, 139, 168)).bold(),
]).bg(Color::Rgb(57, 57, 57)),
Line::default().bg(Color::Rgb(57, 57, 57)),
]
+6 -13
View File
@@ -578,7 +578,7 @@ pub(super) fn merge_user_messages_with_history_record(
pub(crate) struct UserMessageDisplay {
pub(crate) message: String,
// Keep distinct replies distinct when their visible question and answer text match.
question_ids: Vec<String>,
pub(super) replies: Vec<crate::async_question_reply::AsyncQuestionReply>,
pub(crate) remote_image_urls: Vec<String>,
pub(crate) local_images: Vec<PathBuf>,
pub(crate) text_elements: Vec<TextElement>,
@@ -709,20 +709,13 @@ impl ChatWidget {
local_images: Vec<PathBuf>,
remote_image_urls: Vec<String>,
) -> UserMessageDisplay {
let question_ids = crate::async_question_reply::parse(&message)
.unwrap_or_default()
.into_iter()
.map(|reply| reply.question_item_id)
.collect();
let reply_text = crate::async_question_reply::display_text(&message);
let replies = crate::async_question_reply::parse(&message);
let (message, prompt_request_offset) =
crate::user_prompt::extract_prompt_request_with_offset(&message);
if let Some(message) =
reply_text.or_else(|| crate::async_question_reply::display_text(message))
{
if let Some(replies) = replies.or_else(|| crate::async_question_reply::parse(message)) {
return UserMessageDisplay {
message,
question_ids,
message: crate::async_question_reply::display_replies(&replies),
replies,
text_elements: Vec::new(),
local_images,
remote_image_urls,
@@ -750,7 +743,7 @@ impl ChatWidget {
UserMessageDisplay {
message: message.to_string(),
question_ids: Vec::new(),
replies: Vec::new(),
remote_image_urls,
local_images,
text_elements,
@@ -0,0 +1,82 @@
//! Render each tagged title and the following suggestions from the tool's visible Markdown.
use crate::style::accent_color_on;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::prefix_hyperlink_lines;
use codex_protocol::items::AsyncUserInputQuestion;
use ratatui::style::Style;
use ratatui::text::Span;
use std::ops::Range;
/// Core joins each title and its Markdown bullets, then separates questions by a blank line.
/// Stop decorating if the metadata and visible Markdown diverge.
pub(super) fn title_ranges(
source: &str,
questions: &[AsyncUserInputQuestion],
) -> Vec<Range<usize>> {
let mut titles = Vec::new();
let mut rest = source;
for question in questions {
rest = rest.trim_start_matches('\n');
let title = question.title.replace("\r\n", "\n");
let title = title.trim_matches('\n');
let Some(after_title) = rest.strip_prefix(title) else {
break;
};
let start = source.len() - rest.len();
titles.push(start..start + title.len());
rest = after_title;
for option in question.options.iter().flatten() {
let option = option.replace("\r\n", "\n");
let Some(after_option) = rest
.trim_start_matches('\n')
.strip_prefix("- ")
.and_then(|s| s.strip_prefix(&option))
else {
return titles;
};
rest = after_option;
}
}
titles
}
pub(super) fn render(
source: &str,
titles: &[Range<usize>],
markdown: impl Fn(&str) -> Vec<HyperlinkLine>,
) -> Vec<HyperlinkLine> {
let accent = Style::new().fg(accent_color_on(/*background*/ None));
let mut lines = Vec::new();
for (index, title) in titles.iter().enumerate() {
if !lines.is_empty() {
lines.push(HyperlinkLine::default());
}
let heading = markdown(&source[title.clone()])
.into_iter()
.map(|row| {
let style = accent.patch(row.line.style);
row.style(style).resolve_span_styles()
})
.collect();
lines.extend(prefix_hyperlink_lines(
heading,
Span::styled("? ", accent),
" ".into(),
));
let end = titles
.get(index + 1)
.map_or(source.len(), |next| next.start);
let body = source[title.end..end].trim_matches('\n');
if !body.is_empty() {
lines.push(HyperlinkLine::default());
lines.extend(prefix_hyperlink_lines(
markdown(body),
" ".into(),
" ".into(),
));
}
}
lines
}
@@ -0,0 +1,118 @@
//! Render structured replies on the user-message surface. Only the repeated question is shortened.
use super::messages::sanitize_user_text;
use crate::async_question_reply::AsyncQuestionReply;
use crate::line_truncation::truncate_line_to_width;
use crate::markdown_render::hide_web_link_destination;
use crate::markdown_render::render_markdown_lines_with_width_cwd_and_hidden_link_destinations;
use crate::style::accent_color_on;
use crate::style::history_prompt_style;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::LogicalLineSource;
use crate::terminal_hyperlinks::prefix_hyperlink_lines;
use crate::terminal_hyperlinks::remap_source_wrapped_line;
use crate::terminal_hyperlinks::retain_initial_prefix;
use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_line_to_width;
use ratatui::style::Stylize;
use std::path::Path;
pub(super) fn render(
replies: &[AsyncQuestionReply],
cwd: &Path,
width: usize,
) -> Vec<HyperlinkLine> {
let surface = history_prompt_style();
let accent = accent_color_on(surface.bg);
let markdown = |text: &str, width| {
render_markdown_lines_with_width_cwd_and_hidden_link_destinations(
text,
width,
Some(cwd),
&hide_web_link_destination,
)
};
let mut lines = Vec::new();
for reply in replies {
if !lines.is_empty() {
lines.push(HyperlinkLine::default().style(surface));
}
let mut question = markdown(&sanitize_user_text(reply.question.as_str().into()), None);
if question.is_empty() {
question.push(HyperlinkLine::default());
}
let mut wrapped = question
.into_iter()
.enumerate()
.flat_map(|(index, row)| wrap(row, width, if index == 0 { "Re: " } else { "" }));
let mut caption: Vec<_> = wrapped.by_ref().take(2).collect();
if wrapped.next().is_some()
&& let Some(last) = caption.last_mut()
{
last.line =
truncate_line_to_width(std::mem::take(&mut last.line), width.saturating_sub(1));
last.clip_hyperlinks_to_width(last.width());
last.line.push_span("…");
// Copy the visible excerpt as prose alongside the Markdown answer.
caption.iter_mut().for_each(mark_as_prose);
} else {
retain_initial_prefix(&mut caption, "Re: ");
}
let caption = prefix_hyperlink_lines(caption, "↪ ".fg(accent).not_dim(), " ".into());
lines.extend(caption.into_iter().map(|row| {
let style = surface.fg(accent).dim().patch(row.line.style);
row.style(style).resolve_span_styles()
}));
let text = sanitize_user_text(reply.answer.as_str().into());
if text.trim().is_empty() {
continue;
}
let mut answer = markdown(&text, Some(width));
if !answer
.iter()
.flat_map(|row| &row.line.spans)
.any(|span| !span.content.trim().is_empty())
{
answer = text
.lines()
.map(str::to_owned)
.map(HyperlinkLine::from)
.collect();
answer.iter_mut().for_each(mark_as_prose);
}
lines.push(HyperlinkLine::default().style(surface));
let answer = answer
.into_iter()
.flat_map(|row| wrap(row, width, ""))
.map(|row| {
let style = row.line.style.patch(surface);
row.style(style)
})
.collect();
lines.extend(prefix_hyperlink_lines(answer, " ".into(), " ".into()));
}
lines
}
fn mark_as_prose(row: &mut HyperlinkLine) {
row.source = Some(LogicalLineSource {
copy_as_prose: true,
..LogicalLineSource::from_line(&row.line)
});
}
// Markdown leaves some code and URLs overwide. Keep all of the row and its links in the gutter.
fn wrap(row: HyperlinkLine, width: usize, initial: &'static str) -> Vec<HyperlinkLine> {
if initial.is_empty() && row.width() <= width {
return vec![row];
}
let indent = row
.source
.as_ref()
.map(|s| s.continuation_indent.clone())
.unwrap_or_default();
let options = RtOptions::new(width)
.initial_indent(initial.into())
.subsequent_indent(indent);
remap_source_wrapped_line(&row, adaptive_wrap_line_to_width(&row.line, options))
}
+81 -41
View File
@@ -3,6 +3,7 @@
use super::markdown_render_cache::MarkdownRenderCache;
use super::*;
use crate::async_question_reply::AsyncQuestionReply;
use crate::style::accent_color_on;
use crate::style::history_prompt_style;
use crate::terminal_hyperlinks::annotate_web_urls;
@@ -26,6 +27,14 @@ pub(crate) enum UserMessagePresentation {
#[default]
Plain,
Spoken,
/// Render each submitted answer as Markdown beneath its question caption.
AsyncReplies {
/// Keep the decoded pairs so the two-row limit applies only to question captions.
/// In the flattened message, an answer's own blockquotes can look like captions.
replies: Vec<AsyncQuestionReply>,
/// Capture the session directory so relative Markdown links resolve the same way on reflow.
cwd: PathBuf,
},
}
/// Remove CSI sequences and control characters, preserving tabs and newlines.
@@ -159,6 +168,20 @@ fn build_user_message_lines_with_elements(
}
impl UserHistoryCell {
pub(crate) fn with_async_replies(
mut self,
replies: Vec<AsyncQuestionReply>,
cwd: &Path,
) -> Self {
if !replies.is_empty() {
self.presentation = UserMessagePresentation::AsyncReplies {
replies,
cwd: cwd.to_owned(),
};
}
self
}
pub(crate) fn is_spoken(&self) -> bool {
matches!(self.presentation, UserMessagePresentation::Spoken)
}
@@ -220,41 +243,43 @@ impl HistoryCell for UserHistoryCell {
.wrap_algorithm(textwrap::WrapAlgorithm::FirstFit),
));
let wrapped_message = wrap_user_message(message, text_elements, style, wrap_width);
if wrapped_images.is_empty() && wrapped_message.is_none() {
let wrapped_message =
if let UserMessagePresentation::AsyncReplies { replies, cwd } = &self.presentation {
super::async_replies::render(replies, cwd, usize::from(wrap_width))
} else {
let marker = if self.is_spoken() {
"› ".red().bold()
} else {
"› ".bold().dim()
};
prefix_hyperlink_lines(
wrap_user_message(message, text_elements, style, wrap_width),
marker,
" ".into(),
)
};
if wrapped_images.is_empty() && wrapped_message.is_empty() {
return Vec::new();
}
let mut lines = vec![HyperlinkLine::new(Line::from("").style(style))];
if !wrapped_images.is_empty() {
lines.extend(prefix_hyperlink_lines(
wrapped_images,
" ".into(),
" ".into(),
));
if wrapped_message.is_some() {
if !wrapped_message.is_empty() {
lines.push(HyperlinkLine::new(Line::from("").style(style)));
}
}
if let Some(wrapped_message) = wrapped_message {
lines.extend(prefix_hyperlink_lines(
wrapped_message,
if self.is_spoken() {
"› ".red().bold()
} else {
"› ".bold().dim()
},
" ".into(),
));
}
lines.extend(wrapped_message);
lines.push(HyperlinkLine::new(Line::from("").style(style)));
for source in lines.iter_mut().filter_map(|line| line.source.as_mut()) {
source.right_reserve = 1;
source.copy_as_prose = true;
source.copy_as_prose |= matches!(
self.presentation,
UserMessagePresentation::Plain | UserMessagePresentation::Spoken
);
}
lines
}
@@ -283,10 +308,10 @@ fn wrap_user_message(
text_elements: &[TextElement],
style: Style,
wrap_width: u16,
) -> Option<Vec<HyperlinkLine>> {
) -> Vec<HyperlinkLine> {
let element_style = style.fg(accent_color_on(style.bg));
if message.is_empty() && text_elements.is_empty() {
return None;
return Vec::new();
}
let wrap_options =
@@ -328,7 +353,7 @@ fn wrap_user_message(
}) {
wrapped.pop();
}
(!wrapped.is_empty()).then_some(wrapped)
wrapped
}
#[derive(Debug)]
@@ -518,6 +543,7 @@ pub(crate) struct AgentMarkdownCell {
inline_visualization_context: Option<crate::inline_visualization::InlineVisualizationContext>,
rendered_lines: Option<MarkdownRenderCache>,
spoken_artifacts: bool,
question_titles: Vec<std::ops::Range<usize>>,
}
impl AgentMarkdownCell {
@@ -557,9 +583,19 @@ impl AgentMarkdownCell {
inline_visualization_context,
rendered_lines,
spoken_artifacts: false,
question_titles: Vec::new(),
}
}
pub(crate) fn with_questions(
mut self,
questions: &[codex_protocol::items::AsyncUserInputQuestion],
) -> Self {
self.question_titles =
super::async_questions::title_ranges(&self.markdown_source, questions);
self
}
pub(crate) fn new_spoken(markdown_source: String, cwd: &Path) -> Self {
let mut cell = Self::new_with_inline_visualizations(
markdown_source,
@@ -609,25 +645,29 @@ impl AgentMarkdownCell {
// Re-render markdown from source at the current width. Reserve 2 columns for the "• " /
// " " prefix prepended below.
let lines = crate::markdown::render_markdown_agent_with_list_spacing(
&self.markdown_source,
Some(wrap_width),
Some(self.cwd.as_path()),
self.inline_visualization_context.as_ref(),
list_spacing,
);
let lines = if self.spoken_artifacts {
let mut lines = lines;
super::spoken_artifacts::annotate_spoken_artifacts(&mut lines, &self.cwd);
lines
} else {
lines
let markdown = |source: &str| {
crate::markdown::render_markdown_agent_with_list_spacing(
source,
Some(wrap_width),
Some(self.cwd.as_path()),
self.inline_visualization_context.as_ref(),
list_spacing,
)
};
normalize_whitespace_only_hyperlink_lines(prefix_hyperlink_lines(
lines,
"• ".dim(),
" ".into(),
))
let lines = if self.question_titles.is_empty() {
let mut lines = markdown(&self.markdown_source);
if self.spoken_artifacts {
super::spoken_artifacts::annotate_spoken_artifacts(&mut lines, &self.cwd);
}
prefix_hyperlink_lines(lines, "• ".dim(), " ".into())
} else {
super::async_questions::render(
&self.markdown_source,
&self.question_titles,
markdown,
)
};
normalize_whitespace_only_hyperlink_lines(lines)
};
if let Some(rendered_lines) = &self.rendered_lines {
+2
View File
@@ -104,6 +104,8 @@ const RAW_DIFF_SUMMARY_WIDTH: usize = 10_000;
mod activity_details;
pub(crate) mod activity_preview;
mod approvals;
mod async_questions;
mod async_replies;
mod base;
mod dynamic;
mod exec;
+20
View File
@@ -292,6 +292,26 @@ impl SelectedLine {
range: Range<usize>,
separator: &str,
) {
if let Some((label_end, body)) = source.labelled_body.as_deref() {
let body_start = source.text.len() - body.text.len();
let mut separator = separator;
if range.start < *label_end {
let mut label = LogicalLineSource::new(source.text[..*label_end].to_owned());
label.copy_as_prose = true;
Self::append(
lines,
label,
range.start..range.end.min(*label_end),
separator,
);
separator = "";
}
if range.end > body_start {
let body_range = range.start.saturating_sub(body_start)..range.end - body_start;
Self::append(lines, body.clone(), body_range, separator);
}
return;
}
// Streaming can split the same logical line across history cells.
if let Some(previous) = lines.last_mut()
&& Arc::ptr_eq(&previous.source.text, &source.text)
+36 -9
View File
@@ -202,6 +202,24 @@ impl HyperlinkLine {
}
}
pub(crate) fn clip_hyperlinks_to_width(&mut self, width: usize) {
self.hyperlinks.retain_mut(|link| {
link.columns.end = link.columns.end.min(width);
!link.columns.is_empty()
});
}
/// Native scrollback paints the row style last. Resolve text styles onto the spans so
/// inline Markdown and gutter overrides survive, keeping the background for empty columns.
pub(crate) fn resolve_span_styles(mut self) -> Self {
let style = std::mem::take(&mut self.line.style);
self.line.style.bg = style.bg;
for span in &mut self.line.spans {
span.style = style.patch(span.style);
}
self
}
pub(crate) fn style(mut self, style: ratatui::style::Style) -> Self {
self.line = self.line.style(style);
if let Some(source) = &mut self.source {
@@ -283,24 +301,32 @@ pub(crate) fn prefix_hyperlink_lines(
/// Retain a known first-line label in copy text without changing the existing wrapping.
///
/// Callers pass the suffix of the first row's synthetic prefix that carries meaning, such as
/// a checkbox or `answer:`. Only that row's shared logical source is updated.
/// The label may precede Markdown indentation or follow a display-only gutter.
/// Only the first row's shared logical source is updated.
pub(crate) fn retain_initial_prefix(lines: &mut [HyperlinkLine], prefix: &str) {
let Some(origin) = lines.first().and_then(|line| line.source.clone()) else {
return;
};
debug_assert!(origin.prefix_bytes >= prefix.len());
let prefix_line = LogicalLineSource::from_line(&lines[0].line)
.styled_range(origin.prefix_bytes - prefix.len()..origin.prefix_bytes);
let first = LogicalLineSource::from_line(&lines[0].line);
let indent = &first.text[..origin.prefix_bytes];
let Some(label_start) = indent.rfind(prefix) else {
return;
};
let retained = &indent[label_start..];
let prefix_line = first.styled_range(label_start..origin.prefix_bytes);
let mut styles = LogicalLineSource::from_line(&prefix_line).styles.to_vec();
styles.extend(origin.styles.iter().map(|(range, style)| {
(
range.start + prefix.len()..range.end + prefix.len(),
range.start + retained.len()..range.end + retained.len(),
origin.line_style.patch(*style).patch(origin.span_style),
)
}));
let styles = std::sync::Arc::from(styles);
let text: std::sync::Arc<str> = format!("{prefix}{}", origin.text).into();
let text: std::sync::Arc<str> = format!("{retained}{}", origin.text).into();
let labelled_body = origin
.copy
.as_ref()
.map(|_| std::sync::Arc::new((prefix.len(), origin.clone())));
for (index, line) in lines.iter_mut().enumerate() {
let Some(source) = line
.source
@@ -311,12 +337,13 @@ pub(crate) fn retain_initial_prefix(lines: &mut [HyperlinkLine], prefix: &str) {
};
source.text = std::sync::Arc::clone(&text);
source.styles = std::sync::Arc::clone(&styles);
source.labelled_body = labelled_body.clone();
source.line_style = ratatui::style::Style::default();
source.span_style = ratatui::style::Style::default();
source.range = source.range.start + prefix.len()..source.range.end + prefix.len();
source.range = source.range.start + retained.len()..source.range.end + retained.len();
if index == 0 {
source.range.start = 0;
source.prefix_bytes -= prefix.len();
source.prefix_bytes -= retained.len();
}
}
}
@@ -24,6 +24,8 @@ pub(crate) enum LineWrapPolicy {
pub(crate) struct LogicalLineSource {
pub(crate) text: Arc<str>,
pub(crate) copy: Option<Arc<crate::markdown_copy::CopyLine>>,
/// Selectable label length and the original body, whose Markdown ranges exclude the label.
pub(crate) labelled_body: Option<Arc<(usize, LogicalLineSource)>>,
/// Literal user text remains prose when copied alongside rendered Markdown.
pub(crate) copy_as_prose: bool,
/// Span styles share the text's byte coordinates, including whitespace omitted by wrapping.
@@ -46,6 +48,7 @@ impl LogicalLineSource {
Self {
text: text.into(),
copy: None,
labelled_body: None,
copy_as_prose: false,
styles: vec![(0..end, Style::default())].into(),
line_style: Style::default(),
@@ -106,6 +109,7 @@ impl LogicalLineSource {
Self {
text: Arc::clone(&self.text),
copy: self.copy.clone(),
labelled_body: self.labelled_body.clone(),
copy_as_prose: self.copy_as_prose,
styles: Arc::clone(&self.styles),
line_style: self.line_style,
+25 -18
View File
@@ -12,7 +12,6 @@ use crate::history_cell::HistoryCell;
use crate::history_cell::PlainHistoryCell;
use crate::history_cell::PrefixedWrappedHistoryCell;
use crate::history_cell::ReasoningSummaryCell;
use crate::history_cell::UserHistoryCell;
use crate::history_cell::split_reasoning_summary_parts;
use crate::inline_visualization::InlineVisualizationContext;
use crate::legacy_core::config::Config;
@@ -252,28 +251,36 @@ fn item_to_cells(
.collect(),
};
let message = item.message();
let reply_text = crate::async_question_reply::display_text(&message);
let text_elements = if reply_text.is_some() {
Vec::new()
let replies = crate::async_question_reply::parse(&message).unwrap_or_default();
let (message, elements) = if replies.is_empty() {
(message, item.text_elements())
} else {
item.text_elements()
(
crate::async_question_reply::display_replies(&replies),
Vec::new(),
)
};
cells.push(Arc::new(UserHistoryCell {
presentation: Default::default(),
message: reply_text.unwrap_or(message),
text_elements,
local_image_paths: item.local_image_paths(),
remote_image_urls: item.image_urls(),
}));
let cell = crate::history_cell::new_user_prompt(
message,
elements,
item.local_image_paths(),
item.image_urls(),
);
cells.push(Arc::new(cell.with_async_replies(replies, cwd.as_path())));
}
ThreadItemPayload::AgentMessage { text, .. } => {
ThreadItemPayload::AgentMessage {
text, questions, ..
} => {
let parsed = parse_assistant_markdown(&text, cwd.as_path());
if !parsed.visible_markdown.trim().is_empty() {
cells.push(Arc::new(AgentMarkdownCell::new_with_inline_visualizations(
parsed.visible_markdown,
cwd.as_path(),
inline_visualization_context,
)));
cells.push(Arc::new(
AgentMarkdownCell::new_with_inline_visualizations(
parsed.visible_markdown,
cwd.as_path(),
inline_visualization_context,
)
.with_questions(questions.as_deref().unwrap_or_default()),
));
}
}
ThreadItemPayload::FunctionCallOutput {
@@ -5,6 +5,7 @@ use crate::history_cell::HistoryCell;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Modifier;
use std::path::Path;
#[path = "table_copy_tests.rs"]
@@ -304,6 +305,101 @@ fn mixed_plain_output_remains_literal_in_html() {
);
}
fn reply_rows(question: &str, answer: &str, width: u16) -> Vec<HyperlinkLine> {
let reply = crate::async_question_reply::AsyncQuestionReply {
question_item_id: "ask".into(),
question: question.into(),
answer: answer.into(),
};
crate::history_cell::new_user_prompt(answer.into(), vec![], vec![], vec![])
.with_async_replies(vec![reply], Path::new("/"))
.display_hyperlink_lines(width)
}
#[test]
fn reply_answer_distinguishes_whitespace_from_nonprinting_markdown() {
let definition = "[docs]: https://example.test";
for (answer, expected) in [
(" \t\n ", vec!["", "↪ Re: Why?", ""]),
(
definition,
vec!["", "↪ Re: Why?", "", " [docs]: https://example.test", ""],
),
] {
let rows: Vec<_> = reply_rows("Why?", answer, /*width*/ 60)
.into_iter()
.map(|row| row.line.to_string())
.collect();
assert_eq!(rows, expected);
}
}
#[test]
fn reply_caption_clips_on_overflow_and_copies_with_its_markdown_answer() {
for (width, clipped) in [(17, false), (12, true)] {
let rows = reply_rows(
"https://example.test",
"12345678901234567890\n\n**Tail**",
width,
);
let last = &rows[2]; // The cell starts with one padding row and two caption rows.
assert_eq!(last.line.to_string().ends_with('…'), clipped);
assert_eq!(
last.hyperlinks.last().unwrap().columns.end,
last.width() - usize::from(clipped)
);
let tail = rows
.iter()
.flat_map(|row| &row.line.spans)
.find(|span| span.content == "Tail")
.unwrap();
assert!(tail.style.add_modifier.contains(Modifier::BOLD));
let layout = TextLayout::new(rows, width);
let (markdown, _) = payload(&layout, 0..layout.text().len());
assert_eq!(markdown.contains('…'), clipped);
assert_eq!(markdown.matches("Re: ").count(), 1, "{markdown}");
assert!(
markdown.contains("12345678901234567890") && markdown.contains("**Tail**"),
"{markdown}"
);
assert!(!markdown.contains("```"), "{markdown}");
}
}
#[test]
fn full_reply_caption_preserves_links_and_partial_selections() {
let width = 44;
for prefix in ["", "> ", "- > "] {
let question = format!("{prefix}Read the [setup guide](https://example.test/setup)?");
let rows = reply_rows(&question, "Yes", width);
let first = rows[1].line.to_string();
let layout = TextLayout::new(rows, width);
assert!(
layout
.text()
.trim_start()
.starts_with(first.trim_start_matches("↪ ").trim_end()),
"{}",
layout.text()
);
let (copy, _) = payload(&layout, 0..layout.text().len());
assert!(
copy.trim_start()
.starts_with(&format!("Re: {prefix}Read the [setup guide")),
"{copy}"
);
assert!(copy.contains("](<https://example.test/setup>)?"), "{copy}");
let start = layout.text().find("guide").unwrap();
let question_layout = markdown_layout(&question, width);
let original = question_layout.text().find("guide").unwrap();
assert_eq!(
payload(&layout, start..start + 5),
payload(&question_layout, original..original + 5),
"{prefix}"
);
}
}
#[test]
fn mixed_user_prose_preserves_literal_text_without_code_fences() {
let cell = crate::history_cell::new_user_prompt(