mirror of
https://github.com/openai/codex.git
synced 2026-11-10 21:00:47 +00:00
Collapse completed turn activity beneath transcript prompts (#60557)
## What changed - Fold earlier commentary, reasoning, and tool activity beneath an expandable prompt control in the full-screen transcript, keeping partial and final answers visible. - Label the control with completion timing when available, or a previous-message count otherwise. Keep tool groups independently expandable within the turn. - Preserve assistant message phases during live completion and replay, and retain untimed completion markers to identify completed turns. ## Testing Add transcript coverage for nested expansion, keyboard and mouse controls, narrow viewports, search anchor preservation, detailed mode, and legacy messages without phase or timing metadata. GitOrigin-RevId: b17d9d7c870990178d76d12a13d463ff9c32b96c
This commit is contained in:
@@ -1097,6 +1097,7 @@ impl App {
|
||||
self.finish_initial_history_replay_buffer(tui);
|
||||
}
|
||||
AppEvent::ConsolidateAgentMessage {
|
||||
phase,
|
||||
interrupted_item,
|
||||
source,
|
||||
copy_source,
|
||||
@@ -1107,6 +1108,7 @@ impl App {
|
||||
} => {
|
||||
let mut cell = history_cell::AgentMarkdownCell::new_with_inline_visualizations(source, &cwd, inline_visualization_context).with_copy_source(copy_source);
|
||||
cell.interrupted_item = interrupted_item;
|
||||
cell.phase = phase;
|
||||
self.handle_consolidate_agent_message(
|
||||
tui,
|
||||
cell,
|
||||
|
||||
@@ -708,12 +708,11 @@ async fn replay_reconciles_only_matching_voice_captions_one_for_one() {
|
||||
AppEvent::InsertHistoryCell(cell) => Some(
|
||||
cell.transcript_lines(/*width*/ 80)
|
||||
.into_iter()
|
||||
.map(|line| line.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
.map(|line| line.to_string()),
|
||||
),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert_eq!(rendered.matches("show <code> & tests").count(), 2);
|
||||
@@ -1086,12 +1085,11 @@ async fn completed_voice_caption_survives_repeated_thread_replacement() {
|
||||
AppEvent::InsertHistoryCell(cell) => Some(
|
||||
cell.transcript_lines(/*width*/ 80)
|
||||
.into_iter()
|
||||
.map(|line| line.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
.map(|line| line.to_string()),
|
||||
),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert_eq!(rendered.matches("spoken complete").count(), 1);
|
||||
|
||||
@@ -313,7 +313,7 @@ pub(crate) enum CapturedSideStream {
|
||||
/// Presentation retained alongside the transcript while its composer is inactive.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct CapturedSideDisplay {
|
||||
pub(crate) rendered_completion_turn_ids: std::collections::HashSet<String>,
|
||||
pub(crate) rendered_completion_turn_ids: std::collections::HashMap<String, bool>,
|
||||
pub(crate) active: Option<Box<dyn HistoryCell>>,
|
||||
pub(crate) auxiliary: Vec<crate::terminal_hyperlinks::HyperlinkLine>,
|
||||
pub(crate) draft: Box<crate::bottom_pane::TextArea>,
|
||||
@@ -1359,6 +1359,7 @@ pub(crate) enum AppEvent {
|
||||
/// `deferred_history_cell` lets callers add the final stream tail to the
|
||||
/// transcript without first writing its provisional render to scrollback.
|
||||
ConsolidateAgentMessage {
|
||||
phase: Option<codex_protocol::models::MessagePhase>,
|
||||
interrupted_item: Option<(String, String)>,
|
||||
source: String,
|
||||
copy_source: Option<String>,
|
||||
|
||||
@@ -16,5 +16,6 @@ impl ChatWidget {
|
||||
self.bottom_pane.status_elapsed().filter(|_| live),
|
||||
live.then(Local::now),
|
||||
)
|
||||
.filter(|cell| self.local_settings.transcript_mode.is_owned() || cell.has_timing())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,7 +498,7 @@ async fn transcript_completion_waits_for_normal_agent_stream_consolidation() {
|
||||
}
|
||||
}
|
||||
|
||||
chat.finalize_completed_assistant_message(Some("normal typed output"));
|
||||
chat.finalize_completed_assistant_message("normal typed output", /*phase*/ None);
|
||||
chat.note_stream_consolidation_completed();
|
||||
chat.flush_realtime_transcript_history();
|
||||
|
||||
@@ -542,7 +542,7 @@ async fn transcript_handoff_moves_deferred_repeats_and_partial_once() {
|
||||
assert_eq!(cells.len(), super::super::MAX_PENDING_TRANSCRIPT_CELLS + 1);
|
||||
assert!(chat.take_realtime_transcript_cells_for_replay().is_empty());
|
||||
chat.restore_realtime_transcript_cells(cells);
|
||||
chat.finalize_completed_assistant_message(Some("ordinary stream"));
|
||||
chat.finalize_completed_assistant_message("ordinary stream", /*phase*/ None);
|
||||
chat.note_stream_consolidation_completed();
|
||||
chat.flush_realtime_transcript_history();
|
||||
|
||||
@@ -849,7 +849,7 @@ async fn completed_user_caption_stays_visible_until_history_commit() {
|
||||
chat.on_agent_message_delta("An earlier answer".into());
|
||||
assert!(chat.take_realtime_transcript_history().is_empty());
|
||||
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Keep these words visible."));
|
||||
chat.finalize_completed_assistant_message(Some("An earlier answer"));
|
||||
chat.finalize_completed_assistant_message("An earlier answer", /*phase*/ None);
|
||||
chat.note_stream_consolidation_completed();
|
||||
commit_realtime_history_events(&mut chat, &mut events);
|
||||
let history = std::iter::from_fn(|| events.try_recv().ok())
|
||||
|
||||
@@ -228,7 +228,7 @@ impl ChatWidget {
|
||||
if hidden_nested_review_turn {
|
||||
self.turn_lifecycle
|
||||
.rendered_completion_turn_ids
|
||||
.insert(turn_id.clone());
|
||||
.insert(turn_id.clone(), /*v*/ true);
|
||||
}
|
||||
if matches!(
|
||||
status,
|
||||
|
||||
@@ -86,10 +86,14 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(super) fn flush_answer_stream_with_separator(&mut self) {
|
||||
self.flush_answer_stream(/*completed_message*/ None);
|
||||
self.flush_answer_stream(/*completed_message*/ None, /*phase*/ None);
|
||||
}
|
||||
|
||||
fn flush_answer_stream(&mut self, completed_message: Option<&str>) {
|
||||
fn flush_answer_stream(
|
||||
&mut self,
|
||||
completed_message: Option<&str>,
|
||||
phase: Option<MessagePhase>,
|
||||
) {
|
||||
let had_stream_controller = self.stream_controller.is_some();
|
||||
if let Some(mut controller) = self.stream_controller.take() {
|
||||
let needs_scrollback_reflow = controller.needs_scrollback_reflow();
|
||||
@@ -147,6 +151,7 @@ impl ChatWidget {
|
||||
});
|
||||
self.note_stream_consolidation_queued();
|
||||
self.app_event_tx.send(AppEvent::ConsolidateAgentMessage {
|
||||
phase,
|
||||
interrupted_item,
|
||||
source,
|
||||
copy_source,
|
||||
@@ -281,18 +286,19 @@ impl ChatWidget {
|
||||
(stream, display)
|
||||
}
|
||||
|
||||
pub(super) fn finalize_completed_assistant_message(&mut self, message: Option<&str>) {
|
||||
if self.stream_controller.is_none()
|
||||
&& let Some(message) = message
|
||||
&& !message.is_empty()
|
||||
{
|
||||
pub(super) fn finalize_completed_assistant_message(
|
||||
&mut self,
|
||||
message: &str,
|
||||
phase: Option<MessagePhase>,
|
||||
) {
|
||||
if self.stream_controller.is_none() && !message.is_empty() {
|
||||
let displayed =
|
||||
parse_assistant_markdown(message, self.config.cwd.as_path()).visible_markdown;
|
||||
self.handle_streaming_delta(displayed);
|
||||
}
|
||||
// Item completion is authoritative. Use it for consolidation so any
|
||||
// deltas dropped by a saturated transport cannot truncate the transcript.
|
||||
self.flush_answer_stream(message);
|
||||
self.flush_answer_stream(Some(message), phase);
|
||||
self.handle_stream_finished();
|
||||
self.request_redraw();
|
||||
}
|
||||
@@ -575,7 +581,8 @@ impl ChatWidget {
|
||||
self.config.cwd.as_path(),
|
||||
context,
|
||||
questions,
|
||||
);
|
||||
)
|
||||
.with_phase(item.phase.clone());
|
||||
if let Some(item) = interrupted {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::CorrectFlushedAgentMessage { item, cell });
|
||||
@@ -592,7 +599,7 @@ impl ChatWidget {
|
||||
self.handle_stream_finished();
|
||||
self.request_redraw();
|
||||
} else {
|
||||
self.finalize_completed_assistant_message(Some(&message));
|
||||
self.finalize_completed_assistant_message(&message, item.phase.clone());
|
||||
}
|
||||
self.transcript.last_flushed_agent_message = None;
|
||||
if !parsed.visible_markdown.is_empty() {
|
||||
|
||||
@@ -1803,6 +1803,7 @@ async fn live_app_server_turn_completion_repairs_dropped_message_deltas() {
|
||||
.filter_map(|event| match event {
|
||||
AppEvent::ConsolidateAgentMessage {
|
||||
source,
|
||||
phase: Some(MessagePhase::FinalAnswer),
|
||||
scrollback_reflow,
|
||||
..
|
||||
} => {
|
||||
|
||||
@@ -443,14 +443,15 @@ async fn replayed_commands_preserve_individual_output_and_failure_status() {
|
||||
|
||||
let cells = std::iter::from_fn(|| rx.try_recv().ok())
|
||||
.filter_map(|event| match event {
|
||||
AppEvent::InsertHistoryCell(cell) => Some(cell),
|
||||
AppEvent::InsertHistoryCell(cell) => Some(cell.transcript_lines(/*width*/ 80)),
|
||||
_ => None,
|
||||
})
|
||||
.filter(|lines| !lines.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(cells.len(), 4);
|
||||
let transcript = cells
|
||||
.iter()
|
||||
.map(|cell| lines_to_single_string(&cell.transcript_lines(/*width*/ 80)))
|
||||
.map(|lines| lines_to_single_string(lines))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
insta::assert_snapshot!(transcript, @r"$ printf first
|
||||
|
||||
@@ -69,7 +69,7 @@ async fn list_spacing_streams_compact_then_reflows_on_completion_or_interruption
|
||||
} else if plan {
|
||||
chat.on_plan_item_completed(source.into());
|
||||
} else {
|
||||
chat.finalize_completed_assistant_message(Some(source));
|
||||
chat.finalize_completed_assistant_message(source, /*phase*/ None);
|
||||
}
|
||||
let mut completed = None;
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Agent-turn lifecycle state for `ChatWidget`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -16,8 +17,8 @@ pub(super) struct TurnLifecycleState {
|
||||
/// Cloud confirmed admission to a turn whose live start we have yet to process.
|
||||
pub(super) admitted_turn_id: Option<String>,
|
||||
pub(super) budget_limited_turn_ids: HashSet<String>,
|
||||
/// Completion labels already inserted into this thread's visible history.
|
||||
pub(super) rendered_completion_turn_ids: HashSet<String>,
|
||||
/// False records an untimed turn boundary that may still receive a completion label.
|
||||
pub(super) rendered_completion_turn_ids: HashMap<String, bool>,
|
||||
pub(super) goal_status_active_turn_started_at: Option<Instant>,
|
||||
}
|
||||
|
||||
@@ -30,7 +31,7 @@ impl TurnLifecycleState {
|
||||
has_turn_history: false,
|
||||
admitted_turn_id: None,
|
||||
budget_limited_turn_ids: HashSet::new(),
|
||||
rendered_completion_turn_ids: HashSet::new(),
|
||||
rendered_completion_turn_ids: HashMap::new(),
|
||||
goal_status_active_turn_started_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,6 +588,7 @@ impl HistoryCell for AgentMessageCell {
|
||||
pub(crate) struct AgentMarkdownCell {
|
||||
/// Identifies an interrupted reply that can still receive its authoritative completion.
|
||||
pub(crate) interrupted_item: Option<(String, String)>,
|
||||
pub(crate) phase: Option<codex_protocol::models::MessagePhase>,
|
||||
markdown_source: String,
|
||||
copy_source: Option<String>,
|
||||
cwd: PathBuf,
|
||||
@@ -643,6 +644,7 @@ impl AgentMarkdownCell {
|
||||
interrupted_item: None,
|
||||
markdown_source,
|
||||
copy_source: None,
|
||||
phase: None,
|
||||
cwd: cwd.to_path_buf(),
|
||||
inline_visualization_context,
|
||||
rendered_lines,
|
||||
@@ -651,6 +653,14 @@ impl AgentMarkdownCell {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_phase(
|
||||
mut self,
|
||||
phase: Option<codex_protocol::models::MessagePhase>,
|
||||
) -> Self {
|
||||
self.phase = phase;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_questions(
|
||||
mut self,
|
||||
questions: &[codex_protocol::items::AsyncUserInputQuestion],
|
||||
|
||||
@@ -7,7 +7,7 @@ use chrono::Datelike;
|
||||
use chrono::Local;
|
||||
use chrono::NaiveDate;
|
||||
use codex_app_server_protocol::Turn;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Completion metadata shown after the assistant's final response.
|
||||
///
|
||||
@@ -26,9 +26,10 @@ pub struct FinalMessageSeparator {
|
||||
clock_format: ClockFormat,
|
||||
}
|
||||
impl FinalMessageSeparator {
|
||||
/// Full-screen transcripts also use untimed separators to identify completed turns.
|
||||
pub(crate) fn from_turn_once(
|
||||
turn: &Turn,
|
||||
rendered_turn_ids: &mut HashSet<String>,
|
||||
rendered_completion_turn_ids: &mut HashMap<String, bool>,
|
||||
fallback_elapsed: Option<std::time::Duration>,
|
||||
fallback_completed_at: Option<DateTime<Local>>,
|
||||
) -> Option<Self> {
|
||||
@@ -42,12 +43,14 @@ impl FinalMessageSeparator {
|
||||
.and_then(|timestamp| DateTime::from_timestamp(timestamp, /*nsecs*/ 0))
|
||||
.map(|timestamp| timestamp.with_timezone(&Local))
|
||||
.or(fallback_completed_at);
|
||||
if completed_at.is_none() && elapsed_seconds.is_none() {
|
||||
return None;
|
||||
}
|
||||
if !rendered_turn_ids.insert(turn.id.clone()) {
|
||||
let has_label = completed_at.is_some() || elapsed_seconds.is_some();
|
||||
if rendered_completion_turn_ids
|
||||
.get(&turn.id)
|
||||
.is_some_and(|labeled| *labeled || !has_label)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
rendered_completion_turn_ids.insert(turn.id.clone(), has_label);
|
||||
let cell = Self::new(elapsed_seconds, /*runtime_metrics*/ None);
|
||||
Some(match completed_at {
|
||||
Some(completed_at) => cell.with_completed_at(completed_at, ClockFormat::system()),
|
||||
@@ -87,6 +90,14 @@ impl FinalMessageSeparator {
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn has_timing(&self) -> bool {
|
||||
self.completed_at.is_some() || self.elapsed_seconds.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn display_label(&self) -> Option<String> {
|
||||
self.label(self.display_date)
|
||||
}
|
||||
|
||||
fn label(&self, today: NaiveDate) -> Option<String> {
|
||||
let mut label_parts = Vec::new();
|
||||
if let Some(elapsed_seconds) = self.elapsed_seconds {
|
||||
@@ -129,7 +140,7 @@ impl HistoryCell for FinalMessageSeparator {
|
||||
if width == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
self.label(self.display_date)
|
||||
self.display_label()
|
||||
.map(|label| {
|
||||
let indent = if width > 2 { " " } else { "" };
|
||||
let options = textwrap::Options::new(usize::from(width))
|
||||
@@ -144,7 +155,7 @@ impl HistoryCell for FinalMessageSeparator {
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
self.label(self.display_date)
|
||||
self.display_label()
|
||||
.map(|label| vec![Line::from(label)])
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -275,7 +275,10 @@ fn item_to_cells(
|
||||
cells.push(Arc::new(cell.with_async_replies(replies, cwd.as_path())));
|
||||
}
|
||||
ThreadItemPayload::AgentMessage {
|
||||
text, questions, ..
|
||||
text,
|
||||
questions,
|
||||
phase,
|
||||
..
|
||||
} => {
|
||||
let parsed = parse_assistant_markdown(&text, cwd.as_path());
|
||||
if !parsed.visible_markdown.trim().is_empty() {
|
||||
@@ -285,6 +288,7 @@ fn item_to_cells(
|
||||
cwd.as_path(),
|
||||
inline_visualization_context,
|
||||
)
|
||||
.with_phase(phase)
|
||||
.with_questions(questions.as_deref().unwrap_or_default()),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -166,6 +166,13 @@ impl TranscriptView {
|
||||
.bold()
|
||||
.remove_modifier(Modifier::DIM),
|
||||
);
|
||||
} else if layout.is_turn_disclosure() {
|
||||
buf.set_style(
|
||||
area,
|
||||
Style::default()
|
||||
.fg(crate::style::user_message_accent_color())
|
||||
.bold(),
|
||||
);
|
||||
} else if row == usize::from(layout.separated) {
|
||||
buf.set_style(area, Style::default().underlined());
|
||||
}
|
||||
@@ -334,11 +341,18 @@ impl TranscriptView {
|
||||
let Some(layout) = self.layout(cells, index) else {
|
||||
return false;
|
||||
};
|
||||
if !layout.disclosure {
|
||||
let Some(control) = layout.disclosure_row() else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let ids = self.activity_ids(cells, index);
|
||||
let row = usize::from(layout.separated);
|
||||
let row = if matches!(
|
||||
self.turn_row(cells, index),
|
||||
Some(turn_details::TurnRow::Prompt { .. })
|
||||
) {
|
||||
control
|
||||
} else {
|
||||
usize::from(layout.separated)
|
||||
};
|
||||
let visible = self.visible.iter().any(|visible| {
|
||||
visible.row == row && visible.activity_ids.iter().any(|id| ids.contains(id))
|
||||
});
|
||||
@@ -381,6 +395,12 @@ impl TranscriptView {
|
||||
if self.search.has_active_query() && !matched {
|
||||
if let Some(snapshot) = &mut self.held_reading {
|
||||
snapshot.pinned.remove(&key);
|
||||
if let Some(details) = &self.turn_details {
|
||||
for child in details.child_keys(index) {
|
||||
snapshot.pinned.remove(&child);
|
||||
snapshot.activities.remove(&child);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.release_live_reading();
|
||||
@@ -391,7 +411,11 @@ impl TranscriptView {
|
||||
.and_then(|(key, top_ids, offset, row_bias)| {
|
||||
let unchanged = cells.iter().position(|cell| EntryKey::cell(cell) == key);
|
||||
let top = unchanged.or_else(|| self.canonical_activity_index(cells, &top_ids))?;
|
||||
let preserve_offset = unchanged.is_some() && top != index;
|
||||
let prompt = matches!(
|
||||
self.turn_row(cells, top),
|
||||
Some(turn_details::TurnRow::Prompt { .. })
|
||||
);
|
||||
let preserve_offset = unchanged.is_some() && (top != index || prompt);
|
||||
Some(Anchor {
|
||||
key: cells.get(top).map_or(EntryKey::Live, EntryKey::cell),
|
||||
index: top,
|
||||
|
||||
@@ -240,12 +240,17 @@ impl TranscriptView {
|
||||
}
|
||||
let mode = self.mode;
|
||||
let mut presentation = self.content_presentation(cell.as_ref(), full_content, mode);
|
||||
let mut turn_header = None;
|
||||
if !full_content && let Some(row) = self.turn_row(cells, index) {
|
||||
let ids = row.ids();
|
||||
presentation.expanded = self.disclosure.is_expanded(ids);
|
||||
presentation.summarized = true;
|
||||
match &row {
|
||||
turn_details::TurnRow::Prompt { label, .. } => {
|
||||
let arrow = if presentation.expanded { "▾" } else { "▸" };
|
||||
turn_header = Some(format!("{arrow} {label}"));
|
||||
}
|
||||
turn_details::TurnRow::Tools(_) => {
|
||||
let ids = row.ids();
|
||||
presentation.expanded = self.disclosure.is_expanded(ids);
|
||||
presentation.summarized = true;
|
||||
if presentation.expanded {
|
||||
self.disclosure.expanded.extend(ids.iter().cloned());
|
||||
}
|
||||
@@ -253,9 +258,7 @@ impl TranscriptView {
|
||||
row.layout(width, presentation.expanded, Vec::new())
|
||||
}));
|
||||
}
|
||||
turn_details::TurnRow::Hidden => {
|
||||
return Some(Arc::new(TextLayout::new(Vec::new(), width)));
|
||||
}
|
||||
_ => return Some(Arc::new(TextLayout::new(Vec::new(), width))),
|
||||
}
|
||||
}
|
||||
let shortcut = self
|
||||
@@ -302,6 +305,9 @@ impl TranscriptView {
|
||||
if shares_prompt_background {
|
||||
layout = layout.without_leading_blank_row();
|
||||
}
|
||||
if let Some(label) = turn_header {
|
||||
layout = layout.with_turn_disclosure(label);
|
||||
}
|
||||
layout
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -53,9 +53,15 @@ pub(crate) struct TextLayout {
|
||||
disclosure_control: Option<DisclosureControl>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum DisclosureAnchor {
|
||||
Activity(usize),
|
||||
Turn,
|
||||
}
|
||||
|
||||
struct DisclosureControl {
|
||||
anchor: DisclosureAnchor,
|
||||
label: String,
|
||||
source_offset: usize,
|
||||
row: usize,
|
||||
}
|
||||
|
||||
@@ -160,8 +166,7 @@ impl TextLayout {
|
||||
pub(super) fn rewrap(&self, width: u16) -> Self {
|
||||
let mut layout = Self::from_logical(self.logical.clone(), width);
|
||||
if let Some(control) = &self.disclosure_control {
|
||||
layout =
|
||||
layout.with_disclosure_control_at(control.label.clone(), control.source_offset);
|
||||
layout = layout.with_control(control.label.clone(), control.anchor);
|
||||
}
|
||||
layout.image = self.image.clone();
|
||||
if self.leading_blank_hidden {
|
||||
@@ -181,46 +186,63 @@ impl TextLayout {
|
||||
}
|
||||
|
||||
/// Place a control after an activity, before any following auxiliary source text.
|
||||
pub(super) fn with_disclosure_control_at(
|
||||
mut self,
|
||||
label: String,
|
||||
source_offset: usize,
|
||||
) -> Self {
|
||||
pub(super) fn with_disclosure_control_at(self, label: String, source_offset: usize) -> Self {
|
||||
self.with_control(label, DisclosureAnchor::Activity(source_offset))
|
||||
}
|
||||
|
||||
pub(super) fn with_turn_disclosure(self, label: String) -> Self {
|
||||
self.with_control(label, DisclosureAnchor::Turn)
|
||||
}
|
||||
|
||||
pub(super) fn is_turn_disclosure(&self) -> bool {
|
||||
self.disclosure_control
|
||||
.as_ref()
|
||||
.is_some_and(|control| control.anchor == DisclosureAnchor::Turn)
|
||||
}
|
||||
|
||||
fn with_control(mut self, label: String, anchor: DisclosureAnchor) -> Self {
|
||||
if label.is_empty() {
|
||||
return self;
|
||||
}
|
||||
if let Some(control) = self.disclosure_control.take() {
|
||||
self.rows.remove(control.row);
|
||||
if control.anchor == DisclosureAnchor::Turn {
|
||||
self.rows.remove(control.row - 1);
|
||||
}
|
||||
}
|
||||
let source_offset = self
|
||||
.text
|
||||
.floor_char_boundary(source_offset.min(self.text.len()));
|
||||
let control_row = self
|
||||
let source_offset = match anchor {
|
||||
DisclosureAnchor::Activity(offset) => {
|
||||
self.text.floor_char_boundary(offset.min(self.text.len()))
|
||||
}
|
||||
DisclosureAnchor::Turn => self.text.len(),
|
||||
};
|
||||
let mut control_row = self
|
||||
.rows
|
||||
.iter()
|
||||
.rposition(|row| row.source.end <= source_offset)
|
||||
.map_or(/*default*/ 0, |row| row + 1);
|
||||
let indent = usize::from(self.width / 4).min(/*other*/ 4);
|
||||
let indent = match anchor {
|
||||
DisclosureAnchor::Activity(_) => usize::from(self.width / 4).min(/*other*/ 4),
|
||||
DisclosureAnchor::Turn => {
|
||||
self.insert_presentation_row(
|
||||
control_row,
|
||||
source_offset,
|
||||
/*indent*/ 0,
|
||||
Line::default(),
|
||||
);
|
||||
control_row += 1;
|
||||
0
|
||||
}
|
||||
};
|
||||
let line = truncate_line_to_width(
|
||||
Line::from(format!("{}{label}", " ".repeat(indent))).dim(),
|
||||
usize::from(self.width),
|
||||
);
|
||||
self.rows.insert(
|
||||
control_row,
|
||||
TextRow {
|
||||
line: HyperlinkLine::from(line),
|
||||
source: source_offset..source_offset,
|
||||
line_end: None,
|
||||
content_width: self.width,
|
||||
first_column: indent,
|
||||
prefix_columns: indent,
|
||||
tabs: tabs::TabStops::default(),
|
||||
},
|
||||
);
|
||||
self.insert_presentation_row(control_row, source_offset, indent, line);
|
||||
self.disclosure = true;
|
||||
self.disclosure_control = Some(DisclosureControl {
|
||||
anchor,
|
||||
label,
|
||||
source_offset,
|
||||
row: control_row,
|
||||
});
|
||||
self
|
||||
@@ -254,17 +276,11 @@ impl TextLayout {
|
||||
/// Reserve one presentation-only row before existing spacing and source text.
|
||||
pub(super) fn with_leading_spacer(mut self) -> Self {
|
||||
if !self.rows.is_empty() {
|
||||
self.rows.insert(
|
||||
self.insert_presentation_row(
|
||||
/*index*/ 0,
|
||||
TextRow {
|
||||
line: HyperlinkLine::from(""),
|
||||
source: 0..0,
|
||||
line_end: None,
|
||||
content_width: self.width,
|
||||
first_column: 0,
|
||||
prefix_columns: 0,
|
||||
tabs: tabs::TabStops::default(),
|
||||
},
|
||||
/*offset*/ 0,
|
||||
/*indent*/ 0,
|
||||
Line::default(),
|
||||
);
|
||||
if let Some(control) = &mut self.disclosure_control {
|
||||
control.row += 1;
|
||||
@@ -273,6 +289,27 @@ impl TextLayout {
|
||||
self
|
||||
}
|
||||
|
||||
fn insert_presentation_row(
|
||||
&mut self,
|
||||
index: usize,
|
||||
offset: usize,
|
||||
indent: usize,
|
||||
line: Line<'static>,
|
||||
) {
|
||||
self.rows.insert(
|
||||
index,
|
||||
TextRow {
|
||||
line: HyperlinkLine::from(line),
|
||||
source: offset..offset,
|
||||
line_end: None,
|
||||
content_width: self.width,
|
||||
first_column: indent,
|
||||
prefix_columns: indent,
|
||||
tabs: tabs::TabStops::default(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Source text between adjacent retained fragments; distinct logical lines have a hard break.
|
||||
pub(super) fn separator_after(&self, next: &Self) -> &str {
|
||||
if let Some(previous) = self.logical.last()
|
||||
|
||||
@@ -2,12 +2,18 @@
|
||||
//! and scrollback retain their original cells, including command output.
|
||||
|
||||
use super::*;
|
||||
use crate::history_cell::AgentMarkdownCell;
|
||||
use crate::history_cell::AgentMessageCell;
|
||||
use crate::history_cell::DynamicToolCallCell;
|
||||
use crate::history_cell::FinalMessageSeparator;
|
||||
use crate::history_cell::ReasoningSummaryCell;
|
||||
use crate::history_cell::UserHistoryCell;
|
||||
use crate::history_cell::WebSearchCell;
|
||||
use codex_protocol::models::MessagePhase;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum TurnRow {
|
||||
Prompt { id: String, label: String },
|
||||
Tools(Arc<ToolGroup>),
|
||||
Hidden,
|
||||
}
|
||||
@@ -15,6 +21,7 @@ pub(crate) enum TurnRow {
|
||||
impl TurnRow {
|
||||
pub(crate) fn ids(&self) -> &[String] {
|
||||
match self {
|
||||
Self::Prompt { id, .. } => std::slice::from_ref(id),
|
||||
Self::Tools(group) => &group.ids,
|
||||
Self::Hidden => &[],
|
||||
}
|
||||
@@ -70,6 +77,7 @@ pub(super) struct TurnDetails {
|
||||
|
||||
struct Entry {
|
||||
key: EntryKey,
|
||||
parent: Option<usize>,
|
||||
row: Option<TurnRow>,
|
||||
}
|
||||
|
||||
@@ -151,6 +159,13 @@ impl ToolGroup {
|
||||
}
|
||||
|
||||
impl TurnDetails {
|
||||
pub(super) fn child_keys(&self, parent: usize) -> impl Iterator<Item = EntryKey> + '_ {
|
||||
self.rows
|
||||
.iter()
|
||||
.filter(move |entry| entry.parent == Some(parent))
|
||||
.map(|entry| entry.key)
|
||||
}
|
||||
|
||||
fn index_turn(
|
||||
&mut self,
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
@@ -159,16 +174,27 @@ impl TurnDetails {
|
||||
) {
|
||||
let rows = &mut self.rows;
|
||||
let start = rows.len();
|
||||
let has_prompt = cells
|
||||
.first()
|
||||
.is_some_and(|cell| cell.as_any().is::<UserHistoryCell>());
|
||||
let keys = cells
|
||||
.iter()
|
||||
.map(EntryKey::cell)
|
||||
.chain(latest.then_some(EntryKey::Live));
|
||||
rows.extend(keys.map(|key| Entry { key, row: None }));
|
||||
rows.extend(keys.map(|key| Entry {
|
||||
key,
|
||||
parent: None,
|
||||
row: None,
|
||||
}));
|
||||
let rows = &mut rows[start..];
|
||||
let mut group = ToolGroup::default();
|
||||
if latest && !self.live.0.is_empty() {
|
||||
group.push(rows, cells.len(), self.live.1.clone(), self.live.0.clone());
|
||||
}
|
||||
let mut completion = None;
|
||||
let mut separator = None;
|
||||
let mut fold = false;
|
||||
let mut hidden_count = 0;
|
||||
let mut searches = std::collections::HashSet::new();
|
||||
for (index, cell) in cells.iter().enumerate().rev() {
|
||||
// Keep older calls outside the group whose disclosure is pinned on screen.
|
||||
@@ -187,7 +213,43 @@ impl TurnDetails {
|
||||
rows[index].row = Some(TurnRow::Hidden);
|
||||
continue;
|
||||
}
|
||||
let markdown = any.downcast_ref::<AgentMarkdownCell>();
|
||||
let phase = markdown.and_then(|message| message.phase.as_ref());
|
||||
let calls = cell.tool_call_labels();
|
||||
if let Some(done) = any.downcast_ref::<FinalMessageSeparator>() {
|
||||
// /review can complete another turn under the same prompt.
|
||||
// A live timestamp can arrive immediately after an untimed replay marker.
|
||||
if separator.is_none_or(|(next, _)| next != index + 1)
|
||||
|| done.display_label().is_some()
|
||||
{
|
||||
separator = Some((index, done));
|
||||
}
|
||||
fold = false;
|
||||
} else if phase == Some(&MessagePhase::FinalAnswer)
|
||||
|| (!fold
|
||||
&& separator.is_some()
|
||||
&& ((markdown.is_some() && phase.is_none())
|
||||
|| any.is::<crate::history_cell::ProposedPlanCell>()))
|
||||
{
|
||||
// An answer item can finish before its turn succeeds.
|
||||
fold |= separator.is_some();
|
||||
} else if fold
|
||||
&& phase != Some(&MessagePhase::PartialAnswer)
|
||||
&& has_prompt
|
||||
&& (!calls.is_empty()
|
||||
|| markdown.is_some()
|
||||
|| any.is::<AgentMessageCell>()
|
||||
|| any
|
||||
.downcast_ref::<ReasoningSummaryCell>()
|
||||
.is_some_and(|reasoning| !reasoning.is_transcript_only()))
|
||||
{
|
||||
rows[index].parent = Some(start);
|
||||
hidden_count += calls.len().max(1);
|
||||
if let Some((at, done)) = separator.take() {
|
||||
rows[at].row = Some(TurnRow::Hidden);
|
||||
completion.get_or_insert(done);
|
||||
}
|
||||
}
|
||||
if let Some(tool) = any.downcast_ref::<DynamicToolCallCell>()
|
||||
&& tool.is_active()
|
||||
{
|
||||
@@ -213,6 +275,18 @@ impl TurnDetails {
|
||||
}
|
||||
}
|
||||
group.finish(rows, cells);
|
||||
if hidden_count > 0 {
|
||||
let label = completion
|
||||
.and_then(FinalMessageSeparator::display_label)
|
||||
.unwrap_or_else(|| match hidden_count {
|
||||
1 => "1 previous message".into(),
|
||||
count => format!("{count} previous messages"),
|
||||
});
|
||||
rows[0].row = Some(TurnRow::Prompt {
|
||||
id: format!("turn:{:?}", rows[0].key),
|
||||
label,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,6 +396,12 @@ impl TranscriptView {
|
||||
let details = self.turn_details.as_ref()?;
|
||||
let key = cells.get(index).map_or(EntryKey::Live, EntryKey::cell);
|
||||
let entry = details.rows.get(index).filter(|entry| entry.key == key)?;
|
||||
if let Some(parent) = entry.parent
|
||||
&& let Some(TurnRow::Prompt { id, .. }) = &details.rows[parent].row
|
||||
&& !self.disclosure.expanded.contains(id)
|
||||
{
|
||||
return Some(TurnRow::Hidden);
|
||||
}
|
||||
entry.row.clone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,10 @@ impl TranscriptView {
|
||||
cells
|
||||
.last()
|
||||
.filter(|cell| {
|
||||
cell.as_any()
|
||||
.is::<crate::history_cell::FinalMessageSeparator>()
|
||||
self.turn_row(cells, cells.len() - 1).is_none()
|
||||
&& cell
|
||||
.as_any()
|
||||
.is::<crate::history_cell::FinalMessageSeparator>()
|
||||
&& !cell.display_lines(area.width).is_empty()
|
||||
})
|
||||
.map(EntryKey::cell)
|
||||
|
||||
@@ -1408,8 +1408,130 @@ fn tool_disclosure_hides_output() {
|
||||
");
|
||||
}
|
||||
|
||||
fn assistant_message(
|
||||
text: &str,
|
||||
phase: Option<codex_protocol::models::MessagePhase>,
|
||||
) -> Arc<dyn HistoryCell> {
|
||||
Arc::new(
|
||||
crate::history_cell::AgentMarkdownCell::new(
|
||||
text.into(),
|
||||
&crate::test_support::test_path_buf("/repo"),
|
||||
)
|
||||
.with_phase(phase),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_search_summary_follows_the_completed_call() {
|
||||
fn tool_groups_expand_independently_inside_a_completed_turn() {
|
||||
use crate::exec_cell::CommandOutput;
|
||||
use crate::exec_cell::new_active_exec_command;
|
||||
use codex_app_server_protocol::CommandExecutionSource;
|
||||
use codex_protocol::models::MessagePhase::Commentary;
|
||||
use codex_protocol::models::MessagePhase::FinalAnswer;
|
||||
let mut cells = vec![
|
||||
prompt("Check why the service writes a diagnostic to stderr each time it starts."),
|
||||
assistant_message("Checking stderr.", Some(Commentary)),
|
||||
];
|
||||
for id in ["hello", "world"] {
|
||||
let mut command = new_active_exec_command(
|
||||
id.into(),
|
||||
vec!["echo".into(), id.into()],
|
||||
Vec::new(),
|
||||
CommandExecutionSource::Agent,
|
||||
/*interaction_input*/ None,
|
||||
/*animations_enabled*/ false,
|
||||
);
|
||||
command.complete_call(
|
||||
id,
|
||||
CommandOutput::new(/*exit_code*/ 0, "output sentinel".into()),
|
||||
std::time::Duration::ZERO,
|
||||
);
|
||||
cells.push(Arc::new(command));
|
||||
}
|
||||
cells.push(assistant_message(
|
||||
"The path is valid.",
|
||||
Some(codex_protocol::models::MessagePhase::PartialAnswer),
|
||||
));
|
||||
cells.push(assistant_message(
|
||||
"The fixture is missing.",
|
||||
Some(FinalAnswer),
|
||||
));
|
||||
let mut view = TranscriptView::default();
|
||||
view.sync_turn_details(&cells, /*enabled*/ true, /*active*/ None);
|
||||
assert!(turn_screen(&mut view, &cells).contains("Checking stderr."));
|
||||
cells.push(Arc::new(crate::history_cell::FinalMessageSeparator::new(
|
||||
/*elapsed_seconds*/ None, /*runtime_metrics*/ None,
|
||||
)));
|
||||
view.sync_turn_details(&cells, /*enabled*/ true, /*active*/ None);
|
||||
insta::assert_snapshot!(turn_screen(&mut view, &cells).trim(), @r"
|
||||
› Check why the service writes a diagnostic to
|
||||
stderr each time it starts.
|
||||
|
||||
|
||||
▸ 3 previous messages
|
||||
|
||||
• The path is valid.
|
||||
|
||||
• The fixture is missing.
|
||||
");
|
||||
|
||||
// The wrapped prompt is taller than this viewport. Keyboard expansion keeps its control visible.
|
||||
let narrow = |view: &mut TranscriptView| {
|
||||
text(&render(view, &cells, /*width*/ 25, /*height*/ 3))
|
||||
};
|
||||
narrow(&mut view);
|
||||
view.handle_key(KeyCode::F(4).into(), &cells);
|
||||
narrow(&mut view);
|
||||
view.handle_key(KeyCode::Enter.into(), &cells);
|
||||
assert!(narrow(&mut view).contains("▾ 3 previous messages"));
|
||||
let expanded = turn_screen(&mut view, &cells);
|
||||
assert!(expanded.contains("Checking stderr.") && expanded.contains("▸ Ran 2 commands"));
|
||||
assert!(!expanded.contains("Ran echo hello"));
|
||||
view.handle_key(KeyCode::Down.into(), &cells);
|
||||
view.handle_key(KeyCode::Enter.into(), &cells);
|
||||
let expanded = turn_screen(&mut view, &cells);
|
||||
assert!(expanded.contains("▾ Ran 2 commands"));
|
||||
assert!(expanded.contains("Ran echo hello") && expanded.contains("Ran echo world"));
|
||||
assert!(!expanded.contains("output sentinel"));
|
||||
view.begin_search();
|
||||
view.paste_search("fixture is missing");
|
||||
while view.advance_search(&cells) {}
|
||||
view.handle_key(KeyCode::Enter.into(), &cells);
|
||||
let matched = view.search.match_anchor().expect("final answer match");
|
||||
view.scroll(&cells, /*rows*/ -20);
|
||||
let frame = turn_screen(&mut view, &cells);
|
||||
let row = |needle: &str| {
|
||||
frame
|
||||
.lines()
|
||||
.position(|line| line.contains(needle))
|
||||
.unwrap_or_else(|| panic!("missing {needle} in {frame}")) as u16
|
||||
};
|
||||
view.begin_selection(
|
||||
&cells,
|
||||
/*column*/ 5,
|
||||
row("Checking stderr."),
|
||||
/*clicks*/ 2,
|
||||
);
|
||||
assert!(view.toggle_disclosure_at(&cells, /*column*/ 0, row("previous messages")));
|
||||
let collapsed = turn_screen(&mut view, &cells);
|
||||
assert!(!collapsed.contains("Checking stderr.") && !collapsed.contains("Ran 2 commands"));
|
||||
assert!(collapsed.contains("The fixture is missing."));
|
||||
assert_eq!(view.search.match_anchor(), Some(matched));
|
||||
view.handle_key(KeyCode::Esc.into(), &cells);
|
||||
view.set_presentation(/*detailed*/ true, HistoryRenderMode::Rich);
|
||||
assert!(turn_screen(&mut view, &cells).contains("output sentinel"));
|
||||
view.set_presentation(/*detailed*/ false, HistoryRenderMode::Rich);
|
||||
|
||||
view.sync_turn_details(&cells, /*enabled*/ false, /*active*/ None);
|
||||
view.jump_to_latest();
|
||||
let disabled = turn_screen(&mut view, &cells);
|
||||
assert!(disabled.contains("Checking stderr."));
|
||||
assert!(disabled.contains("output sentinel"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_progress_stays_visible_until_a_final_answer() {
|
||||
use codex_protocol::models::MessagePhase::FinalAnswer;
|
||||
let completed = |id: &str, query: &str| {
|
||||
Arc::new(crate::history_cell::new_web_search_call(
|
||||
id.into(),
|
||||
@@ -1419,10 +1541,7 @@ fn web_search_summary_follows_the_completed_call() {
|
||||
};
|
||||
let mut cells = vec![
|
||||
prompt("Check the fixture"),
|
||||
Arc::new(crate::history_cell::AgentMarkdownCell::new(
|
||||
"Intermediate progress".into(),
|
||||
&crate::test_support::test_path_buf("/repo"),
|
||||
)),
|
||||
assistant_message("Intermediate progress", /*phase*/ None),
|
||||
];
|
||||
let search = crate::history_cell::new_active_web_search_call(
|
||||
"search".into(),
|
||||
@@ -1484,6 +1603,23 @@ fn web_search_summary_follows_the_completed_call() {
|
||||
let joined = turn_screen(&mut view, &cells);
|
||||
assert!(joined.contains("▾ 3 tool calls"));
|
||||
assert!(joined.contains(" Image generation failed\n Searched the web for fixture\n Searched the web for docs"));
|
||||
cells.extend([
|
||||
assistant_message("Fixture verified", /*phase*/ None),
|
||||
Arc::new(crate::history_cell::FinalMessageSeparator::new(
|
||||
/*elapsed_seconds*/ None, /*runtime_metrics*/ None,
|
||||
)),
|
||||
Arc::new(crate::history_cell::FinalMessageSeparator::new(
|
||||
Some(2),
|
||||
/*runtime_metrics*/ None,
|
||||
)),
|
||||
cell("Code review started"),
|
||||
assistant_message("Review complete", Some(FinalAnswer)),
|
||||
]);
|
||||
view.sync_turn_details(&cells, /*enabled*/ true, /*active*/ None);
|
||||
let screen = turn_screen(&mut view, &cells);
|
||||
assert!(screen.contains("▸ Worked for 2s"), "{screen}");
|
||||
assert!(screen.contains("Fixture verified") && screen.contains("Review complete"));
|
||||
assert!(!screen.contains("Intermediate progress"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user