From 370b2b1bd4ae4e6ada9ac1fdc4010f3cf224edfb Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Mon, 31 Aug 2026 20:54:03 +0800 Subject: [PATCH 1/2] fix(tui): preserve thinking and tool order --- crates/tui/src/chatwidget/text_stream.rs | 17 +++-- crates/tui/src/chatwidget/transcript_sync.rs | 2 +- crates/tui/src/chatwidget_tests.rs | 65 ++++++++++++++++++++ crates/tui/src/transcript/lifecycle.rs | 2 + crates/tui/src/transcript/projector.rs | 34 ++++++++-- crates/tui/src/worker.rs | 2 + crates/tui/src/worker/item_dispatch.rs | 2 +- crates/tui/src/worker/native_items.rs | 38 ++++++++++-- crates/tui/src/worker/tool_lifecycle.rs | 48 ++++++++++++++- crates/tui/src/worker_event_test_helpers.rs | 8 ++- 10 files changed, 199 insertions(+), 19 deletions(-) diff --git a/crates/tui/src/chatwidget/text_stream.rs b/crates/tui/src/chatwidget/text_stream.rs index 330bae16..32bc47b8 100644 --- a/crates/tui/src/chatwidget/text_stream.rs +++ b/crates/tui/src/chatwidget/text_stream.rs @@ -70,7 +70,11 @@ impl ChatWidget { self.flush_active_cell(); let item_id = self.legacy_text_item_id(kind); if !self.transcript_projector.has_live_text(item_id) { - self.apply_item_lifecycle(ItemLifecycleEvent::TextStarted { item_id, kind }); + self.apply_item_lifecycle(ItemLifecycleEvent::TextStarted { + item_id, + kind, + item_seq: None, + }); } self.apply_item_lifecycle(ItemLifecycleEvent::TextDelta { item_id, @@ -128,7 +132,12 @@ impl ChatWidget { self.frame_requester.schedule_frame(); } - pub(super) fn start_text_item(&mut self, item_id: ActiveTextItemId, kind: TextItemKind) { + pub(super) fn start_text_item( + &mut self, + item_id: ActiveTextItemId, + kind: TextItemKind, + seq: u64, + ) { if self .active_text_items .iter() @@ -141,7 +150,6 @@ impl ChatWidget { self.commit_completed_assistant_before_next_reasoning(); } - let seq = self.reserve_seq(); let insert_index = self.active_text_item_insert_index(kind); tracing::debug!( item_id = %item_id.log_label(), @@ -232,7 +240,8 @@ impl ChatWidget { return index; } - self.start_text_item(item_id, kind); + let seq = self.reserve_seq(); + self.start_text_item(item_id, kind, seq); self.active_text_items .iter() .position(|item| item.item_id == item_id) diff --git a/crates/tui/src/chatwidget/transcript_sync.rs b/crates/tui/src/chatwidget/transcript_sync.rs index 83d97a38..945a2bd0 100644 --- a/crates/tui/src/chatwidget/transcript_sync.rs +++ b/crates/tui/src/chatwidget/transcript_sync.rs @@ -152,7 +152,7 @@ impl ChatWidget { .extend(cell.iter_calls().map(|call| call.call_id.clone())); self.active_cell = None; } - self.start_text_item(item_id, live.kind); + self.start_text_item(item_id, live.kind, live.seq); } self.sync_live_text_item(item_id); diff --git a/crates/tui/src/chatwidget_tests.rs b/crates/tui/src/chatwidget_tests.rs index bdc91fd9..404b572a 100644 --- a/crates/tui/src/chatwidget_tests.rs +++ b/crates/tui/src/chatwidget_tests.rs @@ -11040,6 +11040,71 @@ fn merged_explored_group_becomes_explored_after_all_results_arrive() { ); } +#[test] +fn live_tool_order_stays_before_reasoning_after_reasoning_completes() { + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); + let _ = widget.drain_scrollback_lines(100); + + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-1".to_string(), + "Web Search(\"query\")".to_string(), + false, + None, + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "tool-1".to_string(), + "Web Search(\"query\")".to_string(), + "status: completed".to_string(), + false, + false, + )); + let reasoning_id = devo_core::ItemId::new(); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_started( + reasoning_id, + TextItemKind::Reasoning, + )); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_delta( + reasoning_id, + TextItemKind::Reasoning, + "thinking body", + )); + + let live = line_texts(widget.active_viewport_lines_for_test(100)).join("\n"); + let tool_position = live + .find("Web Search(\"query\")") + .expect("live tool row should render"); + let thinking_position = live + .find("Thinking: thinking body") + .expect("live reasoning row should render"); + assert!( + tool_position < thinking_position, + "tool should stay above live reasoning:\n{live}" + ); + + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_completed( + reasoning_id, + TextItemKind::Reasoning, + "thinking body", + )); + + let transcript = line_texts(widget.transcript_overlay_lines(100)).join("\n"); + let tool_position = transcript + .find("Web Search(\"query\")") + .expect("tool row should remain in transcript"); + let thought_position = transcript + .find("Thought: thinking body") + .expect("completed reasoning row should render"); + assert!( + tool_position < thought_position, + "tool should stay above completed reasoning:\n{transcript}" + ); +} + #[test] fn live_viewport_shows_explored_group_while_active() { let model = Model { diff --git a/crates/tui/src/transcript/lifecycle.rs b/crates/tui/src/transcript/lifecycle.rs index 2cc67015..b2969606 100644 --- a/crates/tui/src/transcript/lifecycle.rs +++ b/crates/tui/src/transcript/lifecycle.rs @@ -27,6 +27,7 @@ pub(crate) enum ItemLifecycleEvent { TextStarted { item_id: ItemId, kind: TextItemKind, + item_seq: Option, }, TextDelta { item_id: ItemId, @@ -54,6 +55,7 @@ pub(crate) enum ItemLifecycleEvent { tool_use_id: String, tool_name: String, input: serde_json::Value, + item_seq: Option, command: Option, command_source: Option, parsed_commands: Vec, diff --git a/crates/tui/src/transcript/projector.rs b/crates/tui/src/transcript/projector.rs index ff13b8c4..24a10d9c 100644 --- a/crates/tui/src/transcript/projector.rs +++ b/crates/tui/src/transcript/projector.rs @@ -41,6 +41,7 @@ impl TranscriptProjector { tool_use_id, tool_name, input, + item_seq, command, command_source, parsed_commands, @@ -56,7 +57,7 @@ impl TranscriptProjector { // the viewport that no future boundary would ever flush. return; } - let seq = self.reserve_seq(); + let seq = self.reserve_seq(item_seq); let tool = ToolModel::new_opened( tool_use_id.clone(), seq, @@ -209,11 +210,15 @@ impl TranscriptProjector { self.live_text.clear(); self.text_order.clear(); } - ItemLifecycleEvent::TextStarted { item_id, kind } => { + ItemLifecycleEvent::TextStarted { + item_id, + kind, + item_seq, + } => { if self.live_text.contains_key(&item_id) { return; } - let seq = self.reserve_seq(); + let seq = self.reserve_seq(item_seq); self.text_order.push(item_id); self.live_text.insert( item_id, @@ -237,7 +242,7 @@ impl TranscriptProjector { apply_stream_text_delta(&mut live.text, &delta); return; } - let seq = self.reserve_seq(); + let seq = self.reserve_seq(/*item_seq*/ None); self.text_order.push(item_id); self.live_text.insert( item_id, @@ -336,7 +341,12 @@ impl TranscriptProjector { self.synced_committed = 0; } - fn reserve_seq(&mut self) -> u64 { + fn reserve_seq(&mut self, item_seq: Option) -> u64 { + if let Some(item_seq) = item_seq { + self.next_seq = self.next_seq.max(item_seq.saturating_add(1)); + return item_seq; + } + let seq = self.next_seq; self.next_seq = self.next_seq.wrapping_add(1); seq @@ -390,6 +400,7 @@ mod tests { tool_use_id: "read-1".into(), tool_name: "read".into(), input: serde_json::Value::Null, + item_seq: None, command: None, command_source: None, parsed_commands: Vec::new(), @@ -446,6 +457,7 @@ mod tests { tool_use_id: "grep-1".into(), tool_name: "grep".into(), input: serde_json::json!({"pattern": "plan", "path": "crates"}), + item_seq: None, command: None, command_source: None, parsed_commands: Vec::new(), @@ -502,6 +514,7 @@ mod tests { tool_use_id: "bash-1".into(), tool_name: "bash".into(), input: serde_json::json!({"command": "cargo test"}), + item_seq: None, command: Some("cargo test".into()), command_source: None, parsed_commands: Vec::new(), @@ -541,6 +554,7 @@ mod tests { tool_use_id: "bash-1".into(), tool_name: "bash".into(), input: serde_json::json!({"command": "cargo test"}), + item_seq: None, command: Some("cargo test".into()), command_source: None, parsed_commands: Vec::new(), @@ -558,6 +572,7 @@ mod tests { tool_use_id: "grep-1".into(), tool_name: "grep".into(), input: serde_json::json!({"pattern": "plan"}), + item_seq: None, command: None, command_source: None, parsed_commands: Vec::new(), @@ -566,6 +581,7 @@ mod tests { projector.apply(ItemLifecycleEvent::TextStarted { item_id, kind: crate::events::TextItemKind::Assistant, + item_seq: None, }); projector.apply(ItemLifecycleEvent::ToolClosed { tool_use_id: "grep-1".into(), @@ -609,6 +625,7 @@ mod tests { tool_use_id: "bash-1".into(), tool_name: "bash".into(), input: serde_json::json!({"command": "cargo build"}), + item_seq: None, command: Some("cargo build".into()), command_source: None, parsed_commands: Vec::new(), @@ -621,6 +638,7 @@ mod tests { projector.apply(ItemLifecycleEvent::TextStarted { item_id, kind: crate::events::TextItemKind::Assistant, + item_seq: None, }); projector.apply(ItemLifecycleEvent::TextCompleted { item_id, @@ -716,6 +734,7 @@ mod tests { tool_use_id: "edit-1".into(), tool_name: "edit".into(), input: serde_json::json!({"filePath": "a.rs"}), + item_seq: None, command: None, command_source: None, parsed_commands: Vec::new(), @@ -761,6 +780,7 @@ mod tests { tool_use_id: "exec-1".into(), tool_name: "exec_command".into(), input, + item_seq: None, command: Some("cargo check".into()), command_source: None, parsed_commands: Vec::new(), @@ -783,6 +803,7 @@ mod tests { tool_use_id: id.into(), tool_name: "exec_command".into(), input: serde_json::json!({"command": id}), + item_seq: None, command: Some(id.into()), command_source: None, parsed_commands: Vec::new(), @@ -834,6 +855,7 @@ mod tests { tool_use_id: "missing-result".into(), tool_name: "exec_command".into(), input: serde_json::json!({"command": "echo hi"}), + item_seq: None, command: Some("echo hi".into()), command_source: None, parsed_commands: Vec::new(), @@ -871,6 +893,7 @@ mod tests { projector.apply(ItemLifecycleEvent::TextStarted { item_id, kind: crate::events::TextItemKind::Reasoning, + item_seq: None, }); projector.apply(ItemLifecycleEvent::TextDelta { item_id, @@ -909,6 +932,7 @@ mod tests { projector.apply(ItemLifecycleEvent::TextStarted { item_id, kind: crate::events::TextItemKind::Assistant, + item_seq: None, }); let mut seed = 0x9e37_79b9_7f4a_7c15_u64; diff --git a/crates/tui/src/worker.rs b/crates/tui/src/worker.rs index 94659b5e..04c06ea8 100644 --- a/crates/tui/src/worker.rs +++ b/crates/tui/src/worker.rs @@ -5802,6 +5802,7 @@ mod tests { input: serde_json::json!({ "filePath": "crates/tui/src/mod.rs" }), + item_seq: None, command: None, command_source: None, parsed_commands: vec![devo_protocol::parse_command::ParsedCommand::Read { @@ -5854,6 +5855,7 @@ mod tests { "pattern": "**/Cargo.toml", "path": "crates" }), + item_seq: None, command: None, command_source: None, parsed_commands: vec![devo_protocol::parse_command::ParsedCommand::ListFiles { diff --git a/crates/tui/src/worker/item_dispatch.rs b/crates/tui/src/worker/item_dispatch.rs index 2a46ef27..0a3c11f3 100644 --- a/crates/tui/src/worker/item_dispatch.rs +++ b/crates/tui/src/worker/item_dispatch.rs @@ -20,7 +20,7 @@ pub(crate) fn dispatch_typed_item_lifecycle( ) { let item = &payload.item.item; let transcript_events = if method == "item/started" { - native_items::started_events(item, item_id) + native_items::started_events(item, item_id, payload.context.item_seq) } else { native_items::completed_events(item, item_id) }; diff --git a/crates/tui/src/worker/native_items.rs b/crates/tui/src/worker/native_items.rs index fd38b822..874af885 100644 --- a/crates/tui/src/worker/native_items.rs +++ b/crates/tui/src/worker/native_items.rs @@ -10,20 +10,26 @@ use crate::transcript::lifecycle::ItemLifecycleEvent; use super::tool_lifecycle::{ native_file_changes, tool_closed_from_command, tool_closed_from_file_change, - tool_closed_from_result, tool_opened_from_call, tool_opened_from_command, - tool_opened_refresh_from_call, + tool_closed_from_result, tool_opened_from_call_with_item_seq, + tool_opened_from_command_with_item_seq, tool_opened_refresh_from_call, }; /// Projects a native item `item/started` notification into lifecycle events. -pub(crate) fn started_events(item: &Item, item_id: ItemId) -> Vec { +pub(crate) fn started_events( + item: &Item, + item_id: ItemId, + item_seq: Option, +) -> Vec { match item { Item::AssistantMessage { .. } => vec![ItemLifecycleEvent::TextStarted { item_id, kind: TextItemKind::Assistant, + item_seq, }], Item::Reasoning { .. } => vec![ItemLifecycleEvent::TextStarted { item_id, kind: TextItemKind::Reasoning, + item_seq, }], Item::ToolCall { call_id, @@ -37,7 +43,7 @@ pub(crate) fn started_events(item: &Item, item_id: ItemId) -> Vec Vec vec![tool_opened_from_command( + } => vec![tool_opened_from_command_with_item_seq( call_id.clone(), command.clone(), input.clone(), *origin, Vec::new(), + item_seq, )], _ => Vec::new(), } @@ -172,6 +179,24 @@ mod tests { } } + #[test] + fn started_native_reasoning_preserves_item_sequence() { + let item_id = ItemId::new(); + let item = Item::Reasoning { + text: String::new(), + provider_payload_ref: None, + }; + + assert_eq!( + started_events(&item, item_id, Some(7)), + vec![ItemLifecycleEvent::TextStarted { + item_id, + kind: TextItemKind::Reasoning, + item_seq: Some(7), + }] + ); + } + /// A streamed tool call opens with empty parameters, the server then /// re-broadcasts `item/started` with the complete input, and the result /// closes the row. The running row must render the command as soon as the @@ -181,7 +206,7 @@ mod tests { let item_id = ItemId::new(); let mut projector = TranscriptProjector::default(); - for event in started_events(&tool_call_item(Some(serde_json::json!({}))), item_id) { + for event in started_events(&tool_call_item(Some(serde_json::json!({}))), item_id, None) { projector.apply(event); } @@ -192,6 +217,7 @@ mod tests { for event in started_events( &tool_call_item(Some(serde_json::json!({ "command": "cargo test" }))), item_id, + None, ) { projector.apply(event); } diff --git a/crates/tui/src/worker/tool_lifecycle.rs b/crates/tui/src/worker/tool_lifecycle.rs index ec6d90f8..11a6d000 100644 --- a/crates/tui/src/worker/tool_lifecycle.rs +++ b/crates/tui/src/worker/tool_lifecycle.rs @@ -19,10 +19,18 @@ use super::tool_summaries::tool_call_started_actions; use super::tool_summaries::tool_call_updated_actions; pub(crate) fn tool_opened_from_call(payload: &ToolCallPayload) -> ItemLifecycleEvent { + tool_opened_from_call_with_item_seq(payload, None) +} + +pub(crate) fn tool_opened_from_call_with_item_seq( + payload: &ToolCallPayload, + item_seq: Option, +) -> ItemLifecycleEvent { ItemLifecycleEvent::ToolOpened { tool_use_id: payload.tool_call_id.clone(), tool_name: payload.tool_name.clone(), input: payload.parameters.clone(), + item_seq, command: shell_command_from_input(&payload.parameters), command_source: command_source_from_tool_name(&payload.tool_name), parsed_commands: tool_call_started_actions(payload), @@ -35,6 +43,7 @@ pub(crate) fn tool_opened_refresh_from_call(payload: &ToolCallPayload) -> ItemLi tool_use_id: payload.tool_call_id.clone(), tool_name: payload.tool_name.clone(), input: payload.parameters.clone(), + item_seq: None, command: shell_command_from_input(&payload.parameters), command_source: command_source_from_tool_name(&payload.tool_name), parsed_commands: tool_call_updated_actions(payload, &summary), @@ -63,12 +72,31 @@ pub(crate) fn tool_opened_from_command_source( input: Option, source: ExecCommandSource, command_actions: Vec, +) -> ItemLifecycleEvent { + tool_opened_from_command_source_with_item_seq( + call_id, + command, + input, + source, + command_actions, + None, + ) +} + +pub(crate) fn tool_opened_from_command_source_with_item_seq( + call_id: String, + command: String, + input: Option, + source: ExecCommandSource, + command_actions: Vec, + item_seq: Option, ) -> ItemLifecycleEvent { let input = input.unwrap_or_else(|| serde_json::json!({ "command": command })); ItemLifecycleEvent::ToolOpened { tool_use_id: call_id, tool_name: "exec_command".to_string(), input, + item_seq, command: Some(command), command_source: Some(source), parsed_commands: command_actions, @@ -81,12 +109,30 @@ pub(crate) fn tool_opened_from_command( input: Option, origin: ExecOrigin, command_actions: Vec, +) -> ItemLifecycleEvent { + tool_opened_from_command_with_item_seq(call_id, command, input, origin, command_actions, None) +} + +pub(crate) fn tool_opened_from_command_with_item_seq( + call_id: String, + command: String, + input: Option, + origin: ExecOrigin, + command_actions: Vec, + item_seq: Option, ) -> ItemLifecycleEvent { let source = match origin { ExecOrigin::AgentTool => ExecCommandSource::Agent, ExecOrigin::UserShell => ExecCommandSource::UserShell, }; - tool_opened_from_command_source(call_id, command, input, source, command_actions) + tool_opened_from_command_source_with_item_seq( + call_id, + command, + input, + source, + command_actions, + item_seq, + ) } pub(crate) fn tool_closed_from_file_change( diff --git a/crates/tui/src/worker_event_test_helpers.rs b/crates/tui/src/worker_event_test_helpers.rs index 821504a1..4437ccc4 100644 --- a/crates/tui/src/worker_event_test_helpers.rs +++ b/crates/tui/src/worker_event_test_helpers.rs @@ -25,7 +25,11 @@ fn transcript(event: ItemLifecycleEvent) -> WorkerEvent { /// Shim for removed `WorkerEvent::TextItemStarted`. pub(crate) fn text_item_started(item_id: ItemId, kind: TextItemKind) -> WorkerEvent { - transcript(ItemLifecycleEvent::TextStarted { item_id, kind }) + transcript(ItemLifecycleEvent::TextStarted { + item_id, + kind, + item_seq: None, + }) } /// Shim for removed `WorkerEvent::TextItemDelta`. @@ -181,6 +185,7 @@ fn tool_opened_event( command, command_source: command_source_from_tool_name(&tool_name), input, + item_seq: None, parsed_commands, }) } @@ -301,6 +306,7 @@ pub(crate) fn command_execution_started( tool_use_id, tool_name: "exec_command".to_string(), input, + item_seq: None, command: Some(command), command_source: Some(source), parsed_commands: command_actions, From b2758a1328a48bd79a76a17b0a5937ba0e68825b Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Mon, 31 Aug 2026 21:15:23 +0800 Subject: [PATCH 2/2] fix(tui): keep completed exploration groups collapsed --- crates/tui/src/chatwidget/transcript_sync.rs | 6 +- crates/tui/src/tool_rendering_e2e_tests.rs | 80 ++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/chatwidget/transcript_sync.rs b/crates/tui/src/chatwidget/transcript_sync.rs index 945a2bd0..9c94b843 100644 --- a/crates/tui/src/chatwidget/transcript_sync.rs +++ b/crates/tui/src/chatwidget/transcript_sync.rs @@ -142,11 +142,15 @@ impl ChatWidget { .iter() .any(|item| item.item_id == item_id) { + // A completed exploration group is already in its compact + // display form and can safely remain alongside live text. + // Only detach an unfinished group so later tools do not get + // merged across the text boundary. if let Some(cell) = self .active_cell .as_ref() .and_then(|cell| cell.as_any().downcast_ref::()) - .filter(|cell| cell.is_exploring_cell()) + .filter(|cell| cell.is_exploring_cell() && cell.is_active()) { self.detached_exec_tool_ids .extend(cell.iter_calls().map(|call| call.call_id.clone())); diff --git a/crates/tui/src/tool_rendering_e2e_tests.rs b/crates/tui/src/tool_rendering_e2e_tests.rs index b4b2d1e4..0a1edea1 100644 --- a/crates/tui/src/tool_rendering_e2e_tests.rs +++ b/crates/tui/src/tool_rendering_e2e_tests.rs @@ -53,6 +53,15 @@ fn active_display(widget: &ChatWidget) -> String { .join("\n") } +fn viewport_display(widget: &ChatWidget) -> String { + widget + .active_viewport_lines_for_test(100) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n") +} + #[test] fn streaming_read_and_glob_updates_render_in_one_explored_cell() { let model = Model { @@ -148,3 +157,74 @@ fn streaming_read_and_glob_updates_render_in_one_explored_cell() { "glob placeholder must be replaced in place:\n{display}" ); } + +#[test] +fn explored_group_stays_collapsed_when_live_reasoning_starts() { + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); + + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "grep-1".to_string(), + "grep 'plan' in crates".to_string(), + false, + Some(vec![devo_protocol::parse_command::ParsedCommand::Search { + cmd: "grep 'plan' in crates".to_string(), + query: Some("plan".to_string()), + path: Some("crates".to_string()), + }]), + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "grep-1".to_string(), + "grep 'plan' in crates".to_string(), + "match".to_string(), + false, + false, + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "read-1".to_string(), + "read crates/tui/src/worker.rs".to_string(), + false, + Some(vec![devo_protocol::parse_command::ParsedCommand::Read { + cmd: "read crates/tui/src/worker.rs".to_string(), + name: "worker.rs".to_string(), + path: PathBuf::from("crates/tui/src/worker.rs"), + }]), + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "read-1".to_string(), + "read crates/tui/src/worker.rs".to_string(), + "source".to_string(), + false, + false, + )); + + let reasoning_id = devo_core::ItemId::new(); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_started( + reasoning_id, + crate::events::TextItemKind::Reasoning, + )); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_delta( + reasoning_id, + crate::events::TextItemKind::Reasoning, + "thinking while the explored group remains collapsed", + )); + + let display = viewport_display(&widget); + assert!( + display.contains("▌ Explored"), + "starting live reasoning must not expand the explored group into separate tool cells:\n{display}" + ); + assert!( + display.contains("Grepped plan in crates") + && display.contains("Read crates/tui/src/worker.rs"), + "the grouped explored summary must remain visible:\n{display}" + ); + assert!( + display.contains("Thinking: thinking while the explored group remains collapsed"), + "live reasoning must still render after the explored group:\n{display}" + ); +}