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:
Mason Daugherty
2026-05-11 12:50:36 -07:00
committed by GitHub
parent 7811dca740
commit 06a21e8a69
2 changed files with 141 additions and 2 deletions
+42 -2
View File
@@ -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.