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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ venv/
# Personal notes
launch.txt
posts.txt
.writing/

# Claude Code
.claude/worktrees/
Expand Down
4 changes: 4 additions & 0 deletions Cotabby.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
E10000012F93000100DDD001 /* PromptContextSanitizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10000112F93000100DDD011 /* PromptContextSanitizerTests.swift */; };
E10000022F93000100DDD002 /* PermissionAndContextModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10000122F93000100DDD012 /* PermissionAndContextModelTests.swift */; };
E10000032F93000100DDD003 /* GhostSuggestionLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10000132F93000100DDD013 /* GhostSuggestionLayoutTests.swift */; };
E10000992F93000100DDD099 /* GhostFontSizeStabilizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10000A92F93000100DDD0A9 /* GhostFontSizeStabilizerTests.swift */; };
E10000042F93000100DDD004 /* BundledRuntimeLocatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10000142F93000100DDD014 /* BundledRuntimeLocatorTests.swift */; };
E10000052F93000100DDD005 /* DisplayCoordinateConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10000152F93000100DDD015 /* DisplayCoordinateConverterTests.swift */; };
F10000012FA0000100EEE001 /* TextDirectionDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10000112FA0000100EEE011 /* TextDirectionDetectorTests.swift */; };
Expand Down Expand Up @@ -69,6 +70,7 @@
E10000112F93000100DDD011 /* PromptContextSanitizerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PromptContextSanitizerTests.swift; sourceTree = "<group>"; };
E10000122F93000100DDD012 /* PermissionAndContextModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PermissionAndContextModelTests.swift; sourceTree = "<group>"; };
E10000132F93000100DDD013 /* GhostSuggestionLayoutTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GhostSuggestionLayoutTests.swift; sourceTree = "<group>"; };
E10000A92F93000100DDD0A9 /* GhostFontSizeStabilizerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GhostFontSizeStabilizerTests.swift; sourceTree = "<group>"; };
E10000142F93000100DDD014 /* BundledRuntimeLocatorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BundledRuntimeLocatorTests.swift; sourceTree = "<group>"; };
E10000152F93000100DDD015 /* DisplayCoordinateConverterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DisplayCoordinateConverterTests.swift; sourceTree = "<group>"; };
F10000112FA0000100EEE011 /* TextDirectionDetectorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TextDirectionDetectorTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -151,6 +153,7 @@
E10000112F93000100DDD011 /* PromptContextSanitizerTests.swift */,
E10000122F93000100DDD012 /* PermissionAndContextModelTests.swift */,
E10000132F93000100DDD013 /* GhostSuggestionLayoutTests.swift */,
E10000A92F93000100DDD0A9 /* GhostFontSizeStabilizerTests.swift */,
E10000142F93000100DDD014 /* BundledRuntimeLocatorTests.swift */,
E10000152F93000100DDD015 /* DisplayCoordinateConverterTests.swift */,
F10000112FA0000100EEE011 /* TextDirectionDetectorTests.swift */,
Expand Down Expand Up @@ -296,6 +299,7 @@
E10000012F93000100DDD001 /* PromptContextSanitizerTests.swift in Sources */,
E10000022F93000100DDD002 /* PermissionAndContextModelTests.swift in Sources */,
E10000032F93000100DDD003 /* GhostSuggestionLayoutTests.swift in Sources */,
E10000992F93000100DDD099 /* GhostFontSizeStabilizerTests.swift in Sources */,
E10000042F93000100DDD004 /* BundledRuntimeLocatorTests.swift in Sources */,
E10000052F93000100DDD005 /* DisplayCoordinateConverterTests.swift in Sources */,
F10000012FA0000100EEE001 /* TextDirectionDetectorTests.swift in Sources */,
Expand Down
21 changes: 8 additions & 13 deletions Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,7 @@ extension SuggestionCoordinator {
presentOverlay(
text: advancedSession.remainingText,
at: predictedCaret,
inputFrameRect: liveContext.inputFrameRect,
caretQuality: liveContext.caretQuality,
observedCharWidth: liveContext.observedCharWidth,
context: liveContext,
isRightToLeft: isRTL
)
schedulePostInsertionRefresh()
Expand Down Expand Up @@ -170,9 +168,7 @@ extension SuggestionCoordinator {
presentOverlay(
text: advancedSession.remainingText,
at: session.baseContext.caretRect,
inputFrameRect: session.baseContext.inputFrameRect,
caretQuality: session.baseContext.caretQuality,
observedCharWidth: session.baseContext.observedCharWidth
context: session.baseContext
)
logStage(
"typed-match-advanced",
Expand Down Expand Up @@ -314,17 +310,16 @@ extension SuggestionCoordinator {
func presentOverlay(
text: String,
at caretRect: CGRect,
inputFrameRect: CGRect?,
caretQuality: CaretGeometryQuality,
observedCharWidth: CGFloat?,
context: FocusedInputContext,
isRightToLeft: Bool = false
) {
let geometry = SuggestionOverlayGeometry(
caretRect: caretRect,
inputFrameRect: inputFrameRect,
caretQuality: caretQuality,
observedCharWidth: observedCharWidth,
isRightToLeft: isRightToLeft
inputFrameRect: context.inputFrameRect,
caretQuality: context.caretQuality,
observedCharWidth: context.observedCharWidth,
isRightToLeft: isRightToLeft,
focusChangeSequence: context.focusChangeSequence
)
if let message = overlayPresenter.present(
text: text,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -224,9 +224,7 @@ extension SuggestionCoordinator {
presentOverlay(
text: session.remainingText,
at: liveContext.caretRect,
inputFrameRect: liveContext.inputFrameRect,
caretQuality: liveContext.caretQuality,
observedCharWidth: liveContext.observedCharWidth,
context: liveContext,
isRightToLeft: TextDirectionDetector.isRightToLeft(liveContext.precedingText)
)
logStage(
Expand Down Expand Up @@ -319,9 +317,7 @@ extension SuggestionCoordinator {
presentOverlay(
text: reconciledSession.remainingText,
at: liveContext.caretRect,
inputFrameRect: liveContext.inputFrameRect,
caretQuality: liveContext.caretQuality,
observedCharWidth: liveContext.observedCharWidth,
context: liveContext,
isRightToLeft: TextDirectionDetector.isRightToLeft(liveContext.precedingText)
)
if let advancement {
Expand Down
19 changes: 3 additions & 16 deletions Cotabby/Models/LlamaRuntimeModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,9 @@ enum RuntimeModelCatalog {
static func displayName(for filename: String) -> String {
switch filename {
case "Qwen3-0.6B-Q4_K_M.gguf":
return "cotabby-swift-1"
case "Qwen3.5-0.8B-Q4_K_M.gguf":
return "cotabby-balanced-1"
return "tabby-fast-1"
case "gemma-4-E2B-it-Q4_K_M.gguf":
return "cotabby-careful-1"
return "tabby-balanced-1"
default:
return filename
}
Expand All @@ -115,17 +113,6 @@ enum RuntimeModelCatalog {
expectedSizeBytes: 396_705_472,
sha256: "ac2d97712095a558e31573f62f466a3f9d93990898b0ec79d7c974c1780d524a"
),
DownloadableRuntimeModel(
filename: "Qwen3.5-0.8B-Q4_K_M.gguf",
displayName: displayName(for: "Qwen3.5-0.8B-Q4_K_M.gguf"),
downloadURL: URL(
string:
"https://huggingface.co/unsloth/Qwen3.5-0.8B-GGUF/resolve/main/Qwen3.5-0.8B-Q4_K_M.gguf?download=true"
)!,
approximateSizeInGigabytes: 0.5,
expectedSizeBytes: 532_517_120,
sha256: "bd258782e35f7f458f8aced1adc053e6e92e89bc735ba3be89d38a06121dc517"
),
DownloadableRuntimeModel(
filename: "gemma-4-E2B-it-Q4_K_M.gguf",
displayName: displayName(for: "gemma-4-E2B-it-Q4_K_M.gguf"),
Expand Down Expand Up @@ -153,7 +140,7 @@ struct LlamaRuntimeConfiguration: Equatable, Sendable {
static let `default` = LlamaRuntimeConfiguration(
runtimeDirectoryPath: nil,
preferredModelNames: [
"Qwen3.5-0.8B-Q4_K_M.gguf",
"gemma-4-E2B-it-Q4_K_M.gguf",
"Qwen3-0.6B-Q4_K_M.gguf"
],
contextWindowTokens: 2048,
Expand Down
31 changes: 26 additions & 5 deletions Cotabby/Models/SuggestionModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,17 @@ enum SuggestionWordCountPreset: String, CaseIterable, Equatable, Hashable, Senda
}
}

/// Token budget sized at ~1.5x the upper word bound. Tight enough to enforce the word cap
/// while leaving room for multi-token words (contractions, proper nouns, punctuation).
/// Token budget bumped 50% above the prior ~1.5x-upper-word-bound sizing, giving the
/// token-cap-only path (no in-prompt word range for the local model) room to land a clean
/// stopping point instead of hard-truncating mid-thought.
var suggestedPredictionTokenBudget: Int {
switch self {
case .threeToSeven:
return 11
return 17
case .sevenToTwelve:
return 18
return 27
case .twelveToTwenty:
return 30
return 45
}
}
}
Expand Down Expand Up @@ -352,6 +353,26 @@ struct SuggestionOverlayGeometry: Equatable, Sendable {
/// When `true`, the text near the caret is Right-to-Left (Arabic, Hebrew, etc.) and the ghost
/// text overlay should appear to the left of the caret instead of the right.
let isRightToLeft: Bool
/// Identifies the focus session that produced this geometry. `OverlayController` keys its
/// per-session font-size stabilization on this value, so a field switch (or focus loss) starts
/// a fresh size baseline. Defaults to 0 for tests that do not exercise session-scoped behavior.
let focusChangeSequence: UInt64

init(
caretRect: CGRect,
inputFrameRect: CGRect?,
caretQuality: CaretGeometryQuality,
observedCharWidth: CGFloat?,
isRightToLeft: Bool,
focusChangeSequence: UInt64 = 0
) {
self.caretRect = caretRect
self.inputFrameRect = inputFrameRect
self.caretQuality = caretQuality
self.observedCharWidth = observedCharWidth
self.isRightToLeft = isRightToLeft
self.focusChangeSequence = focusChangeSequence
}
}

/// The overlay is intentionally modeled as data so diagnostics can reason about visibility
Expand Down
18 changes: 14 additions & 4 deletions Cotabby/Services/UI/OverlayController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ final class OverlayController: SuggestionOverlayControlling {
/// instead of a full view rebuild + layout pass.
private var hostingView: NSHostingView<GhostSuggestionView>?

/// Per-focus-session floor for caret-derived font size. Caret height flickers between the real
/// line height and the coarse field-height fallback from poll to poll; stabilizing keeps ghost
/// text from ballooning when the fallback wins. See `GhostFontSizeStabilizer`.
private var ghostFontStabilizer = GhostFontSizeStabilizer()

init(suggestionSettings: SuggestionSettingsModel) {
self.suggestionSettings = suggestionSettings
}
Expand Down Expand Up @@ -66,8 +71,12 @@ final class OverlayController: SuggestionOverlayControlling {
return
}

let stabilizedCaretHeight = ghostFontStabilizer.stabilizedCaretHeight(
geometry.caretRect.height,
focusSessionKey: geometry.focusChangeSequence
)
let fontSize = resolvedGhostFontSize(
for: geometry.caretRect,
forCaretHeight: stabilizedCaretHeight,
caretQuality: geometry.caretQuality
)
let layout = GhostSuggestionLayout.make(
Expand Down Expand Up @@ -118,14 +127,15 @@ final class OverlayController: SuggestionOverlayControlling {
/// Exact and derived caret rects usually reflect the real text line height, so they may scale
/// up in larger editors. Estimated rects are much less trustworthy because some apps only
/// expose the full field frame; the extra ceiling prevents one bad estimate from rendering
/// comically oversized ghost text.
/// comically oversized ghost text. `caretHeight` is already floored to the per-session minimum
/// by `ghostFontStabilizer`, so this only applies the static floor and quality ceilings.
private func resolvedGhostFontSize(
for caretRect: CGRect,
forCaretHeight caretHeight: CGFloat,
caretQuality: CaretGeometryQuality
) -> CGFloat {
let proposedSize = max(
Layout.minimumGhostFontSize,
caretRect.height * Layout.fontToLineHeightRatio
caretHeight * Layout.fontToLineHeightRatio
)
let qualityCap = caretQuality == .estimated
? Layout.maximumEstimatedGhostFontSize
Expand Down
43 changes: 43 additions & 0 deletions Cotabby/Support/GhostFontSizeStabilizer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import CoreGraphics
import Foundation

/// Floors ghost-text size to the smallest caret line height observed during one focus session.
///
/// AX caret geometry is eventually consistent and app-specific. The same field can yield a tight
/// line-height caret on one poll (zero-length `BoundsForRange`) and the full field-height `AXFrame`
/// fallback on the next, when the precise branches happen to fail. Because `OverlayController`
/// derives ghost font size from caret height, that fluctuation renders the suggestion comically
/// oversized whenever the coarse fallback wins a poll.
///
/// Within a single focus session the real line height does not grow, so we treat the smallest
/// height we have seen as the truth and clamp larger readings down to it. The baseline is keyed by
/// `FocusTracker`'s `focusChangeSequence`, so switching fields — or leaving and re-entering the same
/// field — starts a fresh measurement instead of inheriting a stale ceiling.
///
/// This intentionally biases toward the smaller reading: an over-tall fallback is the observed
/// failure mode, and the downstream `minimumGhostFontSize` floor bounds how small a spurious low
/// reading can make the text.
struct GhostFontSizeStabilizer {
private var sessionKey: UInt64?
private var minCaretHeight: CGFloat?

/// Returns the caret height to derive font size from: the running per-session minimum.
///
/// Non-positive heights (empty rects) pass through untouched so a transient bad poll can't pin
/// the session minimum to zero and force every later suggestion to the font-size floor.
mutating func stabilizedCaretHeight(_ caretHeight: CGFloat, focusSessionKey: UInt64) -> CGFloat {
guard caretHeight > 0 else {
return caretHeight
}

if sessionKey != focusSessionKey {
sessionKey = focusSessionKey
minCaretHeight = caretHeight
return caretHeight
}

let stabilized = min(caretHeight, minCaretHeight ?? caretHeight)
minCaretHeight = stabilized
return stabilized
}
}
6 changes: 5 additions & 1 deletion Cotabby/Support/LlamaPromptRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,11 @@ enum LlamaPromptRenderer {
if let languageInstruction, !languageInstruction.isEmpty {
sections.append("- \(languageInstruction)")
}
sections.append("- \(completionLengthInstruction)")
// Experiment: the explicit word-range line (`completionLengthInstruction`) is intentionally
// omitted from the local-model prompt so length is governed purely by the token budget
// (`SuggestionWordCountPreset.suggestedPredictionTokenBudget`). The parameter stays wired so
// re-enabling the in-prompt cue is a one-line change. Apple Intelligence still gets the cue.
_ = completionLengthInstruction
sections.append("- The next line must begin directly with the continuation text.")
sections.append("Text before caret:")
sections.append(prefixText)
Expand Down
10 changes: 10 additions & 0 deletions Cotabby/UI/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,16 @@ struct SettingsView: View {
}
}

if suggestionSettings.fullAcceptanceKeyCode != SuggestionSettingsModel.defaultFullAcceptanceKeyCode {
Button("Reset") {
suggestionSettings.setFullAcceptanceKey(
keyCode: SuggestionSettingsModel.defaultFullAcceptanceKeyCode,
label: SuggestionSettingsModel.defaultFullAcceptanceKeyLabel
)
isRecordingFullAcceptKeybind = false
}
}

if suggestionSettings.fullAcceptanceKeyCode != SuggestionSettingsModel.disabledKeyCode {
Button("Clear") {
suggestionSettings.clearFullAcceptanceKey()
Expand Down
5 changes: 4 additions & 1 deletion Cotabby/UI/WelcomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,10 @@ extension WelcomeView {
suggestionSettings.setFullAcceptanceKey(keyCode: keyCode, label: label)
},
onReset: suggestionSettings.fullAcceptanceKeyCode != SuggestionSettingsModel.defaultFullAcceptanceKeyCode ? {
suggestionSettings.clearFullAcceptanceKey()
suggestionSettings.setFullAcceptanceKey(
keyCode: SuggestionSettingsModel.defaultFullAcceptanceKeyCode,
label: SuggestionSettingsModel.defaultFullAcceptanceKeyLabel
)
} : nil
)
}
Expand Down
14 changes: 9 additions & 5 deletions CotabbyTests/CustomRulesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ final class CustomRulesTests: XCTestCase {
XCTAssertTrue(instruction?.contains("Spanish") == true)
}

func test_llamaRenderer_includesLanguageInstructionBeforeLengthCue() {
func test_llamaRenderer_includesLanguageInstructionInFinalBlock() {
// The length cue is no longer rendered (token-budget-only experiment), so this guards that
// the language directive still lands in the late, high-attention final-instruction block.
let prompt = LlamaPromptRenderer.prompt(
prefixText: "Hola",
applicationName: "Notes",
Expand All @@ -111,12 +113,14 @@ final class CustomRulesTests: XCTestCase {
languageInstruction: SuggestionLanguage.spanish.promptInstruction
)

guard let langRange = prompt.range(of: "Spanish"),
let lenRange = prompt.range(of: "UNIQUE_LENGTH_CUE") else {
XCTFail("Expected language directive and length cue in the prompt")
XCTAssertFalse(prompt.contains("UNIQUE_LENGTH_CUE"))

guard let finalRange = prompt.range(of: "Final instruction:"),
let langRange = prompt.range(of: "Spanish") else {
XCTFail("Expected final instruction header and language directive in the prompt")
return
}
XCTAssertLessThan(langRange.lowerBound, lenRange.lowerBound)
XCTAssertLessThan(finalRange.lowerBound, langRange.lowerBound)
}

func test_foundationModelInstructions_includeLanguageOverride() {
Expand Down
Loading
Loading