mirror of
https://github.com/openai/codex.git
synced 2026-11-10 21:00:47 +00:00
## Why `codex-rs/tui/src/history_cell.rs` had become the dumping ground for transcript rendering: the shared trait, common helpers, and the concrete cells for messages, plans, MCP/search, notices, patches, approvals, session chrome, and separators all lived together. That made small transcript changes require reopening a very large file and made ownership less obvious. ## What changed - Replaced the monolithic `history_cell.rs` with a `history_cell/` module tree organized by concern. - Kept the existing `crate::history_cell::*` surface stable through re-exports in `history_cell/mod.rs`. - Moved the existing render coverage into `history_cell/tests.rs`. ## Reviewer notes - This PR is intentionally mechanical in mature — existing code and tests moving into files that match their concern. - The snapshot files under `codex-rs/tui/src/history_cell/snapshots/` moved with the extracted test module. `insta` resolves these unnamed snapshots relative to the source file that declares them, so this is path churn only; snapshot contents were not updated. - The small non-mechanical seam edits are limited to split fallout: sibling-module visibility for shared cell containers, moving approval-specific exec-snippet helpers beside approvals, fixing the separator module path, and keeping a couple of existing test helpers reachable after extraction.
188 lines
6.5 KiB
Rust
188 lines
6.5 KiB
Rust
//! Completed request-user-input transcript rendering.
|
|
|
|
use super::*;
|
|
|
|
/// Renders a completed (or interrupted) request_user_input exchange in history.
|
|
#[derive(Debug)]
|
|
pub(crate) struct RequestUserInputResultCell {
|
|
pub(crate) questions: Vec<ToolRequestUserInputQuestion>,
|
|
pub(crate) answers: HashMap<String, ToolRequestUserInputAnswer>,
|
|
pub(crate) interrupted: bool,
|
|
}
|
|
|
|
impl HistoryCell for RequestUserInputResultCell {
|
|
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
|
let width = width.max(1) as usize;
|
|
let total = self.questions.len();
|
|
let answered = self
|
|
.questions
|
|
.iter()
|
|
.filter(|question| {
|
|
self.answers
|
|
.get(&question.id)
|
|
.is_some_and(|answer| !answer.answers.is_empty())
|
|
})
|
|
.count();
|
|
let unanswered = total.saturating_sub(answered);
|
|
|
|
let mut header = vec!["•".dim(), " ".into(), "Questions".bold()];
|
|
header.push(format!(" {answered}/{total} answered").dim());
|
|
if self.interrupted {
|
|
header.push(" (interrupted)".cyan());
|
|
}
|
|
|
|
let mut lines: Vec<Line<'static>> = vec![header.into()];
|
|
|
|
for question in &self.questions {
|
|
let answer = self.answers.get(&question.id);
|
|
let answer_missing = match answer {
|
|
Some(answer) => answer.answers.is_empty(),
|
|
None => true,
|
|
};
|
|
let mut question_lines = wrap_with_prefix(
|
|
&question.question,
|
|
width,
|
|
" • ".into(),
|
|
" ".into(),
|
|
Style::default(),
|
|
);
|
|
if answer_missing && let Some(last) = question_lines.last_mut() {
|
|
last.spans.push(" (unanswered)".dim());
|
|
}
|
|
lines.extend(question_lines);
|
|
|
|
let Some(answer) = answer.filter(|answer| !answer.answers.is_empty()) else {
|
|
continue;
|
|
};
|
|
if question.is_secret {
|
|
lines.extend(wrap_with_prefix(
|
|
"••••••",
|
|
width,
|
|
" answer: ".dim(),
|
|
" ".dim(),
|
|
Style::default().fg(Color::Cyan),
|
|
));
|
|
continue;
|
|
}
|
|
|
|
let (options, note) = split_request_user_input_answer(answer);
|
|
|
|
for option in options {
|
|
lines.extend(wrap_with_prefix(
|
|
&option,
|
|
width,
|
|
" answer: ".dim(),
|
|
" ".dim(),
|
|
Style::default().fg(Color::Cyan),
|
|
));
|
|
}
|
|
if let Some(note) = note {
|
|
let (label, continuation, style) = if question.options.is_some() {
|
|
(
|
|
" note: ".dim(),
|
|
" ".dim(),
|
|
Style::default().fg(Color::Cyan),
|
|
)
|
|
} else {
|
|
(
|
|
" answer: ".dim(),
|
|
" ".dim(),
|
|
Style::default().fg(Color::Cyan),
|
|
)
|
|
};
|
|
lines.extend(wrap_with_prefix(¬e, width, label, continuation, style));
|
|
}
|
|
}
|
|
|
|
if self.interrupted && unanswered > 0 {
|
|
let summary = format!("interrupted with {unanswered} unanswered");
|
|
lines.extend(wrap_with_prefix(
|
|
&summary,
|
|
width,
|
|
" ↳ ".cyan().dim(),
|
|
" ".dim(),
|
|
Style::default().fg(Color::Cyan).add_modifier(Modifier::DIM),
|
|
));
|
|
}
|
|
|
|
lines
|
|
}
|
|
|
|
fn raw_lines(&self) -> Vec<Line<'static>> {
|
|
let total = self.questions.len();
|
|
let answered = self
|
|
.questions
|
|
.iter()
|
|
.filter(|question| {
|
|
self.answers
|
|
.get(&question.id)
|
|
.is_some_and(|answer| !answer.answers.is_empty())
|
|
})
|
|
.count();
|
|
let mut lines = vec![Line::from(format!("Questions {answered}/{total} answered"))];
|
|
if self.interrupted {
|
|
lines.push(Line::from("(interrupted)"));
|
|
}
|
|
for question in &self.questions {
|
|
lines.push(Line::from(question.question.clone()));
|
|
if let Some(answer) = self
|
|
.answers
|
|
.get(&question.id)
|
|
.filter(|answer| !answer.answers.is_empty())
|
|
{
|
|
if question.is_secret {
|
|
lines.push(Line::from("answer: ******"));
|
|
} else {
|
|
let (options, note) = split_request_user_input_answer(answer);
|
|
lines.extend(
|
|
options
|
|
.into_iter()
|
|
.map(|option| Line::from(format!("answer: {option}"))),
|
|
);
|
|
if let Some(note) = note {
|
|
lines.push(Line::from(format!("note: {note}")));
|
|
}
|
|
}
|
|
} else {
|
|
lines.push(Line::from("(unanswered)"));
|
|
}
|
|
}
|
|
lines
|
|
}
|
|
}
|
|
|
|
/// Wrap a plain string with textwrap and prefix each line, while applying a style to the content.
|
|
fn wrap_with_prefix(
|
|
text: &str,
|
|
width: usize,
|
|
initial_prefix: Span<'static>,
|
|
subsequent_prefix: Span<'static>,
|
|
style: Style,
|
|
) -> Vec<Line<'static>> {
|
|
let line = Line::from(vec![Span::from(text.to_string()).set_style(style)]);
|
|
let opts = RtOptions::new(width.max(1))
|
|
.initial_indent(Line::from(vec![initial_prefix]))
|
|
.subsequent_indent(Line::from(vec![subsequent_prefix]));
|
|
let wrapped = adaptive_wrap_line(&line, opts);
|
|
let mut out = Vec::new();
|
|
push_owned_lines(&wrapped, &mut out);
|
|
out
|
|
}
|
|
|
|
/// Split a request_user_input answer into option labels and an optional freeform note.
|
|
/// Notes are encoded as "user_note: <text>" entries in the answers list.
|
|
fn split_request_user_input_answer(
|
|
answer: &ToolRequestUserInputAnswer,
|
|
) -> (Vec<String>, Option<String>) {
|
|
let mut options = Vec::new();
|
|
let mut note = None;
|
|
for entry in &answer.answers {
|
|
if let Some(note_text) = entry.strip_prefix("user_note: ") {
|
|
note = Some(note_text.to_string());
|
|
} else {
|
|
options.push(entry.clone());
|
|
}
|
|
}
|
|
(options, note)
|
|
}
|