Files
codex/codex-rs/tui/src/history_cell/request_user_input.rs
T
Eric Traut e6a7368810 TUI: split history cells into focused modules (#22704)
## 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.
2026-05-14 21:19:06 -07:00

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(&note, 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)
}