mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
perf(cli): gate dropped-path detection on multi-character insert size (#3343)
Fixes #2343 --- Normal keystrokes in `ChatInput` currently trigger blocking filesystem stat calls (`Path.exists()` / `Path.is_file()`) on every `TextArea.Changed` event because `_is_dropped_path_payload` and `_apply_inline_dropped_path_replacement` run unconditionally. This is wasted work for single-character changes — which can never be a drag-drop or bracketed-paste payload — and adds noticeable latency on each keypress. ## Changes - **Gate path detection by insert size.** Introduced `_should_check_path_payload()` to compare the previous and current `TextArea` text, compute the length of the inserted span, and skip the path-detection helpers entirely when only a single character was inserted. Multi-character changes (drag-drop, bracketed paste, or replacement edits) are still evaluated. - **Preserve replacement-edit detection.** The check looks at the actual changed span rather than the net length delta, so replacing selected text with a path of similar length still correctly triggers inline image attachment. - **Added unit-test coverage.** `TestPathPayloadDetectionGating` verifies that character-by-character typing never invokes the detection helpers, while bulk text changes and replacement edits continue to attach dropped paths and images.
This commit is contained in:
@@ -1124,6 +1124,11 @@ class ChatInput(Vertical):
|
||||
# immediately recurse into the same replacement path.
|
||||
self._applying_inline_path_replacement = False
|
||||
|
||||
# Text area content from the previous Changed event. Used to skip
|
||||
# blocking filesystem path-detection on single-keystroke edits while
|
||||
# still detecting replacement edits that insert a full path payload.
|
||||
self._prev_text = ""
|
||||
|
||||
# Track current suggestions for click handling
|
||||
self._current_suggestions: list[tuple[str, str]] = []
|
||||
self._current_selected_index = 0
|
||||
@@ -1236,6 +1241,13 @@ class ChatInput(Vertical):
|
||||
def on_text_area_changed(self, event: TextArea.Changed) -> None:
|
||||
"""Detect input mode and update completions."""
|
||||
text = event.text_area.text
|
||||
# Drag-drop / bracketed paste arrive as one Changed event with a
|
||||
# multi-character inserted span. Normal typing arrives one character at
|
||||
# a time. Checking the changed span (rather than net length delta)
|
||||
# preserves replacement edits where selected text is replaced by a path
|
||||
# of similar length.
|
||||
should_check_path_payload = self._should_check_path_payload(text)
|
||||
self._prev_text = text
|
||||
self._sync_media_tracker_to_text(text)
|
||||
|
||||
# History handlers explicitly decide mode and stripped display text.
|
||||
@@ -1255,13 +1267,17 @@ class ChatInput(Vertical):
|
||||
|
||||
if self._applying_inline_path_replacement:
|
||||
self._applying_inline_path_replacement = False
|
||||
elif self._apply_inline_dropped_path_replacement(text):
|
||||
elif should_check_path_payload and self._apply_inline_dropped_path_replacement(
|
||||
text
|
||||
):
|
||||
return
|
||||
|
||||
# Checked after the guards above so we skip the (potentially slow)
|
||||
# filesystem lookup when the text change came from history navigation
|
||||
# or prefix stripping, which never need path detection.
|
||||
is_path_payload = self._is_dropped_path_payload(text)
|
||||
is_path_payload = should_check_path_payload and self._is_dropped_path_payload(
|
||||
text
|
||||
)
|
||||
|
||||
# Guard: skip mode re-detection after we programmatically stripped
|
||||
# a prefix character.
|
||||
@@ -1317,6 +1333,30 @@ class ChatInput(Vertical):
|
||||
# Scroll input into view when content changes (handles text wrap)
|
||||
self.scroll_visible()
|
||||
|
||||
def _should_check_path_payload(self, text: str) -> bool:
|
||||
"""Return whether a text change may contain a pasted path payload."""
|
||||
old = self._prev_text
|
||||
if text == old:
|
||||
return False
|
||||
|
||||
prefix_len = 0
|
||||
max_prefix_len = min(len(old), len(text))
|
||||
while prefix_len < max_prefix_len and old[prefix_len] == text[prefix_len]:
|
||||
prefix_len += 1
|
||||
|
||||
old_suffix = len(old)
|
||||
text_suffix = len(text)
|
||||
while (
|
||||
old_suffix > prefix_len
|
||||
and text_suffix > prefix_len
|
||||
and old[old_suffix - 1] == text[text_suffix - 1]
|
||||
):
|
||||
old_suffix -= 1
|
||||
text_suffix -= 1
|
||||
|
||||
inserted_len = text_suffix - prefix_len
|
||||
return inserted_len > 1
|
||||
|
||||
@staticmethod
|
||||
def _parse_dropped_path_payload(
|
||||
text: str, *, allow_leading_path: bool = False
|
||||
|
||||
@@ -2158,6 +2158,105 @@ class TestDroppedVideoPaste:
|
||||
assert len(app.tracker.get_videos()) == 1
|
||||
|
||||
|
||||
class TestPathPayloadDetectionGating:
|
||||
"""Single-keystroke edits should skip the blocking path-detection helpers.
|
||||
|
||||
`_is_dropped_path_payload` and `_apply_inline_dropped_path_replacement`
|
||||
reach `Path.exists()` / `Path.is_file()` via
|
||||
`deepagents_cli.input.parse_pasted_path_payload`, which are synchronous
|
||||
stat syscalls on the event-loop thread. They are only meaningful when a
|
||||
text change inserts more than one character (drag-drop / bracketed paste);
|
||||
on normal typing they cost real wall-clock time for no possible match.
|
||||
"""
|
||||
|
||||
async def test_typing_does_not_invoke_path_detection(self) -> None:
|
||||
"""Char-by-char keypresses must not run path-detection helpers."""
|
||||
app = _ChatInputTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
chat = app.query_one(ChatInput)
|
||||
ta = chat._text_area
|
||||
assert ta is not None
|
||||
|
||||
detect_calls = 0
|
||||
replace_calls = 0
|
||||
original_detect = chat._is_dropped_path_payload
|
||||
original_replace = chat._apply_inline_dropped_path_replacement
|
||||
|
||||
def counting_detect(text: str) -> bool:
|
||||
nonlocal detect_calls
|
||||
detect_calls += 1
|
||||
return original_detect(text)
|
||||
|
||||
def counting_replace(text: str) -> bool:
|
||||
nonlocal replace_calls
|
||||
replace_calls += 1
|
||||
return original_replace(text)
|
||||
|
||||
chat._is_dropped_path_payload = counting_detect # type: ignore[method-assign]
|
||||
chat._apply_inline_dropped_path_replacement = counting_replace # type: ignore[method-assign]
|
||||
|
||||
for char in "hello":
|
||||
await pilot.press(char)
|
||||
await pilot.pause()
|
||||
|
||||
assert detect_calls == 0
|
||||
assert replace_calls == 0
|
||||
|
||||
async def test_bulk_text_change_invokes_path_detection(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""Multi-char Changed events (drag-drop / paste) must still detect paths."""
|
||||
target = tmp_path / "dropped.txt"
|
||||
target.write_text("payload")
|
||||
|
||||
app = _ChatInputTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
chat = app.query_one(ChatInput)
|
||||
ta = chat._text_area
|
||||
assert ta is not None
|
||||
|
||||
detect_calls = 0
|
||||
original_detect = chat._is_dropped_path_payload
|
||||
|
||||
def counting_detect(text: str) -> bool:
|
||||
nonlocal detect_calls
|
||||
detect_calls += 1
|
||||
return original_detect(text)
|
||||
|
||||
chat._is_dropped_path_payload = counting_detect # type: ignore[method-assign]
|
||||
|
||||
ta.text = str(target)
|
||||
await pilot.pause()
|
||||
|
||||
assert detect_calls >= 1
|
||||
|
||||
async def test_replacement_edit_with_small_length_delta_detects_path(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""Replacing selected text with a similar-length path should attach it."""
|
||||
img_path = tmp_path / "similar-length.png"
|
||||
from PIL import Image
|
||||
|
||||
image = Image.new("RGB", (3, 3), color="orange")
|
||||
image.save(img_path, format="PNG")
|
||||
|
||||
app = _ImagePasteApp()
|
||||
async with app.run_test() as pilot:
|
||||
chat = app.query_one(ChatInput)
|
||||
ta = chat._text_area
|
||||
assert ta is not None
|
||||
|
||||
ta.text = "x" * len(str(img_path))
|
||||
await pilot.pause()
|
||||
|
||||
ta.text = str(img_path)
|
||||
await pilot.pause()
|
||||
|
||||
assert ta.text == "[image 1] "
|
||||
assert chat.mode == "normal"
|
||||
assert len(app.tracker.get_images()) == 1
|
||||
|
||||
|
||||
class TestBackslashEnterNewline:
|
||||
"""Test that backslash followed quickly by enter inserts a newline.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user