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
+80
View File
@@ -512,3 +512,83 @@ class TestCommandPhrases(unittest.TestCase):
self.assertEqual(aliases[f"point d{apostrophe}interrogation"], "?")
self.assertEqual(aliases[f"point d{apostrophe}exclamation"], "!")
self.assertEqual(aliases["point"], ".")
class TestStandaloneCommandPunctuation(unittest.TestCase):
"""Engine sentence-final punctuation on standalone commands (issue #955).
whisper.cpp transcripts arrive capitalized and punctuated ("New line."),
which used to leave a stray "." after the substituted newline — or, for
punctuation commands, a doubled mark. Edge punctuation is dropped only
when the whole segment is command phrases; real text keeps it.
"""
def setUp(self) -> None:
"""Set up for tests."""
self.processor = CommandProcessor()
def test_standalone_line_breaks_with_engine_punctuation(self) -> None:
"""Punctuated standalone line-break commands still emit the break."""
cases = [
("New line.", "\n"),
("new line.", "\n"),
("New line!", "\n"),
("New line?", "\n"),
("new line...", "\n"),
(", new line", "\n"),
("New paragraph.", "\n\n"),
("new paragraph!", "\n\n"),
]
for input_text, expected in cases:
with self.subTest(input_text=input_text):
result, actions = self.processor.process_text(input_text)
self.assertEqual(result, expected)
self.assertEqual(actions, [])
def test_standalone_punctuation_commands_with_engine_punctuation(self) -> None:
"""Punctuated standalone punctuation commands emit one mark, not two."""
cases = [
("Period.", "."),
("Full stop.", "."),
("Comma.", ","),
("Question mark?", "?"),
]
for input_text, expected in cases:
with self.subTest(input_text=input_text):
result, actions = self.processor.process_text(input_text)
self.assertEqual(result, expected)
self.assertEqual(actions, [])
def test_standalone_action_commands_with_engine_punctuation(self) -> None:
"""Punctuated standalone action commands fire without stray text."""
cases = [
("Delete that.", ["delete_last"]),
("Select all.", ["select_all"]),
("Undo!", ["undo"]),
("Redo?", ["redo"]),
]
for input_text, expected_actions in cases:
with self.subTest(input_text=input_text):
result, actions = self.processor.process_text(input_text)
self.assertEqual(result, "")
self.assertEqual(actions, expected_actions)
def test_real_text_keeps_engine_sentence_punctuation(self) -> None:
"""Non-command utterances keep the punctuation the engine emitted."""
cases = [
("Hello world.", "Hello world.", []),
("capitalize word.", "Word.", []),
("the end full stop", "the end.", []),
("scratch that previous text.", "previous text.", ["delete_last"]),
]
for input_text, expected_text, expected_actions in cases:
with self.subTest(input_text=input_text):
result, actions = self.processor.process_text(input_text)
self.assertEqual(result, expected_text)
self.assertEqual(actions, expected_actions)
def test_punctuation_only_segment_is_not_eaten(self) -> None:
"""A segment that is only punctuation stays text, not a command."""
result, actions = self.processor.process_text("...")
self.assertEqual(result, "...")
self.assertEqual(actions, [])