Improve inline autocomplete reliability, sanitization, and debug logging (#407)

* Enhance autocomplete functionality and logging. Increased debounce time for autocomplete suggestions and added minimum context character requirement. Improved inline suggestion handling with new cleanup logic for tab acceptance. Introduced a new logging option for autocomplete-only logs in CLI. Updated various components to support these changes, including sanitization and error handling in the autocomplete engine.

* Add autocomplete CLI adapter for improved argument handling

This commit introduces a new module, , which encapsulates the argument parsing and logging logic specific to the autocomplete namespace in the CLI. Key features include extraction of leading verbose flags, handling of the  flag, and improved help message printing. The existing CLI command handling has been refactored to utilize this new adapter, enhancing code organization and maintainability.

* Refactor inline completion sanitization and enhance context handling
This commit is contained in:
YellowSnnowmann
2026-04-07 09:11:42 -07:00
committed by GitHub
parent 000b40bf43
commit fb8987bcad
19 changed files with 943 additions and 185 deletions
+76 -33
View File
@@ -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<HTMLTextAreaElement>) => {
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) {
+1 -1
View File
@@ -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
+181
View File
@@ -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<String>,
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<String>) {
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<CliLogDefault> {
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<Option<serde_json::Value>> {
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<AutocompleteStartCliOptions> {
let mut debounce_ms: Option<u64> = 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::<u64>()
.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 <u64>] [--serve|--spawn]");
println!();
println!(" --debounce-ms <u64> 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()
]
);
}
}
+27 -85
View File
@@ -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<String> = 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 <addr>] [--port <u16>] [--jsonrpc-only] [-v|--verbose]");
println!("Usage: openhuman run [--host <addr>] [--port <u16>] [--jsonrpc-only] [--autocomplete-logs] [-v|--verbose]");
println!();
println!(
" --host <addr> Bind address (default: 127.0.0.1 or OPENHUMAN_CORE_HOST)"
@@ -118,6 +125,7 @@ fn run_server_command(args: &[String]) -> Result<()> {
" --port <u16> 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<AutocompleteStartCliOptions> {
let mut debounce_ms: Option<u64> = 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::<u64>()
.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 <u64>] [--serve|--spawn]");
println!();
println!(" --debounce-ms <u64> 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} <function> --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 {
+36 -4
View File
@@ -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 <INFO> (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();
+6 -6
View File
@@ -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()
+1
View File
@@ -2,6 +2,7 @@
use serde::Serialize;
pub mod all;
pub mod autocomplete_cli_adapter;
pub mod cli;
pub mod dispatch;
pub mod jsonrpc;
+4 -1
View File
@@ -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()
+9 -3
View File
@@ -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 {
+5 -2
View File
@@ -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 {
+5 -2
View File
@@ -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 {
+1 -1
View File
@@ -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,
+49
View File
@@ -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;
+195 -18
View File
@@ -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<String> = {
@@ -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, &current_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<Arc<AutocompleteEngine>> =
pub fn global_engine() -> Arc<AutocompleteEngine> {
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);
}
}
+1
View File
@@ -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;
+41 -3
View File
@@ -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::<Vec<_>>()
.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(""), "");
+1 -1
View File
@@ -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 {
+222 -14
View File
@@ -17,24 +17,151 @@ pub(crate) fn parse_suggestions(raw: &str, limit: usize) -> Vec<Suggestion> {
.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::<Vec<_>>().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::<Vec<_>>()
.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<char> = 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::<String>()
.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"
);
}
}
+82 -11
View File
@@ -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>", "<\\/USER_TEXT>");
prompt.push_str("\nUser text (verbatim):\n<USER_TEXT>\n");
prompt.push_str(&escaped_context);
prompt.push_str("\n</USER_TEXT>");
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<u32>,
no_think: bool,
) -> Result<String, String> {
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<u32>,
no_think: bool,
temperature: f32,
) -> Result<String, String> {
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<u32>,
no_think: bool,
temperature: f32,
) -> Result<String, String> {
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<u32>,
no_think: bool,
temperature: f32,
allow_empty: bool,
) -> Result<String, String> {
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)
}