From d50a60b1329097574538a43e511f61cf09eed190 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Wed, 3 Jun 2026 22:00:45 -0500 Subject: [PATCH 01/11] feat: write /rewrite and /refine results back into the source app Signed-off-by: Logan Nguyen --- docs/commands.md | 4 +- docs/configurations.md | 13 + src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/config/defaults.rs | 13 + src-tauri/src/config/loader.rs | 2 + src-tauri/src/config/schema.rs | 42 +- src-tauri/src/config/tests.rs | 62 ++- src-tauri/src/context.rs | 11 +- src-tauri/src/lib.rs | 12 + src-tauri/src/replace.rs | 366 ++++++++++++++++++ src-tauri/src/settings_commands/tests.rs | 11 +- src/App.tsx | 65 +++- src/__tests__/App.test.tsx | 57 ++- src/components/ChatBubble.tsx | 8 + src/components/ReplaceButton.tsx | 41 ++ src/components/__tests__/ChatBubble.test.tsx | 25 ++ .../__tests__/ReplaceButton.test.tsx | 19 + src/config/commands.ts | 14 +- src/contexts/ConfigContext.tsx | 67 +++- src/contexts/__tests__/ConfigContext.test.tsx | 83 ++++ src/hooks/useOllama.ts | 6 + src/settings/SettingsWindow.test.tsx | 3 + src/settings/components/SaveField.test.tsx | 3 + src/settings/configHelpers.ts | 4 + src/settings/hooks/useConfigSync.test.ts | 3 + src/settings/hooks/useDebouncedSave.test.ts | 3 + src/settings/tabs/ModelTab.tsx | 20 + src/settings/tabs/tabs.test.tsx | 3 + src/settings/types.ts | 3 + src/utils/__tests__/replaceSelection.test.ts | 57 +++ src/utils/replaceSelection.ts | 38 ++ src/view/ConversationView.tsx | 7 + 33 files changed, 1015 insertions(+), 52 deletions(-) create mode 100644 src-tauri/src/replace.rs create mode 100644 src/components/ReplaceButton.tsx create mode 100644 src/components/__tests__/ReplaceButton.test.tsx create mode 100644 src/utils/__tests__/replaceSelection.test.ts create mode 100644 src/utils/replaceSelection.ts diff --git a/docs/commands.md b/docs/commands.md index 42bdcc7e..44f8e4ed 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. **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. **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..0081deee 100644 --- a/docs/configurations.md +++ b/docs/configurations.md @@ -62,6 +62,11 @@ 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 + [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 +172,14 @@ 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. | + ### `[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..2733813f 100644 --- a/src-tauri/src/config/defaults.rs +++ b/src-tauri/src/config/defaults.rs @@ -229,6 +229,16 @@ 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 (AI tab). +pub const DEFAULT_AUTO_REPLACE: 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 +306,8 @@ pub const ALLOWED_FIELDS: &[(&str, &str)] = &[ ("quote", "max_display_lines"), ("quote", "max_display_chars"), ("quote", "max_context_length"), + // [behavior] + ("behavior", "auto_replace"), // [search] ("search", "searxng_url"), ("search", "reader_url"), @@ -323,6 +335,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..349a5d17 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_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,25 @@ 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, +} + +impl Default for BehaviorSection { + fn default() -> Self { + Self { + auto_replace: DEFAULT_AUTO_REPLACE, + } + } +} + /// Search pipeline and service configuration. /// /// Service URLs control where the SearXNG and reader sidecar processes live. @@ -306,6 +325,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..c05bd305 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_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,45 @@ 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); +} + +#[test] +fn app_config_default_includes_behavior_section_with_compiled_defaults() { + let c = AppConfig::default(); + assert_eq!(c.behavior.auto_replace, DEFAULT_AUTO_REPLACE); +} + +#[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 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)]" + ); +} + // ── 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..db51b6e8 --- /dev/null +++ b/src-tauri/src/replace.rs @@ -0,0 +1,366 @@ +//! 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 frontend dismisses the overlay first, +//! so keyboard focus has returned to the target app by the time this runs. +//! The clipboard is saved, the rewrite written (tagged transient so +//! clipboard-history managers skip it), the target re-activated for good +//! measure, Cmd+V posted, and the clipboard restored. Paste is used rather +//! than an Accessibility write because, once an app regains focus, Cmd+V +//! reliably *replaces* its 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, + /// 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 +/// frontend must dismiss the overlay before invoking this so focus has +/// returned to the target. 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 focus transfers, 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, NSString}; + + use super::{should_record_activation, LastActiveAppState, ReplaceOutcome}; + + /// macOS virtual keycode for 'v'. + const KEY_V: u16 = 0x09; + /// CGEventTapLocation::kCGHIDEventTap. + const K_CG_HID_EVENT_TAP: u32 = 0; + /// 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); + fn CGEventPost(tap_location: u32, 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, so the following synthetic + /// Cmd+V lands in it. + unsafe fn activate_and_settle(pid: i32) { + 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 [20u64, 30, 50, 80] { + if frontmost_app_pid() == Some(pid) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(delay_ms)); + } + } + + /// Posts a synthetic Cmd+V to whatever app currently holds key focus. + unsafe fn simulate_cmd_v() { + let down = CGEventCreateKeyboardEvent(std::ptr::null(), KEY_V, true); + if !down.is_null() { + CGEventSetFlags(down, K_CG_EVENT_FLAG_MASK_COMMAND); + CGEventPost(K_CG_HID_EVENT_TAP, down); + CFRelease(down); + } + let up = CGEventCreateKeyboardEvent(std::ptr::null(), KEY_V, false); + if !up.is_null() { + CGEventSetFlags(up, K_CG_EVENT_FLAG_MASK_COMMAND); + CGEventPost(K_CG_HID_EVENT_TAP, up); + CFRelease(up); + } + } + + /// Reads the general pasteboard's plain-text string, if any. + unsafe fn pasteboard_string() -> Option { + let pb: *mut AnyObject = msg_send![class!(NSPasteboard), generalPasteboard]; + let s: *mut NSString = msg_send![pb, stringForType: plain_text_type()]; + if s.is_null() { + None + } else { + Some((*s).to_string()) + } + } + + /// 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 to a previously-saved plain-text value, + /// or clears it when there was nothing to restore. + unsafe fn restore_pasteboard(saved: Option) { + let pb: *mut AnyObject = msg_send![class!(NSPasteboard), generalPasteboard]; + let _: isize = msg_send![pb, clearContents]; + if let Some(prev) = saved { + let plain = plain_text_type(); + let types = NSArray::from_slice(&[plain]); + let nil: *mut AnyObject = std::ptr::null_mut(); + let _: isize = msg_send![pb, declareTypes: &*types, owner: nil]; + let value = NSString::from_str(&prev); + let _: bool = msg_send![pb, setString: &*value, forType: plain]; + } + } + + /// 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; + } + let saved = pasteboard_string(); + write_pasteboard_transient(text); + activate_and_settle(pid); + simulate_cmd_v(); + 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/tests.rs b/src-tauri/src/settings_commands/tests.rs index 72464901..11ae2106 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(1) + search(11) + debug(1) + updater(3) = 30 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(), 30); } #[test] @@ -82,6 +82,7 @@ fn allowed_sections_match_app_config_top_level_keys() { "prompt", "window", "quote", + "behavior", "search", "debug", "updater" diff --git a/src/App.tsx b/src/App.tsx index 7e122f37..6f7c9ed8 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,7 @@ import { serializeForClipboard, serializeForFile, } from './lib/exportSerializer'; +import { replaceSelection, shouldAutoReplace } from './utils/replaceSelection'; import './App.css'; const OVERLAY_VISIBILITY_EVENT = 'thuki://visibility'; @@ -242,6 +244,15 @@ const MORPH_SETTLE_GRACE_MS = 250; */ const HIDE_COMMIT_DELAY_MS = 350; +/** + * Delay from triggering a Replace (which dismisses the overlay) to posting the + * synthetic paste into the source app. Must exceed `HIDE_COMMIT_DELAY_MS` so + * the overlay's native window is hidden — returning keyboard focus to the + * target app — before Cmd+V fires; the extra margin lets the OS settle the + * key-window transfer so the paste never lands back in Thuki. + */ +const REPLACE_PASTE_DELAY_MS = HIDE_COMMIT_DELAY_MS + 90; + /** * Parses a message to detect all valid slash commands present as whole words. * Derives detectable commands from the COMMANDS registry so adding a command @@ -433,9 +444,25 @@ 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 `performReplace`, so the turn-completion handler (defined before + * `performReplace`) can trigger an auto-replace without a forward reference. + */ + const performReplaceRef = useRef<((text: string) => void) | 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 +470,9 @@ function App() { assistantMsg: Parameters[1], ) => { await persistTurn(userMsg, assistantMsg); + if (shouldAutoReplace(autoReplaceRef.current, assistantMsg, userMsg)) { + performReplaceRef.current?.(assistantMsg.content); + } }, [persistTurn], ); @@ -557,6 +587,12 @@ 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; + }, [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. @@ -947,6 +983,29 @@ function App() { }); }, [cancel]); + /** + * Dismisses the overlay, then writes a `/rewrite` or `/refine` result back + * into the source app. Dismissing returns keyboard focus to the target app, + * so the synthetic paste — fired once the overlay's native window has hidden + * — lands there rather than in Thuki. Drives both the manual Replace button + * and the auto-replace path. + */ + const performReplace = useCallback( + (text: string) => { + requestHideOverlay(); + setTimeout(() => { + void replaceSelection(text); + }, REPLACE_PASTE_DELAY_MS); + }, + [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 @@ -2203,6 +2262,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, ); }, [ @@ -2667,6 +2727,8 @@ function App() { undefined, hasThink || undefined, composedPrompt, + undefined, + REPLACEABLE_COMMANDS.has(utilityTrigger) ? utilityTrigger : undefined, ); setSelectedContext(null); setQuery(''); @@ -3225,6 +3287,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..5131bae7 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,58 @@ 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 dismisses the overlay first, then pastes once the native + // hide has committed and focus has returned to the source app. Wait past + // both deadlines so the deferred paste fires. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 600)); + }); + + // 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('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..3d98224c 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) => void; /** 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..8a37d955 --- /dev/null +++ b/src/components/__tests__/ReplaceButton.test.tsx @@ -0,0 +1,19 @@ +import { render, screen, fireEvent } 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', () => { + const onReplace = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: LABEL })); + expect(onReplace).toHaveBeenCalledWith('rewritten text'); + }); +}); diff --git a/src/config/commands.ts b/src/config/commands.ts index 1b1c7752..9fc4d797 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.', 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.', 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/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index 943cf0df..f15ba38c 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,9 @@ interface RawAppConfig { max_display_chars: number; max_context_length: number; }; + behavior: { + auto_replace: boolean; + }; } /** Camel-cased, frontend-friendly view of the configuration. */ @@ -74,6 +86,10 @@ export interface AppConfig { maxDisplayChars: number; maxContextLength: number; }; + behavior: { + /** Auto-write `/rewrite` & `/refine` results back into the source app. */ + autoReplace: boolean; + }; } function transform(raw: RawAppConfig): AppConfig { @@ -98,6 +114,9 @@ function transform(raw: RawAppConfig): AppConfig { maxDisplayChars: raw.quote.max_display_chars, maxContextLength: raw.quote.max_context_length, }, + behavior: { + autoReplace: raw.behavior.auto_replace, + }, }; } @@ -140,26 +159,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 +256,7 @@ export const DEFAULT_CONFIG: AppConfig = { maxDisplayChars: 300, maxContextLength: 4096, }, + behavior: { + autoReplace: false, + }, }; diff --git a/src/contexts/__tests__/ConfigContext.test.tsx b/src/contexts/__tests__/ConfigContext.test.tsx index c606aadd..89213121 100644 --- a/src/contexts/__tests__/ConfigContext.test.tsx +++ b/src/contexts/__tests__/ConfigContext.test.tsx @@ -95,6 +95,9 @@ describe('ConfigContext', () => { max_display_chars: 500, max_context_length: 8192, }, + behavior: { + auto_replace: true, + }, }); render( @@ -184,6 +187,9 @@ describe('ConfigContext', () => { max_display_chars: 300, max_context_length: 4096, }, + behavior: { + auto_replace: false, + }, }; const updated = { ...initial, @@ -211,6 +217,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 +296,9 @@ describe('ConfigContext', () => { max_display_chars: 300, max_context_length: 4096, }, + behavior: { + auto_replace: false, + }, }; invoke .mockResolvedValueOnce(initial) @@ -266,6 +340,9 @@ describe('ConfigContext', () => { max_display_chars: 300, max_context_length: 4096, }, + behavior: { + auto_replace: false, + }, }); const { unmount } = render( @@ -297,6 +374,9 @@ describe('ConfigContext', () => { max_display_chars: 300, max_context_length: 4096, }, + behavior: { + auto_replace: false, + }, }); render( @@ -383,6 +463,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..6e4d4203 100644 --- a/src/settings/SettingsWindow.test.tsx +++ b/src/settings/SettingsWindow.test.tsx @@ -36,6 +36,9 @@ const SAMPLE: RawAppConfig = { max_display_chars: 300, max_context_length: 4096, }, + behavior: { + auto_replace: false, + }, search: { searxng_url: 'http://127.0.0.1:25017', reader_url: 'http://127.0.0.1:25018', diff --git a/src/settings/components/SaveField.test.tsx b/src/settings/components/SaveField.test.tsx index fcbff85d..40afb33e 100644 --- a/src/settings/components/SaveField.test.tsx +++ b/src/settings/components/SaveField.test.tsx @@ -29,6 +29,9 @@ const SAMPLE: RawAppConfig = { max_display_chars: 300, max_context_length: 4096, }, + behavior: { + auto_replace: false, + }, search: { searxng_url: 'http://127.0.0.1:25017', reader_url: 'http://127.0.0.1:25018', diff --git a/src/settings/configHelpers.ts b/src/settings/configHelpers.ts index 34dd757d..ba8d2af7 100644 --- a/src/settings/configHelpers.ts +++ b/src/settings/configHelpers.ts @@ -70,6 +70,10 @@ 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 the app you were using, 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 yourself. The Replace button is always available either way. Off by default because it edits text in another app.', + }, 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..0746ce4a 100644 --- a/src/settings/hooks/useConfigSync.test.ts +++ b/src/settings/hooks/useConfigSync.test.ts @@ -33,6 +33,9 @@ const CONFIG_A: RawAppConfig = { max_display_chars: 300, max_context_length: 4096, }, + behavior: { + auto_replace: 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..e36e7022 100644 --- a/src/settings/hooks/useDebouncedSave.test.ts +++ b/src/settings/hooks/useDebouncedSave.test.ts @@ -30,6 +30,9 @@ const SAMPLE_CONFIG: RawAppConfig = { max_display_chars: 300, max_context_length: 4096, }, + behavior: { + auto_replace: false, + }, search: { searxng_url: 'http://127.0.0.1:25017', reader_url: 'http://127.0.0.1:25018', diff --git a/src/settings/tabs/ModelTab.tsx b/src/settings/tabs/ModelTab.tsx index cc0d7f39..949740d7 100644 --- a/src/settings/tabs/ModelTab.tsx +++ b/src/settings/tabs/ModelTab.tsx @@ -436,6 +436,26 @@ export function ModelTab({ config, resyncToken, onSaved }: ModelTabProps) { /> +
+ ( + + )} + /> +
+
+ + + ); } diff --git a/src/components/__tests__/ReplaceButton.test.tsx b/src/components/__tests__/ReplaceButton.test.tsx index 8a37d955..4eb03636 100644 --- a/src/components/__tests__/ReplaceButton.test.tsx +++ b/src/components/__tests__/ReplaceButton.test.tsx @@ -16,4 +16,12 @@ describe('ReplaceButton', () => { 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(); + }); }); diff --git a/src/settings/SettingsWindow.test.tsx b/src/settings/SettingsWindow.test.tsx index 6e4d4203..893895b8 100644 --- a/src/settings/SettingsWindow.test.tsx +++ b/src/settings/SettingsWindow.test.tsx @@ -94,16 +94,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(() => @@ -158,7 +176,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' ? ( void; +} + +export function BehaviorTab({ + config, + resyncToken, + onSaved, +}: BehaviorTabProps) { + return ( +
+ ( + + )} + /> +
+ ); +} diff --git a/src/settings/tabs/ModelTab.tsx b/src/settings/tabs/ModelTab.tsx index 949740d7..cc0d7f39 100644 --- a/src/settings/tabs/ModelTab.tsx +++ b/src/settings/tabs/ModelTab.tsx @@ -436,26 +436,6 @@ export function ModelTab({ config, resyncToken, onSaved }: ModelTabProps) { /> -
- ( - - )} - /> -
-
+ + ) : null} +
{children} ); diff --git a/src/settings/configHelpers.ts b/src/settings/configHelpers.ts index 84a88598..76d556fd 100644 --- a/src/settings/configHelpers.ts +++ b/src/settings/configHelpers.ts @@ -72,9 +72,9 @@ const HELPERS = { }, behavior: { auto_replace: - 'When on, a /rewrite or /refine result is written straight back into the app you were using, 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 yourself. The Replace button is always available either way. Off by default because it edits text in another app.', + '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 right after a /rewrite or /refine result is replaced back into your app, whether the replace happened automatically (Auto-replace) or from clicking the Replace button. Only closes when the replace actually goes through. Turn it on for a one-shot rewrite-and-dismiss flow; leave it off to keep Thuki open and replace more than once. Off by default.', + '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: diff --git a/src/settings/tabs/BehaviorTab.tsx b/src/settings/tabs/BehaviorTab.tsx index de576cac..c15dc995 100644 --- a/src/settings/tabs/BehaviorTab.tsx +++ b/src/settings/tabs/BehaviorTab.tsx @@ -19,13 +19,21 @@ interface BehaviorTabProps { 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 ( -
+
{ 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/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; From b7ce6bd376d4c7975543f74fd064c6bc91c12a80 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Fri, 5 Jun 2026 13:26:48 -0500 Subject: [PATCH 07/11] feat(replace): keep Replace on /rewrite and /refine follow-up turns Signed-off-by: Logan Nguyen --- docs/commands.md | 4 +- src/App.tsx | 37 ++++++++++- src/__tests__/App.test.tsx | 131 +++++++++++++++++++++++++++++++++++++ src/config/commands.ts | 4 +- 4 files changed, 171 insertions(+), 5 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 44f8e4ed..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. 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. +**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. 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. +**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/src/App.tsx b/src/App.tsx index 1c260c50..2c766f60 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -449,6 +449,16 @@ function App() { */ 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. @@ -948,6 +958,7 @@ function App() { setPendingUserMessage(null); setCaptureError(null); setSearchActive(false); + stickyReplaceCommandRef.current = null; void refreshModels(); reset(); @@ -968,6 +979,7 @@ function App() { screenCapturePendingRef.current = false; screenCaptureInputSnapshotRef.current = null; setSearchActive(false); + stickyReplaceCommandRef.current = null; setSelectedContext(null); setPreviewImageUrl(null); setAttachedImages((prev) => { @@ -1600,6 +1612,7 @@ function App() { const loaded = await loadConversation(id); loadMessages(loaded); setSearchActive(false); + stickyReplaceCommandRef.current = null; } catch { // Load failed - current session is preserved intact. } finally { @@ -1630,6 +1643,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 { @@ -1677,6 +1691,7 @@ function App() { setIsSubmitPending(false); setPendingUserMessage(null); setSearchActive(false); + stickyReplaceCommandRef.current = null; }, [reset, resetHistory]); /** @@ -1888,7 +1903,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) { @@ -2623,6 +2648,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 diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index c4037ba8..fabea89a 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -1269,6 +1269,137 @@ describe('App', () => { 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); + }); + + 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/config/commands.ts b/src/config/commands.ts index 9fc4d797..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. 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.', + '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. 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.', + '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.', }, From 28ee93d0c79452c1ae61bd4aaea7659b8d4ca476 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Sat, 6 Jun 2026 15:37:27 -0500 Subject: [PATCH 08/11] feat(replace): update Replace functionality to return a promise and enhance user feedback Signed-off-by: Logan Nguyen --- src-tauri/src/config/defaults.rs | 2 +- src/App.tsx | 17 +-- src/components/ChatBubble.tsx | 2 +- src/components/ReplaceButton.tsx | 104 ++++++++++++++---- .../__tests__/ReplaceButton.test.tsx | 79 ++++++++++++- src/config/__tests__/tips.test.ts | 7 ++ src/config/tips.ts | 3 + src/view/ConversationView.tsx | 2 +- 8 files changed, 178 insertions(+), 38 deletions(-) diff --git a/src-tauri/src/config/defaults.rs b/src-tauri/src/config/defaults.rs index 0ab44bbe..898483c6 100644 --- a/src-tauri/src/config/defaults.rs +++ b/src-tauri/src/config/defaults.rs @@ -236,7 +236,7 @@ pub const DEFAULT_DEBUG_TRACE_ENABLED: bool = false; /// 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 (AI tab). +/// panel (Behavior tab). pub const DEFAULT_AUTO_REPLACE: bool = false; /// When `true`, the Thuki overlay dismisses itself immediately after a diff --git a/src/App.tsx b/src/App.tsx index 2c766f60..dca63d42 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -463,7 +463,9 @@ function App() { * 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) => void) | null>(null); + const performReplaceRef = useRef<((text: string) => Promise) | null>( + null, + ); /** * Persist a completed user/assistant turn to SQLite if the conversation @@ -479,7 +481,7 @@ function App() { ) => { await persistTurn(userMsg, assistantMsg); if (shouldAutoReplace(autoReplaceRef.current, assistantMsg, userMsg)) { - performReplaceRef.current?.(assistantMsg.content); + void performReplaceRef.current?.(assistantMsg.content); } }, [persistTurn], @@ -1000,16 +1002,17 @@ function App() { * 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. + * 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) => { - void replaceSelection(text).then((replaced) => { + (text: string): Promise => + replaceSelection(text).then((replaced) => { if (replaced && autoCloseRef.current) { requestHideOverlay(); } - }); - }, + return replaced; + }), [requestHideOverlay], ); diff --git a/src/components/ChatBubble.tsx b/src/components/ChatBubble.tsx index 3d98224c..b70caf20 100644 --- a/src/components/ChatBubble.tsx +++ b/src/components/ChatBubble.tsx @@ -251,7 +251,7 @@ interface ChatBubbleProps { 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) => void; + 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[]; diff --git a/src/components/ReplaceButton.tsx b/src/components/ReplaceButton.tsx index 296dd8ca..df8aec15 100644 --- a/src/components/ReplaceButton.tsx +++ b/src/components/ReplaceButton.tsx @@ -1,3 +1,5 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; import { Tooltip } from './Tooltip'; interface ReplaceButtonProps { @@ -6,40 +8,98 @@ interface ReplaceButtonProps { /** * Writes `content` into the source app, replacing the user's selection. The * paste lands in the source app while the overlay stays open, so the user - * can replace repeatedly. + * can replace repeatedly. Resolves to whether the write succeeded, so the + * button can confirm a successful replace with a tick. */ - onReplace: (text: string) => void; + onReplace: (text: string) => Promise; } /** * Icon-only button rendered below a `/rewrite` or `/refine` result. Writes the - * rewritten text back into the source app, replacing the user's selection. A - * hover tooltip (the same `Tooltip` the chat header icons use) names the - * action, since the button carries only an icon. + * rewritten text back into the source app, replacing the user's selection. On a + * successful write it flips to a checkmark for 1.5s, then reverts: the paste + * lands in another app where the confirmation is not otherwise visible, so the + * tick is the only signal it worked (mirrors the sibling `CopyButton`). A + * skipped write (no target / secure field) leaves the button unchanged. A hover + * tooltip (the same `Tooltip` the chat header icons use) names the action. */ export function ReplaceButton({ content, onReplace }: ReplaceButtonProps) { + const [replaced, setReplaced] = useState(false); + const timerRef = useRef | null>(null); + + const handleReplace = useCallback(async () => { + const ok = await onReplace(content); + if (!ok) return; + if (timerRef.current) clearTimeout(timerRef.current); + setReplaced(true); + timerRef.current = setTimeout(() => setReplaced(false), 1500); + }, [content, onReplace]); + + useEffect( + () => () => { + if (timerRef.current) clearTimeout(timerRef.current); + }, + [], + ); + return ( ); diff --git a/src/components/__tests__/ReplaceButton.test.tsx b/src/components/__tests__/ReplaceButton.test.tsx index 4eb03636..93e1be67 100644 --- a/src/components/__tests__/ReplaceButton.test.tsx +++ b/src/components/__tests__/ReplaceButton.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; import { ReplaceButton } from '../ReplaceButton'; @@ -6,22 +6,89 @@ const LABEL = 'Replace selection in source app'; describe('ReplaceButton', () => { it('renders an accessible button', () => { - render(); + render( + , + ); expect(screen.getByRole('button', { name: LABEL })).toBeInTheDocument(); }); - it('calls onReplace with the content on click', () => { - const onReplace = vi.fn(); + it('calls onReplace with the content on click', async () => { + const onReplace = vi.fn().mockResolvedValue(false); render(); - fireEvent.click(screen.getByRole('button', { name: LABEL })); + 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(); + 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/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/view/ConversationView.tsx b/src/view/ConversationView.tsx index 73d98855..bcbc0e3d 100644 --- a/src/view/ConversationView.tsx +++ b/src/view/ConversationView.tsx @@ -72,7 +72,7 @@ interface ConversationViewProps { * 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) => void; + 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 From 8d3e8e9a445162dc14f550553b7f290b9c0e4337 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Sat, 6 Jun 2026 18:31:46 -0500 Subject: [PATCH 09/11] fix(replace): clean auto-replace text to match the manual Replace path Signed-off-by: Logan Nguyen --- src/App.tsx | 6 ++++- src/__tests__/App.test.tsx | 49 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index dca63d42..906e69f0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -61,6 +61,7 @@ import { serializeForFile, } from './lib/exportSerializer'; import { replaceSelection, shouldAutoReplace } from './utils/replaceSelection'; +import { cleanForRender } from './utils/sanitizeAssistantContent'; import './App.css'; const OVERLAY_VISIBILITY_EVENT = 'thuki://visibility'; @@ -481,7 +482,10 @@ function App() { ) => { await persistTurn(userMsg, assistantMsg); if (shouldAutoReplace(autoReplaceRef.current, assistantMsg, userMsg)) { - void performReplaceRef.current?.(assistantMsg.content); + // 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], diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index fabea89a..1ad63a44 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -1170,6 +1170,55 @@ describe('App', () => { }); }); + 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: { From 9b66836d8499d27e3a6f0b5cddbc8df4b044400f Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Sat, 6 Jun 2026 19:19:31 -0500 Subject: [PATCH 10/11] fix(replace): gate write-back on activation and restore full clipboard Signed-off-by: Logan Nguyen --- src-tauri/src/replace.rs | 113 ++++++++++++++++++++++++++------------- 1 file changed, 77 insertions(+), 36 deletions(-) diff --git a/src-tauri/src/replace.rs b/src-tauri/src/replace.rs index 13aa72a7..6ef06e66 100644 --- a/src-tauri/src/replace.rs +++ b/src-tauri/src/replace.rs @@ -10,14 +10,17 @@ //! 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 clipboard is saved, the rewrite -//! written to it (tagged transient so clipboard-history managers skip it), -//! the target app activated, 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. Paste is used rather than an Accessibility write +//! 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 @@ -52,7 +55,8 @@ pub enum ReplaceOutcome { /// Text was pasted into the target app. Replaced, /// No-op: empty text, Accessibility not granted, no target app observed, - /// or secure input was active. + /// the target app could not be brought to the foreground, or secure input + /// was active. Skipped, } @@ -136,7 +140,7 @@ mod macos { use objc2::rc::autoreleasepool; use objc2::runtime::AnyObject; use objc2::{class, msg_send}; - use objc2_foundation::{ns_string, NSArray, NSString}; + use objc2_foundation::{ns_string, NSArray, NSData, NSString}; use super::{should_record_activation, LastActiveAppState, ReplaceOutcome}; @@ -240,21 +244,26 @@ mod macos { } /// Brings the app with `pid` to the foreground and waits, with bounded - /// backoff, for the activation to take effect, so its focused text field is - /// first responder and handles the synthetic Cmd+V as a paste over the - /// selection. - unsafe fn activate_and_settle(pid: i32) { + /// 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 [20u64, 30, 50, 80] { + 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) { - break; + return true; } - std::thread::sleep(std::time::Duration::from_millis(delay_ms)); } + false } /// Posts a synthetic Cmd+V directly to the process `pid`. Targeting the @@ -275,15 +284,31 @@ mod macos { } } - /// Reads the general pasteboard's plain-text string, if any. - unsafe fn pasteboard_string() -> Option { + /// 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 s: *mut NSString = msg_send![pb, stringForType: plain_text_type()]; - if s.is_null() { - None - } else { - Some((*s).to_string()) + 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 + @@ -305,18 +330,21 @@ mod macos { let _: bool = msg_send![pb, setData: empty, forType: autogen]; } - /// Restores the general pasteboard to a previously-saved plain-text value, - /// or clears it when there was nothing to restore. - unsafe fn restore_pasteboard(saved: Option) { + /// 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 let Some(prev) = saved { - let plain = plain_text_type(); - let types = NSArray::from_slice(&[plain]); - let nil: *mut AnyObject = std::ptr::null_mut(); - let _: isize = msg_send![pb, declareTypes: &*types, owner: nil]; - let value = NSString::from_str(&prev); - let _: bool = msg_send![pb, setString: &*value, forType: plain]; + 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]; } } @@ -327,9 +355,22 @@ mod macos { if is_secure_input() { return ReplaceOutcome::Skipped; } - let saved = pasteboard_string(); + // 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); - activate_and_settle(pid); post_cmd_v_to_pid(pid); std::thread::sleep(std::time::Duration::from_millis(PASTE_SETTLE_MS)); restore_pasteboard(saved); From af35335569cea47a1c0d975d5084d08ea53ffcab Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Sat, 6 Jun 2026 19:19:31 -0500 Subject: [PATCH 11/11] test(replace): pin behavior config mapping and auto-replace-off guard Signed-off-by: Logan Nguyen --- src/__tests__/App.test.tsx | 7 +++++++ src/contexts/__tests__/ConfigContext.test.tsx | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index 1ad63a44..19e74491 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -1377,6 +1377,13 @@ describe('App', () => { 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 () => { diff --git a/src/contexts/__tests__/ConfigContext.test.tsx b/src/contexts/__tests__/ConfigContext.test.tsx index 89213121..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)}
); } @@ -97,6 +101,7 @@ describe('ConfigContext', () => { }, behavior: { auto_replace: true, + auto_close: true, }, }); @@ -123,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 () => {