Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ Rewrites text to read more naturally and clearly.
- `/rewrite` with highlighted text: rewrites the selected text
- `/rewrite so basically what happened was i was trying to fix the bug`: rewrites typed text for clarity

**Behavior:** Preserves the original meaning while improving flow and readability. Outputs only the rewritten text.
**Behavior:** Preserves the original meaning while improving flow and readability. Outputs only the rewritten text. A Replace button on the result writes the rewritten text straight back into the app you were using, replacing your selection; turn on auto-replace in Settings to skip the button. Follow-up tweaks in the same chat, like asking for a longer or more formal version, keep the Replace button too.

**Composable:** `/rewrite` works with attached images or `/screen`. Vision OCR extracts the text first, then rewrites it.

Expand Down Expand Up @@ -153,7 +153,7 @@ Fixes grammar, spelling, and punctuation while preserving your voice.
- `/refine` with highlighted text: corrects the selected text
- `/refine hey just wanted to follow up on the thing we discussed`: cleans up typed text

**Behavior:** Corrects errors and smooths rough phrasing without restructuring or adding new ideas. Your original tone and meaning stay intact.
**Behavior:** Corrects errors and smooths rough phrasing without restructuring or adding new ideas. Your original tone and meaning stay intact. A Replace button on the result writes the refined text straight back into the app you were using, replacing your selection; turn on auto-replace in Settings to skip the button. Follow-up tweaks in the same chat, like asking for a longer or more formal version, keep the Replace button too.

**Composable:** `/refine` works with attached images or `/screen`. Vision OCR extracts the text first, then refines it.

Expand Down
17 changes: 17 additions & 0 deletions docs/configurations.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ max_display_lines = 4
max_display_chars = 300
max_context_length = 4096

[behavior]
# Write /rewrite and /refine results straight back into the source app,
# replacing your selection, without clicking the in-chat Replace button.
auto_replace = false
# Dismiss the Thuki overlay after a /rewrite or /refine result is replaced
# back into the source app (manual Replace click or auto-replace).
auto_close = false

[search]
# URLs of the local sandbox services. Match the bindings in
# `sandbox/docker-compose.yml`. Override only if you run SearXNG or the
Expand Down Expand Up @@ -167,6 +175,15 @@ Controls how text you select in another app (and bring to Thuki) appears as a qu
| `max_display_chars` | `300` | Yes | — | `[1, 10000]` | How many characters of the quoted text are shown as a preview in the input bar. Same idea as `max_display_lines`: the full text is still sent to the AI. Raise for a longer preview; lower to keep the bar compact. |
| `max_context_length` | `4096` | Yes | — | `[1, 65536]` | How many characters of the quoted text are actually sent to the AI. Anything past this is cut off. Raise if you quote long passages and want the AI to see all of it; lower if your model has a small context window or you want to save tokens on big selections. |

### `[behavior]`

Controls what happens to a `/rewrite` or `/refine` result: whether Thuki writes it straight back into the app you were using, or waits for you to send it back yourself.

| Constant | Default | Tunable? | Why not tunable | Bounds | Description |
| :------------- | :------ | :------- | :-------------- | :----- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `auto_replace` | `false` | Yes | — | — | When on, a `/rewrite` or `/refine` result is written straight back into the source app, replacing your highlighted text, the moment the rewrite is ready, with no extra click. When off, the rewrite appears in Thuki and you press the Replace button to send it back. The Replace button is available either way. |
| `auto_close` | `false` | Yes | — | — | When on, the Thuki overlay closes itself right after a `/rewrite` or `/refine` result is replaced back into the source app, whether the replace happened automatically (`auto_replace`) or from a manual Replace click. Only closes on a successful replace. Independent of `auto_replace`. Turn on for a one-shot rewrite-and-dismiss flow; leave off to keep Thuki open and replace repeatedly. |

### `[search]`

Settings for the `/search` command, which lets the AI search the web and read pages to answer your question. Covers where Thuki's local search and page-reader services live, how hard it should try to find good results, and how long to wait at each step.
Expand Down
1 change: 1 addition & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
24 changes: 24 additions & 0 deletions src-tauri/src/config/defaults.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,26 @@ pub const BOUNDS_TIMEOUT_S: (u64, u64) = (1, 300);
/// by default.
pub const DEFAULT_DEBUG_TRACE_ENABLED: bool = false;

