Skip to content
Open
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: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,9 @@ The Swift generation loop owns the maximum output-token budget.
reads as overwriting them. Change those pure helpers and their tests rather than nudging offsets
in the controller.
- `SuggestionOverlayPresenter` decides whether a suggestion should be shown or hidden.
- `ActivationIndicatorController` owns the optional caret/field-edge indicator.
- `ActivationIndicatorController` owns the optional caret/field-edge indicator. Selection rewrite
borrows it as its wand button while text is selected (`showRewrite` / `endRewrite`);
`SelectionRewriteCoordinator` owns that flow, its popup and its Replace checks.
- `FocusDebugOverlayController` is for developer visibility and should stay gated behind debug
options, not normal user settings.
- Settings panes (under `Cotabby/UI/Settings/Panes/`) and onboarding views should remain
Expand Down
21 changes: 21 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,26 @@ the single input-capture slot between:
Both use pure trigger state machines, stay pinned to one supported focus sequence, and cancel on
focus change or incompatible input. They do not call a language model.

Selection rewrite is a sibling feature with its own state machine,
[SelectionRewriteCoordinator.swift](Cotabby/App/Coordinators/Rewrite/SelectionRewriteCoordinator.swift)
(hidden → button → popup → generating → preview). Focus snapshots carry the selected text and its
range in the host's value (`selectedText`, `hostSelection`). A stable selection that
[SelectionRewriteEligibility.swift](Cotabby/Support/Rewrite/SelectionRewriteEligibility.swift) accepts
(two words or more, not secure, not a terminal, Cotabby on for the app) turns the field-edge icon into a
wand (`ActivationIndicatorController.showRewrite`) and arms ⌃⌥R through a `TemporaryKeyTap` that exists only
while that selection is armed; the "Rewrite Selected Text" switch in Settings → Rewrite turns all of it off. The popup is
a non-activating panel that takes the keyboard while open, so Return can never reach a chat app.
[RewriteEngine.swift](Cotabby/Services/Rewrite/RewriteEngine.swift) runs one rewrite on the provider
chosen in Settings → Rewrite: `LocalRewriteProvider` (Apple Intelligence, else the selected llama base
model with an example-style prompt that stops at the next `Draft:`), or `APIRewriteProvider` (Claude via
Anthropic's Messages API, other services via OpenAI-compatible chat completions, each service's key in
its own Keychain item). [RewritePromptBuilder.swift](Cotabby/Support/Rewrite/RewritePromptBuilder.swift)
names the draft's language (`RewriteLanguageDetector`), `RewriteOutputCleaner` strips prefaces, fences
and quotes and rejects runaway output, and `RewriteSpecificsCheck` flags numbers and names the user did
not write, which turns plain Replace off. Replace pastes over the selection only after
[SelectionReplaceValidator.swift](Cotabby/Support/Rewrite/SelectionReplaceValidator.swift) confirms the
same field, text and range, then reads the field back; anything else goes to the clipboard with a notice.

[SettingsCoordinator.swift](Cotabby/App/Coordinators/SettingsCoordinator.swift) and
[WelcomeCoordinator.swift](Cotabby/App/Coordinators/WelcomeCoordinator.swift) own app-lifetime AppKit
windows hosting SwiftUI content. Settings and onboarding observe the shared graph. Hiding the menu bar
Expand Down Expand Up @@ -583,6 +603,7 @@ policies, prompt utilities, normalization, and layout logic should receive focus
| Clipboard context is irrelevant | ClipboardRelevanceFilter, ClipboardContentDistiller |
| Screenshot context is stale/noisy | VisualContextCoordinator, OCRTextHygiene |
| Emoji or macro conflicts with suggestions | InlineCommandCoordinator, feature trigger machine |
| Rewrite wand missing, popup misplaced, or Replace copies instead | SelectionRewriteCoordinator, SelectionRewriteEligibility, SelectionReplaceValidator |
| Permission loop or lost grant | PermissionManager, PermissionGuidanceController, app identity |
| Settings/onboarding window issue | SettingsCoordinator, WelcomeCoordinator |
| Settings persistence or domain mismatch | SuggestionSettingsModel, SuggestionSettingsData, SuggestionSettingsStore |
Expand Down
238 changes: 238 additions & 0 deletions Cotabby.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

560 changes: 560 additions & 0 deletions Cotabby/App/Coordinators/Rewrite/SelectionRewriteCoordinator.swift

Large diffs are not rendered by default.

