Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,651 changes: 1,192 additions & 459 deletions app/src/ai/blocklist/block/cli.rs

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question:EventHandler.with_always_handle() 在滚动到底部边界时会意外解除 auto-scroll 钉住状态

CLISubagentView::render() 末尾:

let content = EventHandler::new(clipped_content)
    .with_always_handle()
    .on_scroll_wheel(|ctx, _app, _, _| {
        ctx.dispatch_typed_action(CLISubagentAction::ConversationScrollManuallyMoved);
        cli_subagent_conversation_scroll_wheel_dispatch_result()
    })
    .finish();

NewScrollable 设置了 with_propagate_mousewheel_if_not_handled(true):当内容已经滚到底部,再向下滚动时,scroll 事件「未被消费」→ 向上冒泡 → 外层 EventHandler 触发 → ConversationScrollManuallyMoved 被 dispatch → is_conversation_scroll_pinned_to_bottom = false

场景:内容正在流式输出(持续 auto-scroll 到底),用户下意识再往下滚一下(已在底部)→ 解钉 → 新输出不再自动跟踪,用户需手动拖回底部。

是否可以检测「scroll 事件实际上是向下的且内容已在底部」时不解钉?或者在 ConversationScrollManuallyMoved handler 中加一个「当前是否已在底部」的判断?


Generated by Claude Code

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已经修复

Large diffs are not rendered by default.

119 changes: 80 additions & 39 deletions app/src/ai/blocklist/block/secret_redaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
93 changes: 93 additions & 0 deletions app/src/ai/blocklist/block/secret_redaction_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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![];
Expand Down Expand Up @@ -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]
Expand Down
18 changes: 14 additions & 4 deletions app/src/ai/blocklist/block/view_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
22 changes: 17 additions & 5 deletions app/src/ai/blocklist/block/view_impl/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 13 additions & 3 deletions app/src/ai/blocklist/block/view_impl/common_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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![
Expand Down
Loading