mirror of
https://github.com/openai/codex.git
synced 2026-11-01 19:33:35 +00:00
## 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
68 lines
2.2 KiB
Rust
68 lines
2.2 KiB
Rust
//! Recognize the desktop's existing async-question reply envelope for display and dismissal.
|
|
//! 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(Clone, Debug, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub(crate) struct AsyncQuestionReply {
|
|
pub(crate) question_item_id: String,
|
|
pub(crate) question: String,
|
|
pub(crate) answer: String,
|
|
}
|
|
|
|
pub(crate) fn parse(text: &str) -> Option<Vec<AsyncQuestionReply>> {
|
|
#[derive(Deserialize)]
|
|
#[serde(untagged)]
|
|
enum Replies {
|
|
Many(Vec<AsyncQuestionReply>),
|
|
One(AsyncQuestionReply),
|
|
}
|
|
let text = text.trim();
|
|
// JSON strings escape newlines, so this cannot match a delimiter inside an answer.
|
|
let text = if text.starts_with("# Context from my IDE setup:\n") {
|
|
text.rsplit_once("\n## My request for Codex:\n")?.1.trim()
|
|
} else {
|
|
text
|
|
};
|
|
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],
|
|
};
|
|
(!replies.is_empty()).then_some(replies)
|
|
}
|
|
|
|
pub(crate) fn display_text(text: &str) -> Option<String> {
|
|
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>> {
|
|
let mut content = input
|
|
.iter()
|
|
.filter(|item| !matches!(item, UserInput::Skill { .. } | UserInput::Mention { .. }));
|
|
let UserInput::Text { text, .. } = content.next()? else {
|
|
return None;
|
|
};
|
|
if content.next().is_some() {
|
|
return None;
|
|
}
|
|
parse(text)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "async_question_reply_tests.rs"]
|
|
mod tests;
|