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:
Eric Traut
2026-11-10 08:34:17 +00:00
committed by copyberry
parent 36e074a2fb
commit fdb8be1485
32 changed files with 528 additions and 92 deletions
+4
View File
@@ -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))]
+5
View File
@@ -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": [
{
+2
View File
@@ -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(),
+2
View File
@@ -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(),
+28 -17
View File
@@ -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]))],
);
}
+12 -4
View File
@@ -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| {
+17 -4
View File
@@ -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,
+2
View File
@@ -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()),
+1 -6
View File
@@ -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(),
});
}
}
+2 -2
View File
@@ -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());
}
+16 -2
View File
@@ -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 {
@@ -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
+6
View File
@@ -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;
+44
View File
@@ -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))
+5
View File
@@ -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> {
+7
View File
@@ -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
+1
View File
@@ -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,
+22 -5
View File
@@ -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;
+1 -1
View File
@@ -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()
});
}
+18 -1
View File
@@ -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
+11 -1
View File
@@ -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)
}
}
+74
View File
@@ -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
");
}