diff --git a/CHANGELOG.md b/CHANGELOG.md index c1ae3f3b..33b26c69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable user-facing changes to Koe are documented here. ## Unreleased +### Added + +- The clipboard restoration delay after automatic paste is now configurable via `clipboard.restore_delay_ms` in `config.yaml` (default 1500, range 0–60000; 0 restores immediately after the paste completes). Both the normal paste flow and the experimental ASR-first flow honor the setting; invalid values fall back to 1500 with a warning without affecting other configuration. + ### Changed - Reordered the built-in user prompt so stable dictionary context precedes per-request ASR content, improving exact-prefix cache reuse for compatible LLM providers when the rendered dictionary remains unchanged. Existing custom `user_prompt.txt` files are not overwritten. diff --git a/DESIGN.md b/DESIGN.md index ef53593f..ed807a12 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -804,7 +804,7 @@ hotkey: trigger_mode: "hold" # hold | toggle | double_tap ``` -> **Note:** The tap/hold threshold (180ms), audio framing, and paste timing are still hardcoded. Provider credentials, local model or locale selection, hotkeys, cue sounds, and prompt file paths are user-configurable. Advanced fields such as custom ASR headers, raw keycodes, and `llm.no_reasoning_control` are currently edited in `config.yaml`. +> **Note:** The tap/hold threshold (180ms), audio framing, and the pre-paste/post-event stabilization waits are still hardcoded; the clipboard restoration delay is configurable via `clipboard.restore_delay_ms`. Provider credentials, local model or locale selection, hotkeys, cue sounds, and prompt file paths are user-configurable. Advanced fields such as custom ASR headers, raw keycodes, and `llm.no_reasoning_control` are currently edited in `config.yaml`. ### 14.2 Configuration Rules @@ -1360,11 +1360,13 @@ Recommended default: After pasting: -1. Wait for 1500ms +1. Wait for the configured `clipboard.restore_delay_ms` (default 1500ms, range 0–60000; 0 restores immediately after the paste completion callback). The effective value is validated at config load, snapshotted per session, and shared by both automatic paste flows (normal final-text and experimental ASR-first) 2. Check whether the current clipboard still contains the content written by this application 3. If the user copied new content during this period, do not restore, to avoid overwriting the user's new clipboard contents 4. If the clipboard is unchanged, restore to the pre-session contents +Output that is intentionally delivered via the clipboard (missing Accessibility permission, prompt-template rewrites, or an ASR-first correction that could not be applied in place) is not restored. + ### 20.14 Session End 1. Rust cleans up the session object diff --git a/KoeApp/Koe/AppDelegate/SPAppDelegate.m b/KoeApp/Koe/AppDelegate/SPAppDelegate.m index a06ebbe6..e52454ef 100644 --- a/KoeApp/Koe/AppDelegate/SPAppDelegate.m +++ b/KoeApp/Koe/AppDelegate/SPAppDelegate.m @@ -5,6 +5,7 @@ #import "SPAudioDeviceManager.h" #import "SPRustBridge.h" #import "SPClipboardManager.h" +#import "SPClipboardRestorePolicy.h" #import "SPPasteManager.h" #import "SPInstantPasteGuard.h" #import "SPCuePlayer.h" @@ -33,6 +34,9 @@ @interface SPAppDelegate () + +@class SPClipboardManager; + +/// Fallback restoration delay used only if a paste happens before any session +/// snapshot was captured. The authoritative default and validation live in the +/// Rust config layer (`clipboard.restore_delay_ms`); this value mirrors it. +extern const NSUInteger SPClipboardRestoreFallbackDelayMs; + +/// Session-scoped clipboard restoration policy shared by both automatic paste +/// flows (normal final-text and experimental ASR-first). The effective delay +/// is snapshotted once per voice-input session, so a config edit during an +/// active session takes effect on the next session. +@interface SPClipboardRestorePolicy : NSObject + +@property (nonatomic, strong) SPClipboardManager *clipboardManager; + +/// Capture the effective restoration delay for the session that just began. +- (void)captureSessionRestoreDelayMs:(NSUInteger)delayMs; + +/// Schedule clipboard restoration using the current session's snapshot. +/// The single entry point for every automatic paste completion callback; +/// clipboard-only delivery must not call this. +- (void)scheduleRestoreForCurrentSession; + +@end diff --git a/KoeApp/Koe/Clipboard/SPClipboardRestorePolicy.m b/KoeApp/Koe/Clipboard/SPClipboardRestorePolicy.m new file mode 100644 index 00000000..edef8aa4 --- /dev/null +++ b/KoeApp/Koe/Clipboard/SPClipboardRestorePolicy.m @@ -0,0 +1,28 @@ +#import "SPClipboardRestorePolicy.h" +#import "SPClipboardManager.h" + +const NSUInteger SPClipboardRestoreFallbackDelayMs = 1500; + +@interface SPClipboardRestorePolicy () +@property (nonatomic, assign) NSUInteger sessionRestoreDelayMs; +@end + +@implementation SPClipboardRestorePolicy + +- (instancetype)init { + self = [super init]; + if (self) { + _sessionRestoreDelayMs = SPClipboardRestoreFallbackDelayMs; + } + return self; +} + +- (void)captureSessionRestoreDelayMs:(NSUInteger)delayMs { + self.sessionRestoreDelayMs = delayMs; +} + +- (void)scheduleRestoreForCurrentSession { + [self.clipboardManager scheduleRestoreAfterDelay:self.sessionRestoreDelayMs]; +} + +@end diff --git a/KoeApp/Koe/Clipboard/SPClipboardRestorePolicyTests.m b/KoeApp/Koe/Clipboard/SPClipboardRestorePolicyTests.m new file mode 100644 index 00000000..791d5d16 --- /dev/null +++ b/KoeApp/Koe/Clipboard/SPClipboardRestorePolicyTests.m @@ -0,0 +1,84 @@ +#import +#import "SPClipboardRestorePolicy.h" +#import "SPClipboardManager.h" + +/// Test double: records restoration scheduling instead of touching the real +/// pasteboard or dispatch timers, so tests stay deterministic and never +/// overwrite the developer's clipboard. +@interface SPClipboardManagerRestoreRecorder : SPClipboardManager +@property (nonatomic, assign) NSInteger scheduleCount; +@property (nonatomic, assign) NSUInteger lastScheduledDelayMs; +@end + +@implementation SPClipboardManagerRestoreRecorder + +- (void)scheduleRestoreAfterDelay:(NSUInteger)delayMs { + self.scheduleCount += 1; + self.lastScheduledDelayMs = delayMs; +} + +@end + +@interface SPClipboardRestorePolicyTests : XCTestCase +@property (nonatomic, strong) SPClipboardRestorePolicy *policy; +@property (nonatomic, strong) SPClipboardManagerRestoreRecorder *recorder; +@end + +@implementation SPClipboardRestorePolicyTests + +- (void)setUp { + [super setUp]; + self.recorder = [[SPClipboardManagerRestoreRecorder alloc] init]; + self.policy = [[SPClipboardRestorePolicy alloc] init]; + self.policy.clipboardManager = self.recorder; +} + +- (void)testScheduleUsesSessionSnapshotValue { + [self.policy captureSessionRestoreDelayMs:250]; + [self.policy scheduleRestoreForCurrentSession]; + + XCTAssertEqual(self.recorder.scheduleCount, 1); + XCTAssertEqual(self.recorder.lastScheduledDelayMs, (NSUInteger)250); +} + +- (void)testScheduleWithoutCaptureUsesFallbackDelay { + [self.policy scheduleRestoreForCurrentSession]; + + XCTAssertEqual(self.recorder.scheduleCount, 1); + XCTAssertEqual(self.recorder.lastScheduledDelayMs, SPClipboardRestoreFallbackDelayMs); +} + +- (void)testFallbackDelayMatchesHistoricalDefault { + XCTAssertEqual(SPClipboardRestoreFallbackDelayMs, (NSUInteger)1500); +} + +- (void)testZeroDelayIsScheduledNotSkipped { + [self.policy captureSessionRestoreDelayMs:0]; + [self.policy scheduleRestoreForCurrentSession]; + + XCTAssertEqual(self.recorder.scheduleCount, 1); + XCTAssertEqual(self.recorder.lastScheduledDelayMs, (NSUInteger)0); +} + +- (void)testNewSessionSnapshotReplacesPreviousOne { + [self.policy captureSessionRestoreDelayMs:250]; + [self.policy scheduleRestoreForCurrentSession]; + + // A consecutive session captures its own policy; its automatic paste + // must use the new snapshot, not the previous session's. + [self.policy captureSessionRestoreDelayMs:3000]; + [self.policy scheduleRestoreForCurrentSession]; + + XCTAssertEqual(self.recorder.scheduleCount, 2); + XCTAssertEqual(self.recorder.lastScheduledDelayMs, (NSUInteger)3000); +} + +- (void)testNoScheduleHappensWithoutExplicitRequest { + [self.policy captureSessionRestoreDelayMs:250]; + + // Clipboard-only delivery never calls the entry point, so nothing may + // be scheduled as a side effect of capturing a session policy. + XCTAssertEqual(self.recorder.scheduleCount, 0); +} + +@end diff --git a/KoeApp/project.yml b/KoeApp/project.yml index 78f58efe..f4629cca 100644 --- a/KoeApp/project.yml +++ b/KoeApp/project.yml @@ -124,6 +124,9 @@ targets: - path: Koe/Hotkey/SPHotkeyMonitor.m - path: Koe/History/SPHistoryManager.m - path: Koe/History/SPHistoryManagerTests.m + - path: Koe/Clipboard/SPClipboardManager.m + - path: Koe/Clipboard/SPClipboardRestorePolicy.m + - path: Koe/Clipboard/SPClipboardRestorePolicyTests.m settings: base: GENERATE_INFOPLIST_FILE: YES diff --git a/koe-core/src/config.rs b/koe-core/src/config.rs index 6007aeae..651043a8 100644 --- a/koe-core/src/config.rs +++ b/koe-core/src/config.rs @@ -15,6 +15,8 @@ pub struct Config { #[serde(default)] pub dictionary: DictionarySection, #[serde(default)] + pub clipboard: ClipboardSection, + #[serde(default)] pub hotkey: HotkeySection, #[serde(default)] pub overlay: OverlaySection, @@ -549,6 +551,245 @@ pub struct DictionarySection { pub path: String, } +// ─── Clipboard Configuration ──────────────────────────────────────── + +pub const DEFAULT_CLIPBOARD_RESTORE_DELAY_MS: u32 = 1500; +pub const MAX_CLIPBOARD_RESTORE_DELAY_MS: u32 = 60_000; + +/// Clipboard behavior for automatic paste. +#[derive(Debug, Clone)] +pub struct ClipboardSection { + /// How long to wait after the automatic-paste completion callback before + /// restoring the pre-session clipboard contents, in milliseconds. + /// Valid range 0..=60000. 0 schedules restoration immediately after the + /// paste completion callback; it does not disable restoration. + pub restore_delay_ms: u32, +} + +impl Default for ClipboardSection { + fn default() -> Self { + Self { + restore_delay_ms: DEFAULT_CLIPBOARD_RESTORE_DELAY_MS, + } + } +} + +/// Tolerant deserialization: an invalid `clipboard` section or +/// `restore_delay_ms` value falls back to the default with a warning instead +/// of failing the whole config load, so a clipboard typo cannot poison +/// unrelated ASR/LLM/hotkey settings. The warning never includes clipboard +/// contents — only the key name and the accepted range. Custom visitors are +/// required (rather than deserializing into `serde_yaml::Value`) because +/// serde_yaml hard-errors on integers wider than u64 unless the visitor +/// accepts i128/u128. +impl<'de> Deserialize<'de> for ClipboardSection { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct SectionVisitor; + + impl<'de> serde::de::Visitor<'de> for SectionVisitor { + type Value = ClipboardSection; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a clipboard config mapping") + } + + // `clipboard:` with no value. + fn visit_unit(self) -> std::result::Result { + Ok(ClipboardSection::default()) + } + + fn visit_none(self) -> std::result::Result { + Ok(ClipboardSection::default()) + } + + fn visit_map(self, mut map: A) -> std::result::Result + where + A: serde::de::MapAccess<'de>, + { + let mut section = ClipboardSection::default(); + while let Some(key) = map.next_key::()? { + if key == "restore_delay_ms" { + section.restore_delay_ms = map.next_value::()?.0; + } else { + map.next_value::()?; + } + } + Ok(section) + } + + fn visit_bool( + self, + _: bool, + ) -> std::result::Result { + Ok(warn_clipboard_section_not_mapping()) + } + + fn visit_i64(self, _: i64) -> std::result::Result { + Ok(warn_clipboard_section_not_mapping()) + } + + fn visit_u64(self, _: u64) -> std::result::Result { + Ok(warn_clipboard_section_not_mapping()) + } + + fn visit_i128( + self, + _: i128, + ) -> std::result::Result { + Ok(warn_clipboard_section_not_mapping()) + } + + fn visit_u128( + self, + _: u128, + ) -> std::result::Result { + Ok(warn_clipboard_section_not_mapping()) + } + + fn visit_f64(self, _: f64) -> std::result::Result { + Ok(warn_clipboard_section_not_mapping()) + } + + fn visit_str( + self, + _: &str, + ) -> std::result::Result { + Ok(warn_clipboard_section_not_mapping()) + } + + fn visit_seq(self, mut seq: A) -> std::result::Result + where + A: serde::de::SeqAccess<'de>, + { + while seq.next_element::()?.is_some() {} + Ok(warn_clipboard_section_not_mapping()) + } + } + + deserializer.deserialize_any(SectionVisitor) + } +} + +fn warn_clipboard_section_not_mapping() -> ClipboardSection { + log::warn!("config: `clipboard` section is not a mapping; using defaults"); + ClipboardSection::default() +} + +/// A `restore_delay_ms` value that never fails deserialization: any +/// non-integer or out-of-range value becomes the default with a warning. +struct TolerantRestoreDelay(u32); + +fn validate_restore_delay(value: Option) -> u32 { + match value { + Some(v) if v <= MAX_CLIPBOARD_RESTORE_DELAY_MS as u64 => v as u32, + _ => { + log::warn!( + "config: `clipboard.restore_delay_ms` must be an integer between 0 and \ + {MAX_CLIPBOARD_RESTORE_DELAY_MS}; falling back to \ + {DEFAULT_CLIPBOARD_RESTORE_DELAY_MS}" + ); + DEFAULT_CLIPBOARD_RESTORE_DELAY_MS + } + } +} + +impl<'de> Deserialize<'de> for TolerantRestoreDelay { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct DelayVisitor; + + impl<'de> serde::de::Visitor<'de> for DelayVisitor { + type Value = TolerantRestoreDelay; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("an integer between 0 and 60000") + } + + fn visit_u64(self, v: u64) -> std::result::Result { + Ok(TolerantRestoreDelay(validate_restore_delay(Some(v)))) + } + + fn visit_i64(self, v: i64) -> std::result::Result { + Ok(TolerantRestoreDelay(validate_restore_delay( + u64::try_from(v).ok(), + ))) + } + + fn visit_u128( + self, + v: u128, + ) -> std::result::Result { + Ok(TolerantRestoreDelay(validate_restore_delay( + u64::try_from(v).ok(), + ))) + } + + fn visit_i128( + self, + v: i128, + ) -> std::result::Result { + Ok(TolerantRestoreDelay(validate_restore_delay( + u64::try_from(v).ok(), + ))) + } + + fn visit_f64(self, _: f64) -> std::result::Result { + Ok(TolerantRestoreDelay(validate_restore_delay(None))) + } + + fn visit_bool( + self, + _: bool, + ) -> std::result::Result { + Ok(TolerantRestoreDelay(validate_restore_delay(None))) + } + + fn visit_str( + self, + _: &str, + ) -> std::result::Result { + Ok(TolerantRestoreDelay(validate_restore_delay(None))) + } + + // A missing value (`restore_delay_ms:` with nothing after it) is + // treated like an absent field, not an invalid one — no warning. + fn visit_unit(self) -> std::result::Result { + Ok(TolerantRestoreDelay(DEFAULT_CLIPBOARD_RESTORE_DELAY_MS)) + } + + fn visit_none(self) -> std::result::Result { + Ok(TolerantRestoreDelay(DEFAULT_CLIPBOARD_RESTORE_DELAY_MS)) + } + + fn visit_seq(self, mut seq: A) -> std::result::Result + where + A: serde::de::SeqAccess<'de>, + { + while seq.next_element::()?.is_some() {} + Ok(TolerantRestoreDelay(validate_restore_delay(None))) + } + + fn visit_map(self, mut map: A) -> std::result::Result + where + A: serde::de::MapAccess<'de>, + { + while map + .next_entry::()? + .is_some() + {} + Ok(TolerantRestoreDelay(validate_restore_delay(None))) + } + } + + deserializer.deserialize_any(DelayVisitor) + } +} + #[derive(Debug, Deserialize, Clone)] pub struct OverlaySection { #[serde(default = "default_overlay_font_family")] @@ -1875,6 +2116,14 @@ feedback: dictionary: path: "dictionary.txt" # relative to ~/.koe/ +clipboard: + # 自动粘贴完成后,恢复原剪贴板内容前的等待时间(毫秒)。 + # 取值范围 0-60000;0 表示粘贴完成后立即恢复(并非禁用恢复)。 + # 值太小可能导致慢速应用还没读取剪贴板就被恢复;值太大则旧剪贴板内容回来得更晚。 + # 仅影响自动粘贴流程;主动保留在剪贴板中的结果(如模板改写、无辅助功能权限时)不会被恢复。 + # 无效值会回退到 1500 并记录警告,不影响其他配置项。 + restore_delay_ms: 1500 + hotkey: # 触发键:fn | left_option | right_option | left_command | right_command | left_control | right_control # 也可以填 macOS keycode 数字来使用非修饰键,例如 122 (F1)、120 (F2)、99 (F3) 等 @@ -2358,4 +2607,84 @@ mod tests { let _ = fs::remove_dir_all(&tmp); } + + // ─── Clipboard Section ────────────────────────────────────────── + + fn clipboard_delay_from_yaml(yaml: &str) -> u32 { + let config: Config = serde_yaml::from_str(yaml).unwrap(); + config.clipboard.restore_delay_ms + } + + #[test] + fn clipboard_defaults_when_section_absent() { + assert_eq!(clipboard_delay_from_yaml("{}"), 1500); + assert_eq!(Config::default().clipboard.restore_delay_ms, 1500); + } + + #[test] + fn clipboard_defaults_when_field_absent_or_null() { + assert_eq!(clipboard_delay_from_yaml("clipboard: {}"), 1500); + assert_eq!(clipboard_delay_from_yaml("clipboard:\n"), 1500); + assert_eq!( + clipboard_delay_from_yaml("clipboard:\n restore_delay_ms:\n"), + 1500 + ); + } + + #[test] + fn clipboard_accepts_valid_range() { + assert_eq!( + clipboard_delay_from_yaml("clipboard:\n restore_delay_ms: 0"), + 0 + ); + assert_eq!( + clipboard_delay_from_yaml("clipboard:\n restore_delay_ms: 1500"), + 1500 + ); + assert_eq!( + clipboard_delay_from_yaml("clipboard:\n restore_delay_ms: 60000"), + 60000 + ); + } + + #[test] + fn clipboard_invalid_values_fall_back_to_default() { + for invalid in [ + "-1", + "1.5", + "\"fast\"", + "true", + "60001", + "99999999999999999999999999", // overflows any integer type + "[1500]", + ] { + assert_eq!( + clipboard_delay_from_yaml(&format!("clipboard:\n restore_delay_ms: {invalid}")), + 1500, + "restore_delay_ms {invalid} should fall back to the default" + ); + } + } + + #[test] + fn clipboard_section_with_invalid_shape_falls_back() { + assert_eq!(clipboard_delay_from_yaml("clipboard: fast"), 1500); + assert_eq!(clipboard_delay_from_yaml("clipboard: 1500"), 1500); + } + + #[test] + fn clipboard_invalid_value_preserves_unrelated_sections() { + let yaml = r#" +asr: + provider: "qwen" +llm: + timeout_ms: 1234 +clipboard: + restore_delay_ms: "not-a-number" +"#; + let config: Config = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(config.clipboard.restore_delay_ms, 1500); + assert_eq!(config.asr.provider, "qwen"); + assert_eq!(config.llm.timeout_ms, 1234); + } } diff --git a/koe-core/src/ffi.rs b/koe-core/src/ffi.rs index 0adcf31d..bcddbdcb 100644 --- a/koe-core/src/ffi.rs +++ b/koe-core/src/ffi.rs @@ -59,8 +59,14 @@ pub struct SPCallbacks { /// free them. `llm_applied` is true only when LLM correction ran and its /// output was used (false when the LLM is disabled, fails, or its output /// is discarded as degenerate). - pub on_session_result_meta: - Option, + pub on_session_result_meta: Option< + extern "C" fn( + token: u64, + asr_text: *const c_char, + asr_provider: *const c_char, + llm_applied: bool, + ), + >, } static CALLBACKS: Mutex> = Mutex::new(None); @@ -166,7 +172,12 @@ pub fn invoke_asr_final_text(token: u64, text: &str) { } } -pub fn invoke_session_result_meta(token: u64, asr_text: &str, asr_provider: &str, llm_applied: bool) { +pub fn invoke_session_result_meta( + token: u64, + asr_text: &str, + asr_provider: &str, + llm_applied: bool, +) { let f = { let cb = CALLBACKS.lock().unwrap(); cb.as_ref().and_then(|cbs| cbs.on_session_result_meta) @@ -199,6 +210,15 @@ pub struct SPFeedbackConfig { pub mute_system_output: bool, } +/// Clipboard configuration exposed to the Obj-C layer +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct SPClipboardConfig { + /// Delay (ms) between the automatic-paste completion callback and + /// clipboard restoration. Validated to 0..=60000 at config load. + pub restore_delay_ms: u32, +} + /// Hotkey configuration exposed to the Obj-C layer #[repr(C)] #[derive(Debug, Clone, Copy)] diff --git a/koe-core/src/lib.rs b/koe-core/src/lib.rs index 6f62079e..18020a81 100644 --- a/koe-core/src/lib.rs +++ b/koe-core/src/lib.rs @@ -15,7 +15,7 @@ use crate::ffi::{ cstr_to_str, invoke_asr_final_text, invoke_final_text_ready, invoke_interim_text, invoke_rewrite_text_ready, invoke_session_error, invoke_session_ready, invoke_session_result_meta, invoke_session_warning, invoke_state_changed, SPCallbacks, - SPFeedbackConfig, SPHotkeyConfig, SPSessionContext, SPSessionMode, + SPClipboardConfig, SPFeedbackConfig, SPHotkeyConfig, SPSessionContext, SPSessionMode, }; #[cfg(feature = "mlx")] use crate::llm::mlx::MlxLlmProvider; @@ -703,6 +703,24 @@ pub extern "C" fn sp_core_get_feedback_config() -> SPFeedbackConfig { } } +/// Query current clipboard configuration. +/// Returns the validated restoration delay from the cached config — never the +/// raw YAML scalar — so field validation, defaults, and session snapshot +/// semantics apply. Callers snapshot this once per session. +#[no_mangle] +pub extern "C" fn sp_core_get_clipboard_config() -> SPClipboardConfig { + let global = CORE.lock().unwrap(); + if let Some(ref core) = *global { + SPClipboardConfig { + restore_delay_ms: core.config.clipboard.restore_delay_ms, + } + } else { + SPClipboardConfig { + restore_delay_ms: config::DEFAULT_CLIPBOARD_RESTORE_DELAY_MS, + } + } +} + /// Query current hotkey configuration. /// Returns key codes and modifier flags for the configured trigger key. fn hotkey_trigger_mode_code(trigger_mode: &str) -> u8 {