diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index 28a7ac9e6..75c4258b6 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -47,7 +47,8 @@ const AGENTIC_MODEL_ID = 'agentic-v1'; type ToolTimelineEntryStatus = 'running' | 'success' | 'error'; type InputMode = 'text' | 'voice'; type ReplyMode = 'text' | 'voice'; -const AUTOCOMPLETE_POLL_DEBOUNCE_MS = 180; +const AUTOCOMPLETE_POLL_DEBOUNCE_MS = 320; +const AUTOCOMPLETE_MIN_CONTEXT_CHARS = 3; interface ToolTimelineEntry { id: string; @@ -71,13 +72,54 @@ function formatRelativeTime(dateStr: string): string { function getInlineCompletionSuffix(input: string, suggestion: string): string { if (!input || !suggestion) return ''; - // If backend returns full string (starts with input), extract the suffix portion. - if (suggestion.startsWith(input)) { - const suffix = suggestion.slice(input.length); - return suffix || ''; + const normalize = (value: string) => + value + .replace(/\u2192/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + + const normalizedInput = normalize(input); + const normalizedSuggestion = normalize(suggestion); + if (!normalizedSuggestion) return ''; + + // Full-text response: strip already-typed prefix. + if (normalizedSuggestion.startsWith(normalizedInput)) { + return normalizedSuggestion.slice(normalizedInput.length).trimStart(); } - // Suggestion doesn't start with current input — it's stale; discard it. - return ''; + + // Remove overlap to prevent duplicate phrase insertion: + // "...want to" + "want to create..." => "create..." + const maxOverlap = Math.min(normalizedInput.length, normalizedSuggestion.length, 120); + for (let overlap = maxOverlap; overlap >= 1; overlap -= 1) { + if ( + normalizedInput.slice(normalizedInput.length - overlap) === + normalizedSuggestion.slice(0, overlap) + ) { + return normalizedSuggestion.slice(overlap).trimStart(); + } + } + + // Suffix-only fallback (the backend is intended to return suffix text). + if (normalizedInput.endsWith(normalizedSuggestion)) { + return ''; + } + return normalizedSuggestion; +} + +function buildAcceptedInlineCompletion(input: string, suffix: string): string { + const normalizedInput = input.replace(/\u2192/g, ' ').replace(/\t+/g, ' '); + const cleanSuffix = suffix + .replace(/\u2192/g, ' ') + .replace(/\t+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + + if (!cleanSuffix) return normalizedInput; + + const needsSpace = + normalizedInput.length > 0 && !/\s$/.test(normalizedInput) && !/^[,.;:!?)]/.test(cleanSuffix); + + return `${normalizedInput}${needsSpace ? ' ' : ''}${cleanSuffix}`; } function formatResetTime(isoStr: string): string { @@ -269,7 +311,7 @@ const Conversations = () => { !rustChat || inputMode !== 'text' || isSending || - inputValue.trim().length === 0 + inputValue.trim().length < AUTOCOMPLETE_MIN_CONTEXT_CHARS ) { setInlineSuggestionValue(''); return; @@ -813,39 +855,40 @@ const Conversations = () => { const handleInputKeyDown = (e: React.KeyboardEvent) => { const inlineSuffix = getInlineCompletionSuffix(inputValue, inlineSuggestionValue); - - if (e.key === 'Tab' && inlineSuffix.length > 0) { - e.preventDefault(); - setInputValue(prev => prev + inlineSuffix); + const textarea = e.currentTarget; + const caretAtEnd = + textarea.selectionStart === inputValue.length && textarea.selectionEnd === inputValue.length; + const tryAcceptInlineSuggestion = () => { + const nextValue = buildAcceptedInlineCompletion(inputValue, inlineSuffix); + if (!nextValue || nextValue === inputValue) return false; + setInputValue(nextValue); setInlineSuggestionValue(''); if (isTauri()) { - void openhumanAutocompleteAccept({ - suggestion: inputValue + inlineSuffix, - skip_apply: true, - }).catch(() => { + void openhumanAutocompleteAccept({ suggestion: nextValue, skip_apply: true }).catch(() => { // Keep local UX smooth even if accept RPC fails. }); } + return true; + }; + + if ( + e.key === 'Tab' && + !e.shiftKey && + !e.altKey && + !e.ctrlKey && + !e.metaKey && + inlineSuffix.length > 0 && + caretAtEnd + ) { + e.preventDefault(); + tryAcceptInlineSuggestion(); return; } - if (e.key === 'ArrowRight' && inlineSuffix.length > 0) { - const textarea = e.currentTarget; - if ( - textarea.selectionStart === inputValue.length && - textarea.selectionEnd === inputValue.length - ) { - e.preventDefault(); - setInputValue(prev => prev + inlineSuffix); - setInlineSuggestionValue(''); - if (isTauri()) { - void openhumanAutocompleteAccept({ - suggestion: inputValue + inlineSuffix, - skip_apply: true, - }).catch(() => {}); - } - return; - } + if (e.key === 'ArrowRight' && inlineSuffix.length > 0 && caretAtEnd) { + e.preventDefault(); + tryAcceptInlineSuggestion(); + return; } if (e.key === 'Enter' && !e.shiftKey) { diff --git a/scripts/test-subconscious-ticks.sh b/scripts/test-subconscious-ticks.sh index f295ceb71..34b2b567f 100644 --- a/scripts/test-subconscious-ticks.sh +++ b/scripts/test-subconscious-ticks.sh @@ -11,7 +11,7 @@ FIXTURES="./tests/fixtures/subconscious" if [ ! -f "$CORE_BIN" ]; then echo "ERROR: Core binary not found"; exit 1; fi # Check Ollama -if ! curl -s --max-time 3 http://127.0.0.1:11434/ >/dev/null 2>&1; then +if ! curl -s --max-time 3 http://localhost:11434/ >/dev/null 2>&1; then echo "ERROR: Ollama not running. Start with: ollama serve" exit 1 fi diff --git a/src/core/autocomplete_cli_adapter.rs b/src/core/autocomplete_cli_adapter.rs new file mode 100644 index 000000000..53ba0488d --- /dev/null +++ b/src/core/autocomplete_cli_adapter.rs @@ -0,0 +1,181 @@ +//! Autocomplete-specific CLI adapter. +//! +//! Keeps autocomplete-only argument handling out of the generic core CLI. + +use anyhow::Result; + +use crate::core::logging::CliLogDefault; +use crate::openhuman::autocomplete::ops::{autocomplete_start_cli, AutocompleteStartCliOptions}; + +pub struct NamespacePreparse { + pub args: Vec, + pub init_logging: Option<(bool, CliLogDefault)>, +} + +/// Extract only *leading* global verbose flags so parameter values remain intact. +/// Returns `(verbose, remaining_args)`. +fn extract_leading_verbose_flags(args: &[String]) -> (bool, Vec) { + let mut verbose = false; + let mut index = 0usize; + while index < args.len() { + match args[index].as_str() { + "-v" | "--verbose" => { + verbose = true; + index += 1; + } + _ => break, + } + } + (verbose, args[index..].to_vec()) +} + +pub fn preparse_namespace(namespace: &str, args: &[String]) -> NamespacePreparse { + if namespace != "autocomplete" { + return NamespacePreparse { + args: args.to_vec(), + init_logging: None, + }; + } + + let (verbose, remaining) = extract_leading_verbose_flags(args); + NamespacePreparse { + args: remaining, + init_logging: Some((verbose, CliLogDefault::AutocompleteOnly)), + } +} + +pub fn parse_run_scope_flag(flag: &str) -> Option { + if flag == "--autocomplete-logs" { + Some(CliLogDefault::AutocompleteOnly) + } else { + None + } +} + +pub fn print_run_scope_help_line() { + println!( + " --autocomplete-logs When RUST_LOG is unset: stderr shows only inline-autocomplete logs" + ); +} + +pub fn maybe_print_namespace_help_footer(namespace: &str) { + if namespace == "autocomplete" { + println!( + "Logging: stderr is autocomplete-only by default (unless RUST_LOG is set); add -v for trace." + ); + } +} + +pub fn maybe_print_start_help(namespace: &str, function: &str) -> bool { + if namespace == "autocomplete" && function == "start" { + print_autocomplete_start_help(); + true + } else { + false + } +} + +pub fn maybe_handle_namespace_start( + namespace: &str, + function: &str, + args: &[String], +) -> Result> { + if namespace != "autocomplete" || function != "start" { + return Ok(None); + } + + let cli_options = parse_autocomplete_start_cli_options(args)?; + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + let value = rt + .block_on(async { autocomplete_start_cli(cli_options).await }) + .map_err(anyhow::Error::msg)?; + Ok(Some(value)) +} + +/// Parses CLI options specific to the `autocomplete start` command. +fn parse_autocomplete_start_cli_options(args: &[String]) -> Result { + let mut debounce_ms: Option = None; + let mut serve = false; + let mut spawn = false; + let mut i = 0usize; + + while i < args.len() { + match args[i].as_str() { + "--debounce-ms" => { + let raw = args + .get(i + 1) + .ok_or_else(|| anyhow::anyhow!("missing value for --debounce-ms"))?; + debounce_ms = Some( + raw.parse::() + .map_err(|e| anyhow::anyhow!("invalid --debounce-ms: {e}"))?, + ); + i += 2; + } + "--serve" => { + serve = true; + i += 1; + } + "--spawn" => { + spawn = true; + i += 1; + } + other => return Err(anyhow::anyhow!("unknown autocomplete start arg: {other}")), + } + } + + if serve && spawn { + return Err(anyhow::anyhow!( + "--serve and --spawn are mutually exclusive" + )); + } + + Ok(AutocompleteStartCliOptions { + debounce_ms, + serve, + spawn, + }) +} + +/// Prints help information for the `autocomplete start` command. +fn print_autocomplete_start_help() { + println!("Usage: openhuman autocomplete start [--debounce-ms ] [--serve|--spawn]"); + println!(); + println!(" --debounce-ms Override debounce in milliseconds."); + println!(" --serve Run autocomplete loop in the current foreground process."); + println!(" --spawn Spawn autocomplete loop as a background process."); +} + +#[cfg(test)] +mod tests { + use super::parse_autocomplete_start_cli_options; + + #[test] + fn parse_autocomplete_start_cli_options_rejects_serve_and_spawn() { + let args = vec!["--serve".to_string(), "--spawn".to_string()]; + let err = parse_autocomplete_start_cli_options(&args) + .expect_err("must reject mutually exclusive flags"); + assert!(err.to_string().contains("mutually exclusive")); + } + + #[test] + fn extract_leading_verbose_flags_preserves_param_like_values() { + let args = vec![ + "-v".to_string(), + "set_style".to_string(), + "--style-instructions".to_string(), + "--verbose".to_string(), + ]; + let (verbose, remaining) = super::extract_leading_verbose_flags(&args); + assert!(verbose); + assert_eq!( + remaining, + vec![ + "set_style".to_string(), + "--style-instructions".to_string(), + "--verbose".to_string() + ] + ); + } +} diff --git a/src/core/cli.rs b/src/core/cli.rs index 6da104cb3..538956234 100644 --- a/src/core/cli.rs +++ b/src/core/cli.rs @@ -9,9 +9,10 @@ use serde_json::{Map, Value}; use std::collections::BTreeMap; use crate::core::all; +use crate::core::autocomplete_cli_adapter; use crate::core::jsonrpc::{default_state, invoke_method, parse_json_params}; +use crate::core::logging::CliLogDefault; use crate::core::{ControllerSchema, TypeSchema}; -use crate::openhuman::autocomplete::ops::{autocomplete_start_cli, AutocompleteStartCliOptions}; /// The ASCII banner displayed when the CLI starts. const CLI_BANNER: &str = r#" @@ -77,6 +78,7 @@ fn run_server_command(args: &[String]) -> Result<()> { let mut host: Option = None; let mut socketio_enabled = true; let mut verbose = false; + let mut log_scope = CliLogDefault::Global; let mut i = 0usize; // Manual argument parsing loop for specific flags. @@ -108,8 +110,13 @@ fn run_server_command(args: &[String]) -> Result<()> { verbose = true; i += 1; } + other if autocomplete_cli_adapter::parse_run_scope_flag(other).is_some() => { + log_scope = autocomplete_cli_adapter::parse_run_scope_flag(other) + .unwrap_or(CliLogDefault::Global); + i += 1; + } "-h" | "--help" => { - println!("Usage: openhuman run [--host ] [--port ] [--jsonrpc-only] [-v|--verbose]"); + println!("Usage: openhuman run [--host ] [--port ] [--jsonrpc-only] [--autocomplete-logs] [-v|--verbose]"); println!(); println!( " --host Bind address (default: 127.0.0.1 or OPENHUMAN_CORE_HOST)" @@ -118,6 +125,7 @@ fn run_server_command(args: &[String]) -> Result<()> { " --port Listen address port (default: 7788 or OPENHUMAN_CORE_PORT)" ); println!(" --jsonrpc-only HTTP JSON-RPC only; disable Socket.IO"); + autocomplete_cli_adapter::print_run_scope_help_line(); println!(" -v, --verbose Shorthand for RUST_LOG=debug when RUST_LOG is unset"); println!(); println!("Logging: set RUST_LOG (e.g. RUST_LOG=debug openhuman run). Default level is info."); @@ -127,7 +135,7 @@ fn run_server_command(args: &[String]) -> Result<()> { } } - crate::core::logging::init_for_cli_run(verbose); + crate::core::logging::init_for_cli_run(verbose, log_scope); // Initialize the Tokio runtime and start the server. let rt = tokio::runtime::Builder::new_multi_thread() @@ -250,7 +258,7 @@ fn run_voice_server_command(args: &[String]) -> Result<()> { } } - crate::core::logging::init_for_cli_run(verbose); + crate::core::logging::init_for_cli_run(verbose, CliLogDefault::Global); let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -305,6 +313,12 @@ fn run_namespace_command( )); }; + let preparsed = autocomplete_cli_adapter::preparse_namespace(namespace, args); + let args: &[String] = &preparsed.args; + if let Some((verbose, scope)) = preparsed.init_logging { + crate::core::logging::init_for_cli_run(verbose, scope); + } + if args.is_empty() || is_help(&args[0]) { print_namespace_help(namespace, schemas); return Ok(()); @@ -317,19 +331,15 @@ fn run_namespace_command( )); }; - // Special case for autocomplete start command which has its own CLI options. - if namespace == "autocomplete" && function == "start" { - if args.len() > 1 && is_help(&args[1]) { - print_autocomplete_start_help(); + // Domain adapters can intercept specific namespace/function combinations. + if args.len() > 1 && is_help(&args[1]) { + if autocomplete_cli_adapter::maybe_print_start_help(namespace, function) { return Ok(()); } - let cli_options = parse_autocomplete_start_cli_options(&args[1..])?; - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build()?; - let value = rt - .block_on(async { autocomplete_start_cli(cli_options).await }) - .map_err(anyhow::Error::msg)?; + } + if let Some(value) = + autocomplete_cli_adapter::maybe_handle_namespace_start(namespace, function, &args[1..])? + { println!("{}", serde_json::to_string_pretty(&value)?); return Ok(()); } @@ -355,64 +365,6 @@ fn run_namespace_command( Ok(()) } -/// Parses CLI options specific to the `autocomplete start` command. -/// -/// # Arguments -/// -/// * `args` - CLI arguments for the autocomplete start command. -fn parse_autocomplete_start_cli_options(args: &[String]) -> Result { - let mut debounce_ms: Option = None; - let mut serve = false; - let mut spawn = false; - let mut i = 0usize; - - while i < args.len() { - match args[i].as_str() { - "--debounce-ms" => { - let raw = args - .get(i + 1) - .ok_or_else(|| anyhow::anyhow!("missing value for --debounce-ms"))?; - debounce_ms = Some( - raw.parse::() - .map_err(|e| anyhow::anyhow!("invalid --debounce-ms: {e}"))?, - ); - i += 2; - } - "--serve" => { - serve = true; - i += 1; - } - "--spawn" => { - spawn = true; - i += 1; - } - other => return Err(anyhow::anyhow!("unknown autocomplete start arg: {other}")), - } - } - - // Ensure the user doesn't try to both foreground and background the process. - if serve && spawn { - return Err(anyhow::anyhow!( - "--serve and --spawn are mutually exclusive" - )); - } - - Ok(AutocompleteStartCliOptions { - debounce_ms, - serve, - spawn, - }) -} - -/// Prints help information for the `autocomplete start` command. -fn print_autocomplete_start_help() { - println!("Usage: openhuman autocomplete start [--debounce-ms ] [--serve|--spawn]"); - println!(); - println!(" --debounce-ms Override debounce in milliseconds."); - println!(" --serve Run autocomplete loop in the current foreground process."); - println!(" --spawn Spawn autocomplete loop as a background process."); -} - /// Parses command-line arguments into a JSON map based on a function's schema. /// /// # Arguments @@ -548,6 +500,7 @@ fn print_namespace_help(namespace: &str, schemas: &[ControllerSchema]) { println!(" {} - {}", schema.function, schema.description); } println!("\nUse `openhuman {namespace} --help` for parameters."); + autocomplete_cli_adapter::maybe_print_namespace_help_footer(namespace); } /// Prints detailed help for a specific function, including its parameters and description. @@ -576,10 +529,7 @@ fn is_help(value: &str) -> bool { #[cfg(test)] mod tests { - use super::{ - grouped_schemas, parse_autocomplete_start_cli_options, parse_function_params, - parse_input_value, - }; + use super::{grouped_schemas, parse_function_params, parse_input_value}; use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; #[test] @@ -597,14 +547,6 @@ mod tests { assert!(grouped.contains_key("local_ai")); } - #[test] - fn parse_autocomplete_start_cli_options_rejects_serve_and_spawn() { - let args = vec!["--serve".to_string(), "--spawn".to_string()]; - let err = parse_autocomplete_start_cli_options(&args) - .expect_err("must reject mutually exclusive flags"); - assert!(err.to_string().contains("mutually exclusive")); - } - #[test] fn parse_function_params_rejects_unknown_param() { let schema = ControllerSchema { diff --git a/src/core/logging.rs b/src/core/logging.rs index 85cd2d3f1..4d23d94f0 100644 --- a/src/core/logging.rs +++ b/src/core/logging.rs @@ -16,6 +16,15 @@ use tracing_subscriber::util::SubscriberInitExt; static INIT: Once = Once::new(); +/// Default `RUST_LOG` when it is unset: either global levels or only the inline autocomplete module tree. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CliLogDefault { + /// Typical server/CLI logging (`info`, or `debug` when `verbose`). + Global, + /// Silence other modules; only `openhuman_core::openhuman::autocomplete::*` emits logs. + AutocompleteOnly, +} + /// `14:32:01 (jsonrpc) message…` — colors when stderr is a TTY. struct CleanCliFormat; @@ -77,16 +86,39 @@ fn short_target(target: &str) -> &str { /// Initialize `tracing` + bridge the `log` crate so existing `log::info!` calls appear. /// -/// - If `RUST_LOG` is unset: uses `info`, or `debug` when `verbose` is true. +/// - If `RUST_LOG` is unset: uses [`CliLogDefault`] and `verbose` to pick a default filter string. /// - Safe to call once; subsequent calls are ignored. -pub fn init_for_cli_run(verbose: bool) { +pub fn init_for_cli_run(verbose: bool, default_scope: CliLogDefault) { INIT.call_once(|| { if std::env::var_os("RUST_LOG").is_none() { - std::env::set_var("RUST_LOG", if verbose { "debug" } else { "info" }); + let default = match default_scope { + CliLogDefault::Global => { + if verbose { + "debug".to_string() + } else { + "info".to_string() + } + } + CliLogDefault::AutocompleteOnly => { + let level = if verbose { "trace" } else { "debug" }; + format!("off,openhuman_core::openhuman::autocomplete={level}") + } + }; + std::env::set_var("RUST_LOG", default); } let filter = tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| { - tracing_subscriber::EnvFilter::new(if verbose { "debug" } else { "info" }) + match default_scope { + CliLogDefault::Global => { + tracing_subscriber::EnvFilter::new(if verbose { "debug" } else { "info" }) + } + CliLogDefault::AutocompleteOnly => { + let level = if verbose { "trace" } else { "debug" }; + tracing_subscriber::EnvFilter::new(format!( + "off,openhuman_core::openhuman::autocomplete={level}" + )) + } + } }); let use_color = io::stderr().is_terminal(); diff --git a/src/core/memory_cli.rs b/src/core/memory_cli.rs index 61f54352b..7635aa744 100644 --- a/src/core/memory_cli.rs +++ b/src/core/memory_cli.rs @@ -92,7 +92,7 @@ fn run_ingest(args: &[String]) -> Result<()> { anyhow::anyhow!("missing file argument. Use a file path or '-' for stdin.") })?; - crate::core::logging::init_for_cli_run(verbose); + crate::core::logging::init_for_cli_run(verbose, crate::core::logging::CliLogDefault::Global); let content = read_input(&file_path)?; let doc_key = key.unwrap_or_else(|| file_path.clone()); @@ -194,7 +194,7 @@ fn run_docs(args: &[String]) -> Result<()> { } } - crate::core::logging::init_for_cli_run(verbose); + crate::core::logging::init_for_cli_run(verbose, crate::core::logging::CliLogDefault::Global); let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -245,7 +245,7 @@ fn run_graph_query(args: &[String]) -> Result<()> { } } - crate::core::logging::init_for_cli_run(verbose); + crate::core::logging::init_for_cli_run(verbose, crate::core::logging::CliLogDefault::Global); let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -307,7 +307,7 @@ fn run_query(args: &[String]) -> Result<()> { namespace.ok_or_else(|| anyhow::anyhow!("--namespace is required for query"))?; let query = query.ok_or_else(|| anyhow::anyhow!("--query is required"))?; - crate::core::logging::init_for_cli_run(verbose); + crate::core::logging::init_for_cli_run(verbose, crate::core::logging::CliLogDefault::Global); let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -339,7 +339,7 @@ fn run_namespaces(args: &[String]) -> Result<()> { } } - crate::core::logging::init_for_cli_run(verbose); + crate::core::logging::init_for_cli_run(verbose, crate::core::logging::CliLogDefault::Global); let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -382,7 +382,7 @@ fn run_clear(args: &[String]) -> Result<()> { let namespace = namespace.ok_or_else(|| anyhow::anyhow!("--namespace is required for clear"))?; - crate::core::logging::init_for_cli_run(verbose); + crate::core::logging::init_for_cli_run(verbose, crate::core::logging::CliLogDefault::Global); let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() diff --git a/src/core/mod.rs b/src/core/mod.rs index 758c94789..65aa62bc7 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -2,6 +2,7 @@ use serde::Serialize; pub mod all; +pub mod autocomplete_cli_adapter; pub mod cli; pub mod dispatch; pub mod jsonrpc; diff --git a/src/core/repl.rs b/src/core/repl.rs index eaa285957..c843bb6b7 100644 --- a/src/core/repl.rs +++ b/src/core/repl.rs @@ -35,7 +35,10 @@ pub fn run_repl(args: &[String]) -> anyhow::Result<()> { return Ok(()); } - crate::core::logging::init_for_cli_run(opts.verbose); + crate::core::logging::init_for_cli_run( + opts.verbose, + crate::core::logging::CliLogDefault::Global, + ); let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() diff --git a/src/core/screen_intelligence_cli.rs b/src/core/screen_intelligence_cli.rs index cf309787e..d3e478f95 100644 --- a/src/core/screen_intelligence_cli.rs +++ b/src/core/screen_intelligence_cli.rs @@ -160,7 +160,10 @@ fn run_server(args: &[String]) -> Result<()> { return Ok(()); } - crate::core::logging::init_for_cli_run(opts.verbose); + crate::core::logging::init_for_cli_run( + opts.verbose, + crate::core::logging::CliLogDefault::Global, + ); let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -342,7 +345,10 @@ fn run_start_session(args: &[String]) -> Result<()> { } let (opts, _) = parse_opts(args)?; - crate::core::logging::init_for_cli_run(opts.verbose); + crate::core::logging::init_for_cli_run( + opts.verbose, + crate::core::logging::CliLogDefault::Global, + ); let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -622,7 +628,7 @@ fn init_quiet_logging(verbose: bool) { if !verbose && std::env::var_os("RUST_LOG").is_none() { std::env::set_var("RUST_LOG", "warn"); } - crate::core::logging::init_for_cli_run(verbose); + crate::core::logging::init_for_cli_run(verbose, crate::core::logging::CliLogDefault::Global); } fn is_help(value: &str) -> bool { diff --git a/src/core/skills_cli.rs b/src/core/skills_cli.rs index 009db03a9..7309da6cd 100644 --- a/src/core/skills_cli.rs +++ b/src/core/skills_cli.rs @@ -166,7 +166,10 @@ fn run_skills_server(args: &[String]) -> Result<()> { return Ok(()); } - crate::core::logging::init_for_cli_run(opts.verbose); + crate::core::logging::init_for_cli_run( + opts.verbose, + crate::core::logging::CliLogDefault::Global, + ); let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -508,7 +511,7 @@ fn init_quiet_logging(verbose: bool) { if !verbose && std::env::var_os("RUST_LOG").is_none() { std::env::set_var("RUST_LOG", "warn"); } - crate::core::logging::init_for_cli_run(verbose); + crate::core::logging::init_for_cli_run(verbose, crate::core::logging::CliLogDefault::Global); } fn is_help(value: &str) -> bool { diff --git a/src/core/text_input_cli.rs b/src/core/text_input_cli.rs index 320427d8a..139232ed2 100644 --- a/src/core/text_input_cli.rs +++ b/src/core/text_input_cli.rs @@ -119,7 +119,10 @@ fn run_server(args: &[String]) -> Result<()> { return Ok(()); } - crate::core::logging::init_for_cli_run(opts.verbose); + crate::core::logging::init_for_cli_run( + opts.verbose, + crate::core::logging::CliLogDefault::Global, + ); let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -354,7 +357,7 @@ fn init_quiet_logging(verbose: bool) { if !verbose && std::env::var_os("RUST_LOG").is_none() { std::env::set_var("RUST_LOG", "warn"); } - crate::core::logging::init_for_cli_run(verbose); + crate::core::logging::init_for_cli_run(verbose, crate::core::logging::CliLogDefault::Global); } fn is_help(value: &str) -> bool { diff --git a/src/openhuman/accessibility/mod.rs b/src/openhuman/accessibility/mod.rs index 3f34a9930..42966704e 100644 --- a/src/openhuman/accessibility/mod.rs +++ b/src/openhuman/accessibility/mod.rs @@ -29,7 +29,7 @@ pub use globe::{ pub use helper::precompile_helper_background; pub use keys::{is_escape_key_down, is_tab_key_down}; pub use overlay::{hide_overlay, quit_overlay, show_overlay}; -pub use paste::apply_text_to_focused_field; +pub use paste::{apply_text_to_focused_field, send_backspace}; #[cfg(target_os = "macos")] pub use permissions::{ detect_accessibility_permission, detect_input_monitoring_permission, diff --git a/src/openhuman/accessibility/paste.rs b/src/openhuman/accessibility/paste.rs index 0b7fe5a15..528cd4a36 100644 --- a/src/openhuman/accessibility/paste.rs +++ b/src/openhuman/accessibility/paste.rs @@ -39,6 +39,48 @@ pub fn apply_text_to_focused_field(text: &str) -> Result<(), String> { apply_text_via_axvalue(text) } +/// Synthesize backspace keypresses on the focused element. +/// +/// Used by autocomplete Tab-accept flow to remove the native Tab indentation +/// side-effect before pasting the accepted suggestion. +#[cfg(target_os = "macos")] +pub fn send_backspace(count: usize) -> Result<(), String> { + if count == 0 { + return Ok(()); + } + + // Safety clamp: autocomplete only ever asks for small cleanup counts. + let presses = count.min(8); + log::debug!( + "[accessibility] sending backspace cleanup keypresses count={}", + presses + ); + + unsafe { + for _ in 0..presses { + let key_down = CGEventCreateKeyboardEvent(std::ptr::null(), KVK_DELETE, true); + let key_up = CGEventCreateKeyboardEvent(std::ptr::null(), KVK_DELETE, false); + if key_down.is_null() || key_up.is_null() { + if !key_down.is_null() { + CFRelease(key_down as *const _); + } + if !key_up.is_null() { + CFRelease(key_up as *const _); + } + return Err("failed to create CGEvent for backspace".to_string()); + } + CGEventPost(KCG_HID_EVENT_TAP, key_down); + std::thread::sleep(std::time::Duration::from_millis(5)); + CGEventPost(KCG_HID_EVENT_TAP, key_up); + CFRelease(key_down as *const _); + CFRelease(key_up as *const _); + std::thread::sleep(std::time::Duration::from_millis(2)); + } + } + + Ok(()) +} + /// Paste via the unified Swift helper. #[cfg(target_os = "macos")] fn paste_text_via_helper(text: &str) -> Result<(), String> { @@ -216,6 +258,11 @@ pub fn apply_text_to_focused_field(_text: &str) -> Result<(), String> { Err("text insertion is only supported on macOS".to_string()) } +#[cfg(not(target_os = "macos"))] +pub fn send_backspace(_count: usize) -> Result<(), String> { + Err("backspace synthesis is only supported on macOS".to_string()) +} + // --------------------------------------------------------------------------- // macOS FFI declarations for paste // --------------------------------------------------------------------------- @@ -241,6 +288,8 @@ extern "C" { #[cfg(target_os = "macos")] const KVK_V: u16 = 9; #[cfg(target_os = "macos")] +const KVK_DELETE: u16 = 51; +#[cfg(target_os = "macos")] const KCG_HID_EVENT_TAP: u32 = 0; #[cfg(target_os = "macos")] const KCG_EVENT_FLAG_MASK_COMMAND: u64 = 0x00100000; diff --git a/src/openhuman/autocomplete/core/engine.rs b/src/openhuman/autocomplete/core/engine.rs index 6f79bdfe9..5321f3fdb 100644 --- a/src/openhuman/autocomplete/core/engine.rs +++ b/src/openhuman/autocomplete/core/engine.rs @@ -9,7 +9,7 @@ use tokio::time::{self, Duration, Instant}; use super::focus::{ apply_text_to_focused_field, focused_text_context_verbose, is_escape_key_down, is_tab_key_down, - validate_focused_target, + send_backspace, validate_focused_target, }; use super::overlay::{overlay_helper_quit, show_overflow_badge}; use super::terminal::{ @@ -158,13 +158,15 @@ impl AutocompleteEngine { state.target_role.clone(), ) }; - let refresh_result = time::timeout( - Duration::from_secs(REFRESH_TIMEOUT_SECS), - engine.refresh(None), - ) - .await; + let mut refresh_task = tokio::spawn({ + let engine = engine.clone(); + async move { engine.refresh(None).await } + }); + let refresh_result = + time::timeout(Duration::from_secs(REFRESH_TIMEOUT_SECS), &mut refresh_task) + .await; match refresh_result { - Ok(Err(err)) => { + Ok(Ok(Err(err))) => { let error_message = { let mut state = engine.inner.lock().await; state.phase = "error".to_string(); @@ -194,14 +196,25 @@ impl AutocompleteEngine { } } } - Ok(Ok(())) => { + Ok(Ok(Ok(()))) => { let mut state = engine.inner.lock().await; if state.phase == "error" { state.phase = "idle".to_string(); } state.last_error = None; } + Ok(Err(join_err)) => { + log::error!( + "[autocomplete] refresh task crashed; keeping loop alive: {}", + join_err + ); + let mut state = engine.inner.lock().await; + state.phase = "error".to_string(); + state.last_error = Some(format!("refresh task crashed: {join_err}")); + state.updated_at_ms = Some(Utc::now().timestamp_millis()); + } Err(_elapsed) => { + refresh_task.abort(); log::warn!( "[autocomplete] refresh timed out after {}s, skipping", REFRESH_TIMEOUT_SECS @@ -267,7 +280,16 @@ impl AutocompleteEngine { let context_override = params .and_then(|p| p.context) .filter(|c| !c.trim().is_empty()); - self.refresh(context_override).await?; + if let Err(err) = self.refresh(context_override).await { + // `current()` can be called independently from the background loop + // (for example from the in-app composer polling path). Ensure an + // inference failure here cannot leave phase stuck at "generating". + let mut state = self.inner.lock().await; + state.phase = "error".to_string(); + state.last_error = Some(err.clone()); + state.updated_at_ms = Some(Utc::now().timestamp_millis()); + return Err(err); + } let state = self.inner.lock().await; Ok(AutocompleteCurrentResult { app_name: state.app_name.clone(), @@ -572,7 +594,30 @@ impl AutocompleteEngine { { let mut state = self.inner.lock().await; + if state.phase == "generating" { + let now_ms = Utc::now().timestamp_millis(); + let generating_age_ms = state + .updated_at_ms + .map(|ts| now_ms.saturating_sub(ts)) + .unwrap_or(0); + // Self-heal stale generating state so inference cannot freeze. + if generating_age_ms > 12_000 { + log::warn!( + "[autocomplete] detected stale generating phase (age={}ms); resetting to continue inference", + generating_age_ms + ); + state.phase = "idle".to_string(); + } else { + log::debug!( + "[autocomplete] skipping refresh while generation is in-flight (context_chars={}, age={}ms)", + context.chars().count(), + generating_age_ms + ); + return Ok(()); + } + } state.phase = "generating".to_string(); + state.updated_at_ms = Some(Utc::now().timestamp_millis()); } let service = local_ai::global(&config); @@ -581,10 +626,28 @@ impl AutocompleteEngine { // 2. Most recent past completions (KV recency signal / fallback) // 3. Static user-configured examples // Deduplicated and capped at 8 total. - let relevant_examples = - crate::openhuman::autocomplete::history::query_relevant_examples(&context, 4).await; - let recent_examples = - crate::openhuman::autocomplete::history::load_recent_examples(4).await; + let (relevant_examples, recent_examples) = if is_in_app { + // Keep in-app typing latency low by skipping local memory queries. + (Vec::new(), Vec::new()) + } else { + let relevant_future = + crate::openhuman::autocomplete::history::query_relevant_examples(&context, 4); + let recent_future = crate::openhuman::autocomplete::history::load_recent_examples(4); + let (relevant_result, recent_result) = tokio::join!( + time::timeout(Duration::from_millis(35), relevant_future), + time::timeout(Duration::from_millis(35), recent_future) + ); + ( + relevant_result.unwrap_or_else(|_| { + log::debug!("[autocomplete] skipped semantic history examples due to timeout"); + Vec::new() + }), + recent_result.unwrap_or_else(|_| { + log::debug!("[autocomplete] skipped recent history examples due to timeout"); + Vec::new() + }), + ) + }; let static_examples = config.autocomplete.style_examples.clone(); let merged_examples: Vec = { @@ -605,16 +668,26 @@ impl AutocompleteEngine { v }; - let generated = service + let generated = match service .inline_complete( &config, &context, &config.autocomplete.style_preset, config.autocomplete.style_instructions.as_deref(), &merged_examples, - Some(36), + Some(24), ) - .await?; + .await + { + Ok(value) => value, + Err(err) => { + let mut state = self.inner.lock().await; + state.phase = "error".to_string(); + state.last_error = Some(err.clone()); + state.updated_at_ms = Some(Utc::now().timestamp_millis()); + return Err(err); + } + }; let suggestion = sanitize_suggestion(&generated); let app_name = focused.app_name.clone(); @@ -690,11 +763,14 @@ impl AutocompleteEngine { if !edge { None } else { - state.suggestion.as_ref().map(|s| s.value.clone()) + state + .suggestion + .as_ref() + .map(|s| (s.value.clone(), state.context.clone())) } }; - if let Some(suggestion) = pending { + if let Some((suggestion, expected_context)) = pending { let cleaned = sanitize_suggestion(&suggestion); if !cleaned.is_empty() { // Validate the focused element still matches before inserting. @@ -721,6 +797,7 @@ impl AutocompleteEngine { let mut state = self.inner.lock().await; state.phase = "accepting".to_string(); } + self.cleanup_tab_side_effect(&expected_context).await; apply_text_to_focused_field(&cleaned)?; { let mut state = self.inner.lock().await; @@ -781,6 +858,50 @@ impl AutocompleteEngine { Ok(()) } + async fn cleanup_tab_side_effect(&self, expected_context: &str) { + if expected_context.trim().is_empty() { + return; + } + + let focused = match focused_text_context_verbose() { + Ok(value) => value, + Err(err) => { + log::debug!( + "[autocomplete] tab cleanup skipped: unable to read focused context: {err}" + ); + return; + } + }; + + if focused.raw_error.is_some() { + return; + } + + let current_context = if is_terminal_app(focused.app_name.as_deref()) + || looks_like_terminal_buffer(&focused.text) + { + extract_terminal_input_context(&focused.text) + } else { + focused.text + }; + + let cleanup_count = detect_tab_artifact_suffix(expected_context, ¤t_context); + if cleanup_count == 0 { + return; + } + + match send_backspace(cleanup_count) { + Ok(()) => log::debug!( + "[autocomplete] tab cleanup removed {cleanup_count} trailing tab-indentation chars before accept" + ), + Err(err) => log::warn!( + "[autocomplete] tab cleanup failed (count={}): {}", + cleanup_count, + err + ), + } + } + async fn try_reject_via_escape(&self) -> Result<(), String> { let is_down = is_escape_key_down(); let rejected = { @@ -821,3 +942,59 @@ pub static AUTOCOMPLETE_ENGINE: Lazy> = pub fn global_engine() -> Arc { AUTOCOMPLETE_ENGINE.clone() } + +fn detect_tab_artifact_suffix(expected_context: &str, current_context: &str) -> usize { + if expected_context.is_empty() || current_context.is_empty() { + return 0; + } + + // Ordered by preference: literal tab, then common indentation widths. + const CANDIDATE_SUFFIXES: [&str; 9] = [ + "\t", " ", " ", " ", " ", " ", " ", " ", " ", + ]; + + for suffix in CANDIDATE_SUFFIXES { + let mut expected_plus_suffix = String::with_capacity(expected_context.len() + suffix.len()); + expected_plus_suffix.push_str(expected_context); + expected_plus_suffix.push_str(suffix); + if current_context.ends_with(&expected_plus_suffix) { + return suffix.chars().count(); + } + } + + 0 +} + +#[cfg(test)] +mod tests { + use super::detect_tab_artifact_suffix; + + #[test] + fn detects_literal_tab_suffix() { + assert_eq!( + detect_tab_artifact_suffix("hello world", "hello world\t"), + 1 + ); + } + + #[test] + fn detects_space_indentation_suffix() { + assert_eq!( + detect_tab_artifact_suffix("hello world", "hello world "), + 4 + ); + } + + #[test] + fn returns_zero_when_context_does_not_match_expected_tail() { + assert_eq!( + detect_tab_artifact_suffix("hello world", "different "), + 0 + ); + } + + #[test] + fn returns_zero_when_no_tab_like_suffix_present() { + assert_eq!(detect_tab_artifact_suffix("hello world", "hello worldx"), 0); + } +} diff --git a/src/openhuman/autocomplete/core/focus.rs b/src/openhuman/autocomplete/core/focus.rs index c0c75f924..33f64dc21 100644 --- a/src/openhuman/autocomplete/core/focus.rs +++ b/src/openhuman/autocomplete/core/focus.rs @@ -6,4 +6,5 @@ pub(super) use crate::openhuman::accessibility::apply_text_to_focused_field; pub(super) use crate::openhuman::accessibility::focused_text_context_verbose; pub(super) use crate::openhuman::accessibility::is_escape_key_down; pub(super) use crate::openhuman::accessibility::is_tab_key_down; +pub(super) use crate::openhuman::accessibility::send_backspace; pub(super) use crate::openhuman::accessibility::validate_focused_target; diff --git a/src/openhuman/autocomplete/core/text.rs b/src/openhuman/autocomplete/core/text.rs index 69e73afbc..381e0a6f7 100644 --- a/src/openhuman/autocomplete/core/text.rs +++ b/src/openhuman/autocomplete/core/text.rs @@ -11,10 +11,30 @@ pub(super) fn truncate_head(text: &str, max_chars: usize) -> String { pub(super) fn sanitize_suggestion(text: &str) -> String { let first_line = text.lines().next().unwrap_or_default().trim(); - let cleaned = first_line - .trim_matches('"') + let stripped_prefix = { + const TOKEN_PREFIXES: [&str; 4] = ["- ", "* ", "> ", "→ "]; + let mut value = first_line.trim_matches('"').trim_start(); + loop { + let mut changed = false; + for prefix in TOKEN_PREFIXES { + if let Some(rest) = value.strip_prefix(prefix) { + value = rest.trim_start(); + changed = true; + break; + } + } + if !changed { + break value; + } + } + }; + let cleaned = stripped_prefix .replace('\t', " ") + .replace('→', " ") .replace('\r', "") + .split_whitespace() + .collect::>() + .join(" ") .trim() .to_string(); if cleaned.is_empty() { @@ -93,10 +113,28 @@ mod tests { #[test] fn sanitize_suggestion_replaces_embedded_tabs_with_spaces() { - // Leading/trailing tabs are stripped by trim(); only interior tabs become spaces. + // Leading/trailing tabs are stripped by trim(); interior tabs are normalized. assert_eq!(sanitize_suggestion("he\tllo"), "he llo"); } + #[test] + fn sanitize_suggestion_removes_arrow_tokens_and_collapses_spaces() { + assert_eq!( + sanitize_suggestion(" \t→ keep it concise "), + "keep it concise" + ); + } + + #[test] + fn sanitize_suggestion_preserves_double_dash_tokens() { + assert_eq!(sanitize_suggestion("--help"), "--help"); + } + + #[test] + fn sanitize_suggestion_preserves_dash_without_space_prefix() { + assert_eq!(sanitize_suggestion("-[ ] task"), "-[ ] task"); + } + #[test] fn sanitize_suggestion_empty_input_returns_empty() { assert_eq!(sanitize_suggestion(""), ""); diff --git a/src/openhuman/local_ai/ollama_api.rs b/src/openhuman/local_ai/ollama_api.rs index 8700a867a..628eb77cf 100644 --- a/src/openhuman/local_ai/ollama_api.rs +++ b/src/openhuman/local_ai/ollama_api.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; -pub(crate) const OLLAMA_BASE_URL: &str = "http://127.0.0.1:11434"; +pub(crate) const OLLAMA_BASE_URL: &str = "http://localhost:11434"; #[derive(Debug, Serialize)] pub(crate) struct OllamaPullRequest { diff --git a/src/openhuman/local_ai/parse.rs b/src/openhuman/local_ai/parse.rs index 1a8b85c9d..a7657573c 100644 --- a/src/openhuman/local_ai/parse.rs +++ b/src/openhuman/local_ai/parse.rs @@ -17,24 +17,151 @@ pub(crate) fn parse_suggestions(raw: &str, limit: usize) -> Vec { .collect() } -pub(crate) fn sanitize_inline_completion(raw: &str) -> String { - let line = raw.lines().next().unwrap_or_default().trim(); +fn normalize_inline_text(value: &str) -> String { + value + .replace('\u{200B}', "") + .replace('\u{200C}', "") + .replace('\u{200D}', "") + .replace('\u{FEFF}', "") + .replace('\u{00A0}', " ") + .replace('\u{2028}', " ") + .replace('\u{2029}', " ") + .replace('\t', " ") + .replace('→', " ") +} + +fn trim_generation_prefixes(mut value: &str) -> &str { + value = value.trim_start(); + + // Common wrappers from LLM output formatting. + for prefix in ["suffix:", "completion:", "result:", "output:"] { + if value + .get(..prefix.len()) + .map_or(false, |s| s.eq_ignore_ascii_case(prefix)) + { + value = value.get(prefix.len()..).unwrap_or(value).trim_start(); + break; + } + } + + value +} + +fn strip_inline_wrapper_prefix(value: &str) -> &str { + fn strip_known_markers(input: &str) -> Option<&str> { + for marker in ["- ", "* ", "> ", "→ "] { + if let Some(rest) = input.strip_prefix(marker) { + return Some(rest.trim_start()); + } + } + None + } + + fn strip_numbered_token(input: &str) -> Option<&str> { + let bytes = input.as_bytes(); + let mut i = 0usize; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + if i == 0 { + return None; + } + let punctuation = bytes.get(i).copied(); + let following_space = bytes.get(i + 1).copied(); + if matches!(punctuation, Some(b'.' | b')')) && following_space == Some(b' ') { + return input.get(i + 2..).map(str::trim_start); + } + None + } + + let trimmed = value.trim_start(); + if let Some(stripped) = strip_known_markers(trimmed) { + return stripped; + } + if let Some(stripped) = strip_numbered_token(trimmed) { + return stripped; + } + + // Quoted marker variants, e.g. "\"- item" or "\"1. item". + if let Some(after_quote) = trimmed.strip_prefix('"') { + if let Some(stripped) = strip_known_markers(after_quote) { + return stripped; + } + if let Some(stripped) = strip_numbered_token(after_quote) { + return stripped; + } + } + + trimmed +} + +pub(crate) fn sanitize_inline_completion(raw: &str, context: &str) -> String { + let raw_norm = normalize_inline_text(raw); + let line = raw_norm + .lines() + .next() + .unwrap_or_default() + .trim() + .to_string(); if line.is_empty() { return String::new(); } - let mut cleaned = line - .trim_matches('"') - .trim_start_matches(|c: char| matches!(c, '-' | '*' | '>' | '1'..='9' | '.' | ')')) - .trim() - .replace('\t', " "); + let unquoted = line.trim_matches('"'); + let mut cleaned = strip_inline_wrapper_prefix(unquoted).trim().to_string(); + cleaned = trim_generation_prefixes(&cleaned).to_string(); + + cleaned = cleaned.split_whitespace().collect::>().join(" "); if cleaned.eq_ignore_ascii_case("none") || cleaned.eq_ignore_ascii_case("n/a") { return String::new(); } - if cleaned.chars().count() > 128 { - cleaned = cleaned.chars().take(128).collect(); + let context_norm = normalize_inline_text(context) + .split_whitespace() + .collect::>() + .join(" "); + + // Avoid overly aggressive overlap stripping for very short contexts. + // Example: context="hello", model="hello world" should usually stay as + // "hello world" instead of collapsing to "world". + const MIN_CONTEXT_CHARS_FOR_DEDUP: usize = 6; + let should_dedup_against_context = context_norm.chars().count() >= MIN_CONTEXT_CHARS_FOR_DEDUP; + + if !context_norm.is_empty() && should_dedup_against_context { + // If model returned full text, keep suffix only. + if cleaned.starts_with(&context_norm) { + cleaned = cleaned[context_norm.len()..].trim_start().to_string(); + } else { + // Remove overlap between end of context and start of prediction. + let cleaned_chars: Vec = cleaned.chars().collect(); + let max_overlap = context_norm + .chars() + .count() + .min(cleaned_chars.len()) + .min(160); + for overlap in (1..=max_overlap).rev() { + let overlap_prefix: String = cleaned_chars.iter().take(overlap).collect(); + if context_norm.ends_with(&overlap_prefix) { + cleaned = cleaned_chars + .iter() + .skip(overlap) + .collect::() + .trim_start() + .to_string(); + break; + } + } + } + + // If "completion" is already part of the context tail, drop it. + if !cleaned.is_empty() && context_norm.ends_with(&cleaned) { + return String::new(); + } + } + + if cleaned.chars().count() > 96 { + cleaned = cleaned.chars().take(96).collect(); } cleaned @@ -56,15 +183,96 @@ mod tests { #[test] fn sanitize_inline_completion_handles_placeholders_and_clamps_length() { - assert_eq!(sanitize_inline_completion("none"), ""); - assert_eq!(sanitize_inline_completion("n/a"), ""); + assert_eq!(sanitize_inline_completion("none", "hello"), ""); + assert_eq!(sanitize_inline_completion("n/a", "hello"), ""); assert_eq!( - sanitize_inline_completion("\"- hello world\""), + sanitize_inline_completion("\"- hello world\"", "hello"), "hello world" ); let long = "a".repeat(256); - let out = sanitize_inline_completion(&long); - assert_eq!(out.chars().count(), 128); + let out = sanitize_inline_completion(&long, "hello"); + assert_eq!(out.chars().count(), 96); + } + + #[test] + fn sanitize_inline_completion_strips_arrow_and_extra_whitespace() { + assert_eq!( + sanitize_inline_completion("\t→ keep it concise\t", "hello"), + "keep it concise" + ); + } + + #[test] + fn sanitize_inline_completion_strips_quoted_generation_label() { + assert_eq!( + sanitize_inline_completion("\"suffix: hello\"", "context example"), + "hello" + ); + } + + #[test] + fn sanitize_inline_completion_returns_suffix_only_when_model_repeats_context() { + let ctx = "Yesterday, I went"; + let raw = "Yesterday, I went to the garden"; + assert_eq!(sanitize_inline_completion(raw, ctx), "to the garden"); + } + + #[test] + fn sanitize_inline_completion_drops_tabby_unicode_noise() { + let ctx = "Yester"; + let raw = "Yester\tday, \u{2028}I went\t to garden"; + assert_eq!( + sanitize_inline_completion(raw, ctx), + "day, I went to garden" + ); + } + + #[test] + fn sanitize_inline_completion_preserves_iso_date_prefix() { + assert_eq!( + sanitize_inline_completion("2026-04-07", "context example"), + "2026-04-07" + ); + } + + #[test] + fn sanitize_inline_completion_preserves_time_prefix() { + assert_eq!( + sanitize_inline_completion("3pm meeting", "context example"), + "3pm meeting" + ); + } + + #[test] + fn sanitize_inline_completion_preserves_double_dash_help_token() { + assert_eq!( + sanitize_inline_completion("--help", "context example"), + "--help" + ); + } + + #[test] + fn sanitize_inline_completion_preserves_task_marker_without_space() { + assert_eq!( + sanitize_inline_completion("-[ ] task", "context example"), + "-[ ] task" + ); + } + + #[test] + fn sanitize_inline_completion_strips_numbered_list_prefix_dot() { + assert_eq!( + sanitize_inline_completion("1. item", "context example"), + "item" + ); + } + + #[test] + fn sanitize_inline_completion_strips_numbered_list_prefix_paren() { + assert_eq!( + sanitize_inline_completion("2) item", "context example"), + "item" + ); } } diff --git a/src/openhuman/local_ai/service/public_infer.rs b/src/openhuman/local_ai/service/public_infer.rs index 325dd4f76..52f001d6d 100644 --- a/src/openhuman/local_ai/service/public_infer.rs +++ b/src/openhuman/local_ai/service/public_infer.rs @@ -83,10 +83,18 @@ impl LocalAiService { } let mut prompt = String::from( - "Complete the user's text with the most likely next words.\n\ - Return only the continuation suffix, no explanations.\n\ - Do not repeat text already written by the user.\n\ - Keep it natural and concise.\n\n", + "You are an inline autocomplete engine.\n\ + Predict the most likely continuation of the user's partial text.\n\ + Return only the exact continuation suffix.\n\ + Do not repeat or rewrite any part of the user's existing text.\n\ + Do not include any prefix labels like 'suffix:' or 'completion:'.\n\ + Return exactly one line with plain text and no quotes.\n\ + Do not add leading or trailing whitespace.\n\ + Do not add tabs or newlines.\n\ + Do not add non-breaking spaces or zero-width characters.\n\ + Preserve natural spacing inside the continuation only when required between words.\n\ + If the user is in the middle of a word, continue that word directly with no space.\n\ + If the continuation is uncertain, return an empty string.\n", ); prompt.push_str(&format!("Style preset: {}\n", style_preset.trim())); if let Some(instructions) = style_instructions { @@ -105,19 +113,22 @@ impl LocalAiService { } } } - prompt.push_str("\nUser text:\n"); - prompt.push_str(context.trim()); + let escaped_context = context.replace("", "<\\/USER_TEXT>"); + prompt.push_str("\nUser text (verbatim):\n\n"); + prompt.push_str(&escaped_context); + prompt.push_str("\n"); let raw = self - .inference( + .inference_with_temperature_allow_empty( config, "You are a low-latency inline text completion assistant.", &prompt, - max_tokens.or(Some(36)), + max_tokens.or(Some(24)), true, + 0.05, ) .await?; - Ok(sanitize_inline_completion(&raw)) + Ok(sanitize_inline_completion(&raw, context)) } /// Multi-turn chat completion via Ollama /api/chat. @@ -234,6 +245,62 @@ impl LocalAiService { prompt: &str, max_tokens: Option, no_think: bool, + ) -> Result { + self.inference_with_temperature(config, system, prompt, max_tokens, no_think, 0.2) + .await + } + + pub(crate) async fn inference_with_temperature( + &self, + config: &Config, + system: &str, + prompt: &str, + max_tokens: Option, + no_think: bool, + temperature: f32, + ) -> Result { + self.inference_with_temperature_internal( + config, + system, + prompt, + max_tokens, + no_think, + temperature, + false, + ) + .await + } + + async fn inference_with_temperature_allow_empty( + &self, + config: &Config, + system: &str, + prompt: &str, + max_tokens: Option, + no_think: bool, + temperature: f32, + ) -> Result { + self.inference_with_temperature_internal( + config, + system, + prompt, + max_tokens, + no_think, + temperature, + true, + ) + .await + } + + async fn inference_with_temperature_internal( + &self, + config: &Config, + system: &str, + prompt: &str, + max_tokens: Option, + no_think: bool, + temperature: f32, + allow_empty: bool, ) -> Result { if !matches!(self.status.lock().state.as_str(), "ready") { self.bootstrap(config).await; @@ -253,7 +320,7 @@ impl LocalAiService { images: None, stream: false, options: Some(OllamaGenerateOptions { - temperature: Some(0.2), + temperature: Some(temperature), top_k: Some(40), top_p: Some(0.9), num_predict: max_tokens.map(|v| v as i32), @@ -307,7 +374,11 @@ impl LocalAiService { } if payload.response.trim().is_empty() { - Err("ollama returned empty content".to_string()) + if allow_empty { + Ok(String::new()) + } else { + Err("ollama returned empty content".to_string()) + } } else { Ok(payload.response) }