diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 2b192ffa..a74104aa 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1859,6 +1859,42 @@ If Whisper transcribes "vox type" (or "Vox Type"), it will be replaced with "vox "omar key" = "Omarchy" ``` +### smart_auto_submit + +**Type:** Boolean +**Default:** `false` +**Required:** No + +When `true`, Voxtype watches for the word "submit" at the end of each transcription. If detected, it strips the word from the output and presses Enter - as if `auto_submit` had fired, but triggered by voice rather than being permanently on. Trailing punctuation on "submit" (e.g., "submit." from spoken punctuation) is handled correctly. + +**Example:** + +```toml +[text] +smart_auto_submit = true +``` + +Saying "send a reply to Alice submit" types "send a reply to Alice" and presses Enter. + +**Per-recording override:** + +```bash +# Enable for just this recording (even if config has it off) +voxtype record start --smart-auto-submit +voxtype record toggle --smart-auto-submit + +# Disable for just this recording (even if config has it on) +voxtype record start --no-smart-auto-submit +``` + +**Environment variable:** + +```bash +VOXTYPE_SMART_AUTO_SUBMIT=true voxtype +``` + +**Note:** `smart_auto_submit` is conditional - it only fires when you say "submit". The existing `auto_submit` option always presses Enter after every transcription. Use `smart_auto_submit` when you want the choice per dictation, and `auto_submit` when you always want Enter pressed. + --- ## [vad] diff --git a/docs/SMOKE_TESTS.md b/docs/SMOKE_TESTS.md index b1da6788..d1bab718 100644 --- a/docs/SMOKE_TESTS.md +++ b/docs/SMOKE_TESTS.md @@ -56,6 +56,84 @@ sleep 2 voxtype record stop ``` +## Smart Auto-Submit + +Tests the `smart_auto_submit` feature: saying "submit" at the end of dictation +strips the word and presses Enter. + +### Config-based + +```bash +# 1. Enable in config.toml: +# [text] +# smart_auto_submit = true + +# 2. Restart daemon +systemctl --user restart voxtype + +# 3. Record and say "hello world submit" (or "hello world submit.") +voxtype record start +sleep 4 +voxtype record stop + +# 4. Expected: "hello world" is typed and Enter is pressed +# +# To verify via logs, the daemon must be running with debug logging (-v): +# journalctl --user -u voxtype --since "30 seconds ago" | grep "Smart auto-submit triggered" +# At default log level the trigger fires silently - verify by observing Enter being pressed. +``` + +### CLI override (per-recording) + +```bash +# Force on for this recording (even if config has smart_auto_submit = false) +voxtype record start --smart-auto-submit +sleep 4 +voxtype record stop +# Say "hello world submit" - should type "hello world" and press Enter + +# Force off for this recording (even if config has smart_auto_submit = true) +voxtype record start --no-smart-auto-submit +sleep 4 +voxtype record stop +# Say "hello world submit" - "submit" should remain in output, no Enter pressed +``` + +### Environment variable + +```bash +# Stop the managed daemon first to avoid running two daemons simultaneously +systemctl --user stop voxtype + +# Start a temporary daemon with the env var +VOXTYPE_SMART_AUTO_SUBMIT=true voxtype daemon & +DAEMON_PID=$! +sleep 2 + +voxtype record start && sleep 4 && voxtype record stop +# Say "hello world submit" - should type "hello world" and press Enter + +# Clean up: stop the temp daemon and restart the managed one +kill $DAEMON_PID +systemctl --user start voxtype +``` + +### Negative cases + +```bash +# "submitted" (partial word) should NOT trigger +voxtype record start --smart-auto-submit +sleep 4 +voxtype record stop +# Say "I submitted the form" - full text including "submitted" should appear, no Enter + +# "submit" in the middle should NOT trigger +voxtype record start --smart-auto-submit +sleep 4 +voxtype record stop +# Say "please submit this form now" - full text should appear, no Enter +``` + ## File Output Tests the file output mode for writing transcriptions to files instead of typing. diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index bdc0b441..3e57c5c3 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -1452,6 +1452,33 @@ auto_submit = true # Press Enter after transcription Useful for chat applications or command lines where you want to submit immediately after dictating. +**Smart auto-submit (say "submit" to press Enter):** + +```toml +[text] +smart_auto_submit = true +``` + +With this enabled, ending your dictation with the word "submit" strips that word from the output and presses Enter. Unlike `auto_submit` (which always presses Enter), this only fires when you choose to say it. + +``` +# You say: "reply to Alice and cc Bob submit" +# Voxtype types: "reply to Alice and cc Bob" [then presses Enter] +``` + +Per-recording override (useful with compositor keybindings): + +```bash +voxtype record start --smart-auto-submit # force on for this recording +voxtype record start --no-smart-auto-submit # force off for this recording +``` + +Or via environment variable for the whole session: + +```bash +VOXTYPE_SMART_AUTO_SUBMIT=true voxtype +``` + **Shift+Enter for newlines:** ```toml diff --git a/src/cli.rs b/src/cli.rs index 4b6e5ec5..2ce4d64d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -202,6 +202,14 @@ pub struct Cli { #[arg(long, conflicts_with = "shift_enter_newlines", help_heading = "Output")] pub no_shift_enter_newlines: bool, + /// Enable smart auto-submit (say "submit" to press Enter) + #[arg(long, help_heading = "Output")] + pub smart_auto_submit: bool, + + /// Disable smart auto-submit (overrides config) + #[arg(long, conflicts_with = "smart_auto_submit", help_heading = "Output")] + pub no_smart_auto_submit: bool, + /// Delay between typed characters in milliseconds (0 = fastest) #[arg(long, value_name = "MS", help_heading = "Output")] pub type_delay: Option, @@ -425,6 +433,14 @@ pub enum RecordAction { /// Disable Shift+Enter newlines for this transcription (overrides config) #[arg(long, conflicts_with = "shift_enter_newlines")] no_shift_enter_newlines: bool, + + /// Enable smart auto-submit for this recording (say "submit" to press Enter) + #[arg(long, conflicts_with = "no_smart_auto_submit")] + smart_auto_submit: bool, + + /// Disable smart auto-submit for this recording + #[arg(long, conflicts_with = "smart_auto_submit")] + no_smart_auto_submit: bool, }, /// Stop recording and transcribe (send SIGUSR2 to daemon) Stop { @@ -483,6 +499,14 @@ pub enum RecordAction { /// Disable Shift+Enter newlines for this transcription (overrides config) #[arg(long, conflicts_with = "shift_enter_newlines")] no_shift_enter_newlines: bool, + + /// Enable smart auto-submit for this recording (say "submit" to press Enter) + #[arg(long, conflicts_with = "no_smart_auto_submit")] + smart_auto_submit: bool, + + /// Disable smart auto-submit for this recording (overrides config) + #[arg(long, conflicts_with = "smart_auto_submit")] + no_smart_auto_submit: bool, }, /// Cancel current recording or transcription (discard without output) Cancel, @@ -704,6 +728,32 @@ impl RecordAction { None } } + + /// Get the smart auto-submit override from --smart-auto-submit / --no-smart-auto-submit flags + /// Returns Some(true) to enable, Some(false) to disable, None if not specified + pub fn smart_auto_submit_override(&self) -> Option { + let (enable, disable) = match self { + RecordAction::Start { + smart_auto_submit, + no_smart_auto_submit, + .. + } => (*smart_auto_submit, *no_smart_auto_submit), + RecordAction::Toggle { + smart_auto_submit, + no_smart_auto_submit, + .. + } => (*smart_auto_submit, *no_smart_auto_submit), + RecordAction::Stop { .. } | RecordAction::Cancel => return None, + }; + + if enable { + Some(true) + } else if disable { + Some(false) + } else { + None + } + } } #[derive(Subcommand)] @@ -1715,4 +1765,89 @@ mod tests { _ => panic!("Expected Record command"), } } + + // ========================================================================= + // Smart auto-submit flag tests + // ========================================================================= + + #[test] + fn test_record_start_smart_auto_submit_enable() { + let cli = Cli::parse_from(["voxtype", "record", "start", "--smart-auto-submit"]); + match cli.command { + Some(Commands::Record { action }) => { + assert_eq!(action.smart_auto_submit_override(), Some(true)); + } + _ => panic!("Expected Record command"), + } + } + + #[test] + fn test_record_start_no_smart_auto_submit() { + let cli = Cli::parse_from(["voxtype", "record", "start", "--no-smart-auto-submit"]); + match cli.command { + Some(Commands::Record { action }) => { + assert_eq!(action.smart_auto_submit_override(), Some(false)); + } + _ => panic!("Expected Record command"), + } + } + + #[test] + fn test_record_start_smart_auto_submit_mutual_exclusion() { + let result = Cli::try_parse_from([ + "voxtype", + "record", + "start", + "--smart-auto-submit", + "--no-smart-auto-submit", + ]); + assert!( + result.is_err(), + "Should not allow both flags simultaneously" + ); + } + + #[test] + fn test_record_start_smart_auto_submit_no_flags_returns_none() { + let cli = Cli::parse_from(["voxtype", "record", "start"]); + match cli.command { + Some(Commands::Record { action }) => { + assert_eq!(action.smart_auto_submit_override(), None); + } + _ => panic!("Expected Record command"), + } + } + + #[test] + fn test_record_toggle_smart_auto_submit_enable() { + let cli = Cli::parse_from(["voxtype", "record", "toggle", "--smart-auto-submit"]); + match cli.command { + Some(Commands::Record { action }) => { + assert_eq!(action.smart_auto_submit_override(), Some(true)); + } + _ => panic!("Expected Record command"), + } + } + + #[test] + fn test_record_toggle_no_smart_auto_submit() { + let cli = Cli::parse_from(["voxtype", "record", "toggle", "--no-smart-auto-submit"]); + match cli.command { + Some(Commands::Record { action }) => { + assert_eq!(action.smart_auto_submit_override(), Some(false)); + } + _ => panic!("Expected Record command"), + } + } + + #[test] + fn test_record_stop_has_no_smart_auto_submit_override() { + let cli = Cli::parse_from(["voxtype", "record", "stop"]); + match cli.command { + Some(Commands::Record { action }) => { + assert_eq!(action.smart_auto_submit_override(), None); + } + _ => panic!("Expected Record command"), + } + } } diff --git a/src/config.rs b/src/config.rs index 65ba2f4b..6a1e4691 100644 --- a/src/config.rs +++ b/src/config.rs @@ -226,6 +226,10 @@ on_transcription = true # # Custom word replacements (case-insensitive) # replacements = { "vox type" = "voxtype" } +# +# Smart auto-submit: say "submit" at the end of dictation to press Enter. +# The word "submit" is stripped from the output text and Enter is pressed. +# smart_auto_submit = false # [vad] # Voice Activity Detection - filters silence-only recordings @@ -1215,6 +1219,11 @@ pub struct TextConfig { /// Example: { "vox type" = "voxtype" } #[serde(default)] pub replacements: HashMap, + + /// Smart auto-submit: say "submit" at the end of dictation to press Enter. + /// The word "submit" is stripped from the output and Enter is pressed. + #[serde(default)] + pub smart_auto_submit: bool, } /// Meeting transcription configuration @@ -2083,6 +2092,9 @@ pub fn load_config(path: Option<&Path>) -> Result { config.output.restore_clipboard_delay_ms = ms; } } + if let Ok(val) = std::env::var("VOXTYPE_SMART_AUTO_SUBMIT") { + config.text.smart_auto_submit = parse_bool_env(&val); + } Ok(config) } diff --git a/src/daemon.rs b/src/daemon.rs index fb0d5aff..2ebfdd59 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -735,10 +735,11 @@ impl Daemon { tracing::info!("Meeting started: {}", meeting_id); // Start dual audio capture for meeting (mic + loopback) - let loopback_device = match self.config.meeting.audio.loopback_device.as_str() { - "disabled" | "" => None, - other => Some(other), - }; + let loopback_device = + match self.config.meeting.audio.loopback_device.as_str() { + "disabled" | "" => None, + other => Some(other), + }; match audio::DualCapture::new(&self.config.audio, loopback_device) { Ok(mut capture) => { if let Err(e) = capture.start().await { @@ -773,11 +774,17 @@ impl Daemon { tracing::info!("GTCRN speech enhancer loaded for meeting echo cancellation"); } Err(e) => { - tracing::warn!("Failed to load GTCRN enhancer, continuing without: {}", e); + tracing::warn!( + "Failed to load GTCRN enhancer, continuing without: {}", + e + ); } } } else { - tracing::debug!("GTCRN model not found at {:?}, skipping speech enhancement", model_path); + tracing::debug!( + "GTCRN model not found at {:?}, skipping speech enhancement", + model_path + ); } } @@ -915,6 +922,7 @@ impl Daemon { cleanup_profile_override(); cleanup_bool_override("auto_submit"); cleanup_bool_override("shift_enter"); + cleanup_bool_override("smart_auto_submit"); *state = State::Idle; self.update_state("idle"); @@ -1241,6 +1249,19 @@ impl Daemon { tracing::debug!("After text processing: {:?}", processed_text); } + // Smart auto-submit: detect "submit" trigger word at end + // CLI override (--smart-auto-submit / --no-smart-auto-submit) takes priority + let smart_auto_submit_cli = read_bool_override("smart_auto_submit"); + let (processed_text, smart_submit) = self + .text_processor + .detect_submit(&processed_text, smart_auto_submit_cli); + if smart_submit { + tracing::debug!( + "Smart auto-submit triggered, stripped text: {:?}", + processed_text + ); + } + // Check for profile override from CLI flags let profile_override = read_profile_override(); let active_profile = profile_override @@ -1292,6 +1313,13 @@ impl Daemon { processed_text }; + if smart_submit { + tracing::debug!( + "Smart auto-submit: final text after post-processing: {:?}", + final_text + ); + } + // Check for output mode override from CLI flags let output_override = read_output_mode_override(); @@ -1381,6 +1409,11 @@ impl Daemon { output_config.shift_enter_newlines = shift_enter; } + // If smart auto-submit triggered, enable auto_submit for this cycle + if smart_submit { + output_config.auto_submit = true; + } + let output_chain = output::create_output_chain(&output_config); // Output the text @@ -1985,6 +2018,7 @@ impl Daemon { cleanup_output_mode_override(); cleanup_model_override(); cleanup_profile_override(); + cleanup_bool_override("smart_auto_submit"); state = State::Idle; self.update_state("idle"); self.play_feedback(SoundEvent::Cancelled); @@ -2010,6 +2044,7 @@ impl Daemon { cleanup_output_mode_override(); cleanup_model_override(); cleanup_profile_override(); + cleanup_bool_override("smart_auto_submit"); state = State::Idle; self.update_state("idle"); self.play_feedback(SoundEvent::Cancelled); @@ -2055,6 +2090,7 @@ impl Daemon { cleanup_output_mode_override(); cleanup_model_override(); cleanup_profile_override(); + cleanup_bool_override("smart_auto_submit"); state = State::Idle; self.update_state("idle"); self.play_feedback(SoundEvent::Cancelled); @@ -2089,6 +2125,7 @@ impl Daemon { cleanup_output_mode_override(); cleanup_model_override(); cleanup_profile_override(); + cleanup_bool_override("smart_auto_submit"); // Get model override from state before transitioning let model_override = match &state { @@ -2320,6 +2357,7 @@ impl Daemon { cleanup_output_mode_override(); cleanup_model_override(); cleanup_profile_override(); + cleanup_bool_override("smart_auto_submit"); state = State::Idle; self.update_state("idle"); self.play_feedback(SoundEvent::Cancelled); diff --git a/src/main.rs b/src/main.rs index 11540b52..e13ba0a1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -262,6 +262,12 @@ async fn main() -> anyhow::Result<()> { if cli.no_shift_enter_newlines { config.output.shift_enter_newlines = false; } + if cli.smart_auto_submit { + config.text.smart_auto_submit = true; + } + if cli.no_smart_auto_submit { + config.text.smart_auto_submit = false; + } if let Some(delay) = cli.type_delay { config.output.type_delay_ms = delay; } @@ -642,6 +648,13 @@ fn send_record_command( .map_err(|e| anyhow::anyhow!("Failed to write model override: {}", e))?; } + // Write smart auto-submit override file if specified + if let Some(enabled) = action.smart_auto_submit_override() { + let override_file = config::Config::runtime_dir().join("smart_auto_submit_override"); + std::fs::write(&override_file, if enabled { "true" } else { "false" }) + .map_err(|e| anyhow::anyhow!("Failed to write smart auto-submit override: {}", e))?; + } + // Write profile override file if specified if let Some(profile_name) = action.profile() { // Validate that the profile exists in config diff --git a/src/text/mod.rs b/src/text/mod.rs index 54ee726b..6d9069f7 100644 --- a/src/text/mod.rs +++ b/src/text/mod.rs @@ -14,6 +14,10 @@ pub struct TextProcessor { spoken_punctuation: bool, /// Custom word replacements (lowercase key → replacement value) replacements: HashMap, + /// Whether smart auto-submit is enabled + smart_auto_submit: bool, + /// Pre-compiled regex for submit trigger detection + submit_re: Regex, } impl TextProcessor { @@ -26,9 +30,16 @@ impl TextProcessor { .map(|(k, v)| (k.to_lowercase(), v.clone())) .collect(); + // Use (?:^|\s) instead of \b so that hyphenated forms like "pre-submit" + // do not trigger: a hyphen satisfies \b but not (?:^|\s). + let submit_re = Regex::new(r"(?i)(?:^|\s)submit[.!?,;]*\s*$") + .expect("BUG: submit regex is a compile-time constant and must be valid"); + Self { spoken_punctuation: config.spoken_punctuation, replacements, + smart_auto_submit: config.smart_auto_submit, + submit_re, } } @@ -49,6 +60,37 @@ impl TextProcessor { result } + /// Check if text ends with the submit trigger word. + /// + /// Returns `(stripped_text, should_submit)`. Handles trailing punctuation (e.g., + /// "submit." from spoken punctuation) and is case-insensitive. + /// + /// `cli_override` allows the caller to force enable (`Some(true)`) or disable + /// (`Some(false)`) detection, overriding the config value. `None` uses the config. + pub fn detect_submit(&self, text: &str, cli_override: Option) -> (String, bool) { + let enabled = cli_override.unwrap_or(self.smart_auto_submit); + if !enabled { + return (text.to_string(), false); + } + + // Match "submit" preceded by start-of-string or whitespace (not hyphens), + // optionally followed by punctuation. Leading whitespace in the match is + // consumed by replace(); trim_end() cleans any remaining trailing space. + if self.submit_re.is_match(text) { + // After stripping "submit", also remove trailing connector punctuation + // (commas, semicolons) that would otherwise dangle at end of text. + // Sentence-ending punctuation (. ! ?) is preserved. + let stripped = self + .submit_re + .replace(text, "") + .trim_end_matches(|c: char| c.is_whitespace() || c == ',' || c == ';') + .to_string(); + (stripped, true) + } else { + (text.to_string(), false) + } + } + /// Apply spoken punctuation conversions fn apply_spoken_punctuation(&self, text: &str) -> String { let mut result = text.to_string(); @@ -187,6 +229,15 @@ mod tests { .iter() .map(|(k, v)| (k.to_string(), v.to_string())) .collect(), + smart_auto_submit: false, + } + } + + fn make_config_with_submit(spoken_punctuation: bool) -> TextConfig { + TextConfig { + spoken_punctuation, + replacements: HashMap::new(), + smart_auto_submit: true, } } @@ -282,4 +333,177 @@ mod tests { ); assert_eq!(processor.process("col one tab col two"), "col one\tcol two"); } + + #[test] + fn test_detect_submit_basic() { + let config = make_config_with_submit(false); + let processor = TextProcessor::new(&config); + + let (text, submit) = processor.detect_submit("hello world submit", None); + assert_eq!(text, "hello world"); + assert!(submit); + } + + #[test] + fn test_detect_submit_with_period() { + let config = make_config_with_submit(false); + let processor = TextProcessor::new(&config); + + // spoken punctuation may add a period after "submit" + let (text, submit) = processor.detect_submit("hello world submit.", None); + assert_eq!(text, "hello world"); + assert!(submit); + } + + #[test] + fn test_detect_submit_with_exclamation() { + let config = make_config_with_submit(false); + let processor = TextProcessor::new(&config); + + let (text, submit) = processor.detect_submit("hello world submit!", None); + assert_eq!(text, "hello world"); + assert!(submit); + } + + #[test] + fn test_detect_submit_uppercase() { + let config = make_config_with_submit(false); + let processor = TextProcessor::new(&config); + + let (text, submit) = processor.detect_submit("SUBMIT", None); + assert_eq!(text, ""); + assert!(submit); + } + + #[test] + fn test_detect_submit_in_middle_no_match() { + let config = make_config_with_submit(false); + let processor = TextProcessor::new(&config); + + let (text, submit) = processor.detect_submit("Submit this please", None); + assert_eq!(text, "Submit this please"); + assert!(!submit); + } + + #[test] + fn test_detect_submit_partial_word_no_match() { + let config = make_config_with_submit(false); + let processor = TextProcessor::new(&config); + + let (text, submit) = processor.detect_submit("submitted", None); + assert_eq!(text, "submitted"); + assert!(!submit); + } + + #[test] + fn test_pipeline_spoken_punctuation_then_detect_submit() { + // Simulates the full daemon pipeline: user says "hello world comma submit" + // process() converts "comma" → "," then detect_submit() strips ", submit" + let config = TextConfig { + spoken_punctuation: true, + replacements: HashMap::new(), + smart_auto_submit: true, + }; + let processor = TextProcessor::new(&config); + + let processed = processor.process("hello world comma submit"); + let (text, submit) = processor.detect_submit(&processed, None); + assert_eq!(text, "hello world"); + assert!(submit); + } + + #[test] + fn test_pipeline_spoken_punctuation_period_then_detect_submit() { + // Simulates: user says "hello world period submit" + // process() converts "period" → "." then detect_submit() strips " submit" + // The period on the prior sentence is preserved. + let config = TextConfig { + spoken_punctuation: true, + replacements: HashMap::new(), + smart_auto_submit: true, + }; + let processor = TextProcessor::new(&config); + + let processed = processor.process("hello world period submit"); + let (text, submit) = processor.detect_submit(&processed, None); + assert_eq!(text, "hello world."); + assert!(submit); + } + + #[test] + fn test_detect_submit_strips_trailing_comma() { + let config = make_config_with_submit(false); + let processor = TextProcessor::new(&config); + + // "hello world, submit" - spoken punctuation may produce a comma before + // "submit"; the dangling comma should be stripped from the result. + let (text, submit) = processor.detect_submit("hello world, submit", None); + assert_eq!(text, "hello world"); + assert!(submit); + } + + #[test] + fn test_detect_submit_strips_trailing_semicolon() { + let config = make_config_with_submit(false); + let processor = TextProcessor::new(&config); + + let (text, submit) = processor.detect_submit("hello world; submit", None); + assert_eq!(text, "hello world"); + assert!(submit); + } + + #[test] + fn test_detect_submit_preserves_trailing_period() { + let config = make_config_with_submit(false); + let processor = TextProcessor::new(&config); + + // A sentence ending in ". submit" should keep the period on the prior sentence. + let (text, submit) = processor.detect_submit("hello world. submit", None); + assert_eq!(text, "hello world."); + assert!(submit); + } + + #[test] + fn test_detect_submit_hyphenated_prefix_no_match() { + let config = make_config_with_submit(false); + let processor = TextProcessor::new(&config); + + // "pre-submit" ends with "submit" but hyphen is not a word boundary we + // accept: saying "I need to pre-submit" should not fire auto-submit. + let (text, submit) = processor.detect_submit("I need to pre-submit", None); + assert_eq!(text, "I need to pre-submit"); + assert!(!submit); + } + + #[test] + fn test_detect_submit_disabled() { + let config = make_config(false, &[]); + let processor = TextProcessor::new(&config); + + let (text, submit) = processor.detect_submit("hello world submit", None); + assert_eq!(text, "hello world submit"); + assert!(!submit); + } + + #[test] + fn test_detect_submit_cli_override_enable() { + // Config has smart_auto_submit=false, but CLI forces it on + let config = make_config(false, &[]); + let processor = TextProcessor::new(&config); + + let (text, submit) = processor.detect_submit("hello world submit", Some(true)); + assert_eq!(text, "hello world"); + assert!(submit); + } + + #[test] + fn test_detect_submit_cli_override_disable() { + // Config has smart_auto_submit=true, but CLI forces it off + let config = make_config_with_submit(false); + let processor = TextProcessor::new(&config); + + let (text, submit) = processor.detect_submit("hello world submit", Some(false)); + assert_eq!(text, "hello world submit"); + assert!(!submit); + } }