/// Whether `/rewrite` and `/refine` results are written straight back into the
/// source app (replacing the selection) the moment the model finishes,
/// without the user clicking the in-chat Replace button.
///
/// Off by default: auto-replace mutates text in another app, so the
/// conservative default is to require an explicit click. When on, the Replace
/// button still renders as a manual re-trigger. Toggleable from the Settings
/// panel (Behavior tab).
pub const DEFAULT_AUTO_REPLACE: bool = false;

/// When `true`, the Thuki overlay dismisses itself immediately after a
/// `/rewrite` or `/refine` result is replaced back into the source app, whether
/// the replace was automatic (see [`DEFAULT_AUTO_REPLACE`]) or a manual Replace
/// click. Only closes on a *successful* replace; a skipped write (no target /
/// secure field) leaves the overlay open.
///
/// Off by default. Independent of auto-replace: usable with either trigger.
/// Toggleable from the Settings panel (Behavior tab).
pub const DEFAULT_AUTO_CLOSE: bool = false;

// Ollama API baked-in limits: not exposed in config.toml because they bound
// attacker-controlled data (response bodies from the local Ollama daemon) and
// keep the UI responsive when the daemon is hung. Changing either timeout
Expand Down Expand Up @@ -296,6 +316,9 @@ pub const ALLOWED_FIELDS: &[(&str, &str)] = &[
("quote", "max_display_lines"),
("quote", "max_display_chars"),
("quote", "max_context_length"),
// [behavior]
("behavior", "auto_replace"),
("behavior", "auto_close"),
// [search]
("search", "searxng_url"),
("search", "reader_url"),
Expand Down Expand Up @@ -323,6 +346,7 @@ pub const ALLOWED_SECTIONS: &[&str] = &[
"prompt",
"window",
"quote",
"behavior",
"search",
"debug",
"updater",
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/config/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
48 changes: 37 additions & 11 deletions src-tauri/src/config/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,17 @@
use serde::{Deserialize, Serialize};

use super::defaults::{
DEFAULT_DEBUG_TRACE_ENABLED, DEFAULT_JUDGE_TIMEOUT_S, DEFAULT_KEEP_WARM_INACTIVITY_MINUTES,
DEFAULT_MAX_CHAT_HEIGHT, DEFAULT_MAX_IMAGES, DEFAULT_MAX_ITERATIONS, DEFAULT_NUM_CTX,
DEFAULT_OLLAMA_URL, DEFAULT_OVERLAY_WIDTH, DEFAULT_PIPELINE_WALL_CLOCK_BUDGET_S,
DEFAULT_QUOTE_MAX_CONTEXT_LENGTH, DEFAULT_QUOTE_MAX_DISPLAY_CHARS,
DEFAULT_QUOTE_MAX_DISPLAY_LINES, DEFAULT_READER_BATCH_TIMEOUT_S,
DEFAULT_READER_PER_URL_TIMEOUT_S, DEFAULT_READER_URL, DEFAULT_ROUTER_TIMEOUT_S,
DEFAULT_SEARCH_TIMEOUT_S, DEFAULT_SEARXNG_MAX_RESULTS, DEFAULT_SEARXNG_URL,
DEFAULT_SYSTEM_CUSTOMIZED, DEFAULT_SYSTEM_PROMPT_BASE, DEFAULT_TEXT_BASE_PX,
DEFAULT_TEXT_FONT_WEIGHT, DEFAULT_TEXT_LETTER_SPACING_PX, DEFAULT_TEXT_LINE_HEIGHT,
DEFAULT_TOP_K_URLS, DEFAULT_UPDATER_AUTO_CHECK, DEFAULT_UPDATER_CHECK_INTERVAL_HOURS,
DEFAULT_UPDATER_MANIFEST_URL,
DEFAULT_AUTO_CLOSE, DEFAULT_AUTO_REPLACE, DEFAULT_DEBUG_TRACE_ENABLED, DEFAULT_JUDGE_TIMEOUT_S,
DEFAULT_KEEP_WARM_INACTIVITY_MINUTES, DEFAULT_MAX_CHAT_HEIGHT, DEFAULT_MAX_IMAGES,
DEFAULT_MAX_ITERATIONS, DEFAULT_NUM_CTX, DEFAULT_OLLAMA_URL, DEFAULT_OVERLAY_WIDTH,
DEFAULT_PIPELINE_WALL_CLOCK_BUDGET_S, DEFAULT_QUOTE_MAX_CONTEXT_LENGTH,
DEFAULT_QUOTE_MAX_DISPLAY_CHARS, DEFAULT_QUOTE_MAX_DISPLAY_LINES,
DEFAULT_READER_BATCH_TIMEOUT_S, DEFAULT_READER_PER_URL_TIMEOUT_S, DEFAULT_READER_URL,
DEFAULT_ROUTER_TIMEOUT_S, DEFAULT_SEARCH_TIMEOUT_S, DEFAULT_SEARXNG_MAX_RESULTS,
DEFAULT_SEARXNG_URL, DEFAULT_SYSTEM_CUSTOMIZED, DEFAULT_SYSTEM_PROMPT_BASE,
DEFAULT_TEXT_BASE_PX, DEFAULT_TEXT_FONT_WEIGHT, DEFAULT_TEXT_LETTER_SPACING_PX,
DEFAULT_TEXT_LINE_HEIGHT, DEFAULT_TOP_K_URLS, DEFAULT_UPDATER_AUTO_CHECK,
DEFAULT_UPDATER_CHECK_INTERVAL_HOURS, DEFAULT_UPDATER_MANIFEST_URL,
};

/// Static, user-tunable inference daemon configuration.
Expand Down Expand Up @@ -172,6 +172,31 @@ impl Default for QuoteSection {
}
}

/// Selection-replacement behavior for the `/rewrite` and `/refine` commands.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct BehaviorSection {
/// When `true`, a `/rewrite` or `/refine` result is written straight back
/// into the source app (replacing the selection) as soon as the model
/// finishes, with no Replace-button click required. When `false`, the
/// user triggers the write manually via the in-chat Replace button.
pub auto_replace: bool,
/// When `true`, the overlay dismisses itself after a `/rewrite` or
/// `/refine` result is successfully replaced into the source app, whether
/// the replace was automatic (`auto_replace`) or a manual Replace click.
/// Independent of `auto_replace`; only closes on a successful replace.
pub auto_close: bool,
}

