From 7684d4bf7d04081c91e87103c4be89aa80b0e8e5 Mon Sep 17 00:00:00 2001 From: Mason Daugherty <61371264+mdrxy@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:32:41 +0000 Subject: [PATCH 01/12] fix(code): keep rapid typing visible Delay paste-buffer promotion until a rapid embedded newline confirms a key-event paste, preserving multiline grouping without hiding ordinary keystrokes. Co-authored-by: open-swe[bot] --- .../tui/widgets/_inline_prompt.py | 7 +-- .../tui/widgets/_paste_textarea.py | 57 +++++++------------ .../deepagents_code/tui/widgets/chat_input.py | 9 +-- .../unit_tests/tui/widgets/test_chat_input.py | 5 +- .../tui/widgets/test_inline_prompt.py | 5 +- 5 files changed, 35 insertions(+), 48 deletions(-) diff --git a/libs/code/deepagents_code/tui/widgets/_inline_prompt.py b/libs/code/deepagents_code/tui/widgets/_inline_prompt.py index 904a14457b..34e9062744 100644 --- a/libs/code/deepagents_code/tui/widgets/_inline_prompt.py +++ b/libs/code/deepagents_code/tui/widgets/_inline_prompt.py @@ -161,7 +161,7 @@ async def _on_key(self, event: events.Key) -> None: now = time.monotonic() # Drive the shared paste-burst state machine so a paste replayed as rapid - # key events (no bracketed paste) stays grouped and can be collapsed. + # key events (no bracketed paste) stays grouped without delaying typing. if await self._absorb_key_into_burst(event, now): event.prevent_default() event.stop() @@ -172,10 +172,7 @@ async def _on_key(self, event: events.Key) -> None: event.stop() return - if self._track_burst_run(event, now): - event.prevent_default() - event.stop() - return + self._track_burst_run(event, now) if event.key == "backspace" and self._delete_placeholder_token(backwards=True): event.prevent_default() diff --git a/libs/code/deepagents_code/tui/widgets/_paste_textarea.py b/libs/code/deepagents_code/tui/widgets/_paste_textarea.py index ac6c79a660..597f4f084a 100644 --- a/libs/code/deepagents_code/tui/widgets/_paste_textarea.py +++ b/libs/code/deepagents_code/tui/widgets/_paste_textarea.py @@ -2,14 +2,14 @@ Terminals deliver a paste in one of two shapes: a single bracketed `Paste` event, or — when bracketed paste is unavailable — a rapid stream of individual -key events. Both the primary chat input and the inline free-text prompts need -to (a) keep a multi-line paste grouped instead of submitting on the first -embedded newline, and (b) collapse a large paste into a compact -`[Pasted text #N]` placeholder that expands back to the full text on submit. +key events. Both the primary chat input and the inline free-text prompts keep a +multi-line paste grouped instead of submitting on the first embedded newline. +Bracketed and detected multi-line key-event pastes may additionally collapse +into a compact `[Pasted text #N]` placeholder that expands on submit. `PasteBurstTextArea` owns the burst detection and Enter-suppression state machine, leaving policy (slash-command context, whether collapsing is enabled, -how a flushed payload is handled) to overridable hooks. +how a buffered payload is handled) to overridable hooks. `CollapsingPasteTextArea` layers the large-paste collapse + placeholder storage on top, keeping the full content off-screen until submission. """ @@ -258,9 +258,9 @@ def _enter_inserts_newline_during_burst(self, now: float) -> bool: def _should_start_paste_burst(self, char: str) -> bool: """Return whether a keypress should start paste-burst buffering. - Quote-prefixed input at an empty cursor is buffered immediately for - dropped-path parsing. Other printable runs are promoted into the same - buffer once they reach `PASTE_BURST_MIN_CHARS` rapid keystrokes. + Only quote-prefixed input at an empty cursor is buffered for dropped-path + parsing. Other rapid printable runs stay in the document while arming the + Enter-suppression window. """ if char not in PASTE_BURST_START_CHARS: return False @@ -283,32 +283,23 @@ async def _flush_paste_burst(self) -> None: return await self._dispatch_burst_payload(payload) - def _promote_paste_burst_run(self, char: str, now: float) -> bool: - """Move a detected rapid run from the document into the burst buffer. - - The first keys in an unquoted run are inserted normally while the run is - still indistinguishable from typing. Once the threshold is reached, this - removes those keys and buffers the complete run so its eventual flush can - apply dropped-path and paste-collapse policy. - - Args: - char: Current character, which has not yet been inserted. - now: Monotonic timestamp for the current key event. + def _promote_paste_burst_run(self, now: float) -> bool: + """Move an inserted rapid run into the buffer after its first newline. Returns: - `True` when the run was promoted and the current key was buffered. + Whether the visible rapid run moved into the paste buffer. """ - if not char or not self.selection.is_empty: + payload = self._paste_burst_run_text + if not payload or not self.selection.is_empty: return False - prefix = self._paste_burst_run_text[: -len(char)] cursor = self.cursor_location cursor_offset = self.document.get_index_from_location(cursor) # ty: ignore[unresolved-attribute] # Document has this method; DocumentBase stub is narrower - start_offset = cursor_offset - len(prefix) - if start_offset < 0 or self.text[start_offset:cursor_offset] != prefix: + start_offset = cursor_offset - len(payload) + if start_offset < 0 or self.text[start_offset:cursor_offset] != payload: return False start = self.document.get_location_from_index(start_offset) # ty: ignore[unresolved-attribute] self.delete(start, cursor) - self._start_paste_burst(self._paste_burst_run_text, now) + self._start_paste_burst(payload, now) return True def action_insert_newline(self) -> None: @@ -357,13 +348,8 @@ def _maybe_start_burst(self, event: events.Key, now: float) -> bool: return True return False - def _track_burst_run(self, event: events.Key, now: float) -> bool: - """Track a rapid run and promote it into the paste buffer once detected. - - Returns: - `True` when the current key was buffered and should not be handled by - the caller. - """ + def _track_burst_run(self, event: events.Key, now: float) -> None: + """Track a rapid run and arm its Enter-suppression window.""" if event.is_printable and event.character is not None: self._paste_burst_last_suppressed_enter_time = None self._note_paste_burst_keystroke(event.character, now) @@ -374,10 +360,8 @@ def _track_burst_run(self, event: events.Key, now: float) -> bool: self._paste_burst_window_until = ( now + PASTE_ENTER_SUPPRESS_WINDOW_SECONDS ) - return self._promote_paste_burst_run(event.character, now) elif event.key != "enter": self._reset_paste_burst_run() - return False def _consume_enter_as_burst_newline(self, now: float) -> bool: """Insert a newline instead of submitting when inside a paste burst. @@ -391,7 +375,10 @@ def _consume_enter_as_burst_newline(self, now: float) -> bool: return False self._paste_burst_window_until = now + PASTE_ENTER_SUPPRESS_WINDOW_SECONDS self._paste_burst_last_suppressed_enter_time = now - self.action_insert_newline() + if not self._paste_burst_buffer and self._promote_paste_burst_run(now): + self._append_paste_burst("\n", now) + else: + self.action_insert_newline() return True # -- Newline affordances (shared by concrete text areas) ------------------ diff --git a/libs/code/deepagents_code/tui/widgets/chat_input.py b/libs/code/deepagents_code/tui/widgets/chat_input.py index dbf1bf6882..0517417c7d 100644 --- a/libs/code/deepagents_code/tui/widgets/chat_input.py +++ b/libs/code/deepagents_code/tui/widgets/chat_input.py @@ -950,12 +950,9 @@ async def _on_key(self, event: events.Key) -> None: event.stop() return - # Promote rapid keystroke runs into the paste buffer so terminals without - # bracketed paste still get newline grouping and large-paste collapsing. - if self._track_burst_run(event, now): - event.prevent_default() - event.stop() - return + # Track rapid keystroke runs so terminals without bracketed paste keep + # embedded newlines grouped without delaying ordinary text insertion. + self._track_burst_run(event, now) # A mode trigger (`!`, `!!`, `/`) typed at the very start of an # unselected input switches modes. Handle it before TextArea inserts the diff --git a/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py b/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py index ef336e6d6b..4f0064bca9 100644 --- a/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py +++ b/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py @@ -4498,7 +4498,7 @@ class TestPasteBurstEnterSuppression: async def test_rapid_burst_with_newline_does_not_submit( self, monkeypatch: pytest.MonkeyPatch ) -> None: - """A fast keystroke run followed by enter inserts a newline.""" + """A fast keystroke run stays visible and enter inserts a newline.""" # Widen the burst gap so wall-clock delays between pilot.press calls on # slow CI runners still register as a single rapid burst. monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 60.0) @@ -4514,6 +4514,9 @@ async def test_rapid_burst_with_newline_does_not_submit( for char in "hello": await pilot.press(char) + assert ta.text == "hello" + assert ta._paste_burst_buffer == "" + await pilot.press("enter") await pilot.press("w") await pilot.pause(0.15) diff --git a/libs/code/tests/unit_tests/tui/widgets/test_inline_prompt.py b/libs/code/tests/unit_tests/tui/widgets/test_inline_prompt.py index cc139e7e78..9d121563c0 100644 --- a/libs/code/tests/unit_tests/tui/widgets/test_inline_prompt.py +++ b/libs/code/tests/unit_tests/tui/widgets/test_inline_prompt.py @@ -190,7 +190,7 @@ async def test_modified_backspace_after_tab_deletes_placeholder_atomically( async def test_key_burst_with_newline_does_not_submit( self, monkeypatch: pytest.MonkeyPatch ) -> None: - """A multi-line paste replayed as key events inserts a newline, no submit.""" + """Rapid key-event text stays visible and inserts a newline, no submit.""" # Widen the burst gap/window so wall-clock delays on slow runners still # register as one rapid burst. monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 60.0) @@ -205,6 +205,9 @@ async def test_key_burst_with_newline_does_not_submit( for char in "hello": await pilot.press(char) + assert ta.text == "hello" + assert ta._paste_burst_buffer == "" + await pilot.press("enter") await pilot.press("w") await pilot.pause(0.15) From 160bbd9b1ab333040b0b8458324382b2baa705c5 Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Tue, 11 Aug 2026 15:23:11 -0700 Subject: [PATCH 02/12] fix(code): reject unquoted media key bursts in inline prompts A terminal without bracketed paste replays a dropped media file as rapid key events. The previous fix for fast typing delayed burst promotion until a newline arrived, which meant single-line unquoted drops were never promoted into the burst buffer and therefore never rejected by _reject_dropped_media. Add _check_burst_run_for_dropped_media to the shared paste-burst state machine. After each printable key is inserted, if the detected run reaches burst threshold and matches the dropped-path shape (/, ~, drive letter, file://, UNC), promote it immediately and flush so the subclass rejection hook runs. Ordinary fast typing is unaffected because it never starts with those characters. Add regression tests for both the unquoted media burst rejection and for rapid typing staying visible. --- .../tui/widgets/_inline_prompt.py | 6 +++ .../tui/widgets/_paste_textarea.py | 19 ++++++++ .../deepagents_code/tui/widgets/chat_input.py | 6 +++ .../tui/widgets/test_inline_prompt.py | 46 +++++++++++++++++++ 4 files changed, 77 insertions(+) diff --git a/libs/code/deepagents_code/tui/widgets/_inline_prompt.py b/libs/code/deepagents_code/tui/widgets/_inline_prompt.py index 34e9062744..d6f83ce00e 100644 --- a/libs/code/deepagents_code/tui/widgets/_inline_prompt.py +++ b/libs/code/deepagents_code/tui/widgets/_inline_prompt.py @@ -202,6 +202,12 @@ async def _on_key(self, event: events.Key) -> None: await super()._on_key(event) + # After the key is inserted, check whether a detected rapid run looks + # like a dropped media path and promote it into the burst buffer for + # rejection. Must run after `super()._on_key` so the current character + # is already in the document and `_promote_paste_burst_run` can find it. + await self._check_burst_run_for_dropped_media() + async def _on_paste(self, event: events.Paste) -> None: """Reject a dragged media file, else defer to shared paste handling.""" # Flush first, matching the base handler: a rejection returns early, and diff --git a/libs/code/deepagents_code/tui/widgets/_paste_textarea.py b/libs/code/deepagents_code/tui/widgets/_paste_textarea.py index 597f4f084a..04cb056f72 100644 --- a/libs/code/deepagents_code/tui/widgets/_paste_textarea.py +++ b/libs/code/deepagents_code/tui/widgets/_paste_textarea.py @@ -22,6 +22,7 @@ from textual.binding import Binding from textual.widgets import TextArea +from deepagents_code.input import looks_like_dropped_payload from deepagents_code.paste_collapse import ( PASTE_PLACEHOLDER_PATTERN, PastedContent, @@ -363,6 +364,24 @@ def _track_burst_run(self, event: events.Key, now: float) -> None: elif event.key != "enter": self._reset_paste_burst_run() + async def _check_burst_run_for_dropped_media(self) -> None: + """Promote a rapid run into the burst buffer if it looks like a dropped path. + + Called after each printable key event when the run reaches burst + threshold. Dropped paths start with a shape (`/`, `~`, drive letter, + `file://`, UNC) that fast typing never produces, so promoting now keeps + ordinary typing visible while still catching unquoted drops. The + promotion is async because it may trigger an immediate flush when the + payload is rejected by a subclass hook. + """ + if ( + self._paste_burst_run >= PASTE_BURST_MIN_CHARS + and not self._paste_burst_buffer + and looks_like_dropped_payload(self._paste_burst_run_text) + ): + self._promote_paste_burst_run(self._paste_burst_last_key_time or 0.0) + await self._flush_paste_burst() + def _consume_enter_as_burst_newline(self, now: float) -> bool: """Insert a newline instead of submitting when inside a paste burst. diff --git a/libs/code/deepagents_code/tui/widgets/chat_input.py b/libs/code/deepagents_code/tui/widgets/chat_input.py index 0517417c7d..2416981ca5 100644 --- a/libs/code/deepagents_code/tui/widgets/chat_input.py +++ b/libs/code/deepagents_code/tui/widgets/chat_input.py @@ -1029,6 +1029,12 @@ async def _on_key(self, event: events.Key) -> None: await super()._on_key(event) + # After the key is inserted, check whether a detected rapid run looks + # like a dropped media path and promote it into the burst buffer for + # rejection. Must run after `super()._on_key` so the current character + # is already in the document and `_promote_paste_burst_run` can find it. + await self._check_burst_run_for_dropped_media() + def action_delete_right(self) -> None: """Delete a bound placeholder atomically or the next character.""" if not self._delete_placeholder_token(backwards=False): diff --git a/libs/code/tests/unit_tests/tui/widgets/test_inline_prompt.py b/libs/code/tests/unit_tests/tui/widgets/test_inline_prompt.py index 9d121563c0..d8010c65cf 100644 --- a/libs/code/tests/unit_tests/tui/widgets/test_inline_prompt.py +++ b/libs/code/tests/unit_tests/tui/widgets/test_inline_prompt.py @@ -215,6 +215,25 @@ async def test_key_burst_with_newline_does_not_submit( assert app.submissions == [] assert "\n" in ta.text + async def test_rapid_typing_stays_visible( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Rapid ordinary typing is not hidden by the dropped-media check.""" + monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 60.0) + app = _PromptApp() + async with app.run_test() as pilot: + ta = app.query_one(InlinePromptTextArea) + ta.focus() + await pilot.pause() + + for char in "hello": + await pilot.press(char) + await pilot.pause() + + assert ta.text == "hello" + assert ta._paste_burst_buffer == "" + assert not list(app._notifications) + async def test_deliberate_enter_submits( self, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -527,6 +546,33 @@ async def test_key_burst_of_video_is_rejected(self, tmp_path: Path) -> None: assert latest.message.startswith(MEDIA_UNSUPPORTED_TOAST_PREFIX) assert "clip.mp4" in latest.message + async def test_unquoted_media_key_burst_is_rejected( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An unquoted media path replayed as rapid keys is rejected, not typed.""" + monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 60.0) + monkeypatch.setattr( + paste_textarea_module, "PASTE_BURST_FLUSH_DELAY_SECONDS", 0.25 + ) + clip = tmp_path / "clip.mp4" + clip.write_bytes(b"vid") + app = _PromptApp() + async with app.run_test() as pilot: + ta = app.query_one(InlinePromptTextArea) + ta.focus() + await pilot.pause() + + for char in str(clip): + event = Key(char, char) + await ta._on_key(event) + + await pilot.pause(0.35) + + assert ta.text == "" + latest = list(app._notifications)[-1] + assert latest.message.startswith(MEDIA_UNSUPPORTED_TOAST_PREFIX) + assert "clip.mp4" in latest.message + async def test_non_media_path_burst_is_inserted( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From a3b9f0b290cb3533a2e62d135b98ee72b6487501 Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Tue, 11 Aug 2026 15:51:11 -0700 Subject: [PATCH 03/12] fix(code): keep rapid typing visible until a paste is confirmed A rapid keystroke run now stays in the document until something confirms it is a paste: an embedded newline, a dropped-path shape, or a length no human reaches at burst speed. Only then is the run promoted into the hidden burst buffer, so ordinary fast typing is never pulled off screen. Also fixes the space-key early return bypassing burst tracking, which desynchronised the run tracker from the document and failed every later promotion in that paste. --- .../tui/widgets/_inline_prompt.py | 10 +- .../tui/widgets/_paste_textarea.py | 179 ++++++++--- .../deepagents_code/tui/widgets/chat_input.py | 40 ++- .../unit_tests/tui/widgets/test_chat_input.py | 282 ++++++++++++++++++ .../tui/widgets/test_inline_prompt.py | 7 + 5 files changed, 468 insertions(+), 50 deletions(-) diff --git a/libs/code/deepagents_code/tui/widgets/_inline_prompt.py b/libs/code/deepagents_code/tui/widgets/_inline_prompt.py index d6f83ce00e..88d5b70cd0 100644 --- a/libs/code/deepagents_code/tui/widgets/_inline_prompt.py +++ b/libs/code/deepagents_code/tui/widgets/_inline_prompt.py @@ -202,11 +202,11 @@ async def _on_key(self, event: events.Key) -> None: await super()._on_key(event) - # After the key is inserted, check whether a detected rapid run looks - # like a dropped media path and promote it into the burst buffer for - # rejection. Must run after `super()._on_key` so the current character - # is already in the document and `_promote_paste_burst_run` can find it. - await self._check_burst_run_for_dropped_media() + # After the key is inserted, promote the rapid run if its shape (dropped + # path, for media rejection) or size already confirms a paste. Must run + # after `super()._on_key` so the current character is already in the + # document and `_promote_paste_burst_run` can find it. + self._check_burst_run_for_promotion() async def _on_paste(self, event: events.Paste) -> None: """Reject a dragged media file, else defer to shared paste handling.""" diff --git a/libs/code/deepagents_code/tui/widgets/_paste_textarea.py b/libs/code/deepagents_code/tui/widgets/_paste_textarea.py index 04cb056f72..d93d31e57c 100644 --- a/libs/code/deepagents_code/tui/widgets/_paste_textarea.py +++ b/libs/code/deepagents_code/tui/widgets/_paste_textarea.py @@ -4,12 +4,18 @@ event, or — when bracketed paste is unavailable — a rapid stream of individual key events. Both the primary chat input and the inline free-text prompts keep a multi-line paste grouped instead of submitting on the first embedded newline. -Bracketed and detected multi-line key-event pastes may additionally collapse -into a compact `[Pasted text #N]` placeholder that expands on submit. +Bracketed and detected key-event pastes may additionally collapse into a compact +`[Pasted text #N]` placeholder that expands on submit, and dropped-path payloads +(quoted, or bare paths detected by shape) are routed to path/media handling. + +Ordinary typing is never hidden: a rapid run stays in the document until +something confirms it is a paste — an embedded newline, a dropped-path shape, or +a length no human reaches at burst speed — at which point it is promoted into +the hidden buffer. `PasteBurstTextArea` owns the burst detection and Enter-suppression state machine, leaving policy (slash-command context, whether collapsing is enabled, -how a buffered payload is handled) to overridable hooks. +how a flushed payload is handled) to overridable hooks. `CollapsingPasteTextArea` layers the large-paste collapse + placeholder storage on top, keeping the full content off-screen until submission. """ @@ -25,6 +31,7 @@ from deepagents_code.input import looks_like_dropped_payload from deepagents_code.paste_collapse import ( PASTE_PLACEHOLDER_PATTERN, + PASTE_THRESHOLD_CHARS, PastedContent, count_lines, expand_paste_refs, @@ -53,6 +60,21 @@ Terminals that lack bracketed paste replay a paste as individual key events. Counting a short run of rapid chars distinguishes that from human typing, which has much larger inter-key gaps. + +Reaching this count does not by itself hide the run: it arms the Enter +suppression window and makes the run eligible for promotion into +`_paste_burst_buffer`. Promotion itself needs further evidence of a paste — see +`_check_burst_run_for_promotion` and `_consume_enter_as_burst_newline`. +""" + +PASTE_BURST_PROMOTE_CHARS = PASTE_THRESHOLD_CHARS +"""Rapid-run length that on its own confirms a key-event paste. + +A run this long arriving at burst speed (each char within +`PASTE_BURST_CHAR_GAP_SECONDS`) is unreachable by human typing, so it is +promoted into the buffer even without an embedded newline. Matched to the +collapse threshold so a large single-line key-event paste still collapses into +a `[Pasted text #N]` placeholder. """ PASTE_ENTER_SUPPRESS_WINDOW_SECONDS = 0.12 @@ -234,10 +256,18 @@ def _enter_inserts_newline_during_burst(self, now: float) -> bool: True when the preceding keystroke was part of a rapid run or the previous `enter` was already suppressed, and the suppression window is - still open. The char-gap check keeps a deliberate `enter` pressed after - a burst settles from being swallowed; the window bounds how long a - replayed paste's newlines stay grouped. Returns `False` immediately in - slash-command context (see `_in_slash_command_context`). + still open. The window bounds how long a replayed paste's newlines stay + grouped. Returns `False` immediately in slash-command context (see + `_in_slash_command_context`). + + A run that already reached `PASTE_BURST_MIN_CHARS` is trusted for the + whole window: the newline of a key-event paste can arrive well after the + surrounding characters when it lands across a terminal read boundary + (~50 ms is ordinary over SSH), and requiring it within + `PASTE_BURST_CHAR_GAP_SECONDS` would submit mid-paste. A run only + reaches that count at machine speed, so this cannot swallow a human's + deliberate `enter`; the char-gap fallback below covers the case where a + window is open without a qualifying run (e.g. just after a flush). """ if self._in_slash_command_context(): return False @@ -254,14 +284,19 @@ def _enter_inserts_newline_during_burst(self, now: float) -> bool: if last_enter is not None: return True last_key = self._paste_burst_last_key_time - return last_key is not None and (now - last_key) <= PASTE_BURST_CHAR_GAP_SECONDS + if last_key is None: + return False + if self._paste_burst_run >= PASTE_BURST_MIN_CHARS: + return True + return (now - last_key) <= PASTE_BURST_CHAR_GAP_SECONDS def _should_start_paste_burst(self, char: str) -> bool: """Return whether a keypress should start paste-burst buffering. - Only quote-prefixed input at an empty cursor is buffered for dropped-path - parsing. Other rapid printable runs stay in the document while arming the - Enter-suppression window. + Only a quote typed into an empty document buffers immediately, for + dropped-path parsing. Other rapid printable runs stay visible in the + document until something confirms a paste and promotes them — see + `_check_burst_run_for_promotion` and `_consume_enter_as_burst_newline`. """ if char not in PASTE_BURST_START_CHARS: return False @@ -285,10 +320,25 @@ async def _flush_paste_burst(self) -> None: await self._dispatch_burst_payload(payload) def _promote_paste_burst_run(self, now: float) -> bool: - """Move an inserted rapid run into the buffer after its first newline. + """Move an already-inserted rapid run out of the document into the buffer. + + Deletes the run's characters from the document — they are visible on + screen at this point — and hands them to `_start_paste_burst` so the + eventual flush can apply dropped-path and paste-collapse policy. + + All guards run before any mutation, so a `False` return never leaves a + partially-promoted document. + + Args: + now: Monotonic timestamp for the current key event. Returns: - Whether the visible rapid run moved into the paste buffer. + `True` when the run was verified present immediately before the + cursor and moved into the buffer. `False` when promotion is unsafe: + an empty run, a non-empty selection (deleting would clobber the + user's selected range), or a document that no longer ends with the + tracked run — which means an intervening edit desynchronised the + tracker. Callers must fall back to handling the key normally. """ payload = self._paste_burst_run_text if not payload or not self.selection.is_empty: @@ -297,10 +347,25 @@ def _promote_paste_burst_run(self, now: float) -> bool: cursor_offset = self.document.get_index_from_location(cursor) # ty: ignore[unresolved-attribute] # Document has this method; DocumentBase stub is narrower start_offset = cursor_offset - len(payload) if start_offset < 0 or self.text[start_offset:cursor_offset] != payload: + # The tracker's model of the document is wrong, so every later + # promotion in this run would fail the same way. Log it (never the + # payload itself, which is user content) and drop the stale run so + # tracking restarts cleanly on the next keystroke. + logger.debug( + "Burst run diverged from document (run=%d chars, start=%d); " + "skipping promotion", + len(payload), + start_offset, + ) + self._reset_paste_burst_run() return False start = self.document.get_location_from_index(start_offset) # ty: ignore[unresolved-attribute] self.delete(start, cursor) self._start_paste_burst(payload, now) + # The buffer now owns these characters; clearing the run keeps the + # "run tracks visible text, buffer tracks hidden text" invariant true by + # construction, so a later Enter cannot re-promote the same stale text. + self._reset_paste_burst_run() return True def action_insert_newline(self) -> None: @@ -350,37 +415,71 @@ def _maybe_start_burst(self, event: events.Key, now: float) -> bool: return False def _track_burst_run(self, event: events.Key, now: float) -> None: - """Track a rapid run and arm its Enter-suppression window.""" + """Track a rapid run, arming Enter suppression once it looks like a paste. + + The key stays in the document; see `_note_printable_burst_keystroke`. + """ if event.is_printable and event.character is not None: - self._paste_burst_last_suppressed_enter_time = None - self._note_paste_burst_keystroke(event.character, now) - if ( - self._paste_burst_run >= PASTE_BURST_MIN_CHARS - and not self._in_slash_command_context() - ): - self._paste_burst_window_until = ( - now + PASTE_ENTER_SUPPRESS_WINDOW_SECONDS - ) + self._note_printable_burst_keystroke(event.character, now) elif event.key != "enter": self._reset_paste_burst_run() - async def _check_burst_run_for_dropped_media(self) -> None: - """Promote a rapid run into the burst buffer if it looks like a dropped path. + def _note_printable_burst_keystroke(self, char: str, now: float) -> None: + """Record a printable char in the rapid run and arm Enter suppression. + + Arming is conditional: the run must reach `PASTE_BURST_MIN_CHARS` and + must not be a slash command, where Enter always submits. + + Call this for any character that reaches the document without passing + through `_track_burst_run` — a caller that inserts text itself and + returns early must still keep the tracker in sync, or the run text will + diverge from the document and every promotion in this paste will fail. - Called after each printable key event when the run reaches burst - threshold. Dropped paths start with a shape (`/`, `~`, drive letter, - `file://`, UNC) that fast typing never produces, so promoting now keeps - ordinary typing visible while still catching unquoted drops. The - promotion is async because it may trigger an immediate flush when the - payload is rejected by a subclass hook. + Args: + char: The character being inserted into the document. + now: Monotonic timestamp for the current key event. """ + self._paste_burst_last_suppressed_enter_time = None + self._note_paste_burst_keystroke(char, now) if ( self._paste_burst_run >= PASTE_BURST_MIN_CHARS - and not self._paste_burst_buffer - and looks_like_dropped_payload(self._paste_burst_run_text) + and not self._in_slash_command_context() + ): + self._paste_burst_window_until = now + PASTE_ENTER_SUPPRESS_WINDOW_SECONDS + + def _check_burst_run_for_promotion(self) -> None: + """Promote a rapid run whose shape or size already confirms a paste. + + Called after each printable key has been inserted, so the run is present + in the document and `_promote_paste_burst_run` can find it. Two + confirmations do not need to wait for a newline: + + - A dropped-path shape (`/`, `~`, drive letter, `file://`, UNC), which + fast typing never produces. Without this, an unquoted single-line drop + would never reach path parsing or media rejection. + - A run past `PASTE_BURST_PROMOTE_CHARS`, which no human reaches at + burst speed. Without this, a large single-line key-event paste would + never collapse into a placeholder. + + Neither case flushes here. The paste is still streaming, so the run is + only a prefix of the payload; once promoted, the remaining characters + are absorbed straight into the buffer by `_absorb_key_into_burst` and + the idle timer flushes the complete payload when the stream stops. + Flushing per keystroke would instead hand each prefix to + `_dispatch_burst_payload` — re-running path parsing, and its filesystem + probes, once per character. + + Ordinary typing is unaffected: it neither starts with a path shape nor + reaches the length threshold within the burst gap. + """ + if self._paste_burst_buffer or self._paste_burst_run < PASTE_BURST_MIN_CHARS: + return + payload = self._paste_burst_run_text + if ( + looks_like_dropped_payload(payload) + or len(payload) >= PASTE_BURST_PROMOTE_CHARS ): self._promote_paste_burst_run(self._paste_burst_last_key_time or 0.0) - await self._flush_paste_burst() def _consume_enter_as_burst_newline(self, now: float) -> bool: """Insert a newline instead of submitting when inside a paste burst. @@ -392,12 +491,20 @@ def _consume_enter_as_burst_newline(self, now: float) -> bool: if not self._enter_inserts_newline_during_burst(now): self._paste_burst_last_suppressed_enter_time = None return False - self._paste_burst_window_until = now + PASTE_ENTER_SUPPRESS_WINDOW_SECONDS - self._paste_burst_last_suppressed_enter_time = now + # This newline confirms a multi-line key-event paste, so pull the + # still-visible run into the buffer and keep the newline with it. The + # `_paste_burst_buffer` guard mirrors the one at + # `_enter_inserts_newline_during_burst`: the shipped `_on_key`s absorb or + # flush an active buffer before Enter reaches here, so it is defensive + # today, but promoting on top of a live buffer would double-count the run. if not self._paste_burst_buffer and self._promote_paste_burst_run(now): self._append_paste_burst("\n", now) else: self.action_insert_newline() + # Set after promotion: `_promote_paste_burst_run` resets run tracking, + # which clears `_paste_burst_last_suppressed_enter_time`. + self._paste_burst_window_until = now + PASTE_ENTER_SUPPRESS_WINDOW_SECONDS + self._paste_burst_last_suppressed_enter_time = now return True # -- Newline affordances (shared by concrete text areas) ------------------ diff --git a/libs/code/deepagents_code/tui/widgets/chat_input.py b/libs/code/deepagents_code/tui/widgets/chat_input.py index 2416981ca5..79688aeb8b 100644 --- a/libs/code/deepagents_code/tui/widgets/chat_input.py +++ b/libs/code/deepagents_code/tui/widgets/chat_input.py @@ -898,6 +898,11 @@ async def _dispatch_burst_payload(self, payload: str) -> None: return self.insert(payload) + # A multi-line payload adds rows the same way `action_insert_newline` + # does, and needs the same post-refresh scroll for the same reason: the + # built-in scroll sees stale dimensions and leaves the cursor off screen. + if "\n" in payload: + self.call_after_refresh(self.scroll_cursor_visible) async def _on_key(self, event: events.Key) -> None: """Handle key events.""" @@ -929,7 +934,18 @@ async def _on_key(self, event: events.Key) -> None: if event.key == "space" and event.character is None: event.prevent_default() event.stop() - self.insert(" ") + # This branch bypasses the burst helpers below, so it has to drive + # them itself: a space inside a replayed paste must reach the buffer + # (if one is live) or the run tracker (if not), otherwise the + # tracker's text diverges from the document and every promotion for + # the rest of that paste fails the verification in + # `_promote_paste_burst_run`. + space_now = time.monotonic() + if self._paste_burst_buffer: + self._append_paste_burst(" ", space_now) + else: + self.insert(" ") + self._note_printable_burst_keystroke(" ", space_now) self.post_message(self.Typing()) return @@ -968,6 +984,10 @@ async def _on_key(self, event: events.Key) -> None: ): event.prevent_default() event.stop() + # `_track_burst_run` above already counted this character, but it is + # consumed as a mode switch rather than inserted. Drop the run so the + # tracker does not claim a character the document never received. + self._reset_paste_burst_run() return # Some terminals (e.g. VSCode built-in) send a literal backslash @@ -1009,9 +1029,11 @@ async def _on_key(self, event: events.Key) -> None: return # Plain Enter submits, unless a recent keystroke burst suggests this - # newline is part of a paste replayed as key events; then insert a - # newline and keep the window alive so the rest of the paste stays - # grouped instead of submitting mid-stream. + # newline is part of a paste replayed as key events. In that case the + # visible run is pulled off screen into the paste buffer along with this + # newline, and the window is kept alive so the rest of the paste stays + # grouped instead of submitting mid-stream. The text reappears when the + # burst flushes — possibly as a `[Pasted text #N]` placeholder. if event.key == "enter": event.prevent_default() event.stop() @@ -1029,11 +1051,11 @@ async def _on_key(self, event: events.Key) -> None: await super()._on_key(event) - # After the key is inserted, check whether a detected rapid run looks - # like a dropped media path and promote it into the burst buffer for - # rejection. Must run after `super()._on_key` so the current character - # is already in the document and `_promote_paste_burst_run` can find it. - await self._check_burst_run_for_dropped_media() + # After the key is inserted, promote the rapid run if its shape (dropped + # path, for path routing) or size already confirms a paste. Must run + # after `super()._on_key` so the current character is already in the + # document and `_promote_paste_burst_run` can find it. + self._check_burst_run_for_promotion() def action_delete_right(self) -> None: """Delete a bound placeholder atomically or the next character.""" diff --git a/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py b/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py index 4f0064bca9..96250743e8 100644 --- a/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py +++ b/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py @@ -4658,6 +4658,288 @@ async def test_slash_command_enter_still_submits_during_burst(self) -> None: assert len(app.submitted) == 1 + async def test_late_newline_in_burst_does_not_submit( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A newline arriving after the char gap still groups with its burst. + + A key-event paste's `enter` can land in a later terminal read than the + characters around it (~50 ms is ordinary over SSH). The suppression + window, not the much tighter char gap, is what bounds the burst. + """ + monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 0.03) + monkeypatch.setattr( + paste_textarea_module, "PASTE_ENTER_SUPPRESS_WINDOW_SECONDS", 0.12 + ) + + app = _RecordingApp() + async with app.run_test() as pilot: + chat = app.query_one(ChatInput) + ta = chat._text_area + assert ta is not None + + ta.text = "abc" + now = chat_input_module.time.monotonic() + # A qualifying run, but the last keystroke landed 50 ms ago — + # outside the 30 ms char gap, still inside the 120 ms window. + ta._paste_burst_run = paste_textarea_module.PASTE_BURST_MIN_CHARS + ta._paste_burst_run_text = "abc" + ta._paste_burst_last_key_time = now - 0.05 + ta._paste_burst_window_until = now + 0.12 + + await ta._on_key(events.Key("enter", None)) + await pilot.pause() + + assert len(app.submitted) == 0 + + +class TestPasteBurstPromotion: + """Promotion of a visible rapid run into the hidden paste buffer. + + Rapid typing stays in the document until something confirms a paste: an + embedded newline, a dropped-path shape, or a length no human reaches at + burst speed. These tests drive the real `_on_key` path, since the chat + input's key handling interleaves several branches ahead of the burst + helpers. + """ + + async def test_multiline_key_event_paste_collapses( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A multi-line key-event paste is promoted and collapsed.""" + monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 60.0) + monkeypatch.setattr( + paste_textarea_module, "PASTE_ENTER_SUPPRESS_WINDOW_SECONDS", 60.0 + ) + monkeypatch.setattr( + paste_textarea_module, "PASTE_BURST_FLUSH_DELAY_SECONDS", 0.25 + ) + payload = "alpha\n" + "beta gamma delta\n" * 3 + app = _RecordingApp() + async with app.run_test() as pilot: + chat = app.query_one(ChatInput) + ta = chat._text_area + assert ta is not None + + for char in payload: + event = ( + events.Key("enter", None) + if char == "\n" + else events.Key(char, char) + ) + await ta._on_key(event) + await pilot.pause(0.35) + + assert "[Pasted text #1" in ta.text + assert chat._pasted_contents[1].content == payload + assert len(app.submitted) == 0 + + async def test_large_single_line_key_event_paste_collapses( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A long single-line key-event paste collapses without any newline. + + `should_collapse_paste` triggers on length as well as line count, so a + newline must not be required to reach collapse handling. + """ + monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 60.0) + monkeypatch.setattr( + paste_textarea_module, "PASTE_BURST_FLUSH_DELAY_SECONDS", 0.25 + ) + payload = "y" * 900 + app = _RecordingApp() + async with app.run_test() as pilot: + chat = app.query_one(ChatInput) + ta = chat._text_area + assert ta is not None + + for char in payload: + await ta._on_key(events.Key(char, char)) + await pilot.pause(0.35) + + assert "[Pasted text #1]" in ta.text + assert payload not in ta.text + assert chat._pasted_contents[1].content == payload + + async def test_ordinary_rapid_typing_is_never_promoted( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A short rapid run with no paste evidence stays fully visible.""" + monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 60.0) + + app = _RecordingApp() + async with app.run_test() as pilot: + chat = app.query_one(ChatInput) + ta = chat._text_area + assert ta is not None + + for char in "hello world": + await pilot.press(char) + await pilot.pause(0.15) + + assert ta.text == "hello world" + assert ta._paste_burst_buffer == "" + + async def test_promotion_falls_back_when_selection_is_active( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A selection at Enter blocks promotion; the newline is inserted plainly. + + Promoting would delete the user's selected range rather than the run. + """ + monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 60.0) + monkeypatch.setattr( + paste_textarea_module, "PASTE_ENTER_SUPPRESS_WINDOW_SECONDS", 60.0 + ) + + app = _RecordingApp() + async with app.run_test() as pilot: + chat = app.query_one(ChatInput) + ta = chat._text_area + assert ta is not None + + for char in "abc": + await pilot.press(char) + ta.selection = Selection((0, 0), (0, 3)) + + await ta._on_key(events.Key("enter", None)) + await pilot.pause() + + assert ta._paste_burst_buffer == "" + assert "abc" in ta.text + assert len(app.submitted) == 0 + + async def test_promotion_falls_back_when_run_diverges_from_document( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A run that no longer sits before the cursor is dropped, not deleted. + + Moving the cursor mid-run (e.g. a mouse click, which is not a key + event) desynchronises the tracker. Promoting on a stale run would + delete whatever text now happens to precede the cursor. + """ + monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 60.0) + monkeypatch.setattr( + paste_textarea_module, "PASTE_ENTER_SUPPRESS_WINDOW_SECONDS", 60.0 + ) + + app = _RecordingApp() + async with app.run_test() as pilot: + chat = app.query_one(ChatInput) + ta = chat._text_area + assert ta is not None + + ta.text = "XXXX" + for char in "abc": + await pilot.press(char) + # Relocate the cursor without a key event, so the run survives but + # no longer describes the characters before the cursor. + ta.selection = Selection((0, 2), (0, 2)) + + await ta._on_key(events.Key("enter", None)) + await pilot.pause() + + assert ta._paste_burst_buffer == "" + # Every character survives; only a newline was added at the cursor. + assert ta.text.replace("\n", "") == "abcXXXX" + assert ta.text.count("\n") == 1 + # The diverged run is dropped so it cannot be re-promoted later. + assert ta._paste_burst_run_text == "" + + async def test_vscode_space_workaround_keeps_run_in_sync( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A CSI-u space is tracked, so a burst containing one still promotes. + + VS Code sends space as a key with no character; the workaround inserts + it directly and returns before the burst helpers, so it must feed the + tracker itself or the run text diverges from the document. + """ + monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 60.0) + monkeypatch.setattr( + paste_textarea_module, "PASTE_ENTER_SUPPRESS_WINDOW_SECONDS", 60.0 + ) + monkeypatch.setattr( + paste_textarea_module, "PASTE_BURST_FLUSH_DELAY_SECONDS", 0.25 + ) + + app = _RecordingApp() + async with app.run_test() as pilot: + chat = app.query_one(ChatInput) + ta = chat._text_area + assert ta is not None + + for char in "ab": + await ta._on_key(events.Key(char, char)) + await ta._on_key(events.Key("space", None)) + for char in "cd": + await ta._on_key(events.Key(char, char)) + + assert ta.text == "ab cd" + assert ta._paste_burst_run_text == "ab cd" + + await ta._on_key(events.Key("enter", None)) + await pilot.pause() + + # Promotion succeeded, so the run moved into the buffer rather than + # failing verification and falling back to a plain newline. + assert ta.text == "" + assert ta._paste_burst_buffer == "ab cd\n" + assert len(app.submitted) == 0 + + async def test_flushed_run_is_not_re_promoted_by_a_later_enter( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A second Enter after a flush must not re-promote the same run. + + Promotion hands the run's characters to the buffer, which flushes them + back into the document. If the run tracker still claimed them, a second + Enter inside the window would find them sitting before the cursor, + delete them, and re-dispatch text the user had already committed. + """ + monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 60.0) + monkeypatch.setattr( + paste_textarea_module, "PASTE_ENTER_SUPPRESS_WINDOW_SECONDS", 60.0 + ) + monkeypatch.setattr( + paste_textarea_module, "PASTE_BURST_FLUSH_DELAY_SECONDS", 0.25 + ) + + app = _RecordingApp() + async with app.run_test() as pilot: + chat = app.query_one(ChatInput) + ta = chat._text_area + assert ta is not None + + for char in "abc": + await ta._on_key(events.Key(char, char)) + await ta._on_key(events.Key("enter", None)) + for char in "abc": + await ta._on_key(events.Key(char, char)) + # Let the burst flush its payload back into the document. + await pilot.pause(0.35) + assert ta.text == "abc\nabc" + assert ta._paste_burst_buffer == "" + + await ta._on_key(events.Key("enter", None)) + await pilot.pause() + + # A newline was added; the trailing "abc" was not swallowed. + assert ta.text == "abc\nabc\n" + + async def test_consumed_mode_prefix_resets_the_run(self) -> None: + """A mode trigger is counted but never inserted, so it clears the run.""" + app = _RecordingApp() + async with app.run_test() as pilot: + chat = app.query_one(ChatInput) + ta = chat._text_area + assert ta is not None + + await ta._on_key(events.Key("!", "!")) + await pilot.pause() + + assert ta._paste_burst_run_text == "" + class TestPasteCollapseHelpers: """Unit tests for the paste_collapse module helpers.""" diff --git a/libs/code/tests/unit_tests/tui/widgets/test_inline_prompt.py b/libs/code/tests/unit_tests/tui/widgets/test_inline_prompt.py index d8010c65cf..1bd4135a1d 100644 --- a/libs/code/tests/unit_tests/tui/widgets/test_inline_prompt.py +++ b/libs/code/tests/unit_tests/tui/widgets/test_inline_prompt.py @@ -101,6 +101,13 @@ async def test_unquoted_key_event_paste_collapses_and_expands( monkeypatch.setattr( paste_textarea_module, "PASTE_BURST_FLUSH_DELAY_SECONDS", 0.25 ) + # Promotion now happens on the first suppressed Enter, so the + # suppression window is load-bearing here: without widening it, a slow + # runner can spend more than the default 0.12s between the third + # character and the Enter and the paste would submit instead. + monkeypatch.setattr( + paste_textarea_module, "PASTE_ENTER_SUPPRESS_WINDOW_SECONDS", 60.0 + ) payload = "alpha\nbeta\ngamma\ndelta" app = _PromptApp() async with app.run_test() as pilot: From 2fced5cbae4bf3fab50cf0b8bbd4f9a8df55f2ab Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Tue, 11 Aug 2026 16:06:49 -0700 Subject: [PATCH 04/12] fix(code): respect paste burst timing gaps --- .../tui/widgets/_paste_textarea.py | 45 +++++++++++-------- .../deepagents_code/tui/widgets/chat_input.py | 10 +++-- .../unit_tests/tui/widgets/test_chat_input.py | 43 ++++++++++++++---- 3 files changed, 68 insertions(+), 30 deletions(-) diff --git a/libs/code/deepagents_code/tui/widgets/_paste_textarea.py b/libs/code/deepagents_code/tui/widgets/_paste_textarea.py index d93d31e57c..f942a5e771 100644 --- a/libs/code/deepagents_code/tui/widgets/_paste_textarea.py +++ b/libs/code/deepagents_code/tui/widgets/_paste_textarea.py @@ -220,6 +220,21 @@ def _append_paste_burst(self, text: str, now: float) -> None: self._paste_burst_window_until = now + PASTE_ENTER_SUPPRESS_WINDOW_SECONDS self._schedule_paste_burst_flush() + def _append_recent_paste_burst_text(self, text: str, now: float) -> bool: + """Append text only when it continues an active rapid burst. + + Returns: + `True` when the text was appended, or `False` when there is no + active burst or it has gone idle. + """ + if not self._paste_burst_buffer: + return False + last_time = self._paste_burst_last_char_time + if last_time is None or (now - last_time) > PASTE_BURST_CHAR_GAP_SECONDS: + return False + self._append_paste_burst(text, now) + return True + def _note_paste_burst_keystroke(self, char: str, now: float) -> None: """Track text and timing for consecutive rapid keystrokes.""" last = self._paste_burst_last_key_time @@ -260,14 +275,12 @@ def _enter_inserts_newline_during_burst(self, now: float) -> bool: grouped. Returns `False` immediately in slash-command context (see `_in_slash_command_context`). - A run that already reached `PASTE_BURST_MIN_CHARS` is trusted for the - whole window: the newline of a key-event paste can arrive well after the - surrounding characters when it lands across a terminal read boundary - (~50 ms is ordinary over SSH), and requiring it within - `PASTE_BURST_CHAR_GAP_SECONDS` would submit mid-paste. A run only - reaches that count at machine speed, so this cannot swallow a human's - deliberate `enter`; the char-gap fallback below covers the case where a - window is open without a qualifying run (e.g. just after a flush). + The first suppressed `enter` must remain within the character gap. A + completed single-line burst followed by a deliberate `enter` is + otherwise indistinguishable from a delayed pasted newline, and submit + behavior takes priority once the rapid stream has gone idle. After one + `enter` is suppressed, the wider window keeps consecutive pasted blank + lines grouped. """ if self._in_slash_command_context(): return False @@ -286,8 +299,6 @@ def _enter_inserts_newline_during_burst(self, now: float) -> bool: last_key = self._paste_burst_last_key_time if last_key is None: return False - if self._paste_burst_run >= PASTE_BURST_MIN_CHARS: - return True return (now - last_key) <= PASTE_BURST_CHAR_GAP_SECONDS def _should_start_paste_burst(self, char: str) -> bool: @@ -387,14 +398,12 @@ async def _absorb_key_into_burst(self, event: events.Key, now: float) -> bool: if event.key == "enter": self._append_paste_burst("\n", now) return True - if event.is_printable and event.character is not None: - last_time = self._paste_burst_last_char_time - if ( - last_time is not None - and (now - last_time) <= PASTE_BURST_CHAR_GAP_SECONDS - ): - self._append_paste_burst(event.character, now) - return True + if ( + event.is_printable + and event.character is not None + and self._append_recent_paste_burst_text(event.character, now) + ): + return True await self._flush_paste_burst() return False diff --git a/libs/code/deepagents_code/tui/widgets/chat_input.py b/libs/code/deepagents_code/tui/widgets/chat_input.py index 79688aeb8b..145a321205 100644 --- a/libs/code/deepagents_code/tui/widgets/chat_input.py +++ b/libs/code/deepagents_code/tui/widgets/chat_input.py @@ -942,10 +942,12 @@ async def _on_key(self, event: events.Key) -> None: # `_promote_paste_burst_run`. space_now = time.monotonic() if self._paste_burst_buffer: - self._append_paste_burst(" ", space_now) - else: - self.insert(" ") - self._note_printable_burst_keystroke(" ", space_now) + if self._append_recent_paste_burst_text(" ", space_now): + self.post_message(self.Typing()) + return + await self._flush_paste_burst() + self.insert(" ") + self._note_printable_burst_keystroke(" ", space_now) self.post_message(self.Typing()) return diff --git a/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py b/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py index 96250743e8..1ee18562ff 100644 --- a/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py +++ b/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py @@ -4562,6 +4562,8 @@ async def test_single_line_burst_then_manual_enter_submits( ta.text = "abc" now = chat_input_module.time.monotonic() + ta._paste_burst_run = paste_textarea_module.PASTE_BURST_MIN_CHARS + ta._paste_burst_run_text = "abc" ta._paste_burst_last_key_time = ( now - paste_textarea_module.PASTE_BURST_CHAR_GAP_SECONDS - 0.01 ) @@ -4658,15 +4660,10 @@ async def test_slash_command_enter_still_submits_during_burst(self) -> None: assert len(app.submitted) == 1 - async def test_late_newline_in_burst_does_not_submit( + async def test_late_enter_after_qualifying_run_still_submits( self, monkeypatch: pytest.MonkeyPatch ) -> None: - """A newline arriving after the char gap still groups with its burst. - - A key-event paste's `enter` can land in a later terminal read than the - characters around it (~50 ms is ordinary over SSH). The suppression - window, not the much tighter char gap, is what bounds the burst. - """ + """A qualifying run does not swallow deliberate enter after going idle.""" monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 0.03) monkeypatch.setattr( paste_textarea_module, "PASTE_ENTER_SUPPRESS_WINDOW_SECONDS", 0.12 @@ -4690,7 +4687,8 @@ async def test_late_newline_in_burst_does_not_submit( await ta._on_key(events.Key("enter", None)) await pilot.pause() - assert len(app.submitted) == 0 + assert len(app.submitted) == 1 + assert app.submitted[0].value == "abc" class TestPasteBurstPromotion: @@ -4887,6 +4885,35 @@ async def test_vscode_space_workaround_keeps_run_in_sync( assert ta._paste_burst_buffer == "ab cd\n" assert len(app.submitted) == 0 + async def test_vscode_space_workaround_flushes_stale_buffer( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A delayed CSI-u space starts normal input after a completed burst.""" + monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 0.03) + monkeypatch.setattr( + paste_textarea_module, "PASTE_BURST_FLUSH_DELAY_SECONDS", 0.25 + ) + + app = _RecordingApp() + async with app.run_test() as pilot: + chat = app.query_one(ChatInput) + ta = chat._text_area + assert ta is not None + + stale_time = ( + chat_input_module.time.monotonic() + - paste_textarea_module.PASTE_BURST_CHAR_GAP_SECONDS + - 0.01 + ) + ta._start_paste_burst("abc", stale_time) + + await ta._on_key(events.Key("space", None)) + await pilot.pause() + + assert ta._paste_burst_buffer == "" + assert ta.text == "abc " + assert ta._paste_burst_run_text == " " + async def test_flushed_run_is_not_re_promoted_by_a_later_enter( self, monkeypatch: pytest.MonkeyPatch ) -> None: From 304ca885cae15c9a2d2c76607f5d704bccba83fb Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Tue, 11 Aug 2026 16:32:11 -0700 Subject: [PATCH 05/12] fix(code): preserve key-event paste contents --- .../tui/widgets/_paste_textarea.py | 1 + .../deepagents_code/tui/widgets/chat_input.py | 27 +++++++++ .../unit_tests/tui/widgets/test_chat_input.py | 57 +++++++++++++++++++ 3 files changed, 85 insertions(+) diff --git a/libs/code/deepagents_code/tui/widgets/_paste_textarea.py b/libs/code/deepagents_code/tui/widgets/_paste_textarea.py index f942a5e771..d7bdbcfcdb 100644 --- a/libs/code/deepagents_code/tui/widgets/_paste_textarea.py +++ b/libs/code/deepagents_code/tui/widgets/_paste_textarea.py @@ -560,6 +560,7 @@ def _consume_backslash_enter_newline( and enabled and self._backslash_pending_time is not None and (now - self._backslash_pending_time) <= _BACKSLASH_ENTER_GAP_SECONDS + and not self._enter_inserts_newline_during_burst(now) ): self._backslash_pending_time = None if self._delete_preceding_backslash(): diff --git a/libs/code/deepagents_code/tui/widgets/chat_input.py b/libs/code/deepagents_code/tui/widgets/chat_input.py index 145a321205..e64682048c 100644 --- a/libs/code/deepagents_code/tui/widgets/chat_input.py +++ b/libs/code/deepagents_code/tui/widgets/chat_input.py @@ -502,6 +502,18 @@ def __init__(self, text: str) -> None: self.text = text super().__init__() + class DeferredSpace(Message): + """Insert a synthetic space after an already-queued burst payload.""" + + def __init__(self, burst_time: float) -> None: + """Initialize with the original key-event timestamp. + + Args: + burst_time: Monotonic time when the space key arrived. + """ + self.burst_time = burst_time + super().__init__() + class Typing(Message): """Posted when the user presses a printable key or backspace. @@ -946,6 +958,12 @@ async def _on_key(self, event: events.Key) -> None: self.post_message(self.Typing()) return await self._flush_paste_burst() + # Large-paste and dropped-path dispatch posts a message to the + # owner. Queue the space behind that message so its insertion + # cannot move the cursor before the payload is handled. + self.post_message(self.DeferredSpace(space_now)) + self.post_message(self.Typing()) + return self.insert(" ") self._note_printable_burst_keystroke(" ", space_now) self.post_message(self.Typing()) @@ -2290,6 +2308,15 @@ def on_chat_text_area_pasted_text(self, event: ChatTextArea.PastedText) -> None: return self._collapse_and_insert_paste(event.text) + def on_chat_text_area_deferred_space( + self, event: ChatTextArea.DeferredSpace + ) -> None: + """Insert a synthetic space after the preceding burst payload.""" + if not self._text_area: + return + self._text_area.insert(" ") + self._text_area._note_printable_burst_keystroke(" ", event.burst_time) + def handle_external_paste(self, pasted: str) -> bool: """Handle paste text from app-level routing when input is not focused. diff --git a/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py b/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py index 1ee18562ff..9bbec3416b 100644 --- a/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py +++ b/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py @@ -4732,6 +4732,33 @@ async def test_multiline_key_event_paste_collapses( assert chat._pasted_contents[1].content == payload assert len(app.submitted) == 0 + async def test_key_event_paste_preserves_backslash_before_newline( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A burst newline takes priority over the backslash+Enter fallback.""" + monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 60.0) + monkeypatch.setattr( + paste_textarea_module, "PASTE_ENTER_SUPPRESS_WINDOW_SECONDS", 60.0 + ) + monkeypatch.setattr( + paste_textarea_module, "PASTE_BURST_FLUSH_DELAY_SECONDS", 0.25 + ) + + app = _RecordingApp() + async with app.run_test() as pilot: + chat = app.query_one(ChatInput) + ta = chat._text_area + assert ta is not None + + for char in "abc": + await ta._on_key(events.Key(char, char)) + await ta._on_key(events.Key("backslash", "\\")) + await ta._on_key(events.Key("enter", None)) + await pilot.pause(0.35) + + assert ta.text == "abc\\\n" + assert len(app.submitted) == 0 + async def test_large_single_line_key_event_paste_collapses( self, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -4914,6 +4941,36 @@ async def test_vscode_space_workaround_flushes_stale_buffer( assert ta.text == "abc " assert ta._paste_burst_run_text == " " + async def test_vscode_space_follows_queued_stale_payload( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A stale burst's queued placeholder is inserted before its space.""" + monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 0.03) + monkeypatch.setattr( + paste_textarea_module, "PASTE_BURST_FLUSH_DELAY_SECONDS", 0.25 + ) + + app = _RecordingApp() + async with app.run_test() as pilot: + chat = app.query_one(ChatInput) + ta = chat._text_area + assert ta is not None + + stale_time = ( + chat_input_module.time.monotonic() + - paste_textarea_module.PASTE_BURST_CHAR_GAP_SECONDS + - 0.01 + ) + payload = "x" * 900 + ta._start_paste_burst(payload, stale_time) + + await ta._on_key(events.Key("space", None)) + await pilot.pause() + + assert ta.text == "[Pasted text #1] " + assert chat._pasted_contents[1].content == payload + assert ta._paste_burst_run_text == " " + async def test_flushed_run_is_not_re_promoted_by_a_later_enter( self, monkeypatch: pytest.MonkeyPatch ) -> None: From 0796e92a5a119f19d165d4a157238af5c9ee8fcd Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Tue, 11 Aug 2026 21:26:35 -0700 Subject: [PATCH 06/12] fix(code): preserve rapid path and space ordering --- .../tui/widgets/_paste_textarea.py | 24 +++++++- .../deepagents_code/tui/widgets/chat_input.py | 60 +++++++++++++++++-- .../unit_tests/tui/widgets/test_chat_input.py | 59 ++++++++++++++++++ 3 files changed, 137 insertions(+), 6 deletions(-) diff --git a/libs/code/deepagents_code/tui/widgets/_paste_textarea.py b/libs/code/deepagents_code/tui/widgets/_paste_textarea.py index d7bdbcfcdb..250954421b 100644 --- a/libs/code/deepagents_code/tui/widgets/_paste_textarea.py +++ b/libs/code/deepagents_code/tui/widgets/_paste_textarea.py @@ -187,6 +187,20 @@ async def _dispatch_burst_payload(self, payload: str) -> None: """Handle a flushed burst payload. Base behavior inserts it verbatim.""" self.insert(payload) + def _burst_run_payload_for_dispatch(self, payload: str) -> str: # noqa: PLR6301 # overridable hook + """Return the payload represented by a visible rapid-key run. + + Most text areas display every character in the run, so the payload is + unchanged. Subclasses with virtual prefixes may restore characters + that were consumed before insertion. + """ + return payload + + def _on_burst_run_promoted( + self, visible_payload: str, dispatch_payload: str + ) -> None: + """React after a visible run has been promoted into the burst buffer.""" + # -- Burst state machine -------------------------------------------------- def _cancel_paste_burst_timer(self) -> None: @@ -484,11 +498,17 @@ def _check_burst_run_for_promotion(self) -> None: if self._paste_burst_buffer or self._paste_burst_run < PASTE_BURST_MIN_CHARS: return payload = self._paste_burst_run_text + dispatch_payload = self._burst_run_payload_for_dispatch(payload) if ( - looks_like_dropped_payload(payload) + looks_like_dropped_payload(dispatch_payload) or len(payload) >= PASTE_BURST_PROMOTE_CHARS ): - self._promote_paste_burst_run(self._paste_burst_last_key_time or 0.0) + promoted = self._promote_paste_burst_run( + self._paste_burst_last_key_time or 0.0 + ) + if promoted: + self._paste_burst_buffer = dispatch_payload + self._on_burst_run_promoted(payload, dispatch_payload) def _consume_enter_as_burst_newline(self, now: float) -> bool: """Insert a newline instead of submitting when inside a paste burst. diff --git a/libs/code/deepagents_code/tui/widgets/chat_input.py b/libs/code/deepagents_code/tui/widgets/chat_input.py index e64682048c..66c1b03ebb 100644 --- a/libs/code/deepagents_code/tui/widgets/chat_input.py +++ b/libs/code/deepagents_code/tui/widgets/chat_input.py @@ -28,7 +28,11 @@ detect_mode_prefix, is_ascii_mode, ) -from deepagents_code.input import IMAGE_PLACEHOLDER_PATTERN, VIDEO_PLACEHOLDER_PATTERN +from deepagents_code.input import ( + IMAGE_PLACEHOLDER_PATTERN, + VIDEO_PLACEHOLDER_PATTERN, + looks_like_dropped_payload, +) from deepagents_code.paste_collapse import ( PASTE_PLACEHOLDER_PATTERN, PastedContent, @@ -532,6 +536,8 @@ def __init__(self, **kwargs: Any) -> None: self._chat_input_owner: ChatInput | None = None self._skip_history_change_events = 0 self._completion_active = False + self._deferred_space_pending = False + self._deferred_keys: list[tuple[str, str | None]] = [] # Paste-burst and backslash-pending state is initialized by # PasteBurstTextArea.__init__. # Tracks terminal focus so a click that re-focuses the window only @@ -916,8 +922,54 @@ async def _dispatch_burst_payload(self, payload: str) -> None: if "\n" in payload: self.call_after_refresh(self.scroll_cursor_visible) + def _burst_run_payload_for_dispatch(self, payload: str) -> str: + """Restore a virtual command prefix when a burst is an absolute path. + + Returns: + The payload with a stripped leading slash restored when applicable. + """ + owner = self._chat_input_owner + if owner is None or owner.mode != "command": + return payload + candidate = f"/{payload.lstrip('/')}" + if looks_like_dropped_payload(candidate): + return candidate + return payload + + def _on_burst_run_promoted( + self, visible_payload: str, dispatch_payload: str + ) -> None: + """Leave command mode when promotion recovered a leading path slash.""" + if visible_payload == dispatch_payload: + return + owner = self._chat_input_owner + if owner is not None and owner.mode == "command": + owner.mode = "normal" + + async def _insert_deferred_space_and_keys(self, burst_time: float) -> None: + """Insert a queued synthetic space before replaying later key events.""" + self.insert(" ") + self._note_printable_burst_keystroke(" ", burst_time) + deferred_keys = self._deferred_keys + self._deferred_keys = [] + self._deferred_space_pending = False + + from textual import events as textual_events + + for key, character in deferred_keys: + await self._on_key(textual_events.Key(key, character)) + async def _on_key(self, event: events.Key) -> None: """Handle key events.""" + # A character-less VS Code space may need to wait behind a burst payload + # message. Keys already present in Textual's FIFO queue would otherwise + # overtake it, so hold and replay them after the space is inserted. + if self._deferred_space_pending: + event.prevent_default() + event.stop() + self._deferred_keys.append((event.key, event.character)) + return + # Lock keys (Caps Lock, Num Lock, Scroll Lock) must never type. The # kitty parser patch in `_textual_patches.py` already neutralizes these # at the source; this is defense-in-depth in case a lock key still @@ -961,6 +1013,7 @@ async def _on_key(self, event: events.Key) -> None: # Large-paste and dropped-path dispatch posts a message to the # owner. Queue the space behind that message so its insertion # cannot move the cursor before the payload is handled. + self._deferred_space_pending = True self.post_message(self.DeferredSpace(space_now)) self.post_message(self.Typing()) return @@ -2308,14 +2361,13 @@ def on_chat_text_area_pasted_text(self, event: ChatTextArea.PastedText) -> None: return self._collapse_and_insert_paste(event.text) - def on_chat_text_area_deferred_space( + async def on_chat_text_area_deferred_space( self, event: ChatTextArea.DeferredSpace ) -> None: """Insert a synthetic space after the preceding burst payload.""" if not self._text_area: return - self._text_area.insert(" ") - self._text_area._note_printable_burst_keystroke(" ", event.burst_time) + await self._text_area._insert_deferred_space_and_keys(event.burst_time) def handle_external_paste(self, pasted: str) -> bool: """Handle paste text from app-level routing when input is not focused. diff --git a/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py b/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py index 9bbec3416b..7ea696a549 100644 --- a/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py +++ b/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py @@ -3180,6 +3180,36 @@ async def test_key_burst_quoted_path_rewrites_without_showing_raw_path( assert chat._text_area.text == "[image 1] " assert len(app.tracker.get_images()) == 1 + async def test_key_burst_absolute_path_preserves_leading_slash( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A rapid absolute path recovers the slash consumed by command mode.""" + monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 1.0) + monkeypatch.setattr( + paste_textarea_module, "PASTE_BURST_FLUSH_DELAY_SECONDS", 0.25 + ) + + img_path = tmp_path / "absolute-burst.png" + from PIL import Image + + Image.new("RGB", (3, 3), color="navy").save(img_path, format="PNG") + + app = _ImagePasteApp() + async with app.run_test() as pilot: + chat = app.query_one(ChatInput) + assert chat._text_area is not None + + for char in str(img_path): + await chat._text_area._on_key(events.Key(char, char)) + + assert chat.mode == "normal" + assert chat._text_area.text == "" + + await pilot.pause(0.35) + + assert chat._text_area.text == "[image 1] " + assert len(app.tracker.get_images()) == 1 + async def test_submit_absolute_path_without_paste_event_attaches_image( self, tmp_path ) -> None: @@ -4971,6 +5001,35 @@ async def test_vscode_space_follows_queued_stale_payload( assert chat._pasted_contents[1].content == payload assert ta._paste_burst_run_text == " " + async def test_vscode_space_stays_ahead_of_already_queued_key( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A key queued before `DeferredSpace` must not overtake the space.""" + monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 0.03) + monkeypatch.setattr( + paste_textarea_module, "PASTE_BURST_FLUSH_DELAY_SECONDS", 0.25 + ) + + app = _RecordingApp() + async with app.run_test() as pilot: + chat = app.query_one(ChatInput) + ta = chat._text_area + assert ta is not None + + stale_time = ( + chat_input_module.time.monotonic() + - paste_textarea_module.PASTE_BURST_CHAR_GAP_SECONDS + - 0.01 + ) + ta._start_paste_burst("abc", stale_time) + + ta.post_message(events.Key("space", None)) + ta.post_message(events.Key("x", "x")) + await pilot.pause() + + assert ta.text == "abc x" + assert ta._paste_burst_run_text == " x" + async def test_flushed_run_is_not_re_promoted_by_a_later_enter( self, monkeypatch: pytest.MonkeyPatch ) -> None: From 73820c3a03ea053825625fba78fa6a8ddc74034b Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Tue, 11 Aug 2026 21:53:03 -0700 Subject: [PATCH 07/12] fix(code): keep quoted rapid typing visible --- .../tui/widgets/_inline_prompt.py | 5 --- .../tui/widgets/_paste_textarea.py | 34 ------------------- .../deepagents_code/tui/widgets/chat_input.py | 5 --- .../tests/unit_tests/test_input_parsing.py | 3 +- .../unit_tests/tui/widgets/test_chat_input.py | 9 ++--- .../tui/widgets/test_inline_prompt.py | 18 +++++----- 6 files changed, 16 insertions(+), 58 deletions(-) diff --git a/libs/code/deepagents_code/tui/widgets/_inline_prompt.py b/libs/code/deepagents_code/tui/widgets/_inline_prompt.py index 88d5b70cd0..7f22190a62 100644 --- a/libs/code/deepagents_code/tui/widgets/_inline_prompt.py +++ b/libs/code/deepagents_code/tui/widgets/_inline_prompt.py @@ -167,11 +167,6 @@ async def _on_key(self, event: events.Key) -> None: event.stop() return - if self._maybe_start_burst(event, now): - event.prevent_default() - event.stop() - return - self._track_burst_run(event, now) if event.key == "backspace" and self._delete_placeholder_token(backwards=True): diff --git a/libs/code/deepagents_code/tui/widgets/_paste_textarea.py b/libs/code/deepagents_code/tui/widgets/_paste_textarea.py index 250954421b..7b309e549b 100644 --- a/libs/code/deepagents_code/tui/widgets/_paste_textarea.py +++ b/libs/code/deepagents_code/tui/widgets/_paste_textarea.py @@ -51,9 +51,6 @@ PASTE_BURST_FLUSH_DELAY_SECONDS = 0.08 """Idle timeout before flushing buffered burst text.""" -PASTE_BURST_START_CHARS = {"'", '"'} -"""Characters that can start dropped-path payloads.""" - PASTE_BURST_MIN_CHARS = 3 """Consecutive fast keystrokes before a stream is treated as a paste burst. @@ -315,21 +312,6 @@ def _enter_inserts_newline_during_burst(self, now: float) -> bool: return False return (now - last_key) <= PASTE_BURST_CHAR_GAP_SECONDS - def _should_start_paste_burst(self, char: str) -> bool: - """Return whether a keypress should start paste-burst buffering. - - Only a quote typed into an empty document buffers immediately, for - dropped-path parsing. Other rapid printable runs stay visible in the - document until something confirms a paste and promotes them — see - `_check_burst_run_for_promotion` and `_consume_enter_as_burst_newline`. - """ - if char not in PASTE_BURST_START_CHARS: - return False - if self.text or not self.selection.is_empty: - return False - row, col = self.cursor_location - return row == 0 and col == 0 - async def _flush_paste_burst(self) -> None: """Flush buffered burst text through the payload dispatch hook. @@ -421,22 +403,6 @@ async def _absorb_key_into_burst(self, event: events.Key, now: float) -> bool: await self._flush_paste_burst() return False - def _maybe_start_burst(self, event: events.Key, now: float) -> bool: - """Start buffering when a keypress looks like the head of a paste. - - Returns: - `True` when a burst was started and the caller should stop handling - the key. - """ - if ( - event.is_printable - and event.character is not None - and self._should_start_paste_burst(event.character) - ): - self._start_paste_burst(event.character, now) - return True - return False - def _track_burst_run(self, event: events.Key, now: float) -> None: """Track a rapid run, arming Enter suppression once it looks like a paste. diff --git a/libs/code/deepagents_code/tui/widgets/chat_input.py b/libs/code/deepagents_code/tui/widgets/chat_input.py index 66c1b03ebb..b264650423 100644 --- a/libs/code/deepagents_code/tui/widgets/chat_input.py +++ b/libs/code/deepagents_code/tui/widgets/chat_input.py @@ -1034,11 +1034,6 @@ async def _on_key(self, event: events.Key) -> None: event.stop() return - if self._maybe_start_burst(event, now): - event.prevent_default() - event.stop() - return - # Track rapid keystroke runs so terminals without bracketed paste keep # embedded newlines grouped without delaying ordinary text insertion. self._track_burst_run(event, now) diff --git a/libs/code/tests/unit_tests/test_input_parsing.py b/libs/code/tests/unit_tests/test_input_parsing.py index 35d04a7ac7..e5b3fc17c9 100644 --- a/libs/code/tests/unit_tests/test_input_parsing.py +++ b/libs/code/tests/unit_tests/test_input_parsing.py @@ -435,8 +435,7 @@ def test_dropped_payload_paths_resolves_quoted_payload( """Quoted and bracketed drops resolve, since terminals wrap paths that way. The shape guard strips leading `<`, `'`, and `"` for exactly this reason; - without that strip every quoted drop would look like typed text. A quoted - path is also the designed burst shape — see `PASTE_BURST_START_CHARS`. + without that strip every quoted drop would look like typed text. """ img = tmp_path / "shot.png" img.write_bytes(b"img") diff --git a/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py b/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py index 7ea696a549..5b2010c5b1 100644 --- a/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py +++ b/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py @@ -4816,10 +4816,11 @@ async def test_large_single_line_key_event_paste_collapses( assert payload not in ta.text assert chat._pasted_contents[1].content == payload + @pytest.mark.parametrize("payload", ["hello world", '"hello world"']) async def test_ordinary_rapid_typing_is_never_promoted( - self, monkeypatch: pytest.MonkeyPatch + self, monkeypatch: pytest.MonkeyPatch, payload: str ) -> None: - """A short rapid run with no paste evidence stays fully visible.""" + """A short rapid run, including quoted text, stays fully visible.""" monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 60.0) app = _RecordingApp() @@ -4828,11 +4829,11 @@ async def test_ordinary_rapid_typing_is_never_promoted( ta = chat._text_area assert ta is not None - for char in "hello world": + for char in payload: await pilot.press(char) await pilot.pause(0.15) - assert ta.text == "hello world" + assert ta.text == payload assert ta._paste_burst_buffer == "" async def test_promotion_falls_back_when_selection_is_active( diff --git a/libs/code/tests/unit_tests/tui/widgets/test_inline_prompt.py b/libs/code/tests/unit_tests/tui/widgets/test_inline_prompt.py index 1bd4135a1d..973079b19c 100644 --- a/libs/code/tests/unit_tests/tui/widgets/test_inline_prompt.py +++ b/libs/code/tests/unit_tests/tui/widgets/test_inline_prompt.py @@ -222,10 +222,11 @@ async def test_key_burst_with_newline_does_not_submit( assert app.submissions == [] assert "\n" in ta.text + @pytest.mark.parametrize("payload", ["hello", '"hello"']) async def test_rapid_typing_stays_visible( - self, monkeypatch: pytest.MonkeyPatch + self, monkeypatch: pytest.MonkeyPatch, payload: str ) -> None: - """Rapid ordinary typing is not hidden by the dropped-media check.""" + """Rapid ordinary typing, including quoted text, stays visible.""" monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 60.0) app = _PromptApp() async with app.run_test() as pilot: @@ -233,11 +234,11 @@ async def test_rapid_typing_stays_visible( ta.focus() await pilot.pause() - for char in "hello": + for char in payload: await pilot.press(char) await pilot.pause() - assert ta.text == "hello" + assert ta.text == payload assert ta._paste_burst_buffer == "" assert not list(app._notifications) @@ -720,8 +721,8 @@ async def test_quoted_image_drop_via_real_key_events_is_rejected( Drives the production route end to end — `_on_key` -> burst promotion -> flush timer -> `_dispatch_burst_payload` — rather than calling the - dispatcher directly. A leading quote is in `PASTE_BURST_START_CHARS` - precisely so a dropped path buffers, so this is the designed shape. + dispatcher directly. The leading quote stays visible until the rapid + run contains enough of the absolute path to confirm a dropped payload. """ monkeypatch.setattr( paste_textarea_module, "_collapse_pastes_enabled", lambda: False @@ -773,13 +774,14 @@ async def test_pending_burst_is_flushed_before_a_media_paste_is_refused( ta.focus() await pilot.pause() - for char in "'abc": + pending = f"'{tmp_path / 'draft.txt'}'" + for char in pending: await ta._on_key(Key(char, char)) assert ta._paste_burst_buffer, "expected a pending burst to flush" await _paste(pilot, str(img)) - assert ta.text == "'abc" + assert ta.text == pending assert not ta._paste_burst_buffer latest = list(app._notifications)[-1] assert latest.message.startswith(MEDIA_UNSUPPORTED_TOAST_PREFIX) From 8c08acb3bc45240eccd215be91bb47519590889c Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Mon, 17 Aug 2026 10:23:46 -0400 Subject: [PATCH 08/12] fix(code): preserve slash commands in burst handling --- .../tui/widgets/_paste_textarea.py | 11 +++++-- .../unit_tests/tui/widgets/test_chat_input.py | 29 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/libs/code/deepagents_code/tui/widgets/_paste_textarea.py b/libs/code/deepagents_code/tui/widgets/_paste_textarea.py index 7b309e549b..acb61a4f7b 100644 --- a/libs/code/deepagents_code/tui/widgets/_paste_textarea.py +++ b/libs/code/deepagents_code/tui/widgets/_paste_textarea.py @@ -465,10 +465,15 @@ def _check_burst_run_for_promotion(self) -> None: return payload = self._paste_burst_run_text dispatch_payload = self._burst_run_payload_for_dispatch(payload) + is_path_payload = looks_like_dropped_payload(dispatch_payload) + # A virtual slash prefix is restored only for a ChatInput absolute + # path. Its visible suffix has its own separator (for example, + # `private/tmp/...`), unlike a slash-command name such as `help`. + recovered_path_has_separator = dispatch_payload != payload and "/" in payload if ( - looks_like_dropped_payload(dispatch_payload) - or len(payload) >= PASTE_BURST_PROMOTE_CHARS - ): + (not self._in_slash_command_context() or recovered_path_has_separator) + and is_path_payload + ) or len(payload) >= PASTE_BURST_PROMOTE_CHARS: promoted = self._promote_paste_burst_run( self._paste_burst_last_key_time or 0.0 ) diff --git a/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py b/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py index 51d78718b9..c89f731deb 100644 --- a/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py +++ b/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py @@ -4816,6 +4816,35 @@ async def test_large_single_line_key_event_paste_collapses( assert payload not in ta.text assert chat._pasted_contents[1].content == payload + async def test_rapid_slash_command_is_not_promoted_as_a_dropped_path( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A key-event `/help` burst must retain command submission semantics.""" + monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 60.0) + monkeypatch.setattr( + paste_textarea_module, "PASTE_BURST_FLUSH_DELAY_SECONDS", 0.25 + ) + + app = _RecordingApp() + async with app.run_test() as pilot: + chat = app.query_one(ChatInput) + ta = chat._text_area + assert ta is not None + + for char in "/help": + await pilot.press(char) + + assert chat.mode == "command" + assert ta.text == "help" + assert ta._paste_burst_buffer == "" + + await pilot.press("enter") + await pilot.pause() + + assert len(app.submitted) == 1 + assert app.submitted[0].value == "/help" + assert app.submitted[0].mode == "command" + @pytest.mark.parametrize("payload", ["hello world", '"hello world"']) async def test_ordinary_rapid_typing_is_never_promoted( self, monkeypatch: pytest.MonkeyPatch, payload: str From 2f0b3d9b11678b63fbd1e7e23ecfc16931124955 Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Mon, 17 Aug 2026 12:42:44 -0400 Subject: [PATCH 09/12] fix(code): harden burst promotion and deferred-space handling Rapid-key paste detection guesses whether a fast keystroke run is a paste. Two helpers supporting that guess had defects that corrupted or swallowed input. The command-mode path recovery asked `looks_like_dropped_payload` about a candidate it had just prefixed with `/`. That function is a leading-token check, so the question was vacuously true and every rapid run in command mode qualified: the run was hidden, the input silently left command mode, and the flush re-inserted a `/` the user never typed. Recovery now requires the run to start at document offset 0, to contain its own separator, and to have no whitespace before it, so `help` stays a command name and `read src/main.py` keeps its command semantics. `_check_burst_run_for_promotion` gains the slash-command guard its sibling already had, with a documented exception for a recovered path. A recovered path that does not resolve on disk falls through to a plain insert at offset 0, which tripped mode-prefix detection a second time and stripped the restored slash again. The insert now suppresses that detection for exactly that payload. The deferred-space gate held every keystroke behind a posted payload but was cleared in a single place, so a dropped message, an unmounted owner, or a raising handler left the input permanently dead. It also rebuilt held keys as synthetic events, which never reach binding resolution or bubble to `ChatInput`, losing `tab`, arrow keys, and `alt+backspace`. The gate now arms only when dispatch actually posted a message, lets non-printable keys resolve it and take their normal path, drains destructively from the front inside `try/finally`, and carries a watchdog plus a reset hook so it cannot stick. Also: reset the run when the completion branch swallows a printable space; log burst-run divergence, abandoned promotion, and the unreachable live-buffer Enter case at warning; refuse promotion instead of substituting a bogus monotonic timestamp; drop the held space when a path replacement already appended one; restore exactly one leading slash instead of `lstrip("/")`; and correct comments that overstated the guarantees. --- .../tui/widgets/_inline_prompt.py | 20 +- .../tui/widgets/_paste_textarea.py | 173 ++++++--- .../deepagents_code/tui/widgets/chat_input.py | 271 +++++++++++--- .../unit_tests/tui/widgets/test_chat_input.py | 340 ++++++++++++++++++ 4 files changed, 698 insertions(+), 106 deletions(-) diff --git a/libs/code/deepagents_code/tui/widgets/_inline_prompt.py b/libs/code/deepagents_code/tui/widgets/_inline_prompt.py index 704123c39c..d5735190b2 100644 --- a/libs/code/deepagents_code/tui/widgets/_inline_prompt.py +++ b/libs/code/deepagents_code/tui/widgets/_inline_prompt.py @@ -199,10 +199,8 @@ async def _on_key(self, event: events.Key) -> None: await super()._on_key(event) - # After the key is inserted, promote the rapid run if its shape (dropped - # path, for media rejection) or size already confirms a paste. Must run - # after `super()._on_key` so the current character is already in the - # document and `_promote_paste_burst_run` can find it. + # Must follow `super()._on_key`: promotion verifies the run against the + # document, so the current character has to be in it already. self._check_burst_run_for_promotion() async def _on_paste(self, event: events.Paste) -> None: @@ -224,11 +222,17 @@ async def _on_paste(self, event: events.Paste) -> None: # them a second time. The `prevent_default()` above is what stops that # walk on the rejection path. - async def _dispatch_burst_payload(self, payload: str) -> None: - """Reject a media file replayed as a key burst, else defer to the base.""" + async def _dispatch_burst_payload(self, payload: str) -> bool: + """Reject a media file replayed as a key burst, else defer to the base. + + Returns: + `False` on the rejection path — nothing is inserted, so there is no + pending application for a caller to order against — otherwise + whatever the base reports. + """ if await self._reject_dropped_media(payload): - return - await super()._dispatch_burst_payload(payload) + return False + return await super()._dispatch_burst_payload(payload) async def _reject_dropped_media(self, text: str) -> bool: """Toast and swallow a dropped payload containing an image or video. diff --git a/libs/code/deepagents_code/tui/widgets/_paste_textarea.py b/libs/code/deepagents_code/tui/widgets/_paste_textarea.py index acb61a4f7b..63c4ac2cc5 100644 --- a/libs/code/deepagents_code/tui/widgets/_paste_textarea.py +++ b/libs/code/deepagents_code/tui/widgets/_paste_textarea.py @@ -69,9 +69,11 @@ A run this long arriving at burst speed (each char within `PASTE_BURST_CHAR_GAP_SECONDS`) is unreachable by human typing, so it is -promoted into the buffer even without an embedded newline. Matched to the -collapse threshold so a large single-line key-event paste still collapses into -a `[Pasted text #N]` placeholder. +promoted into the buffer even without an embedded newline. Derived from the +collapse threshold so a large single-line key-event paste reaches +`[Pasted text #N]` collapsing. The comparison here is `>=` while +`should_collapse_paste` uses `>`, so a run of exactly this length is promoted and +then re-inserted verbatim. """ PASTE_ENTER_SUPPRESS_WINDOW_SECONDS = 0.12 @@ -180,9 +182,17 @@ def _in_slash_command_context(self) -> bool: # noqa: PLR6301 # overridable hoo """ return False - async def _dispatch_burst_payload(self, payload: str) -> None: - """Handle a flushed burst payload. Base behavior inserts it verbatim.""" + async def _dispatch_burst_payload(self, payload: str) -> bool: + """Handle a flushed burst payload. Base behavior inserts it verbatim. + + Returns: + `True` when the payload was deferred to a posted message rather than + applied to the document here, so a caller that must order a following + keystroke after the payload has to wait for that message. The base + implementation inserts synchronously and returns `False`. + """ self.insert(payload) + return False def _burst_run_payload_for_dispatch(self, payload: str) -> str: # noqa: PLR6301 # overridable hook """Return the payload represented by a visible rapid-key run. @@ -284,7 +294,8 @@ def _enter_inserts_newline_during_burst(self, now: float) -> bool: previous `enter` was already suppressed, and the suppression window is still open. The window bounds how long a replayed paste's newlines stay grouped. Returns `False` immediately in slash-command context (see - `_in_slash_command_context`). + `_in_slash_command_context`), and an active burst buffer short-circuits to + `True` regardless of the window (see below). The first suppressed `enter` must remain within the character gap. A completed single-line burst followed by a deliberate `enter` is @@ -312,19 +323,24 @@ def _enter_inserts_newline_during_burst(self, now: float) -> bool: return False return (now - last_key) <= PASTE_BURST_CHAR_GAP_SECONDS - async def _flush_paste_burst(self) -> None: + async def _flush_paste_burst(self) -> bool: """Flush buffered burst text through the payload dispatch hook. When the buffer is empty this is a no-op, so it is safe to call defensively before handling a bracketed paste. + + Returns: + Whatever `_dispatch_burst_payload` reported — `True` when the payload + will be applied by a posted message rather than synchronously. `False` + when there was nothing buffered. """ payload = self._paste_burst_buffer self._paste_burst_buffer = "" self._paste_burst_last_char_time = None self._cancel_paste_burst_timer() if not payload: - return - await self._dispatch_burst_payload(payload) + return False + return await self._dispatch_burst_payload(payload) def _promote_paste_burst_run(self, now: float) -> bool: """Move an already-inserted rapid run out of the document into the buffer. @@ -333,8 +349,9 @@ def _promote_paste_burst_run(self, now: float) -> bool: screen at this point — and hands them to `_start_paste_burst` so the eventual flush can apply dropped-path and paste-collapse policy. - All guards run before any mutation, so a `False` return never leaves a - partially-promoted document. + No document mutation happens until every guard has passed, so a `False` + return never leaves a partially-promoted document. A failed verification + does still discard the tracked run, so `False` is not side-effect-free. Args: now: Monotonic timestamp for the current key event. @@ -343,9 +360,10 @@ def _promote_paste_burst_run(self, now: float) -> bool: `True` when the run was verified present immediately before the cursor and moved into the buffer. `False` when promotion is unsafe: an empty run, a non-empty selection (deleting would clobber the - user's selected range), or a document that no longer ends with the - tracked run — which means an intervening edit desynchronised the - tracker. Callers must fall back to handling the key normally. + user's selected range), or a document whose text immediately before + the cursor is no longer the tracked run — which means an intervening + edit desynchronised the tracker. Callers must fall back to handling + the key normally. """ payload = self._paste_burst_run_text if not payload or not self.selection.is_empty: @@ -354,15 +372,18 @@ def _promote_paste_burst_run(self, now: float) -> bool: cursor_offset = self.document.get_index_from_location(cursor) # ty: ignore[unresolved-attribute] # Document has this method; DocumentBase stub is narrower start_offset = cursor_offset - len(payload) if start_offset < 0 or self.text[start_offset:cursor_offset] != payload: - # The tracker's model of the document is wrong, so every later - # promotion in this run would fail the same way. Log it (never the - # payload itself, which is user content) and drop the stale run so - # tracking restarts cleanly on the next keystroke. - logger.debug( - "Burst run diverged from document (run=%d chars, start=%d); " - "skipping promotion", + # An untracked edit desynchronised the tracker, so drop the stale run + # and let tracking restart on the next keystroke. Logged at warning + # (not debug) so it survives the default INFO level — this is a state + # -machine bug, not a user-reachable condition. The message carries + # only sizes, never the payload, which is user content. + logger.warning( + "Burst run diverged from document (run=%d chars, start=%d, " + "cursor=%d, doc=%d); skipping promotion", len(payload), start_offset, + cursor_offset, + len(self.text), ) self._reset_paste_burst_run() return False @@ -404,10 +425,7 @@ async def _absorb_key_into_burst(self, event: events.Key, now: float) -> bool: return False def _track_burst_run(self, event: events.Key, now: float) -> None: - """Track a rapid run, arming Enter suppression once it looks like a paste. - - The key stays in the document; see `_note_printable_burst_keystroke`. - """ + """Track a rapid run, arming Enter suppression once it looks like a paste.""" if event.is_printable and event.character is not None: self._note_printable_burst_keystroke(event.character, now) elif event.key != "enter": @@ -419,10 +437,14 @@ def _note_printable_burst_keystroke(self, char: str, now: float) -> None: Arming is conditional: the run must reach `PASTE_BURST_MIN_CHARS` and must not be a slash command, where Enter always submits. + Any printable char also un-latches the suppressed-Enter state, so the next + Enter must re-qualify through the character gap. + Call this for any character that reaches the document without passing through `_track_burst_run` — a caller that inserts text itself and returns early must still keep the tracker in sync, or the run text will - diverge from the document and every promotion in this paste will fail. + diverge from the document and the run is discarded, losing grouping for + that stretch of the paste. Args: char: The character being inserted into the document. @@ -439,13 +461,13 @@ def _note_printable_burst_keystroke(self, char: str, now: float) -> None: def _check_burst_run_for_promotion(self) -> None: """Promote a rapid run whose shape or size already confirms a paste. - Called after each printable key has been inserted, so the run is present - in the document and `_promote_paste_burst_run` can find it. Two - confirmations do not need to wait for a newline: + Callers must invoke this only once the current character is in the + document, so the run is present and `_promote_paste_burst_run` can find + it. Two confirmations do not need to wait for a newline: - - A dropped-path shape (`/`, `~`, drive letter, `file://`, UNC), which - fast typing never produces. Without this, an unquoted single-line drop - would never reach path parsing or media rejection. + - A dropped-path shape (`/`, `~`, drive letter, `file://`, UNC). Without + this, an unquoted single-line drop would never reach path parsing or + media rejection. - A run past `PASTE_BURST_PROMOTE_CHARS`, which no human reaches at burst speed. Without this, a large single-line key-event paste would never collapse into a placeholder. @@ -458,28 +480,51 @@ def _check_burst_run_for_promotion(self) -> None: `_dispatch_burst_payload` — re-running path parsing, and its filesystem probes, once per character. - Ordinary typing is unaffected: it neither starts with a path shape nor - reaches the length threshold within the burst gap. + Ordinary typing reaches neither confirmation: to qualify it would have to + sustain `PASTE_BURST_MIN_CHARS` keystrokes inside + `PASTE_BURST_CHAR_GAP_SECONDS` of each other (~400 WPM) *and* either open + with a path shape or run past the length threshold. """ if self._paste_burst_buffer or self._paste_burst_run < PASTE_BURST_MIN_CHARS: return payload = self._paste_burst_run_text dispatch_payload = self._burst_run_payload_for_dispatch(payload) - is_path_payload = looks_like_dropped_payload(dispatch_payload) - # A virtual slash prefix is restored only for a ChatInput absolute - # path. Its visible suffix has its own separator (for example, - # `private/tmp/...`), unlike a slash-command name such as `help`. - recovered_path_has_separator = dispatch_payload != payload and "/" in payload - if ( - (not self._in_slash_command_context() or recovered_path_has_separator) - and is_path_payload - ) or len(payload) >= PASTE_BURST_PROMOTE_CHARS: - promoted = self._promote_paste_burst_run( - self._paste_burst_last_key_time or 0.0 + # In slash-command context Enter always submits and the text is a command, + # not content, so nothing may be hidden — with one exception: a payload the + # dispatch hook rewrote is a path whose leading `/` the mode prefix + # consumed (see `ChatTextArea._burst_run_payload_for_dispatch`), and that + # must reach path handling precisely so it stops being read as a command. + if self._in_slash_command_context() and dispatch_payload == payload: + return + if not ( + looks_like_dropped_payload(dispatch_payload) + or len(payload) >= PASTE_BURST_PROMOTE_CHARS + ): + return + last_key_time = self._paste_burst_last_key_time + if last_key_time is None: + # Unreachable: the run is at least `PASTE_BURST_MIN_CHARS`, and every + # counted keystroke stamps this field. Refuse rather than substitute a + # timestamp, which on a monotonic clock would land decades in the past + # and make the burst flush per character. + logger.warning( + "Qualifying burst run (%d chars) has no key timestamp; " + "skipping promotion", + self._paste_burst_run, ) - if promoted: - self._paste_burst_buffer = dispatch_payload - self._on_burst_run_promoted(payload, dispatch_payload) + return + if not self._promote_paste_burst_run(last_key_time): + # The shape or length already confirmed a paste, so failing here means + # dropped-path routing, media rejection, and collapsing are all + # silently skipped for this payload. Worth a breadcrumb. + logger.warning( + "Confirmed burst paste (%d chars) could not be promoted; " + "path routing and collapsing are skipped for it", + len(payload), + ) + return + self._paste_burst_buffer = dispatch_payload + self._on_burst_run_promoted(payload, dispatch_payload) def _consume_enter_as_burst_newline(self, now: float) -> bool: """Insert a newline instead of submitting when inside a paste burst. @@ -493,16 +538,29 @@ def _consume_enter_as_burst_newline(self, now: float) -> bool: return False # This newline confirms a multi-line key-event paste, so pull the # still-visible run into the buffer and keep the newline with it. The - # `_paste_burst_buffer` guard mirrors the one at - # `_enter_inserts_newline_during_burst`: the shipped `_on_key`s absorb or - # flush an active buffer before Enter reaches here, so it is defensive - # today, but promoting on top of a live buffer would double-count the run. - if not self._paste_burst_buffer and self._promote_paste_burst_run(now): + # `_paste_burst_buffer` guard checks the same field as + # `_enter_inserts_newline_during_burst` but to the opposite effect: there a + # live buffer forces suppression, here it forces the plain-newline + # fallback. Both shipped `_on_key`s absorb or flush an active buffer before + # Enter reaches here, so a live buffer is unreachable — and it would be + # actively wrong, not merely redundant: the newline would land in the + # document while the buffered run flushed in *after* it, reordering the + # paste. Loud rather than silently wrong if a future caller gets here. + if self._paste_burst_buffer: + logger.warning( + "Enter reached burst-newline handling with a live buffer " + "(%d chars); keeping the newline with the buffer", + len(self._paste_burst_buffer), + ) + self._append_paste_burst("\n", now) + elif self._promote_paste_burst_run(now): self._append_paste_burst("\n", now) else: self.action_insert_newline() # Set after promotion: `_promote_paste_burst_run` resets run tracking, # which clears `_paste_burst_last_suppressed_enter_time`. + # `_paste_burst_window_until` is not cleared by that reset; it is + # refreshed here to extend the grouping window. self._paste_burst_window_until = now + PASTE_ENTER_SUPPRESS_WINDOW_SECONDS self._paste_burst_last_suppressed_enter_time = now return True @@ -623,9 +681,14 @@ def _paste_collapse_enabled(self) -> bool: """ return self._collapse_pastes - async def _dispatch_burst_payload(self, payload: str) -> None: - """Collapse a large flushed burst, otherwise insert it verbatim.""" + async def _dispatch_burst_payload(self, payload: str) -> bool: + """Collapse a large flushed burst, otherwise insert it verbatim. + + Returns: + `False`; both branches apply the payload synchronously. + """ self._insert_paste_payload(payload) + return False def _insert_paste_payload(self, payload: str) -> None: """Collapse `payload` into a placeholder when large, else insert it.""" diff --git a/libs/code/deepagents_code/tui/widgets/chat_input.py b/libs/code/deepagents_code/tui/widgets/chat_input.py index f0e074079f..4e4f255d8c 100644 --- a/libs/code/deepagents_code/tui/widgets/chat_input.py +++ b/libs/code/deepagents_code/tui/widgets/chat_input.py @@ -31,7 +31,6 @@ from deepagents_code.input import ( IMAGE_PLACEHOLDER_PATTERN, VIDEO_PLACEHOLDER_PATTERN, - looks_like_dropped_payload, ) from deepagents_code.paste_collapse import ( PASTE_PLACEHOLDER_PATTERN, @@ -110,10 +109,20 @@ def _default_history_path() -> Path: A periodic refresh keeps `@` suggestions current; the walk runs off the event loop and swaps in atomically, so it never blocks typing.""" +_DEFERRED_SPACE_WATCHDOG_SECONDS = 0.25 +"""Deadline for a held synthetic space to be resolved by its posted message. + +The gate that holds the space swallows every keystroke behind it, so it needs a +backstop: a `DeferredSpace` that is never delivered would otherwise wedge the +input permanently. Comfortably longer than a message round trip, short enough +that a user who hits it sees a hiccup rather than a dead input. +""" + if TYPE_CHECKING: from textual import events from textual.app import ComposeResult from textual.events import Click + from textual.timer import Timer from deepagents_code.config_manifest import CursorStyle from deepagents_code.input import MediaTracker, ParsedPastedPathPayload @@ -507,7 +516,11 @@ def __init__(self, text: str) -> None: super().__init__() class DeferredSpace(Message): - """Insert a synthetic space after an already-queued burst payload.""" + """Posted when a synthetic space must wait for a queued burst payload. + + Relayed through `ChatInput` so it is handled after the `PastedText` / + `PastedPaths` message carrying the payload. + """ def __init__(self, burst_time: float) -> None: """Initialize with the original key-event timestamp. @@ -537,7 +550,12 @@ def __init__(self, **kwargs: Any) -> None: self._skip_history_change_events = 0 self._completion_active = False self._deferred_space_pending = False + self._deferred_space_time: float | None = None + self._deferred_space_replaying = False + self._deferred_space_timer: Timer | None = None + self._replaying_key_event: events.Key | None = None self._deferred_keys: list[tuple[str, str | None]] = [] + self._burst_payload_keeps_leading_slash = False # Paste-burst and backslash-pending state is initialized by # PasteBurstTextArea.__init__. # Tracks terminal focus so a click that re-focuses the window only @@ -884,14 +902,23 @@ def _paste_collapse_enabled(self) -> bool: owner = self._chat_input_owner return owner is None or owner._collapse_pastes - async def _dispatch_burst_payload(self, payload: str) -> None: + async def _dispatch_burst_payload(self, payload: str) -> bool: """Route a flushed burst through dropped-path and large-paste checks. When parsing fails, the buffered text is inserted unchanged so regular typing behavior is preserved. + + Returns: + `True` when the payload was handed to the owner as a message and will + be applied on a later event-loop turn, `False` when it was inserted + synchronously. Callers that must order a following keystroke against + the payload only need to wait in the `True` case. """ from deepagents_code.input import parse_pasted_path_payload + keeps_leading_slash = self._burst_payload_keeps_leading_slash + self._burst_payload_keeps_leading_slash = False + try: parsed = await asyncio.to_thread(parse_pasted_path_payload, payload) except Exception: @@ -909,32 +936,63 @@ async def _dispatch_burst_payload(self, payload: str) -> None: parsed = None if parsed is not None: self.post_message(self.PastedPaths(payload, parsed.paths)) - return + return True if self._paste_collapse_enabled() and _should_collapse_chat_paste(payload): self.post_message(self.PastedText(payload)) - return + return True + owner = self._chat_input_owner + if keeps_leading_slash and owner is not None: + # Consumed by the change handler this insert triggers, suppressing the + # mode re-detection that would otherwise strip the restored `/`. + owner._stripping_prefix = True self.insert(payload) # A multi-line payload adds rows the same way `action_insert_newline` # does, and needs the same post-refresh scroll for the same reason: the # built-in scroll sees stale dimensions and leaves the cursor off screen. if "\n" in payload: self.call_after_refresh(self.scroll_cursor_visible) + return False def _burst_run_payload_for_dispatch(self, payload: str) -> str: """Restore a virtual command prefix when a burst is an absolute path. + A `/` typed at offset 0 switches the input into command mode and is never + inserted, so a dropped absolute path replayed as key events loses its + leading separator. Restoring it lets the run be recognised as a path. + + The restore is deliberately narrow, because a payload rewritten here is + also what takes the input *out* of command mode + (`_on_burst_run_promoted`). Asking `looks_like_dropped_payload` about the + `/`-prefixed candidate cannot decide this — that function is a leading- + token check, so prepending `/` makes it vacuously true for any text. Three + conditions stand in for it instead: + + - The run must start at document offset 0, i.e. it is the text that + directly followed the consumed `/` rather than a later burst. + - The payload must contain its own separator, so `help` stays a command + name while `private/tmp/x` reads as a path tail. + - Nothing before that separator may be whitespace, which keeps a command + with a path argument (`read src/main.py`) from qualifying. + Returns: - The payload with a stripped leading slash restored when applicable. + The payload with the consumed leading slash restored, or the payload + unchanged when it does not look like the tail of an absolute path. """ owner = self._chat_input_owner if owner is None or owner.mode != "command": return payload - candidate = f"/{payload.lstrip('/')}" - if looks_like_dropped_payload(candidate): - return candidate - return payload + cursor_offset = self.document.get_index_from_location(self.cursor_location) # ty: ignore[unresolved-attribute] # Document has this method; DocumentBase stub is narrower + if cursor_offset != len(payload) or not self.text.startswith(payload): + return payload + head, separator, _ = payload.partition("/") + if not separator or any(char.isspace() for char in head): + return payload + # Exactly one `/` was consumed, so exactly one is restored: `lstrip("/")` + # would eat a second leading slash and silently drop a character from a + # `//host/share` payload. + return f"/{payload}" def _on_burst_run_promoted( self, visible_payload: str, dispatch_payload: str @@ -942,33 +1000,139 @@ def _on_burst_run_promoted( """Leave command mode when promotion recovered a leading path slash.""" if visible_payload == dispatch_payload: return + # The payload now leads with the restored `/`, so re-inserting it at + # offset 0 would trip mode-prefix detection a second time and strip the + # slash again — losing the character for good on the paths that do not + # resolve on disk. Flag it so the insert suppresses that detection. + self._burst_payload_keeps_leading_slash = True owner = self._chat_input_owner if owner is not None and owner.mode == "command": owner.mode = "normal" - async def _insert_deferred_space_and_keys(self, burst_time: float) -> None: - """Insert a queued synthetic space before replaying later key events.""" - self.insert(" ") - self._note_printable_burst_keystroke(" ", burst_time) - deferred_keys = self._deferred_keys - self._deferred_keys = [] + def _arm_deferred_space(self, space_now: float) -> None: + """Hold a synthetic space until a posted burst payload has been applied. + + Args: + space_now: Monotonic time when the space key arrived. + """ + self._deferred_space_pending = True + self._deferred_space_time = space_now + self.post_message(self.DeferredSpace(space_now)) + # The gate this arms swallows keystrokes, so it must not be able to + # outlive the message that clears it. A dropped message (widget closing), + # an unmounted owner, or a raising handler would otherwise leave the input + # silently dead with no recovery short of restarting the app. + self._cancel_deferred_space_timer() + self._deferred_space_timer = self.set_timer( + _DEFERRED_SPACE_WATCHDOG_SECONDS, self._recover_deferred_space + ) + + def _cancel_deferred_space_timer(self) -> None: + """Stop the deferred-space watchdog if one is scheduled.""" + if self._deferred_space_timer is None: + return + self._deferred_space_timer.stop() + self._deferred_space_timer = None + + async def _recover_deferred_space(self) -> None: + """Resolve a deferral whose `DeferredSpace` message never arrived.""" + if not self._deferred_space_pending: + return + logger.warning( + "DeferredSpace was never handled; recovering %d held key(s)", + len(self._deferred_keys), + ) + await self._resolve_deferred_space() + + def _clear_deferred_space(self) -> None: + """Drop all deferred-space state without replaying anything.""" + self._cancel_deferred_space_timer() self._deferred_space_pending = False + self._deferred_space_replaying = False + self._deferred_space_time = None + self._replaying_key_event = None + self._deferred_keys.clear() + + def _reset_paste_burst_state(self) -> None: + """Reset burst tracking, including the deferred-space gate. - from textual import events as textual_events + A wholesale text swap invalidates any held space and keystrokes: replaying + them would insert at a position that no longer means anything, and leaving + the gate armed would swallow input against the new text. + """ + self._clear_deferred_space() + super()._reset_paste_burst_state() + + def _insert_deferred_space(self, burst_time: float) -> None: + """Insert the held space unless the payload already ended with one.""" + # A dropped-path payload is applied by `_build_path_replacement`, which + # already appends its own trailing space. Adding ours would double it. + cursor_offset = self.document.get_index_from_location(self.cursor_location) # ty: ignore[unresolved-attribute] # Document has this method; DocumentBase stub is narrower + if cursor_offset > 0 and self.text[cursor_offset - 1] == " ": + return + self.insert(" ") + self._note_printable_burst_keystroke(" ", burst_time) - for key, character in deferred_keys: - await self._on_key(textual_events.Key(key, character)) + async def _resolve_deferred_space(self) -> None: + """Insert the held space, then replay the keys queued behind it.""" + if self._deferred_space_replaying: + return + burst_time = self._deferred_space_time + self._cancel_deferred_space_timer() + self._deferred_space_replaying = True + try: + if burst_time is not None: + self._insert_deferred_space(burst_time) + from textual import events as textual_events + + # Drained destructively from the front: a key that arrives while a + # replayed one is awaiting is appended to this same list by the gate, + # so arrival order holds and no key is ever held only in a local that + # an exception mid-replay would discard. + while self._deferred_keys: + key, character = self._deferred_keys.pop(0) + replay = textual_events.Key(key, character) + self._replaying_key_event = replay + try: + await self._on_key(replay) + finally: + self._replaying_key_event = None + finally: + self._deferred_space_replaying = False + self._deferred_space_pending = False + self._deferred_space_time = None + if self._deferred_keys: + # Only reachable when a replayed key raised. Report the loss + # rather than leaving them to be replayed into a later, unrelated + # document position. + logger.warning( + "Deferred key replay stopped early; dropping %d held key(s)", + len(self._deferred_keys), + ) + self._deferred_keys.clear() async def _on_key(self, event: events.Key) -> None: """Handle key events.""" # A character-less VS Code space may need to wait behind a burst payload # message. Keys already present in Textual's FIFO queue would otherwise # overtake it, so hold and replay them after the space is inserted. - if self._deferred_space_pending: - event.prevent_default() - event.stop() - self._deferred_keys.append((event.key, event.character)) - return + # The identity check lets the key currently being replayed through, while + # anything arriving from the terminal during that replay is still held. + if self._deferred_space_pending and event is not self._replaying_key_event: + printable = event.is_printable and event.character is not None + if printable or self._deferred_space_replaying: + event.prevent_default() + event.stop() + self._deferred_keys.append((event.key, event.character)) + return + # Only printable keys are safe to replay. A replayed event is built + # here rather than dispatched through the DOM, so it never bubbles to + # `ChatInput` (which owns completion navigation for `tab`/`up`/`down`) + # and never reaches binding resolution (`alt+backspace`); and a + # replayed `enter` would be judged against replay time, past the burst + # gap, and submit half the paste. So resolve the deferral now and let + # this key take its ordinary path. + await self._resolve_deferred_space() # Lock keys (Caps Lock, Num Lock, Scroll Lock) must never type. The # kitty parser patch in `_textual_patches.py` already neutralizes these @@ -1001,25 +1165,31 @@ async def _on_key(self, event: events.Key) -> None: # This branch bypasses the burst helpers below, so it has to drive # them itself: a space inside a replayed paste must reach the buffer # (if one is live) or the run tracker (if not), otherwise the - # tracker's text diverges from the document and every promotion for - # the rest of that paste fails the verification in - # `_promote_paste_burst_run`. + # tracker's text diverges from the document and the run is discarded, + # losing grouping for that stretch of the paste. space_now = time.monotonic() - if self._paste_burst_buffer: - if self._append_recent_paste_burst_text(" ", space_now): - self.post_message(self.Typing()) - return - await self._flush_paste_burst() - # Large-paste and dropped-path dispatch posts a message to the - # owner. Queue the space behind that message so its insertion - # cannot move the cursor before the payload is handled. - self._deferred_space_pending = True - self.post_message(self.DeferredSpace(space_now)) + if self._paste_burst_buffer and self._append_recent_paste_burst_text( + " ", space_now + ): + self.post_message(self.Typing()) + return + # The burst (if any) had gone idle, so this space follows the paste + # rather than belonging to it. Flush first, then order the space after + # the payload — but only when the payload will be applied by a posted + # message. Large-paste and dropped-path dispatch post; everything else + # inserted synchronously above, and arming the gate for those would + # swallow keystrokes for no reason. + if self._paste_burst_buffer and await self._flush_paste_burst(): + self._arm_deferred_space(space_now) self.post_message(self.Typing()) return self.insert(" ") self._note_printable_burst_keystroke(" ", space_now) self.post_message(self.Typing()) + # The space is in the document, so the run may now qualify — a path + # payload can end on a space, and a long single-line paste can cross + # the length threshold here. + self._check_burst_run_for_promotion() return now = time.monotonic() @@ -1094,6 +1264,12 @@ async def _on_key(self, event: events.Key) -> None: # Prevent TextArea's default behavior (e.g., Enter inserting newline) # but let event bubble to ChatInput for completion handling event.prevent_default() + # `space` is the one printable key here, so `_track_burst_run` above + # counted it while this branch inserts nothing. Drop the run so the + # tracker does not claim a character the document never received — + # the same reason as the mode-prefix branch below. + if event.is_printable: + self._reset_paste_burst_run() return # Plain Enter submits, unless a recent keystroke burst suggests this @@ -1119,10 +1295,8 @@ async def _on_key(self, event: events.Key) -> None: await super()._on_key(event) - # After the key is inserted, promote the rapid run if its shape (dropped - # path, for path routing) or size already confirms a paste. Must run - # after `super()._on_key` so the current character is already in the - # document and `_promote_paste_burst_run` can find it. + # Must follow `super()._on_key`: promotion verifies the run against the + # document, so the current character has to be in it already. self._check_burst_run_for_promotion() def action_delete_right(self) -> None: @@ -2359,10 +2533,21 @@ def on_chat_text_area_pasted_text(self, event: ChatTextArea.PastedText) -> None: async def on_chat_text_area_deferred_space( self, event: ChatTextArea.DeferredSpace ) -> None: - """Insert a synthetic space after the preceding burst payload.""" + """Insert a synthetic space after the preceding burst payload. + + Handled here rather than on `ChatTextArea` so it runs *after* the payload: + a `DeferredSpace` posted to the text area is drained by that widget's own + pump, which would process it before `PastedText`/`PastedPaths` reach this + one. The text area's own gate is what holds keystrokes in the meantime. + """ + del event # The timestamp is read from the text area's held state. if not self._text_area: + # No text area to replay into, but the gate lives on it and is already + # unreachable from here, so there is nothing to clear and nothing that + # can still swallow input. + logger.warning("DeferredSpace arrived with no text area; dropping it") return - await self._text_area._insert_deferred_space_and_keys(event.burst_time) + await self._text_area._resolve_deferred_space() def handle_external_paste(self, pasted: str) -> bool: """Handle paste text from app-level routing when input is not focused. diff --git a/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py b/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py index c89f731deb..597b9f8bad 100644 --- a/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py +++ b/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py @@ -211,6 +211,12 @@ def on_completion_option_clicked( assert 1 in app.clicked_indices +async def _ignore_deferred_space( + _self: ChatInput, _event: ChatTextArea.DeferredSpace +) -> None: + """Stand-in handler that drops a `DeferredSpace`, simulating a lost message.""" + + class _ChatInputTestApp(App[None]): """Minimal app that hosts a ChatInput for testing.""" @@ -5060,6 +5066,170 @@ async def test_vscode_space_stays_ahead_of_already_queued_key( assert ta.text == "abc x" assert ta._paste_burst_run_text == " x" + async def test_vscode_space_is_absorbed_into_a_live_burst( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A CSI-u space mid-paste joins the buffer instead of flushing it. + + This is the common case for a VS Code key-event paste. Without it, every + space would flush the paste mid-stream into separate fragments. + """ + monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 60.0) + monkeypatch.setattr( + paste_textarea_module, "PASTE_ENTER_SUPPRESS_WINDOW_SECONDS", 60.0 + ) + monkeypatch.setattr( + paste_textarea_module, "PASTE_BURST_FLUSH_DELAY_SECONDS", 0.25 + ) + + app = _RecordingApp() + async with app.run_test() as pilot: + chat = app.query_one(ChatInput) + ta = chat._text_area + assert ta is not None + + # Promote via a newline so the buffer is live and fresh. + for char in "abc": + await ta._on_key(events.Key(char, char)) + await ta._on_key(events.Key("enter", None)) + assert ta._paste_burst_buffer == "abc\n" + + await ta._on_key(events.Key("space", None)) + + # Absorbed into the hidden buffer, not inserted into the document. + assert ta._paste_burst_buffer == "abc\n " + assert ta.text == "" + assert ta._deferred_space_pending is False + + await pilot.pause(0.35) + assert ta.text == "abc\n " + + async def test_control_key_behind_a_held_space_still_reaches_its_binding( + self, + ) -> None: + """A non-printable key resolves the deferral instead of being replayed. + + A replayed key is constructed here rather than dispatched through the DOM, + so it never bubbles to `ChatInput` (completion navigation) and never + reaches binding resolution. `alt+backspace` is binding-driven, so it is + lost outright if the gate swallows it. + """ + app = _RecordingApp() + async with app.run_test() as pilot: + chat = app.query_one(ChatInput) + ta = chat._text_area + assert ta is not None + + ta.text = "foo bar" + ta.selection = Selection((0, 7), (0, 7)) + # Arm the gate with no message and no watchdog, so only the + # non-printable key itself can resolve it. + ta._deferred_space_pending = True + ta._deferred_space_time = None + + await pilot.press("alt+backspace") + await pilot.pause() + + assert ta._deferred_space_pending is False + assert ta.text == "foo " + + async def test_keys_queued_behind_a_held_space_keep_their_order( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Printable keys already in the queue land after the space, in order.""" + monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 0.03) + monkeypatch.setattr( + paste_textarea_module, "PASTE_BURST_FLUSH_DELAY_SECONDS", 0.25 + ) + + app = _RecordingApp() + async with app.run_test() as pilot: + chat = app.query_one(ChatInput) + ta = chat._text_area + assert ta is not None + + stale_time = ( + chat_input_module.time.monotonic() + - paste_textarea_module.PASTE_BURST_CHAR_GAP_SECONDS + - 0.01 + ) + ta._start_paste_burst("x" * 900, stale_time) + + # The space arms the gate; the rest of the paste is already queued. + ta.post_message(events.Key("space", None)) + for char in "world": + ta.post_message(events.Key(char, char)) + await pilot.pause(0.35) + + assert len(app.submitted) == 0 + assert ta.text == "[Pasted text #1] world" + + async def test_deferred_space_recovers_when_its_message_is_lost( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A `DeferredSpace` that never arrives must not wedge the input. + + The gate swallows every keystroke behind it, so a dropped message would + otherwise leave the input permanently dead with no recovery. + """ + monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 0.03) + monkeypatch.setattr(chat_input_module, "_DEFERRED_SPACE_WATCHDOG_SECONDS", 0.05) + + app = _RecordingApp() + async with app.run_test() as pilot: + chat = app.query_one(ChatInput) + ta = chat._text_area + assert ta is not None + + # Arm through the production path, but drop the message it posts so + # only the watchdog can clear the gate. + monkeypatch.setattr( + type(chat), + "on_chat_text_area_deferred_space", + _ignore_deferred_space, + ) + ta._arm_deferred_space(chat_input_module.time.monotonic()) + + await ta._on_key(events.Key("h", "h")) + assert ta.text == "" + + await pilot.pause(0.2) + + assert ta._deferred_space_pending is False + assert ta.text == " h" + + async def test_dropped_path_replacement_is_not_double_spaced( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The held space is dropped when the payload already ended with one.""" + monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 0.03) + monkeypatch.setattr( + paste_textarea_module, "PASTE_BURST_FLUSH_DELAY_SECONDS", 0.25 + ) + + img_path = tmp_path / "spaced.png" + from PIL import Image + + Image.new("RGB", (3, 3), color="teal").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 + + stale_time = ( + chat_input_module.time.monotonic() + - paste_textarea_module.PASTE_BURST_CHAR_GAP_SECONDS + - 0.01 + ) + ta._start_paste_burst(str(img_path), stale_time) + + await ta._on_key(events.Key("space", None)) + await pilot.pause(0.35) + + assert ta.text == "[image 1] " + async def test_flushed_run_is_not_re_promoted_by_a_later_enter( self, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -5113,6 +5283,176 @@ async def test_consumed_mode_prefix_resets_the_run(self) -> None: assert ta._paste_burst_run_text == "" + async def test_rapid_typing_in_command_mode_stays_visible( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Fast typing after a `/` is not mistaken for a dropped path. + + The slash-recovery hook prepends `/` before asking whether the payload + looks like a path, so a guard phrased as a question about the `/`-prefixed + candidate is vacuously true for any text. If that is the only guard, every + rapid run in command mode is hidden and the input silently leaves command + mode mid-command. + """ + monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 60.0) + monkeypatch.setattr( + paste_textarea_module, "PASTE_BURST_FLUSH_DELAY_SECONDS", 0.25 + ) + + app = _RecordingApp() + async with app.run_test() as pilot: + chat = app.query_one(ChatInput) + ta = chat._text_area + assert ta is not None + + for char in "/git": + await pilot.press(char) + for char in "add": + await pilot.press(char) + await pilot.pause(0.35) + + assert ta.text == "gitadd" + assert chat.mode == "command" + assert ta._paste_burst_buffer == "" + + async def test_rapid_slash_command_with_path_argument_is_not_promoted( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A command whose argument is a path keeps its command semantics. + + The payload contains a separator, so a separator-only test would treat + `read src/main.py` as the tail of an absolute path — injecting a `/` and + dropping out of command mode. Whitespace before the separator rules it out. + """ + monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 60.0) + monkeypatch.setattr( + paste_textarea_module, "PASTE_BURST_FLUSH_DELAY_SECONDS", 0.25 + ) + + app = _RecordingApp() + async with app.run_test() as pilot: + chat = app.query_one(ChatInput) + ta = chat._text_area + assert ta is not None + + # Driven through `_on_key` rather than `pilot.press` so slash-command + # completion cannot rewrite the text out from under the assertion. + for char in "/read src/main.py": + key = "space" if char == " " else char + await ta._on_key(events.Key(key, char)) + await pilot.pause(0.35) + + # Still a command, and nothing was hidden or slash-prefixed. (The + # space itself is swallowed by the open completion popup, so the exact + # text is not asserted here.) + assert chat.mode == "command" + assert ta._paste_burst_buffer == "" + assert not ta.text.startswith("/") + assert ta.text.endswith("src/main.py") + + async def test_rapid_absolute_path_that_does_not_exist_keeps_its_slash( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A recovered slash survives the insert that follows a failed parse. + + Only an existing path takes the `PastedPaths` branch. Everything else + falls through to a plain insert at offset 0, which trips mode-prefix + detection a second time — stripping the recovered slash again and losing + the character for good. + """ + monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 60.0) + monkeypatch.setattr( + paste_textarea_module, "PASTE_BURST_FLUSH_DELAY_SECONDS", 0.25 + ) + missing = tmp_path / "no-such-file.txt" + + app = _RecordingApp() + async with app.run_test() as pilot: + chat = app.query_one(ChatInput) + ta = chat._text_area + assert ta is not None + + for char in str(missing): + await ta._on_key(events.Key(char, char)) + await pilot.pause(0.35) + + assert ta.text == str(missing) + assert chat.mode == "normal" + assert chat.value == str(missing) + + async def test_run_just_below_the_promote_threshold_stays_visible( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Length-based promotion brackets exactly at `PASTE_BURST_PROMOTE_CHARS`.""" + monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 60.0) + monkeypatch.setattr( + paste_textarea_module, "PASTE_BURST_FLUSH_DELAY_SECONDS", 0.25 + ) + payload = "z" * (paste_textarea_module.PASTE_BURST_PROMOTE_CHARS - 1) + + app = _RecordingApp() + async with app.run_test() as pilot: + chat = app.query_one(ChatInput) + ta = chat._text_area + assert ta is not None + + for char in payload: + await ta._on_key(events.Key(char, char)) + await pilot.pause(0.35) + + assert ta.text == payload + assert ta._paste_burst_buffer == "" + + async def test_run_resets_at_human_typing_speed(self) -> None: + """Real inter-key gaps keep the run at one, so nothing ever qualifies. + + Every other test here widens the char gap so each keystroke counts as + burst speed. That is the right worst case for visibility, but it never + exercises the reset — and if the reset regressed, a slowly typed long + paragraph would vanish into a placeholder. + """ + app = _RecordingApp() + async with app.run_test() as pilot: + chat = app.query_one(ChatInput) + ta = chat._text_area + assert ta is not None + + for char in "abcde": + await ta._on_key(events.Key(char, char)) + await asyncio.sleep( + paste_textarea_module.PASTE_BURST_CHAR_GAP_SECONDS + 0.01 + ) + await pilot.pause() + + assert ta.text == "abcde" + assert ta._paste_burst_run == 1 + + async def test_completion_space_resets_the_run( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A space swallowed for completion is counted but never inserted. + + `space` is the one printable key the completion-navigation branch + intercepts, so without a reset the tracker claims a character the + document never received and later promotions fail verification. + """ + monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 60.0) + + app = _RecordingApp() + async with app.run_test() as pilot: + chat = app.query_one(ChatInput) + ta = chat._text_area + assert ta is not None + + for char in "ab": + await ta._on_key(events.Key(char, char)) + ta._completion_active = True + await ta._on_key(events.Key("space", " ")) + await pilot.pause() + + assert ta.text == "ab" + assert ta._paste_burst_run_text == "" + class TestPasteCollapseHelpers: """Unit tests for the paste_collapse module helpers.""" From 4a262ba8462141c608c1fa5a85ae84ca6e3829bc Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Tue, 18 Aug 2026 13:44:58 -0400 Subject: [PATCH 10/12] fix(code): apply burst pastes synchronously A flushed paste burst was applied by posting `PastedPaths`/`PastedText` to the owner, so the payload landed on a later event-loop turn. Textual appends a posted message to the tail of the widget's FIFO queue, behind any key the terminal already delivered, so the next keystroke was inserted ahead of the paste: typing `y` as a burst broke produced `y[Pasted text #1]`. A paste split across a slow terminal read boundary came out scrambled. Route every payload through `ChatInput.apply_paste_payload` instead, called synchronously from the burst flush, the bracketed-paste handler, and `handle_external_paste` (which already applied its payloads this way). The payload is in the document before the flush returns, so handling the current key afterwards orders it after the paste. The deferred-space gate existed to work around the posted application for one key, holding keystrokes and replaying them as synthetic events. Synchronous dispatch makes it unnecessary, so drop it along with the `DeferredSpace` message, its watchdog, and the six fields it tracked. A replayed key never bubbled to `ChatInput` and never reached binding resolution, so removing the gate also stops `tab`, `enter`, and `ctrl+c` being swallowed behind a held space. Also clear `_burst_payload_keeps_leading_slash` when the buffer it describes is discarded; a stale flag spent one mode re-detection on a later burst. --- .../tui/widgets/_inline_prompt.py | 14 +- .../tui/widgets/_paste_textarea.py | 58 ++-- .../deepagents_code/tui/widgets/chat_input.py | 325 +++++------------- .../unit_tests/tui/widgets/test_chat_input.py | 177 +++++++--- 4 files changed, 236 insertions(+), 338 deletions(-) diff --git a/libs/code/deepagents_code/tui/widgets/_inline_prompt.py b/libs/code/deepagents_code/tui/widgets/_inline_prompt.py index d5735190b2..5f0d32b7b8 100644 --- a/libs/code/deepagents_code/tui/widgets/_inline_prompt.py +++ b/libs/code/deepagents_code/tui/widgets/_inline_prompt.py @@ -222,17 +222,11 @@ async def _on_paste(self, event: events.Paste) -> None: # them a second time. The `prevent_default()` above is what stops that # walk on the rejection path. - async def _dispatch_burst_payload(self, payload: str) -> bool: - """Reject a media file replayed as a key burst, else defer to the base. - - Returns: - `False` on the rejection path — nothing is inserted, so there is no - pending application for a caller to order against — otherwise - whatever the base reports. - """ + async def _dispatch_burst_payload(self, payload: str) -> None: + """Reject a media file replayed as a key burst, else defer to the base.""" if await self._reject_dropped_media(payload): - return False - return await super()._dispatch_burst_payload(payload) + return + await super()._dispatch_burst_payload(payload) async def _reject_dropped_media(self, text: str) -> bool: """Toast and swallow a dropped payload containing an image or video. diff --git a/libs/code/deepagents_code/tui/widgets/_paste_textarea.py b/libs/code/deepagents_code/tui/widgets/_paste_textarea.py index 6824070642..a2543c63c1 100644 --- a/libs/code/deepagents_code/tui/widgets/_paste_textarea.py +++ b/libs/code/deepagents_code/tui/widgets/_paste_textarea.py @@ -154,13 +154,13 @@ def __init__(self, **kwargs: Any) -> None: def _init_paste_burst_state(self) -> None: """Reset all paste-burst tracking fields to their initial values.""" - # Buffer high-frequency key bursts from terminals that emulate paste via - # rapid key events instead of dispatching a paste event. + # Holds burst text only after promotion. A rapid run stays in the + # document until something confirms it is a paste. self._paste_burst_buffer = "" self._paste_burst_last_char_time = None self._paste_burst_timer = None - # Counts consecutive rapid keystrokes so a paste-like stream can be - # detected even when it doesn't begin with a quote. + # Counts consecutive rapid keystrokes so a paste replayed as key events + # can be recognized without a bracketed paste event. self._paste_burst_run = 0 self._paste_burst_run_text = "" self._paste_burst_last_key_time = None @@ -219,17 +219,15 @@ def _in_slash_command_context(self) -> bool: # noqa: PLR6301 # overridable hoo """ return False - async def _dispatch_burst_payload(self, payload: str) -> bool: + async def _dispatch_burst_payload(self, payload: str) -> None: """Handle a flushed burst payload. Base behavior inserts it verbatim. - Returns: - `True` when the payload was deferred to a posted message rather than - applied to the document here, so a caller that must order a following - keystroke after the payload has to wait for that message. The base - implementation inserts synchronously and returns `False`. + Implementations must apply the payload before returning. A payload + applied later — from a posted message or a scheduled callback — is + ordered behind any key event already waiting in this widget's queue, so + the next keystroke would be inserted ahead of the paste. """ self.insert(payload) - return False def _burst_run_payload_for_dispatch(self, payload: str) -> str: # noqa: PLR6301 # overridable hook """Return the payload represented by a visible rapid-key run. @@ -360,24 +358,21 @@ def _enter_inserts_newline_during_burst(self, now: float) -> bool: return False return (now - last_key) <= PASTE_BURST_CHAR_GAP_SECONDS - async def _flush_paste_burst(self) -> bool: + async def _flush_paste_burst(self) -> None: """Flush buffered burst text through the payload dispatch hook. When the buffer is empty this is a no-op, so it is safe to call - defensively before handling a bracketed paste. - - Returns: - Whatever `_dispatch_burst_payload` reported — `True` when the payload - will be applied by a posted message rather than synchronously. `False` - when there was nothing buffered. + defensively before handling a bracketed paste. The payload is applied + before this returns, so a caller may go on to handle the current key + knowing it lands after the paste. """ payload = self._paste_burst_buffer self._paste_burst_buffer = "" self._paste_burst_last_char_time = None self._cancel_paste_burst_timer() if not payload: - return False - return await self._dispatch_burst_payload(payload) + return + await self._dispatch_burst_payload(payload) def _promote_paste_burst_run(self, now: float) -> bool: """Move an already-inserted rapid run out of the document into the buffer. @@ -399,7 +394,7 @@ def _promote_paste_burst_run(self, now: float) -> bool: an empty run, a non-empty selection (deleting would clobber the user's selected range), or a document whose text immediately before the cursor is no longer the tracked run — which means an intervening - edit desynchronised the tracker. Callers must fall back to handling + edit desynchronized the tracker. Callers must fall back to handling the key normally. """ payload = self._paste_burst_run_text @@ -409,7 +404,7 @@ def _promote_paste_burst_run(self, now: float) -> bool: cursor_offset = self.document.get_index_from_location(cursor) # ty: ignore[unresolved-attribute] # Document has this method; DocumentBase stub is narrower start_offset = cursor_offset - len(payload) if start_offset < 0 or self.text[start_offset:cursor_offset] != payload: - # An untracked edit desynchronised the tracker, so drop the stale run + # An untracked edit desynchronized the tracker, so drop the stale run # and let tracking restart on the next keystroke. Logged at warning # (not debug) so it survives the default INFO level — this is a state # -machine bug, not a user-reachable condition. The message carries @@ -445,7 +440,9 @@ async def _absorb_key_into_burst(self, event: events.Key, now: float) -> bool: Returns: `True` when the key was buffered and the caller should stop handling it; `False` when there is no active burst (or it was just flushed) - and the caller should continue normal key handling. + and the caller should continue normal key handling. A flush applies + its payload before returning, so handling the key afterwards orders + it after the paste. """ if not self._paste_burst_buffer: return False @@ -505,9 +502,9 @@ def _check_burst_run_for_promotion(self) -> None: - A dropped-path shape (`/`, `~`, drive letter, `file://`, UNC). Without this, an unquoted single-line drop would never reach path parsing or media rejection. - - A run past `PASTE_BURST_PROMOTE_CHARS`, which no human reaches at - burst speed. Without this, a large single-line key-event paste would - never collapse into a placeholder. + - A run that reaches `PASTE_BURST_PROMOTE_CHARS`, a length no human + reaches at burst speed. Without this, a large single-line key-event + paste would never collapse into a placeholder. Neither case flushes here. The paste is still streaming, so the run is only a prefix of the payload; once promoted, the remaining characters @@ -718,14 +715,9 @@ def _paste_collapse_enabled(self) -> bool: """ return self._collapse_pastes - async def _dispatch_burst_payload(self, payload: str) -> bool: - """Collapse a large flushed burst, otherwise insert it verbatim. - - Returns: - `False`; both branches apply the payload synchronously. - """ + async def _dispatch_burst_payload(self, payload: str) -> None: + """Collapse a large flushed burst, otherwise insert it verbatim.""" self._insert_paste_payload(payload) - return False def _insert_paste_payload(self, payload: str) -> None: """Collapse `payload` into a placeholder when large, else insert it.""" diff --git a/libs/code/deepagents_code/tui/widgets/chat_input.py b/libs/code/deepagents_code/tui/widgets/chat_input.py index 5031424983..11eca05040 100644 --- a/libs/code/deepagents_code/tui/widgets/chat_input.py +++ b/libs/code/deepagents_code/tui/widgets/chat_input.py @@ -180,21 +180,11 @@ def _default_history_path() -> Path: A periodic refresh keeps `@` suggestions current; the walk runs off the event loop and swaps in atomically, so it never blocks typing.""" -_DEFERRED_SPACE_WATCHDOG_SECONDS = 0.25 -"""Deadline for a held synthetic space to be resolved by its posted message. - -The gate that holds the space swallows every keystroke behind it, so it needs a -backstop: a `DeferredSpace` that is never delivered would otherwise wedge the -input permanently. Comfortably longer than a message round trip, short enough -that a user who hits it sees a hiccup rather than a dead input. -""" - if TYPE_CHECKING: from textual import events from textual.app import ComposeResult from textual.events import Click from textual.screen import Screen - from textual.timer import Timer from deepagents_code.config_manifest import CursorStyle from deepagents_code.input import MediaTracker, ParsedPastedPathPayload @@ -590,47 +580,6 @@ def __init__(self, current_text: str) -> None: class HistoryNext(Message): """Request next history entry.""" - class PastedPaths(Message): - """Message sent when paste payload resolves to file paths.""" - - def __init__(self, raw_text: str, paths: list[Path]) -> None: - """Initialize with raw pasted text and parsed file paths.""" - self.raw_text = raw_text - self.paths = paths - super().__init__() - - class PastedText(Message): - """Message sent when a paste is large enough to be collapsed. - - The full text is carried in the message so `ChatInput` can store it - and insert a compact placeholder into the text area instead. - """ - - def __init__(self, text: str) -> None: - """Initialize with the full pasted text. - - Args: - text: The complete pasted text content. - """ - self.text = text - super().__init__() - - class DeferredSpace(Message): - """Posted when a synthetic space must wait for a queued burst payload. - - Relayed through `ChatInput` so it is handled after the `PastedText` / - `PastedPaths` message carrying the payload. - """ - - def __init__(self, burst_time: float) -> None: - """Initialize with the original key-event timestamp. - - Args: - burst_time: Monotonic time when the space key arrived. - """ - self.burst_time = burst_time - super().__init__() - class Typing(Message): """Posted when the user presses a printable key or backspace. @@ -649,12 +598,6 @@ def __init__(self, **kwargs: Any) -> None: self._chat_input_owner: ChatInput | None = None self._skip_history_change_events = 0 self._completion_active = False - self._deferred_space_pending = False - self._deferred_space_time: float | None = None - self._deferred_space_replaying = False - self._deferred_space_timer: Timer | None = None - self._replaying_key_event: events.Key | None = None - self._deferred_keys: list[tuple[str, str | None]] = [] self._burst_payload_keeps_leading_slash = False # Paste-burst and backslash-pending state is initialized by # PasteBurstTextArea.__init__. @@ -1002,22 +945,23 @@ def _paste_collapse_enabled(self) -> bool: owner = self._chat_input_owner return owner is None or owner._collapse_pastes - async def _dispatch_burst_payload(self, payload: str) -> bool: + async def _dispatch_burst_payload(self, payload: str) -> None: """Route a flushed burst through dropped-path and large-paste checks. - When parsing fails, the buffered text is inserted unchanged so regular - typing behavior is preserved. + Routed payloads are applied through the owner synchronously rather than + posted to it, so the payload is in the document before this returns. A + posted message lands at the tail of this widget's queue, behind any + keystroke the terminal has already delivered, which would insert that + character ahead of the paste. - Returns: - `True` when the payload was handed to the owner as a message and will - be applied on a later event-loop turn, `False` when it was inserted - synchronously. Callers that must order a following keystroke against - the payload only need to wait in the `True` case. + When parsing fails, or there is no owner to route through, the buffered + text is inserted unchanged so regular typing behavior is preserved. """ from deepagents_code.input import parse_pasted_path_payload keeps_leading_slash = self._burst_payload_keeps_leading_slash self._burst_payload_keeps_leading_slash = False + owner = self._chat_input_owner try: parsed = await asyncio.to_thread(parse_pasted_path_payload, payload) @@ -1034,33 +978,35 @@ async def _dispatch_burst_payload(self, payload: str) -> bool: exc_info=True, ) parsed = None - if parsed is not None: - self.post_message(self.PastedPaths(payload, parsed.paths)) - return True - - if self._paste_collapse_enabled() and _should_collapse_chat_paste(payload): - self.post_message(self.PastedText(payload)) - return True + if owner is not None: + if parsed is not None: + applied = owner.apply_paste_payload(payload, parsed.paths) + elif self._paste_collapse_enabled() and _should_collapse_chat_paste( + payload + ): + applied = owner.apply_paste_payload(payload, None) + else: + applied = False + if applied: + return - owner = self._chat_input_owner if keeps_leading_slash and owner is not None: # Consumed by the change handler this insert triggers, suppressing the # mode re-detection that would otherwise strip the restored `/`. - owner._stripping_prefix = True + owner.suppress_next_prefix_detection() self.insert(payload) # A multi-line payload adds rows the same way `action_insert_newline` # does, and needs the same post-refresh scroll for the same reason: the # built-in scroll sees stale dimensions and leaves the cursor off screen. if "\n" in payload: self.call_after_refresh(self.scroll_cursor_visible) - return False def _burst_run_payload_for_dispatch(self, payload: str) -> str: """Restore a virtual command prefix when a burst is an absolute path. A `/` typed at offset 0 switches the input into command mode and is never inserted, so a dropped absolute path replayed as key events loses its - leading separator. Restoring it lets the run be recognised as a path. + leading separator. Restoring it lets the run be recognized as a path. The restore is deliberately narrow, because a payload rewritten here is also what takes the input *out* of command mode @@ -1109,131 +1055,28 @@ def _on_burst_run_promoted( if owner is not None and owner.mode == "command": owner.mode = "normal" - def _arm_deferred_space(self, space_now: float) -> None: - """Hold a synthetic space until a posted burst payload has been applied. - - Args: - space_now: Monotonic time when the space key arrived. - """ - self._deferred_space_pending = True - self._deferred_space_time = space_now - self.post_message(self.DeferredSpace(space_now)) - # The gate this arms swallows keystrokes, so it must not be able to - # outlive the message that clears it. A dropped message (widget closing), - # an unmounted owner, or a raising handler would otherwise leave the input - # silently dead with no recovery short of restarting the app. - self._cancel_deferred_space_timer() - self._deferred_space_timer = self.set_timer( - _DEFERRED_SPACE_WATCHDOG_SECONDS, self._recover_deferred_space - ) - - def _cancel_deferred_space_timer(self) -> None: - """Stop the deferred-space watchdog if one is scheduled.""" - if self._deferred_space_timer is None: - return - self._deferred_space_timer.stop() - self._deferred_space_timer = None - - async def _recover_deferred_space(self) -> None: - """Resolve a deferral whose `DeferredSpace` message never arrived.""" - if not self._deferred_space_pending: - return - logger.warning( - "DeferredSpace was never handled; recovering %d held key(s)", - len(self._deferred_keys), - ) - await self._resolve_deferred_space() - - def _clear_deferred_space(self) -> None: - """Drop all deferred-space state without replaying anything.""" - self._cancel_deferred_space_timer() - self._deferred_space_pending = False - self._deferred_space_replaying = False - self._deferred_space_time = None - self._replaying_key_event = None - self._deferred_keys.clear() - def _reset_paste_burst_state(self) -> None: - """Reset burst tracking, including the deferred-space gate. + """Reset burst tracking, including the restored-slash flag. - A wholesale text swap invalidates any held space and keystrokes: replaying - them would insert at a position that no longer means anything, and leaving - the gate armed would swallow input against the new text. + The flag describes the buffered payload that `super()` is about to + discard, so it must not outlive it: a stale `True` would suppress the next + burst's legitimate mode re-detection. """ - self._clear_deferred_space() + self._burst_payload_keeps_leading_slash = False super()._reset_paste_burst_state() - def _insert_deferred_space(self, burst_time: float) -> None: - """Insert the held space unless the payload already ended with one.""" - # A dropped-path payload is applied by `_build_path_replacement`, which - # already appends its own trailing space. Adding ours would double it. - cursor_offset = self.document.get_index_from_location(self.cursor_location) # ty: ignore[unresolved-attribute] # Document has this method; DocumentBase stub is narrower - if cursor_offset > 0 and self.text[cursor_offset - 1] == " ": - return - self.insert(" ") - self._note_printable_burst_keystroke(" ", burst_time) + def _payload_supplied_trailing_space(self) -> bool: + """Return whether the text before the cursor already ends in a space. - async def _resolve_deferred_space(self) -> None: - """Insert the held space, then replay the keys queued behind it.""" - if self._deferred_space_replaying: - return - burst_time = self._deferred_space_time - self._cancel_deferred_space_timer() - self._deferred_space_replaying = True - try: - if burst_time is not None: - self._insert_deferred_space(burst_time) - from textual import events as textual_events - - # Drained destructively from the front: a key that arrives while a - # replayed one is awaiting is appended to this same list by the gate, - # so arrival order holds and no key is ever held only in a local that - # an exception mid-replay would discard. - while self._deferred_keys: - key, character = self._deferred_keys.pop(0) - replay = textual_events.Key(key, character) - self._replaying_key_event = replay - try: - await self._on_key(replay) - finally: - self._replaying_key_event = None - finally: - self._deferred_space_replaying = False - self._deferred_space_pending = False - self._deferred_space_time = None - if self._deferred_keys: - # Only reachable when a replayed key raised. Report the loss - # rather than leaving them to be replayed into a later, unrelated - # document position. - logger.warning( - "Deferred key replay stopped early; dropping %d held key(s)", - len(self._deferred_keys), - ) - self._deferred_keys.clear() + A dropped-path payload is applied by `_build_path_replacement`, which + appends its own trailing space. Inserting the pending space as well would + double it. + """ + cursor_offset = self.document.get_index_from_location(self.cursor_location) # ty: ignore[unresolved-attribute] # Document has this method; DocumentBase stub is narrower + return cursor_offset > 0 and self.text[cursor_offset - 1] == " " async def _on_key(self, event: events.Key) -> None: """Handle key events.""" - # A character-less VS Code space may need to wait behind a burst payload - # message. Keys already present in Textual's FIFO queue would otherwise - # overtake it, so hold and replay them after the space is inserted. - # The identity check lets the key currently being replayed through, while - # anything arriving from the terminal during that replay is still held. - if self._deferred_space_pending and event is not self._replaying_key_event: - printable = event.is_printable and event.character is not None - if printable or self._deferred_space_replaying: - event.prevent_default() - event.stop() - self._deferred_keys.append((event.key, event.character)) - return - # Only printable keys are safe to replay. A replayed event is built - # here rather than dispatched through the DOM, so it never bubbles to - # `ChatInput` (which owns completion navigation for `tab`/`up`/`down`) - # and never reaches binding resolution (`alt+backspace`); and a - # replayed `enter` would be judged against replay time, past the burst - # gap, and submit half the paste. So resolve the deferral now and let - # this key take its ordinary path. - await self._resolve_deferred_space() - # Lock keys (Caps Lock, Num Lock, Scroll Lock) must never type. The # kitty parser patch in `_textual_patches.py` already neutralizes these # at the source; this is defense-in-depth in case a lock key still @@ -1274,15 +1117,13 @@ async def _on_key(self, event: events.Key) -> None: self.post_message(self.Typing()) return # The burst (if any) had gone idle, so this space follows the paste - # rather than belonging to it. Flush first, then order the space after - # the payload — but only when the payload will be applied by a posted - # message. Large-paste and dropped-path dispatch post; everything else - # inserted synchronously above, and arming the gate for those would - # swallow keystrokes for no reason. - if self._paste_burst_buffer and await self._flush_paste_burst(): - self._arm_deferred_space(space_now) - self.post_message(self.Typing()) - return + # rather than belonging to it. Flushing applies the payload before it + # returns, so the space inserted next lands after it. + if self._paste_burst_buffer: + await self._flush_paste_burst() + if self._payload_supplied_trailing_space(): + self.post_message(self.Typing()) + return self.insert(" ") self._note_printable_burst_keystroke(" ", space_now) self.post_message(self.Typing()) @@ -1367,7 +1208,7 @@ async def _on_key(self, event: events.Key) -> None: # `space` is the one printable key here, so `_track_burst_run` above # counted it while this branch inserts nothing. Drop the run so the # tracker does not claim a character the document never received — - # the same reason as the mode-prefix branch below. + # the same reason as the mode-prefix branch above. if event.is_printable: self._reset_paste_burst_run() return @@ -1567,19 +1408,25 @@ async def _on_paste(self, event: events.Paste) -> None: exc_info=True, ) parsed = None - if parsed is not None: + owner = self._chat_input_owner + if parsed is not None and owner is not None: event.prevent_default() event.stop() - self.post_message(self.PastedPaths(event.text, parsed.paths)) + owner.apply_paste_payload(event.text, parsed.paths) return - if self._paste_collapse_enabled() and _should_collapse_chat_paste(event.text): + if ( + owner is not None + and self._paste_collapse_enabled() + and _should_collapse_chat_paste(event.text) + ): # Intercept the paste so Textual's default _on_paste doesn't insert - # the full text. ChatInput stores the content and inserts a compact - # placeholder instead. + # the full text. The owner stores the content and inserts a compact + # placeholder instead — applied here rather than posted, so a + # keystroke queued behind this paste cannot overtake it. event.prevent_default() event.stop() - self.post_message(self.PastedText(event.text)) + owner.apply_paste_payload(event.text, None) return # Don't call super() here — Textual's MRO dispatch already calls @@ -3140,45 +2987,41 @@ def on_chat_text_area_history_next( else: self.app.bell() - def on_chat_text_area_pasted_paths(self, event: ChatTextArea.PastedPaths) -> None: - """Handle paste payloads that resolve to dropped file paths.""" - if not self._text_area: - return - - self._insert_pasted_paths(event.raw_text, event.paths) + def apply_paste_payload(self, text: str, paths: list[Path] | None) -> bool: + """Apply an already-parsed paste payload to the input. - def on_chat_text_area_pasted_text(self, event: ChatTextArea.PastedText) -> None: - """Handle large pastes by collapsing into a compact placeholder. - - Stores the full text in `_pasted_contents` and inserts a - `[Pasted text #N +M lines]` placeholder into the text area instead - of the raw content, keeping the input box compact. + Callers apply a payload through this method rather than posting it as a + message so it reaches the document synchronously. Textual appends a + posted message to the tail of the receiving widget's FIFO queue, so a + keystroke the terminal already delivered would be handled first and land + ahead of the paste. Args: - event: The `PastedText` message carrying the full pasted text. + text: Raw payload text. + paths: Resolved dropped paths, or `None` to collapse `text` into a + `[Pasted text #N]` placeholder. + + Returns: + `True` when the payload was applied. `False` when there is no text + area to apply it to, in which case the caller still owns the text. """ if not self._text_area: - return - self._collapse_and_insert_paste(event.text) + return False + if paths is not None: + self._insert_pasted_paths(text, paths) + else: + self._collapse_and_insert_paste(text) + return True - async def on_chat_text_area_deferred_space( - self, event: ChatTextArea.DeferredSpace - ) -> None: - """Insert a synthetic space after the preceding burst payload. + def suppress_next_prefix_detection(self) -> None: + """Skip mode-prefix detection for the next text change. - Handled here rather than on `ChatTextArea` so it runs *after* the payload: - a `DeferredSpace` posted to the text area is drained by that widget's own - pump, which would process it before `PastedText`/`PastedPaths` reach this - one. The text area's own gate is what holds keystrokes in the meantime. + Used when inserting text that legitimately starts with a mode trigger, so + the change handler does not consume that character. Shares the guard with + `_strip_mode_prefix`, which reports a guard left uncleared by a missed + change event. """ - del event # The timestamp is read from the text area's held state. - if not self._text_area: - # No text area to replay into, but the gate lives on it and is already - # unreachable from here, so there is nothing to clear and nothing that - # can still swallow input. - logger.warning("DeferredSpace arrived with no text area; dropping it") - return - await self._text_area._resolve_deferred_space() + self._stripping_prefix = True def handle_external_paste(self, pasted: str) -> bool: """Handle paste text from app-level routing when input is not focused. @@ -3199,9 +3042,9 @@ def handle_external_paste(self, pasted: str) -> bool: parsed = self._parse_dropped_path_payload(pasted) if parsed is not None: - self._insert_pasted_paths(pasted, parsed.paths) + self.apply_paste_payload(pasted, parsed.paths) elif self._collapse_pastes and _should_collapse_chat_paste(pasted): - self._collapse_and_insert_paste(pasted) + self.apply_paste_payload(pasted, None) else: self._text_area.insert(pasted) diff --git a/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py b/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py index 0dd23e1877..26064a06f9 100644 --- a/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py +++ b/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py @@ -217,12 +217,6 @@ def on_completion_option_clicked( assert 1 in app.clicked_indices -async def _ignore_deferred_space( - _self: ChatInput, _event: ChatTextArea.DeferredSpace -) -> None: - """Stand-in handler that drops a `DeferredSpace`, simulating a lost message.""" - - class _ChatInputTestApp(App[None]): """Minimal app that hosts a ChatInput for testing.""" @@ -5752,7 +5746,7 @@ async def test_vscode_space_follows_queued_stale_payload( async def test_vscode_space_stays_ahead_of_already_queued_key( self, monkeypatch: pytest.MonkeyPatch ) -> None: - """A key queued before `DeferredSpace` must not overtake the space.""" + """A key queued behind the space must not overtake it.""" monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 0.03) monkeypatch.setattr( paste_textarea_module, "PASTE_BURST_FLUSH_DELAY_SECONDS", 0.25 @@ -5811,44 +5805,52 @@ async def test_vscode_space_is_absorbed_into_a_live_burst( # Absorbed into the hidden buffer, not inserted into the document. assert ta._paste_burst_buffer == "abc\n " assert ta.text == "" - assert ta._deferred_space_pending is False await pilot.pause(0.35) assert ta.text == "abc\n " - async def test_control_key_behind_a_held_space_still_reaches_its_binding( - self, + async def test_printable_key_lands_after_a_flushed_payload( + self, monkeypatch: pytest.MonkeyPatch ) -> None: - """A non-printable key resolves the deferral instead of being replayed. + """A key that breaks a stale burst is inserted after the payload. - A replayed key is constructed here rather than dispatched through the DOM, - so it never bubbles to `ChatInput` (completion navigation) and never - reaches binding resolution. `alt+backspace` is binding-driven, so it is - lost outright if the gate swallows it. + The payload is applied while handling this key, so it cannot be overtaken. + Applying it by a posted message instead put the character first, splitting + a paste that arrived across a slow terminal read boundary. """ + monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 0.03) + monkeypatch.setattr( + paste_textarea_module, "PASTE_BURST_FLUSH_DELAY_SECONDS", 0.25 + ) + app = _RecordingApp() async with app.run_test() as pilot: chat = app.query_one(ChatInput) ta = chat._text_area assert ta is not None - ta.text = "foo bar" - ta.selection = Selection((0, 7), (0, 7)) - # Arm the gate with no message and no watchdog, so only the - # non-printable key itself can resolve it. - ta._deferred_space_pending = True - ta._deferred_space_time = None + stale_time = ( + chat_input_module.time.monotonic() + - paste_textarea_module.PASTE_BURST_CHAR_GAP_SECONDS + - 0.01 + ) + payload = "x" * 900 + ta._start_paste_burst(payload, stale_time) - await pilot.press("alt+backspace") + await ta._on_key(events.Key("y", "y")) await pilot.pause() - assert ta._deferred_space_pending is False - assert ta.text == "foo " + assert ta.text == "[Pasted text #1]y" + assert chat._pasted_contents[1].content == payload - async def test_keys_queued_behind_a_held_space_keep_their_order( + async def test_backspace_after_a_flushed_payload_leaves_earlier_text_alone( self, monkeypatch: pytest.MonkeyPatch ) -> None: - """Printable keys already in the queue land after the space, in order.""" + """Backspace breaking a stale burst edits the payload, not the text before it. + + The payload lands first, so the deletion applies to it. Applying the + payload later deleted a character the user typed before the paste. + """ monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 0.03) monkeypatch.setattr( paste_textarea_module, "PASTE_BURST_FLUSH_DELAY_SECONDS", 0.25 @@ -5860,6 +5862,8 @@ async def test_keys_queued_behind_a_held_space_keep_their_order( ta = chat._text_area assert ta is not None + ta.focus() + ta.insert("ab") stale_time = ( chat_input_module.time.monotonic() - paste_textarea_module.PASTE_BURST_CHAR_GAP_SECONDS @@ -5867,25 +5871,43 @@ async def test_keys_queued_behind_a_held_space_keep_their_order( ) ta._start_paste_burst("x" * 900, stale_time) - # The space arms the gate; the rest of the paste is already queued. - ta.post_message(events.Key("space", None)) - for char in "world": - ta.post_message(events.Key(char, char)) - await pilot.pause(0.35) + # Pressed through the app so the backspace binding actually resolves. + await pilot.press("backspace") + await pilot.pause() - assert len(app.submitted) == 0 - assert ta.text == "[Pasted text #1] world" + # The placeholder is deleted as one token, so only the paste is undone. + assert ta.text == "ab" - async def test_deferred_space_recovers_when_its_message_is_lost( + async def test_bracketed_paste_stays_ahead_of_a_queued_key(self) -> None: + """A key queued behind a bracketed paste must not overtake it.""" + app = _RecordingApp() + async with app.run_test() as pilot: + chat = app.query_one(ChatInput) + ta = chat._text_area + assert ta is not None + + payload = "z" * 900 + ta.post_message(events.Paste(payload)) + ta.post_message(events.Key("q", "q")) + await pilot.pause() + + assert ta.text == "[Pasted text #1]q" + assert chat._pasted_contents[1].content == payload + + async def test_run_of_exactly_the_promote_threshold_is_reinserted_verbatim( self, monkeypatch: pytest.MonkeyPatch ) -> None: - """A `DeferredSpace` that never arrives must not wedge the input. + """A run of exactly `PASTE_BURST_PROMOTE_CHARS` promotes but does not collapse. - The gate swallows every keystroke behind it, so a dropped message would - otherwise leave the input permanently dead with no recovery. + Promotion uses `>=` while `should_collapse_paste` uses `>`, so this length + is hidden and then restored unchanged. Pinned because the two comparisons + must keep agreeing that no character is lost between them. """ - monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 0.03) - monkeypatch.setattr(chat_input_module, "_DEFERRED_SPACE_WATCHDOG_SECONDS", 0.05) + monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 60.0) + monkeypatch.setattr( + paste_textarea_module, "PASTE_BURST_FLUSH_DELAY_SECONDS", 0.05 + ) + length = paste_textarea_module.PASTE_BURST_PROMOTE_CHARS app = _RecordingApp() async with app.run_test() as pilot: @@ -5893,22 +5915,69 @@ async def test_deferred_space_recovers_when_its_message_is_lost( ta = chat._text_area assert ta is not None - # Arm through the production path, but drop the message it posts so - # only the watchdog can clear the gate. - monkeypatch.setattr( - type(chat), - "on_chat_text_area_deferred_space", - _ignore_deferred_space, - ) - ta._arm_deferred_space(chat_input_module.time.monotonic()) + for _ in range(length): + await ta._on_key(events.Key("a", "a")) - await ta._on_key(events.Key("h", "h")) + # Confirmed by length alone, so the run is hidden before it flushes. + assert ta._paste_burst_buffer == "a" * length assert ta.text == "" await pilot.pause(0.2) - assert ta._deferred_space_pending is False - assert ta.text == " h" + assert ta.text == "a" * length + assert chat._pasted_contents == {} + + async def test_burst_state_reset_clears_the_restored_slash_flag(self) -> None: + """A discarded payload must not leave its slash flag set. + + The flag suppresses one mode re-detection. Surviving the payload it + describes would spend that suppression on an unrelated later burst. + """ + app = _RecordingApp() + async with app.run_test(): + chat = app.query_one(ChatInput) + ta = chat._text_area + assert ta is not None + + ta._burst_payload_keeps_leading_slash = True + ta._start_paste_burst("/private/tmp", chat_input_module.time.monotonic()) + + ta.clear_text() + + assert ta._paste_burst_buffer == "" + assert ta._burst_payload_keeps_leading_slash is False + + async def test_keys_queued_behind_a_flushed_payload_keep_their_order( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Printable keys already in the queue land after the payload, in order.""" + monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 0.03) + monkeypatch.setattr( + paste_textarea_module, "PASTE_BURST_FLUSH_DELAY_SECONDS", 0.25 + ) + + app = _RecordingApp() + async with app.run_test() as pilot: + chat = app.query_one(ChatInput) + ta = chat._text_area + assert ta is not None + + stale_time = ( + chat_input_module.time.monotonic() + - paste_textarea_module.PASTE_BURST_CHAR_GAP_SECONDS + - 0.01 + ) + ta._start_paste_burst("x" * 900, stale_time) + + # The space flushes the stale payload; the rest of the paste is + # already queued behind it. + ta.post_message(events.Key("space", None)) + for char in "world": + ta.post_message(events.Key(char, char)) + await pilot.pause(0.35) + + assert len(app.submitted) == 0 + assert ta.text == "[Pasted text #1] world" async def test_dropped_path_replacement_is_not_double_spaced( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -6067,7 +6136,7 @@ async def test_rapid_absolute_path_that_does_not_exist_keeps_its_slash( ) -> None: """A recovered slash survives the insert that follows a failed parse. - Only an existing path takes the `PastedPaths` branch. Everything else + Only an existing path takes the dropped-path branch. Everything else falls through to a plain insert at offset 0, which trips mode-prefix detection a second time — stripping the recovered slash again and losing the character for good. @@ -6629,9 +6698,9 @@ async def test_bracketed_paste_event_collapses( ) -> None: """A real Paste event over the threshold collapses to a placeholder. - Exercises the production path (`_on_paste` -> `PastedText` message -> - `on_chat_text_area_pasted_text`) rather than `handle_external_paste`, - and asserts the collapse toast fires on that path too. + Exercises the production path (`_on_paste` -> `apply_paste_payload`) + rather than `handle_external_paste`, and asserts the collapse toast fires + on that path too. """ big_text = "z" * 900 app = _RecordingApp() From 5a51b5e5768532dbba6cfa53b8b9eb3e6d27775e Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Tue, 18 Aug 2026 14:03:18 -0400 Subject: [PATCH 11/12] fix(code): preserve double-slash path pastes --- .../deepagents_code/tui/widgets/chat_input.py | 8 +++++ .../unit_tests/tui/widgets/test_chat_input.py | 32 +++++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/libs/code/deepagents_code/tui/widgets/chat_input.py b/libs/code/deepagents_code/tui/widgets/chat_input.py index 11eca05040..1667901ec6 100644 --- a/libs/code/deepagents_code/tui/widgets/chat_input.py +++ b/libs/code/deepagents_code/tui/widgets/chat_input.py @@ -2718,6 +2718,14 @@ def handle_mode_prefix_keystroke(self, char: str) -> bool: True if the keystroke was consumed as a mode selector without inserting the character, otherwise False. """ + # The first slash enters command mode without being inserted. A second + # slash at the same offset can be the leading separator of a UNC-style + # path replayed as key events, so retain it rather than consuming both + # characters as mode triggers. + if char == "/" and self.mode == "command": + self.suppress_next_prefix_detection() + return False + detected_prefix = detect_mode_prefix(char) if detected_prefix is None: return False diff --git a/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py b/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py index 26064a06f9..074d703d1e 100644 --- a/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py +++ b/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py @@ -2506,8 +2506,8 @@ async def test_handle_mode_prefix_keystroke_switches_without_text_change( # Non-trigger characters are never consumed. assert chat.handle_mode_prefix_keystroke("a") is False - async def test_redundant_typed_slash_keystroke_stays_command_mode(self) -> None: - """A redundant `/` at the command prompt is consumed as a mode selector.""" + async def test_second_typed_slash_stays_in_command_text(self) -> None: + """A second `/` is retained so key-event path pastes keep both slashes.""" app = _ChatInputTestApp() async with app.run_test() as pilot: chat = app.query_one(ChatInput) @@ -2521,7 +2521,7 @@ async def test_redundant_typed_slash_keystroke_stays_command_mode(self) -> None: await pilot.press("/") await _pause_for_strip(pilot) assert chat.mode == "command" - assert chat._text_area.text == "" + assert chat._text_area.text == "/" async def test_typed_bang_keystroke_skips_strip_round_trip( self, monkeypatch: pytest.MonkeyPatch @@ -6161,6 +6161,32 @@ async def test_rapid_absolute_path_that_does_not_exist_keeps_its_slash( assert chat.mode == "normal" assert chat.value == str(missing) + async def test_rapid_double_slash_path_keeps_both_leading_slashes( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A key-event UNC-style path does not lose its second slash to mode handling. + + The second slash is text rather than another mode trigger. + """ + monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 60.0) + monkeypatch.setattr( + paste_textarea_module, "PASTE_BURST_FLUSH_DELAY_SECONDS", 0.25 + ) + payload = "//host/share" + + app = _RecordingApp() + async with app.run_test() as pilot: + chat = app.query_one(ChatInput) + ta = chat._text_area + assert ta is not None + + for char in payload: + await ta._on_key(events.Key(char, char)) + await pilot.pause(0.35) + + assert ta.text == payload + assert chat.value == payload + async def test_run_just_below_the_promote_threshold_stays_visible( self, monkeypatch: pytest.MonkeyPatch ) -> None: From 3ed1744ea6f43413cdff9ca147c69f4c8e2a6b45 Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Tue, 18 Aug 2026 14:25:00 -0400 Subject: [PATCH 12/12] fix(code): preserve typed spaces and failed burst pastes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The double-space guard after a burst flush asked whether the document happened to end in a space, not whether the flush had appended one. A payload inserted verbatim that ended in a space therefore swallowed the user's next spacebar press — one space of a pasted indent, silently. `_insert_pasted_paths` now reports whether `_build_path_replacement` supplied the trailing space, and the guard reads that fact. `_flush_paste_burst` also cleared the buffer before dispatching, while promotion had already deleted the run from the document. A raising dispatch — media decode, attachment tracking, or a notification during teardown — left the text nowhere at all. The payload is now re-inserted verbatim before the error propagates. Three tests named for the rapid-typing regression asserted only after a pause long enough for the flush timer to restore the text, so they passed with the bug reintroduced. They now assert while typing. --- .../tui/widgets/_paste_textarea.py | 34 ++++--- .../deepagents_code/tui/widgets/chat_input.py | 39 +++++--- .../unit_tests/tui/widgets/test_chat_input.py | 89 +++++++++++++++++++ 3 files changed, 142 insertions(+), 20 deletions(-) diff --git a/libs/code/deepagents_code/tui/widgets/_paste_textarea.py b/libs/code/deepagents_code/tui/widgets/_paste_textarea.py index a2543c63c1..40eae56fa0 100644 --- a/libs/code/deepagents_code/tui/widgets/_paste_textarea.py +++ b/libs/code/deepagents_code/tui/widgets/_paste_textarea.py @@ -365,6 +365,13 @@ async def _flush_paste_burst(self) -> None: defensively before handling a bracketed paste. The payload is applied before this returns, so a caller may go on to handle the current key knowing it lands after the paste. + + The buffer is cleared before dispatch, and `_promote_paste_burst_run` has + already deleted the run from the document, so a raising dispatch would + leave the text nowhere at all — not on screen, not in the buffer, not in + undo history. Media decoding, attachment tracking and notifications all + run inside that call, so the payload is re-inserted verbatim before the + error propagates. """ payload = self._paste_burst_buffer self._paste_burst_buffer = "" @@ -372,7 +379,16 @@ async def _flush_paste_burst(self) -> None: self._cancel_paste_burst_timer() if not payload: return - await self._dispatch_burst_payload(payload) + try: + await self._dispatch_burst_payload(payload) + except Exception: + logger.warning( + "Burst dispatch failed (%d chars); inserting payload verbatim", + len(payload), + exc_info=True, + ) + self.insert(payload) + raise def _promote_paste_burst_run(self, now: float) -> bool: """Move an already-inserted rapid run out of the document into the buffer. @@ -571,15 +587,13 @@ def _consume_enter_as_burst_newline(self, now: float) -> bool: self._paste_burst_last_suppressed_enter_time = None return False # This newline confirms a multi-line key-event paste, so pull the - # still-visible run into the buffer and keep the newline with it. The - # `_paste_burst_buffer` guard checks the same field as - # `_enter_inserts_newline_during_burst` but to the opposite effect: there a - # live buffer forces suppression, here it forces the plain-newline - # fallback. Both shipped `_on_key`s absorb or flush an active buffer before - # Enter reaches here, so a live buffer is unreachable — and it would be - # actively wrong, not merely redundant: the newline would land in the - # document while the buffered run flushed in *after* it, reordering the - # paste. Loud rather than silently wrong if a future caller gets here. + # still-visible run into the buffer and keep the newline with it. Both + # shipped `_on_key`s absorb or flush an active buffer before Enter + # reaches here, so the `_paste_burst_buffer` branch is unreachable. It + # exists because falling through to `_promote_paste_burst_run` would call + # `_start_paste_burst`, which *assigns* the buffer rather than appending + # — silently dropping the text already in it. Loud rather than silently + # wrong if a future caller gets here. if self._paste_burst_buffer: logger.warning( "Enter reached burst-newline handling with a live buffer " diff --git a/libs/code/deepagents_code/tui/widgets/chat_input.py b/libs/code/deepagents_code/tui/widgets/chat_input.py index 1667901ec6..2c8d2c9673 100644 --- a/libs/code/deepagents_code/tui/widgets/chat_input.py +++ b/libs/code/deepagents_code/tui/widgets/chat_input.py @@ -962,6 +962,11 @@ async def _dispatch_burst_payload(self, payload: str) -> None: keeps_leading_slash = self._burst_payload_keeps_leading_slash self._burst_payload_keeps_leading_slash = False owner = self._chat_input_owner + if owner is not None: + # Cleared up front so the verbatim-insert path below cannot leave a + # previous payload's answer standing for + # `_payload_supplied_trailing_space`. + owner._paste_appended_trailing_space = False try: parsed = await asyncio.to_thread(parse_pasted_path_payload, payload) @@ -1066,14 +1071,16 @@ def _reset_paste_burst_state(self) -> None: super()._reset_paste_burst_state() def _payload_supplied_trailing_space(self) -> bool: - """Return whether the text before the cursor already ends in a space. + """Return whether the flush appended a trailing space of its own. - A dropped-path payload is applied by `_build_path_replacement`, which - appends its own trailing space. Inserting the pending space as well would - double it. + An attached dropped-path payload gets a trailing space from + `_build_path_replacement`; inserting the pending space as well would + double it. The question is what the flush *did*, not what the document + happens to end with — a verbatim payload that merely ends in a space + would otherwise swallow the user's real keystroke. """ - cursor_offset = self.document.get_index_from_location(self.cursor_location) # ty: ignore[unresolved-attribute] # Document has this method; DocumentBase stub is narrower - return cursor_offset > 0 and self.text[cursor_offset - 1] == " " + owner = self._chat_input_owner + return owner is not None and owner._paste_appended_trailing_space async def _on_key(self, event: events.Key) -> None: """Handle key events.""" @@ -2154,6 +2161,11 @@ def __init__( # immediately recurse into the same replacement path. self._applying_inline_path_replacement = False + # Whether the most recent `apply_paste_payload` appended its own + # trailing space. Read by `ChatTextArea._payload_supplied_trailing_space` + # to decide whether a pending space keystroke would double it. + self._paste_appended_trailing_space = 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. @@ -3016,9 +3028,10 @@ def apply_paste_payload(self, text: str, paths: list[Path] | None) -> bool: if not self._text_area: return False if paths is not None: - self._insert_pasted_paths(text, paths) + self._paste_appended_trailing_space = self._insert_pasted_paths(text, paths) else: self._collapse_and_insert_paste(text) + self._paste_appended_trailing_space = False return True def suppress_next_prefix_detection(self) -> None: @@ -3128,22 +3141,28 @@ def _apply_inline_dropped_path_replacement(self, text: str) -> bool: self._text_area.move_cursor_to_end() return True - def _insert_pasted_paths(self, raw_text: str, paths: list[Path]) -> None: + def _insert_pasted_paths(self, raw_text: str, paths: list[Path]) -> bool: """Insert pasted path payload, attaching images when possible. Args: raw_text: Original paste payload text. paths: Resolved file paths parsed from the payload. + + Returns: + `True` when the inserted text carries a trailing space that + `_build_path_replacement` appended. Unattached payloads are inserted + verbatim, so they never do. """ if not self._text_area: - return + return False replacement, attached = self._build_path_replacement( raw_text, paths, add_trailing_space=True ) if attached: self._text_area.insert(replacement) - return + return replacement.endswith(" ") self._text_area.insert(raw_text) + return False def _build_path_replacement( self, diff --git a/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py b/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py index 074d703d1e..f2fd64df9e 100644 --- a/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py +++ b/libs/code/tests/unit_tests/tui/widgets/test_chat_input.py @@ -242,6 +242,10 @@ def compose(self) -> ComposeResult: yield ChatInput(id="chat-input") +class _DispatchError(RuntimeError): + """Stand-in for a burst dispatch that raises (media decode, notify, ...).""" + + class _RecordingApp(App[None]): """App that records ChatInput.Submitted events for assertion.""" @@ -5572,6 +5576,13 @@ async def test_ordinary_rapid_typing_is_never_promoted( for char in payload: await pilot.press(char) + + # Asserted before any pause: the flush timer would restore the text + # and hide the very regression this covers. Typing must be visible + # *while* typing, not once it stops. + assert ta.text == payload + assert ta._paste_burst_buffer == "" + await pilot.pause(0.15) assert ta.text == payload @@ -6011,6 +6022,71 @@ async def test_dropped_path_replacement_is_not_double_spaced( assert ta.text == "[image 1] " + async def test_verbatim_payload_ending_in_space_keeps_the_typed_space( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A flushed payload that merely ends in a space must not eat the next one. + + The double-space guard applies only to the trailing space that + `_build_path_replacement` appends. A payload inserted verbatim supplies + no such space, so the user's own keystroke has to survive — checking the + document instead would silently swallow one space of, say, a pasted + indent. + """ + monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 0.03) + monkeypatch.setattr( + paste_textarea_module, "PASTE_BURST_FLUSH_DELAY_SECONDS", 0.25 + ) + + app = _RecordingApp() + async with app.run_test() as pilot: + chat = app.query_one(ChatInput) + ta = chat._text_area + assert ta is not None + + stale_time = ( + chat_input_module.time.monotonic() + - paste_textarea_module.PASTE_BURST_CHAR_GAP_SECONDS + - 0.01 + ) + ta._start_paste_burst("hello ", stale_time) + + await ta._on_key(events.Key("space", None)) + await pilot.pause(0.35) + + assert ta.text == "hello " + + async def test_failed_dispatch_reinserts_the_payload_verbatim( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A raising dispatch must not destroy the buffered paste. + + Promotion has already deleted the run from the document and the flush + clears the buffer before dispatching, so without the guard the text + exists nowhere — not on screen, not in the buffer, not in undo. + """ + monkeypatch.setattr(paste_textarea_module, "PASTE_BURST_CHAR_GAP_SECONDS", 60.0) + + app = _RecordingApp() + async with app.run_test() as pilot: + chat = app.query_one(ChatInput) + ta = chat._text_area + assert ta is not None + + async def _boom(payload: str) -> None: # noqa: ARG001, RUF029 + raise _DispatchError + + monkeypatch.setattr(ta, "_dispatch_burst_payload", _boom) + + ta._start_paste_burst("important paste", chat_input_module.time.monotonic()) + + with pytest.raises(_DispatchError): + await ta._flush_paste_burst() + await pilot.pause() + + assert ta.text == "important paste" + assert ta._paste_burst_buffer == "" + async def test_flushed_run_is_not_re_promoted_by_a_later_enter( self, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -6205,6 +6281,12 @@ async def test_run_just_below_the_promote_threshold_stays_visible( for char in payload: await ta._on_key(events.Key(char, char)) + + # Before the pause: waiting for the flush timer would restore the + # text and mask a run that had wrongly been promoted. + assert ta.text == payload + assert ta._paste_burst_buffer == "" + await pilot.pause(0.35) assert ta.text == payload @@ -6224,8 +6306,15 @@ async def test_run_resets_at_human_typing_speed(self) -> None: ta = chat._text_area assert ta is not None + typed = "" for char in "abcde": await ta._on_key(events.Key(char, char)) + typed += char + # Checked after every keystroke rather than only at the end: the + # inter-key sleep is shorter than the flush delay, so a promoted + # run would be hidden right here and restored before the final + # assertion could see it. + assert ta.text == typed await asyncio.sleep( paste_textarea_module.PASTE_BURST_CHAR_GAP_SECONDS + 0.01 )