mirror of
https://github.com/openai/codex.git
synced 2026-11-10 21:00:47 +00:00
Add optional tool call summaries to the full-screen transcript (#60554)
## What changed - Add `tui.collapse_turn_details`, disabled by default, and a “Collapse turn details” toggle in the full-screen settings panel. - Summarize command executions with output-free labels, grouping multiple calls behind an expandable row. Keep full details available through transcript search and detailed view. - Restore interruption notices at their turn boundaries when resuming and loading older history, including turns split across pages. ## Testing Add coverage for live summary updates, expansion controls, search access to hidden output, settings visibility, and interruption ordering across history pages. GitOrigin-RevId: 68987fb0239e39414dac383098acac190fc4f516
This commit is contained in:
@@ -938,6 +938,10 @@ pub struct Tui {
|
||||
#[serde(default = "default_true")]
|
||||
pub fullscreen_transcript: bool,
|
||||
|
||||
/// Summarize tool calls and collapse completed turns in the full-screen transcript. Defaults to `false`.
|
||||
#[serde(default)]
|
||||
pub collapse_turn_details: bool,
|
||||
|
||||
/// Preferred docked sidebar width as a percentage of the terminal. Defaults to one third.
|
||||
#[serde(default)]
|
||||
#[schemars(range(min = 1, max = 99))]
|
||||
|
||||
@@ -5245,6 +5245,11 @@
|
||||
"description": "Generate automatic conversation recaps when the terminal is unfocused. Defaults to `true`. Disabling this leaves `/recap` available on demand.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"collapse_turn_details": {
|
||||
"default": false,
|
||||
"description": "Summarize tool calls and collapse completed turns in the full-screen transcript. Defaults to `false`.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"copy_on_select": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -1404,6 +1404,7 @@ fn config_toml_deserializes_model_availability_nux() {
|
||||
question_esc_back: true,
|
||||
raw_output_mode: false,
|
||||
fullscreen_transcript: true,
|
||||
collapse_turn_details: false,
|
||||
sidebar_width_percent: None,
|
||||
mouse_scroll_speed: None,
|
||||
copy_on_select: Default::default(),
|
||||
@@ -4772,6 +4773,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() {
|
||||
question_esc_back: true,
|
||||
raw_output_mode: false,
|
||||
fullscreen_transcript: true,
|
||||
collapse_turn_details: false,
|
||||
sidebar_width_percent: None,
|
||||
mouse_scroll_speed: None,
|
||||
copy_on_select: Default::default(),
|
||||
|
||||
@@ -849,6 +849,7 @@ pub struct Config {
|
||||
|
||||
/// Own the fullscreen transcript when the alternate screen is enabled.
|
||||
pub tui_fullscreen_transcript: bool,
|
||||
pub tui_collapse_turn_details: bool,
|
||||
|
||||
/// Preferred docked sidebar width as a percentage of the terminal.
|
||||
pub tui_sidebar_width_percent: Option<u8>,
|
||||
@@ -4874,6 +4875,7 @@ impl Config {
|
||||
.as_ref()
|
||||
.map(|t| t.raw_output_mode)
|
||||
.unwrap_or(false),
|
||||
tui_collapse_turn_details: cfg.tui.as_ref().is_some_and(|tui| tui.collapse_turn_details),
|
||||
tui_fullscreen_transcript: cfg
|
||||
.tui
|
||||
.as_ref()
|
||||
|
||||
@@ -302,6 +302,7 @@ async fn new_config(
|
||||
tui_auto_recap: true,
|
||||
model_availability_nux: ModelAvailabilityNuxConfig::default(),
|
||||
tui_fullscreen_transcript: false,
|
||||
tui_collapse_turn_details: false,
|
||||
tui_sidebar_width_percent: None,
|
||||
tui_mouse_scroll_speed: None,
|
||||
tui_copy_on_select: Default::default(),
|
||||
|
||||
@@ -1,31 +1,42 @@
|
||||
//! Separate older history at every turn boundary; only successful turns gain completion metadata.
|
||||
//! A page splitting a turn only adds completion metadata when it contains that turn's last item.
|
||||
//! Split older items at turn boundaries so each terminal row is rendered on the page that owns it.
|
||||
|
||||
use codex_app_server_protocol::ThreadItemPayload;
|
||||
use codex_app_server_protocol::Turn;
|
||||
use codex_app_server_protocol::TurnItemsView;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub(super) fn group_completed_turn_items(
|
||||
items: Vec<ThreadItemPayload>,
|
||||
turns: &[Turn],
|
||||
) -> Vec<(Vec<ThreadItemPayload>, Option<&Turn>)> {
|
||||
let boundaries: HashMap<_, _> = turns
|
||||
pub(super) fn group_turn_items<'a>(
|
||||
items: &[ThreadItemPayload],
|
||||
turns: &'a [Turn],
|
||||
) -> Vec<(std::ops::Range<usize>, Option<&'a Turn>)> {
|
||||
let offsets: HashMap<_, _> = items
|
||||
.iter()
|
||||
.filter_map(|turn| turn.items.last().map(|item| (item.id(), turn)))
|
||||
.enumerate()
|
||||
.map(|(i, item)| (item.id(), i + 1))
|
||||
.collect();
|
||||
let first = turns.iter().find_map(|turn| turn.items.first());
|
||||
// Itemless interruptions follow the preceding turn, or precede the first item in history.
|
||||
let mut end = first
|
||||
.filter(|item| offsets.get(item.id()) == Some(&1))
|
||||
.map(|_| 0);
|
||||
let mut start = 0;
|
||||
let mut groups = Vec::new();
|
||||
let mut pending = Vec::new();
|
||||
for item in items {
|
||||
let boundary = boundaries.get(item.id()).copied();
|
||||
pending.push(item);
|
||||
if let Some(turn) = boundary {
|
||||
let completed_turn = (turn.status == TurnStatus::Completed).then_some(turn);
|
||||
groups.push((std::mem::take(&mut pending), completed_turn));
|
||||
for turn in turns {
|
||||
if let Some(last) = turn.items.last() {
|
||||
end = offsets.get(last.id()).copied();
|
||||
} else if turn.status != TurnStatus::Interrupted
|
||||
|| turn.items_view == TurnItemsView::NotLoaded
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(end) = end {
|
||||
groups.push((start..end, Some(turn)));
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
if !pending.is_empty() {
|
||||
groups.push((pending, None));
|
||||
if start < items.len() {
|
||||
groups.push((start..items.len(), None));
|
||||
}
|
||||
groups
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use super::*;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::TurnItemsView;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn payloads(items: &[codex_app_server_protocol::ThreadItem]) -> Vec<ThreadItemPayload> {
|
||||
@@ -39,12 +40,12 @@ fn split_turn_only_completes_on_the_page_with_its_last_item() {
|
||||
&["first", "second", "last"],
|
||||
)];
|
||||
assert_eq!(
|
||||
group_completed_turn_items(payloads(&turns[0].items[1..]), &turns),
|
||||
vec![(payloads(&turns[0].items[1..]), Some(&turns[0]))],
|
||||
group_turn_items(&payloads(&turns[0].items[1..]), &turns),
|
||||
vec![(0..2, Some(&turns[0]))],
|
||||
);
|
||||
assert_eq!(
|
||||
group_completed_turn_items(payloads(&turns[0].items[..1]), &turns),
|
||||
vec![(payloads(&turns[0].items[..1]), None)],
|
||||
group_turn_items(&payloads(&turns[0].items[..1]), &turns),
|
||||
vec![(0..1, None)],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,33 +59,31 @@ fn multiple_turns_keep_item_groups_and_completion_order() {
|
||||
.iter()
|
||||
.flat_map(Turn::item_payloads)
|
||||
.cloned()
|
||||
.collect();
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
group_completed_turn_items(items, &turns),
|
||||
vec![
|
||||
(payloads(&turns[0].items), Some(&turns[0])),
|
||||
(payloads(&turns[1].items), Some(&turns[1])),
|
||||
],
|
||||
group_turn_items(&items, &turns),
|
||||
vec![(0..2, Some(&turns[0])), (2..4, Some(&turns[1])),],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsuccessful_and_running_turns_separate_groups_without_completion_metadata() {
|
||||
let turns = vec![
|
||||
fn itemless_interruptions_keep_their_position_on_the_owning_page() {
|
||||
let turns = [
|
||||
turn("empty first", TurnStatus::Interrupted, &[]),
|
||||
turn("failed", TurnStatus::Failed, &["a"]),
|
||||
turn("interrupted", TurnStatus::Interrupted, &["b"]),
|
||||
turn("running", TurnStatus::InProgress, &["c"]),
|
||||
turn("empty between", TurnStatus::Interrupted, &[]),
|
||||
turn("running", TurnStatus::InProgress, &["b"]),
|
||||
];
|
||||
let items = turns
|
||||
.iter()
|
||||
.flat_map(Turn::item_payloads)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
group_completed_turn_items(items, &turns),
|
||||
turns
|
||||
.iter()
|
||||
.map(|turn| (payloads(&turn.items), None))
|
||||
.collect::<Vec<_>>(),
|
||||
group_turn_items(&payloads(&turns[1].items), &turns),
|
||||
vec![
|
||||
(0..0, Some(&turns[0])),
|
||||
(0..1, Some(&turns[1])),
|
||||
(1..1, Some(&turns[2]))
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
group_turn_items(&payloads(&turns[3].items), &turns),
|
||||
vec![(0..1, Some(&turns[3]))],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -242,7 +242,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Project successful turn completion metadata after the corresponding page's final item.
|
||||
/// Project each terminal row on the page containing the end of its turn.
|
||||
fn project_older_history_cells(
|
||||
&mut self,
|
||||
items: Vec<ThreadItemPayload>,
|
||||
@@ -253,9 +253,9 @@ impl App {
|
||||
visibility: RawReasoningVisibility,
|
||||
) -> Vec<Arc<dyn HistoryCell>> {
|
||||
let mut cells = Vec::new();
|
||||
for (items, completed_turn) in completion::group_completed_turn_items(items, turns) {
|
||||
for (range, turn_end) in completion::group_turn_items(&items, turns) {
|
||||
// Internal prompts stay invisible, but still separate adjacent tool groups.
|
||||
for visible in items.split(|item| hidden_item_ids.contains(item.id())) {
|
||||
for visible in items[range].split(|item| hidden_item_ids.contains(item.id())) {
|
||||
cells.extend(thread_items_to_transcript_cells(
|
||||
Some(thread_id),
|
||||
cwd,
|
||||
@@ -264,7 +264,12 @@ impl App {
|
||||
Some(&self.config),
|
||||
));
|
||||
}
|
||||
if let Some(turn) = completed_turn
|
||||
let Some(turn) = turn_end else { continue };
|
||||
if turn.status == TurnStatus::Interrupted && !self.side_threads.contains_key(&thread_id)
|
||||
{
|
||||
cells.push(Arc::new(history_cell::new_interruption_notice()));
|
||||
}
|
||||
if turn.status == TurnStatus::Completed
|
||||
&& let Some(completion) = if let Some(pane) =
|
||||
self.side_companions.get_mut(&thread_id)
|
||||
&& self.chat_widget.thread_id() != Some(thread_id)
|
||||
@@ -474,6 +479,9 @@ fn merge_older_turns(current_turns: &mut Vec<Turn>, mut older_turns: Vec<Turn>)
|
||||
.into_iter()
|
||||
.filter(|item| !current.items.iter().any(|known| known.id() == item.id()))
|
||||
.collect::<Vec<_>>();
|
||||
if current.items_view == codex_app_server_protocol::TurnItemsView::NotLoaded {
|
||||
current.items_view = turn.items_view;
|
||||
}
|
||||
current.items.splice(0..0, items);
|
||||
false
|
||||
});
|
||||
|
||||
@@ -117,6 +117,62 @@ fn repeated_prompt_matching_removes_only_the_hidden_occurrence() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resume_places_interruption_before_searches_split_across_pages() {
|
||||
let (mut app, mut events, _) = crate::app::tests::make_test_app_with_channels().await;
|
||||
let mut canceled = turn("canceled", TurnStatus::Interrupted, &[]);
|
||||
canceled.items_view = TurnItemsView::NotLoaded;
|
||||
let search = |query: &str| {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"type": "webSearch", "id": query, "query": query,
|
||||
}))
|
||||
.expect("valid search")
|
||||
};
|
||||
let mut audit = turn("audit", TurnStatus::Completed, &[]);
|
||||
audit.items = vec![search("newer")];
|
||||
app.chat_widget.replay_thread_turns(
|
||||
vec![canceled.clone(), audit.clone()],
|
||||
ReplayKind::ResumeInitialMessages,
|
||||
);
|
||||
let mut cells: Vec<Arc<dyn HistoryCell>> = Vec::new();
|
||||
while let Ok(event) = events.try_recv() {
|
||||
if let AppEvent::InsertHistoryCell(cell) = event {
|
||||
cells.push(Arc::from(cell));
|
||||
}
|
||||
}
|
||||
|
||||
canceled.items.push(search("canceled"));
|
||||
audit.items.insert(/*index*/ 0, search("older"));
|
||||
let page = vec![
|
||||
canceled.items[0].payload.clone(),
|
||||
audit.items[0].payload.clone(),
|
||||
];
|
||||
let cwd = app.config.cwd.clone();
|
||||
let older = app.project_older_history_cells(
|
||||
page,
|
||||
&[canceled, audit],
|
||||
&HashSet::new(),
|
||||
ThreadId::new(),
|
||||
&cwd,
|
||||
RawReasoningVisibility::Hidden,
|
||||
);
|
||||
cells.splice(0..0, older);
|
||||
let rows = cells
|
||||
.iter()
|
||||
.flat_map(|cell| cell.display_lines(/*width*/ 100))
|
||||
.map(|line| line.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
rows,
|
||||
[
|
||||
"• Searched the web for canceled",
|
||||
"■ Conversation interrupted - use /feedback if something went wrong",
|
||||
"• Searched the web for older",
|
||||
"• Searched the web for newer",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hidden_last_item_keeps_turn_groups_and_completion_boundaries() {
|
||||
let computer = |id: &str| {
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::bottom_pane::CommandPopupPlacement;
|
||||
use crate::bottom_pane::ComposerRenderOptions;
|
||||
use crate::custom_terminal::Frame;
|
||||
use crate::empty_state_animation::ComposerState;
|
||||
#[cfg(test)]
|
||||
use crate::history_cell::HistoryRenderMode;
|
||||
use crate::keymap::KeymapContext;
|
||||
use crate::keymap::bindings_for_action;
|
||||
@@ -217,11 +218,14 @@ impl App {
|
||||
self.local_settings.tui.copy_on_select_clear_selection;
|
||||
view.set_keymap_bindings(&self.keymap);
|
||||
view.set_presentation(view.is_detailed(), chat_widget.history_render_mode());
|
||||
let active = chat_widget.active_activity_cell();
|
||||
let (live_summary, expanded) = view.sync_live_tool_summary(
|
||||
&self.transcript_cells,
|
||||
self.local_settings.tui.collapse_turn_details,
|
||||
active,
|
||||
);
|
||||
let active_key = chat_widget.active_cell_transcript_key();
|
||||
let detailed = view.is_detailed();
|
||||
let active_ids = chat_widget.active_activity_ids();
|
||||
let expanded = view.sync_live_activity(&self.transcript_cells, active_ids)
|
||||
&& chat_widget.history_render_mode() == HistoryRenderMode::Rich;
|
||||
let search_changed = view.sync_search_live_tail(transcript_width, active_key, |width| {
|
||||
chat_widget.active_cell_transcript_hyperlink_lines(width)
|
||||
});
|
||||
@@ -229,6 +233,10 @@ impl App {
|
||||
view.sync_live_tail(transcript_width, active_key, |width| {
|
||||
chat_widget.active_cell_transcript_hyperlink_lines(width)
|
||||
})
|
||||
} else if let Some(row) = live_summary {
|
||||
view.sync_live_layout(transcript_width, active_key, |width| {
|
||||
Some(row.layout(width, expanded, chat_widget.active_auxiliary_lines(width)))
|
||||
})
|
||||
} else {
|
||||
view.sync_live_activity_tail(transcript_width, active_key, expanded, |width| {
|
||||
chat_widget.active_cell_owned_transcript_lines(width, expanded)
|
||||
@@ -329,7 +337,12 @@ impl App {
|
||||
Some(sidebar::SidebarLayout::Chat(panes)) => {
|
||||
if let Some(companion) = self.side_companions.get_mut(&panes.passive_thread) {
|
||||
companion.view.image_protocol = self.transcript_view.image_protocol;
|
||||
panes.paint_companion(companion, frame, &frames);
|
||||
panes.paint_companion(
|
||||
companion,
|
||||
frame,
|
||||
self.local_settings.tui.collapse_turn_details,
|
||||
&frames,
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(sidebar::SidebarLayout::Chooser { sidebar, focus, .. }) => {
|
||||
|
||||
@@ -1664,6 +1664,17 @@ async fn find_refreshes_live_details_before_searching_the_first_query() -> Resul
|
||||
))
|
||||
.contains("needle")
|
||||
);
|
||||
app.local_settings.tui.collapse_turn_details = true;
|
||||
app.render_owned_transcript(&mut tui, Size::new(80, 24))?;
|
||||
let summary = buffer_text(crate::custom_terminal::test_support::last_rendered_buffer(
|
||||
&tui.terminal,
|
||||
));
|
||||
assert!(
|
||||
summary.contains("Running")
|
||||
&& summary.contains("printf visible")
|
||||
&& !summary.contains("needle"),
|
||||
"{summary}"
|
||||
);
|
||||
crate::app::test_support::enable_side_retention(&mut app)?;
|
||||
app.handle_tui_event(
|
||||
&mut tui,
|
||||
|
||||
@@ -434,6 +434,7 @@ impl SidePaneLayout {
|
||||
&self,
|
||||
companion: &mut SideCompanion,
|
||||
frame: &mut Frame<'_>,
|
||||
collapse_turn_details: bool,
|
||||
frames: &FrameRequester,
|
||||
) {
|
||||
companion.flush_answer();
|
||||
@@ -464,6 +465,7 @@ impl SidePaneLayout {
|
||||
&companion.cells,
|
||||
companion.display.active.as_deref(),
|
||||
&companion.display.auxiliary,
|
||||
collapse_turn_details,
|
||||
);
|
||||
frame
|
||||
.image_placements
|
||||
|
||||
@@ -24,6 +24,7 @@ use codex_app_server_protocol::ThreadTurnsListParams;
|
||||
use codex_app_server_protocol::ThreadTurnsListResponse;
|
||||
use codex_app_server_protocol::Turn;
|
||||
use codex_app_server_protocol::TurnItemsView;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_protocol::ThreadId;
|
||||
use color_eyre::eyre::Result;
|
||||
use color_eyre::eyre::WrapErr;
|
||||
@@ -139,6 +140,21 @@ fn prepend_older_turns(turns: &mut Vec<Turn>, mut older: Vec<Turn>) {
|
||||
turns.splice(0..0, older.into_iter().rev());
|
||||
}
|
||||
|
||||
// Turn metadata can extend before the loaded item window. Inside the window an empty
|
||||
// interrupted turn is genuinely itemless and its notice is ready to replay.
|
||||
fn mark_loaded_interruptions(turns: &mut [Turn], older_cursor: Option<&str>) {
|
||||
let mut in_window = older_cursor.is_none();
|
||||
for turn in turns {
|
||||
in_window |= !turn.items.is_empty();
|
||||
if in_window
|
||||
&& turn.status == TurnStatus::Interrupted
|
||||
&& turn.items_view == TurnItemsView::NotLoaded
|
||||
{
|
||||
turn.items_view = TurnItemsView::Summary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct ThreadHistoryPagination {
|
||||
pub(super) history_mode: ThreadHistoryMode,
|
||||
@@ -237,6 +253,7 @@ impl AppServerSession {
|
||||
let items = self
|
||||
.merge_thread_item_page(thread_id, page, &mut state, turns)
|
||||
.await?;
|
||||
mark_loaded_interruptions(turns, state.next_item_cursor.as_deref());
|
||||
state.loading_older = false;
|
||||
self.history_pagination.insert(thread_id, state);
|
||||
Ok(items)
|
||||
@@ -450,6 +467,7 @@ impl AppServerSession {
|
||||
prepend_older_turns(&mut thread.turns, page.data);
|
||||
}
|
||||
}
|
||||
mark_loaded_interruptions(&mut thread.turns, state.next_item_cursor.as_deref());
|
||||
if config.is_some() {
|
||||
self.history_pagination.insert(thread_id, state);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,8 @@ use super::*;
|
||||
use crate::transcript_view::ActivityTranscriptLines;
|
||||
|
||||
impl ChatWidget {
|
||||
pub(crate) fn active_activity_ids(&self) -> Vec<String> {
|
||||
self.transcript
|
||||
.active_cell
|
||||
.as_ref()
|
||||
.map_or_else(Vec::new, |cell| cell.activity_ids())
|
||||
pub(crate) fn active_activity_cell(&self) -> Option<&dyn HistoryCell> {
|
||||
self.transcript.active_cell.as_deref()
|
||||
}
|
||||
|
||||
/// Render only the chosen activity presentation, before any auxiliary live text.
|
||||
@@ -20,36 +17,19 @@ impl ChatWidget {
|
||||
) -> Option<ActivityTranscriptLines> {
|
||||
let active = self.transcript.active_cell.as_deref();
|
||||
let mode = self.history_render_mode();
|
||||
let render_compact = |cell: &dyn HistoryCell, width| {
|
||||
if cell.as_any().is::<history_cell::SessionInfoCell>() {
|
||||
history_cell::fullscreen_session_lines(cell, width, /*detailed*/ false, mode)
|
||||
} else if mode == HistoryRenderMode::Raw {
|
||||
cell.display_hyperlink_lines_for_mode(width, mode)
|
||||
} else {
|
||||
cell.compact_hyperlink_lines(width)
|
||||
}
|
||||
};
|
||||
let has_activity = mode == HistoryRenderMode::Rich
|
||||
&& active.is_some_and(|cell| !cell.activity_ids().is_empty());
|
||||
let activity = active.map_or_else(Vec::new, |cell| {
|
||||
if expanded && has_activity {
|
||||
cell.expanded_hyperlink_lines(width)
|
||||
} else {
|
||||
render_compact(cell, width)
|
||||
history_cell::fullscreen_session_lines(cell, width, /*detailed*/ false, mode)
|
||||
}
|
||||
});
|
||||
let disclosure = active
|
||||
.filter(|_| has_activity)
|
||||
.and_then(|cell| cell.activity_disclosure(width));
|
||||
let mut auxiliary = self
|
||||
.active_cell_hyperlink_lines_with(width, |cell, width| {
|
||||
if active.is_some_and(|active| std::ptr::eq(active, cell)) {
|
||||
Vec::new()
|
||||
} else {
|
||||
render_compact(cell, width)
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut auxiliary = self.active_auxiliary_lines(width);
|
||||
if activity.is_empty() && auxiliary.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -62,4 +42,16 @@ impl ChatWidget {
|
||||
disclosure,
|
||||
})
|
||||
}
|
||||
pub(crate) fn active_auxiliary_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
let active = self.active_activity_cell();
|
||||
let mode = self.history_render_mode();
|
||||
self.active_cell_hyperlink_lines_with(width, |cell, width| {
|
||||
if active.is_some_and(|active| std::ptr::eq(active, cell)) {
|
||||
Vec::new()
|
||||
} else {
|
||||
history_cell::fullscreen_session_lines(cell, width, /*detailed*/ false, mode)
|
||||
}
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ impl ChatWidget {
|
||||
self.status_surface_overrides.status_line_use_colors = None;
|
||||
}
|
||||
ConfigToggle::AutomaticRecaps
|
||||
| ConfigToggle::CollapseTurnDetails
|
||||
| ConfigToggle::CopyOnSelectClearSelection
|
||||
| ConfigToggle::Notifications
|
||||
| ConfigToggle::StartInVimMode
|
||||
@@ -155,6 +156,7 @@ impl ChatWidget {
|
||||
);
|
||||
}
|
||||
ConfigToggle::AutomaticRecaps
|
||||
| ConfigToggle::CollapseTurnDetails
|
||||
| ConfigToggle::CopyOnSelectClearSelection
|
||||
| ConfigToggle::Notifications
|
||||
| ConfigToggle::PasteDetection
|
||||
@@ -203,6 +205,7 @@ impl ChatWidget {
|
||||
|
||||
fn config_panel_status(&self) -> ConfigPanelStatus {
|
||||
ConfigPanelStatus {
|
||||
transcript_mode: self.local_settings.transcript_mode,
|
||||
copy_on_select: self
|
||||
.local_settings
|
||||
.copy_on_select(&codex_terminal_detection::terminal_info()),
|
||||
|
||||
@@ -453,12 +453,7 @@ impl ChatWidget {
|
||||
TurnAbortReason::BudgetLimited => history_cell::new_error_event(
|
||||
"Goal budget reached - the turn was stopped.".to_string(),
|
||||
),
|
||||
TurnAbortReason::Interrupted => PlainHistoryCell::new(vec![
|
||||
Line::from(
|
||||
"■ Conversation interrupted - use /feedback if something went wrong",
|
||||
)
|
||||
.style(crate::style::secondary_text_style()),
|
||||
]),
|
||||
TurnAbortReason::Interrupted => history_cell::new_interruption_notice(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,9 +124,9 @@ impl ChatWidget {
|
||||
.collect::<Vec<_>>();
|
||||
for (turn, hidden_nested_review_turn) in turns.into_iter().zip(hidden_nested_review_turns) {
|
||||
self.restore_realtime_transcripts_before_turn(&turn.id);
|
||||
// Defer completed metadata-only turns until their page loads. Active
|
||||
// Defer terminal transcript rows until their page loads. Active
|
||||
// turns must restore their lifecycle even before any items are available.
|
||||
if turn.status == TurnStatus::Completed
|
||||
if matches!(turn.status, TurnStatus::Completed | TurnStatus::Interrupted)
|
||||
&& turn.items_view == codex_app_server_protocol::TurnItemsView::NotLoaded
|
||||
&& turn.items.is_empty()
|
||||
{
|
||||
|
||||
@@ -24,6 +24,7 @@ use tokio::sync::mpsc::UnboundedReceiver;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
const STATUS: ConfigPanelStatus = ConfigPanelStatus {
|
||||
transcript_mode: crate::transcript_mode::TranscriptMode::Owned,
|
||||
copy_on_select: true,
|
||||
right_click_paste: Ok(false),
|
||||
custom_notification_filters: false,
|
||||
@@ -371,10 +372,17 @@ fn unavailable_right_click_paste_remains_editable() {
|
||||
AppEventSender::new(tx),
|
||||
RuntimeKeymap::defaults().list,
|
||||
ConfigPanelStatus {
|
||||
transcript_mode: crate::transcript_mode::TranscriptMode::Terminal,
|
||||
right_click_paste: Err("Unavailable in inline mode; use your terminal's paste command"),
|
||||
..STATUS
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
!panel
|
||||
.settings
|
||||
.iter()
|
||||
.any(|row| row.label == "Collapse turn details")
|
||||
);
|
||||
for _ in 0..4 {
|
||||
panel.handle_key_event(KeyCode::Right.into());
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ pub(crate) enum ResumeCurrentAvailability {
|
||||
/// Effective state the panel cannot derive from the `Tui` preferences alone.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct ConfigPanelStatus {
|
||||
pub(crate) transcript_mode: crate::transcript_mode::TranscriptMode,
|
||||
pub(crate) copy_on_select: bool,
|
||||
pub(crate) right_click_paste: Result<bool, &'static str>,
|
||||
pub(crate) custom_notification_filters: bool,
|
||||
@@ -273,7 +274,7 @@ fn settings(tui: &Tui, status: ConfigPanelStatus) -> Vec<ConfigSetting> {
|
||||
tui.notification_settings.notifications,
|
||||
Notifications::Enabled(false)
|
||||
);
|
||||
vec![
|
||||
let mut settings = vec![
|
||||
ConfigSetting {
|
||||
section: ConfigSection::Appearance,
|
||||
label: "Theme",
|
||||
@@ -445,7 +446,20 @@ fn settings(tui: &Tui, status: ConfigPanelStatus) -> Vec<ConfigSetting> {
|
||||
keywords: "clipboard mouse paste automatic",
|
||||
value: ConfigEditor::RightClickPaste.value(tui, status),
|
||||
},
|
||||
]
|
||||
];
|
||||
if status.transcript_mode.is_owned() {
|
||||
settings.push(ConfigSetting {
|
||||
section: ConfigSection::Sessions,
|
||||
label: "Collapse turn details",
|
||||
description: "Summarize tool calls in the conversation",
|
||||
keywords: "collapse_turn_details fullscreen transcript expand history",
|
||||
value: ConfigSettingValue::Toggle {
|
||||
setting: ConfigToggle::CollapseTurnDetails,
|
||||
enabled: ConfigToggle::CollapseTurnDetails.enabled(tui),
|
||||
},
|
||||
});
|
||||
}
|
||||
settings
|
||||
}
|
||||
|
||||
pub(crate) struct ConfigPanel {
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ expression: "render(&panel, 140)"
|
||||
Session picker layout Dense › Choose tighter rows or a roomier list when resuming and forking conversations
|
||||
Resume directory Ask › Choose which working directory to use when the current directory and saved session differ
|
||||
Model providers Choose › Choose the default provider for new chats on the connected server
|
||||
|
||||
Collapse turn details [ ] Off Summarize tool calls in the conversation
|
||||
|
||||
|
||||
left/right group · / search · enter toggle · esc clear search/close
|
||||
|
||||
@@ -14,6 +14,7 @@ use codex_config::types::Tui;
|
||||
pub(crate) enum ConfigToggle {
|
||||
Animations,
|
||||
AutomaticRecaps,
|
||||
CollapseTurnDetails,
|
||||
ComposerStars,
|
||||
CopyOnSelectClearSelection,
|
||||
Notifications,
|
||||
@@ -102,6 +103,7 @@ impl ConfigToggle {
|
||||
}
|
||||
Self::Animations => ("animations", enabled),
|
||||
Self::AutomaticRecaps => ("auto_recap", enabled),
|
||||
Self::CollapseTurnDetails => ("collapse_turn_details", enabled),
|
||||
Self::CopyOnSelectClearSelection => ("copy_on_select_clear_selection", enabled),
|
||||
Self::PasteDetection => ("disable_paste_burst", !enabled),
|
||||
Self::StartInVimMode => ("vim_mode_default", enabled),
|
||||
@@ -118,6 +120,7 @@ impl ConfigToggle {
|
||||
match self {
|
||||
Self::Animations => tui.animations,
|
||||
Self::AutomaticRecaps => tui.auto_recap,
|
||||
Self::CollapseTurnDetails => tui.collapse_turn_details,
|
||||
Self::ComposerStars => tui.effects.starfield,
|
||||
Self::CopyOnSelectClearSelection => tui.copy_on_select_clear_selection,
|
||||
Self::Notifications => !matches!(
|
||||
@@ -136,6 +139,9 @@ impl ConfigToggle {
|
||||
match self {
|
||||
Self::Animations => target.animations = source.animations,
|
||||
Self::AutomaticRecaps => target.auto_recap = source.auto_recap,
|
||||
Self::CollapseTurnDetails => {
|
||||
target.collapse_turn_details = source.collapse_turn_details
|
||||
}
|
||||
Self::ComposerStars => target.effects.starfield = source.effects.starfield,
|
||||
Self::CopyOnSelectClearSelection => {
|
||||
target.copy_on_select_clear_selection = source.copy_on_select_clear_selection;
|
||||
|
||||
@@ -292,6 +292,50 @@ impl HistoryCell for ExecCell {
|
||||
self.detailed_hyperlink_lines(width, HistoryRenderMode::Rich)
|
||||
}
|
||||
|
||||
fn tool_call_labels(&self) -> Vec<String> {
|
||||
self.iter_calls()
|
||||
.filter(|call| !call.is_user_shell_command())
|
||||
.map(|call| {
|
||||
let active = call.duration.is_none();
|
||||
let (running, finished, target) = match call.parsed.as_slice() {
|
||||
[ParsedCommand::Read { name, .. }] => ("Reading", "Read", name.clone()),
|
||||
[
|
||||
ParsedCommand::Search {
|
||||
query: Some(query),
|
||||
path,
|
||||
..
|
||||
},
|
||||
] => {
|
||||
let target = match path {
|
||||
Some(path) => format!("{query} in {path}"),
|
||||
None => query.clone(),
|
||||
};
|
||||
("Searching for", "Searched for", target)
|
||||
}
|
||||
[
|
||||
ParsedCommand::Search {
|
||||
path: Some(path), ..
|
||||
},
|
||||
] => ("Searching files in", "Searched files in", path.clone()),
|
||||
[ParsedCommand::ListFiles { path, .. }] => (
|
||||
"Listing files in",
|
||||
"Listed files in",
|
||||
path.as_deref().unwrap_or(".").to_owned(),
|
||||
),
|
||||
_ => ("Running", "Ran", strip_bash_lc_and_escape(&call.command)),
|
||||
};
|
||||
let verb = if active { running } else { finished };
|
||||
let mut label = format!("{verb} {target}");
|
||||
if let Some(output) = &call.output
|
||||
&& output.exit_code != 0
|
||||
{
|
||||
label.push_str(&format!(" (exit {})", output.exit_code));
|
||||
}
|
||||
label
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn activity_ids(&self) -> Vec<String> {
|
||||
self.iter_calls()
|
||||
.map(|call| format!("exec:{}", call.call_id))
|
||||
|
||||
@@ -258,6 +258,11 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
|
||||
}
|
||||
}
|
||||
|
||||
/// Output-free descriptions of individual tool invocations, in transcript order.
|
||||
fn tool_call_labels(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Stable, namespaced member identities used to retain disclosure across grouping and replay.
|
||||
/// Empty identities indicate ordinary content without a local disclosure control.
|
||||
fn activity_ids(&self) -> Vec<String> {
|
||||
|
||||
@@ -341,6 +341,13 @@ pub(crate) fn new_info_event(message: String, hint: Option<String>) -> PlainHist
|
||||
PlainHistoryCell { lines }
|
||||
}
|
||||
|
||||
pub(crate) fn new_interruption_notice() -> PlainHistoryCell {
|
||||
PlainHistoryCell::new(vec![
|
||||
Line::from("■ Conversation interrupted - use /feedback if something went wrong")
|
||||
.style(crate::style::secondary_text_style()),
|
||||
])
|
||||
}
|
||||
|
||||
pub(crate) fn new_error_event(message: String) -> PlainHistoryCell {
|
||||
// Use a hair space (U+200A) to create a subtle, near-invisible separation
|
||||
// before the text. VS16 is intentionally omitted to keep spacing tighter
|
||||
|
||||
@@ -77,6 +77,7 @@ impl LocalSettings {
|
||||
question_esc_back: config.tui_question_esc_back,
|
||||
raw_output_mode: config.tui_raw_output_mode,
|
||||
fullscreen_transcript: config.tui_fullscreen_transcript,
|
||||
collapse_turn_details: config.tui_collapse_turn_details,
|
||||
sidebar_width_percent: config.tui_sidebar_width_percent,
|
||||
mouse_scroll_speed: config.tui_mouse_scroll_speed,
|
||||
copy_on_select: config.tui_copy_on_select,
|
||||
|
||||
@@ -21,6 +21,7 @@ mod select_mode;
|
||||
mod selection;
|
||||
mod snapshot;
|
||||
mod text;
|
||||
mod turn_details;
|
||||
mod turn_tip;
|
||||
|
||||
use std::sync::Arc;
|
||||
@@ -124,6 +125,7 @@ pub(crate) struct TranscriptView {
|
||||
last_tail: Option<EntryKey>,
|
||||
last_click: Option<(std::time::Instant, u16, u16, u8)>,
|
||||
disclosure: disclosure::Disclosure,
|
||||
collapse_turn_details: bool,
|
||||
}
|
||||
|
||||
impl Default for TranscriptView {
|
||||
@@ -164,6 +166,7 @@ impl Default for TranscriptView {
|
||||
last_tail: None,
|
||||
last_click: None,
|
||||
disclosure: disclosure::Disclosure::default(),
|
||||
collapse_turn_details: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,6 +208,16 @@ impl TranscriptView {
|
||||
self.tail_visible = false;
|
||||
return;
|
||||
}
|
||||
if let Some(key) = self.disclosure.anchor_control.take()
|
||||
&& let Position::Reading(mut anchor) = self.position
|
||||
&& anchor.key == key
|
||||
&& let Some(layout) = self.layout(cells, anchor.index)
|
||||
&& let Some(row) = layout.disclosure_row()
|
||||
{
|
||||
anchor.offset = layout.position_at(row, /*column*/ 0);
|
||||
anchor.row_bias = layout.row_for_offset(anchor.offset) as isize - row as isize;
|
||||
self.position = Position::Reading(anchor);
|
||||
}
|
||||
let initial_start = self.start(cells);
|
||||
let initial_position = self.position;
|
||||
let mut start = initial_start;
|
||||
@@ -350,12 +363,12 @@ impl TranscriptView {
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
active: Option<&dyn HistoryCell>,
|
||||
auxiliary: &[HyperlinkLine],
|
||||
collapse_turn_details: bool,
|
||||
) {
|
||||
let width = area.width;
|
||||
let unseen_activity = self.unseen_activity;
|
||||
let ids = active.map_or_else(Vec::new, HistoryCell::activity_ids);
|
||||
let disclose = !ids.is_empty() && self.mode == HistoryRenderMode::Rich;
|
||||
let expanded = self.sync_live_activity(cells, ids);
|
||||
let (summary, expanded) = self.sync_live_tool_summary(cells, collapse_turn_details, active);
|
||||
let disclose = !self.disclosure.live_ids.is_empty() && self.mode == HistoryRenderMode::Rich;
|
||||
let mode = self.mode;
|
||||
let lines = |width| {
|
||||
let mut lines = active.map_or_else(Vec::new, |c| c.transcript_hyperlink_lines(width));
|
||||
@@ -368,6 +381,10 @@ impl TranscriptView {
|
||||
self.sync_search_live_tail(width, /*key*/ None, lines);
|
||||
if self.detailed {
|
||||
self.sync_live_tail(width, /*key*/ None, lines);
|
||||
} else if let Some(row) = summary {
|
||||
self.sync_live_layout(width, /*key*/ None, |width| {
|
||||
Some(row.layout(width, expanded, auxiliary.to_vec()))
|
||||
});
|
||||
} else {
|
||||
self.sync_live_activity_tail(width, /*key*/ None, expanded && disclose, |width| {
|
||||
let activity = active.map_or_else(Vec::new, |cell| {
|
||||
@@ -442,8 +459,8 @@ impl TranscriptView {
|
||||
self.live_separated = None;
|
||||
self.live_continuation = key.is_some_and(|key| key.is_stream_continuation);
|
||||
let live = layout(width).map(Arc::new);
|
||||
if self.live.as_ref().map(|layout| layout.text())
|
||||
!= live.as_ref().map(|layout| layout.text())
|
||||
if self.live.as_ref().map(|layout| layout.activity_content())
|
||||
!= live.as_ref().map(|layout| layout.activity_content())
|
||||
{
|
||||
if !self.is_following() && revision_changed && live.is_some() {
|
||||
self.unseen_activity = true;
|
||||
|
||||
@@ -20,7 +20,7 @@ impl TranscriptView {
|
||||
let key = cells.get(index).map_or(EntryKey::Live, EntryKey::cell);
|
||||
return self.visible.last().is_some_and(|visible| {
|
||||
visible.key == key
|
||||
&& visible.layout.text() == layout.text()
|
||||
&& visible.layout.activity_content() == layout.activity_content()
|
||||
&& visible.row + 1 == layout.row_count()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ pub(super) struct Disclosure {
|
||||
pub(super) expanded: HashSet<String>,
|
||||
pub(super) focused: Option<Vec<String>>,
|
||||
pub(super) resume_after_approval: Option<(ThreadId, String)>,
|
||||
// Resolve against the next layout, once a toggled live row has also been rebuilt.
|
||||
pub(super) anchor_control: Option<EntryKey>,
|
||||
pub(super) live_ids: Vec<String>,
|
||||
pub(super) keymap: RuntimeKeymap,
|
||||
}
|
||||
@@ -29,6 +31,7 @@ impl Default for Disclosure {
|
||||
expanded: HashSet::new(),
|
||||
focused: None,
|
||||
resume_after_approval: None,
|
||||
anchor_control: None,
|
||||
live_ids: Vec::new(),
|
||||
keymap: RuntimeKeymap::defaults(),
|
||||
}
|
||||
@@ -92,7 +95,10 @@ impl TranscriptView {
|
||||
pub(super) fn activity_ids(&self, cells: &[Arc<dyn HistoryCell>], index: usize) -> Vec<String> {
|
||||
cells.get(index).map_or_else(
|
||||
|| self.disclosure.live_ids.clone(),
|
||||
|cell| cell.activity_ids(),
|
||||
|cell| match self.tool_summary(cell.as_ref()) {
|
||||
Some(row) if !self.is_search_expanded(EntryKey::cell(cell)) => row.ids,
|
||||
_ => cell.activity_ids(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -103,6 +109,9 @@ impl TranscriptView {
|
||||
index: usize,
|
||||
) -> Arc<[String]> {
|
||||
let key = cells.get(index).map_or(EntryKey::Live, EntryKey::cell);
|
||||
if self.is_search_expanded(key) {
|
||||
return self.activity_ids(cells, index).into();
|
||||
}
|
||||
self.snapshot()
|
||||
.and_then(|snapshot| snapshot.activities.get(&key))
|
||||
.cloned()
|
||||
@@ -330,8 +339,16 @@ impl TranscriptView {
|
||||
}
|
||||
let key = self.entry_key(cells, index);
|
||||
let matched = self.is_search_expanded(key);
|
||||
self.disclosure.anchor_control = None;
|
||||
let previous_top = self.visible.first().map(|visible| {
|
||||
let offset = visible.layout.position_at(visible.row, /*column*/ 0);
|
||||
if !matched
|
||||
&& offset == 0
|
||||
&& visible.layout.disclosure_row() == Some(visible.row)
|
||||
&& visible.activity_ids.iter().any(|id| ids.contains(id))
|
||||
{
|
||||
self.disclosure.anchor_control = Some(key);
|
||||
}
|
||||
(
|
||||
visible.key,
|
||||
Arc::clone(&visible.activity_ids),
|
||||
|
||||
@@ -238,6 +238,13 @@ impl TranscriptView {
|
||||
}
|
||||
let mode = self.mode;
|
||||
let mut presentation = self.content_presentation(cell.as_ref(), full_content, mode);
|
||||
if !full_content && let Some(summary) = self.tool_summary(cell.as_ref()) {
|
||||
let expanded = self.disclosure.is_expanded(&summary.ids);
|
||||
if expanded {
|
||||
self.disclosure.expanded.extend(summary.ids.iter().cloned());
|
||||
}
|
||||
return Some(Arc::new(summary.layout(width, expanded, Vec::new())));
|
||||
}
|
||||
let shortcut = self
|
||||
.disclosure
|
||||
.keymap
|
||||
@@ -255,6 +262,9 @@ impl TranscriptView {
|
||||
}) {
|
||||
return layout.row_count() > 0;
|
||||
}
|
||||
if !full_content && self.tool_summary(previous.as_ref()).is_some() {
|
||||
return true;
|
||||
}
|
||||
let previous_presentation =
|
||||
self.content_presentation(previous.as_ref(), self.detailed, mode);
|
||||
self.cache
|
||||
|
||||
@@ -186,7 +186,7 @@ impl TextLayout {
|
||||
label: String,
|
||||
source_offset: usize,
|
||||
) -> Self {
|
||||
if self.text.is_empty() || label.is_empty() {
|
||||
if label.is_empty() {
|
||||
return self;
|
||||
}
|
||||
if let Some(control) = self.disclosure_control.take() {
|
||||
@@ -339,6 +339,16 @@ impl TextLayout {
|
||||
})
|
||||
}
|
||||
|
||||
/// A control label is visible activity even when its selectable source is empty.
|
||||
pub(super) fn activity_content(&self) -> (&str, Option<&str>) {
|
||||
(
|
||||
self.text(),
|
||||
self.disclosure_control
|
||||
.as_ref()
|
||||
.map(|control| control.label.as_str()),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn disclosure_row(&self) -> Option<usize> {
|
||||
self.disclosure_control.as_ref().map(|control| control.row)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Output-free tool summaries for the full-screen conversation.
|
||||
use super::*;
|
||||
|
||||
pub(crate) struct ToolSummary {
|
||||
pub(super) ids: Vec<String>,
|
||||
calls: Vec<String>,
|
||||
}
|
||||
|
||||
impl ToolSummary {
|
||||
pub(crate) fn layout(
|
||||
&self,
|
||||
width: u16,
|
||||
expanded: bool,
|
||||
auxiliary: Vec<HyperlinkLine>,
|
||||
) -> TextLayout {
|
||||
let mut lines = Vec::new();
|
||||
if expanded {
|
||||
for call in &self.calls {
|
||||
let call = call.replace(char::is_control, " ");
|
||||
let line = Line::from(format!(" {call}")).dim();
|
||||
lines.push(crate::history_cell::activity_preview::clipped_line(
|
||||
line, width,
|
||||
));
|
||||
}
|
||||
}
|
||||
lines.extend(auxiliary);
|
||||
let label = self.label();
|
||||
let arrow = if expanded {
|
||||
"▾"
|
||||
} else if self
|
||||
.calls
|
||||
.last()
|
||||
.is_some_and(|call| call.starts_with("Search"))
|
||||
{
|
||||
"⌕"
|
||||
} else {
|
||||
"▸"
|
||||
};
|
||||
TextLayout::new(lines, width)
|
||||
.with_disclosure_control_at(format!("{arrow} {label}"), /*source_offset*/ 0)
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
let calls = &self.calls;
|
||||
let mut lines = calls.last().into_iter().flat_map(|last| last.lines());
|
||||
let mut label = lines
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.replace(char::is_control, " ");
|
||||
if lines.next().is_some() {
|
||||
label.push_str(" …");
|
||||
}
|
||||
if calls.len() > 1 {
|
||||
return format!("{} tool calls · {label}", calls.len());
|
||||
}
|
||||
label
|
||||
}
|
||||
}
|
||||
|
||||
impl TranscriptView {
|
||||
pub(super) fn tool_summary(&self, cell: &dyn HistoryCell) -> Option<ToolSummary> {
|
||||
if !self.collapse_turn_details || self.detailed || self.mode != HistoryRenderMode::Rich {
|
||||
return None;
|
||||
}
|
||||
let calls = cell.tool_call_labels();
|
||||
if calls.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let ids = cell
|
||||
.activity_ids()
|
||||
.into_iter()
|
||||
.map(|id| format!("summary:{id}"))
|
||||
.collect();
|
||||
Some(ToolSummary { ids, calls })
|
||||
}
|
||||
|
||||
pub(crate) fn sync_live_tool_summary(
|
||||
&mut self,
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
enabled: bool,
|
||||
active: Option<&dyn HistoryCell>,
|
||||
) -> (Option<ToolSummary>, bool) {
|
||||
if self.collapse_turn_details != enabled {
|
||||
self.collapse_turn_details = enabled;
|
||||
self.selection = None;
|
||||
self.select_mode = None;
|
||||
self.copy_mode = None;
|
||||
self.disclosure.focused = None;
|
||||
self.live_key = None;
|
||||
self.cache.clear();
|
||||
self.release_live_reading();
|
||||
}
|
||||
let summary = active.and_then(|cell| self.tool_summary(cell));
|
||||
let ids = summary.as_ref().map_or_else(
|
||||
|| active.map_or_else(Vec::new, HistoryCell::activity_ids),
|
||||
|summary| summary.ids.clone(),
|
||||
);
|
||||
let expanded = self.sync_live_activity(cells, ids) && self.mode == HistoryRenderMode::Rich;
|
||||
(summary, expanded)
|
||||
}
|
||||
}
|
||||
@@ -1362,3 +1362,77 @@ fn spoken_prompt_selection_keeps_its_fallback_contrast() {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn parked_screen(
|
||||
view: &mut TranscriptView,
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
active: Option<&dyn HistoryCell>,
|
||||
) -> String {
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 8,
|
||||
);
|
||||
let mut buf = Buffer::empty(area);
|
||||
view.render_parked(
|
||||
area,
|
||||
&mut buf,
|
||||
cells,
|
||||
active,
|
||||
&[],
|
||||
/*collapse_turn_details*/ true,
|
||||
);
|
||||
text(&buf).trim_end().to_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_search_group_keeps_its_summary_in_the_parked_pane() {
|
||||
use codex_app_server_protocol::CommandExecutionSource;
|
||||
use codex_shell_command::parse_command::parse_command;
|
||||
let search = |query: &str| ["rg", "-l", query, "src"].map(str::to_owned).to_vec();
|
||||
let first_call = search("TODO");
|
||||
let second_call = search("FIXME");
|
||||
let mut command = crate::exec_cell::new_active_exec_command(
|
||||
"first".into(),
|
||||
first_call.clone(),
|
||||
parse_command(&first_call),
|
||||
CommandExecutionSource::Agent,
|
||||
/*interaction_input*/ None,
|
||||
/*animations_enabled*/ false,
|
||||
);
|
||||
command.append_output("first", "private output");
|
||||
let mut view = TranscriptView::default();
|
||||
let first = parked_screen(&mut view, &[], Some(&command));
|
||||
view.scroll(&[], /*rows*/ 0); // Pin the visible live row.
|
||||
assert!(command.add_call(
|
||||
"second".into(),
|
||||
second_call.clone(),
|
||||
parse_command(&second_call),
|
||||
CommandExecutionSource::Agent,
|
||||
/*interaction_input*/ None
|
||||
));
|
||||
let (summary, expanded) =
|
||||
view.sync_live_tool_summary(&[], /*enabled*/ true, Some(&command));
|
||||
view.sync_live_layout(/*width*/ 40, /*key*/ None, |width| {
|
||||
Some(summary.unwrap().layout(width, expanded, Vec::new()))
|
||||
});
|
||||
assert!(view.unseen_activity);
|
||||
assert!(!view.current_tail_is_visible(&[]));
|
||||
view.jump_to_latest();
|
||||
let grouped = parked_screen(&mut view, &[], Some(&command));
|
||||
assert!(view.toggle_disclosure_at(&[], /*column*/ 4, /*row*/ 0));
|
||||
let opened = parked_screen(&mut view, &[], Some(&command));
|
||||
assert!(view.toggle_disclosure_at(&[], /*column*/ 4, /*row*/ 0));
|
||||
assert_eq!(parked_screen(&mut view, &[], Some(&command)), grouped);
|
||||
let cells: &[Arc<dyn HistoryCell>] = &[Arc::new(command)];
|
||||
assert_eq!(parked_screen(&mut view, cells, /*active*/ None), grouped);
|
||||
view.jump_to_latest();
|
||||
view.handle_key(KeyCode::F(4).into(), cells);
|
||||
view.handle_key(KeyCode::Enter.into(), cells);
|
||||
assert_eq!(parked_screen(&mut view, cells, /*active*/ None), opened);
|
||||
insta::assert_snapshot!(format!("{first}\n{grouped}\n{opened}"), @r"
|
||||
⌕ Searching for TODO in src
|
||||
⌕ 2 tool calls · Searching for FIXME
|
||||
▾ 2 tool calls · Searching for FIXME
|
||||
Searching for TODO in src
|
||||
Searching for FIXME in src
|
||||
");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user