diff --git a/docs/commands.md b/docs/commands.md index 42bdcc7e..12da6a98 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -121,7 +121,7 @@ Rewrites text to read more naturally and clearly. - `/rewrite` with highlighted text: rewrites the selected text - `/rewrite so basically what happened was i was trying to fix the bug`: rewrites typed text for clarity -**Behavior:** Preserves the original meaning while improving flow and readability. Outputs only the rewritten text. +**Behavior:** Preserves the original meaning while improving flow and readability. Outputs only the rewritten text. A Replace button on the result writes the rewritten text straight back into the app you were using, replacing your selection; turn on auto-replace in Settings to skip the button. Follow-up tweaks in the same chat, like asking for a longer or more formal version, keep the Replace button too. **Composable:** `/rewrite` works with attached images or `/screen`. Vision OCR extracts the text first, then rewrites it. @@ -153,7 +153,7 @@ Fixes grammar, spelling, and punctuation while preserving your voice. - `/refine` with highlighted text: corrects the selected text - `/refine hey just wanted to follow up on the thing we discussed`: cleans up typed text -**Behavior:** Corrects errors and smooths rough phrasing without restructuring or adding new ideas. Your original tone and meaning stay intact. +**Behavior:** Corrects errors and smooths rough phrasing without restructuring or adding new ideas. Your original tone and meaning stay intact. A Replace button on the result writes the refined text straight back into the app you were using, replacing your selection; turn on auto-replace in Settings to skip the button. Follow-up tweaks in the same chat, like asking for a longer or more formal version, keep the Replace button too. **Composable:** `/refine` works with attached images or `/screen`. Vision OCR extracts the text first, then refines it. diff --git a/docs/configurations.md b/docs/configurations.md index cd43becd..cf69ae23 100644 --- a/docs/configurations.md +++ b/docs/configurations.md @@ -62,6 +62,14 @@ max_display_lines = 4 max_display_chars = 300 max_context_length = 4096 +[behavior] +# Write /rewrite and /refine results straight back into the source app, +# replacing your selection, without clicking the in-chat Replace button. +auto_replace = false +# Dismiss the Thuki overlay after a /rewrite or /refine result is replaced +# back into the source app (manual Replace click or auto-replace). +auto_close = false + [search] # URLs of the local sandbox services. Match the bindings in # `sandbox/docker-compose.yml`. Override only if you run SearXNG or the @@ -167,6 +175,15 @@ Controls how text you select in another app (and bring to Thuki) appears as a qu | `max_display_chars` | `300` | Yes | — | `[1, 10000]` | How many characters of the quoted text are shown as a preview in the input bar. Same idea as `max_display_lines`: the full text is still sent to the AI. Raise for a longer preview; lower to keep the bar compact. | | `max_context_length` | `4096` | Yes | — | `[1, 65536]` | How many characters of the quoted text are actually sent to the AI. Anything past this is cut off. Raise if you quote long passages and want the AI to see all of it; lower if your model has a small context window or you want to save tokens on big selections. | +### `[behavior]` + +Controls what happens to a `/rewrite` or `/refine` result: whether Thuki writes it straight back into the app you were using, or waits for you to send it back yourself. + +| Constant | Default | Tunable? | Why not tunable | Bounds | Description | +| :------------- | :------ | :------- | :-------------- | :----- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `auto_replace` | `false` | Yes | — | — | When on, a `/rewrite` or `/refine` result is written straight back into the source app, replacing your highlighted text, the moment the rewrite is ready, with no extra click. When off, the rewrite appears in Thuki and you press the Replace button to send it back. The Replace button is available either way. | +| `auto_close` | `false` | Yes | — | — | When on, the Thuki overlay closes itself right after a `/rewrite` or `/refine` result is replaced back into the source app, whether the replace happened automatically (`auto_replace`) or from a manual Replace click. Only closes on a successful replace. Independent of `auto_replace`. Turn on for a one-shot rewrite-and-dismiss flow; leave off to keep Thuki open and replace repeatedly. | + ### `[search]` Settings for the `/search` command, which lets the AI search the web and read pages to answer your question. Covers where Thuki's local search and page-reader services live, how hard it should try to find good results, and how long to wait at each step. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7ae9e9e3..25ab4079 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4279,6 +4279,7 @@ version = "0.13.1" dependencies = [ "async-trait", "base64 0.22.1", + "block2", "core-foundation 0.10.1", "core-graphics", "dirs", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8f888aab..5e3e9ea2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -49,6 +49,7 @@ tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2. core-graphics = "0.25" core-foundation = "0.10" objc2 = "0.6" +block2 = "0.6" objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSRunningApplication"] } objc2-foundation = { version = "0.3", features = ["NSString", "NSURL", "NSArray", "NSDictionary"] } objc2-vision = { version = "0.3", features = ["VNRecognizeTextRequest", "VNRequestHandler", "VNTypes", "VNDefines"] } diff --git a/src-tauri/src/config/defaults.rs b/src-tauri/src/config/defaults.rs index 953c0849..898483c6 100644 --- a/src-tauri/src/config/defaults.rs +++ b/src-tauri/src/config/defaults.rs @@ -229,6 +229,26 @@ pub const BOUNDS_TIMEOUT_S: (u64, u64) = (1, 300); /// by default. pub const DEFAULT_DEBUG_TRACE_ENABLED: bool = false; +/// Whether `/rewrite` and `/refine` results are written straight back into the +/// source app (replacing the selection) the moment the model finishes, +/// without the user clicking the in-chat Replace button. +/// +/// Off by default: auto-replace mutates text in another app, so the +/// conservative default is to require an explicit click. When on, the Replace +/// button still renders as a manual re-trigger. Toggleable from the Settings +/// panel (Behavior tab). +pub const DEFAULT_AUTO_REPLACE: bool = false; + +/// When `true`, the Thuki overlay dismisses itself immediately after a +/// `/rewrite` or `/refine` result is replaced back into the source app, whether +/// the replace was automatic (see [`DEFAULT_AUTO_REPLACE`]) or a manual Replace +/// click. Only closes on a *successful* replace; a skipped write (no target / +/// secure field) leaves the overlay open. +/// +/// Off by default. Independent of auto-replace: usable with either trigger. +/// Toggleable from the Settings panel (Behavior tab). +pub const DEFAULT_AUTO_CLOSE: bool = false; + // Ollama API baked-in limits: not exposed in config.toml because they bound // attacker-controlled data (response bodies from the local Ollama daemon) and // keep the UI responsive when the daemon is hung. Changing either timeout @@ -296,6 +316,9 @@ pub const ALLOWED_FIELDS: &[(&str, &str)] = &[ ("quote", "max_display_lines"), ("quote", "max_display_chars"), ("quote", "max_context_length"), + // [behavior] + ("behavior", "auto_replace"), + ("behavior", "auto_close"), // [search] ("search", "searxng_url"), ("search", "reader_url"), @@ -323,6 +346,7 @@ pub const ALLOWED_SECTIONS: &[&str] = &[ "prompt", "window", "quote", + "behavior", "search", "debug", "updater", diff --git a/src-tauri/src/config/loader.rs b/src-tauri/src/config/loader.rs index 50023794..f13ab13a 100644 --- a/src-tauri/src/config/loader.rs +++ b/src-tauri/src/config/loader.rs @@ -307,6 +307,8 @@ pub(crate) fn resolve(config: &mut AppConfig) { config.search.reader_batch_timeout_s = corrected; } + // Behavior section: boolean flag has no resolution step (any value is valid). + // Debug section: boolean flag has no resolution step (any value is valid). // Updater section. diff --git a/src-tauri/src/config/schema.rs b/src-tauri/src/config/schema.rs index 86de4101..96963acc 100644 --- a/src-tauri/src/config/schema.rs +++ b/src-tauri/src/config/schema.rs @@ -14,17 +14,17 @@ use serde::{Deserialize, Serialize}; use super::defaults::{ - DEFAULT_DEBUG_TRACE_ENABLED, DEFAULT_JUDGE_TIMEOUT_S, DEFAULT_KEEP_WARM_INACTIVITY_MINUTES, - DEFAULT_MAX_CHAT_HEIGHT, DEFAULT_MAX_IMAGES, DEFAULT_MAX_ITERATIONS, DEFAULT_NUM_CTX, - DEFAULT_OLLAMA_URL, DEFAULT_OVERLAY_WIDTH, DEFAULT_PIPELINE_WALL_CLOCK_BUDGET_S, - DEFAULT_QUOTE_MAX_CONTEXT_LENGTH, DEFAULT_QUOTE_MAX_DISPLAY_CHARS, - DEFAULT_QUOTE_MAX_DISPLAY_LINES, DEFAULT_READER_BATCH_TIMEOUT_S, - DEFAULT_READER_PER_URL_TIMEOUT_S, DEFAULT_READER_URL, DEFAULT_ROUTER_TIMEOUT_S, - DEFAULT_SEARCH_TIMEOUT_S, DEFAULT_SEARXNG_MAX_RESULTS, DEFAULT_SEARXNG_URL, - DEFAULT_SYSTEM_CUSTOMIZED, DEFAULT_SYSTEM_PROMPT_BASE, DEFAULT_TEXT_BASE_PX, - DEFAULT_TEXT_FONT_WEIGHT, DEFAULT_TEXT_LETTER_SPACING_PX, DEFAULT_TEXT_LINE_HEIGHT, - DEFAULT_TOP_K_URLS, DEFAULT_UPDATER_AUTO_CHECK, DEFAULT_UPDATER_CHECK_INTERVAL_HOURS, - DEFAULT_UPDATER_MANIFEST_URL, + DEFAULT_AUTO_CLOSE, DEFAULT_AUTO_REPLACE, DEFAULT_DEBUG_TRACE_ENABLED, DEFAULT_JUDGE_TIMEOUT_S, + DEFAULT_KEEP_WARM_INACTIVITY_MINUTES, DEFAULT_MAX_CHAT_HEIGHT, DEFAULT_MAX_IMAGES, + DEFAULT_MAX_ITERATIONS, DEFAULT_NUM_CTX, DEFAULT_OLLAMA_URL, DEFAULT_OVERLAY_WIDTH, + DEFAULT_PIPELINE_WALL_CLOCK_BUDGET_S, DEFAULT_QUOTE_MAX_CONTEXT_LENGTH, + DEFAULT_QUOTE_MAX_DISPLAY_CHARS, DEFAULT_QUOTE_MAX_DISPLAY_LINES, + DEFAULT_READER_BATCH_TIMEOUT_S, DEFAULT_READER_PER_URL_TIMEOUT_S, DEFAULT_READER_URL, + DEFAULT_ROUTER_TIMEOUT_S, DEFAULT_SEARCH_TIMEOUT_S, DEFAULT_SEARXNG_MAX_RESULTS, + DEFAULT_SEARXNG_URL, DEFAULT_SYSTEM_CUSTOMIZED, DEFAULT_SYSTEM_PROMPT_BASE, + DEFAULT_TEXT_BASE_PX, DEFAULT_TEXT_FONT_WEIGHT, DEFAULT_TEXT_LETTER_SPACING_PX, + DEFAULT_TEXT_LINE_HEIGHT, DEFAULT_TOP_K_URLS, DEFAULT_UPDATER_AUTO_CHECK, + DEFAULT_UPDATER_CHECK_INTERVAL_HOURS, DEFAULT_UPDATER_MANIFEST_URL, }; /// Static, user-tunable inference daemon configuration. @@ -172,6 +172,31 @@ impl Default for QuoteSection { } } +/// Selection-replacement behavior for the `/rewrite` and `/refine` commands. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct BehaviorSection { + /// When `true`, a `/rewrite` or `/refine` result is written straight back + /// into the source app (replacing the selection) as soon as the model + /// finishes, with no Replace-button click required. When `false`, the + /// user triggers the write manually via the in-chat Replace button. + pub auto_replace: bool, + /// When `true`, the overlay dismisses itself after a `/rewrite` or + /// `/refine` result is successfully replaced into the source app, whether + /// the replace was automatic (`auto_replace`) or a manual Replace click. + /// Independent of `auto_replace`; only closes on a successful replace. + pub auto_close: bool, +} + +impl Default for BehaviorSection { + fn default() -> Self { + Self { + auto_replace: DEFAULT_AUTO_REPLACE, + auto_close: DEFAULT_AUTO_CLOSE, + } + } +} + /// Search pipeline and service configuration. /// /// Service URLs control where the SearXNG and reader sidecar processes live. @@ -306,6 +331,7 @@ pub struct AppConfig { pub prompt: PromptSection, pub window: WindowSection, pub quote: QuoteSection, + pub behavior: BehaviorSection, pub search: SearchSection, pub debug: DebugSection, #[serde(default)] diff --git a/src-tauri/src/config/tests.rs b/src-tauri/src/config/tests.rs index 5b9cfbf1..34d92e26 100644 --- a/src-tauri/src/config/tests.rs +++ b/src-tauri/src/config/tests.rs @@ -13,22 +13,23 @@ use std::path::PathBuf; use super::defaults::{ - DEFAULT_DEBUG_TRACE_ENABLED, DEFAULT_JUDGE_TIMEOUT_S, DEFAULT_KEEP_WARM_INACTIVITY_MINUTES, - DEFAULT_MAX_CHAT_HEIGHT, DEFAULT_MAX_IMAGES, DEFAULT_MAX_ITERATIONS, DEFAULT_NUM_CTX, - DEFAULT_OLLAMA_URL, DEFAULT_OVERLAY_WIDTH, DEFAULT_QUOTE_MAX_CONTEXT_LENGTH, - DEFAULT_QUOTE_MAX_DISPLAY_CHARS, DEFAULT_QUOTE_MAX_DISPLAY_LINES, - DEFAULT_READER_BATCH_TIMEOUT_S, DEFAULT_READER_PER_URL_TIMEOUT_S, DEFAULT_READER_URL, - DEFAULT_ROUTER_TIMEOUT_S, DEFAULT_SEARCH_TIMEOUT_S, DEFAULT_SEARXNG_MAX_RESULTS, - DEFAULT_SEARXNG_URL, DEFAULT_SYSTEM_PROMPT_BASE, DEFAULT_TEXT_BASE_PX, - DEFAULT_TEXT_FONT_WEIGHT, DEFAULT_TEXT_LETTER_SPACING_PX, DEFAULT_TEXT_LINE_HEIGHT, - DEFAULT_TOP_K_URLS, DEFAULT_UPDATER_CHECK_INTERVAL_HOURS, DEFAULT_UPDATER_MANIFEST_URL, + DEFAULT_AUTO_CLOSE, DEFAULT_AUTO_REPLACE, DEFAULT_DEBUG_TRACE_ENABLED, DEFAULT_JUDGE_TIMEOUT_S, + DEFAULT_KEEP_WARM_INACTIVITY_MINUTES, DEFAULT_MAX_CHAT_HEIGHT, DEFAULT_MAX_IMAGES, + DEFAULT_MAX_ITERATIONS, DEFAULT_NUM_CTX, DEFAULT_OLLAMA_URL, DEFAULT_OVERLAY_WIDTH, + DEFAULT_QUOTE_MAX_CONTEXT_LENGTH, DEFAULT_QUOTE_MAX_DISPLAY_CHARS, + DEFAULT_QUOTE_MAX_DISPLAY_LINES, DEFAULT_READER_BATCH_TIMEOUT_S, + DEFAULT_READER_PER_URL_TIMEOUT_S, DEFAULT_READER_URL, DEFAULT_ROUTER_TIMEOUT_S, + DEFAULT_SEARCH_TIMEOUT_S, DEFAULT_SEARXNG_MAX_RESULTS, DEFAULT_SEARXNG_URL, + DEFAULT_SYSTEM_PROMPT_BASE, DEFAULT_TEXT_BASE_PX, DEFAULT_TEXT_FONT_WEIGHT, + DEFAULT_TEXT_LETTER_SPACING_PX, DEFAULT_TEXT_LINE_HEIGHT, DEFAULT_TOP_K_URLS, + DEFAULT_UPDATER_CHECK_INTERVAL_HOURS, DEFAULT_UPDATER_MANIFEST_URL, SLASH_COMMAND_PROMPT_APPENDIX, }; use super::error::ConfigError; use super::loader::{compose_system_prompt, load_from_path}; use super::schema::{ - AppConfig, DebugSection, InferenceSection, PromptSection, QuoteSection, SearchSection, - UpdaterSection, WindowSection, + AppConfig, BehaviorSection, DebugSection, InferenceSection, PromptSection, QuoteSection, + SearchSection, UpdaterSection, WindowSection, }; use super::writer::atomic_write; @@ -1199,6 +1200,60 @@ fn config_error_io_error_serializes_io_source_as_display_string() { assert_eq!(json["source"], "denied here"); } +// ── behavior section ──────────────────────────────────────────────────────── + +#[test] +fn behavior_section_default_matches_compiled_defaults() { + let b = BehaviorSection::default(); + assert_eq!(b.auto_replace, DEFAULT_AUTO_REPLACE); + assert_eq!(b.auto_close, DEFAULT_AUTO_CLOSE); +} + +#[test] +fn app_config_default_includes_behavior_section_with_compiled_defaults() { + let c = AppConfig::default(); + assert_eq!(c.behavior.auto_replace, DEFAULT_AUTO_REPLACE); + assert_eq!(c.behavior.auto_close, DEFAULT_AUTO_CLOSE); +} + +#[test] +fn behavior_auto_replace_round_trips_through_load() { + let dir = fresh_temp_dir(); + let path = config_path_in(&dir); + std::fs::write(&path, "[behavior]\nauto_replace = true\n").unwrap(); + let loaded = load_from_path(&path).unwrap(); + assert!(loaded.behavior.auto_replace); +} + +#[test] +fn behavior_auto_close_round_trips_through_load() { + let dir = fresh_temp_dir(); + let path = config_path_in(&dir); + std::fs::write(&path, "[behavior]\nauto_close = true\n").unwrap(); + let loaded = load_from_path(&path).unwrap(); + assert!(loaded.behavior.auto_close); +} + +#[test] +fn toml_without_behavior_section_deserializes_to_defaults() { + let dir = fresh_temp_dir(); + let path = config_path_in(&dir); + std::fs::write( + &path, + "[inference]\nollama_url = \"http://127.0.0.1:11434\"\n", + ) + .unwrap(); + let loaded = load_from_path(&path).unwrap(); + assert_eq!( + loaded.behavior.auto_replace, DEFAULT_AUTO_REPLACE, + "missing [behavior] section must deserialize to defaults via #[serde(default)]" + ); + assert_eq!( + loaded.behavior.auto_close, DEFAULT_AUTO_CLOSE, + "missing [behavior] section must deserialize to defaults via #[serde(default)]" + ); +} + // ── debug section ─────────────────────────────────────────────────────────── #[test] diff --git a/src-tauri/src/context.rs b/src-tauri/src/context.rs index 49207618..2b77b621 100644 --- a/src-tauri/src/context.rs +++ b/src-tauri/src/context.rs @@ -166,14 +166,21 @@ mod macos { // SAFETY: Accessibility permission is checked before the activator starts. unsafe { simulate_cmd_c() }; // Poll the pasteboard with exponential backoff instead of a fixed sleep. - // Fast machines return in ~10ms; slower machines get up to ~150ms total. + // Fast machines return in ~10ms. The first synthetic Cmd+C intermittently + // fails to land in Electron text inputs (Discord's editor, Slack), so if + // nothing has changed by the midpoint we re-issue it once; a Cmd+C with + // no selection is a harmless no-op. Worst case (no selection) ~315ms. let mut after = before.clone(); - for delay_ms in [10, 20, 40, 80] { + for (i, delay_ms) in [10u64, 20, 40, 65, 80, 100].into_iter().enumerate() { std::thread::sleep(std::time::Duration::from_millis(delay_ms)); after = clipboard_text(); if after != before { break; } + if i == 2 { + // SAFETY: same precondition as the first call above. + unsafe { simulate_cmd_c() }; + } } // Always restore the original clipboard regardless of outcome. if after != before { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e95bb34a..95ed0f2f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -37,6 +37,7 @@ mod activator; mod cg_displays; pub mod context; pub mod permissions; +pub mod replace; use std::sync::{ atomic::{AtomicBool, Ordering}, @@ -1578,6 +1579,15 @@ pub fn run() { // ── Persistent HTTP client ──────────────────────────────── app.manage(reqwest::Client::new()); + + // ── Replace-target tracking ─────────────────────────────── + // Tracks the last-active non-Thuki app via an NSWorkspace + // observer; this is the app the /rewrite & /refine Replace action + // writes into. Managed so `replace_selection` can read the target, + // and the observer is installed on the main thread here at setup. + let last_active_app = crate::replace::LastActiveAppState::default(); + app.manage(last_active_app.clone()); + crate::replace::start_activation_tracking(last_active_app); let warmup_handle = app.handle().clone(); app.manage(warmup::WarmupState::with_on_loaded(Arc::new( move |model| { @@ -1834,6 +1844,8 @@ pub fn run() { ocr::extract_text_command, #[cfg(not(coverage))] export::prompt_and_save_chat_export, + #[cfg(not(coverage))] + replace::replace_selection, notify_overlay_hidden, set_overlay_minimized, notify_frontend_ready, diff --git a/src-tauri/src/replace.rs b/src-tauri/src/replace.rs new file mode 100644 index 00000000..6ef06e66 --- /dev/null +++ b/src-tauri/src/replace.rs @@ -0,0 +1,415 @@ +//! Writes rewritten text back into the user's source app for `/rewrite` and +//! `/refine`. +//! +//! Two parts: +//! +//! 1. **Target tracking.** An `NSWorkspace` activation observer records the PID +//! of the last application the user activated that is *not* Thuki. Because +//! Thuki's overlay is a non-activating panel, switching into it never fires +//! an activation, so this reliably holds "the app you were last really in" — +//! whether that is the app you summoned Thuki from (in-place) or a different +//! app you clicked into afterwards (retarget). +//! +//! 2. **Writing (synthetic paste).** The target app is activated and, only once +//! it is confirmed frontmost, the clipboard is saved (every type, so an +//! image or file copy survives), the rewrite written to it (tagged transient +//! so clipboard-history managers skip it), and a synthetic Cmd+V posted +//! directly to the target process with `CGEventPostToPid`. Posting to the +//! process rather than the system key window means the paste reaches the +//! source app even though Thuki's nonactivating panel still holds the key +//! window, so the overlay stays open instead of having to be dismissed +//! first. The clipboard is then restored. If the target never becomes +//! frontmost the write is skipped entirely and the clipboard left untouched. +//! Paste is used rather than an Accessibility write +//! because Cmd+V reliably *replaces* the selection. An AX selected-text +//! write does not: the selection range collapses when the app loses focus, +//! so the AX write inserts at the caret instead. Secure input (a focused +//! password field) suppresses the write entirely. + +use std::sync::{Arc, Mutex}; + +use serde::Serialize; + +/// Managed state: PID of the last non-Thuki application to activate, kept +/// current by the `NSWorkspace` activation observer. This is the app a +/// `/rewrite` or `/refine` Replace writes into. +#[derive(Default, Clone)] +pub struct LastActiveAppState(pub Arc>>); + +impl LastActiveAppState { + /// The current target app PID, if one has been observed. + pub fn get(&self) -> Option { + *self.0.lock().unwrap() + } + + /// Records `pid` as the latest target app. + pub fn set(&self, pid: i32) { + *self.0.lock().unwrap() = Some(pid); + } +} + +/// Outcome of a replace attempt, surfaced to the frontend. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ReplaceOutcome { + /// Text was pasted into the target app. + Replaced, + /// No-op: empty text, Accessibility not granted, no target app observed, + /// the target app could not be brought to the foreground, or secure input + /// was active. + Skipped, +} + +/// Whether an activation of `activated_pid` should be recorded as the write +/// target: any app other than Thuki itself (`own_pid`). Thuki's own +/// activations are ignored so summoning the overlay never overwrites the +/// remembered target. +pub fn should_record_activation(activated_pid: i32, own_pid: i32) -> bool { + activated_pid != own_pid +} + +/// Starts tracking the last-active external app. Seeds the state with the +/// current frontmost app, then installs the `NSWorkspace` activation observer. +/// Must be called once, on the main thread, during app setup. No-op off macOS. +#[cfg_attr(coverage_nightly, coverage(off))] +pub fn start_activation_tracking(state: LastActiveAppState) { + #[cfg(target_os = "macos")] + { + let own = std::process::id() as i32; + if let Some(pid) = macos::frontmost_app_pid() { + if should_record_activation(pid, own) { + state.set(pid); + } + } + macos::install_activation_observer(state, own); + } + #[cfg(not(target_os = "macos"))] + { + let _ = state; + } +} + +/// Pastes `text` into the last-active app, replacing its selection. The paste +/// is posted directly to the target process, so the overlay does not need to be +/// dismissed first. Returns [`ReplaceOutcome::Skipped`] without side effects +/// when there is nothing safe to write into. +/// +/// Runs the macOS clipboard / event work on a blocking pool thread: the paste +/// path sleeps while the target activates, so it must not run on the main +/// thread. +#[tauri::command] +#[cfg_attr(coverage_nightly, coverage(off))] +pub async fn replace_selection( + text: String, + last_active: tauri::State<'_, LastActiveAppState>, +) -> Result { + if text.is_empty() { + return Ok(ReplaceOutcome::Skipped); + } + + #[cfg(target_os = "macos")] + { + if !crate::permissions::is_accessibility_granted() { + return Ok(ReplaceOutcome::Skipped); + } + let Some(pid) = last_active.get() else { + return Ok(ReplaceOutcome::Skipped); + }; + let outcome = tokio::task::spawn_blocking(move || macos::paste_into(pid, &text)) + .await + .unwrap_or(ReplaceOutcome::Skipped); + Ok(outcome) + } + + #[cfg(not(target_os = "macos"))] + { + let _ = &last_active; + Ok(ReplaceOutcome::Skipped) + } +} + +// ─── macOS clipboard + event implementation ────────────────────────────────── + +#[cfg(target_os = "macos")] +#[cfg_attr(coverage_nightly, coverage(off))] +mod macos { + use std::ffi::c_void; + + use block2::RcBlock; + use core_foundation::base::CFTypeRef; + use objc2::rc::autoreleasepool; + use objc2::runtime::AnyObject; + use objc2::{class, msg_send}; + use objc2_foundation::{ns_string, NSArray, NSData, NSString}; + + use super::{should_record_activation, LastActiveAppState, ReplaceOutcome}; + + /// macOS virtual keycode for 'v'. + const KEY_V: u16 = 0x09; + /// CGEventFlags::kCGEventFlagMaskCommand. + const K_CG_EVENT_FLAG_MASK_COMMAND: u64 = 0x0010_0000; + /// NSApplicationActivationOptions::NSApplicationActivateIgnoringOtherApps. + const NS_ACTIVATE_IGNORING_OTHER_APPS: usize = 1 << 1; + /// Milliseconds to wait after the synthetic paste before restoring the + /// clipboard, giving the target app time to read the pasteboard first. + const PASTE_SETTLE_MS: u64 = 200; + + // CoreGraphics is already linked by activator.rs. + extern "C" { + fn CFRelease(cf: CFTypeRef); + fn CGEventCreateKeyboardEvent( + source: *const c_void, + virtual_key: u16, + key_down: bool, + ) -> CFTypeRef; + fn CGEventSetFlags(event: CFTypeRef, flags: u64); + // Posts an event directly to a target process, bypassing the key-window + // routing that `CGEventPost` uses. This delivers the paste to the + // source app even though Thuki's panel still holds the system key + // window, so the overlay does not need to be dismissed first. + fn CGEventPostToPid(pid: i32, event: CFTypeRef); + } + + #[link(name = "Carbon", kind = "framework")] + extern "C" { + fn IsSecureEventInputEnabled() -> u8; + } + + /// UTI for plain UTF-8 text on the pasteboard (`NSPasteboardTypeString`). + fn plain_text_type() -> &'static NSString { + ns_string!("public.utf8-plain-text") + } + + /// Returns the PID of the frontmost application, or `None`. + pub fn frontmost_app_pid() -> Option { + autoreleasepool(|_| unsafe { + let workspace: *mut AnyObject = msg_send![class!(NSWorkspace), sharedWorkspace]; + if workspace.is_null() { + return None; + } + let app: *mut AnyObject = msg_send![workspace, frontmostApplication]; + if app.is_null() { + return None; + } + let pid: i32 = msg_send![app, processIdentifier]; + Some(pid) + }) + } + + /// Installs an `NSWorkspace` observer that records every non-Thuki app + /// activation into `state`. The observer block is leaked deliberately: it + /// lives for the entire process, so there is never a point at which it + /// should be torn down. + pub fn install_activation_observer(state: LastActiveAppState, own: i32) { + autoreleasepool(|_| unsafe { + let workspace: *mut AnyObject = msg_send![class!(NSWorkspace), sharedWorkspace]; + let center: *mut AnyObject = msg_send![workspace, notificationCenter]; + let name = ns_string!("NSWorkspaceDidActivateApplicationNotification"); + let nil: *mut AnyObject = std::ptr::null_mut(); + + let block = RcBlock::new(move |notification: *mut AnyObject| { + if notification.is_null() { + return; + } + let user_info: *mut AnyObject = msg_send![notification, userInfo]; + if user_info.is_null() { + return; + } + let app: *mut AnyObject = + msg_send![user_info, objectForKey: ns_string!("NSWorkspaceApplicationKey")]; + if app.is_null() { + return; + } + let pid: i32 = msg_send![app, processIdentifier]; + if should_record_activation(pid, own) { + state.set(pid); + } + }); + + let _token: *mut AnyObject = msg_send![ + center, + addObserverForName: name, + object: nil, + queue: nil, + usingBlock: &*block, + ]; + std::mem::forget(block); + }); + } + + /// Whether secure input is active (a focused password field, or iTerm + /// "Secure Keyboard Entry"). + unsafe fn is_secure_input() -> bool { + IsSecureEventInputEnabled() != 0 + } + + /// Brings the app with `pid` to the foreground and waits, with bounded + /// backoff, for the activation to take effect. Returns whether `pid` became + /// frontmost: only then is its focused text field first responder and able + /// to handle the synthetic Cmd+V as a paste over the selection. A `false` + /// return means the paste must not be posted (the app quit, is blocked by a + /// Spaces switch, or the PID no longer maps to a foregroundable app). + unsafe fn activate_and_settle(pid: i32) -> bool { + let app: *mut AnyObject = + msg_send![class!(NSRunningApplication), runningApplicationWithProcessIdentifier: pid]; + if !app.is_null() { + let _: bool = msg_send![app, activateWithOptions: NS_ACTIVATE_IGNORING_OTHER_APPS]; + } + for delay_ms in [0u64, 20, 30, 50, 80] { + if delay_ms != 0 { + std::thread::sleep(std::time::Duration::from_millis(delay_ms)); + } + if frontmost_app_pid() == Some(pid) { + return true; + } + } + false + } + + /// Posts a synthetic Cmd+V directly to the process `pid`. Targeting the + /// process rather than the system key window is what lets the paste land in + /// the source app while Thuki's panel remains key and visible. + unsafe fn post_cmd_v_to_pid(pid: i32) { + let down = CGEventCreateKeyboardEvent(std::ptr::null(), KEY_V, true); + if !down.is_null() { + CGEventSetFlags(down, K_CG_EVENT_FLAG_MASK_COMMAND); + CGEventPostToPid(pid, down); + CFRelease(down); + } + let up = CGEventCreateKeyboardEvent(std::ptr::null(), KEY_V, false); + if !up.is_null() { + CGEventSetFlags(up, K_CG_EVENT_FLAG_MASK_COMMAND); + CGEventPostToPid(pid, up); + CFRelease(up); + } + } + + /// Snapshots every type currently on the general pasteboard as `(UTI, data)` + /// pairs so the user's clipboard can be restored after the synthetic paste + /// in full — text, image, file reference, RTF — not just its plain-text + /// representation. The returned pointers are autoreleased and stay valid for + /// the lifetime of the enclosing autorelease pool (the whole `paste_into` + /// call), which is where the matching [`restore_pasteboard`] runs. + unsafe fn snapshot_pasteboard() -> Vec<(*mut NSString, *mut NSData)> { + let pb: *mut AnyObject = msg_send![class!(NSPasteboard), generalPasteboard]; + let types: *mut AnyObject = msg_send![pb, types]; + if types.is_null() { + return Vec::new(); + } + let count: usize = msg_send![types, count]; + let mut saved = Vec::with_capacity(count); + for i in 0..count { + let ty: *mut NSString = msg_send![types, objectAtIndex: i]; + if ty.is_null() { + continue; + } + let data: *mut NSData = msg_send![pb, dataForType: ty]; + if !data.is_null() { + saved.push((ty, data)); + } + } + saved + } + + /// Writes `text` to the general pasteboard, tagged transient + + /// auto-generated so clipboard-history managers (Maccy, Alfred, Raycast, + /// 1Password, ...) skip recording it. + unsafe fn write_pasteboard_transient(text: &str) { + let pb: *mut AnyObject = msg_send![class!(NSPasteboard), generalPasteboard]; + let plain = plain_text_type(); + let transient = ns_string!("org.nspasteboard.TransientType"); + let autogen = ns_string!("org.nspasteboard.AutoGeneratedType"); + let types = NSArray::from_slice(&[plain, transient, autogen]); + let nil: *mut AnyObject = std::ptr::null_mut(); + let _: isize = msg_send![pb, clearContents]; + let _: isize = msg_send![pb, declareTypes: &*types, owner: nil]; + let value = NSString::from_str(text); + let _: bool = msg_send![pb, setString: &*value, forType: plain]; + let empty: *mut AnyObject = msg_send![class!(NSData), data]; + let _: bool = msg_send![pb, setData: empty, forType: transient]; + let _: bool = msg_send![pb, setData: empty, forType: autogen]; + } + + /// Restores the general pasteboard from a [`snapshot_pasteboard`] snapshot, + /// rewriting every saved type. Clears the pasteboard when the snapshot was + /// empty (nothing was on it to begin with). + unsafe fn restore_pasteboard(saved: Vec<(*mut NSString, *mut NSData)>) { + let pb: *mut AnyObject = msg_send![class!(NSPasteboard), generalPasteboard]; + let _: isize = msg_send![pb, clearContents]; + if saved.is_empty() { + return; + } + let type_refs: Vec<&NSString> = saved.iter().map(|(ty, _)| &**ty).collect(); + let types = NSArray::from_slice(&type_refs); + let nil: *mut AnyObject = std::ptr::null_mut(); + let _: isize = msg_send![pb, declareTypes: &*types, owner: nil]; + for (ty, data) in saved { + let _: bool = msg_send![pb, setData: data, forType: ty]; + } + } + + /// Pastes `text` into the app identified by `pid`, replacing its selection. + /// Refuses to write while secure input is active. + pub fn paste_into(pid: i32, text: &str) -> ReplaceOutcome { + autoreleasepool(|_| unsafe { + if is_secure_input() { + return ReplaceOutcome::Skipped; + } + // Activate the target and confirm it actually became frontmost + // before doing anything else. If it never does, posting Cmd+V would + // land nowhere or in the wrong app, so skip without touching the + // clipboard and report it honestly so the UI does not show a false + // success (and auto-close does not dismiss over a no-op). + if !activate_and_settle(pid) { + return ReplaceOutcome::Skipped; + } + // Re-check after the activation delay: secure input is a moving + // target (a password field may have taken focus meanwhile), and the + // paste must never fire into one. + if is_secure_input() { + return ReplaceOutcome::Skipped; + } + let saved = snapshot_pasteboard(); + write_pasteboard_transient(text); + post_cmd_v_to_pid(pid); + std::thread::sleep(std::time::Duration::from_millis(PASTE_SETTLE_MS)); + restore_pasteboard(saved); + ReplaceOutcome::Replaced + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn records_activations_of_other_apps() { + assert!(should_record_activation(412, 7)); + } + + #[test] + fn ignores_thukis_own_activations() { + assert!(!should_record_activation(7, 7)); + } + + #[test] + fn last_active_state_round_trips() { + let state = LastActiveAppState::default(); + assert_eq!(state.get(), None); + state.set(412); + assert_eq!(state.get(), Some(412)); + } + + #[test] + fn outcome_serializes_to_snake_case() { + assert_eq!( + serde_json::to_string(&ReplaceOutcome::Replaced).unwrap(), + "\"replaced\"" + ); + assert_eq!( + serde_json::to_string(&ReplaceOutcome::Skipped).unwrap(), + "\"skipped\"" + ); + } +} diff --git a/src-tauri/src/settings_commands.rs b/src-tauri/src/settings_commands.rs index df787637..6c219fd8 100644 --- a/src-tauri/src/settings_commands.rs +++ b/src-tauri/src/settings_commands.rs @@ -41,7 +41,7 @@ use parking_lot::RwLock; use serde::Serialize; use serde_json::Value as JsonValue; use tauri::{AppHandle, Emitter, Manager, State}; -use toml_edit::{value as toml_value, Array, DocumentMut, Item, Value as TomlValue}; +use toml_edit::{value as toml_value, Array, DocumentMut, Item, Table, Value as TomlValue}; use crate::config::{ self, @@ -176,6 +176,16 @@ pub(crate) fn write_field_to_disk( } let mut doc = read_document(path)?; + // An allowed section can be absent from an older on-disk file that was + // seeded before the section was added to the schema: the loader fills it + // from defaults in memory but never rewrites the file. Materialize an empty + // table so the field can be patched in. `section` is already validated + // against `ALLOWED_SECTIONS` above, so this can only create a real schema + // section, never an arbitrary one. Without this, writing any field in a + // not-yet-persisted section fails with `UnknownSection`. + if doc.get(section).and_then(Item::as_table).is_none() { + doc.insert(section, Item::Table(Table::new())); + } patch_document(&mut doc, section, key, value)?; // When the user saves the system prompt, mark it as explicitly customized // so the upgrade-migration path in the loader (empty + !customized → diff --git a/src-tauri/src/settings_commands/tests.rs b/src-tauri/src/settings_commands/tests.rs index 72464901..03a88e2e 100644 --- a/src-tauri/src/settings_commands/tests.rs +++ b/src-tauri/src/settings_commands/tests.rs @@ -61,16 +61,16 @@ fn parse_sample() -> DocumentMut { #[test] fn allowed_fields_count_matches_schema_field_count() { // Hand-counted from `AppConfig`: inference(3) + prompt(1) + window(7) + quote(3) - // + search(11) + debug(1) + updater(3) = 29 tunable fields. The active model slug - // lives in the SQLite app_config table via ActiveModelState, not in TOML. The - // collapsed bar height and hide-commit delay are baked into the frontend (see - // `WindowSection` doc) because they have no perceptible effect across + // + behavior(2) + search(11) + debug(1) + updater(3) = 31 tunable fields. The + // active model slug lives in the SQLite app_config table via ActiveModelState, + // not in TOML. The collapsed bar height and hide-commit delay are baked into the + // frontend (see `WindowSection` doc) because they have no perceptible effect across // their usable range. `prompt.system_customized` is an internal migration flag // co-written by set_config_field when prompt.system is saved; it is not // directly user-tunable and is intentionally absent from ALLOWED_FIELDS. // If this assertion fails, the schema has drifted from the allowlist and // someone added a field without extending ALLOWED_FIELDS. - assert_eq!(ALLOWED_FIELDS.len(), 29); + assert_eq!(ALLOWED_FIELDS.len(), 31); } #[test] @@ -82,6 +82,7 @@ fn allowed_sections_match_app_config_top_level_keys() { "prompt", "window", "quote", + "behavior", "search", "debug", "updater" @@ -642,6 +643,25 @@ fn write_field_to_disk_persists_and_returns_resolved_config() { assert!(on_disk.contains("http://10.0.0.1:11434")); } +#[test] +fn write_field_to_disk_creates_section_absent_from_older_file() { + // Regression: a config.toml seeded before the [behavior] section was added + // to the schema has no [behavior] table (SAMPLE_CONFIG reproduces this + // older-file shape). Toggling behavior.auto_replace must create the section + // rather than fail with UnknownSection; otherwise the setting can never be + // turned on for any user whose config predates the section. + let dir = tempdir(); + let path = dir.join("config.toml"); + std::fs::write(&path, SAMPLE_CONFIG).unwrap(); + + let resolved = write_field_to_disk(&path, "behavior", "auto_replace", json!(true)).unwrap(); + assert!(resolved.behavior.auto_replace); + + let on_disk = std::fs::read_to_string(&path).unwrap(); + assert!(on_disk.contains("[behavior]")); + assert!(on_disk.contains("auto_replace = true")); +} + #[test] fn write_field_to_disk_accepts_search_pipeline_wall_clock_budget() { let dir = tempdir(); diff --git a/src/App.tsx b/src/App.tsx index 7e122f37..906e69f0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -51,6 +51,7 @@ import { MAX_IMAGE_SIZE_BYTES } from './types/image'; import { useConfig } from './contexts/ConfigContext'; import { COMMANDS, + REPLACEABLE_COMMANDS, SCREEN_CAPTURE_PLACEHOLDER, buildPrompt, } from './config/commands'; @@ -59,6 +60,8 @@ import { serializeForClipboard, serializeForFile, } from './lib/exportSerializer'; +import { replaceSelection, shouldAutoReplace } from './utils/replaceSelection'; +import { cleanForRender } from './utils/sanitizeAssistantContent'; import './App.css'; const OVERLAY_VISIBILITY_EVENT = 'thuki://visibility'; @@ -433,9 +436,44 @@ function App() { reset: resetHistory, } = useConversationHistory(); + /** + * Latest value of the auto-replace setting, mirrored into a ref so the + * turn-completion handler can read it without taking `config` (created + * further down the component) as a dependency. + */ + const autoReplaceRef = useRef(false); + + /** + * Mirror of `config.behavior.autoClose`, read by the replace path to decide + * whether to dismiss the overlay after a successful write-back. Same ref + * indirection as `autoReplaceRef` so the handlers avoid a `config` dependency. + */ + const autoCloseRef = useRef(false); + + /** + * Sticky rewrite mode: the trigger of the most recent replaceable command + * (`/rewrite` or `/refine`) in this conversation, or `null`. Plain follow-up + * turns inherit it so refinements ("make it longer") keep the Replace button; + * any other command exits the mode. Mirrors `searchActive`'s lifecycle. A ref, + * not state, because nothing re-renders on change — each message carries its + * own `replaceCommand`, fixed at creation. + */ + const stickyReplaceCommandRef = useRef(null); + + /** + * Mirror of `performReplace`, so the turn-completion handler (defined before + * `performReplace`) can trigger an auto-replace without a forward reference. + */ + const performReplaceRef = useRef<((text: string) => Promise) | null>( + null, + ); + /** * Persist a completed user/assistant turn to SQLite if the conversation - * has been saved. Passed as `onTurnComplete` to `useOllama`. + * has been saved. Passed as `onTurnComplete` to `useOllama`. When + * auto-replace is enabled and the turn was a `/rewrite` or `/refine` over a + * selection, dismiss the overlay and write the result back into the source + * app (the same flow as the manual Replace button). */ const handleTurnComplete = useCallback( async ( @@ -443,6 +481,12 @@ function App() { assistantMsg: Parameters[1], ) => { await persistTurn(userMsg, assistantMsg); + if (shouldAutoReplace(autoReplaceRef.current, assistantMsg, userMsg)) { + // Strip any stray turn-boundary tokens so auto-replace writes back the + // exact bytes the bubble shows and the manual Replace button pastes + // (both go through `cleanForRender`); the in-memory content is raw. + void performReplaceRef.current?.(cleanForRender(assistantMsg.content)); + } }, [persistTurn], ); @@ -557,6 +601,13 @@ function App() { const config = useConfig(); const quote = config.quote; + // Keep the auto-replace ref in sync with the latest config so + // `handleTurnComplete` can read it without a `config` dependency. + useEffect(() => { + autoReplaceRef.current = config.behavior.autoReplace; + autoCloseRef.current = config.behavior.autoClose; + }, [config]); + /** * True when the window is near the screen bottom and should grow upward. * Flips the outer container to `justify-end` so content pins to the bottom. @@ -913,6 +964,7 @@ function App() { setPendingUserMessage(null); setCaptureError(null); setSearchActive(false); + stickyReplaceCommandRef.current = null; void refreshModels(); reset(); @@ -933,6 +985,7 @@ function App() { screenCapturePendingRef.current = false; screenCaptureInputSnapshotRef.current = null; setSearchActive(false); + stickyReplaceCommandRef.current = null; setSelectedContext(null); setPreviewImageUrl(null); setAttachedImages((prev) => { @@ -947,6 +1000,32 @@ function App() { }); }, [cancel]); + /** + * Writes a `/rewrite` or `/refine` result back into the source app, replacing + * the user's selection. The native paste is posted directly to the target + * process, so the overlay can stay open and the user can Replace repeatedly. + * When auto-close is on, the overlay dismisses itself after a *successful* + * write (a skipped write leaves it open). Drives both the manual Replace + * button and the auto-replace path. Resolves to whether the write succeeded + * so the Replace button can show a confirmation tick. + */ + const performReplace = useCallback( + (text: string): Promise => + replaceSelection(text).then((replaced) => { + if (replaced && autoCloseRef.current) { + requestHideOverlay(); + } + return replaced; + }), + [requestHideOverlay], + ); + + // Mirror `performReplace` into a ref so the earlier-defined turn-completion + // handler can trigger auto-replace without a forward reference. + useEffect(() => { + performReplaceRef.current = performReplace; + }, [performReplace]); + /** * Restores the parked conversation. The OS window is snapped back to full * chat size (`durationMs:0`, instant + invisible against the transparent @@ -1540,6 +1619,7 @@ function App() { const loaded = await loadConversation(id); loadMessages(loaded); setSearchActive(false); + stickyReplaceCommandRef.current = null; } catch { // Load failed - current session is preserved intact. } finally { @@ -1570,6 +1650,7 @@ function App() { const loaded = await loadConversation(id); loadMessages(loaded); setSearchActive(false); + stickyReplaceCommandRef.current = null; } catch { // Load failed - save already committed; dismiss panel, keep current view. } finally { @@ -1617,6 +1698,7 @@ function App() { setIsSubmitPending(false); setPendingUserMessage(null); setSearchActive(false); + stickyReplaceCommandRef.current = null; }, [reset, resetHistory]); /** @@ -1828,7 +1910,17 @@ function App() { .filter((img) => img.filePath !== null) .map((img) => img.filePath as string); const images = readyPaths.length > 0 ? readyPaths : undefined; - ask(submitQuery, context, images, think); + // Plain submits inherit sticky rewrite mode so refinements of a + // `/rewrite`/`/refine` result keep their Replace button. + ask( + submitQuery, + context, + images, + think, + undefined, + undefined, + stickyReplaceCommandRef.current ?? undefined, + ); setSelectedContext(null); setQuery(''); for (const img of attachedImages) { @@ -2203,6 +2295,7 @@ function App() { composedPrompt, /* v8 ignore next -- dispatch guard ensures at least one image source; empty path is defensive */ readyPaths.length > 0 ? readyPaths : undefined, + REPLACEABLE_COMMANDS.has(trigger) ? trigger : undefined, ); }, [ @@ -2562,6 +2655,16 @@ function App() { ) return; + // Maintain sticky rewrite mode. A replaceable command (re)starts it; any + // other command exits it; a plain follow-up leaves it intact so its + // refinement inherits the Replace button through `executeSubmit`. Search + // turns have already returned above, so `/search` needs no branch here. + if (utilityTrigger && REPLACEABLE_COMMANDS.has(utilityTrigger)) { + stickyReplaceCommandRef.current = utilityTrigger; + } else if (utilityTrigger || hasScreen || hasThink || hasExtract) { + stickyReplaceCommandRef.current = null; + } + const context = sanitizeContext(selectedContext, quote.maxContextLength); // Unified pre-flight pending-images gate. Every command that needs @@ -2667,6 +2770,8 @@ function App() { undefined, hasThink || undefined, composedPrompt, + undefined, + REPLACEABLE_COMMANDS.has(utilityTrigger) ? utilityTrigger : undefined, ); setSelectedContext(null); setQuery(''); @@ -3225,6 +3330,7 @@ function App() { onNewConversation={handleNewConversation} onHistoryOpen={handleHistoryToggle} onImagePreview={handleChatImagePreview} + onReplace={performReplace} searchStage={searchStage} activeModel={activeModel} onModelPickerToggle={ diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index e3c816a8..19e74491 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -7,7 +7,10 @@ import { } from '@testing-library/react'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import App from '../App'; -import { DEFAULT_CONFIG } from '../contexts/ConfigContext'; +import { + DEFAULT_CONFIG, + ConfigProviderForTest, +} from '../contexts/ConfigContext'; import { invoke, emitTauriEvent, @@ -1114,6 +1117,345 @@ describe('App', () => { ); }); + it('auto-replaces the source selection after /rewrite when the setting is on', async () => { + enableChannelCaptureWithResponses({ + get_model_picker_state: { + active: 'gemma4:e2b', + all: ['gemma4:e2b'], + ollamaReachable: true, + }, + replace_selection: 'replaced', + }); + + render( + + + , + ); + await act(async () => {}); + await showOverlay('draft email text'); + + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: '/rewrite ' } }); + }); + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + + act(() => { + getLastChannel()?.simulateMessage({ + type: 'Token', + data: 'Polished draft', + }); + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + await act(async () => {}); + + // Auto-replace writes straight back to the source app on completion. The + // paste is posted to the target process and fires synchronously, with no + // overlay dismiss to wait on. + await act(async () => {}); + + // The completed /rewrite over a selection writes straight back to the + // source app because auto-replace is enabled. + expect(invoke).toHaveBeenCalledWith('replace_selection', { + text: 'Polished draft', + }); + }); + + it('auto-replace strips stray turn-boundary tokens before writing back', async () => { + enableChannelCaptureWithResponses({ + get_model_picker_state: { + active: 'gemma4:e2b', + all: ['gemma4:e2b'], + ollamaReachable: true, + }, + replace_selection: 'replaced', + }); + + render( + + + , + ); + await act(async () => {}); + await showOverlay('draft email text'); + + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: '/rewrite ' } }); + }); + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + + act(() => { + getLastChannel()?.simulateMessage({ + type: 'Token', + data: 'Polished draft', + }); + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + await act(async () => {}); + await act(async () => {}); + + // The raw in-memory content carries the markers, but auto-replace pastes the + // same cleaned text the bubble and manual Replace button use. + expect(invoke).toHaveBeenCalledWith('replace_selection', { + text: 'Polished draft', + }); + }); + + it('auto-closes after a successful replace when auto-close is on', async () => { + enableChannelCaptureWithResponses({ + get_model_picker_state: { + active: 'gemma4:e2b', + all: ['gemma4:e2b'], + ollamaReachable: true, + }, + replace_selection: 'replaced', + }); + + render( + + + , + ); + await act(async () => {}); + await showOverlay('draft email text'); + __mockWindow.hide.mockClear(); + + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: '/rewrite ' } }); + }); + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + + act(() => { + getLastChannel()?.simulateMessage({ + type: 'Token', + data: 'Polished draft', + }); + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + await act(async () => {}); + + // The replace succeeds, so auto-close dismisses the overlay; the native + // window hides once the exit animation commits (HIDE_COMMIT_DELAY_MS). + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 400)); + }); + expect(__mockWindow.hide).toHaveBeenCalled(); + }); + + it('does not auto-close when the replace is skipped', async () => { + enableChannelCaptureWithResponses({ + get_model_picker_state: { + active: 'gemma4:e2b', + all: ['gemma4:e2b'], + ollamaReachable: true, + }, + replace_selection: 'skipped', + }); + + render( + + + , + ); + await act(async () => {}); + await showOverlay('draft email text'); + __mockWindow.hide.mockClear(); + + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: '/rewrite ' } }); + }); + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + + act(() => { + getLastChannel()?.simulateMessage({ + type: 'Token', + data: 'Polished draft', + }); + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + await act(async () => {}); + + // A skipped replace (no target app / secure field) must leave Thuki open. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 400)); + }); + expect(__mockWindow.hide).not.toHaveBeenCalled(); + }); + + it('keeps the Replace button on a plain follow-up after /rewrite', async () => { + enableChannelCaptureWithResponses({ + get_model_picker_state: { + active: 'gemma4:e2b', + all: ['gemma4:e2b'], + ollamaReachable: true, + }, + }); + + render( + + + , + ); + await act(async () => {}); + await showOverlay('draft email text'); + + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: '/rewrite ' } }); + }); + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + act(() => { + getLastChannel()?.simulateMessage({ type: 'Token', data: 'Polished v1' }); + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + await act(async () => {}); + + // The first /rewrite result is replaceable. + expect( + screen.queryAllByLabelText('Replace selection in source app'), + ).toHaveLength(1); + + // A plain refinement inherits sticky rewrite mode, so its result is + // replaceable too. + act(() => { + fireEvent.change(textarea, { target: { value: 'make it longer' } }); + }); + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + act(() => { + getLastChannel()?.simulateMessage({ type: 'Token', data: 'Longer v2' }); + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + await act(async () => {}); + + expect( + screen.queryAllByLabelText('Replace selection in source app'), + ).toHaveLength(2); + + // Auto-replace is off, so neither completed turn may write back on its + // own: the source app is only touched when the user clicks Replace. + expect(invoke).not.toHaveBeenCalledWith( + 'replace_selection', + expect.anything(), + ); + }); + + it('drops the Replace button when a different command interrupts the rewrite session', async () => { + enableChannelCaptureWithResponses({ + get_model_picker_state: { + active: 'gemma4:e2b', + all: ['gemma4:e2b'], + ollamaReachable: true, + }, + }); + + render( + + + , + ); + await act(async () => {}); + await showOverlay('draft email text'); + + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: '/rewrite ' } }); + }); + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + act(() => { + getLastChannel()?.simulateMessage({ type: 'Token', data: 'Polished v1' }); + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + await act(async () => {}); + + // A non-replaceable command exits rewrite mode for itself and for any + // later plain follow-up. + act(() => { + fireEvent.change(textarea, { target: { value: '/tldr wrap this up' } }); + }); + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + act(() => { + getLastChannel()?.simulateMessage({ type: 'Token', data: 'Short.' }); + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + await act(async () => {}); + + act(() => { + fireEvent.change(textarea, { target: { value: 'and again' } }); + }); + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + act(() => { + getLastChannel()?.simulateMessage({ type: 'Token', data: 'Again.' }); + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + await act(async () => {}); + + // Only the original /rewrite result carries a Replace button. + expect( + screen.queryAllByLabelText('Replace selection in source app'), + ).toHaveLength(1); + }); + it('applies justify-end when window is near screen bottom', async () => { render(); await act(async () => {}); diff --git a/src/components/ChatBubble.tsx b/src/components/ChatBubble.tsx index d540500e..b70caf20 100644 --- a/src/components/ChatBubble.tsx +++ b/src/components/ChatBubble.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react'; import { MarkdownRenderer } from './MarkdownRenderer'; import { ErrorCard } from './ErrorCard'; import { CopyButton } from './CopyButton'; +import { ReplaceButton } from './ReplaceButton'; import { ImageThumbnails } from './ImageThumbnails'; import { ThinkingBlock } from './ThinkingBlock'; import { convertFileSrc, invoke } from '@tauri-apps/api/core'; @@ -248,6 +249,9 @@ interface ChatBubbleProps { imagePaths?: string[]; /** Called when the user clicks a thumbnail to preview it. */ onImagePreview?: (path: string) => void; + /** When set, renders a Replace button in the action bar that writes this + * message's content back into the source app (for `/rewrite` & `/refine`). */ + onReplace?: (text: string) => Promise; /** Source URLs forwarded from the SearXNG results. Rendered as a clickable * footer below the answer; clicking opens the URL in the default browser. */ searchSources?: SearchResultPreview[]; @@ -303,6 +307,7 @@ export function ChatBubble({ isStreaming = false, imagePaths, onImagePreview, + onReplace, errorKind, thinkingContent, isThinkingPending, @@ -544,6 +549,9 @@ export function ChatBubble({
+ {onReplace && ( + + )} {searchSources && searchSources.length > 0 && ( + + ); +} diff --git a/src/components/__tests__/ChatBubble.test.tsx b/src/components/__tests__/ChatBubble.test.tsx index 4e4dd7ad..27a112cf 100644 --- a/src/components/__tests__/ChatBubble.test.tsx +++ b/src/components/__tests__/ChatBubble.test.tsx @@ -88,6 +88,31 @@ describe('ChatBubble', () => { ).toBeInTheDocument(); }); + it('renders the Replace button when onReplace is provided', () => { + render( + , + ); + expect( + screen.getByRole('button', { + name: 'Replace selection in source app', + }), + ).toBeInTheDocument(); + }); + + it('omits the Replace button when onReplace is absent', () => { + render(); + expect( + screen.queryByRole('button', { + name: 'Replace selection in source app', + }), + ).toBeNull(); + }); + it('left-aligns assistant messages (justify-start class)', () => { const { container } = render( , diff --git a/src/components/__tests__/ReplaceButton.test.tsx b/src/components/__tests__/ReplaceButton.test.tsx new file mode 100644 index 00000000..93e1be67 --- /dev/null +++ b/src/components/__tests__/ReplaceButton.test.tsx @@ -0,0 +1,94 @@ +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { ReplaceButton } from '../ReplaceButton'; + +const LABEL = 'Replace selection in source app'; + +describe('ReplaceButton', () => { + it('renders an accessible button', () => { + render( + , + ); + expect(screen.getByRole('button', { name: LABEL })).toBeInTheDocument(); + }); + + it('calls onReplace with the content on click', async () => { + const onReplace = vi.fn().mockResolvedValue(false); + render(); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: LABEL })); + }); + expect(onReplace).toHaveBeenCalledWith('rewritten text'); + }); + + it('shows a hover tooltip (same Tooltip used by the chat header icons)', () => { + render( + , + ); + fireEvent.mouseEnter( + screen.getByRole('button', { name: LABEL }).parentElement!, + ); + expect(screen.getByText('Replace selection')).toBeInTheDocument(); + }); + + it('shows a checkmark after a successful replace (aria-label becomes "Replaced")', async () => { + render( + , + ); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: LABEL })); + }); + expect( + screen.getByRole('button', { name: 'Replaced' }), + ).toBeInTheDocument(); + }); + + it('stays in the default state when the replace is skipped', async () => { + render( + , + ); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: LABEL })); + }); + expect(screen.getByRole('button', { name: LABEL })).toBeInTheDocument(); + }); + + it('reverts to the replace icon after 1.5 seconds', async () => { + vi.useFakeTimers(); + render( + , + ); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: LABEL })); + }); + expect( + screen.getByRole('button', { name: 'Replaced' }), + ).toBeInTheDocument(); + act(() => { + vi.advanceTimersByTime(1500); + }); + expect(screen.getByRole('button', { name: LABEL })).toBeInTheDocument(); + vi.useRealTimers(); + }); + + it('clears the prior revert timer on a rapid second replace', async () => { + const onReplace = vi.fn().mockResolvedValue(true); + render(); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: LABEL })); + }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Replaced' })); + }); + expect(onReplace).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/config/__tests__/tips.test.ts b/src/config/__tests__/tips.test.ts index cce174ea..ab358acc 100644 --- a/src/config/__tests__/tips.test.ts +++ b/src/config/__tests__/tips.test.ts @@ -22,6 +22,13 @@ describe('TIPS', () => { expect(tipText(imagesTip!).toLowerCase()).toContain('image'); }); + it('includes a tip about replacing rewritten text back into the source app', () => { + const replaceTip = TIPS.find((t) => + tipText(t).toLowerCase().includes('replace'), + ); + expect(replaceTip).toBeDefined(); + }); + it('linked tips carry an https URL', () => { const linked = TIPS.filter((t) => typeof t !== 'string'); expect(linked.length).toBeGreaterThan(0); diff --git a/src/config/commands.ts b/src/config/commands.ts index 1b1c7752..295cb133 100644 --- a/src/config/commands.ts +++ b/src/config/commands.ts @@ -198,7 +198,7 @@ export const COMMANDS: readonly Command[] = [ '`/rewrite so basically what happened was i was trying to fix the bug`: rewrites typed text for clarity', ], behavior: - 'Preserves the original meaning while improving flow and readability. Outputs only the rewritten text.', + 'Preserves the original meaning while improving flow and readability. Outputs only the rewritten text. A Replace button on the result writes the rewritten text straight back into the app you were using, replacing your selection; turn on auto-replace in Settings to skip the button. Follow-up tweaks in the same chat, like asking for a longer or more formal version, keep the Replace button too.', composability: '`/rewrite` works with attached images or `/screen`. Vision OCR extracts the text first, then rewrites it.', }, @@ -245,7 +245,7 @@ export const COMMANDS: readonly Command[] = [ '`/refine hey just wanted to follow up on the thing we discussed`: cleans up typed text', ], behavior: - 'Corrects errors and smooths rough phrasing without restructuring or adding new ideas. Your original tone and meaning stay intact.', + 'Corrects errors and smooths rough phrasing without restructuring or adding new ideas. Your original tone and meaning stay intact. A Replace button on the result writes the refined text straight back into the app you were using, replacing your selection; turn on auto-replace in Settings to skip the button. Follow-up tweaks in the same chat, like asking for a longer or more formal version, keep the Replace button too.', composability: '`/refine` works with attached images or `/screen`. Vision OCR extracts the text first, then refines it.', }, @@ -340,6 +340,16 @@ export const COMMANDS: readonly Command[] = [ */ export const SCREEN_CAPTURE_PLACEHOLDER = 'blob:screen-capture-loading'; +/** + * Slash commands whose results can be written back into the source app via the + * in-chat Replace button (and the auto-replace setting). Limited to the + * in-place text transforms where "replace my selection" is the natural intent. + */ +export const REPLACEABLE_COMMANDS: ReadonlySet = new Set([ + '/rewrite', + '/refine', +]); + /** * Builds a fully composed prompt from a utility command's template. * diff --git a/src/config/tips.ts b/src/config/tips.ts index 0617dbd5..6db2e546 100644 --- a/src/config/tips.ts +++ b/src/config/tips.ts @@ -69,4 +69,7 @@ export const TIPS: readonly Tip[] = [ 'The export icon in the chat header saves the current session as a Markdown file', 'Click the export icon and pick Copy to clipboard to grab the whole conversation, ready to paste anywhere', 'Exports include the model name, every message, /think reasoning, search sources, and inline screenshots', + 'After /rewrite or /refine, hit Replace to send the result back into your app, replacing your selection', + 'Turn on Auto-replace in Settings → Behavior so /rewrite and /refine results return to your app automatically', + 'Turn on Auto-close in Settings → Behavior to dismiss Thuki automatically once /rewrite or /refine is replaced', ]; diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index 943cf0df..593b16c6 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -27,6 +27,15 @@ import { listen, type UnlistenFn } from '@tauri-apps/api/event'; */ const CONFIG_UPDATED_EVENT = 'thuki://config-updated'; +/** + * Overlay visibility event (mirrors `OVERLAY_VISIBILITY_EVENT` in `App.tsx`). + * The overlay refetches config every time it shows so a setting changed in the + * Settings window (a separate webview) is always reflected by the next summon, + * even if the `config-updated` broadcast was missed while the overlay was + * hidden. The payload's `state` is `'show' | 'hide-request' | 'restore'`. + */ +const OVERLAY_VISIBILITY_EVENT = 'thuki://visibility'; + /** Shape returned by the Rust `get_config` command (snake_case). */ interface RawAppConfig { inference: { @@ -49,6 +58,10 @@ interface RawAppConfig { max_display_chars: number; max_context_length: number; }; + behavior: { + auto_replace: boolean; + auto_close: boolean; + }; } /** Camel-cased, frontend-friendly view of the configuration. */ @@ -74,6 +87,12 @@ export interface AppConfig { maxDisplayChars: number; maxContextLength: number; }; + behavior: { + /** Auto-write `/rewrite` & `/refine` results back into the source app. */ + autoReplace: boolean; + /** Dismiss the overlay after a `/rewrite` or `/refine` result is replaced. */ + autoClose: boolean; + }; } function transform(raw: RawAppConfig): AppConfig { @@ -98,6 +117,10 @@ function transform(raw: RawAppConfig): AppConfig { maxDisplayChars: raw.quote.max_display_chars, maxContextLength: raw.quote.max_context_length, }, + behavior: { + autoReplace: raw.behavior.auto_replace, + autoClose: raw.behavior.auto_close, + }, }; } @@ -140,26 +163,37 @@ export function ConfigProvider({ children }: { children: ReactNode }) { refresh(true); - let unlisten: UnlistenFn | undefined; - void listen(CONFIG_UPDATED_EVENT, () => { - refresh(false); - }) - .then((fn) => { - if (cancelled) { - fn(); - return; - } - unlisten = fn; - }) - .catch(() => { - // Event bridge unavailable (test env, Tauri not ready). Initial - // hydrate still happened above; subscribers fall back to a static - // snapshot and pick up edits on next mount. - }); + const unlisteners: UnlistenFn[] = []; + const subscribe = (event: string, handler: (payload: unknown) => void) => { + void listen(event, ({ payload }) => handler(payload)) + .then((fn) => { + if (cancelled) { + fn(); + return; + } + unlisteners.push(fn); + }) + .catch(() => { + // Event bridge unavailable (test env, Tauri not ready). Initial + // hydrate still happened above; subscribers fall back to a static + // snapshot and pick up edits on next mount. + }); + }; + + // Re-fetch when the backend broadcasts a config write... + subscribe(CONFIG_UPDATED_EVENT, () => refresh(false)); + // ...and every time the overlay shows, so a setting toggled in the + // Settings window is picked up by the next summon regardless of whether + // the broadcast above reached this (possibly hidden) window. + subscribe(OVERLAY_VISIBILITY_EVENT, (payload) => { + if ((payload as { state?: string } | null)?.state === 'show') { + refresh(false); + } + }); return () => { cancelled = true; - unlisten?.(); + for (const fn of unlisteners) fn(); }; }, []); @@ -226,4 +260,8 @@ export const DEFAULT_CONFIG: AppConfig = { maxDisplayChars: 300, maxContextLength: 4096, }, + behavior: { + autoReplace: false, + autoClose: false, + }, }; diff --git a/src/contexts/__tests__/ConfigContext.test.tsx b/src/contexts/__tests__/ConfigContext.test.tsx index c606aadd..2ad9c090 100644 --- a/src/contexts/__tests__/ConfigContext.test.tsx +++ b/src/contexts/__tests__/ConfigContext.test.tsx @@ -29,6 +29,10 @@ function Probe() {
{config.window.textFontWeight}
{config.quote.maxDisplayLines}
{config.prompt.system}
+
+ {String(config.behavior.autoReplace)} +
+
{String(config.behavior.autoClose)}
); } @@ -95,6 +99,10 @@ describe('ConfigContext', () => { max_display_chars: 500, max_context_length: 8192, }, + behavior: { + auto_replace: true, + auto_close: true, + }, }); render( @@ -120,6 +128,10 @@ describe('ConfigContext', () => { expect(screen.getByTestId('system-prompt').textContent).toBe( 'custom base prompt', ); + // The behavior section maps through transform() like every other + // section: snake_case on the wire becomes camelCase in the app config. + expect(screen.getByTestId('auto-replace').textContent).toBe('true'); + expect(screen.getByTestId('auto-close').textContent).toBe('true'); }); it('falls back to DEFAULT_CONFIG when invoke returns nullish', async () => { @@ -184,6 +196,9 @@ describe('ConfigContext', () => { max_display_chars: 300, max_context_length: 4096, }, + behavior: { + auto_replace: false, + }, }; const updated = { ...initial, @@ -211,6 +226,71 @@ describe('ConfigContext', () => { expect(screen.getByTestId('max-chat-height').textContent).toBe('800'); }); + it('refetches config when the overlay shows', async () => { + const initial = { + inference: { ollama_url: 'http://127.0.0.1:11434' }, + prompt: { system: '' }, + window: { overlay_width: 600, max_chat_height: 648, max_images: 3 }, + quote: { + max_display_lines: 4, + max_display_chars: 300, + max_context_length: 4096, + }, + behavior: { auto_replace: false }, + }; + const updated = { + ...initial, + window: { overlay_width: 950, max_chat_height: 648, max_images: 3 }, + }; + invoke.mockResolvedValueOnce(initial).mockResolvedValueOnce(updated); + + render( + + + , + ); + await act(async () => {}); + expect(screen.getByTestId('overlay-width').textContent).toBe('600'); + + await act(async () => { + emitTauriEvent('thuki://visibility', { state: 'show' }); + }); + + expect(screen.getByTestId('overlay-width').textContent).toBe('950'); + }); + + it('does not refetch on non-show or payloadless visibility events', async () => { + const initial = { + inference: { ollama_url: 'http://127.0.0.1:11434' }, + prompt: { system: '' }, + window: { overlay_width: 600, max_chat_height: 648, max_images: 3 }, + quote: { + max_display_lines: 4, + max_display_chars: 300, + max_context_length: 4096, + }, + behavior: { auto_replace: false }, + }; + invoke.mockResolvedValue(initial); + + render( + + + , + ); + await act(async () => {}); + const callsAfterMount = invoke.mock.calls.length; + + // A hide-request (state !== 'show') and a payloadless event must not + // trigger a config refetch. + await act(async () => { + emitTauriEvent('thuki://visibility', { state: 'hide-request' }); + emitTauriEvent('thuki://visibility', null); + }); + + expect(invoke.mock.calls.length).toBe(callsAfterMount); + }); + it('keeps last good config when a refresh invoke rejects', async () => { const initial = { inference: { ollama_url: 'http://127.0.0.1:11434' }, @@ -225,6 +305,9 @@ describe('ConfigContext', () => { max_display_chars: 300, max_context_length: 4096, }, + behavior: { + auto_replace: false, + }, }; invoke .mockResolvedValueOnce(initial) @@ -266,6 +349,9 @@ describe('ConfigContext', () => { max_display_chars: 300, max_context_length: 4096, }, + behavior: { + auto_replace: false, + }, }); const { unmount } = render( @@ -297,6 +383,9 @@ describe('ConfigContext', () => { max_display_chars: 300, max_context_length: 4096, }, + behavior: { + auto_replace: false, + }, }); render( @@ -383,6 +472,9 @@ describe('ConfigContext', () => { max_display_chars: 300, max_context_length: 4096, }, + behavior: { + auto_replace: false, + }, }); const { unmount } = render( diff --git a/src/hooks/useOllama.ts b/src/hooks/useOllama.ts index e9b2201f..95d84502 100644 --- a/src/hooks/useOllama.ts +++ b/src/hooks/useOllama.ts @@ -38,6 +38,10 @@ export interface Message { fromSearch?: boolean; /** Marks an assistant message produced through a `/think` turn. */ fromThink?: boolean; + /** Trigger of the replaceable utility command (`/rewrite` or `/refine`) that + * produced this assistant message. Present only on those results; drives the + * in-chat Replace button and the auto-replace path. */ + replaceCommand?: string; /** Source links forwarded by the search pipeline. */ searchSources?: SearchResultPreview[]; /** Warnings emitted by the `/search` pipeline during this turn. */ @@ -262,6 +266,7 @@ export function useOllama( think?: boolean, promptOverride?: string, displayImagePaths?: string[], + replaceCommand?: string, ) => { if (!displayContent.trim() && (!imagePaths || imagePaths.length === 0)) { return; @@ -290,6 +295,7 @@ export function useOllama( role: 'assistant', content: '', fromThink: think ? true : undefined, + replaceCommand, modelName: activeModel ?? undefined, }; diff --git a/src/settings/SettingsWindow.test.tsx b/src/settings/SettingsWindow.test.tsx index 130b1295..a026b6a2 100644 --- a/src/settings/SettingsWindow.test.tsx +++ b/src/settings/SettingsWindow.test.tsx @@ -36,6 +36,10 @@ const SAMPLE: RawAppConfig = { max_display_chars: 300, max_context_length: 4096, }, + behavior: { + auto_replace: false, + auto_close: false, + }, search: { searxng_url: 'http://127.0.0.1:25017', reader_url: 'http://127.0.0.1:25018', @@ -91,16 +95,34 @@ describe('SettingsWindow', () => { expect(container.firstChild).toBeNull(); }); - it('renders the four tab labels after config loads', async () => { + it('renders the five tab labels after config loads', async () => { render(); await waitFor(() => expect(screen.getByRole('tab', { name: /AI/ })).toBeInTheDocument(), ); + expect(screen.getByRole('tab', { name: /Behavior/ })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: /Web/ })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: /Display/ })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: /About/ })).toBeInTheDocument(); }); + it('switching to the Behavior tab shows the Text Replacement section', async () => { + render(); + await waitFor(() => screen.getByRole('tab', { name: /Behavior/ })); + + fireEvent.click(screen.getByRole('tab', { name: /Behavior/ })); + expect(screen.getByRole('tab', { name: /Behavior/ })).toHaveAttribute( + 'aria-selected', + 'true', + ); + expect(screen.getByText('Text Replacement')).toBeInTheDocument(); + expect( + screen.getByRole('switch', { + name: /Auto-replace selected text after \/rewrite or \/refine/, + }), + ).toBeInTheDocument(); + }); + it('starts on the AI tab', async () => { render(); await waitFor(() => @@ -155,7 +177,7 @@ describe('SettingsWindow', () => { const modelTab = screen.getByRole('tab', { name: /AI/ }); fireEvent.keyDown(modelTab, { key: 'ArrowRight' }); - expect(screen.getByRole('tab', { name: /Web/ })).toHaveAttribute( + expect(screen.getByRole('tab', { name: /Behavior/ })).toHaveAttribute( 'aria-selected', 'true', ); diff --git a/src/settings/SettingsWindow.tsx b/src/settings/SettingsWindow.tsx index 3eb94c38..f33138b7 100644 --- a/src/settings/SettingsWindow.tsx +++ b/src/settings/SettingsWindow.tsx @@ -26,6 +26,7 @@ import { getCurrentWindow } from '@tauri-apps/api/window'; import { useConfigSync } from './hooks/useConfigSync'; import { useSettingsAutoResize } from './hooks/useSettingsAutoResize'; import { ModelTab } from './tabs/ModelTab'; +import { BehaviorTab } from './tabs/BehaviorTab'; import { SearchTab } from './tabs/SearchTab'; import { DisplayTab } from './tabs/DisplayTab'; import { AboutTab } from './tabs/AboutTab'; @@ -60,6 +61,32 @@ const TABS: ReadonlyArray<{ ), }, + { + id: 'behavior', + label: 'Behavior', + // Sliders — settings that change how Thuki acts (text replacement, etc.). + icon: ( + + + + + + + + + + + + ), + }, { id: 'search', label: 'Web', @@ -369,6 +396,13 @@ export function SettingsWindow() { onSaved={handleSaved} /> ) : null} + {activeTab === 'behavior' ? ( + + ) : null} {activeTab === 'search' ? ( { expect(screen.getByText('GENERAL')).toBeInTheDocument(); expect(screen.getByText('row')).toBeInTheDocument(); }); + + it('renders a `?` info button next to the heading when helper is provided', () => { + render( +
+ row +
, + ); + expect( + screen.getByRole('button', { name: /About GENERAL/ }), + ).toBeInTheDocument(); + }); + + it('shows the section helper tooltip on hover', () => { + render( +
+ row +
, + ); + fireEvent.mouseEnter( + screen.getByRole('button', { name: /About GENERAL/ }).parentElement!, + ); + expect(screen.getByText('What this group is about.')).toBeInTheDocument(); + }); + + it('omits the section info button when no helper is provided', () => { + render( +
+ row +
, + ); + expect( + screen.queryByRole('button', { name: /About GENERAL/ }), + ).not.toBeInTheDocument(); + }); }); describe('SettingRow', () => { diff --git a/src/settings/components/index.tsx b/src/settings/components/index.tsx index 5ca29bfb..6ca8eedb 100644 --- a/src/settings/components/index.tsx +++ b/src/settings/components/index.tsx @@ -24,14 +24,32 @@ import type { ConfigError } from '../types'; export function Section({ heading, + helper, children, }: { heading: string; + /** Optional description rendered in a `?` tooltip next to the heading, for + * explaining what the whole section is about (scope), distinct from the + * per-row helpers that explain individual settings. */ + helper?: string; children: ReactNode; }) { return (
-
{heading}
+
+ {heading} + {helper ? ( + + + + ) : null} +
{children}
); diff --git a/src/settings/configHelpers.ts b/src/settings/configHelpers.ts index 34dd757d..76d556fd 100644 --- a/src/settings/configHelpers.ts +++ b/src/settings/configHelpers.ts @@ -70,6 +70,12 @@ const HELPERS = { router_timeout_s: 'How long (in seconds) Thuki waits for the AI to decide whether your question even needs a web search and to plan the first queries. Raise this if your local AI model is slow on your hardware. Lowering it only causes the planning step to give up early.', }, + behavior: { + auto_replace: + 'When on, a /rewrite or /refine result is written straight back into your app, replacing your highlighted text, with no click. When off, click the Replace button to send it back. Off by default.', + auto_close: + 'When on, Thuki closes itself after a /rewrite or /refine result is replaced into your app (via Auto-replace or the Replace button). Only if the replace succeeds. Off by default.', + }, debug: { trace_enabled: 'When on, Thuki saves a JSONL trace of every chat and search session to ~/Library/Application Support/com.quietnode.thuki/traces/. Useful for debugging and refining your prompts. Off by default.', diff --git a/src/settings/hooks/useConfigSync.test.ts b/src/settings/hooks/useConfigSync.test.ts index 82eea203..e6370331 100644 --- a/src/settings/hooks/useConfigSync.test.ts +++ b/src/settings/hooks/useConfigSync.test.ts @@ -33,6 +33,10 @@ const CONFIG_A: RawAppConfig = { max_display_chars: 300, max_context_length: 4096, }, + behavior: { + auto_replace: false, + auto_close: false, + }, search: { searxng_url: 'http://127.0.0.1:25017', reader_url: 'http://127.0.0.1:25018', diff --git a/src/settings/hooks/useDebouncedSave.test.ts b/src/settings/hooks/useDebouncedSave.test.ts index 0bc4a13f..3e39d2ca 100644 --- a/src/settings/hooks/useDebouncedSave.test.ts +++ b/src/settings/hooks/useDebouncedSave.test.ts @@ -30,6 +30,10 @@ const SAMPLE_CONFIG: RawAppConfig = { max_display_chars: 300, max_context_length: 4096, }, + behavior: { + auto_replace: false, + auto_close: false, + }, search: { searxng_url: 'http://127.0.0.1:25017', reader_url: 'http://127.0.0.1:25018', diff --git a/src/settings/tabs/BehaviorTab.tsx b/src/settings/tabs/BehaviorTab.tsx new file mode 100644 index 00000000..c15dc995 --- /dev/null +++ b/src/settings/tabs/BehaviorTab.tsx @@ -0,0 +1,77 @@ +/** + * Behavior tab. + * + * Settings that control how Thuki acts after you invoke it. The Text + * Replacement group covers the `/rewrite` and `/refine` commands: whether their + * result is written straight back into the source app (auto-replace), and + * whether Thuki dismisses itself once it has been replaced (auto-close). The + * per-result Replace button is always available regardless of these toggles. + */ + +import { Section, Toggle } from '../components'; +import { SaveField } from '../components/SaveField'; +import { configHelp } from '../configHelpers'; +import type { RawAppConfig } from '../types'; + +interface BehaviorTabProps { + config: RawAppConfig; + resyncToken: number; + onSaved: (next: RawAppConfig) => void; +} + +/** + * Section-level "?" copy: what the Text Replacement group is and which commands + * it covers. The individual toggles explain their own behavior in their own + * tooltips, so this stays scoped to "what is this and what does it apply to". + */ +const TEXT_REPLACEMENT_HELP = + 'Applies only to /rewrite and /refine: writing their result back into the app you were using, replacing your highlighted text.'; + +export function BehaviorTab({ + config, + resyncToken, + onSaved, +}: BehaviorTabProps) { + return ( +
+ ( + + )} + /> + ( + + )} + /> +
+ ); +} diff --git a/src/settings/tabs/tabs.test.tsx b/src/settings/tabs/tabs.test.tsx index 8e3002b3..abde60de 100644 --- a/src/settings/tabs/tabs.test.tsx +++ b/src/settings/tabs/tabs.test.tsx @@ -1,5 +1,5 @@ /** - * Smoke + interaction tests for the four Settings tabs. + * Smoke + interaction tests for the five Settings tabs. * * Each tab's body is mostly declarative `SaveField` markup whose behavior * is unit-tested in `components.test`, `SaveField.test`, and @@ -28,6 +28,7 @@ import { ModelTab } from './ModelTab'; import { DisplayTab } from './DisplayTab'; import { SearchTab } from './SearchTab'; import { AboutTab } from './AboutTab'; +import { BehaviorTab } from './BehaviorTab'; import type { RawAppConfig } from '../types'; const invokeMock = invoke as unknown as ReturnType; @@ -53,6 +54,10 @@ const CONFIG: RawAppConfig = { max_display_chars: 300, max_context_length: 4096, }, + behavior: { + auto_replace: false, + auto_close: false, + }, search: { searxng_url: 'http://127.0.0.1:25017', reader_url: 'http://127.0.0.1:25018', @@ -110,6 +115,17 @@ describe('ModelTab', () => { expect(screen.getByText('System prompt')).toBeInTheDocument(); }); + it('no longer renders the auto-replace toggle (moved to the Behavior tab)', async () => { + await renderModelTab(); + expect(screen.queryByText('Text Replacement')).not.toBeInTheDocument(); + expect(screen.queryByText('Rewrite')).not.toBeInTheDocument(); + expect( + screen.queryByRole('switch', { + name: /Auto-replace selected text after \/rewrite or \/refine/, + }), + ).not.toBeInTheDocument(); + }); + it('renders the live char counter for the prompt textarea', async () => { await renderModelTab(); expect(screen.getByText(/5 \/ 32000/)).toBeInTheDocument(); @@ -1013,3 +1029,86 @@ describe('AboutTab', () => { ); }); }); + +describe('BehaviorTab', () => { + const TOGGLE_NAME = /Auto-replace selected text after \/rewrite or \/refine/; + + it('renders the Text Replacement section with the Auto-replace toggle', () => { + render( {}} />); + expect(screen.getByText('Text Replacement')).toBeInTheDocument(); + // Label is the short one-line form; full copy lives in the "?" tooltip. + expect(screen.getByText('Auto-replace')).toBeInTheDocument(); + expect(screen.getByRole('switch', { name: TOGGLE_NAME })).toHaveAttribute( + 'aria-checked', + 'false', + ); + }); + + it('reflects an enabled auto_replace value on the toggle', () => { + render( + {}} + />, + ); + expect(screen.getByRole('switch', { name: TOGGLE_NAME })).toHaveAttribute( + 'aria-checked', + 'true', + ); + }); + + const CLOSE_NAME = /Close Thuki after replacing selected text/; + + it('renders the Auto-close toggle in the Text Replacement section', () => { + render( {}} />); + expect(screen.getByText('Auto-close')).toBeInTheDocument(); + expect(screen.getByRole('switch', { name: CLOSE_NAME })).toHaveAttribute( + 'aria-checked', + 'false', + ); + }); + + it('reflects an enabled auto_close value on the toggle', () => { + render( + {}} + />, + ); + expect(screen.getByRole('switch', { name: CLOSE_NAME })).toHaveAttribute( + 'aria-checked', + 'true', + ); + }); + + it('opens the help tooltip upward so it is not clipped at the short window edge', () => { + render( {}} />); + const help = screen.getByRole('button', { name: 'About Auto-replace' }); + fireEvent.mouseEnter(help.parentElement!); + // placement="top" positions the tooltip box with a `translate(..., -100%)` + // transform (it sits above the trigger). The default "bottom" placement + // uses `translateX(-50%)`, which here would overflow the bottom edge. + expect( + document.body.querySelector('[style*="translate(-50%, -100%)"]'), + ).not.toBeNull(); + }); + + it('shows a scope help tooltip on the Text Replacement section heading', () => { + render( {}} />); + fireEvent.mouseEnter( + screen.getByRole('button', { name: 'About Text Replacement' }) + .parentElement!, + ); + expect( + screen.getByText(/Applies only to \/rewrite and \/refine/), + ).toBeInTheDocument(); + }); +}); diff --git a/src/settings/types.ts b/src/settings/types.ts index b176fac3..27d87a5c 100644 --- a/src/settings/types.ts +++ b/src/settings/types.ts @@ -35,6 +35,10 @@ export interface RawAppConfig { max_display_chars: number; max_context_length: number; }; + behavior: { + auto_replace: boolean; + auto_close: boolean; + }; search: { searxng_url: string; reader_url: string; @@ -68,7 +72,12 @@ export interface CorruptMarker { } /** Identifier for the active Settings tab. */ -export type SettingsTabId = 'general' | 'search' | 'display' | 'about'; +export type SettingsTabId = + | 'general' + | 'behavior' + | 'search' + | 'display' + | 'about'; /** * Returns a human-friendly description of a Tauri-side `ConfigError`. Used diff --git a/src/styles/settings.module.css b/src/styles/settings.module.css index 65b11f76..ad399e69 100644 --- a/src/styles/settings.module.css +++ b/src/styles/settings.module.css @@ -216,6 +216,9 @@ padding-top: 24px; } .sectionHeading { + display: flex; + align-items: center; + gap: 6px; font-size: 11px; font-weight: 600; letter-spacing: 0.18em; diff --git a/src/utils/__tests__/replaceSelection.test.ts b/src/utils/__tests__/replaceSelection.test.ts new file mode 100644 index 00000000..db5a7ad7 --- /dev/null +++ b/src/utils/__tests__/replaceSelection.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { replaceSelection, shouldAutoReplace } from '../replaceSelection'; +import { invoke } from '../../testUtils/mocks/tauri'; + +describe('replaceSelection', () => { + beforeEach(() => { + invoke.mockReset(); + }); + + it('invokes the backend with the text and returns true on "replaced"', async () => { + invoke.mockResolvedValueOnce('replaced'); + const ok = await replaceSelection('hello'); + expect(ok).toBe(true); + expect(invoke).toHaveBeenCalledWith('replace_selection', { text: 'hello' }); + }); + + it('returns false when the backend reports "skipped"', async () => { + invoke.mockResolvedValueOnce('skipped'); + expect(await replaceSelection('x')).toBe(false); + }); + + it('returns false when the IPC call rejects', async () => { + invoke.mockRejectedValueOnce(new Error('no access')); + expect(await replaceSelection('x')).toBe(false); + }); +}); + +describe('shouldAutoReplace', () => { + const assistant = { replaceCommand: '/rewrite', content: 'rewritten' }; + const user = { quotedText: 'original' }; + + it('is true when the setting is on, the command is replaceable, there is content, and a selection', () => { + expect(shouldAutoReplace(true, assistant, user)).toBe(true); + }); + + it('is false when the setting is off', () => { + expect(shouldAutoReplace(false, assistant, user)).toBe(false); + }); + + it('is false when the turn was not a replaceable command', () => { + expect(shouldAutoReplace(true, { content: 'rewritten' }, user)).toBe(false); + }); + + it('is false when there is no content', () => { + expect( + shouldAutoReplace( + true, + { replaceCommand: '/rewrite', content: '' }, + user, + ), + ).toBe(false); + }); + + it('is false when there was no selection to replace', () => { + expect(shouldAutoReplace(true, assistant, {})).toBe(false); + }); +}); diff --git a/src/utils/replaceSelection.ts b/src/utils/replaceSelection.ts new file mode 100644 index 00000000..9df6fde6 --- /dev/null +++ b/src/utils/replaceSelection.ts @@ -0,0 +1,38 @@ +import { invoke } from '@tauri-apps/api/core'; +import type { Message } from '../hooks/useOllama'; + +/** + * Writes a `/rewrite` or `/refine` result back into the source app, replacing + * the user's selection. Resolves to `true` when the backend confirms it wrote + * the text; a `'skipped'` outcome (nothing safe to write into) or any IPC + * failure resolves to `false`. Failures are intentionally swallowed: the + * result still lives in chat and the user can copy it manually. + */ +export async function replaceSelection(text: string): Promise { + try { + const outcome = await invoke<'replaced' | 'skipped'>('replace_selection', { + text, + }); + return outcome === 'replaced'; + } catch { + return false; + } +} + +/** + * Whether a completed turn should auto-replace the source selection: the + * setting is on, the turn came from a replaceable command (`/rewrite` or + * `/refine`), it produced content, and the user had selected text to replace. + */ +export function shouldAutoReplace( + autoReplace: boolean, + assistantMsg: Pick, + userMsg: Pick, +): boolean { + return Boolean( + autoReplace && + assistantMsg.replaceCommand && + assistantMsg.content && + userMsg.quotedText, + ); +} diff --git a/src/view/ConversationView.tsx b/src/view/ConversationView.tsx index c56e05d9..bcbc0e3d 100644 --- a/src/view/ConversationView.tsx +++ b/src/view/ConversationView.tsx @@ -68,6 +68,11 @@ interface ConversationViewProps { onNewConversation?: () => void; /** Called when the user clicks a thumbnail to preview it. */ onImagePreview?: (path: string) => void; + /** + * Dismisses the overlay and writes a `/rewrite` or `/refine` result back into + * the source app. Wired to the per-message Replace button on those results. + */ + onReplace?: (text: string) => Promise; /** * Current `/search` pipeline stage. When non-null and the last assistant * message has no content yet, a transient stage pill is rendered in place @@ -116,6 +121,7 @@ export function ConversationView({ onHistoryOpen, onNewConversation, onImagePreview, + onReplace, searchStage = null, activeModel, onModelPickerToggle, @@ -271,6 +277,7 @@ export function ConversationView({ isStreaming={isLastAssistant} imagePaths={msg.imagePaths} onImagePreview={onImagePreview} + onReplace={msg.replaceCommand ? onReplace : undefined} errorKind={msg.errorKind} thinkingContent={msg.thinkingContent} isThinkingPending={isThinkingPending}