feat(dictation): append trailing space after completed transcription (#608)

* feat(dictation): append trailing space after completed transcription

Fix cross-session glueing ("Hello.This") for push-to-talk and toggle
by appending a trailing space to each injected segment instead of relying
on in-session leading-space memory that is cleared on IDLE.

Adds an optional Settings toggle (default on) and preserves legacy
leading-space behavior when disabled. Newlines from voice commands are
left without a trailing space.

Fixes #605

Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>

* fix(dictation): honor trailing-space setting from disk live

Read append_trailing_space from config.json on each injection so the
Settings toggle applies without restart. main() and TrayIndicator each
own a ConfigManager, so an in-memory read missed Settings writes — same
pattern as TextInjector.copy_to_clipboard.

Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>

* test(dictation): align capitalize tests with trailing-space default

Update auto-capitalize expectations to include the default trailing
space, and add edge-case coverage for legacy mode, newlines, empty
input, and config-read failures so patch coverage clears 80%.

Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
This commit is contained in:
Jatin K Malik
2026-07-29 02:00:18 +05:30
committed by GitHub
co-authored by Jatin K Malik Cursor Agent
parent 7422bf927e
commit 8322306aac
4 changed files with 264 additions and 32 deletions
+51 -14
View File
@@ -21,6 +21,30 @@ logger = logging.getLogger(__name__)
# dependency checking to provide better error messages for pip/pipx users
def _should_append_trailing_space() -> bool:
"""Return whether completed transcriptions should get a trailing space.
Reads config.json from disk on each call so Settings toggles take effect
immediately. TrayIndicator and main() each construct their own
ConfigManager, so an in-memory read from main's instance would miss
Settings writes (same pattern as TextInjector._should_copy_to_clipboard).
"""
try:
import json
import os
from .utils.paths import config_dir
config_path = os.path.join(config_dir(), "config.json")
if os.path.exists(config_path):
with open(config_path, "r") as f:
config = json.load(f)
return bool(config.get("text_injection", {}).get("append_trailing_space", True))
except Exception as e:
logger.debug(f"Could not read append_trailing_space setting: {e}")
return True
def parse_arguments():
"""Parse command line arguments."""
parser = argparse.ArgumentParser(prog="vocalinux", description="Vocalinux")
@@ -434,9 +458,9 @@ def main():
#
# text_callback(text: str)
# Called on the recognition thread when a transcription segment
# is finalised. The wrapper below strips whitespace, inserts
# inter-segment spaces, injects the text, and records it so
# "delete that" can undo it.
# is finalised. The wrapper below strips whitespace, optionally
# appends a trailing space (or legacy leading separator), injects
# the text, and records it so "delete that" can undo it.
#
# action_callback(action: str) -> bool
# Called when a voice command (e.g. "undo", "select all") is
@@ -452,24 +476,20 @@ def main():
"""Bridge between speech engine text events and the text injector.
Called on the recognition thread with each finalised transcription
segment. Strips leading/trailing whitespace (whisper tokenizer
sometimes prepends spaces), inserts a single space between
consecutive segments, then injects via TextInjector.
segment. Strips leading whitespace and trailing spaces/tabs (but
preserves trailing newlines from voice commands), then either
appends a trailing space (default) or uses the legacy in-session
leading-space separator, and injects via TextInjector.
Args:
text: Raw transcription segment from the speech engine.
"""
text_to_inject = text.strip()
# Preserve trailing newlines ("new line" / "new paragraph"); only
# strip spaces/tabs that whisper sometimes wraps around tokens.
text_to_inject = text.lstrip().rstrip(" \t")
if not text_to_inject:
return
# Add a separating space between consecutive dictation segments,
# but never for the very first segment (avoids unwanted leading space
# when starting dictation in an empty text field).
if action_handler.last_injected_text and action_handler.last_injected_text.strip():
text_to_inject = " " + text_to_inject
logger.debug("Added space separator before new segment")
# Auto-capitalize sentences if enabled (Vosk only - Whisper outputs proper casing)
auto_capitalize = config_manager.get("text_injection", "auto_capitalize")
if auto_capitalize and speech_engine.engine == "vosk":
@@ -477,6 +497,23 @@ def main():
text_to_inject = capitalize_sentences(text_to_inject)
# Read from disk so the Settings toggle applies without restart
# (main and tray each own a ConfigManager instance).
append_trailing_space = _should_append_trailing_space()
if append_trailing_space:
# Put the separator into the previous field so the next session
# (push-to-talk / toggle) continues cleanly without needing
# cross-session memory — and without a leading space in empty
# fields. Skip after newlines from "new line" / "new paragraph".
if not text_to_inject.endswith((" ", "\t", "\n")):
text_to_inject += " "
logger.debug("Appended trailing space after transcription segment")
elif action_handler.last_injected_text and action_handler.last_injected_text.strip():
# Legacy: leading space between consecutive in-session segments.
text_to_inject = " " + text_to_inject
logger.debug("Added space separator before new segment")
success = text_system.inject_text(text_to_inject)
if success:
action_handler.set_last_injected_text(text_to_inject)
+4
View File
@@ -75,6 +75,10 @@ DEFAULT_CONFIG = {
"text_injection": {
"copy_to_clipboard": False, # Disabled by default; users can enable in Settings
"auto_capitalize": True, # Capitalize first letter of each sentence
# Append a trailing space after each completed transcription segment so
# the next dictation session (push-to-talk / toggle) continues cleanly
# without glueing onto the previous sentence ("Hello.This").
"append_trailing_space": True,
},
"advanced": {
"power_user_mode": False,
+31
View File
@@ -1922,6 +1922,18 @@ class SettingsDialog(Gtk.Dialog):
logger.info(f"Auto-capitalize {'enabled' if enabled else 'disabled'}")
return False
def _on_append_trailing_space_toggled(self, widget, state):
"""Handle toggle of the trailing space after dictation switch."""
if self._initializing or self._applying_settings:
return False
enabled = bool(state)
logger.info(f"Append trailing space toggled: {enabled}")
self.config_manager.set("text_injection", "append_trailing_space", enabled)
self.config_manager.save_settings()
logger.info(f"Append trailing space {'enabled' if enabled else 'disabled'}")
return False
def _on_sound_effects_toggled(self, widget, state):
if self._initializing or self._applying_settings:
return False
@@ -2200,9 +2212,26 @@ class SettingsDialog(Gtk.Dialog):
)
output_group.add_row(copy_to_clipboard_row)
self.append_trailing_space_switch = Gtk.Switch()
self.append_trailing_space_switch.set_tooltip_text(
"Append a space after each completed transcription so the next "
"dictation session continues without gluing onto the previous text "
'(e.g. "Hello. This" instead of "Hello.This").'
)
append_trailing_space_row = PreferenceRow(
title="Trailing Space After Dictation",
subtitle="Insert a space after each completed transcription segment",
widget=self.append_trailing_space_switch,
keywords=("space", "spacing", "punctuation", "push-to-talk"),
)
output_group.add_row(append_trailing_space_row)
self.recognition_settings_tab.pack_start(output_group, False, False, 0)
self.copy_to_clipboard_switch.connect("state-set", self._on_copy_to_clipboard_toggled)
self.auto_capitalize_switch.connect("state-set", self._on_auto_capitalize_toggled)
self.append_trailing_space_switch.connect(
"state-set", self._on_append_trailing_space_toggled
)
if not silero_active:
vad_info_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
@@ -3131,11 +3160,13 @@ class SettingsDialog(Gtk.Dialog):
start_minimized = ui_settings.get("start_minimized", False)
copy_to_clipboard = text_injection_settings.get("copy_to_clipboard", False)
auto_capitalize = text_injection_settings.get("auto_capitalize", True)
append_trailing_space = text_injection_settings.get("append_trailing_space", True)
self.autostart_switch.set_active(autostart_enabled)
self.start_minimized_switch.set_active(start_minimized)
self.copy_to_clipboard_switch.set_active(copy_to_clipboard)
self.auto_capitalize_switch.set_active(auto_capitalize)
self.append_trailing_space_switch.set_active(append_trailing_space)
self.sound_effects_switch.set_active(self.config_manager.is_sound_effects_enabled())
auto_pause_settings = self.config_manager.get_settings().get("auto_pause", {})
+178 -18
View File
@@ -318,12 +318,14 @@ class TestMainModule(unittest.TestCase):
text_callback("World")
calls = [call.args[0] for call in mock_text_instance.inject_text.call_args_list]
self.assertEqual(calls, ["Hello.", " World"])
self.assertEqual(calls, ["Hello. ", "World "])
state_callback(RecognitionState.IDLE)
mock_text_instance.inject_text.reset_mock()
text_callback("Next session")
mock_text_instance.inject_text.assert_called_once_with("Next session")
# Trailing space persists in the previous field; next session starts clean
# (no leading space) but still gets its own trailing space.
mock_text_instance.inject_text.assert_called_once_with("Next session ")
def _run_main_and_get_text_callback(
self,
@@ -402,7 +404,7 @@ class TestMainModule(unittest.TestCase):
)
text_callback("hello world. goodbye")
mock_text_instance.inject_text.assert_called_once_with("Hello world. Goodbye")
mock_text_instance.inject_text.assert_called_once_with("Hello world. Goodbye ")
@patch("vocalinux.main.check_dependencies")
@patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager")
@@ -431,7 +433,7 @@ class TestMainModule(unittest.TestCase):
)
text_callback("hello world. goodbye")
mock_text_instance.inject_text.assert_called_once_with("hello world. goodbye")
mock_text_instance.inject_text.assert_called_once_with("hello world. goodbye ")
@patch("vocalinux.main.check_dependencies")
@patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager")
@@ -460,7 +462,7 @@ class TestMainModule(unittest.TestCase):
)
text_callback("hello world. goodbye")
mock_text_instance.inject_text.assert_called_once_with("hello world. goodbye")
mock_text_instance.inject_text.assert_called_once_with("hello world. goodbye ")
@patch("vocalinux.main.check_dependencies")
@patch("vocalinux.ui.action_handler.ActionHandler")
@@ -895,7 +897,7 @@ class TestMainConfigPrecedence(unittest.TestCase):
class TestTextCallbackSpacing(unittest.TestCase):
"""Test spacing logic in text_callback_wrapper."""
def _make_callback(self):
def _make_callback(self, append_trailing_space: bool = True):
"""Build text_callback_wrapper with mocked dependencies."""
from vocalinux.ui.action_handler import ActionHandler
@@ -904,10 +906,13 @@ class TestTextCallbackSpacing(unittest.TestCase):
action_handler = ActionHandler(text_system)
def text_callback_wrapper(text: str):
text_to_inject = text.strip()
text_to_inject = text.lstrip().rstrip(" \t")
if not text_to_inject:
return
if action_handler.last_injected_text and action_handler.last_injected_text.strip():
if append_trailing_space:
if not text_to_inject.endswith((" ", "\t", "\n")):
text_to_inject += " "
elif action_handler.last_injected_text and action_handler.last_injected_text.strip():
text_to_inject = " " + text_to_inject
success = text_system.inject_text(text_to_inject)
if success:
@@ -919,25 +924,26 @@ class TestTextCallbackSpacing(unittest.TestCase):
return text_callback_wrapper, on_state_change, text_system, action_handler
def test_first_segment_has_no_leading_space(self):
def test_first_segment_has_trailing_space(self):
cb, _, text_system, _ = self._make_callback()
cb("Hello world")
text_system.inject_text.assert_called_once_with("Hello world")
text_system.inject_text.assert_called_once_with("Hello world ")
def test_subsequent_segment_gets_space_separator(self):
def test_subsequent_segment_has_trailing_space_not_leading(self):
cb, _, text_system, _ = self._make_callback()
cb("Hello")
cb("world")
calls = [c.args[0] for c in text_system.inject_text.call_args_list]
self.assertEqual(calls, ["Hello", " world"])
self.assertEqual(calls, ["Hello ", "world "])
def test_reset_clears_leading_space(self):
def test_cross_session_keeps_trailing_space_without_leading_space(self):
cb, on_state_change, text_system, _ = self._make_callback()
cb("first session")
on_state_change(RecognitionState.IDLE)
text_system.inject_text.reset_mock()
cb("second session")
text_system.inject_text.assert_called_once_with("second session")
# No leading space (empty-field safe); trailing space still appended.
text_system.inject_text.assert_called_once_with("second session ")
def test_whitespace_only_input_is_skipped(self):
cb, _, text_system, _ = self._make_callback()
@@ -947,22 +953,27 @@ class TestTextCallbackSpacing(unittest.TestCase):
def test_input_with_leading_space_is_stripped(self):
cb, _, text_system, _ = self._make_callback()
cb(" Hello world")
text_system.inject_text.assert_called_once_with("Hello world")
text_system.inject_text.assert_called_once_with("Hello world ")
def test_multiple_segments_all_get_separators(self):
def test_multiple_segments_all_get_trailing_spaces(self):
cb, _, text_system, _ = self._make_callback()
cb("one")
cb("two")
cb("three")
calls = [c.args[0] for c in text_system.inject_text.call_args_list]
self.assertEqual(calls, ["one", " two", " three"])
self.assertEqual(calls, ["one ", "two ", "three "])
def test_space_after_punctuation_segment(self):
cb, _, text_system, _ = self._make_callback()
cb("Hello.")
cb("World")
calls = [c.args[0] for c in text_system.inject_text.call_args_list]
self.assertEqual(calls, ["Hello.", " World"])
self.assertEqual(calls, ["Hello. ", "World "])
def test_newline_segment_does_not_get_trailing_space(self):
cb, _, text_system, _ = self._make_callback()
cb("Hello.\n")
text_system.inject_text.assert_called_once_with("Hello.\n")
def test_processing_to_listening_keeps_segment_spacing(self):
cb, on_state_change, text_system, _ = self._make_callback()
@@ -971,8 +982,157 @@ class TestTextCallbackSpacing(unittest.TestCase):
on_state_change(RecognitionState.LISTENING)
cb("World")
calls = [c.args[0] for c in text_system.inject_text.call_args_list]
self.assertEqual(calls, ["Hello. ", "World "])
def test_legacy_mode_uses_leading_space_in_session(self):
cb, _, text_system, _ = self._make_callback(append_trailing_space=False)
cb("Hello.")
cb("World")
calls = [c.args[0] for c in text_system.inject_text.call_args_list]
self.assertEqual(calls, ["Hello.", " World"])
def test_legacy_mode_clears_leading_space_across_sessions(self):
cb, on_state_change, text_system, _ = self._make_callback(append_trailing_space=False)
cb("first session")
on_state_change(RecognitionState.IDLE)
text_system.inject_text.reset_mock()
cb("second session")
text_system.inject_text.assert_called_once_with("second session")
class TestShouldAppendTrailingSpace(unittest.TestCase):
"""Test disk-backed trailing-space setting reader."""
def test_reads_setting_from_disk_with_true_default(self):
import json
import os
import tempfile
from vocalinux.main import _should_append_trailing_space
with tempfile.TemporaryDirectory() as d:
with patch("vocalinux.utils.paths.config_dir", return_value=d):
# No config file -> default True
self.assertTrue(_should_append_trailing_space())
cfg = os.path.join(d, "config.json")
with open(cfg, "w") as f:
json.dump({"text_injection": {}}, f)
self.assertTrue(_should_append_trailing_space())
with open(cfg, "w") as f:
json.dump({"text_injection": {"append_trailing_space": False}}, f)
self.assertFalse(_should_append_trailing_space())
with open(cfg, "w") as f:
json.dump({"text_injection": {"append_trailing_space": True}}, f)
self.assertTrue(_should_append_trailing_space())
def test_returns_true_when_config_read_fails(self):
from vocalinux.main import _should_append_trailing_space
with patch("vocalinux.utils.paths.config_dir", side_effect=OSError("boom")):
self.assertTrue(_should_append_trailing_space())
class TestMainCallbackTrailingSpaceEdges(unittest.TestCase):
"""Exercise trailing-space edge paths through the real main() callback."""
def _boot_under_patches(self, *, append_trailing_space: bool = True, inject_ok: bool = True):
"""Return (exit_stack, text_cb, mock_text) with patches still active."""
from contextlib import ExitStack
stack = ExitStack()
stack.enter_context(patch("vocalinux.main.check_dependencies", return_value=True))
mock_config_cls = stack.enter_context(patch("vocalinux.ui.config_manager.ConfigManager"))
mock_config = MagicMock()
mock_config.get_settings.return_value = {
"speech_recognition": {},
"general": {"first_run": False},
}
mock_config.get.return_value = False # auto_capitalize off
mock_config_cls.return_value = mock_config
mock_speech_cls = stack.enter_context(
patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager")
)
mock_speech = MagicMock()
mock_speech.engine = "whisper_cpp"
mock_speech_cls.return_value = mock_speech
mock_text_cls = stack.enter_context(
patch("vocalinux.text_injection.text_injector.TextInjector")
)
mock_text = MagicMock()
mock_text.inject_text.return_value = inject_ok
mock_text_cls.return_value = mock_text
mock_tray_cls = stack.enter_context(patch("vocalinux.ui.tray_indicator.TrayIndicator"))
mock_tray_cls.return_value = MagicMock()
stack.enter_context(patch("vocalinux.ui.logging_manager.initialize_logging"))
stack.enter_context(
patch(
"vocalinux.main._should_append_trailing_space",
return_value=append_trailing_space,
)
)
mock_parse = stack.enter_context(patch("vocalinux.main.parse_arguments"))
stack.enter_context(patch("sys.argv", ["vocalinux"]))
mock_args = MagicMock()
mock_args.debug = False
mock_args.model = "tiny"
mock_args.engine = "whisper_cpp"
mock_args.language = "en-us"
mock_args.wayland = False
mock_args.start_minimized = False
mock_parse.return_value = mock_args
main()
text_cb = mock_speech.register_text_callback.call_args.args[0]
return stack, text_cb, mock_text
def test_whitespace_only_is_skipped_through_main(self):
stack, text_cb, mock_text = self._boot_under_patches()
try:
text_cb(" \t ")
mock_text.inject_text.assert_not_called()
finally:
stack.close()
def test_newline_segment_skips_trailing_space_through_main(self):
stack, text_cb, mock_text = self._boot_under_patches()
try:
text_cb("Hello.\n")
mock_text.inject_text.assert_called_once_with("Hello.\n")
finally:
stack.close()
def test_legacy_mode_adds_leading_space_in_session(self):
stack, text_cb, mock_text = self._boot_under_patches(append_trailing_space=False)
try:
text_cb("Hello.")
text_cb("World")
calls = [c.args[0] for c in mock_text.inject_text.call_args_list]
self.assertEqual(calls, ["Hello.", " World"])
finally:
stack.close()
def test_failed_inject_does_not_remember_text(self):
stack, text_cb, mock_text = self._boot_under_patches(
append_trailing_space=False, inject_ok=False
)
try:
text_cb("Hello.")
mock_text.inject_text.reset_mock()
mock_text.inject_text.return_value = True
text_cb("World")
# Failure means last_injected stays empty; next segment has no leading space
mock_text.inject_text.assert_called_once_with("World")
finally:
stack.close()
if __name__ == "__main__":
unittest.main()