14 changes: 13 additions & 1 deletion Cotabby/App/Coordinators/SettingsCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate {
private let onShowWelcome: () -> Void
private let clearEmojiHistory: () -> Void
private let typingHistoryStore: TypingHistoryStore
private let rewritePreferences: RewritePreferencesStore
private let rewriteCredentials: RewriteCredentialVault
private let rewriteModelList: RewriteModelListService

private var settingsWindowController: NSWindowController?

Expand All @@ -51,7 +54,10 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate {
systemMetricsStore: SystemMetricsStore,
onShowWelcome: @escaping () -> Void,
clearEmojiHistory: @escaping () -> Void,
typingHistoryStore: TypingHistoryStore
typingHistoryStore: TypingHistoryStore,
rewritePreferences: RewritePreferencesStore,
rewriteCredentials: RewriteCredentialVault,
rewriteModelList: RewriteModelListService
) {
self.appUpdateManager = appUpdateManager
self.permissionManager = permissionManager
Expand All @@ -68,6 +74,9 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate {
self.onShowWelcome = onShowWelcome
self.clearEmojiHistory = clearEmojiHistory
self.typingHistoryStore = typingHistoryStore
self.rewritePreferences = rewritePreferences
self.rewriteCredentials = rewriteCredentials
self.rewriteModelList = rewriteModelList
}

/// Shows the settings window, reusing the existing instance if it is already open.
Expand Down Expand Up @@ -98,6 +107,9 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate {
onShowWelcome: onShowWelcome,
clearEmojiHistory: clearEmojiHistory,
typingHistoryStore: typingHistoryStore,
rewritePreferences: rewritePreferences,
rewriteCredentials: rewriteCredentials,
rewriteModelList: rewriteModelList,
onQuit: { NSApplication.shared.terminate(nil) }
)
)
Expand Down
9 changes: 9 additions & 0 deletions Cotabby/App/Core/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
.store(in: &cancellables)

// Selection rewrite borrows the field-edge icon while text is selected; when it gives the icon
// back, show the normal icon for the current focus at once.
activationIndicatorController.onRewriteEnded = { [weak self] in
guard let self else { return }
self.updateActivationIndicator(for: self.focusModel.snapshot)
}

focusModel.$snapshot
.sink { [weak self] snapshot in
self?.updateActivationIndicator(for: snapshot)
Expand Down Expand Up @@ -169,6 +176,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
appUpdateManager.start()
suggestionCoordinator.start()
inlineCommandCoordinator.start()
environment.selectionRewriteCoordinator.start()
welcomeCoordinator.presentIfNeeded()
welcomeCoordinator.presentPermissionReminderIfNeeded()
didStartServices = true
Expand Down Expand Up @@ -253,6 +261,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// Write the field being typed in now; the debounced background save may not have run yet.
environment.typingHistoryStore.flush()
inlineCommandCoordinator.stop()
environment.selectionRewriteCoordinator.stop()
inputMonitor.stop()
focusModel.stop()

Expand Down
57 changes: 56 additions & 1 deletion Cotabby/App/Core/CotabbyAppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ final class CotabbyAppEnvironment {
let performanceMetricsStore: PerformanceMetricsStore
let qualityMetricsStore: SuggestionQualityMetricsStore
let typingHistoryStore: TypingHistoryStore
/// Selection rewrite: the popup flow, started and stopped by `AppDelegate`.
let selectionRewriteCoordinator: SelectionRewriteCoordinator
let settingsCoordinator: SettingsCoordinator
let activationIndicatorController: ActivationIndicatorController
let focusDebugOverlayController: FocusDebugOverlayController?
Expand Down Expand Up @@ -243,6 +245,55 @@ final class CotabbyAppEnvironment {
// "Clear History" control can reach it, and before the picker which reads and writes it.
let emojiUsageStore = EmojiUsageStore()

// Selection rewrite: its own preferences and Keychain items, a local and a cloud provider,
// and the shared inserter and llama runtime. Built once here: the same store and credential
// instances feed the Settings pane and the engine, so a saved change applies at once.
let rewritePreferences = RewritePreferencesStore()
// One Keychain item per cloud service, so switching services keeps every key.
let rewriteCredentials = RewriteCredentialVault()
let anthropicClient = AnthropicMessagesClient()
let rewriteModelList = RewriteModelListService(client: openAICompatibleClient, anthropic: anthropicClient)
let rewriteEngine = RewriteEngine(
local: LocalRewriteProvider(
runtimeManager: runtimeManager,
hasLocalModel: { [weak runtimeModel] in runtimeModel?.selectedModelFilename != nil },
availability: foundationModelAvailabilityService
),
api: APIRewriteProvider(
client: openAICompatibleClient,
anthropic: anthropicClient,
preferences: { [weak rewritePreferences] in rewritePreferences?.preferences ?? .defaults },
credentials: { [weak rewriteCredentials] service in
rewriteCredentials?.store(for: service) ?? InMemoryOpenAICompatibleCredentialStore()
}
),
preferences: { [weak rewritePreferences] in rewritePreferences?.preferences ?? .defaults }
)
let selectionRewriteCoordinator = SelectionRewriteCoordinator(
snapshots: focusModel.$snapshot.eraseToAnyPublisher(),
// `@Published` emits the new value before the store's property changes, so the coordinator
// acts on the emitted value; `dropFirst` skips the replayed current one.
enabledChanges: rewritePreferences.$preferences.map(\.isEnabled).removeDuplicates().dropFirst().eraseToAnyPublisher(),
Comment thread
greptile-apps[bot] marked this conversation as resolved.
currentSnapshot: { [weak focusModel] in focusModel?.snapshot ?? .inactive },
refreshFocus: { [weak focusModel] in focusModel?.refreshNow() },
presenter: SelectionRewritePanelController(indicator: activationIndicatorController),
output: LiveSelectionRewriteTextOutput(inserter: suggestionInserter),
engine: rewriteEngine,
preferences: { [weak rewritePreferences] in rewritePreferences?.preferences ?? .defaults },
isAppAllowed: { [weak suggestionSettings, weak lowPowerModeMonitor] input in
SelectionRewriteEligibility.isAppAllowed(
for: input,
settings: suggestionSettings?.snapshot,
isLowPowerModeEnabled: lowPowerModeMonitor?.isLowPowerModeEnabled ?? false,
disabledDomains: PerDomainDisableSettings.disabledDomains()
)
},
hotkey: TemporaryKeyTap(suppressionController: suppressionController),
keyTap: TemporaryKeyTap(suppressionController: suppressionController),
writingLanguages: { [weak suggestionSettings] in suggestionSettings?.responseLanguages ?? [] },
setFixesMistakes: { [weak rewritePreferences] in rewritePreferences?.setFixesMistakes($0) }
)

let settingsCoordinator = SettingsCoordinator(
appUpdateManager: appUpdateManager,
permissionManager: permissionManager,
Expand All @@ -260,7 +311,10 @@ final class CotabbyAppEnvironment {
welcomeCoordinator?.showWelcome()
},
clearEmojiHistory: { emojiUsageStore.clear() },
typingHistoryStore: typingHistoryStore
typingHistoryStore: typingHistoryStore,
rewritePreferences: rewritePreferences,
rewriteCredentials: rewriteCredentials,
rewriteModelList: rewriteModelList
)

let interactionState = SuggestionInteractionState()
Expand Down Expand Up @@ -367,6 +421,7 @@ final class CotabbyAppEnvironment {
self.performanceMetricsStore = performanceMetricsStore
self.qualityMetricsStore = qualityMetricsStore
self.typingHistoryStore = typingHistoryStore
self.selectionRewriteCoordinator = selectionRewriteCoordinator
self.settingsCoordinator = settingsCoordinator
self.activationIndicatorController = activationIndicatorController
self.focusDebugOverlayController = CotabbyDebugOptions.areOverlaysAvailable
Expand Down
13 changes: 12 additions & 1 deletion Cotabby/Models/Focus/FocusModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,13 @@ nonisolated struct FocusedInputSnapshot: Equatable {
let trailingText: String
let selection: NSRange
let isSecure: Bool
/// The text the user has selected, cut from the same bounded window as `precedingText` and
/// `trailingText`; empty when the selection is a caret. Selection rewrite reads it.
let selectedText: String
/// The selection in the host's full value, for restoring it through `kAXSelectedTextRangeAttribute`;
/// nil when the host only exposes window-relative text markers (Chromium contenteditables), where
/// a rewrite then copies instead of restoring.
let hostSelection: NSRange?

/// True when the resolved field is an xterm.js integrated-terminal surface (VS Code / Cursor /
/// Windsurf terminal, or a browser-hosted web terminal). Set by `FocusSnapshotResolver` from the
Expand Down Expand Up @@ -446,7 +453,9 @@ nonisolated struct FocusedInputSnapshot: Equatable {
fieldPlaceholder: String? = nil,
hostTextMetrics: HostTextMetrics? = nil,
elementFrameRect: CGRect? = nil,
hostMarkedTextRange: NSRange? = nil
hostMarkedTextRange: NSRange? = nil,
selectedText: String = "",
hostSelection: NSRange? = nil
) {
self.applicationName = applicationName
self.bundleIdentifier = bundleIdentifier
Expand Down Expand Up @@ -474,6 +483,8 @@ nonisolated struct FocusedInputSnapshot: Equatable {
self.hostTextMetrics = hostTextMetrics
self.elementFrameRect = elementFrameRect
self.hostMarkedTextRange = hostMarkedTextRange
self.selectedText = selectedText
self.hostSelection = hostSelection
}

var identity: FocusedInputIdentity {
Expand Down
Loading
Loading