fix(commands): keep line breaks from standalone voice commands (#958)

* fix(commands): keep line breaks from standalone voice commands

Two stacked defects ate the newline "new line" / "new paragraph"
produce when spoken alone:

* _normalize_segment_text stripped all leading whitespace, so the
  command's leading newline never reached the injector (a clean
  "new line" segment was dropped entirely).
* whisper.cpp transcripts arrive punctuated ("New line."), which
  missed the exact-match ladder and left a stray "." after the
  substituted break, injected as ". ".

Normalize now strips only spaces/tabs, and the generic path
substitutes on a punctuation-stripped core when the whole segment
is command phrases, so engine edge punctuation cannot leak while
real text keeps it.

Fixes #955

* fix(injection): deliver newline-only segments to the focused app

inject_text treated whitespace-only text as empty and returned early,
so the "\n" a standalone "new line" now correctly produces was
discarded before any backend ran ("Empty text provided, skipping
injection").  Only a truly empty segment is skipped; whitespace-only
input is typed/pasted verbatim (xdotool types "\n" as Return).

Space/tab-only segments never reach this point: segment normalization
strips them upstream.

Fixes #955

* fix(injection): keep skipping space/tab-only segments

The previous commit let every whitespace-only string through, which
also delivered bare spaces and tabs a caller never wants typed.
Narrow the skip to text with no content outside space/tab so "\n"
still reaches the backend while "   " stays a no-op (the pre-existing
test_empty_text_injection contract).

Fixes #955

* fix(injection): deliver newline segments over IBus too

IBusTextInjector.inject_text had the same whitespace guard and dropped
"\n" before it reached the engine socket; narrow it the same way so
a newline-only segment is sent while space/tab-only stays a no-op.

Also let capitalize_sentences uppercase the first letter after leading
whitespace, so Vosk + auto_capitalize keeps working on "\n hello".

Covers Greptile's findings: real-socket test for the IBus path, the
capitalize regression via the main() callback seam, and type hints on
the new injector tests.

Fixes #955

* test(injection): add type hints to new IBus socket test

AGENTS.md requires hints on every signature; the Greptile review
flagged the new test for missing them.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
devin-ai-integration[bot]
2026-10-15 16:54:05 -07:00
committed by GitHub
co-authored by Jatin K Malik
parent 64032fb999
commit 0ddb127437
8 changed files with 282 additions and 14 deletions
@@ -22,6 +22,11 @@ _ATTACH_LEFT_REPLACEMENTS = {".", ",", "?", "!", ";", ":"}
# line-break command just inserted (``\s*`` would collapse "new line" + "period").
_ATTACH_LEFT_WS = r"[^\S\n]*"
# Sentence-final punctuation engines may wrap around a standalone command
# utterance ("New line."). Dropped only when the whole segment is command
# phrases — the same marks stay untouched on real text.
_EDGE_PUNCTUATION = ".,!?;:…"
# Base English text commands (always present).
_BASE_TEXT_COMMANDS = {
# Line commands
@@ -252,22 +257,36 @@ class CommandProcessor:
# If no exact match found, fallback to generic processing
else:
processed_text = text.strip()
# Engines add sentence-final punctuation to an utterance that is
# only a command ("New line.", "Select all."). When the segment is
# nothing but command phrases and whitespace once edge punctuation
# is dropped, substitute on that stripped core so the stray mark
# cannot leak into the output; real text keeps its punctuation.
core = text.strip().strip(_EDGE_PUNCTUATION).strip()
residual = self.format_cmd_regex.sub(
"", self.action_cmd_regex.sub("", self.text_cmd_regex.sub("", core))
)
segment_text = core if core and not residual.strip() else text
processed_text = segment_text.strip()
# Handle action commands
for cmd, action in self.action_commands.items():
cmd_pattern = r"\b" + re.escape(cmd) + r"\b"
if re.search(cmd_pattern, text, re.IGNORECASE):
if re.search(cmd_pattern, segment_text, re.IGNORECASE):
actions.append(action)
# Check if there's text after the command
match = re.search(r"\b" + re.escape(cmd) + r"\s+(.*)", text, re.IGNORECASE)
match = re.search(
r"\b" + re.escape(cmd) + r"\s+(.*)", segment_text, re.IGNORECASE
)
if match:
remaining_text = match.group(1).strip()
# Only add space if there's text before the command
cmd_match = re.search(
r"^(.*?)\b" + re.escape(cmd) + r"\b", text, re.IGNORECASE
r"^(.*?)\b" + re.escape(cmd) + r"\b",
segment_text,
re.IGNORECASE,
)
if cmd_match and cmd_match.group(1).strip():
processed_text = " " + remaining_text
@@ -302,9 +321,11 @@ class CommandProcessor:
for cmd, format_type in self.format_commands.items():
cmd_pattern = r"\b" + re.escape(cmd) + r"\b"
if re.search(cmd_pattern, text, re.IGNORECASE):
if re.search(cmd_pattern, segment_text, re.IGNORECASE):
# Handle format command that modifies next word
match = re.search(r"\b" + re.escape(cmd) + r"\s+(\w+)", text, re.IGNORECASE)
match = re.search(
r"\b" + re.escape(cmd) + r"\s+(\w+)", segment_text, re.IGNORECASE
)
if match:
word = match.group(1)
if format_type == "capitalize_next":
@@ -320,7 +341,7 @@ class CommandProcessor:
processed_text = re.sub(
r"\b" + re.escape(cmd) + r"\s+" + re.escape(word) + r"\b",
replacement,
text,
segment_text,
flags=re.IGNORECASE,
)
else:
@@ -347,8 +368,14 @@ def capitalize_sentences(text: str) -> str:
if not text:
return text
# Capitalize first character
result = text[0].upper() + text[1:]
# Capitalize the first letter, skipping leading whitespace a voice
# command can leave behind ("new line hello" -> "\n hello").
first_letter = re.match(r"\s*([a-z])", text)
if first_letter:
idx = first_letter.start(1)
result = text[:idx] + text[idx].upper() + text[idx + 1 :]
else:
result = text[0].upper() + text[1:]
# Capitalize after sentence-ending punctuation + whitespace
result = re.sub(