impl Default for BehaviorSection {
fn default() -> Self {
Self {
auto_replace: DEFAULT_AUTO_REPLACE,
auto_close: DEFAULT_AUTO_CLOSE,
}
}
}

/// Search pipeline and service configuration.
///
/// Service URLs control where the SearXNG and reader sidecar processes live.
Expand Down Expand Up @@ -306,6 +331,7 @@ pub struct AppConfig {
pub prompt: PromptSection,
pub window: WindowSection,
pub quote: QuoteSection,
pub behavior: BehaviorSection,
pub search: SearchSection,
pub debug: DebugSection,
#[serde(default)]
Expand Down
77 changes: 66 additions & 11 deletions src-tauri/src/config/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,23 @@
use std::path::PathBuf;

use super::defaults::{
DEFAULT_DEBUG_TRACE_ENABLED, DEFAULT_JUDGE_TIMEOUT_S, DEFAULT_KEEP_WARM_INACTIVITY_MINUTES,
DEFAULT_MAX_CHAT_HEIGHT, DEFAULT_MAX_IMAGES, DEFAULT_MAX_ITERATIONS, DEFAULT_NUM_CTX,
DEFAULT_OLLAMA_URL, DEFAULT_OVERLAY_WIDTH, DEFAULT_QUOTE_MAX_CONTEXT_LENGTH,
DEFAULT_QUOTE_MAX_DISPLAY_CHARS, DEFAULT_QUOTE_MAX_DISPLAY_LINES,
DEFAULT_READER_BATCH_TIMEOUT_S, DEFAULT_READER_PER_URL_TIMEOUT_S, DEFAULT_READER_URL,
DEFAULT_ROUTER_TIMEOUT_S, DEFAULT_SEARCH_TIMEOUT_S, DEFAULT_SEARXNG_MAX_RESULTS,
DEFAULT_SEARXNG_URL, DEFAULT_SYSTEM_PROMPT_BASE, DEFAULT_TEXT_BASE_PX,
DEFAULT_TEXT_FONT_WEIGHT, DEFAULT_TEXT_LETTER_SPACING_PX, DEFAULT_TEXT_LINE_HEIGHT,
DEFAULT_TOP_K_URLS, DEFAULT_UPDATER_CHECK_INTERVAL_HOURS, DEFAULT_UPDATER_MANIFEST_URL,
DEFAULT_AUTO_CLOSE, DEFAULT_AUTO_REPLACE, DEFAULT_DEBUG_TRACE_ENABLED, DEFAULT_JUDGE_TIMEOUT_S,
DEFAULT_KEEP_WARM_INACTIVITY_MINUTES, DEFAULT_MAX_CHAT_HEIGHT, DEFAULT_MAX_IMAGES,
DEFAULT_MAX_ITERATIONS, DEFAULT_NUM_CTX, DEFAULT_OLLAMA_URL, DEFAULT_OVERLAY_WIDTH,
DEFAULT_QUOTE_MAX_CONTEXT_LENGTH, DEFAULT_QUOTE_MAX_DISPLAY_CHARS,
DEFAULT_QUOTE_MAX_DISPLAY_LINES, DEFAULT_READER_BATCH_TIMEOUT_S,
DEFAULT_READER_PER_URL_TIMEOUT_S, DEFAULT_READER_URL, DEFAULT_ROUTER_TIMEOUT_S,
DEFAULT_SEARCH_TIMEOUT_S, DEFAULT_SEARXNG_MAX_RESULTS, DEFAULT_SEARXNG_URL,
DEFAULT_SYSTEM_PROMPT_BASE, DEFAULT_TEXT_BASE_PX, DEFAULT_TEXT_FONT_WEIGHT,
DEFAULT_TEXT_LETTER_SPACING_PX, DEFAULT_TEXT_LINE_HEIGHT, DEFAULT_TOP_K_URLS,
DEFAULT_UPDATER_CHECK_INTERVAL_HOURS, DEFAULT_UPDATER_MANIFEST_URL,
SLASH_COMMAND_PROMPT_APPENDIX,
};
use super::error::ConfigError;
use super::loader::{compose_system_prompt, load_from_path};
use super::schema::{
AppConfig, DebugSection, InferenceSection, PromptSection, QuoteSection, SearchSection,
UpdaterSection, WindowSection,
AppConfig, BehaviorSection, DebugSection, InferenceSection, PromptSection, QuoteSection,
SearchSection, UpdaterSection, WindowSection,
};
use super::writer::atomic_write;

Expand Down Expand Up @@ -1199,6 +1200,60 @@ fn config_error_io_error_serializes_io_source_as_display_string() {
assert_eq!(json["source"], "denied here");
}

// ── behavior section ────────────────────────────────────────────────────────

#[test]
fn behavior_section_default_matches_compiled_defaults() {
let b = BehaviorSection::default();
assert_eq!(b.auto_replace, DEFAULT_AUTO_REPLACE);
assert_eq!(b.auto_close, DEFAULT_AUTO_CLOSE);
}

#[test]
fn app_config_default_includes_behavior_section_with_compiled_defaults() {
let c = AppConfig::default();
assert_eq!(c.behavior.auto_replace, DEFAULT_AUTO_REPLACE);
assert_eq!(c.behavior.auto_close, DEFAULT_AUTO_CLOSE);
}

#[test]
fn behavior_auto_replace_round_trips_through_load() {
let dir = fresh_temp_dir();
let path = config_path_in(&dir);
std::fs::write(&path, "[behavior]\nauto_replace = true\n").unwrap();
let loaded = load_from_path(&path).unwrap();
assert!(loaded.behavior.auto_replace);
}

#[test]
fn behavior_auto_close_round_trips_through_load() {
let dir = fresh_temp_dir();
let path = config_path_in(&dir);
std::fs::write(&path, "[behavior]\nauto_close = true\n").unwrap();
let loaded = load_from_path(&path).unwrap();
assert!(loaded.behavior.auto_close);
}

#[test]
fn toml_without_behavior_section_deserializes_to_defaults() {
let dir = fresh_temp_dir();
let path = config_path_in(&dir);
std::fs::write(
&path,
"[inference]\nollama_url = \"http://127.0.0.1:11434\"\n",
)
.unwrap();
let loaded = load_from_path(&path).unwrap();
assert_eq!(
loaded.behavior.auto_replace, DEFAULT_AUTO_REPLACE,
"missing [behavior] section must deserialize to defaults via #[serde(default)]"
);
assert_eq!(
loaded.behavior.auto_close, DEFAULT_AUTO_CLOSE,
"missing [behavior] section must deserialize to defaults via #[serde(default)]"
);
}

// ── debug section ───────────────────────────────────────────────────────────

#[test]
Expand Down
11 changes: 9 additions & 2 deletions src-tauri/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ mod activator;
mod cg_displays;
pub mod context;
pub mod permissions;
pub mod replace;

use std::sync::{
atomic::{AtomicBool, Ordering},
Expand Down Expand Up @@ -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| {
Expand Down Expand Up @@ -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,
Expand Down
Loading