Extend output-free transcript summaries to more tools (#60555)

## What changed

- Add summary labels for MCP and dynamic tools, computer activity, terminal interactions, file edits, images, plan updates, agent calls, and historical tool fallbacks.
- Keep tool output out of summaries and give summaries disclosure identities even when their cells have no activity IDs.
- Bring focused disclosure headers into view and show `expand` or `collapse` in the footer, including for search-expanded entries.

## Testing

Add assertions for tool labels, a transcript snapshot verifying that image-generation output stays hidden, and coverage for expanded and collapsed footer hints.

GitOrigin-RevId: 076b8d74c17f26b10aa8221c65b8b38dbb5ad5ea
This commit is contained in:
Eric Traut
2026-11-10 08:34:50 +00:00
committed by copyberry
parent fdb8be1485
commit 7539b0a856
22 changed files with 250 additions and 52 deletions
@@ -105,7 +105,7 @@ impl ChatWidget {
}
}
pub(super) fn on_collab_event(&mut self, cell: PlainHistoryCell) {
pub(super) fn on_collab_event(&mut self, cell: impl HistoryCell + 'static) {
self.flush_answer_stream_with_separator();
self.add_to_history(cell);
self.request_redraw();
+31
View File
@@ -2,6 +2,7 @@
//! Wrapped prefixes remain display-only while annotated rows retain their logical source.
use super::*;
use itertools::Itertools;
#[derive(Debug)]
pub(crate) struct PlainHistoryCell {
@@ -12,6 +13,16 @@ impl PlainHistoryCell {
pub(crate) fn new(lines: Vec<Line<'static>>) -> Self {
Self { lines }
}
/// Only invocation lines belong in a tool summary.
pub(crate) fn with_tool_header(self, count: usize) -> LabeledToolCell {
let label = self.lines.iter().take(count).join(" · ");
self.with_tool_label(label)
}
pub(crate) fn with_tool_label(self, label: String) -> LabeledToolCell {
LabeledToolCell { cell: self, label }
}
}
impl HistoryCell for PlainHistoryCell {
@@ -24,6 +35,26 @@ impl HistoryCell for PlainHistoryCell {
}
}
#[derive(Debug)]
pub(crate) struct LabeledToolCell {
cell: PlainHistoryCell,
label: String,
}
impl HistoryCell for LabeledToolCell {
fn tool_call_labels(&self) -> Vec<String> {
vec![self.label.clone()]
}
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
self.cell.display_lines(width)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
self.cell.raw_lines()
}
}
#[derive(Debug)]
pub(crate) struct WebHyperlinkHistoryCell {
lines: Vec<HyperlinkLine>,
@@ -119,6 +119,14 @@ fn error_preview(call: &McpToolCallCell) -> Option<&str> {
}
impl HistoryCell for ComputerActivityCell {
fn tool_call_labels(&self) -> Vec<String> {
self.group
.calls
.iter()
.flat_map(HistoryCell::tool_call_labels)
.collect()
}
fn append_reasoning(&mut self, cell: Box<dyn HistoryCell>) -> Result<(), Box<dyn HistoryCell>> {
if self.group.calls.is_empty() {
Err(cell)
+13
View File
@@ -173,6 +173,19 @@ impl DynamicToolCallData {
}
impl HistoryCell for DynamicToolCallCell {
fn tool_call_labels(&self) -> Vec<String> {
let data = self
.data
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
vec![
data.header()
.to_string()
.trim_start_matches("• ")
.to_owned(),
]
}
fn has_stable_transcript_height(&self) -> bool {
false
}
@@ -52,6 +52,9 @@ fn dynamic_status_and_output_match_persisted_presentations() {
);
assert_eq!(replayed.len(), 1);
let cell = &replayed[0];
if label == "success" {
assert_eq!(cell.tool_call_labels(), ["Called example.inspect"]);
}
for (mode, lines) in [
("compact", cell.display_lines(/*width*/ 80)),
(
+13
View File
@@ -24,6 +24,19 @@ impl UnifiedExecInteractionCell {
}
impl HistoryCell for UnifiedExecInteractionCell {
fn tool_call_labels(&self) -> Vec<String> {
let action = if self.stdin.is_empty() {
"Waited for terminal"
} else {
"Sent terminal input"
};
vec![
self.command_display
.as_ref()
.map_or_else(|| action.to_string(), |cmd| format!("{action} · {cmd}")),
]
}
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
Vec::new()
}
+9
View File
@@ -356,6 +356,15 @@ impl McpToolCallCell {
}
impl HistoryCell for McpToolCallCell {
fn tool_call_labels(&self) -> Vec<String> {
let verb = match self.success() {
Some(true) => "Called",
Some(false) => "Failed",
None => "Calling",
};
vec![format!("{verb} {}", self.compact_tool_name())]
}
fn activity_ids(&self) -> Vec<String> {
vec![format!("mcp:{}", self.call_id)]
}
+13 -11
View File
@@ -17,6 +17,18 @@ use std::borrow::Cow;
use std::collections::VecDeque;
impl McpToolCallCell {
pub(super) fn compact_tool_name(&self) -> String {
self.invocation
.arguments
.as_ref()
.filter(|_| self.result_kind() == McpResultKind::NodeRepl)
.and_then(|arguments| arguments.get("title"))
.and_then(serde_json::Value::as_str)
.map(|title| title.split_whitespace().collect::<Vec<_>>().join(" "))
.filter(|title| !title.is_empty())
.unwrap_or_else(|| format!("{}.{}", self.invocation.server, self.invocation.tool))
}
pub(super) fn compact_mcp_lines(&self, width: u16) -> Vec<HyperlinkLine> {
let status = self.success();
let (marker, verb) = match status {
@@ -32,23 +44,13 @@ impl McpToolCallCell {
"Calling",
),
};
let title = self
.invocation
.arguments
.as_ref()
.filter(|_| self.result_kind() == McpResultKind::NodeRepl)
.and_then(|arguments| arguments.get("title"))
.and_then(serde_json::Value::as_str)
.map(|title| title.split_whitespace().collect::<Vec<_>>().join(" "))
.filter(|title| !title.is_empty())
.unwrap_or_else(|| format!("{}.{}", self.invocation.server, self.invocation.tool));
let mut lines = vec![clipped_line(
Line::from(vec![
marker,
" ".into(),
verb.bold(),
" ".into(),
title.fg(accent_color()),
self.compact_tool_name().fg(accent_color()),
]),
width,
)];
@@ -154,6 +154,7 @@ fn mcp_preview_shares_one_limit_across_blocks_and_preserves_transcript() {
json!({"type": "text", "text": "third\nfourth\nfifth"}),
])),
);
assert_eq!(cell.tool_call_labels(), ["Called search.lookup"]);
let display = cell.display_lines(/*width*/ 80);
let transcript = cell.transcript_lines(/*width*/ 80);
insta::assert_snapshot!(format!(
@@ -541,6 +542,7 @@ fn titled_image_call_keeps_error_and_full_title_when_narrow() {
},
/*animations_enabled*/ false,
);
assert_eq!(cell.tool_call_labels(), [format!("Calling {title}")]);
assert_eq!(
cell.display_lines(/*width*/ 80)[0].to_string(),
format!("• {title}")
@@ -571,6 +573,8 @@ fn titled_image_call_keeps_error_and_full_title_when_narrow() {
.to_string()
.contains(title)
);
cell.invocation.arguments = Some(json!({"title": " "}));
assert_eq!(cell.tool_call_labels(), ["Failed node_repl.js"]);
}
/// Reconstruct only from explicit provenance, checking every displayed source slice on the way.
+36 -2
View File
@@ -26,6 +26,20 @@ impl PatchHistoryCell {
}
impl HistoryCell for PatchHistoryCell {
fn tool_call_labels(&self) -> Vec<String> {
let mut paths = self.changes.keys();
let label = match (paths.next(), paths.next()) {
(Some(path), None) => format!(
"Edited {}",
path.file_name()
.unwrap_or(path.as_os_str())
.to_string_lossy()
),
_ => "Edited files".into(),
};
vec![label]
}
fn activity_ids(&self) -> Vec<String> {
vec![self.activity_id.clone()]
}
@@ -92,6 +106,10 @@ pub(crate) struct PatchFailureCell {
}
impl HistoryCell for PatchFailureCell {
fn tool_call_labels(&self) -> Vec<String> {
vec!["Failed to apply patch".into()]
}
fn activity_ids(&self) -> Vec<String> {
vec![self.activity_id.clone()]
}
@@ -178,6 +196,10 @@ impl ViewImageHistoryCell {
}
impl HistoryCell for ViewImageHistoryCell {
fn tool_call_labels(&self) -> Vec<String> {
vec![format!("Viewed image {}", self.path_label)]
}
fn activity_ids(&self) -> Vec<String> {
vec![self.activity_id.clone()]
}
@@ -259,7 +281,8 @@ pub(crate) fn new_image_generation_call(
saved_path: Option<AbsolutePathBuf>,
) -> ImageGenerationHistoryCell {
let detail = revised_prompt.unwrap_or(call_id);
let heading = if status == "failed" {
let failed = status == "failed";
let heading = if failed {
vec!["✗ ".red().bold(), "Image generation failed".bold()].into()
} else {
vec!["• ".dim(), "Generated Image:".bold()].into()
@@ -274,17 +297,28 @@ pub(crate) fn new_image_generation_call(
ImageGenerationHistoryCell {
lines,
saved_path: saved_path.filter(|_| status != "failed"),
failed,
saved_path: saved_path.filter(|_| !failed),
}
}
#[derive(Debug)]
pub(crate) struct ImageGenerationHistoryCell {
failed: bool,
lines: Vec<Line<'static>>,
saved_path: Option<AbsolutePathBuf>,
}
impl HistoryCell for ImageGenerationHistoryCell {
fn tool_call_labels(&self) -> Vec<String> {
let label = if self.failed {
"Image generation failed"
} else {
"Generated image"
};
vec![label.into()]
}
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
self.lines.clone()
}
@@ -21,6 +21,7 @@ fn compact_patches_retain_full_changes_and_failure_details() {
]),
Path::new("."),
);
assert_eq!(patch.tool_call_labels(), ["Edited files"]);
let failure = new_patch_apply_failure("first\nsecond\nthird\nfourth diagnostic".into());
let missing = new_patch_apply_failure(String::new());
let mut snapshots = Vec::new();
+4
View File
@@ -239,6 +239,10 @@ pub(crate) struct PlanUpdateCell {
}
impl HistoryCell for PlanUpdateCell {
fn tool_call_labels(&self) -> Vec<String> {
vec!["Updated plan".into()]
}
fn activity_ids(&self) -> Vec<String> {
vec![self.activity_id.clone()]
}
+25 -8
View File
@@ -4,6 +4,7 @@
//! picker entries, and the fast-switch keyboard shortcuts. Higher-level coordination, such as
//! deciding which thread becomes active or when a thread closes, stays in [`crate::app::App`].
use crate::history_cell::LabeledToolCell;
use crate::history_cell::PlainHistoryCell;
use crate::render::line_utils::prefix_lines;
use crate::style::accent_color;
@@ -205,7 +206,7 @@ pub(crate) fn tool_call_history_cell(
item: &ThreadItemPayload,
cached_spawn_request: Option<&SpawnRequestSummary>,
mut agent_metadata: impl FnMut(ThreadId) -> AgentMetadata,
) -> Option<PlainHistoryCell> {
) -> Option<LabeledToolCell> {
let ThreadItemPayload::CollabAgentToolCall {
tool,
status,
@@ -282,6 +283,12 @@ pub(crate) fn tool_call_history_cell(
.map(|receiver_thread_id| close_end(receiver_thread_id, &mut agent_metadata))
}
}
.map(|cell| match (tool, status) {
(CollabAgentTool::ResumeAgent, CollabAgentToolCallStatus::Failed) => {
cell.with_tool_label("Failed to resume agent".into())
}
_ => cell.with_tool_header(/*count*/ 1),
})
}
pub(crate) fn sub_agent_activity_display(
@@ -928,16 +935,28 @@ mod tests {
#[test]
fn collab_resume_interrupted_snapshot() {
use CollabAgentToolCallStatus::Completed;
use CollabAgentToolCallStatus::Failed;
let cell = resume_cell(Completed, CollabAgentStatus::Interrupted);
assert_snapshot!("collab_resume_interrupted", cell_to_text(&cell));
let missing = resume_cell(Failed, CollabAgentStatus::NotFound);
assert_eq!(missing.tool_call_labels(), ["Failed to resume agent"]);
}
fn resume_cell(
status: CollabAgentToolCallStatus,
agent_status: CollabAgentStatus,
) -> LabeledToolCell {
let sender_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000001")
.expect("valid sender thread id");
let robie_id = ThreadId::from_string("00000000-0000-0000-0000-000000000002")
.expect("valid robie thread id");
let cell = tool_call_history_cell(
tool_call_history_cell(
&ThreadItemPayload::CollabAgentToolCall {
id: "call-resume".to_string(),
tool: CollabAgentTool::ResumeAgent,
status: CollabAgentToolCallStatus::Completed,
status,
sender_thread_id: sender_thread_id.to_string(),
receiver_thread_ids: vec![robie_id.to_string()],
prompt: None,
@@ -945,15 +964,13 @@ mod tests {
reasoning_effort: None,
agents_states: HashMap::from([(
robie_id.to_string(),
agent_state(CollabAgentStatus::Interrupted, /*message*/ None),
agent_state(agent_status, /*message*/ None),
)]),
},
/*cached_spawn_request*/ None,
|thread_id| metadata_for(thread_id, robie_id, ThreadId::new()),
)
.expect("resume item renders");
assert_snapshot!("collab_resume_interrupted", cell_to_text(&cell));
.expect("resume item renders")
}
fn agent_state(status: CollabAgentStatus, message: Option<&str>) -> CollabAgentState {
@@ -979,7 +996,7 @@ mod tests {
}
}
fn cell_to_text(cell: &PlainHistoryCell) -> String {
fn cell_to_text(cell: &impl HistoryCell) -> String {
cell.display_lines(/*width*/ 200)
.iter()
.map(line_to_text)
+13 -2
View File
@@ -27,7 +27,9 @@ use ratatui::text::Line;
/// Preserve status and output when replay cannot reconstruct a rich completed tool cell.
/// Declined commands never ran, and historical terminal interactions do not carry stdin details.
/// Pending items retain their last known state without starting a running clock.
pub(crate) fn historical_tool_fallback(item: &ThreadItemPayload) -> Option<PlainHistoryCell> {
pub(crate) fn historical_tool_fallback(
item: &ThreadItemPayload,
) -> Option<crate::history_cell::LabeledToolCell> {
let lines = match item {
ThreadItemPayload::CommandExecution {
command,
@@ -108,7 +110,16 @@ pub(crate) fn historical_tool_fallback(item: &ThreadItemPayload) -> Option<Plain
}
_ => return None,
};
Some(PlainHistoryCell::new(lines))
let cell = PlainHistoryCell::new(lines);
Some(match item {
ThreadItemPayload::CommandExecution {
status: CommandExecutionStatus::Declined,
command,
..
} => cell.with_tool_label(format!("Declined · {command}")),
ThreadItemPayload::CommandExecution { .. } => cell.with_tool_header(/*count*/ 2),
_ => cell.with_tool_header(/*count*/ 1),
})
}
// The protocol joins argv with shlex. Keep noncanonical legacy commands verbatim.
@@ -229,6 +229,9 @@ fn historical_command_fallbacks_preserve_status_output_and_group_boundaries() {
/*config*/ None,
);
assert_eq!(cells.len(), 3);
if label == "declined" {
assert_eq!(cells[1].tool_call_labels(), ["Declined · cargo check"]);
}
for cell in [&cells[0], &cells[2]] {
assert!(
cell.as_any()
+25 -19
View File
@@ -17,7 +17,7 @@ use super::*;
pub(super) struct Disclosure {
pub(super) expanded: HashSet<String>,
pub(super) focused: Option<Vec<String>>,
pub(super) focused: Option<(EntryKey, 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>,
@@ -95,7 +95,7 @@ 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| match self.tool_summary(cell.as_ref()) {
|cell| match self.tool_summary(cell.as_ref(), EntryKey::cell(cell)) {
Some(row) if !self.is_search_expanded(EntryKey::cell(cell)) => row.ids,
_ => cell.activity_ids(),
},
@@ -130,7 +130,7 @@ impl TranscriptView {
.disclosure
.focused
.as_ref()
.is_some_and(|focused| ids.iter().any(|id| focused.contains(id)));
.is_some_and(|(_, focused)| ids.iter().any(|id| focused.contains(id)));
if !focused || !layout.disclosure {
return;
}
@@ -214,7 +214,7 @@ impl TranscriptView {
.disclosure
.focused
.as_ref()
.and_then(|ids| self.canonical_activity_index(cells, ids));
.and_then(|(_, ids)| self.canonical_activity_index(cells, ids));
let Some(focused) = focused else {
self.disclosure.focused = None;
return None;
@@ -313,21 +313,24 @@ impl TranscriptView {
}
fn focus_activity(&mut self, cells: &[Arc<dyn HistoryCell>], index: usize) -> bool {
if self
.current_layout(cells, index)
.is_none_or(|layout| !layout.disclosure)
{
let Some(layout) = self.layout(cells, index) else {
return false;
};
if !layout.disclosure {
return false;
}
let ids = self.activity_ids(cells, index);
let header_visible = self.visible.iter().any(|row| {
row.layout.disclosure
&& row.activity_ids.iter().any(|id| ids.contains(id))
&& row.row == usize::from(row.layout.separated)
let row = usize::from(layout.separated);
let visible = self.visible.iter().any(|visible| {
visible.row == row && visible.activity_ids.iter().any(|id| ids.contains(id))
});
self.disclosure.focused = Some(ids);
if !header_visible {
self.disclosure.focused = Some((self.entry_key(cells, index), ids));
if !visible {
self.jump_to_entry(cells, index);
if let Position::Reading(anchor) = &mut self.position {
anchor.offset = layout.position_at(row, /*column*/ 0);
anchor.row_bias = layout.row_for_offset(anchor.offset) as isize - row as isize;
}
}
true
}
@@ -389,7 +392,7 @@ impl TranscriptView {
} else {
self.disclosure.expanded.extend(ids.iter().cloned());
}
self.disclosure.focused = Some(ids);
self.disclosure.focused = Some((key, ids));
self.position = Position::Reading(anchor);
self.cache.clear();
self.live_key = None;
@@ -400,14 +403,17 @@ impl TranscriptView {
}
pub(super) fn disclosure_footer(&self, width: u16) -> Option<TranscriptFooter> {
if !self.is_activity_focused() {
return None;
}
let (key, ids) = self.disclosure.focused.as_ref()?;
let toggle = if self.disclosure.is_expanded(ids) || self.is_search_expanded(*key) {
"collapse"
} else {
"expand"
};
let keymap = &self.disclosure.keymap;
let hints: Vec<_> = [
(ListAction::MoveUp, "previous"),
(ListAction::MoveDown, "next"),
(ListAction::Accept, "details"),
(ListAction::Accept, toggle),
(ListAction::Cancel, "back"),
]
.into_iter()
@@ -522,6 +522,8 @@ fn activity_focus_keeps_controls_without_passive_hints() {
assert!(view.disclosure.is_expanded(&["sample-activity".to_owned()]));
let expanded = render_live(&mut view);
hints.push(format!("expanded\n{expanded:?}"));
let footer = view.footer(/*width*/ 80, MotionMode::Reduced).unwrap();
hints.push(format!("expanded, 80 columns: {}", footer.text));
let hidden = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 32, /*height*/ 0,
);
+4 -2
View File
@@ -238,7 +238,9 @@ 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()) {
if !full_content
&& let Some(summary) = self.tool_summary(cell.as_ref(), EntryKey::cell(cell))
{
let expanded = self.disclosure.is_expanded(&summary.ids);
if expanded {
self.disclosure.expanded.extend(summary.ids.iter().cloned());
@@ -262,7 +264,7 @@ impl TranscriptView {
}) {
return layout.row_count() > 0;
}
if !full_content && self.tool_summary(previous.as_ref()).is_some() {
if !full_content && self.tool_summary(previous.as_ref(), key).is_some() {
return true;
}
let previous_presentation =
@@ -1149,7 +1149,7 @@ fn find_expands_only_the_match_and_preserves_manual_disclosures() {
insta::assert_snapshot!(hints.join("\n"), @"
↑ Loading earlier messages… · esc latest
Retry history: ⌥</⌃home. esc latest
↑ previous · ↓ next · enter details · esc back
↑ previous · ↓ next · enter expand · esc back
");
view.handle_key(KeyCode::Enter.into(), &cells);
view.handle_key(KeyCode::Esc.into(), &cells);
@@ -1163,6 +1163,13 @@ fn find_expands_only_the_match_and_preserves_manual_disclosures() {
view.handle_key(KeyCode::F(4).into(), &cells);
view.handle_key(KeyCode::End.into(), &cells);
view.handle_key(KeyCode::Up.into(), &cells);
assert!(
view.footer(/*width*/ 80, crate::motion::MotionMode::Reduced)
.unwrap()
.text
.to_string()
.contains("enter collapse")
);
for (key, expanded) in [
(KeyCode::Right, true),
(KeyCode::Left, false),
@@ -1,6 +1,5 @@
---
source: tui/src/transcript_view/footer_tests.rs
assertion_line: 515
expression: "hints.join(\"\\n\")"
---
collapsed
@@ -37,8 +36,8 @@ Buffer {
]
}
focused, 20 columns: esc back
focused, 32 columns: enter details · esc back
focused, 80 columns: ↑ previous · ↓ next · enter details · esc back
focused, 32 columns: ↓ next · enter expand · esc back
focused, 80 columns: ↑ previous · ↓ next · enter expand · esc back
expanded
Buffer {
area: Rect { x: 0, y: 0, width: 32, height: 3 },
@@ -55,6 +54,7 @@ Buffer {
x: 18, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
]
}
expanded, 80 columns: ↑ previous · ↓ next · enter collapse · esc back
"ctrl-x t": <composer hints>
["ctrl-x t","f4"]: <composer hints>
[]: <composer hints>
@@ -58,7 +58,11 @@ impl ToolSummary {
}
impl TranscriptView {
pub(super) fn tool_summary(&self, cell: &dyn HistoryCell) -> Option<ToolSummary> {
pub(super) fn tool_summary(
&self,
cell: &dyn HistoryCell,
key: EntryKey,
) -> Option<ToolSummary> {
if !self.collapse_turn_details || self.detailed || self.mode != HistoryRenderMode::Rich {
return None;
}
@@ -66,11 +70,14 @@ impl TranscriptView {
if calls.is_empty() {
return None;
}
let ids = cell
let mut ids: Vec<_> = cell
.activity_ids()
.into_iter()
.map(|id| format!("summary:{id}"))
.collect();
if key != EntryKey::Live || ids.is_empty() {
ids.push(format!("work:{key:?}"));
}
Some(ToolSummary { ids, calls })
}
@@ -90,7 +97,7 @@ impl TranscriptView {
self.cache.clear();
self.release_live_reading();
}
let summary = active.and_then(|cell| self.tool_summary(cell));
let summary = active.and_then(|cell| self.tool_summary(cell, EntryKey::Live));
let ids = summary.as_ref().map_or_else(
|| active.map_or_else(Vec::new, HistoryCell::activity_ids),
|summary| summary.ids.clone(),
+21
View File
@@ -1383,6 +1383,27 @@ fn parked_screen(
text(&buf).trim_end().to_owned()
}
#[test]
fn tool_disclosure_hides_output() {
let image = crate::history_cell::new_image_generation_call(
"image".into(),
"failed",
Some("private prompt".into()),
/*saved_path*/ None,
);
let cells: &[Arc<dyn HistoryCell>] = &[Arc::new(image)];
let mut view = TranscriptView::default();
let collapsed = parked_screen(&mut view, cells, /*active*/ None);
view.handle_key(KeyCode::F(4).into(), cells);
view.handle_key(KeyCode::Enter.into(), cells);
let expanded = parked_screen(&mut view, cells, /*active*/ None);
insta::assert_snapshot!(format!("{collapsed}\n{expanded}"), @r"
▸ Image generation failed
▾ Image generation failed
Image generation failed
");
}
#[test]
fn live_search_group_keeps_its_summary_in_the_parked_pane() {
use codex_app_server_protocol::CommandExecutionSource;