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
8 changes: 8 additions & 0 deletions Cotabby.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@
E17CAA453B1F534D284F0D89 /* PermissionHostApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6ACCB12E4DB32D2F2BEA567 /* PermissionHostApp.swift */; };
E27E6377D36D4981301568DD /* LaunchAtLoginStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5807E8508D9355D0271A00C5 /* LaunchAtLoginStateTests.swift */; };
E313639E71AE1374D2B9A956 /* SuggestionWorkController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B2D97BAA3618A7D0357AC44 /* SuggestionWorkController.swift */; };
E4382BEA8A8551612E5966B9 /* BaseCompletionPromptRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85EF79E6144D6C6AD062B569 /* BaseCompletionPromptRenderer.swift */; };
E51FA12B690428CA431328FC /* WritingPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48B95B6665109B6C6A63B42 /* WritingPaneView.swift */; };
E6EE3C13FA31F261CD734C69 /* DownloadOutcomeClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DE1975F3B5F4A70478DBF41 /* DownloadOutcomeClassifier.swift */; };
E912D4617AE1376061DF1F00 /* LanguageSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4793D4EA5D36D7E5CC216C27 /* LanguageSupportTests.swift */; };
Expand All @@ -226,6 +227,7 @@
F08C139B246C1EC7BB435455 /* MenuBarPresentationObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00824BDD8D0E9B3063827C78 /* MenuBarPresentationObserver.swift */; };
F0DEEE8A866ABB560E7A7E6A /* LaunchAtLoginService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 220CD4AFA1E96A37BC4514AD /* LaunchAtLoginService.swift */; };
F496D63FD0A163D222D8C76F /* EmojiPickerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A3B81B92E0743C6152ED8DD /* EmojiPickerController.swift */; };
F4A01E4F12F0183449BCCBB9 /* BaseCompletionPromptRendererTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9966D09D8D80F54BF2734E00 /* BaseCompletionPromptRendererTests.swift */; };
F4EEE6291095B0BF2D3FBA21 /* GhostTextColorPreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E44E393DD58B978B1EAB6CF /* GhostTextColorPreset.swift */; };
F78F594F77C26C233377E71F /* KeyCodeLabels.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAAE6B395FAB604DF059280A /* KeyCodeLabels.swift */; };
F8E86FA4D6CEEBFA7FB55F8D /* KeyRecorderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A567677424A82D9EEF47495 /* KeyRecorderView.swift */; };
Expand Down Expand Up @@ -358,6 +360,7 @@
82F7F7355967725162DF2D1B /* CustomRulesEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomRulesEditor.swift; sourceTree = "<group>"; };
83A810F9D28A18BA6F2066C7 /* MenuBarSections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarSections.swift; sourceTree = "<group>"; };
85BF316556FDA64CB8AD07B6 /* PermissionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionManager.swift; sourceTree = "<group>"; };
85EF79E6144D6C6AD062B569 /* BaseCompletionPromptRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseCompletionPromptRenderer.swift; sourceTree = "<group>"; };
86460C747AA883FDE756BDBA /* SuggestionSettingsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSettingsModel.swift; sourceTree = "<group>"; };
8724ECA8FABBC82B0A2B943B /* FoundationModelAvailabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationModelAvailabilityService.swift; sourceTree = "<group>"; };
8896D976C7F116EBA0F3969F /* ChromiumAccessibilityEnabler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChromiumAccessibilityEnabler.swift; sourceTree = "<group>"; };
Expand All @@ -372,6 +375,7 @@
9458F0820B3161FE9CF1DDAF /* GhostFontSizeStabilizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhostFontSizeStabilizer.swift; sourceTree = "<group>"; };
96495E4147D828C0B1B22765 /* ClipboardContentDistiller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardContentDistiller.swift; sourceTree = "<group>"; };
979A7867966180A545BB44C4 /* PerformanceMetricsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceMetricsStore.swift; sourceTree = "<group>"; };
9966D09D8D80F54BF2734E00 /* BaseCompletionPromptRendererTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseCompletionPromptRendererTests.swift; sourceTree = "<group>"; };
9A7CDA90E128350BFF1A9D66 /* LanguageTagsEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageTagsEditor.swift; sourceTree = "<group>"; };
9AA0117B322C625F6D4BBEAB /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = "<group>"; };
9B55A4362AB7F0528C661C4C /* SuggestionTextNormalizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionTextNormalizerTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -691,6 +695,7 @@
children = (
A168A7B6A7AD11559B60C56B /* ApplicationBundleMetadataTests.swift */,
C046CB4F3CB4BFE9391DB5DE /* AXTextGeometryResolverTests.swift */,
9966D09D8D80F54BF2734E00 /* BaseCompletionPromptRendererTests.swift */,
04D853218B0A77B0CE090828 /* BrowserAppDetectorTests.swift */,
18D990E515E1AE4F312F4E95 /* BundledRuntimeLocatorTests.swift */,
EFD89799BB82AF7A92559AEB /* ClipboardContentDistillerTests.swift */,
Expand Down Expand Up @@ -842,6 +847,7 @@
children = (
352AF5B2834FEE1F597394E4 /* ApplicationBundleMetadata.swift */,
AC70775535A3428991025AB8 /* AXHelper.swift */,
85EF79E6144D6C6AD062B569 /* BaseCompletionPromptRenderer.swift */,
B997EC69E1C65B1E18234221 /* BrowserAppDetector.swift */,
AA33F5FFAC5B99384E15CE3E /* BundledRuntimeLocator.swift */,
E3C84377F352140759B448C9 /* CaretGeometrySelector.swift */,
Expand Down Expand Up @@ -1025,6 +1031,7 @@
C4C6734678797669055988E0 /* AppUpdateManager.swift in Sources */,
66C23A7C2FCDE0266FF425F8 /* ApplicationBundleMetadata.swift in Sources */,
EF0DE5E045F328F1E912A02A /* AppsPaneView.swift in Sources */,
E4382BEA8A8551612E5966B9 /* BaseCompletionPromptRenderer.swift in Sources */,
49C91DE326A590708D76102A /* BrowserAppDetector.swift in Sources */,
3CBBC3BFAC0DC8952EE24EF7 /* BundledRuntimeLocator.swift in Sources */,
76FD91607794883F8E121450 /* CaretGeometrySelector.swift in Sources */,
Expand Down Expand Up @@ -1183,6 +1190,7 @@
files = (
6D0E79CF3C1A8CE53046FCE5 /* AXTextGeometryResolverTests.swift in Sources */,
A36481222BB5B2A67349D389 /* ApplicationBundleMetadataTests.swift in Sources */,
F4A01E4F12F0183449BCCBB9 /* BaseCompletionPromptRendererTests.swift in Sources */,
5C0D3B6012C7412001BE3773 /* BrowserAppDetectorTests.swift in Sources */,
58AC3193D846FDE88513377D /* BundledRuntimeLocatorTests.swift in Sources */,
8865B95FE84198D70390DF80 /* ClipboardContentDistillerTests.swift in Sources */,
Expand Down
4 changes: 4 additions & 0 deletions Cotabby/Models/SuggestionEngineModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ struct SuggestionSettingsSnapshot: Equatable, Sendable {
/// When true, the screenshot/OCR visual-context pipeline is skipped entirely for lower-latency
/// suggestions. Defaults to false. Only affects visual context β€” predictions still run.
let isFastModeEnabled: Bool
/// Experimental: when true and the Open Source engine is selected, the local path uses the
/// base-model continuation prompt (no instruction preamble, prefix last) instead of the
/// instruction-rendered prompt. Default false, so existing installs are byte-for-byte unchanged.
let useBaseCompletionPipeline: Bool
/// User preference for how suggestions are presented (inline ghost text vs popup card vs auto
/// based on caret geometry quality). Travels in the snapshot so consumers can react to changes
/// without subscribing to the settings model directly.
Expand Down
16 changes: 14 additions & 2 deletions Cotabby/Models/SuggestionSettingsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ final class SuggestionSettingsModel: ObservableObject {
@Published private(set) var selectedWordCountPreset: SuggestionWordCountPreset
@Published private(set) var isClipboardContextEnabled: Bool
@Published private(set) var isFastModeEnabled: Bool
/// Experimental, opt-in via the `cotabbyBaseCompletionPipelineEnabled` default. Routes the local
/// llama path through the base-model continuation prompt. No UI yet; read at launch.
@Published private(set) var useBaseCompletionPipeline: Bool
/// Whether the Performance pane is recording per-request latency. Defaults to false so the
/// default user never pays any extra storage or write cost β€” recording only kicks in once the
/// user opts in from Settings.
Expand Down Expand Up @@ -93,6 +96,7 @@ final class SuggestionSettingsModel: ObservableObject {
private static let legacyShortPresetRawValue = "3-7"
private static let clipboardContextEnabledDefaultsKey = "cotabbyClipboardContextEnabled"
private static let fastModeEnabledDefaultsKey = "cotabbyFastModeEnabled"
private static let baseCompletionPipelineEnabledDefaultsKey = "cotabbyBaseCompletionPipelineEnabled"
private static let performanceTrackingEnabledDefaultsKey = "cotabbyPerformanceTrackingEnabled"
private static let menuBarWordCountVisibleDefaultsKey = "cotabbyMenuBarWordCountVisible"
private static let mirrorPreferenceDefaultsKey = "cotabbyMirrorPreference"
Expand Down Expand Up @@ -190,6 +194,10 @@ final class SuggestionSettingsModel: ObservableObject {
// into fast mode turns it off.
let resolvedFastModeEnabled =
userDefaults.object(forKey: Self.fastModeEnabledDefaultsKey) as? Bool ?? false
// Experimental base-model pipeline. Defaults to false so the merged-but-dark path changes
// nothing for existing users until the flag is explicitly set.
let resolvedBaseCompletionPipelineEnabled =
userDefaults.object(forKey: Self.baseCompletionPipelineEnabledDefaultsKey) as? Bool ?? false
// Defaults to false so the metrics ring buffer stays empty until the user explicitly opts
// in from the Performance pane.
let resolvedPerformanceTrackingEnabled =
Expand Down Expand Up @@ -318,6 +326,7 @@ final class SuggestionSettingsModel: ObservableObject {
selectedWordCountPreset = resolvedWordCountPreset
isClipboardContextEnabled = resolvedClipboardContextEnabled
isFastModeEnabled = resolvedFastModeEnabled
useBaseCompletionPipeline = resolvedBaseCompletionPipelineEnabled
isPerformanceTrackingEnabled = resolvedPerformanceTrackingEnabled
isMenuBarWordCountVisible = resolvedMenuBarWordCountVisible
mirrorPreference = resolvedMirrorPreference
Expand Down Expand Up @@ -353,6 +362,7 @@ final class SuggestionSettingsModel: ObservableObject {
persistSelectedWordCountPreset(resolvedWordCountPreset)
persistClipboardContextEnabled(resolvedClipboardContextEnabled)
persistFastModeEnabled(resolvedFastModeEnabled)
userDefaults.set(resolvedBaseCompletionPipelineEnabled, forKey: Self.baseCompletionPipelineEnabledDefaultsKey)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Persistence for the new flag is written inline in persistSettings() while every other setting in this function delegates to a dedicated persist…() helper (e.g. persistFastModeEnabled). Keeping the same pattern makes the function easier to scan and the flag easier to move to a dedicated resetToDefaults-style call later.

Suggested change
userDefaults.set(resolvedBaseCompletionPipelineEnabled, forKey: Self.baseCompletionPipelineEnabledDefaultsKey)
persistBaseCompletionPipelineEnabled(resolvedBaseCompletionPipelineEnabled)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex Fix in Claude Code

persistPerformanceTrackingEnabled(resolvedPerformanceTrackingEnabled)
persistMenuBarWordCountVisible(resolvedMenuBarWordCountVisible)
persistMirrorPreference(resolvedMirrorPreference)
Expand Down Expand Up @@ -410,6 +420,7 @@ final class SuggestionSettingsModel: ObservableObject {
isMultiLineEnabled: isMultiLineEnabled,
autoAcceptTrailingPunctuation: autoAcceptTrailingPunctuation,
isFastModeEnabled: isFastModeEnabled,
useBaseCompletionPipeline: useBaseCompletionPipeline,
mirrorPreference: mirrorPreference,
acceptanceGranularity: acceptanceGranularity
)
Expand Down Expand Up @@ -1109,8 +1120,8 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding {
// The outer CombineLatest stack is already at Combine's per-operator cap, so each new
// top-level setting gets layered above via another `CombineLatest`. `extendedContext` joins
// alongside `acceptanceGranularity` here for the same reason.
return Publishers.CombineLatest3(primary, $acceptanceGranularity, $extendedContext)
.map { primaryTuple, granularity, extendedContext in
return Publishers.CombineLatest4(primary, $acceptanceGranularity, $extendedContext, $useBaseCompletionPipeline)
.map { primaryTuple, granularity, extendedContext, baseCompletionEnabled in
let (combinedSettings, presentationToggles, profile, timing) = primaryTuple
let (globallyEnabled, disabledAppRules, engine, wordCountPreset) = combinedSettings
let (clipboardContextEnabled, fastModeEnabled, mirrorPreference) = presentationToggles
Expand All @@ -1131,6 +1142,7 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding {
isMultiLineEnabled: multiLine,
autoAcceptTrailingPunctuation: autoAcceptPunctuation,
isFastModeEnabled: fastModeEnabled,
useBaseCompletionPipeline: baseCompletionEnabled,
mirrorPreference: mirrorPreference,
acceptanceGranularity: granularity
)
Expand Down
112 changes: 112 additions & 0 deletions Cotabby/Support/BaseCompletionPromptRenderer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import Foundation

/// File overview:
/// Renders the prompt for the experimental base-model completion pipeline (Open Source engine with
/// `useBaseCompletionPipeline` enabled).
///
/// Why this exists separately from `LlamaPromptRenderer`:
/// `LlamaPromptRenderer` wraps the user's text in an instruction blob ("Task: ... do not answer the
/// user ...") for instruction-tuned models. A *base* model has no instruction-following channel and
/// will happily continue a bare "Task:" line as if it were the document, so that prompt shape leaks
/// scaffolding into the ghost text. This renderer instead treats the model as a pure text continuer:
///
/// - No task preamble and no standalone `Label:` lines.
/// - Custom instructions work by *conditioning*, not obedience: persona, voice, and language are
/// folded into a short framing sentence that makes the desired continuation the most likely one.
/// - Supporting context (notes/screen/clipboard) is included as compact prose ahead of the prefix.
/// - The single invariant that locates the caret is that `prefixText` is the LAST thing in the
/// prompt, with trailing whitespace trimmed so generation begins at a clean word boundary.
enum BaseCompletionPromptRenderer {
static func prompt(
prefixText: String,
applicationName: String,
userName: String?,
customRules: [String] = [],
extendedContext: String? = nil,
languageInstruction: String? = nil,
clipboardContext: String? = nil,
visualContextSummary: String? = nil
) -> String {
var preface: [String] = []

// Persona/voice/language framing, phrased as a description of the document rather than a
// command, because a base model conditions on description but ignores instructions. Emitted
// only when the user supplied something, so a bare field stays pure continuation (the
// strongest base-model setup). `applicationName` is intentionally not stated as a label here;
// app/window metadata biases a base model toward code/numbers over prose.
if let framing = authorFraming(
userName: userName,
customRules: customRules,
languageInstruction: languageInstruction
) {
preface.append(framing)
}

// Free-form reference notes (glossary/terminology) ahead of the prefix so the user's terms
// become likelier continuations through in-context conditioning.
if let extendedContext, !extendedContext.isEmpty {
preface.append("Notes the writer keeps in mind: \(extendedContext)")
}
if let visualContextSummary, !visualContextSummary.isEmpty {
preface.append("Nearby on screen: \(visualContextSummary)")
}
if let clipboardContext, !clipboardContext.isEmpty {
preface.append("On the clipboard: \(clipboardContext)")
}

// Trailing whitespace is trimmed so the model continues from a clean word boundary instead of
// being asked to emit a leading-space token (which base models do poorly). A prefix ending
// mid-word keeps its final partial word, so mid-word continuation still works. Output spacing
// is reconciled downstream by `SuggestionTextNormalizer`.
let trimmedPrefix = Self.trimmingTrailingWhitespace(prefixText)

guard !preface.isEmpty else {
// No context to condition on: hand the model the bare text and let it continue.
return trimmedPrefix
}

// A blank line separates the conditioning preface from the live text without a label the
// model could copy. The prefix remains the final bytes of the prompt.
return preface.joined(separator: "\n") + "\n\n" + trimmedPrefix
}

/// Builds the conditioning sentence from persona/style/language, or nil when none were supplied.
private static func authorFraming(
userName: String?,
customRules: [String],
languageInstruction: String?
) -> String? {
let name = (userName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let rules = customRules
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
let language = (languageInstruction ?? "").trimmingCharacters(in: .whitespacesAndNewlines)

guard !name.isEmpty || !rules.isEmpty || !language.isEmpty else {
return nil
}

var sentence = "The following is text"
if !name.isEmpty {
sentence += " written by \(name)"
}
if !rules.isEmpty {
sentence += " in a \(rules.joined(separator: ", ")) style"
}
sentence += "."
if !language.isEmpty {
// `languageInstruction` is already a soft directive sentence; append it verbatim.
sentence += " \(language)"
}
return sentence
Comment on lines +89 to +101
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Language-only framing produces a vacuous sentence. When userName and customRules are both empty but languageInstruction is supplied, the sentence becomes "The following is text. Write in English." β€” the first clause carries no conditioning signal and the imperative "Write in English." looks out of place in what is meant to read as a document description. Either skip the wrapper clause entirely for the language-only case, or return language on its own.

Suggested change
var sentence = "The following is text"
if !name.isEmpty {
sentence += " written by \(name)"
}
if !rules.isEmpty {
sentence += " in a \(rules.joined(separator: ", ")) style"
}
sentence += "."
if !language.isEmpty {
// `languageInstruction` is already a soft directive sentence; append it verbatim.
sentence += " \(language)"
}
return sentence
// If we only have a language directive and no other framing, return it directly β€” wrapping
// it in "The following is text." adds nothing and makes the preface read oddly.
if name.isEmpty && rules.isEmpty {
return language.isEmpty ? nil : language
}
var sentence = "The following is text"
if !name.isEmpty {
sentence += " written by \(name)"
}
if !rules.isEmpty {
sentence += " in a \(rules.joined(separator: ", ")) style"
}
sentence += "."
if !language.isEmpty {
// `languageInstruction` is already a soft directive sentence; append it verbatim.
sentence += " \(language)"
}
return sentence

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex Fix in Claude Code

}

/// Drops trailing spaces, tabs, and newlines so the base-model prompt ends at a word boundary.
static func trimmingTrailingWhitespace(_ text: String) -> String {
var view = Substring(text)
while let last = view.last, last.isWhitespace {
view = view.dropLast()
}
return String(view)
}
}
Loading