fix(injection): send newlines as Return on the xdotool backend (#962)

* fix(injection): send newlines as Return on the xdotool backend

xdotool type maps '\n' to the Linefeed keysym, which GTK targets
silently drop — "new line" produced nothing on X11 even though the
segment reached the injector.  Split each chunk on '\n' and press
Return between typed segments so a newline lands as a real keypress
in every app.

Verified against a live GtkTextView on Xvfb: the same "A\nB" input
that previously produced 'AB' now produces 'A\nB'.

Fixes #955

* fix(commands): consume trailing punctuation after mid-utterance commands

"New paragraph." inside a longer utterance substituted correctly but
left the engine's sentence-final period typed after the break
("\\n\\n. Does this work now?").  Text-command substitution now
consumes a run of trailing , . ! ? ; : … when it precedes whitespace
or the end of the segment — the reporter's comma-phrased case
("…, new paragraph, …") stops leaking the stray comma the same way.
A mark glued to following text ("newline.io") is untouched.

Fixes #955

* fix(injection,commands): resume mid-chunk on retry, don't eat produced marks

Greptile review follow-ups on #962:

- A failed Return mid-chunk left 'typed' at the chunk start, so the
  retry re-sent text xdotool had already delivered.  'typed' now
  advances per typed segment and per Return keypress, so the retry
  resumes exactly at the un-typed position — and the clipboard
  fallback reports the same progress if retries run out.
- _TRAILING_PUNCTUATION could consume a mark produced by an earlier
  command replacement ('hello new line exclamation mark' gave '\\n'
  instead of '\\n!').  The eater now only consumes marks before
  whitespace; the end-of-utterance case ('hello new line.') is a
  pre-pass on the raw segment before any substitution runs.

* style: black-format comment indent and test condition

* fix(commands): strip segment before the end-of-utterance cleanup

Trailing whitespace ('hello new line. ') hid the final mark from the
pre-pass: the raw segment ended in a space, and the stripped segment
left the dot at end-of-string where the mid-utterance eater cannot
touch it.  Strip first, then run the cleanup.

Also add the missing type hints on the new test helper.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
devin-ai-integration[bot]
2026-10-16 19:05:34 -07:00
committed by GitHub
co-authored by Jatin K Malik
parent e866cb85e4
commit d3ba4ee743
4 changed files with 194 additions and 22 deletions
@@ -27,6 +27,14 @@ _ATTACH_LEFT_WS = r"[^\S\n]*"
# phrases — the same marks stay untouched on real text.
_EDGE_PUNCTUATION = ".,!?;:…"
# Trailing punctuation right after a spoken command mid-utterance is also
# phrasing, not text ("testing, new paragraph, done" must not type a stray
# comma after the break). Command substitution consumes it — but only
# before whitespace: a mark at end-of-string may have been produced by an
# earlier command replacement ("…new line exclamation mark" → "\n!"), and
# the end-of-utterance case is handled by a pre-pass on the raw segment.
_TRAILING_PUNCTUATION = r"(?:[,.!?;:…]+(?=\s))?"
# Base English text commands (always present).
_BASE_TEXT_COMMANDS = {
# Line commands
@@ -119,7 +127,8 @@ class CommandProcessor:
"""Compile regex patterns for command matching."""
# Longer phrases first so alternation prefers "punto interrogativo" over "punto"
text_keys = sorted(self.text_commands.keys(), key=len, reverse=True)
text_cmd_pattern = r"\b(" + "|".join(re.escape(cmd) for cmd in text_keys) + r")\b"
self._text_cmd_alternation = "|".join(re.escape(cmd) for cmd in text_keys)
text_cmd_pattern = r"\b(" + self._text_cmd_alternation + r")\b"
self.text_cmd_regex = re.compile(text_cmd_pattern, re.IGNORECASE)
# Create regex pattern for action commands
@@ -267,6 +276,18 @@ class CommandProcessor:
"", self.action_cmd_regex.sub("", self.text_cmd_regex.sub("", core))
)
segment_text = core if core and not residual.strip() else text
# End-of-utterance form of _TRAILING_PUNCTUATION: a segment that
# ends with a command plus engine marks ("hello new line.") has
# no following whitespace for the eater to see, so strip the run
# here. Runs at end-of-string mid-loop are left alone — a mark
# there may have been produced by another command's replacement.
segment_text = re.sub(
r"(\b(?:" + self._text_cmd_alternation + r")\b)[,.!?;:…]+$",
r"\1",
segment_text.strip(),
flags=re.IGNORECASE,
)
processed_text = segment_text.strip()
# Handle action commands
@@ -304,14 +325,14 @@ class CommandProcessor:
if replacement in _ATTACH_LEFT_REPLACEMENTS:
# Punctuation: drop horizontal space before the spoken phrase
processed_text = re.sub(
_ATTACH_LEFT_WS + cmd_pattern + _ATTACH_LEFT_WS,
_ATTACH_LEFT_WS + cmd_pattern + _TRAILING_PUNCTUATION + _ATTACH_LEFT_WS,
replacement,
processed_text,
flags=re.IGNORECASE,
)
else:
processed_text = re.sub(
cmd_pattern,
cmd_pattern + _TRAILING_PUNCTUATION,
replacement,
processed_text,
flags=re.IGNORECASE,
+54 -19
View File
@@ -1850,8 +1850,8 @@ class TextInjector:
max_retries = 2
logger.debug(f"Starting xdotool injection with {max_retries} max retries")
# Retries resume at the first untyped chunk: restarting from the
# top would duplicate the chunks a failed attempt already sent.
# Retries resume at the first untyped position: restarting from
# the top would duplicate what a failed attempt already sent.
typed = 0
for retry in range(max_retries + 1):
try:
@@ -1868,23 +1868,58 @@ class TextInjector:
chunk = text[i : i + chunk_size]
chunk_num = (i // chunk_size) + 1
# First try with clearmodifiers. ``--`` ends option
# parsing: a chunk that starts with '-' (a word
# hyphenated across the chunk boundary) is typed
# literally instead of read as a flag and rejected
# (#921).
cmd = ["xdotool", "type", "--clearmodifiers", "--", chunk]
logger.debug(f"Injecting chunk {chunk_num}/{total_chunks}: '{chunk}'")
subprocess.run(
cmd,
env=host_env(env),
check=True,
stderr=subprocess.PIPE,
text=True,
timeout=5,
)
typed = min(i + chunk_size, len(text))
# ``xdotool type`` maps '\n' to the Linefeed keysym
# (0xFF0A), which GTK targets silently drop — only a
# real Return keypress inserts the newline (#955).
# Split the chunk on '\n', typing each segment and
# pressing Return between them. The '\t' case is
# untouched: Tab is a working keysym in type strings.
segments = chunk.split("\n")
for seg_index, segment in enumerate(segments):
if self._abort_injections.is_set():
raise _InjectionAborted
if segment:
# ``--`` ends option parsing: a segment that
# starts with '-' (a word hyphenated across
# the chunk boundary) is typed literally
# instead of read as a flag and rejected
# (#921).
cmd = [
"xdotool",
"type",
"--clearmodifiers",
"--",
segment,
]
logger.debug(
f"Injecting chunk {chunk_num}/{total_chunks}: '{segment}'"
)
subprocess.run(
cmd,
env=host_env(env),
check=True,
stderr=subprocess.PIPE,
text=True,
timeout=5,
)
# Advance past what just went out so a retry
# resumes mid-chunk instead of re-typing it.
typed += len(segment)
if seg_index < len(segments) - 1:
subprocess.run(
[
"xdotool",
"key",
"--clearmodifiers",
"Return",
],
env=host_env(env),
check=True,
stderr=subprocess.PIPE,
text=True,
timeout=5,
)
typed += 1 # the '\n' the Return produced
# Add a larger delay between chunks
if i + chunk_size < len(text):