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: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
45 changes: 29 additions & 16 deletions KoeApp/Koe/AppDelegate/SPAppDelegate.m
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -33,6 +34,9 @@ @interface SPAppDelegate () <SPAudioDeviceManagerDelegate, SPOverlayPanelDelegat
// Experimental paste-ASR-first flow (experimental.paste_asr_first):
// raw text pasted at ASR-final time, awaiting the LLM correction.
@property (nonatomic, strong) SPInstantPasteGuard *instantPasteGuard;
// Session-scoped clipboard restoration policy; the effective delay is
// snapshotted when a session begins (see beginRustSessionWithMode:).
@property (nonatomic, strong) SPClipboardRestorePolicy *clipboardRestorePolicy;
@property (nonatomic, copy) NSString *instantPastedText;
@property (nonatomic, assign) BOOL instantPasteKeepClipboard;
@property (nonatomic, strong) id numberKeyMonitor;
Expand Down Expand Up @@ -177,6 +181,8 @@ - (void)applicationDidFinishLaunching:(NSNotification *)notification {
// Initialize components
self.cuePlayer = [[SPCuePlayer alloc] init];
self.clipboardManager = [[SPClipboardManager alloc] init];
self.clipboardRestorePolicy = [[SPClipboardRestorePolicy alloc] init];
self.clipboardRestorePolicy.clipboardManager = self.clipboardManager;
self.pasteManager = [[SPPasteManager alloc] init];
self.instantPasteGuard = [[SPInstantPasteGuard alloc] init];
self.audioCaptureManager = [[SPAudioCaptureManager alloc] init];
Expand Down Expand Up @@ -382,6 +388,25 @@ - (void)hotkeyMonitorDidCancelTrigger {
}
}

/// Begin a Rust session and capture per-session state shared by the hold and
/// tap start paths. Returns NO (after surfacing the error) when the session
/// could not be started.
- (BOOL)beginRustSessionWithMode:(SPSessionModeObjC)mode {
if (![self.rustBridge beginSessionWithMode:mode]) {
[self handleAudioCaptureError:@"Failed to start session"];
return NO;
}
// Snapshot the effective clipboard restoration policy for this session.
// Rust hot-reloads config inside session_begin, so this is the validated
// value governing this session; edits apply from the next session.
[self.clipboardRestorePolicy
captureSessionRestoreDelayMs:sp_core_get_clipboard_config().restore_delay_ms];
self.loggedFirstRecognitionForActivation = NO;
self.recognitionMetricActivationSequence = self.audioCaptureManager.activationSequence;
self.recognitionMetricSessionToken = self.rustBridge.currentSessionToken;
return YES;
}

- (void)hotkeyMonitorDidDetectHoldStart {
NSLog(@"[Koe] Hold start detected");
[self.audioCaptureManager logActivationMilestone:@"hold decision"];
Expand All @@ -403,13 +428,7 @@ - (void)hotkeyMonitorDidDetectHoldStart {
[self.overlayPanel updateState:@"recording"];

// Start Rust session + audio capture
if (![self.rustBridge beginSessionWithMode:SPSessionModeHold]) {
[self handleAudioCaptureError:@"Failed to start session"];
return;
}
self.loggedFirstRecognitionForActivation = NO;
self.recognitionMetricActivationSequence = self.audioCaptureManager.activationSequence;
self.recognitionMetricSessionToken = self.rustBridge.currentSessionToken;
if (![self beginRustSessionWithMode:SPSessionModeHold]) return;
[self startAudioCaptureWithRetryIncludingPreRoll:YES];
}

Expand Down Expand Up @@ -452,13 +471,7 @@ - (void)hotkeyMonitorDidDetectTapStart {
[self.statusBarManager updateState:@"recording"];
[self.overlayPanel updateState:@"recording"];

if (![self.rustBridge beginSessionWithMode:SPSessionModeToggle]) {
[self handleAudioCaptureError:@"Failed to start session"];
return;
}
self.loggedFirstRecognitionForActivation = NO;
self.recognitionMetricActivationSequence = self.audioCaptureManager.activationSequence;
self.recognitionMetricSessionToken = self.rustBridge.currentSessionToken;
if (![self beginRustSessionWithMode:SPSessionModeToggle]) return;
[self startAudioCaptureWithRetryIncludingPreRoll:NO];
}

Expand Down Expand Up @@ -569,7 +582,7 @@ - (void)rustBridgeDidReceiveFinalText:(NSString *)text {
if (accessOK) {
[self.pasteManager simulatePasteWithCompletion:^{
NSLog(@"[Koe] Paste completion callback fired");
[self.clipboardManager scheduleRestoreAfterDelay:1500];
[self.clipboardRestorePolicy scheduleRestoreForCurrentSession];
if (token != self.rustBridge.currentSessionToken) return;
[self.statusBarManager updateState:@"idle"];
[self showPromptTemplateButtonsIfNeededOrDismiss];
Expand Down Expand Up @@ -724,7 +737,7 @@ - (void)maybeInstantPasteAsrText:(NSString *)text {
// The correction may have replaced the clipboard content already
// (fast LLM); in that case the backup must not be restored over it.
if (!self.instantPasteKeepClipboard) {
[self.clipboardManager scheduleRestoreAfterDelay:1500];
[self.clipboardRestorePolicy scheduleRestoreForCurrentSession];
}
if (token != self.rustBridge.currentSessionToken) return;
// Only capture when the correction hasn't been handled yet.
Expand Down
26 changes: 26 additions & 0 deletions KoeApp/Koe/Clipboard/SPClipboardRestorePolicy.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#import <Foundation/Foundation.h>

@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
28 changes: 28 additions & 0 deletions KoeApp/Koe/Clipboard/SPClipboardRestorePolicy.m
Original file line number Diff line number Diff line change
@@ -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
84 changes: 84 additions & 0 deletions KoeApp/Koe/Clipboard/SPClipboardRestorePolicyTests.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#import <XCTest/XCTest.h>
#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
3 changes: 3 additions & 0 deletions KoeApp/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading