From af9b592ad6346bae2555a26c6429c6be4123af4f Mon Sep 17 00:00:00 2001 From: wushijian Date: Thu, 25 Jun 2026 18:29:16 +0800 Subject: [PATCH 1/9] =?UTF-8?q?fix:=20=E6=94=BE=E5=A4=A7=20CLI=20agent=20?= =?UTF-8?q?=E6=B5=AE=E7=AA=97=E6=8B=96=E6=8B=BD=E8=8C=83=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/ai/blocklist/block/cli.rs | 96 ++++++++++++++++++++++++-- app/src/terminal/block_list_element.rs | 70 +++++++++++++++++-- 2 files changed, 156 insertions(+), 10 deletions(-) diff --git a/app/src/ai/blocklist/block/cli.rs b/app/src/ai/blocklist/block/cli.rs index 588862d80e..50add0531d 100644 --- a/app/src/ai/blocklist/block/cli.rs +++ b/app/src/ai/blocklist/block/cli.rs @@ -9,9 +9,10 @@ use warp_core::report_error; use warp_core::ui::theme::color::internal_colors; use warpui::elements::new_scrollable::SingleAxisConfig; use warpui::elements::{ - ClippedScrollStateHandle, ConstrainedBox, Empty, Fill, FormattedTextElement, Highlight, - HighlightedHyperlink, Hoverable, MainAxisAlignment, MainAxisSize, NewScrollable, SavePosition, - SelectableArea, SizeConstraintCondition, SizeConstraintSwitch, + resizable_state_handle, ClippedScrollStateHandle, ConstrainedBox, DragBarSide, Empty, Fill, + FormattedTextElement, Highlight, HighlightedHyperlink, Hoverable, MainAxisAlignment, + MainAxisSize, NewScrollable, Resizable, ResizableStateHandle, SavePosition, SelectableArea, + SizeConstraintCondition, SizeConstraintSwitch, }; use warpui::fonts::Weight; use warpui::platform::{Cursor, OperatingSystem}; @@ -116,11 +117,31 @@ use super::{ }; const MENU_WIDTH: f32 = 200.0; const MAX_HEIGHT: f32 = 320.0; +// CLI agent 浮窗的最小宽度,避免内容被拖到不可读。 +const MIN_RESIZABLE_WIDTH: f32 = 360.0; +// CLI agent 浮窗的最小高度,保留一行以上内容和拖拽命中区域。 +const MIN_RESIZABLE_HEIGHT: f32 = 40.0; +// 横向缩放时给窗口边缘保留的少量可见宽度。 +const MIN_REMAINING_WINDOW_WIDTH: f32 = 16.0; +// 纵向缩放时给窗口边缘保留的少量可见高度。 +const MIN_REMAINING_WINDOW_HEIGHT: f32 = 16.0; const AVATAR_RIGHT_MARGIN: f32 = 8.; const CONTENT_PADDING: f32 = 12.; const ALLOW_ACTION_POSITION_ID: &str = "allow-action-position-id"; const USER_QUERY_POSITION_ID: &str = "cli-subagent-user-query-position-id"; +fn cli_subagent_width_bounds(window_width: f32) -> (f32, f32) { + // 最大宽度接近整窗,允许右下角浮窗向左覆盖大部分终端区域。 + let max = (window_width - MIN_REMAINING_WINDOW_WIDTH).max(MIN_RESIZABLE_WIDTH); + (MIN_RESIZABLE_WIDTH, max) +} + +fn cli_subagent_height_bounds(window_height: f32) -> (f32, f32) { + // 最大高度接近整窗,允许右下角浮窗向上覆盖大部分终端区域。 + let max = (window_height - MIN_REMAINING_WINDOW_HEIGHT).max(MIN_RESIZABLE_HEIGHT); + (MIN_RESIZABLE_HEIGHT, max) +} + lazy_static! { static ref ACCEPT_KEYSTROKE: Keystroke = Keystroke { key: "enter".to_owned(), @@ -233,6 +254,10 @@ pub struct CLISubagentView { is_input_dismissed: bool, input_dismiss_timer_handle: Option, + // 用户拖拽后的浮窗宽度,交给 Resizable 在多次 render 间保持。 + resizable_width: ResizableStateHandle, + // 用户拖拽后的浮窗高度,同时作为内部滚动区域的 max height。 + resizable_height: ResizableStateHandle, current_working_directory: Option, shell_launch_data: Option, @@ -507,6 +532,8 @@ impl CLISubagentView { always_allow_read_files_checked, is_input_dismissed: false, input_dismiss_timer_handle: None, + resizable_width: resizable_state_handle(MIN_RESIZABLE_WIDTH), + resizable_height: resizable_state_handle(MAX_HEIGHT), current_working_directory, shell_launch_data, selected_text: Arc::new(RwLock::new(None)), @@ -1002,6 +1029,11 @@ impl View for CLISubagentView { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let semantic_selection = SemanticSelection::handle(app).as_ref(app); + let resizable_height = self + .resizable_height + .lock() + .map(|state| state.size()) + .unwrap_or(MAX_HEIGHT); let mut result = Flex::column() .with_main_axis_size(MainAxisSize::Min) @@ -1063,6 +1095,7 @@ impl View for CLISubagentView { child: selectable_text.finish(), background_color: internal_colors::accent_bg(theme).into(), border: Some(Border::all(1.).with_border_fill(theme.accent())), + max_height: resizable_height, }, app, ) @@ -1176,6 +1209,7 @@ impl View for CLISubagentView { border: Some(Border::all(1.).with_border_fill( internal_colors::neutral_3(theme), )), + max_height: resizable_height, }, app, ) @@ -1203,6 +1237,7 @@ impl View for CLISubagentView { internal_colors::neutral_3(theme), ), ), + max_height: resizable_height, }, app, ) @@ -1308,6 +1343,7 @@ impl View for CLISubagentView { child: output.finish(), background_color: internal_colors::neutral_2(appearance.theme()), border: Some(output_border), + max_height: resizable_height, }, app, ) @@ -1327,6 +1363,7 @@ impl View for CLISubagentView { input: input.clone(), mode, scroll_state: self.state_handles.input_scroll_state.clone(), + max_height: resizable_height, }, app, )), @@ -1429,7 +1466,23 @@ impl View for CLISubagentView { ); } - result.finish() + let content = result.finish(); + let width_resizable = Resizable::new(self.resizable_width.clone(), content) + .with_dragbar_side(DragBarSide::Left) + .on_resize(|ctx, _| ctx.notify()) + .with_bounds_callback(Box::new(|window_size| { + cli_subagent_width_bounds(window_size.x()) + })) + .finish(); + + // 外层负责纵向缩放,内层负责横向缩放;拖拽边放在左上两侧,贴合右下角浮窗形态。 + Resizable::new(self.resizable_height.clone(), width_resizable) + .with_dragbar_side(DragBarSide::Top) + .on_resize(|ctx, _| ctx.notify()) + .with_bounds_callback(Box::new(|window_size| { + cli_subagent_height_bounds(window_size.y()) + })) + .finish() } fn keymap_context(&self, app: &AppContext) -> warpui::keymap::Context { @@ -1741,6 +1794,8 @@ struct ScrollableContainerProps { child: Box, background_color: ColorU, border: Option, + // 当前浮窗高度,用来限制每个可滚动内容块的最大高度。 + max_height: f32, } fn render_scrollable_container(props: ScrollableContainerProps, _app: &AppContext) -> Container { @@ -1749,6 +1804,7 @@ fn render_scrollable_container(props: ScrollableContainerProps, _app: &AppContex child, background_color, border, + max_height, } = props; let scrollable = NewScrollable::vertical( @@ -1764,7 +1820,7 @@ fn render_scrollable_container(props: ScrollableContainerProps, _app: &AppContex .finish(); let clipped = ConstrainedBox::new(scrollable) - .with_max_height(MAX_HEIGHT) + .with_max_height(max_height) .finish(); let mut container = Container::new(clipped) @@ -1949,6 +2005,8 @@ struct WriteToPtyInputProps { input: bytes::Bytes, mode: AIAgentPtyWriteMode, scroll_state: ClippedScrollStateHandle, + // 写入命令预览也跟随浮窗高度缩放,避免外层变矮后内部仍占用旧高度。 + max_height: f32, } fn render_write_to_pty_input(props: WriteToPtyInputProps, app: &AppContext) -> Box { @@ -1956,6 +2014,7 @@ fn render_write_to_pty_input(props: WriteToPtyInputProps, app: &AppContext) -> B input, mode, scroll_state, + max_height, } = props; let appearance = Appearance::as_ref(app); @@ -2001,7 +2060,7 @@ fn render_write_to_pty_input(props: WriteToPtyInputProps, app: &AppContext) -> B .finish(); let clipped = ConstrainedBox::new(scrollable) - .with_max_height(MAX_HEIGHT) + .with_max_height(max_height) .finish(); Container::new(clipped) @@ -2177,3 +2236,28 @@ fn render_blocked_action(props: BlockedActionProps<'_>, app: &AppContext) -> Box ) .finish() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cli_subagent_resize_width_bounds_allow_nearly_full_window() { + assert_eq!(cli_subagent_width_bounds(1000.0), (360.0, 984.0)); + } + + #[test] + fn cli_subagent_resize_width_bounds_do_not_drop_below_panel_minimum() { + assert_eq!(cli_subagent_width_bounds(320.0), (360.0, 360.0)); + } + + #[test] + fn cli_subagent_resize_height_bounds_allow_nearly_full_window() { + assert_eq!(cli_subagent_height_bounds(700.0), (40.0, 684.0)); + } + + #[test] + fn cli_subagent_resize_height_bounds_do_not_drop_below_panel_minimum() { + assert_eq!(cli_subagent_height_bounds(50.0), (40.0, 40.0)); + } +} diff --git a/app/src/terminal/block_list_element.rs b/app/src/terminal/block_list_element.rs index 3cbbf60bca..fc9ef51de4 100644 --- a/app/src/terminal/block_list_element.rs +++ b/app/src/terminal/block_list_element.rs @@ -185,6 +185,31 @@ const SPACE_BETWEEN_SELECTED_BLOCK_AVATARS: f32 = 2.; const CLI_SUBAGENT_HORIZONTAL_MARGIN: f32 = 8.; const CLI_SUBAGENT_VERTICAL_MARGIN: f32 = 8.; +// CLI agent 浮窗在普通 block list 中最多接近铺满终端区域,保留少量边缘余量。 +const CLI_SUBAGENT_MAX_WIDTH_RATIO: f32 = 0.98; +// 高度同样接近整窗,但仍受当前 block 可用高度限制。 +const CLI_SUBAGENT_MAX_HEIGHT_RATIO: f32 = 0.98; + +fn cli_subagent_layout_max_size( + available_size: Vector2F, + block_height: f32, + is_agent_blocked: bool, +) -> Vector2F { + // 参考 Warp 的外层约束形态:由 block list 先给浮窗足够大的布局上限, + // 再交给 CLISubagentView 内部 Resizable 处理最终拖拽尺寸。 + let max_width = (available_size.x() * CLI_SUBAGENT_MAX_WIDTH_RATIO + - CLI_SUBAGENT_HORIZONTAL_MARGIN) + .max(0.); + let window_max_height = available_size.y() * CLI_SUBAGENT_MAX_HEIGHT_RATIO; + let max_height = if is_agent_blocked { + window_max_height + } else { + // 非 blocked 状态保持 Warp 的 block 内约束,避免非活跃浮窗越出所属 block。 + (block_height - CLI_SUBAGENT_VERTICAL_MARGIN * 2.).min(window_max_height) + } + .max(0.); + vec2f(max_width, max_height) +} pub type LabelBuilderFn = dyn Fn( Vec, @@ -3358,10 +3383,10 @@ impl Element for BlockListElement { cli_subagent_view.layout( SizeConstraint { min: vec2f(0., 0.), - max: vec2f( - constraint.max.x() * 0.4 - - CLI_SUBAGENT_HORIZONTAL_MARGIN, - block_height - CLI_SUBAGENT_VERTICAL_MARGIN * 2., + max: cli_subagent_layout_max_size( + constraint.max, + block_height, + block.is_agent_blocked(), ), }, ctx, @@ -4846,3 +4871,40 @@ where button.finish() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cli_subagent_layout_max_size_allows_nearly_full_block_list_width() { + assert_eq!( + cli_subagent_layout_max_size(vec2f(1000., 700.), 900., true), + vec2f(972., 686.) + ); + } + + #[test] + fn cli_subagent_layout_max_size_allows_nearly_full_height_when_agent_blocked() { + assert_eq!( + cli_subagent_layout_max_size(vec2f(1000., 700.), 300., true), + vec2f(972., 686.) + ); + } + + #[test] + fn cli_subagent_layout_max_size_keeps_block_height_limit_when_not_agent_blocked() { + assert_eq!( + cli_subagent_layout_max_size(vec2f(1000., 700.), 300., false), + vec2f(972., 284.) + ); + } + + #[test] + fn cli_subagent_layout_max_size_does_not_go_negative() { + assert_eq!( + cli_subagent_layout_max_size(vec2f(4., 4.), 10., false), + vec2f(0., 0.) + ); + } +} From 0d386989127e3b9825772d6b843a3410d285a0d2 Mon Sep 17 00:00:00 2001 From: wushijian Date: Fri, 26 Jun 2026 10:16:48 +0800 Subject: [PATCH 2/9] =?UTF-8?q?fix:=20=E4=BF=9D=E7=95=99=20CLI=20agent=20?= =?UTF-8?q?=E6=B5=AE=E7=AA=97=E5=8E=86=E5=8F=B2=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/ai/blocklist/block/cli.rs | 958 ++++++++++++++++++------------ 1 file changed, 572 insertions(+), 386 deletions(-) diff --git a/app/src/ai/blocklist/block/cli.rs b/app/src/ai/blocklist/block/cli.rs index 50add0531d..42a21b8190 100644 --- a/app/src/ai/blocklist/block/cli.rs +++ b/app/src/ai/blocklist/block/cli.rs @@ -67,9 +67,9 @@ use crate::ToastStack; use crate::{ ai::{ agent::{ - conversation::AIConversationId, task::TaskId, AIAgentActionType, AIAgentOutput, - AIAgentOutputMessageType, AIAgentText, AIAgentTextSection, ProgrammingLanguage, - WebSearchStatus, + conversation::AIConversationId, task::TaskId, AIAgentActionType, AIAgentExchangeId, + AIAgentOutput, AIAgentOutputMessageType, AIAgentText, AIAgentTextSection, + ProgrammingLanguage, WebSearchStatus, }, blocklist::{ code_block::CodeSnippetButtonHandles, BlocklistAIActionModel, BlocklistAIHistoryEvent, @@ -142,6 +142,19 @@ fn cli_subagent_height_bounds(window_height: f32) -> (f32, f32) { (MIN_RESIZABLE_HEIGHT, max) } +/// 追加 CLI agent 浮窗历史 exchange id,并忽略重复事件。 +fn cli_subagent_append_history_exchange_id( + exchange_ids: &mut Vec, + exchange_id: AIAgentExchangeId, +) -> bool { + if exchange_ids.contains(&exchange_id) { + return false; + } + + exchange_ids.push(exchange_id); + true +} + lazy_static! { static ref ACCEPT_KEYSTROKE: Keystroke = Keystroke { key: "enter".to_owned(), @@ -228,6 +241,10 @@ struct StateHandles { pub struct CLISubagentView { block_id: BlockId, model: Rc>, + // CLI agent follow-up 会为同一个 subtask 追加多个 exchange;这里保留历史用于渲染旧轮次。 + history_models: Vec>>, + // 与 history_models 对齐,用于防止重复 AppendedExchange 事件把同一轮渲染两次。 + history_exchange_ids: Vec, subagent_controller: ModelHandle, action_model: ModelHandle, terminal_model: Arc>, @@ -393,20 +410,24 @@ impl CLISubagentView { .. } => { if task_id == &task_id_clone { + let appended_exchange_id = *exchange_id; if let Ok(model) = AIBlockModelImpl::::new( - *exchange_id, + appended_exchange_id, *conversation_id, false, false, ctx, ) { model.on_updated_output( - Box::new(|me, ctx| { - me.handle_updated_exchange_output(ctx); + Box::new(move |me, ctx| { + me.handle_updated_exchange_output(appended_exchange_id, ctx); }), ctx, ); - me.model = Rc::new(model); + let model: Rc> = + Rc::new(model); + me.append_history_model(appended_exchange_id, model.clone()); + me.model = model; me.code_editor_views = Default::default(); me.code_editor_buttons = Default::default(); me.table_section_handles = Default::default(); @@ -452,12 +473,21 @@ impl CLISubagentView { } }, ); - let exchange_id = history_model + let (exchange_id, initial_history_exchange_ids) = history_model .as_ref(ctx) .conversation(&conversation_id) .and_then(|c| { c.get_task(&task_id) - .and_then(|t| t.last_exchange().map(|e| e.id)) + .and_then(|t| { + t.last_exchange().map(|last_exchange| { + ( + last_exchange.id, + t.exchanges() + .map(|exchange| exchange.id) + .collect::>(), + ) + }) + }) .or_else(|| { // Zap BYOP fallback:agent 自起 LRC 时 // `cli_controller::FinishedAction` 通过 @@ -466,14 +496,14 @@ impl CLISubagentView { // `update_for_new_request_input`),用 root task 的 last // exchange 占位。后续用户 follow-up query 路由到此 task → // `AppendedExchange` → 上面的订阅(line 365-394)会自动 - // replace model 到真实 exchange,所以占位只在窗口创建瞬间 - // 短暂存在,UX 上不可见。 - let fallback = c.root_task_exchanges().last().map(|e| e.id); + // 切到真实 exchange。占位不加入 history,避免显示不属于此 + // subtask 的 root exchange。 + let fallback = c.root_task_exchanges().last().map(|e| (e.id, Vec::new())); if fallback.is_some() { log::warn!( "[byop] CLISubagentView::new task={task_id:?} 暂无 \ exchange,fallback 到 root_task last_exchange;\ - 等待 AppendedExchange 触发 replace。" + 等待 AppendedExchange 触发切换。" ); } fallback @@ -489,11 +519,40 @@ impl CLISubagentView { ) .expect("Exchange exists."); model.on_updated_output( - Box::new(|me, ctx| { - me.handle_updated_exchange_output(ctx); + Box::new(move |me, ctx| { + me.handle_updated_exchange_output(exchange_id, ctx); }), ctx, ); + let model: Rc> = Rc::new(model); + let mut history_models: Vec>> = Vec::new(); + let mut history_exchange_ids = Vec::new(); + for history_exchange_id in initial_history_exchange_ids { + if history_exchange_id == exchange_id { + if cli_subagent_append_history_exchange_id( + &mut history_exchange_ids, + history_exchange_id, + ) { + history_models.push(model.clone()); + } + continue; + } + + if let Ok(history_model) = AIBlockModelImpl::::new( + history_exchange_id, + conversation_id, + false, + false, + ctx, + ) { + if cli_subagent_append_history_exchange_id( + &mut history_exchange_ids, + history_exchange_id, + ) { + history_models.push(Rc::new(history_model)); + } + } + } ctx.subscribe_to_model(&subagent_controller, |me, _, event, ctx| match event { CLISubagentEvent::UpdatedControl { block_id, .. } => { @@ -510,7 +569,9 @@ impl CLISubagentView { let mut view = Self { block_id, - model: Rc::new(model), + model, + history_models, + history_exchange_ids, action_model, terminal_model, subagent_controller, @@ -542,6 +603,20 @@ impl CLISubagentView { view } + fn append_history_model( + &mut self, + exchange_id: AIAgentExchangeId, + model: Rc>, + ) -> bool { + // 同一个 AppendedExchange 可能被多条订阅路径观察到,历史列表只保留一次。 + if cli_subagent_append_history_exchange_id(&mut self.history_exchange_ids, exchange_id) { + self.history_models.push(model); + true + } else { + false + } + } + fn execute_pending_action(&mut self, ctx: &mut ViewContext) { let Some(blocked_action) = self.model.blocked_action(&self.action_model, ctx) else { return; @@ -707,7 +782,16 @@ impl CLISubagentView { } } - fn handle_updated_exchange_output(&mut self, ctx: &mut ViewContext) { + fn handle_updated_exchange_output( + &mut self, + exchange_id: AIAgentExchangeId, + ctx: &mut ViewContext, + ) { + if self.model.exchange_id(ctx) != Some(exchange_id) { + ctx.notify(); + return; + } + match self.model.status(ctx) { AIBlockOutputStatus::Pending => { self.secret_redaction_state.reset(); @@ -883,11 +967,19 @@ impl CLISubagentView { handle.abort(); } - let has_user_input = self - .model - .inputs_to_render(ctx) - .iter() - .any(|input| input.is_user_query()); + let has_user_input = if self.history_models.is_empty() { + self.model + .inputs_to_render(ctx) + .iter() + .any(|input| input.is_user_query()) + } else { + self.history_models.iter().any(|model| { + model + .inputs_to_render(ctx) + .iter() + .any(|input| input.is_user_query()) + }) + }; let should_hide_responses = self .terminal_model .lock() @@ -915,27 +1007,44 @@ impl CLISubagentView { self.reset_input_dismiss_timer(ctx); - // Detect links in all user queries - for (input_index, input) in self.model.inputs_to_render(ctx).iter().enumerate() { - if let AIAgentInput::UserQuery { query, .. } = input { - detect_links( - &mut self.link_detection_state, + let user_queries = if self.history_models.is_empty() { + self.model + .inputs_to_render(ctx) + .iter() + .filter_map(|input| match input { + AIAgentInput::UserQuery { query, .. } => Some(query.clone()), + _ => None, + }) + .collect::>() + } else { + self.history_models + .iter() + .flat_map(|model| model.inputs_to_render(ctx)) + .filter_map(|input| match input { + AIAgentInput::UserQuery { query, .. } => Some(query.clone()), + _ => None, + }) + .collect::>() + }; + + // 按历史顺序检测所有用户 query,确保旧轮次重新渲染后链接和脱敏索引仍然连续。 + for (input_index, query) in user_queries.iter().enumerate() { + detect_links( + &mut self.link_detection_state, + query, + TextLocation::Query { input_index }, + self.current_working_directory.as_ref(), + self.shell_launch_data.as_ref(), + ); + + let secret_redaction_mode = get_secret_obfuscation_mode(ctx); + if secret_redaction_mode.should_redact_secret() { + let should_obfuscate = secret_redaction_mode.is_visually_obfuscated(); + self.secret_redaction_state.run_redaction_for_location( query, TextLocation::Query { input_index }, - self.current_working_directory.as_ref(), - self.shell_launch_data.as_ref(), + should_obfuscate, ); - - // Run secret redaction on user queries - let secret_redaction_mode = get_secret_obfuscation_mode(ctx); - if secret_redaction_mode.should_redact_secret() { - let should_obfuscate = secret_redaction_mode.is_visually_obfuscated(); - self.secret_redaction_state.run_redaction_for_location( - query, - TextLocation::Query { input_index }, - should_obfuscate, - ); - } } } } @@ -1039,162 +1148,229 @@ impl View for CLISubagentView { .with_main_axis_size(MainAxisSize::Min) .with_cross_axis_alignment(CrossAxisAlignment::Stretch); - // Render user queries/follow-ups with avatar and interactive text - let inputs = self.model.inputs_to_render(app); - for (input_index, input) in inputs.iter().enumerate() { - if let AIAgentInput::UserQuery { query, .. } = input { - let text = render_query_text( - UserQueryProps { - text: query.to_owned(), - query_prefix_highlight_len: None, - detected_links_state: &self.link_detection_state, - secret_redaction_state: &self.secret_redaction_state, - input_index, - is_selecting: self.state_handles.query_selection_handle.is_selecting(), - is_ai_input_enabled: false, - find_context: None, - font_properties: &Properties { - style: Style::Normal, - weight: Weight::Normal, + let models_to_render = if self.history_models.is_empty() { + vec![&self.model] + } else { + self.history_models.iter().collect::>() + }; + let mut rendered_query_index = 0; + let mut text_section_index = 0; + let model_count = models_to_render.len(); + + for (model_index, model) in models_to_render.into_iter().enumerate() { + let is_latest_model = model_index + 1 == model_count; + + // Render user queries/follow-ups with avatar and interactive text + let inputs = model.inputs_to_render(app); + for input in inputs.iter() { + if let AIAgentInput::UserQuery { query, .. } = input { + let input_index = rendered_query_index; + rendered_query_index += 1; + let text = render_query_text( + UserQueryProps { + text: query.to_owned(), + query_prefix_highlight_len: None, + detected_links_state: &self.link_detection_state, + secret_redaction_state: &self.secret_redaction_state, + input_index, + is_selecting: self.state_handles.query_selection_handle.is_selecting(), + is_ai_input_enabled: false, + find_context: None, + font_properties: &Properties { + style: Style::Normal, + weight: Weight::Normal, + }, }, - }, - app, - ); + app, + ); - let selected_text = self.selected_text.clone(); - let output_selection_handle = self.state_handles.output_selection_handle.clone(); - let action_selection_handle = self.state_handles.action_selection_handle.clone(); - let mut selectable_text = SelectableArea::new( - self.state_handles.query_selection_handle.clone(), - move |selection_args, ctx, _| { - if let Some(selection) = selection_args - .selection - .filter(|selection| !selection.is_empty()) - { - output_selection_handle.clear(); - action_selection_handle.clear(); - ctx.dispatch_typed_action(CLISubagentAction::CopyOnSelect( - selection.clone(), - )); - *selected_text.write() = Some(selection); - ctx.dispatch_typed_action(CLISubagentAction::SelectText); - } - }, - text.finish(), - ) - .with_word_boundaries_policy(semantic_selection.word_boundary_policy()) - .with_smart_select_fn(semantic_selection.smart_select_fn()); + let selected_text = self.selected_text.clone(); + let output_selection_handle = + self.state_handles.output_selection_handle.clone(); + let action_selection_handle = + self.state_handles.action_selection_handle.clone(); + let mut selectable_text = SelectableArea::new( + self.state_handles.query_selection_handle.clone(), + move |selection_args, ctx, _| { + if let Some(selection) = selection_args + .selection + .filter(|selection| !selection.is_empty()) + { + output_selection_handle.clear(); + action_selection_handle.clear(); + ctx.dispatch_typed_action(CLISubagentAction::CopyOnSelect( + selection.clone(), + )); + *selected_text.write() = Some(selection); + ctx.dispatch_typed_action(CLISubagentAction::SelectText); + } + }, + text.finish(), + ) + .with_word_boundaries_policy(semantic_selection.word_boundary_policy()) + .with_smart_select_fn(semantic_selection.smart_select_fn()); - if FeatureFlag::RectSelection.is_enabled() { - selectable_text = selectable_text.should_support_rect_select(); - } + if FeatureFlag::RectSelection.is_enabled() { + selectable_text = selectable_text.should_support_rect_select(); + } - let scrollable_container = render_scrollable_container( - ScrollableContainerProps { - scroll_state: self.state_handles.query_scroll_state.clone(), - child: selectable_text.finish(), - background_color: internal_colors::accent_bg(theme).into(), - border: Some(Border::all(1.).with_border_fill(theme.accent())), - max_height: resizable_height, - }, - app, - ) - .with_margin_bottom(8.) - .finish(); - - let dismissable_stack = render_dismissable_container( - DismissableContainerProps { - child: scrollable_container, - hover_state: self.state_handles.input_hover_state.clone(), - dismiss_mouse_state: self.state_handles.dismiss_input_mouse_state.clone(), - position_id: USER_QUERY_POSITION_ID.to_string(), - }, - app, - ); + let scrollable_container = render_scrollable_container( + ScrollableContainerProps { + scroll_state: self.state_handles.query_scroll_state.clone(), + child: selectable_text.finish(), + background_color: internal_colors::accent_bg(theme).into(), + border: Some(Border::all(1.).with_border_fill(theme.accent())), + max_height: resizable_height, + }, + app, + ) + .with_margin_bottom(8.) + .finish(); - if !self.is_input_dismissed { - result.add_child(dismissable_stack); + let dismissable_stack = render_dismissable_container( + DismissableContainerProps { + child: scrollable_container, + hover_state: self.state_handles.input_hover_state.clone(), + dismiss_mouse_state: self + .state_handles + .dismiss_input_mouse_state + .clone(), + position_id: USER_QUERY_POSITION_ID.to_string(), + }, + app, + ); + + if !self.is_input_dismissed { + result.add_child(dismissable_stack); + } } } - } - // Render agent outputs and actions - let mut output_items = Flex::column() - .with_main_axis_size(MainAxisSize::Min) - .with_cross_axis_alignment(CrossAxisAlignment::Stretch); + // Render agent outputs and actions + let mut output_items = Flex::column() + .with_main_axis_size(MainAxisSize::Min) + .with_cross_axis_alignment(CrossAxisAlignment::Stretch); - let status = self.model.status(app); - let blocked_action = self.model.blocked_action(&self.action_model, app); - let should_hide_responses = block.should_hide_responses(); + let status = model.status(app); + let blocked_action = if is_latest_model { + model.blocked_action(&self.action_model, app) + } else { + None + }; + let should_hide_responses = block.should_hide_responses(); - if let Some(output) = status.output_to_render() { - let output = output.get(); + if let Some(output) = status.output_to_render() { + let output = output.get(); - let mut code_section_index = 0; - let mut text_section_index = 0; - let mut table_section_index = 0; - let mut image_section_index = 0; + let mut code_section_index = 0; + let mut table_section_index = 0; + let mut image_section_index = 0; - fn copy_code_action(snippet: String) -> CLISubagentAction { - CLISubagentAction::CopyCode(snippet) - } + fn copy_code_action(snippet: String) -> CLISubagentAction { + CLISubagentAction::CopyCode(snippet) + } - fn open_code_block_action(source: CodeSource) -> CLISubagentAction { - CLISubagentAction::OpenCodeBlock(source) - } + fn open_code_block_action(source: CodeSource) -> CLISubagentAction { + CLISubagentAction::OpenCodeBlock(source) + } - for output_message in output.messages.iter() { - match &output_message.message { - AIAgentOutputMessageType::Text(AIAgentText { sections }) - if !are_all_text_sections_empty(sections) => - { - let text_color = blended_colors::text_main(theme, theme.surface_1()); - output_items.add_child(render_text_sections( - TextSectionsProps { - model: self.model.as_ref(), - starting_text_section_index: &mut text_section_index, - starting_code_section_index: &mut code_section_index, - starting_table_section_index: &mut table_section_index, - starting_image_section_index: &mut image_section_index, - sections, - is_selecting_text: self - .state_handles - .output_selection_handle - .is_selecting(), - selectable: true, - text_color, - is_ai_input_enabled: false, - secret_redaction_state: &self.secret_redaction_state, - find_context: None, - shell_launch_data: None, - current_working_directory: None, - embedded_code_editor_views: &self.code_editor_views, - code_snippet_button_handles: &self.code_editor_buttons, - table_section_handles: &self.table_section_handles, - // CLI subagent blocks don't render block-list images yet, - // so there are no per-image tooltip handles to thread. - image_section_tooltip_handles: &[], - open_code_block_action_factory: Some(&open_code_block_action), - copy_code_action_factory: Some(©_code_action), - detected_links: Some(&self.link_detection_state), - item_spacing: CONTENT_PADDING, - #[cfg(feature = "local_fs")] - resolved_code_block_paths: None, - #[cfg(feature = "local_fs")] - resolved_blocklist_image_sources: None, - }, - app, - )); - } - AIAgentOutputMessageType::Action(action) => { - let is_cancelled = self - .action_model - .as_ref(app) - .get_action_status(&action.id) - .is_some_and(|status| status.is_cancelled()); - if blocked_action.is_none() && !is_cancelled && !should_hide_responses { - if let Some(rendered_action) = render_action(action.action.clone(), app) + for output_message in output.messages.iter() { + match &output_message.message { + AIAgentOutputMessageType::Text(AIAgentText { sections }) + if !are_all_text_sections_empty(sections) => + { + let text_color = blended_colors::text_main(theme, theme.surface_1()); + output_items.add_child(render_text_sections( + TextSectionsProps { + model: model.as_ref(), + starting_text_section_index: &mut text_section_index, + starting_code_section_index: &mut code_section_index, + starting_table_section_index: &mut table_section_index, + starting_image_section_index: &mut image_section_index, + sections, + is_selecting_text: self + .state_handles + .output_selection_handle + .is_selecting(), + selectable: true, + text_color, + is_ai_input_enabled: false, + secret_redaction_state: &self.secret_redaction_state, + find_context: None, + shell_launch_data: None, + current_working_directory: None, + embedded_code_editor_views: if is_latest_model { + &self.code_editor_views + } else { + &[] + }, + code_snippet_button_handles: if is_latest_model { + &self.code_editor_buttons + } else { + &[] + }, + table_section_handles: if is_latest_model { + &self.table_section_handles + } else { + &[] + }, + // CLI subagent blocks don't render block-list images yet, + // so there are no per-image tooltip handles to thread. + image_section_tooltip_handles: &[], + open_code_block_action_factory: Some(&open_code_block_action), + copy_code_action_factory: Some(©_code_action), + detected_links: Some(&self.link_detection_state), + item_spacing: CONTENT_PADDING, + #[cfg(feature = "local_fs")] + resolved_code_block_paths: None, + #[cfg(feature = "local_fs")] + resolved_blocklist_image_sources: None, + }, + app, + )); + } + AIAgentOutputMessageType::Action(action) => { + let is_cancelled = self + .action_model + .as_ref(app) + .get_action_status(&action.id) + .is_some_and(|status| status.is_cancelled()); + if is_latest_model + && blocked_action.is_none() + && !is_cancelled + && !should_hide_responses { + if let Some(rendered_action) = + render_action(action.action.clone(), app) + { + result.add_child( + render_scrollable_container( + ScrollableContainerProps { + scroll_state: self + .state_handles + .action_scroll_state + .clone(), + child: rendered_action, + background_color: internal_colors::neutral_2( + appearance.theme(), + ), + border: Some(Border::all(1.).with_border_fill( + internal_colors::neutral_3(theme), + )), + max_height: resizable_height, + }, + app, + ) + .with_margin_bottom(8.) + .finish(), + ); + } + } + } + AIAgentOutputMessageType::WebSearch(WebSearchStatus::Searching { + query, + }) => { + if is_latest_model && !should_hide_responses { result.add_child( render_scrollable_container( ScrollableContainerProps { @@ -1202,7 +1378,7 @@ impl View for CLISubagentView { .state_handles .action_scroll_state .clone(), - child: rendered_action, + child: render_web_search(query.clone(), app), background_color: internal_colors::neutral_2( appearance.theme(), ), @@ -1218,57 +1394,30 @@ impl View for CLISubagentView { ); } } + _ => (), } - AIAgentOutputMessageType::WebSearch(WebSearchStatus::Searching { query }) => { - if !should_hide_responses { - result.add_child( - render_scrollable_container( - ScrollableContainerProps { - scroll_state: self - .state_handles - .action_scroll_state - .clone(), - child: render_web_search(query.clone(), app), - background_color: internal_colors::neutral_2( - appearance.theme(), - ), - border: Some( - Border::all(1.).with_border_fill( - internal_colors::neutral_3(theme), - ), - ), - max_height: resizable_height, - }, - app, - ) - .with_margin_bottom(8.) - .finish(), - ); - } - } - _ => (), } } - } - let mut output_border = Border::all(1.).with_border_fill(internal_colors::neutral_3(theme)); - if let AIBlockOutputStatus::Failed { error, .. } = &status { - output_border = Border::all(1.).with_border_color(theme.ui_error_color()); - output_items.add_child(render_failed_output( - FailedOutputProps { - error, - is_ai_input_enabled: false, - invalid_api_key_button_handle: &self - .state_handles - .invalid_api_key_button_handle, - aws_bedrock_credentials_error_view: None, - icon_right_margin: AVATAR_RIGHT_MARGIN, - }, - app, - )); + let mut output_border = + Border::all(1.).with_border_fill(internal_colors::neutral_3(theme)); + if let AIBlockOutputStatus::Failed { error, .. } = &status { + output_border = Border::all(1.).with_border_color(theme.ui_error_color()); + output_items.add_child(render_failed_output( + FailedOutputProps { + error, + is_ai_input_enabled: false, + invalid_api_key_button_handle: &self + .state_handles + .invalid_api_key_button_handle, + aws_bedrock_credentials_error_view: None, + icon_right_margin: AVATAR_RIGHT_MARGIN, + }, + app, + )); - if !self.model.is_restored() && !error.is_invalid_api_key() { - output_items.add_child( + if is_latest_model && !model.is_restored() && !error.is_invalid_api_key() { + output_items.add_child( Container::new(render_informational_footer( app, "This response won't count towards your usage. \"Take over\" to continue." @@ -1279,94 +1428,136 @@ impl View for CLISubagentView { .finish(), ); - output_items.add_child( - Container::new(render_debug_footer( - DebugFooterProps { - conversation: self.model.conversation(app), - model: self.model.as_ref(), - debug_copy_button_handle: self - .state_handles - .debug_copy_button_handle - .clone(), - submit_issue_button_handle: self - .state_handles - .submit_issue_button_handle - .clone(), - should_render_feedback_below: true, - }, - |debug_id, ctx| { - ctx.dispatch_typed_action(CLISubagentAction::CopyDebugId(debug_id)) + output_items.add_child( + Container::new(render_debug_footer( + DebugFooterProps { + conversation: model.conversation(app), + model: model.as_ref(), + debug_copy_button_handle: self + .state_handles + .debug_copy_button_handle + .clone(), + submit_issue_button_handle: self + .state_handles + .submit_issue_button_handle + .clone(), + should_render_feedback_below: true, + }, + |debug_id, ctx| { + ctx.dispatch_typed_action(CLISubagentAction::CopyDebugId(debug_id)) + }, + |ctx| ctx.dispatch_typed_action(CLISubagentAction::OpenFeedbackDocs), + app, + )) + .with_margin_top(8.) + .with_margin_left(icon_size(app) + AVATAR_RIGHT_MARGIN) + .finish(), + ); + } + } + + if !output_items.is_empty() && !should_hide_responses { + let selected_text = self.selected_text.clone(); + let query_selection_handle = self.state_handles.query_selection_handle.clone(); + let action_selection_handle = self.state_handles.action_selection_handle.clone(); + let mut output = SelectableArea::new( + self.state_handles.output_selection_handle.clone(), + move |selection_args, ctx, _| { + if let Some(selection) = selection_args + .selection + .filter(|selection| !selection.is_empty()) + { + query_selection_handle.clear(); + action_selection_handle.clear(); + ctx.dispatch_typed_action(CLISubagentAction::CopyOnSelect( + selection.clone(), + )); + *selected_text.write() = Some(selection); + ctx.dispatch_typed_action(CLISubagentAction::SelectText); + } + }, + output_items.finish(), + ) + .with_word_boundaries_policy(semantic_selection.word_boundary_policy()) + .with_smart_select_fn(semantic_selection.smart_select_fn()); + + if FeatureFlag::RectSelection.is_enabled() { + output = output.should_support_rect_select(); + } + + result.add_child( + render_scrollable_container( + ScrollableContainerProps { + scroll_state: self.state_handles.output_scroll_state.clone(), + child: output.finish(), + background_color: internal_colors::neutral_2(appearance.theme()), + border: Some(output_border), + max_height: resizable_height, }, - |ctx| ctx.dispatch_typed_action(CLISubagentAction::OpenFeedbackDocs), app, - )) - .with_margin_top(8.) - .with_margin_left(icon_size(app) + AVATAR_RIGHT_MARGIN) + ) + .with_margin_bottom(8.) .finish(), ); } - } - - if !output_items.is_empty() && !should_hide_responses { - let selected_text = self.selected_text.clone(); - let query_selection_handle = self.state_handles.query_selection_handle.clone(); - let action_selection_handle = self.state_handles.action_selection_handle.clone(); - let mut output = SelectableArea::new( - self.state_handles.output_selection_handle.clone(), - move |selection_args, ctx, _| { - if let Some(selection) = selection_args - .selection - .filter(|selection| !selection.is_empty()) - { - query_selection_handle.clear(); - action_selection_handle.clear(); - ctx.dispatch_typed_action(CLISubagentAction::CopyOnSelect( - selection.clone(), - )); - *selected_text.write() = Some(selection); - ctx.dispatch_typed_action(CLISubagentAction::SelectText); - } - }, - output_items.finish(), - ) - .with_word_boundaries_policy(semantic_selection.word_boundary_policy()) - .with_smart_select_fn(semantic_selection.smart_select_fn()); - - if FeatureFlag::RectSelection.is_enabled() { - output = output.should_support_rect_select(); - } - result.add_child( - render_scrollable_container( - ScrollableContainerProps { - scroll_state: self.state_handles.output_scroll_state.clone(), - child: output.finish(), - background_color: internal_colors::neutral_2(appearance.theme()), - border: Some(output_border), - max_height: resizable_height, - }, - app, - ) - .with_margin_bottom(8.) - .finish(), - ); - } - - if let Some(rendered_action) = blocked_action.and_then(|action| match action.action { - AIAgentActionType::WriteToLongRunningShellCommand { input, mode, .. } => { - Some(render_blocked_action( + if let Some(rendered_action) = blocked_action.and_then(|action| match action.action { + AIAgentActionType::WriteToLongRunningShellCommand { input, mode, .. } => { + Some(render_blocked_action( + BlockedActionProps { + header: BLOCKED_ACTION_MESSAGE_FOR_WRITE_TO_LONG_RUNNING_SHELL_COMMAND + .to_string(), + description: Some(render_write_to_pty_input( + WriteToPtyInputProps { + input: input.clone(), + mode, + scroll_state: self.state_handles.input_scroll_state.clone(), + max_height: resizable_height, + }, + app, + )), + is_allow_menu_open: self.is_allow_menu_open, + allow_menu: Some(&self.allow_menu), + buttons: vec![ + &self.allow_button, + &self.reject_button, + &self.take_over_button, + ], + speedbump: should_show_write_to_pty_speedbump(app).then_some( + PermissionsSpeedbumpProps { + always_allow_checked: self.always_allow_write_to_pty_checked, + speedbump_checkbox_handle: &self + .state_handles + .speedbump_checkbox_handle, + speedbump_checkbox_action: + CLISubagentAction::ToggleAlwaysAllowWriteToPty, + ai_settings_link: &self.state_handles.ai_settings_link, + }, + ), + }, + app, + )) + } + AIAgentActionType::TransferShellCommandControlToUser { ref reason } => { + Some(render_blocked_action( + BlockedActionProps { + header: BLOCKED_ACTION_MESSAGE_FOR_TRANSFER_CONTROL.to_string(), + description: Some(render_transfer_control_reason(reason, app)), + is_allow_menu_open: false, + allow_menu: None, + buttons: vec![&self.reject_button, &self.transfer_control_button], + speedbump: None, + }, + app, + )) + } + AIAgentActionType::ReadFiles(..) + | AIAgentActionType::Grep { .. } + | AIAgentActionType::FileGlobV2 { .. } => Some(render_blocked_action( BlockedActionProps { - header: BLOCKED_ACTION_MESSAGE_FOR_WRITE_TO_LONG_RUNNING_SHELL_COMMAND - .to_string(), - description: Some(render_write_to_pty_input( - WriteToPtyInputProps { - input: input.clone(), - mode, - scroll_state: self.state_handles.input_scroll_state.clone(), - max_height: resizable_height, - }, - app, - )), + header: get_blocked_action_header(action.action.clone()) + .unwrap_or_default(), + description: render_search_action_input(action.action.clone(), app), is_allow_menu_open: self.is_allow_menu_open, allow_menu: Some(&self.allow_menu), buttons: vec![ @@ -1374,96 +1565,56 @@ impl View for CLISubagentView { &self.reject_button, &self.take_over_button, ], - speedbump: should_show_write_to_pty_speedbump(app).then_some( + speedbump: should_show_read_files_speedbump(app).then_some( PermissionsSpeedbumpProps { - always_allow_checked: self.always_allow_write_to_pty_checked, + always_allow_checked: self.always_allow_read_files_checked, speedbump_checkbox_handle: &self .state_handles .speedbump_checkbox_handle, speedbump_checkbox_action: - CLISubagentAction::ToggleAlwaysAllowWriteToPty, + CLISubagentAction::ToggleAlwaysAllowReadFiles, ai_settings_link: &self.state_handles.ai_settings_link, }, ), }, app, - )) - } - AIAgentActionType::TransferShellCommandControlToUser { ref reason } => { - Some(render_blocked_action( - BlockedActionProps { - header: BLOCKED_ACTION_MESSAGE_FOR_TRANSFER_CONTROL.to_string(), - description: Some(render_transfer_control_reason(reason, app)), - is_allow_menu_open: false, - allow_menu: None, - buttons: vec![&self.reject_button, &self.transfer_control_button], - speedbump: None, + )), + _ => None, + }) { + let selected_text = self.selected_text.clone(); + let query_selection_handle = self.state_handles.query_selection_handle.clone(); + let output_selection_handle = self.state_handles.output_selection_handle.clone(); + let mut selectable_action = SelectableArea::new( + self.state_handles.action_selection_handle.clone(), + move |selection_args, ctx, _| { + if let Some(selection) = selection_args + .selection + .filter(|selection| !selection.is_empty()) + { + query_selection_handle.clear(); + output_selection_handle.clear(); + ctx.dispatch_typed_action(CLISubagentAction::CopyOnSelect( + selection.clone(), + )); + *selected_text.write() = Some(selection); + ctx.dispatch_typed_action(CLISubagentAction::SelectText); + } }, - app, - )) - } - AIAgentActionType::ReadFiles(..) - | AIAgentActionType::Grep { .. } - | AIAgentActionType::FileGlobV2 { .. } => Some(render_blocked_action( - BlockedActionProps { - header: get_blocked_action_header(action.action.clone()).unwrap_or_default(), - description: render_search_action_input(action.action.clone(), app), - is_allow_menu_open: self.is_allow_menu_open, - allow_menu: Some(&self.allow_menu), - buttons: vec![ - &self.allow_button, - &self.reject_button, - &self.take_over_button, - ], - speedbump: should_show_read_files_speedbump(app).then_some( - PermissionsSpeedbumpProps { - always_allow_checked: self.always_allow_read_files_checked, - speedbump_checkbox_handle: &self - .state_handles - .speedbump_checkbox_handle, - speedbump_checkbox_action: - CLISubagentAction::ToggleAlwaysAllowReadFiles, - ai_settings_link: &self.state_handles.ai_settings_link, - }, - ), - }, - app, - )), - _ => None, - }) { - let selected_text = self.selected_text.clone(); - let query_selection_handle = self.state_handles.query_selection_handle.clone(); - let output_selection_handle = self.state_handles.output_selection_handle.clone(); - let mut selectable_action = SelectableArea::new( - self.state_handles.action_selection_handle.clone(), - move |selection_args, ctx, _| { - if let Some(selection) = selection_args - .selection - .filter(|selection| !selection.is_empty()) - { - query_selection_handle.clear(); - output_selection_handle.clear(); - ctx.dispatch_typed_action(CLISubagentAction::CopyOnSelect( - selection.clone(), - )); - *selected_text.write() = Some(selection); - ctx.dispatch_typed_action(CLISubagentAction::SelectText); - } - }, - rendered_action, - ) - .with_word_boundaries_policy(semantic_selection.word_boundary_policy()) - .with_smart_select_fn(semantic_selection.smart_select_fn()); + rendered_action, + ) + .with_word_boundaries_policy(semantic_selection.word_boundary_policy()) + .with_smart_select_fn(semantic_selection.smart_select_fn()); - if FeatureFlag::RectSelection.is_enabled() { - selectable_action = selectable_action.should_support_rect_select(); - } + if FeatureFlag::RectSelection.is_enabled() { + selectable_action = selectable_action.should_support_rect_select(); + } - result.add_child( - Container::new(selectable_action.finish()) - .with_margin_bottom(8.) - .finish(), - ); + result.add_child( + Container::new(selectable_action.finish()) + .with_margin_bottom(8.) + .finish(), + ); + } } let content = result.finish(); @@ -2260,4 +2411,39 @@ mod tests { fn cli_subagent_resize_height_bounds_do_not_drop_below_panel_minimum() { assert_eq!(cli_subagent_height_bounds(50.0), (40.0, 40.0)); } + + #[test] + fn cli_subagent_history_exchange_ids_append_new_rounds_in_order() { + let first_exchange_id = crate::ai::agent::AIAgentExchangeId::new(); + let second_exchange_id = crate::ai::agent::AIAgentExchangeId::new(); + let mut exchange_ids = Vec::new(); + + assert!(cli_subagent_append_history_exchange_id( + &mut exchange_ids, + first_exchange_id + )); + assert!(cli_subagent_append_history_exchange_id( + &mut exchange_ids, + second_exchange_id + )); + + assert_eq!(exchange_ids, vec![first_exchange_id, second_exchange_id]); + } + + #[test] + fn cli_subagent_history_exchange_ids_ignore_duplicate_exchange() { + let exchange_id = crate::ai::agent::AIAgentExchangeId::new(); + let mut exchange_ids = Vec::new(); + + assert!(cli_subagent_append_history_exchange_id( + &mut exchange_ids, + exchange_id + )); + assert!(!cli_subagent_append_history_exchange_id( + &mut exchange_ids, + exchange_id + )); + + assert_eq!(exchange_ids, vec![exchange_id]); + } } From 07ee342bb996c73831f8d83f522043df14183846 Mon Sep 17 00:00:00 2001 From: wushijian Date: Fri, 26 Jun 2026 11:12:16 +0800 Subject: [PATCH 3/9] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=20CLI=20agent=20?= =?UTF-8?q?=E6=B5=AE=E7=AA=97=E6=95=B4=E4=BD=93=E6=BB=9A=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/ai/blocklist/block/cli.rs | 255 ++++++++++++++++-------------- 1 file changed, 133 insertions(+), 122 deletions(-) diff --git a/app/src/ai/blocklist/block/cli.rs b/app/src/ai/blocklist/block/cli.rs index 42a21b8190..6f2ec22bc1 100644 --- a/app/src/ai/blocklist/block/cli.rs +++ b/app/src/ai/blocklist/block/cli.rs @@ -9,10 +9,11 @@ use warp_core::report_error; use warp_core::ui::theme::color::internal_colors; use warpui::elements::new_scrollable::SingleAxisConfig; use warpui::elements::{ - resizable_state_handle, ClippedScrollStateHandle, ConstrainedBox, DragBarSide, Empty, Fill, - FormattedTextElement, Highlight, HighlightedHyperlink, Hoverable, MainAxisAlignment, - MainAxisSize, NewScrollable, Resizable, ResizableStateHandle, SavePosition, SelectableArea, - SizeConstraintCondition, SizeConstraintSwitch, + resizable_state_handle, ClippedScrollStateHandle, ConstrainedBox, DispatchEventResult, + DragBarSide, Empty, EventHandler, Fill, FormattedTextElement, Highlight, HighlightedHyperlink, + Hoverable, MainAxisAlignment, MainAxisSize, NewScrollable, Resizable, ResizableStateHandle, + SavePosition, ScrollTarget, ScrollToPositionMode, SelectableArea, SizeConstraintCondition, + SizeConstraintSwitch, }; use warpui::fonts::Weight; use warpui::platform::{Cursor, OperatingSystem}; @@ -129,6 +130,7 @@ const AVATAR_RIGHT_MARGIN: f32 = 8.; const CONTENT_PADDING: f32 = 12.; const ALLOW_ACTION_POSITION_ID: &str = "allow-action-position-id"; const USER_QUERY_POSITION_ID: &str = "cli-subagent-user-query-position-id"; +const CONVERSATION_SCROLL_BOTTOM_POSITION_ID: &str = "cli-subagent-conversation-bottom-position-id"; fn cli_subagent_width_bounds(window_width: f32) -> (f32, f32) { // 最大宽度接近整窗,允许右下角浮窗向左覆盖大部分终端区域。 @@ -155,6 +157,16 @@ fn cli_subagent_append_history_exchange_id( true } +/// 用户滚轮查看历史后,不再强制把整体对话滚回底部。 +fn cli_subagent_mark_conversation_scroll_manually_moved(is_pinned: &mut bool) { + *is_pinned = false; +} + +/// 新一轮 exchange 追加时,默认恢复跟随最新内容。 +fn cli_subagent_mark_conversation_scroll_should_follow_latest(is_pinned: &mut bool) { + *is_pinned = true; +} + lazy_static! { static ref ACCEPT_KEYSTROKE: Keystroke = Keystroke { key: "enter".to_owned(), @@ -230,10 +242,7 @@ struct StateHandles { action_selection_handle: SelectionHandle, speedbump_checkbox_handle: MouseStateHandle, ai_settings_link: HighlightedHyperlink, - output_scroll_state: ClippedScrollStateHandle, - action_scroll_state: ClippedScrollStateHandle, - input_scroll_state: ClippedScrollStateHandle, - query_scroll_state: ClippedScrollStateHandle, + conversation_scroll_state: ClippedScrollStateHandle, input_hover_state: MouseStateHandle, dismiss_input_mouse_state: MouseStateHandle, } @@ -268,6 +277,8 @@ pub struct CLISubagentView { is_allow_menu_open: bool, always_allow_write_to_pty_checked: bool, always_allow_read_files_checked: bool, + // 整体对话滚动是否仍跟随最新输出;用户手动滚轮查看历史后会关闭。 + is_conversation_scroll_pinned_to_bottom: bool, is_input_dismissed: bool, input_dismiss_timer_handle: Option, @@ -428,6 +439,9 @@ impl CLISubagentView { Rc::new(model); me.append_history_model(appended_exchange_id, model.clone()); me.model = model; + cli_subagent_mark_conversation_scroll_should_follow_latest( + &mut me.is_conversation_scroll_pinned_to_bottom, + ); me.code_editor_views = Default::default(); me.code_editor_buttons = Default::default(); me.table_section_handles = Default::default(); @@ -591,6 +605,7 @@ impl CLISubagentView { is_allow_menu_open: false, always_allow_write_to_pty_checked, always_allow_read_files_checked, + is_conversation_scroll_pinned_to_bottom: true, is_input_dismissed: false, input_dismiss_timer_handle: None, resizable_width: resizable_state_handle(MIN_RESIZABLE_WIDTH), @@ -1144,7 +1159,7 @@ impl View for CLISubagentView { .map(|state| state.size()) .unwrap_or(MAX_HEIGHT); - let mut result = Flex::column() + let mut conversation_items = Flex::column() .with_main_axis_size(MainAxisSize::Min) .with_cross_axis_alignment(CrossAxisAlignment::Stretch); @@ -1214,22 +1229,17 @@ impl View for CLISubagentView { selectable_text = selectable_text.should_support_rect_select(); } - let scrollable_container = render_scrollable_container( - ScrollableContainerProps { - scroll_state: self.state_handles.query_scroll_state.clone(), - child: selectable_text.finish(), - background_color: internal_colors::accent_bg(theme).into(), - border: Some(Border::all(1.).with_border_fill(theme.accent())), - max_height: resizable_height, - }, - app, - ) + let query_container = render_framed_container(FramedContainerProps { + child: selectable_text.finish(), + background_color: internal_colors::accent_bg(theme).into(), + border: Some(Border::all(1.).with_border_fill(theme.accent())), + }) .with_margin_bottom(8.) .finish(); let dismissable_stack = render_dismissable_container( DismissableContainerProps { - child: scrollable_container, + child: query_container, hover_state: self.state_handles.input_hover_state.clone(), dismiss_mouse_state: self .state_handles @@ -1241,7 +1251,7 @@ impl View for CLISubagentView { ); if !self.is_input_dismissed { - result.add_child(dismissable_stack); + conversation_items.add_child(dismissable_stack); } } } @@ -1343,24 +1353,16 @@ impl View for CLISubagentView { if let Some(rendered_action) = render_action(action.action.clone(), app) { - result.add_child( - render_scrollable_container( - ScrollableContainerProps { - scroll_state: self - .state_handles - .action_scroll_state - .clone(), - child: rendered_action, - background_color: internal_colors::neutral_2( - appearance.theme(), - ), - border: Some(Border::all(1.).with_border_fill( - internal_colors::neutral_3(theme), - )), - max_height: resizable_height, - }, - app, - ) + conversation_items.add_child( + render_framed_container(FramedContainerProps { + child: rendered_action, + background_color: internal_colors::neutral_2( + appearance.theme(), + ), + border: Some(Border::all(1.).with_border_fill( + internal_colors::neutral_3(theme), + )), + }) .with_margin_bottom(8.) .finish(), ); @@ -1371,24 +1373,18 @@ impl View for CLISubagentView { query, }) => { if is_latest_model && !should_hide_responses { - result.add_child( - render_scrollable_container( - ScrollableContainerProps { - scroll_state: self - .state_handles - .action_scroll_state - .clone(), - child: render_web_search(query.clone(), app), - background_color: internal_colors::neutral_2( - appearance.theme(), - ), - border: Some(Border::all(1.).with_border_fill( + conversation_items.add_child( + render_framed_container(FramedContainerProps { + child: render_web_search(query.clone(), app), + background_color: internal_colors::neutral_2( + appearance.theme(), + ), + border: Some( + Border::all(1.).with_border_fill( internal_colors::neutral_3(theme), - )), - max_height: resizable_height, - }, - app, - ) + ), + ), + }) .with_margin_bottom(8.) .finish(), ); @@ -1485,17 +1481,12 @@ impl View for CLISubagentView { output = output.should_support_rect_select(); } - result.add_child( - render_scrollable_container( - ScrollableContainerProps { - scroll_state: self.state_handles.output_scroll_state.clone(), - child: output.finish(), - background_color: internal_colors::neutral_2(appearance.theme()), - border: Some(output_border), - max_height: resizable_height, - }, - app, - ) + conversation_items.add_child( + render_framed_container(FramedContainerProps { + child: output.finish(), + background_color: internal_colors::neutral_2(appearance.theme()), + border: Some(output_border), + }) .with_margin_bottom(8.) .finish(), ); @@ -1511,8 +1502,6 @@ impl View for CLISubagentView { WriteToPtyInputProps { input: input.clone(), mode, - scroll_state: self.state_handles.input_scroll_state.clone(), - max_height: resizable_height, }, app, )), @@ -1609,7 +1598,7 @@ impl View for CLISubagentView { selectable_action = selectable_action.should_support_rect_select(); } - result.add_child( + conversation_items.add_child( Container::new(selectable_action.finish()) .with_margin_bottom(8.) .finish(), @@ -1617,7 +1606,49 @@ impl View for CLISubagentView { } } - let content = result.finish(); + let bottom_position_id = + format!("{CONVERSATION_SCROLL_BOTTOM_POSITION_ID}-{}", self.block_id); + conversation_items.add_child( + SavePosition::new( + ConstrainedBox::new(Empty::new().finish()) + .with_height(1.) + .finish(), + &bottom_position_id, + ) + .finish(), + ); + + if self.is_conversation_scroll_pinned_to_bottom { + self.state_handles + .conversation_scroll_state + .scroll_to_position(ScrollTarget { + position_id: bottom_position_id, + mode: ScrollToPositionMode::FullyIntoView, + }); + } + + let scrollable_content = NewScrollable::vertical( + SingleAxisConfig::Clipped { + handle: self.state_handles.conversation_scroll_state.clone(), + child: conversation_items.finish(), + }, + Fill::None, + Fill::None, + Fill::None, + ) + .with_propagate_mousewheel_if_not_handled(true) + .finish(); + + let clipped_content = ConstrainedBox::new(scrollable_content) + .with_max_height(resizable_height) + .finish(); + let content = EventHandler::new(clipped_content) + .with_always_handle() + .on_scroll_wheel(|ctx, _app, _, _| { + ctx.dispatch_typed_action(CLISubagentAction::ConversationScrollManuallyMoved); + DispatchEventResult::PropagateToParent + }) + .finish(); let width_resizable = Resizable::new(self.resizable_width.clone(), content) .with_dragbar_side(DragBarSide::Left) .on_resize(|ctx, _| ctx.notify()) @@ -1669,6 +1700,7 @@ pub enum CLISubagentAction { CopyOnSelect(String), CopyDebugId(String), OpenFeedbackDocs, + ConversationScrollManuallyMoved, } impl TypedActionView for CLISubagentView { @@ -1765,6 +1797,12 @@ impl TypedActionView for CLISubagentView { CLISubagentAction::OpenFeedbackDocs => { ctx.open_url(""); } + CLISubagentAction::ConversationScrollManuallyMoved => { + cli_subagent_mark_conversation_scroll_manually_moved( + &mut self.is_conversation_scroll_pinned_to_bottom, + ); + ctx.notify(); + } } } } @@ -1940,41 +1978,20 @@ fn render_dismissable_container( .with_hover_out_delay(Duration::from_millis(500)) .finish() } -struct ScrollableContainerProps { - scroll_state: ClippedScrollStateHandle, +struct FramedContainerProps { child: Box, background_color: ColorU, border: Option, - // 当前浮窗高度,用来限制每个可滚动内容块的最大高度。 - max_height: f32, } -fn render_scrollable_container(props: ScrollableContainerProps, _app: &AppContext) -> Container { - let ScrollableContainerProps { - scroll_state, +fn render_framed_container(props: FramedContainerProps) -> Container { + let FramedContainerProps { child, background_color, border, - max_height, } = props; - let scrollable = NewScrollable::vertical( - SingleAxisConfig::Clipped { - handle: scroll_state, - child, - }, - Fill::None, - Fill::None, - Fill::None, - ) - .with_propagate_mousewheel_if_not_handled(true) - .finish(); - - let clipped = ConstrainedBox::new(scrollable) - .with_max_height(max_height) - .finish(); - - let mut container = Container::new(clipped) + let mut container = Container::new(child) .with_background_color(background_color) .with_horizontal_padding(CONTENT_PADDING) .with_vertical_padding(CONTENT_PADDING) @@ -2155,18 +2172,10 @@ fn get_blocked_action_header(action: AIAgentActionType) -> Option { struct WriteToPtyInputProps { input: bytes::Bytes, mode: AIAgentPtyWriteMode, - scroll_state: ClippedScrollStateHandle, - // 写入命令预览也跟随浮窗高度缩放,避免外层变矮后内部仍占用旧高度。 - max_height: f32, } fn render_write_to_pty_input(props: WriteToPtyInputProps, app: &AppContext) -> Box { - let WriteToPtyInputProps { - input, - mode, - scroll_state, - max_height, - } = props; + let WriteToPtyInputProps { input, mode } = props; let appearance = Appearance::as_ref(app); let theme = appearance.theme(); @@ -2198,23 +2207,7 @@ fn render_write_to_pty_input(props: WriteToPtyInputProps, app: &AppContext) -> B ) .finish(); - let scrollable = NewScrollable::vertical( - SingleAxisConfig::Clipped { - handle: scroll_state, - child: text, - }, - Fill::None, - Fill::None, - Fill::None, - ) - .with_propagate_mousewheel_if_not_handled(true) - .finish(); - - let clipped = ConstrainedBox::new(scrollable) - .with_max_height(max_height) - .finish(); - - Container::new(clipped) + Container::new(text) .with_background_color(internal_colors::neutral_2(theme)) .with_horizontal_padding(CONTENT_PADDING) .with_vertical_padding(8.) @@ -2446,4 +2439,22 @@ mod tests { assert_eq!(exchange_ids, vec![exchange_id]); } + + #[test] + fn cli_subagent_conversation_scroll_unpins_after_manual_scroll() { + let mut is_pinned = true; + + cli_subagent_mark_conversation_scroll_manually_moved(&mut is_pinned); + + assert!(!is_pinned); + } + + #[test] + fn cli_subagent_conversation_scroll_pins_after_new_exchange() { + let mut is_pinned = false; + + cli_subagent_mark_conversation_scroll_should_follow_latest(&mut is_pinned); + + assert!(is_pinned); + } } From cc289b32044a563913b512aaa1d4a3eef4959b95 Mon Sep 17 00:00:00 2001 From: wushijian Date: Fri, 26 Jun 2026 11:36:24 +0800 Subject: [PATCH 4/9] =?UTF-8?q?fix:=20=E9=98=BB=E6=AD=A2=20CLI=20agent=20?= =?UTF-8?q?=E6=B5=AE=E7=AA=97=E6=BB=9A=E8=BD=AE=E7=A9=BF=E9=80=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/ai/blocklist/block/cli.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/app/src/ai/blocklist/block/cli.rs b/app/src/ai/blocklist/block/cli.rs index 6f2ec22bc1..e6b0d96119 100644 --- a/app/src/ai/blocklist/block/cli.rs +++ b/app/src/ai/blocklist/block/cli.rs @@ -167,6 +167,11 @@ fn cli_subagent_mark_conversation_scroll_should_follow_latest(is_pinned: &mut bo *is_pinned = true; } +/// 浮窗内滚轮事件只消费在整体对话窗口中,避免继续带动外层终端滚动。 +fn cli_subagent_conversation_scroll_wheel_dispatch_result() -> DispatchEventResult { + DispatchEventResult::StopPropagation +} + lazy_static! { static ref ACCEPT_KEYSTROKE: Keystroke = Keystroke { key: "enter".to_owned(), @@ -1646,7 +1651,7 @@ impl View for CLISubagentView { .with_always_handle() .on_scroll_wheel(|ctx, _app, _, _| { ctx.dispatch_typed_action(CLISubagentAction::ConversationScrollManuallyMoved); - DispatchEventResult::PropagateToParent + cli_subagent_conversation_scroll_wheel_dispatch_result() }) .finish(); let width_resizable = Resizable::new(self.resizable_width.clone(), content) @@ -2457,4 +2462,12 @@ mod tests { assert!(is_pinned); } + + #[test] + fn cli_subagent_conversation_scroll_wheel_stops_parent_propagation() { + assert!(matches!( + cli_subagent_conversation_scroll_wheel_dispatch_result(), + DispatchEventResult::StopPropagation + )); + } } From f5a82525d6ed14c5898e3ee776c91ac3a1dcd628 Mon Sep 17 00:00:00 2001 From: wushijian Date: Sat, 27 Jun 2026 17:47:43 +0800 Subject: [PATCH 5/9] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20CLI=20agent=20?= =?UTF-8?q?=E6=B5=AE=E7=AA=97=E8=BE=B9=E7=95=8C=E6=BB=9A=E5=8A=A8=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/ai/blocklist/block/cli.rs | 57 ++++++++++++++++++++++---- app/src/terminal/block_list_element.rs | 13 +++++- 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/app/src/ai/blocklist/block/cli.rs b/app/src/ai/blocklist/block/cli.rs index e6b0d96119..d7d265c4ba 100644 --- a/app/src/ai/blocklist/block/cli.rs +++ b/app/src/ai/blocklist/block/cli.rs @@ -118,8 +118,9 @@ use super::{ }; const MENU_WIDTH: f32 = 200.0; const MAX_HEIGHT: f32 = 320.0; -// CLI agent 浮窗的最小宽度,避免内容被拖到不可读。 -const MIN_RESIZABLE_WIDTH: f32 = 360.0; +// CLI agent 浮窗的最小宽度,避免内容被拖到不可读;外层布局也复用该值保持约束一致。 +pub(crate) const CLI_SUBAGENT_MIN_RESIZABLE_WIDTH: f32 = 360.0; +const MIN_RESIZABLE_WIDTH: f32 = CLI_SUBAGENT_MIN_RESIZABLE_WIDTH; // CLI agent 浮窗的最小高度,保留一行以上内容和拖拽命中区域。 const MIN_RESIZABLE_HEIGHT: f32 = 40.0; // 横向缩放时给窗口边缘保留的少量可见宽度。 @@ -162,6 +163,23 @@ fn cli_subagent_mark_conversation_scroll_manually_moved(is_pinned: &mut bool) { *is_pinned = false; } +/// 根据滚轮方向更新贴底状态:已在底部继续向下滚动时保持 auto-scroll。 +fn cli_subagent_update_conversation_scroll_pin_after_wheel( + is_pinned: &mut bool, + vertical_delta: f32, +) -> bool { + if *is_pinned && vertical_delta <= 0. { + return false; + } + + if *is_pinned { + cli_subagent_mark_conversation_scroll_manually_moved(is_pinned); + return true; + } + + false +} + /// 新一轮 exchange 追加时,默认恢复跟随最新内容。 fn cli_subagent_mark_conversation_scroll_should_follow_latest(is_pinned: &mut bool) { *is_pinned = true; @@ -1649,8 +1667,10 @@ impl View for CLISubagentView { .finish(); let content = EventHandler::new(clipped_content) .with_always_handle() - .on_scroll_wheel(|ctx, _app, _, _| { - ctx.dispatch_typed_action(CLISubagentAction::ConversationScrollManuallyMoved); + .on_scroll_wheel(|ctx, _app, delta, _| { + ctx.dispatch_typed_action(CLISubagentAction::ConversationScrollWheel { + vertical_delta: delta.y(), + }); cli_subagent_conversation_scroll_wheel_dispatch_result() }) .finish(); @@ -1705,7 +1725,7 @@ pub enum CLISubagentAction { CopyOnSelect(String), CopyDebugId(String), OpenFeedbackDocs, - ConversationScrollManuallyMoved, + ConversationScrollWheel { vertical_delta: f32 }, } impl TypedActionView for CLISubagentView { @@ -1802,11 +1822,14 @@ impl TypedActionView for CLISubagentView { CLISubagentAction::OpenFeedbackDocs => { ctx.open_url(""); } - CLISubagentAction::ConversationScrollManuallyMoved => { - cli_subagent_mark_conversation_scroll_manually_moved( + CLISubagentAction::ConversationScrollWheel { vertical_delta } => { + let did_change = cli_subagent_update_conversation_scroll_pin_after_wheel( &mut self.is_conversation_scroll_pinned_to_bottom, + *vertical_delta, ); - ctx.notify(); + if did_change { + ctx.notify(); + } } } } @@ -2470,4 +2493,22 @@ mod tests { DispatchEventResult::StopPropagation )); } + + #[test] + fn cli_subagent_conversation_scroll_keeps_pinned_when_confirming_bottom() { + let mut is_pinned = true; + + cli_subagent_update_conversation_scroll_pin_after_wheel(&mut is_pinned, -1.); + + assert!(is_pinned); + } + + #[test] + fn cli_subagent_conversation_scroll_unpins_when_scrolling_up_from_bottom() { + let mut is_pinned = true; + + cli_subagent_update_conversation_scroll_pin_after_wheel(&mut is_pinned, 1.); + + assert!(!is_pinned); + } } diff --git a/app/src/terminal/block_list_element.rs b/app/src/terminal/block_list_element.rs index fc9ef51de4..4f92e715a8 100644 --- a/app/src/terminal/block_list_element.rs +++ b/app/src/terminal/block_list_element.rs @@ -1,4 +1,5 @@ use crate::ai::blocklist::agent_view::{agent_view_bg_fill, AgentViewState}; +use crate::ai::blocklist::block::cli::CLI_SUBAGENT_MIN_RESIZABLE_WIDTH; use crate::ai::blocklist::{ai_brand_color, ATTACH_AS_AGENT_MODE_CONTEXT_TEXT}; use crate::ai_assistant::{AI_ASSISTANT_SVG_PATH, ASK_AI_ASSISTANT_TEXT}; use crate::appearance::Appearance; @@ -199,7 +200,7 @@ fn cli_subagent_layout_max_size( // 再交给 CLISubagentView 内部 Resizable 处理最终拖拽尺寸。 let max_width = (available_size.x() * CLI_SUBAGENT_MAX_WIDTH_RATIO - CLI_SUBAGENT_HORIZONTAL_MARGIN) - .max(0.); + .max(CLI_SUBAGENT_MIN_RESIZABLE_WIDTH); let window_max_height = available_size.y() * CLI_SUBAGENT_MAX_HEIGHT_RATIO; let max_height = if is_agent_blocked { window_max_height @@ -4904,7 +4905,15 @@ mod tests { fn cli_subagent_layout_max_size_does_not_go_negative() { assert_eq!( cli_subagent_layout_max_size(vec2f(4., 4.), 10., false), - vec2f(0., 0.) + vec2f(360., 0.) + ); + } + + #[test] + fn cli_subagent_layout_max_size_keeps_min_width_for_narrow_windows() { + assert_eq!( + cli_subagent_layout_max_size(vec2f(320., 700.), 300., true).x(), + 360. ); } } From 37f90bd3583d78734a3fda17003699629228e3bd Mon Sep 17 00:00:00 2001 From: wushijian Date: Mon, 29 Jun 2026 10:17:11 +0800 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20CLI=20=E6=B5=AE?= =?UTF-8?q?=E7=AA=97=E5=8E=86=E5=8F=B2=E8=BE=93=E5=87=BA=E8=84=B1=E6=95=8F?= =?UTF-8?q?=E7=B4=A2=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/ai/blocklist/block/cli.rs | 193 ++++++++++++++++-- .../ai/blocklist/block/secret_redaction.rs | 119 +++++++---- .../blocklist/block/secret_redaction_test.rs | 93 +++++++++ 3 files changed, 345 insertions(+), 60 deletions(-) diff --git a/app/src/ai/blocklist/block/cli.rs b/app/src/ai/blocklist/block/cli.rs index d7d265c4ba..d400e51c48 100644 --- a/app/src/ai/blocklist/block/cli.rs +++ b/app/src/ai/blocklist/block/cli.rs @@ -190,6 +190,55 @@ fn cli_subagent_conversation_scroll_wheel_dispatch_result() -> DispatchEventResu DispatchEventResult::StopPropagation } +/// 统计 CLI 浮窗实际渲染的 output sections,供脱敏索引与 render 累计保持一致。 +fn cli_subagent_rendered_output_text_section_count(output: &AIAgentOutput) -> usize { + output + .messages + .iter() + .filter_map(|output_message| { + if let AIAgentOutputMessageType::Text(AIAgentText { sections }) = + &output_message.message + { + if !are_all_text_sections_empty(sections) { + return Some(sections.len()); + } + } + + None + }) + .sum() +} + +/// 按 CLI 浮窗 render 顺序扫描可见 output,并返回参与索引累计的 section 数。 +fn cli_subagent_run_redaction_on_rendered_output( + secret_redaction_state: &mut SecretRedactionState, + output: &AIAgentOutput, + starting_section_index: usize, + should_obfuscate: bool, +) -> usize { + let mut scanned_section_count = 0; + + for output_message in output.messages.iter() { + if let AIAgentOutputMessageType::Text(AIAgentText { sections }) = &output_message.message { + if !are_all_text_sections_empty(sections) { + scanned_section_count += secret_redaction_state + .run_redaction_on_text_sections_with_starting_section_index( + sections, + starting_section_index + scanned_section_count, + should_obfuscate, + ); + } + } + } + + debug_assert_eq!( + scanned_section_count, + cli_subagent_rendered_output_text_section_count(output) + ); + + scanned_section_count +} + lazy_static! { static ref ACCEPT_KEYSTROKE: Keystroke = Keystroke { key: "enter".to_owned(), @@ -470,6 +519,7 @@ impl CLISubagentView { me.table_section_handles = Default::default(); me.secret_redaction_state.reset(); me.set_state_from_updated_inputs(ctx); + me.refresh_output_secret_redaction_state(ctx); } ctx.notify(); } @@ -638,6 +688,7 @@ impl CLISubagentView { selected_text: Arc::new(RwLock::new(None)), }; view.set_state_from_updated_inputs(ctx); + view.refresh_output_secret_redaction_state(ctx); view } @@ -655,6 +706,44 @@ impl CLISubagentView { } } + fn models_to_render_for_output_redaction( + &self, + ) -> Vec>> { + // history_models 非空时已经按 render 顺序包含最新 exchange;否则只渲染当前 model。 + if self.history_models.is_empty() { + vec![self.model.clone()] + } else { + self.history_models.clone() + } + } + + fn refresh_output_secret_redaction_state(&mut self, ctx: &mut ViewContext) { + let secret_redaction_mode = get_secret_obfuscation_mode(ctx); + let models_to_scan = self.models_to_render_for_output_redaction(); + + self.secret_redaction_state.clear_output_locations(); + self.secret_redaction_state.reset(); + + if !secret_redaction_mode.should_redact_secret() { + return; + } + + let should_obfuscate = secret_redaction_mode.is_visually_obfuscated(); + let mut text_section_index = 0; + + for model in models_to_scan { + if let Some(output) = model.status(ctx).output_to_render() { + let output = output.get(); + text_section_index += cli_subagent_run_redaction_on_rendered_output( + &mut self.secret_redaction_state, + &output, + text_section_index, + should_obfuscate, + ); + } + } + } + fn execute_pending_action(&mut self, ctx: &mut ViewContext) { let Some(blocked_action) = self.model.blocked_action(&self.action_model, ctx) else { return; @@ -826,30 +915,39 @@ impl CLISubagentView { ctx: &mut ViewContext, ) { if self.model.exchange_id(ctx) != Some(exchange_id) { + self.refresh_output_secret_redaction_state(ctx); ctx.notify(); return; } match self.model.status(ctx) { AIBlockOutputStatus::Pending => { - self.secret_redaction_state.reset(); + self.refresh_output_secret_redaction_state(ctx); } AIBlockOutputStatus::PartiallyReceived { output } => { let output = output.get(); self.handle_updated_output(&output, ctx); + self.refresh_output_secret_redaction_state(ctx); } AIBlockOutputStatus::Complete { output } => { let output = output.get(); self.handle_updated_output(&output, ctx); - self.handle_complete_output(&output, ctx); + self.refresh_output_secret_redaction_state(ctx); } AIBlockOutputStatus::Cancelled { partial_output, .. } => { if let Some(output) = partial_output.as_ref() { let output = output.get(); self.handle_updated_output(&output, ctx); } + self.refresh_output_secret_redaction_state(ctx); + } + AIBlockOutputStatus::Failed { partial_output, .. } => { + if let Some(output) = partial_output.as_ref() { + let output = output.get(); + self.handle_updated_output(&output, ctx); + } + self.refresh_output_secret_redaction_state(ctx); } - AIBlockOutputStatus::Failed { .. } => (), } ctx.notify(); } @@ -871,14 +969,6 @@ impl CLISubagentView { .for_each(|(index, (code, language, source))| { self.handle_code_section_stream_update(index, code, language, source, ctx); }); - - if get_secret_obfuscation_mode(ctx).should_redact_secret() { - self.secret_redaction_state - .run_incremental_redaction_on_partial_output( - output, - get_secret_obfuscation_mode(ctx).is_visually_obfuscated(), - ); - } } fn handle_code_section_stream_update( @@ -989,16 +1079,6 @@ impl CLISubagentView { } } - fn handle_complete_output(&mut self, output: &AIAgentOutput, ctx: &mut ViewContext) { - // Run secret detection at the end of the stream to catch any secrets we might've missed while streaming, - // due to secret patterns that may include whitespace within them (we delimit on whitespace with the optimized - // secret detection approach). - if get_secret_obfuscation_mode(ctx).is_visually_obfuscated() { - self.secret_redaction_state - .run_redaction_on_complete_output(output); - } - } - fn reset_input_dismiss_timer(&mut self, ctx: &mut ViewContext) { self.is_input_dismissed = false; if let Some(handle) = self.input_dismiss_timer_handle.take() { @@ -2412,6 +2492,38 @@ fn render_blocked_action(props: BlockedActionProps<'_>, app: &AppContext) -> Box #[cfg(test)] mod tests { use super::*; + use crate::ai::agent::{AIAgentOutputMessage, AgentOutputText, MessageId}; + + fn cli_subagent_test_text_output( + message_id: &str, + sections: Vec, + ) -> AIAgentOutput { + AIAgentOutput { + messages: vec![AIAgentOutputMessage::text( + MessageId::new(message_id.to_string()), + AIAgentText { sections }, + )], + ..Default::default() + } + } + + fn cli_subagent_test_reasoning_message(message_id: &str) -> AIAgentOutputMessage { + AIAgentOutputMessage::reasoning( + MessageId::new(message_id.to_string()), + AIAgentText { + sections: vec![AIAgentTextSection::PlainText { + text: AgentOutputText::from("hidden reasoning".to_string()), + }], + }, + None, + ) + } + + fn cli_subagent_test_plain_text_section(text: &str) -> AIAgentTextSection { + AIAgentTextSection::PlainText { + text: AgentOutputText::from(text.to_string()), + } + } #[test] fn cli_subagent_resize_width_bounds_allow_nearly_full_window() { @@ -2468,6 +2580,45 @@ mod tests { assert_eq!(exchange_ids, vec![exchange_id]); } + #[test] + fn cli_subagent_output_redaction_section_count_matches_rendered_text_messages() { + let mut output = cli_subagent_test_text_output( + "message-1", + vec![ + cli_subagent_test_plain_text_section("first"), + cli_subagent_test_plain_text_section("second"), + ], + ); + output + .messages + .push(cli_subagent_test_reasoning_message("reasoning-message")); + + assert_eq!(cli_subagent_rendered_output_text_section_count(&output), 2); + } + + #[test] + fn cli_subagent_output_redaction_section_indices_accumulate_across_history() { + let history_output = cli_subagent_test_text_output( + "history-message", + vec![ + cli_subagent_test_plain_text_section("history 1"), + cli_subagent_test_plain_text_section("history 2"), + ], + ); + let latest_output = cli_subagent_test_text_output( + "latest-message", + vec![cli_subagent_test_plain_text_section("latest")], + ); + let history_start_index = 0; + let latest_start_index = + history_start_index + cli_subagent_rendered_output_text_section_count(&history_output); + let final_section_index = + latest_start_index + cli_subagent_rendered_output_text_section_count(&latest_output); + + assert_eq!(latest_start_index, 2); + assert_eq!(final_section_index, 3); + } + #[test] fn cli_subagent_conversation_scroll_unpins_after_manual_scroll() { let mut is_pinned = true; diff --git a/app/src/ai/blocklist/block/secret_redaction.rs b/app/src/ai/blocklist/block/secret_redaction.rs index 13ca00795e..d21289fe20 100644 --- a/app/src/ai/blocklist/block/secret_redaction.rs +++ b/app/src/ai/blocklist/block/secret_redaction.rs @@ -207,6 +207,28 @@ impl SecretRedactionState { } } + /// 清理所有 output 位置的脱敏结果,保留 query 的脱敏状态。 + pub fn clear_output_locations(&mut self) { + self.detected_secrets + .retain(|location, _| !matches!(location, TextLocation::Output { .. })); + + if self + .currently_hovered_secret_location + .as_ref() + .is_some_and(|location| matches!(location.location, TextLocation::Output { .. })) + { + self.currently_hovered_secret_location = None; + } + + if self + .secret_location_open_tooltip + .as_ref() + .is_some_and(|location| matches!(location.location, TextLocation::Output { .. })) + { + self.secret_location_open_tooltip = None; + } + } + pub fn show_secret_tooltip( &mut self, location: &TextLocation, @@ -301,6 +323,62 @@ impl SecretRedactionState { } } + /// 从指定的渲染 section 起点扫描一组 output sections,并返回参与索引累计的 section 数。 + pub(crate) fn run_redaction_on_text_sections_with_starting_section_index( + &mut self, + sections: &[AIAgentTextSection], + starting_section_index: usize, + should_obfuscate: bool, + ) -> usize { + for (section_offset, section) in sections.iter().enumerate() { + if let AIAgentTextSection::PlainText { text } = section { + let texts = if let Some(formatted_lines) = &text.formatted_lines { + formatted_lines + .lines() + .iter() + .map(|line| line.raw_text()) + .collect() + } else { + vec![text.text()] + }; + + for (line_index, text) in texts.iter().enumerate() { + self.run_redaction_for_location( + text, + TextLocation::Output { + section_index: starting_section_index + section_offset, + line_index, + }, + should_obfuscate, + ); + } + } + } + + sections.len() + } + + /// 从指定的渲染 section 起点扫描完整 output,并返回参与索引累计的 section 数。 + pub(crate) fn run_redaction_on_output_with_starting_section_index( + &mut self, + output: &AIAgentOutput, + starting_section_index: usize, + should_obfuscate: bool, + ) -> usize { + let mut scanned_section_count = 0; + + for text in output.all_text() { + scanned_section_count += self + .run_redaction_on_text_sections_with_starting_section_index( + &text.sections, + starting_section_index + scanned_section_count, + should_obfuscate, + ); + } + + scanned_section_count + } + pub fn run_incremental_redaction_on_partial_output( &mut self, output: &AIAgentOutput, @@ -562,45 +640,8 @@ impl SecretRedactionState { pub fn run_redaction_on_complete_output(&mut self, output: &AIAgentOutput) { // Delete all output secrets as we'll be rescanning the entire output. - self.detected_secrets - .retain(|location, _| !matches!(location, TextLocation::Output { .. })); - for (section_index, section) in output - .all_text() - .flat_map(|text| text.sections.iter()) - .enumerate() - { - if let AIAgentTextSection::PlainText { text } = section { - let texts = match &text.formatted_lines { - Some(text) => text.lines().iter().map(|line| line.raw_text()).collect(), - _ => vec![text.text()], - }; - for (line_index, text) in texts.iter().enumerate() { - let secret_ranges_with_levels = find_secrets_in_text_with_levels(text); - for (secret_range, secret_level) in secret_ranges_with_levels { - if let Some(secret_text) = - text.get(secret_range.byte_range.start..secret_range.byte_range.end) - { - self.detected_secrets - .entry(TextLocation::Output { - section_index, - line_index, - }) - .or_default() - .detected_secrets - .insert( - secret_range, - Secret { - secret: secret_text.to_string(), - is_obfuscated: true, - mouse_state: Default::default(), - secret_level, - }, - ); - } - } - } - } - } + self.clear_output_locations(); + self.run_redaction_on_output_with_starting_section_index(output, 0, true); } fn rerun_secret_detection_for_output_line( diff --git a/app/src/ai/blocklist/block/secret_redaction_test.rs b/app/src/ai/blocklist/block/secret_redaction_test.rs index 6455b797ea..7a17489ded 100644 --- a/app/src/ai/blocklist/block/secret_redaction_test.rs +++ b/app/src/ai/blocklist/block/secret_redaction_test.rs @@ -3,10 +3,48 @@ use serial_test::serial; use warpui::elements::Text; use warpui::fonts::FamilyId; +use crate::ai::agent::{AIAgentOutputMessage, AIAgentText, MessageId}; use crate::terminal::model::secrets::{self, SecretLevel}; use super::*; +fn text_output(message_id: &str, sections: Vec<&str>) -> AIAgentOutput { + AIAgentOutput { + messages: vec![AIAgentOutputMessage::text( + MessageId::new(message_id.to_string()), + AIAgentText { + sections: sections + .into_iter() + .map(|text| AIAgentTextSection::PlainText { + text: text.to_string().into(), + }) + .collect(), + }, + )], + ..Default::default() + } +} + +fn detected_secret_texts_at( + state: &SecretRedactionState, + section_index: usize, + line_index: usize, +) -> Vec { + state + .secrets_for_location(&TextLocation::Output { + section_index, + line_index, + }) + .map(|detected| { + detected + .detected_secrets + .values() + .map(|secret| secret.secret.clone()) + .collect() + }) + .unwrap_or_default() +} + #[test] fn test_merge_no_ranges() { let ranges: Vec<(SecretRange, SecretLevel)> = vec![]; @@ -482,6 +520,61 @@ fn test_add_secret_redaction_to_text_with_multibyte_characters() { assert_eq!(result.text(), "这是一个秘密: 密*****."); } +#[test] +#[serial] +fn test_output_redaction_respects_starting_section_index() { + secrets::set_user_and_enterprise_secret_regexes( + [&Regex::new("FIRST_SECRET").expect("Should be able to construct regex")], + std::iter::empty(), + ); + let output = text_output("message-1", vec!["safe section", "token FIRST_SECRET"]); + let mut state = SecretRedactionState::default(); + + let section_count = state.run_redaction_on_output_with_starting_section_index(&output, 4, true); + + assert_eq!(section_count, 2); + assert_eq!( + detected_secret_texts_at(&state, 5, 0), + vec!["FIRST_SECRET".to_string()] + ); + assert!(detected_secret_texts_at(&state, 1, 0).is_empty()); +} + +#[test] +#[serial] +fn test_output_redaction_keeps_history_and_latest_sections_separate() { + secrets::set_user_and_enterprise_secret_regexes( + [&Regex::new("FIRST_SECRET|SECOND_SECRET").expect("Should be able to construct regex")], + std::iter::empty(), + ); + let history_output = text_output("history-message", vec!["old FIRST_SECRET"]); + let latest_output = text_output("latest-message", vec!["plain latest", "new SECOND_SECRET"]); + let mut state = SecretRedactionState::default(); + let mut text_section_index = 0; + + text_section_index += state.run_redaction_on_output_with_starting_section_index( + &history_output, + text_section_index, + true, + ); + text_section_index += state.run_redaction_on_output_with_starting_section_index( + &latest_output, + text_section_index, + true, + ); + + assert_eq!(text_section_index, 3); + assert_eq!( + detected_secret_texts_at(&state, 0, 0), + vec!["FIRST_SECRET".to_string()] + ); + assert!(detected_secret_texts_at(&state, 1, 0).is_empty()); + assert_eq!( + detected_secret_texts_at(&state, 2, 0), + vec!["SECOND_SECRET".to_string()] + ); +} + // Test case-sensitive matching by default #[test] #[serial] From 68785488594ab404bee69927ef52a427b4108cc5 Mon Sep 17 00:00:00 2001 From: wushijian Date: Tue, 30 Jun 2026 12:48:07 +0800 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DCLI=E6=82=AC?= =?UTF-8?q?=E6=B5=AE=E7=AA=97=E5=88=92=E8=AF=8D=E5=92=8C=E9=9A=90=E8=97=8F?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/ai/blocklist/block/cli.rs | 30 +++++- app/src/terminal/block_list_element.rs | 136 ++++++++++++++++++++++--- 2 files changed, 148 insertions(+), 18 deletions(-) diff --git a/app/src/ai/blocklist/block/cli.rs b/app/src/ai/blocklist/block/cli.rs index d400e51c48..5e55a9ea95 100644 --- a/app/src/ai/blocklist/block/cli.rs +++ b/app/src/ai/blocklist/block/cli.rs @@ -190,6 +190,14 @@ fn cli_subagent_conversation_scroll_wheel_dispatch_result() -> DispatchEventResu DispatchEventResult::StopPropagation } +/// 判断 CLI 浮窗中的用户输入气泡是否应该渲染。 +fn cli_subagent_should_render_user_input( + should_hide_responses: bool, + is_input_dismissed: bool, +) -> bool { + !should_hide_responses && !is_input_dismissed +} + /// 统计 CLI 浮窗实际渲染的 output sections,供脱敏索引与 render 累计保持一致。 fn cli_subagent_rendered_output_text_section_count(output: &AIAgentOutput) -> usize { output @@ -1277,6 +1285,7 @@ impl View for CLISubagentView { for (model_index, model) in models_to_render.into_iter().enumerate() { let is_latest_model = model_index + 1 == model_count; + let should_hide_responses = block.should_hide_responses(); // Render user queries/follow-ups with avatar and interactive text let inputs = model.inputs_to_render(app); @@ -1353,7 +1362,10 @@ impl View for CLISubagentView { app, ); - if !self.is_input_dismissed { + if cli_subagent_should_render_user_input( + should_hide_responses, + self.is_input_dismissed, + ) { conversation_items.add_child(dismissable_stack); } } @@ -1370,7 +1382,6 @@ impl View for CLISubagentView { } else { None }; - let should_hide_responses = block.should_hide_responses(); if let Some(output) = status.output_to_render() { let output = output.get(); @@ -2645,6 +2656,21 @@ mod tests { )); } + #[test] + fn cli_subagent_user_input_hidden_when_responses_are_hidden() { + assert!(!cli_subagent_should_render_user_input(true, false)); + } + + #[test] + fn cli_subagent_user_input_visible_when_responses_are_shown() { + assert!(cli_subagent_should_render_user_input(false, false)); + } + + #[test] + fn cli_subagent_user_input_hidden_after_manual_dismiss() { + assert!(!cli_subagent_should_render_user_input(false, true)); + } + #[test] fn cli_subagent_conversation_scroll_keeps_pinned_when_confirming_bottom() { let mut is_pinned = true; diff --git a/app/src/terminal/block_list_element.rs b/app/src/terminal/block_list_element.rs index 4f92e715a8..39b2b19c46 100644 --- a/app/src/terminal/block_list_element.rs +++ b/app/src/terminal/block_list_element.rs @@ -805,6 +805,38 @@ pub enum VisibleItem { }, } +/// 按事件派发顺序返回当前可见的 RichContent 视图 ID。 +fn visible_rich_content_views_for_event_dispatch( + visible_items: Option<&[VisibleItem]>, +) -> Vec { + let mut view_ids = Vec::new(); + + // 只收集可见的 RichContent,普通终端块和间隔不参与子视图事件派发。 + let Some(visible_items) = visible_items else { + return view_ids; + }; + // RichContent 按列表顺序绘制,越靠后的项越晚绘制;事件派发需要反向遍历, + // 这样发生重叠或命中范围贴边时,视觉上更靠上的 block 会先获得鼠标事件。 + for item in visible_items.iter().rev() { + if let VisibleItem::RichContent { view_id, .. } = item { + view_ids.push(*view_id); + } + } + + view_ids +} + +/// 判断 RichContent 处理事件后是否应阻止事件继续落到其他 block 或终端文本。 +fn should_stop_after_rich_content_handles_event(event: &Event) -> bool { + matches!( + event, + Event::LeftMouseDown { .. } + | Event::LeftMouseDragged { .. } + | Event::LeftMouseUp { .. } + | Event::RightMouseDown { .. } + ) +} + impl VisibleItem { pub fn index(&self) -> TotalIndex { match self { @@ -3064,21 +3096,9 @@ impl BlockListElement { /// filters for RichContent items only, and returns the corresponding /// view id for those that are visible. fn visible_rich_content_views(&self) -> Vec { - let mut result = Vec::new(); - - // If there are no visible items, return an empty vector - let Some(visible_items) = &self.visible_items else { - return result; - }; - - // Filter visible items for RichContent items and collect their view_ids - for item in visible_items.iter() { - if let VisibleItem::RichContent { view_id, .. } = item { - result.push(*view_id); - } - } - - result + visible_rich_content_views_for_event_dispatch( + self.visible_items.as_ref().map(|items| items.as_slice()), + ) } #[cfg(feature = "voice_input")] @@ -4557,9 +4577,15 @@ impl Element for BlockListElement { // Its unclear if this should be the case for the hoverable toolbelt elements above. // That's an open product question. if self.pane_state.is_focused() { + let should_stop_after_rich_content_handles_event = + should_stop_after_rich_content_handles_event(event.raw_event()); for view_id in self.visible_rich_content_views() { if let Some(rich_content) = self.rich_content_elements.get_mut(&view_id) { - handled |= rich_content.dispatch_event(event, ctx, app); + let rich_content_handled = rich_content.dispatch_event(event, ctx, app); + handled |= rich_content_handled; + if rich_content_handled && should_stop_after_rich_content_handles_event { + return true; + } } } } @@ -4877,6 +4903,84 @@ where mod tests { use super::*; + #[test] + fn rich_content_event_dispatch_prefers_last_painted_item() { + let first_view_id = EntityId::from_usize(10); + let second_view_id = EntityId::from_usize(20); + let third_view_id = EntityId::from_usize(30); + + let visible_items = vec![ + VisibleItem::RichContent { + view_id: first_view_id, + height_px: 24., + index: TotalIndex(0), + }, + VisibleItem::Gap { + height_px: 4., + index: TotalIndex(1), + }, + VisibleItem::RichContent { + view_id: second_view_id, + height_px: 24., + index: TotalIndex(2), + }, + VisibleItem::RichContent { + view_id: third_view_id, + height_px: 24., + index: TotalIndex(3), + }, + ]; + + assert_eq!( + visible_rich_content_views_for_event_dispatch(Some(&visible_items)), + vec![third_view_id, second_view_id, first_view_id] + ); + } + + #[test] + fn rich_content_selection_mouse_events_stop_after_being_handled() { + let position = vec2f(4., 8.); + let modifiers = ModifiersState::default(); + + assert!(should_stop_after_rich_content_handles_event( + &Event::LeftMouseDown { + position, + modifiers, + click_count: 1, + is_first_mouse: false, + } + )); + assert!(should_stop_after_rich_content_handles_event( + &Event::LeftMouseDragged { + position, + modifiers, + } + )); + assert!(should_stop_after_rich_content_handles_event( + &Event::LeftMouseUp { + position, + modifiers, + } + )); + assert!(should_stop_after_rich_content_handles_event( + &Event::RightMouseDown { + position, + cmd: false, + shift: false, + click_count: 1, + } + )); + + assert!(!should_stop_after_rich_content_handles_event( + &Event::MouseMoved { + position, + cmd: false, + shift: false, + is_synthetic: false, + } + )); + } + #[test] fn cli_subagent_layout_max_size_allows_nearly_full_block_list_width() { assert_eq!( From 242597805e6a505e2d204aa53c6f92f2293ea0ed Mon Sep 17 00:00:00 2001 From: wushijian Date: Tue, 30 Jun 2026 19:34:13 +0800 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20CLI=20=E6=B5=AE?= =?UTF-8?q?=E7=AA=97=E5=A4=9A=E6=B0=94=E6=B3=A1=E5=88=92=E8=AF=8D=E4=BA=92?= =?UTF-8?q?=E7=9B=B8=E6=B8=85=E9=99=A4=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SSH 跳板机场景的 agent CLI 浮窗走 AltScreenElement 路径,浮窗内多轮 对话气泡(query/output/action)平铺在同一个 Flex::column 里,每个气泡是 独立的 SelectableArea。 根因: - Flex::dispatch_event 会把同一个鼠标事件广播给所有子元素,命中不 break; - SelectableArea::dispatch_event 在 LeftMouseDown/LeftMouseUp 时无条件调用 invoke_selection_handler,即使鼠标不在该气泡内(框架为"点击空白清选"设计); - cli.rs 回调入口无条件调用 clear_selection_handles_for_active_area,会清掉 同组其它 index 的 handle。 三者叠加,导致在 block N 上 down/up 时,前面未命中气泡的回调先执行,用自己 的 index 把 block N 的选择清掉: - 前/中 block:被后续未命中气泡的回调误清 → 无法保持选中; - 最后一个 block:LeftMouseUp 广播时前面的气泡先清掉它,松手即失选。 修复:在三处 SelectableArea 回调(query/output/action)入口加 is_this_area_active 判定 —— 只有本区域确实参与选择时(is_selecting() 为 true 或 selection 非空) 才清掉其它同级区域的旧选择。Flex 广播给未命中气泡的回调不再误清命中区域。 该修复对 alt-screen(SSH 跳板机)和 block_list(主对话区)两条路径都生效, 不触碰框架层(selectable_area.rs / flex)与现有 WIP。 --- app/src/ai/blocklist/block/cli.rs | 299 ++++++++++++++++++++++++++---- 1 file changed, 260 insertions(+), 39 deletions(-) diff --git a/app/src/ai/blocklist/block/cli.rs b/app/src/ai/blocklist/block/cli.rs index 5e55a9ea95..2e417742ad 100644 --- a/app/src/ai/blocklist/block/cli.rs +++ b/app/src/ai/blocklist/block/cli.rs @@ -1,9 +1,8 @@ use parking_lot::{FairMutex, RwLock}; use pathfinder_color::ColorU; use settings::Setting as _; -use std::sync::Arc; use std::time::Duration; -use std::{cmp::Ordering, rc::Rc}; +use std::{cell::RefCell, cmp::Ordering, rc::Rc, sync::Arc}; use warp_core::features::FeatureFlag; use warp_core::report_error; use warp_core::ui::theme::color::internal_colors; @@ -18,6 +17,7 @@ use warpui::elements::{ use warpui::fonts::Weight; use warpui::platform::{Cursor, OperatingSystem}; use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles}; +use warpui::units::IntoPixels; use lazy_static::lazy_static; use pathfinder_geometry::vector::vec2f; @@ -185,6 +185,20 @@ fn cli_subagent_mark_conversation_scroll_should_follow_latest(is_pinned: &mut bo *is_pinned = true; } +/// 切换响应可见性时保存或恢复对话滚动位置。 +fn cli_subagent_response_visibility_scroll_offset( + current_scroll_offset: f32, + should_hide_responses: bool, + saved_scroll_offset: &mut Option, +) -> Option { + if should_hide_responses { + *saved_scroll_offset = Some(current_scroll_offset); + None + } else { + saved_scroll_offset.take() + } +} + /// 浮窗内滚轮事件只消费在整体对话窗口中,避免继续带动外层终端滚动。 fn cli_subagent_conversation_scroll_wheel_dispatch_result() -> DispatchEventResult { DispatchEventResult::StopPropagation @@ -312,14 +326,75 @@ pub fn init(app: &mut AppContext) { )]); } +type SelectionHandleList = Rc>>; + +#[derive(Clone, Copy)] +enum SelectionHandleGroup { + Query, + Output, + Action, +} + +/// 按渲染项索引获取独立的选择状态,避免多个气泡共用同一套划词范围。 +fn selection_handle_for_index(handles: &SelectionHandleList, index: usize) -> SelectionHandle { + let mut handles = handles.borrow_mut(); + while handles.len() <= index { + handles.push(SelectionHandle::default()); + } + handles[index].clone() +} + +/// 清理同一浮窗内一组可选区域的所有划词状态。 +fn clear_selection_handles(handles: &SelectionHandleList) { + for handle in handles.borrow().iter() { + handle.clear(); + } +} + +/// 清理同组其它可选区域,保留当前正在产生选择的区域。 +fn clear_selection_handles_except(handles: &SelectionHandleList, selected_index: usize) { + for (index, handle) in handles.borrow().iter().enumerate() { + if index != selected_index { + handle.clear(); + } + } +} + +/// 开始在一个可选区域内划词时,清掉其它同级区域的旧选择状态。 +fn clear_selection_handles_for_active_area( + query_selection_handles: &SelectionHandleList, + output_selection_handles: &SelectionHandleList, + action_selection_handles: &SelectionHandleList, + active_group: SelectionHandleGroup, + active_index: usize, +) { + match active_group { + SelectionHandleGroup::Query => { + clear_selection_handles_except(query_selection_handles, active_index); + clear_selection_handles(output_selection_handles); + clear_selection_handles(action_selection_handles); + } + SelectionHandleGroup::Output => { + clear_selection_handles(query_selection_handles); + clear_selection_handles_except(output_selection_handles, active_index); + clear_selection_handles(action_selection_handles); + } + SelectionHandleGroup::Action => { + clear_selection_handles(query_selection_handles); + clear_selection_handles(output_selection_handles); + clear_selection_handles_except(action_selection_handles, active_index); + } + } +} + #[derive(Default)] struct StateHandles { invalid_api_key_button_handle: MouseStateHandle, debug_copy_button_handle: MouseStateHandle, submit_issue_button_handle: MouseStateHandle, - query_selection_handle: SelectionHandle, - output_selection_handle: SelectionHandle, - action_selection_handle: SelectionHandle, + query_selection_handles: SelectionHandleList, + output_selection_handles: SelectionHandleList, + action_selection_handles: SelectionHandleList, speedbump_checkbox_handle: MouseStateHandle, ai_settings_link: HighlightedHyperlink, conversation_scroll_state: ClippedScrollStateHandle, @@ -359,6 +434,8 @@ pub struct CLISubagentView { always_allow_read_files_checked: bool, // 整体对话滚动是否仍跟随最新输出;用户手动滚轮查看历史后会关闭。 is_conversation_scroll_pinned_to_bottom: bool, + // Hide responses 会让滚动内容变短并被 scrollable 裁剪到顶部;这里记录隐藏前的位置用于显示时恢复。 + hidden_response_scroll_offset: Option, is_input_dismissed: bool, input_dismiss_timer_handle: Option, @@ -656,6 +733,25 @@ impl CLISubagentView { } } CLISubagentEvent::ToggledHideResponses => { + let should_hide_responses = me + .terminal_model + .lock() + .block_list() + .block_with_id(&me.block_id) + .is_some_and(|block| block.should_hide_responses()); + let restored_scroll_offset = cli_subagent_response_visibility_scroll_offset( + me.state_handles + .conversation_scroll_state + .scroll_start() + .as_f32(), + should_hide_responses, + &mut me.hidden_response_scroll_offset, + ); + if let Some(scroll_offset) = restored_scroll_offset { + me.state_handles + .conversation_scroll_state + .scroll_to(scroll_offset.into_pixels()); + } me.reset_input_dismiss_timer(ctx); ctx.notify(); } @@ -687,6 +783,7 @@ impl CLISubagentView { always_allow_write_to_pty_checked, always_allow_read_files_checked, is_conversation_scroll_pinned_to_bottom: true, + hidden_response_scroll_offset: None, is_input_dismissed: false, input_dismiss_timer_handle: None, resizable_width: resizable_state_handle(MIN_RESIZABLE_WIDTH), @@ -1178,9 +1275,9 @@ impl CLISubagentView { /// Clears text selections at the `CLISubagentView` level (e.g. user query text). /// This does _not_ clear the selection of the child views (code blocks). fn clear_view_level_selection(&mut self) { - self.state_handles.query_selection_handle.clear(); - self.state_handles.output_selection_handle.clear(); - self.state_handles.action_selection_handle.clear(); + clear_selection_handles(&self.state_handles.query_selection_handles); + clear_selection_handles(&self.state_handles.output_selection_handles); + clear_selection_handles(&self.state_handles.action_selection_handles); *self.selected_text.write() = None; } @@ -1280,6 +1377,7 @@ impl View for CLISubagentView { self.history_models.iter().collect::>() }; let mut rendered_query_index = 0; + let mut rendered_output_index = 0; let mut text_section_index = 0; let model_count = models_to_render.len(); @@ -1293,6 +1391,10 @@ impl View for CLISubagentView { if let AIAgentInput::UserQuery { query, .. } = input { let input_index = rendered_query_index; rendered_query_index += 1; + let query_selection_handle = selection_handle_for_index( + &self.state_handles.query_selection_handles, + input_index, + ); let text = render_query_text( UserQueryProps { text: query.to_owned(), @@ -1300,7 +1402,7 @@ impl View for CLISubagentView { detected_links_state: &self.link_detection_state, secret_redaction_state: &self.secret_redaction_state, input_index, - is_selecting: self.state_handles.query_selection_handle.is_selecting(), + is_selecting: query_selection_handle.is_selecting(), is_ai_input_enabled: false, find_context: None, font_properties: &Properties { @@ -1312,24 +1414,43 @@ impl View for CLISubagentView { ); let selected_text = self.selected_text.clone(); - let output_selection_handle = - self.state_handles.output_selection_handle.clone(); - let action_selection_handle = - self.state_handles.action_selection_handle.clone(); + let query_selection_handles = + self.state_handles.query_selection_handles.clone(); + let output_selection_handles = + self.state_handles.output_selection_handles.clone(); + let action_selection_handles = + self.state_handles.action_selection_handles.clone(); + // 克隆一份本区域句柄进闭包,判断"本区域是否真的在参与选择"。 + // Flex 会把同一鼠标事件广播给所有兄弟 SelectableArea,未命中的气泡也会触发本回调, + // 此时不能用未命中的回调去清掉真正命中区域的划词状态。 + let query_selection_handle_clone = query_selection_handle.clone(); let mut selectable_text = SelectableArea::new( - self.state_handles.query_selection_handle.clone(), + query_selection_handle, move |selection_args, ctx, _| { - if let Some(selection) = selection_args - .selection - .filter(|selection| !selection.is_empty()) + let selection = selection_args.selection; + // 只有本区域确实参与选择时(正在 selecting 或已产生非空选中文本), + // 才清掉其它同级区域的旧选择;未命中广播则保持原状。 + let is_this_area_active = query_selection_handle_clone.is_selecting() + || selection.as_ref().is_some_and(|s| !s.is_empty()); + if is_this_area_active { + clear_selection_handles_for_active_area( + &query_selection_handles, + &output_selection_handles, + &action_selection_handles, + SelectionHandleGroup::Query, + input_index, + ); + } + if let Some(selection) = + selection.filter(|selection| !selection.is_empty()) { - output_selection_handle.clear(); - action_selection_handle.clear(); ctx.dispatch_typed_action(CLISubagentAction::CopyOnSelect( selection.clone(), )); *selected_text.write() = Some(selection); ctx.dispatch_typed_action(CLISubagentAction::SelectText); + } else if is_this_area_active { + *selected_text.write() = None; } }, text.finish(), @@ -1382,6 +1503,11 @@ impl View for CLISubagentView { } else { None }; + let output_index = rendered_output_index; + let output_selection_handle = selection_handle_for_index( + &self.state_handles.output_selection_handles, + output_index, + ); if let Some(output) = status.output_to_render() { let output = output.get(); @@ -1412,10 +1538,7 @@ impl View for CLISubagentView { starting_table_section_index: &mut table_section_index, starting_image_section_index: &mut image_section_index, sections, - is_selecting_text: self - .state_handles - .output_selection_handle - .is_selecting(), + is_selecting_text: output_selection_handle.is_selecting(), selectable: true, text_color, is_ai_input_enabled: false, @@ -1568,22 +1691,39 @@ impl View for CLISubagentView { if !output_items.is_empty() && !should_hide_responses { let selected_text = self.selected_text.clone(); - let query_selection_handle = self.state_handles.query_selection_handle.clone(); - let action_selection_handle = self.state_handles.action_selection_handle.clone(); + let query_selection_handles = self.state_handles.query_selection_handles.clone(); + let output_selection_handles = self.state_handles.output_selection_handles.clone(); + let action_selection_handles = self.state_handles.action_selection_handles.clone(); + // 克隆一份本区域的句柄进闭包,用于判断"本区域是否真的在参与选择"。 + // Flex 会把同一鼠标事件广播给所有兄弟 SelectableArea,未命中的气泡也会触发本回调, + // 此时不能用未命中的回调去清掉真正命中区域的划词状态。 + let output_selection_handle_clone = output_selection_handle.clone(); let mut output = SelectableArea::new( - self.state_handles.output_selection_handle.clone(), + output_selection_handle.clone(), move |selection_args, ctx, _| { - if let Some(selection) = selection_args - .selection - .filter(|selection| !selection.is_empty()) + let selection = selection_args.selection; + // 只有本区域确实参与选择时(正在 selecting 或已产生非空选中文本), + // 才清掉其它同级区域的旧选择;未命中广播则保持原状。 + let is_this_area_active = output_selection_handle_clone.is_selecting() + || selection.as_ref().is_some_and(|s| !s.is_empty()); + if is_this_area_active { + clear_selection_handles_for_active_area( + &query_selection_handles, + &output_selection_handles, + &action_selection_handles, + SelectionHandleGroup::Output, + output_index, + ); + } + if let Some(selection) = selection.filter(|selection| !selection.is_empty()) { - query_selection_handle.clear(); - action_selection_handle.clear(); ctx.dispatch_typed_action(CLISubagentAction::CopyOnSelect( selection.clone(), )); *selected_text.write() = Some(selection); ctx.dispatch_typed_action(CLISubagentAction::SelectText); + } else if is_this_area_active { + *selected_text.write() = None; } }, output_items.finish(), @@ -1604,6 +1744,7 @@ impl View for CLISubagentView { .with_margin_bottom(8.) .finish(), ); + rendered_output_index += 1; } if let Some(rendered_action) = blocked_action.and_then(|action| match action.action { @@ -1684,23 +1825,44 @@ impl View for CLISubagentView { )), _ => None, }) { + let action_selection_handle = selection_handle_for_index( + &self.state_handles.action_selection_handles, + model_index, + ); let selected_text = self.selected_text.clone(); - let query_selection_handle = self.state_handles.query_selection_handle.clone(); - let output_selection_handle = self.state_handles.output_selection_handle.clone(); + let query_selection_handles = self.state_handles.query_selection_handles.clone(); + let output_selection_handles = self.state_handles.output_selection_handles.clone(); + let action_selection_handles = self.state_handles.action_selection_handles.clone(); + // 克隆一份本区域句柄进闭包,判断"本区域是否真的在参与选择"。 + // Flex 会把同一鼠标事件广播给所有兄弟 SelectableArea,未命中的气泡也会触发本回调, + // 此时不能用未命中的回调去清掉真正命中区域的划词状态。 + let action_selection_handle_clone = action_selection_handle.clone(); let mut selectable_action = SelectableArea::new( - self.state_handles.action_selection_handle.clone(), + action_selection_handle, move |selection_args, ctx, _| { - if let Some(selection) = selection_args - .selection - .filter(|selection| !selection.is_empty()) + let selection = selection_args.selection; + // 只有本区域确实参与选择时(正在 selecting 或已产生非空选中文本), + // 才清掉其它同级区域的旧选择;未命中广播则保持原状。 + let is_this_area_active = action_selection_handle_clone.is_selecting() + || selection.as_ref().is_some_and(|s| !s.is_empty()); + if is_this_area_active { + clear_selection_handles_for_active_area( + &query_selection_handles, + &output_selection_handles, + &action_selection_handles, + SelectionHandleGroup::Action, + model_index, + ); + } + if let Some(selection) = selection.filter(|selection| !selection.is_empty()) { - query_selection_handle.clear(); - output_selection_handle.clear(); ctx.dispatch_typed_action(CLISubagentAction::CopyOnSelect( selection.clone(), )); *selected_text.write() = Some(selection); ctx.dispatch_typed_action(CLISubagentAction::SelectText); + } else if is_this_area_active { + *selected_text.write() = None; } }, rendered_action, @@ -2504,6 +2666,7 @@ fn render_blocked_action(props: BlockedActionProps<'_>, app: &AppContext) -> Box mod tests { use super::*; use crate::ai::agent::{AIAgentOutputMessage, AgentOutputText, MessageId}; + use warpui::{elements::SelectionBound, text::SelectionType}; fn cli_subagent_test_text_output( message_id: &str, @@ -2556,6 +2719,47 @@ mod tests { assert_eq!(cli_subagent_height_bounds(50.0), (40.0, 40.0)); } + #[test] + fn cli_subagent_selection_handles_clear_other_items_only() { + let handles = SelectionHandleList::default(); + let first_handle = selection_handle_for_index(&handles, 0); + let second_handle = selection_handle_for_index(&handles, 1); + + first_handle.start_selection_outside(SelectionBound::TopLeft, SelectionType::Simple); + second_handle.start_selection_outside(SelectionBound::TopLeft, SelectionType::Simple); + + clear_selection_handles_except(&handles, 1); + + assert!(!first_handle.is_selecting()); + assert!(second_handle.is_selecting()); + } + + #[test] + fn cli_subagent_active_selection_clears_peer_groups() { + let query_handles = SelectionHandleList::default(); + let output_handles = SelectionHandleList::default(); + let action_handles = SelectionHandleList::default(); + let query_handle = selection_handle_for_index(&query_handles, 0); + let output_handle = selection_handle_for_index(&output_handles, 0); + let action_handle = selection_handle_for_index(&action_handles, 0); + + query_handle.start_selection_outside(SelectionBound::TopLeft, SelectionType::Simple); + output_handle.start_selection_outside(SelectionBound::TopLeft, SelectionType::Simple); + action_handle.start_selection_outside(SelectionBound::TopLeft, SelectionType::Simple); + + clear_selection_handles_for_active_area( + &query_handles, + &output_handles, + &action_handles, + SelectionHandleGroup::Output, + 0, + ); + + assert!(!query_handle.is_selecting()); + assert!(output_handle.is_selecting()); + assert!(!action_handle.is_selecting()); + } + #[test] fn cli_subagent_history_exchange_ids_append_new_rounds_in_order() { let first_exchange_id = crate::ai::agent::AIAgentExchangeId::new(); @@ -2648,6 +2852,23 @@ mod tests { assert!(is_pinned); } + #[test] + fn cli_subagent_response_visibility_restores_saved_scroll_offset() { + let mut saved_scroll_offset = None; + + let restored_scroll_offset = + cli_subagent_response_visibility_scroll_offset(120.0, true, &mut saved_scroll_offset); + + assert_eq!(saved_scroll_offset, Some(120.0)); + assert_eq!(restored_scroll_offset, None); + + let restored_scroll_offset = + cli_subagent_response_visibility_scroll_offset(0.0, false, &mut saved_scroll_offset); + + assert_eq!(saved_scroll_offset, None); + assert_eq!(restored_scroll_offset, Some(120.0)); + } + #[test] fn cli_subagent_conversation_scroll_wheel_stops_parent_propagation() { assert!(matches!( From b685ff71284ff5571ad1303cdc1111b780e3ad73 Mon Sep 17 00:00:00 2001 From: wushijian Date: Tue, 30 Jun 2026 20:09:19 +0800 Subject: [PATCH 9/9] =?UTF-8?q?feat:=20=E4=B8=BB=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E5=8C=BA=E5=BC=80=E5=90=AF=20Hide=20responses=20=E6=97=B6?= =?UTF-8?q?=E4=B8=80=E5=B9=B6=E9=9A=90=E8=97=8F=E7=94=A8=E6=88=B7=E6=8F=90?= =?UTF-8?q?=E9=97=AE=E6=B0=94=E6=B3=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前主对话区(block_list 路径)在开启 "Hide responses" 时只隐藏 AI 输出,仍保留用户提问气泡,导致界面只藏输出不藏输入,与 CLI 浮窗 (commit 68785488)的行为不一致。 新增 common::should_render_query_and_header,在原有判断基础上增加 !should_hide_responses 条件,view_impl 在渲染 query/header 以及 contains_user_query_and_is_not_pin_to_top 判断时统一使用该函数。 带 2 个单元测试覆盖隐藏/显示两种场景。 --- app/src/ai/blocklist/block/view_impl.rs | 18 +++++++++++---- .../ai/blocklist/block/view_impl/common.rs | 22 ++++++++++++++----- .../blocklist/block/view_impl/common_tests.rs | 16 +++++++++++--- 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/app/src/ai/blocklist/block/view_impl.rs b/app/src/ai/blocklist/block/view_impl.rs index 7955235fbf..7ecc3ecc68 100644 --- a/app/src/ai/blocklist/block/view_impl.rs +++ b/app/src/ai/blocklist/block/view_impl.rs @@ -860,15 +860,25 @@ impl View for AIBlock { context_references, )) }); - let query_and_index_is_some = - query_and_index.is_some() && !should_hide_first_block_query_and_header; + let should_hide_responses = { + let terminal_model = self.terminal_model.lock(); + terminal_model + .block_list() + .active_block() + .should_hide_responses() + }; + let should_render_query_and_header = common::should_render_query_and_header( + query_and_index.is_some(), + should_hide_first_block_query_and_header, + should_hide_responses, + ); let attachment_name_list = if FeatureFlag::ImageAsContext.is_enabled() { attachment_names(self.model.inputs_to_render(app)) } else { vec![] }; - if !should_hide_first_block_query_and_header { + if should_render_query_and_header { if let Some(( query_for_display, input_index, @@ -1105,7 +1115,7 @@ impl View for AIBlock { // For example, consider a query that results in a requested command. We want the second // block (where requested command output is the AI input) to appear part of the original // query block. - let contains_user_query_and_is_not_pin_to_top = query_and_index_is_some + let contains_user_query_and_is_not_pin_to_top = should_render_query_and_header || InputModeSettings::as_ref(app) .input_mode .value() diff --git a/app/src/ai/blocklist/block/view_impl/common.rs b/app/src/ai/blocklist/block/view_impl/common.rs index 92752f32d9..02e70e222a 100644 --- a/app/src/ai/blocklist/block/view_impl/common.rs +++ b/app/src/ai/blocklist/block/view_impl/common.rs @@ -26,11 +26,11 @@ use warpui::{ new_scrollable::{ScrollableAppearance, SingleAxisConfig}, Align, Axis, Border, ChildAnchor, ChildView, Clipped, ClippedScrollStateHandle, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DispatchEventResult, Empty, - EventHandler, Expanded, Fill, Flex, FormattedTextElement, - Hoverable, Image as WarpImage, MainAxisAlignment, MainAxisSize, MouseStateHandle, - NewScrollable, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, - SavePosition, ScrollTarget, ScrollToPositionMode, ScrollbarWidth, Shrinkable, Stack, Table, - TableColumnWidth, TableConfig, TableHeader, TableVerticalSizing, Text, Wrap, + EventHandler, Expanded, Fill, Flex, FormattedTextElement, Hoverable, Image as WarpImage, + MainAxisAlignment, MainAxisSize, MouseStateHandle, NewScrollable, OffsetPositioning, + ParentAnchor, ParentElement, ParentOffsetBounds, Radius, SavePosition, ScrollTarget, + ScrollToPositionMode, ScrollbarWidth, Shrinkable, Stack, Table, TableColumnWidth, + TableConfig, TableHeader, TableVerticalSizing, Text, Wrap, }, fonts::{Properties, Weight}, image_cache::{CacheOption, ImageType}, @@ -3457,6 +3457,18 @@ pub(super) fn query_prefix_highlight_len( } } +/// 决定当前 block 的用户提问气泡是否应该渲染出来。 +/// +/// 当 "Hide responses" 开启时,这里也要一起隐藏提问本身,避免界面只藏输出 +/// 不藏输入,造成用户看到残留的发送内容。 +pub(super) fn should_render_query_and_header( + query_and_index_is_some: bool, + should_hide_first_block_query_and_header: bool, + should_hide_responses: bool, +) -> bool { + query_and_index_is_some && !should_hide_first_block_query_and_header && !should_hide_responses +} + pub(super) fn query_context_references( input: &AIAgentInput, displayed_query: &str, diff --git a/app/src/ai/blocklist/block/view_impl/common_tests.rs b/app/src/ai/blocklist/block/view_impl/common_tests.rs index 83c7da0f77..f3072e138f 100644 --- a/app/src/ai/blocklist/block/view_impl/common_tests.rs +++ b/app/src/ai/blocklist/block/view_impl/common_tests.rs @@ -12,9 +12,9 @@ use super::{ collect_visual_markdown_lightbox_collection, compute_visual_section_width, display_query_without_context_references, inline_image_source_label, is_supported_blocklist_image_source, lightbox_trigger_for_section, query_context_references, - query_prefix_highlight_len, render_scrollable_collapsible_content, text_sections_with_indices, - CollapsibleElementState, CollapsibleExpansionState, QueryContextReference, - VisualMarkdownLightboxCollection, + query_prefix_highlight_len, render_scrollable_collapsible_content, + should_render_query_and_header, text_sections_with_indices, CollapsibleElementState, + CollapsibleExpansionState, QueryContextReference, VisualMarkdownLightboxCollection, }; use crate::{ ai::agent::{ @@ -116,6 +116,16 @@ fn query_context_references_only_returns_real_at_attachments() { ); } +#[test] +fn should_render_query_and_header_hides_query_when_responses_are_hidden() { + assert!(!should_render_query_and_header(true, false, true)); +} + +#[test] +fn should_render_query_and_header_keeps_query_when_responses_are_visible() { + assert!(should_render_query_and_header(true, false, false)); +} + #[test] fn display_query_without_context_references_removes_chip_text() { let references = vec![