From dcaade23d12ad91a9689b9b575dbf50acadd95e3 Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Mon, 25 May 2026 06:33:23 -0700 Subject: [PATCH 1/4] Govern completion length by token budget only, drop in-prompt word range Remove the explicit word-range cue from both the local-model and Apple Intelligence prompts so completion length is governed solely by the shared token budget (request.maxPredictionTokens). Bump suggestedPredictionTokenBudget 50% (11/18/30 -> 17/27/45) so the cap has room to land on a natural stopping point instead of hard-truncating mid-thought. The completionLengthInstruction parameter stays wired for a one-line revert. --- Cotabby/Models/SuggestionModels.swift | 11 ++++++----- .../FoundationModelPromptRenderer.swift | 4 +++- Cotabby/Support/LlamaPromptRenderer.swift | 6 +++++- CotabbyTests/CustomRulesTests.swift | 14 +++++++++----- CotabbyTests/LlamaPromptRendererTests.swift | 18 +++++++++--------- .../ModelAndPresentationValueTests.swift | 6 +++--- CotabbyTests/PromptPolicyTests.swift | 6 ++++-- 7 files changed, 39 insertions(+), 26 deletions(-) diff --git a/Cotabby/Models/SuggestionModels.swift b/Cotabby/Models/SuggestionModels.swift index ba3ddfc..9fd4b2c 100644 --- a/Cotabby/Models/SuggestionModels.swift +++ b/Cotabby/Models/SuggestionModels.swift @@ -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 } } } diff --git a/Cotabby/Support/FoundationModelPromptRenderer.swift b/Cotabby/Support/FoundationModelPromptRenderer.swift index b5d106f..9ecd3bd 100644 --- a/Cotabby/Support/FoundationModelPromptRenderer.swift +++ b/Cotabby/Support/FoundationModelPromptRenderer.swift @@ -33,7 +33,9 @@ enum FoundationModelPromptRenderer { + "that exact phrase and you are finishing it.", "Continue the existing sentence or thought — extend it, never restart it.", "Return exactly one continuation fragment.", - request.completionLengthInstruction, + // Experiment: the explicit word-range cue (`request.completionLengthInstruction`) is + // omitted here too, matching the local-model path. Length is governed solely by the + // shared token budget (`maximumResponseTokens` ← `request.maxPredictionTokens`). "Do not repeat or quote the existing text.", "Match the existing tone, language, casing, and punctuation.", "Use clipboard and screen context only when it directly helps the inline continuation.", diff --git a/Cotabby/Support/LlamaPromptRenderer.swift b/Cotabby/Support/LlamaPromptRenderer.swift index 8a73829..c33925d 100644 --- a/Cotabby/Support/LlamaPromptRenderer.swift +++ b/Cotabby/Support/LlamaPromptRenderer.swift @@ -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) diff --git a/CotabbyTests/CustomRulesTests.swift b/CotabbyTests/CustomRulesTests.swift index f0b8124..2a27b3d 100644 --- a/CotabbyTests/CustomRulesTests.swift +++ b/CotabbyTests/CustomRulesTests.swift @@ -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", @@ -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() { diff --git a/CotabbyTests/LlamaPromptRendererTests.swift b/CotabbyTests/LlamaPromptRendererTests.swift index a60568f..839a5a7 100644 --- a/CotabbyTests/LlamaPromptRendererTests.swift +++ b/CotabbyTests/LlamaPromptRendererTests.swift @@ -95,10 +95,12 @@ final class LlamaPromptRendererTests: XCTestCase { XCTAssertTrue(prompt.contains("My prefix text here")) } - /// The completion-length instruction is chosen from the user's word-count - /// preset. It must reach the prompt verbatim so the model sees the exact - /// guidance the UI showed the user. - func test_instructionPrompt_includesCompletionLengthInstructionNearPrefix() { + /// Length is enforced by the token budget, not by an in-prompt word range, so the + /// completion-length cue must never reach the local-model prompt even if a caller passes one. + func test_instructionPrompt_omitsCompletionLengthInstruction() { + // Experiment: the local-model prompt no longer carries the word-range cue; length is + // governed solely by the token budget. The cue must not leak into the prompt even when a + // caller still passes one. let prompt = LlamaPromptRenderer.prompt( prefixText: "PREFIX_BODY_XYZ", applicationName: "App", @@ -106,17 +108,15 @@ final class LlamaPromptRendererTests: XCTestCase { userName: nil ) - XCTAssertTrue(prompt.contains("UNIQUE_LENGTH_MARKER_7_TO_12_WORDS")) + XCTAssertFalse(prompt.contains("UNIQUE_LENGTH_MARKER_7_TO_12_WORDS")) guard let finalInstructionRange = prompt.range(of: "Final instruction:"), - let lengthRange = prompt.range(of: "UNIQUE_LENGTH_MARKER_7_TO_12_WORDS"), let prefixRange = prompt.range(of: "PREFIX_BODY_XYZ") else { - XCTFail("Expected final instruction header, length marker, and prefix in the prompt") + XCTFail("Expected final instruction header and prefix in the prompt") return } - XCTAssertLessThan(finalInstructionRange.lowerBound, lengthRange.lowerBound) - XCTAssertLessThan(lengthRange.lowerBound, prefixRange.lowerBound) + XCTAssertLessThan(finalInstructionRange.lowerBound, prefixRange.lowerBound) } func test_instructionPrompt_includesProfileContextWhenProvided() { diff --git a/CotabbyTests/ModelAndPresentationValueTests.swift b/CotabbyTests/ModelAndPresentationValueTests.swift index 0b65d3f..8b5da48 100644 --- a/CotabbyTests/ModelAndPresentationValueTests.swift +++ b/CotabbyTests/ModelAndPresentationValueTests.swift @@ -40,13 +40,13 @@ final class SuggestionTextColorCodecTests: XCTestCase { final class SuggestionModelValueTests: XCTestCase { func test_wordCountPresetsExposeMatchingPromptInstructionsAndTokenBudgets() { XCTAssertEqual(SuggestionWordCountPreset.threeToSeven.promptInstruction, "Return only the next 3 to 7 words.") - XCTAssertEqual(SuggestionWordCountPreset.threeToSeven.suggestedPredictionTokenBudget, 11) + XCTAssertEqual(SuggestionWordCountPreset.threeToSeven.suggestedPredictionTokenBudget, 17) XCTAssertEqual(SuggestionWordCountPreset.sevenToTwelve.promptInstruction, "Return only the next 7 to 12 words.") - XCTAssertEqual(SuggestionWordCountPreset.sevenToTwelve.suggestedPredictionTokenBudget, 18) + XCTAssertEqual(SuggestionWordCountPreset.sevenToTwelve.suggestedPredictionTokenBudget, 27) XCTAssertEqual(SuggestionWordCountPreset.twelveToTwenty.promptInstruction, "Return only the next 12 to 20 words.") - XCTAssertEqual(SuggestionWordCountPreset.twelveToTwenty.suggestedPredictionTokenBudget, 30) + XCTAssertEqual(SuggestionWordCountPreset.twelveToTwenty.suggestedPredictionTokenBudget, 45) } func test_activeSuggestionSession_clampsConsumedCountAndSlicesByCharacters() { diff --git a/CotabbyTests/PromptPolicyTests.swift b/CotabbyTests/PromptPolicyTests.swift index 57515a9..bcc6cd6 100644 --- a/CotabbyTests/PromptPolicyTests.swift +++ b/CotabbyTests/PromptPolicyTests.swift @@ -15,7 +15,8 @@ final class FoundationModelPromptRendererTests: XCTestCase { let instructions = FoundationModelPromptRenderer.sessionInstructions(for: request) XCTAssertTrue(instructions.contains("text-continuation engine")) - XCTAssertTrue(instructions.contains("UNIQUE_LENGTH_POLICY")) + // The word-range cue is no longer injected — length is token-budget-only on both engines. + XCTAssertFalse(instructions.contains("UNIQUE_LENGTH_POLICY")) XCTAssertTrue(instructions.contains("Do not repeat or quote the existing text.")) } @@ -99,7 +100,8 @@ final class FoundationModelPromptRendererTests: XCTestCase { let preview = FoundationModelPromptRenderer.promptPreview(for: request) XCTAssertTrue(preview.contains("Instructions:\n")) - XCTAssertTrue(preview.contains("UNIQUE_LENGTH_POLICY")) + // Length cue removed from the prompt; it should not surface in the diagnostics preview either. + XCTAssertFalse(preview.contains("UNIQUE_LENGTH_POLICY")) XCTAssertTrue(preview.contains("Prompt:\n")) XCTAssertTrue(preview.contains("UNIQUE_APPLE_SCREEN_CONTEXT")) } From 375124a98f58fd23f23a4aab3ae4862a0b3f560a Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Mon, 25 May 2026 06:56:38 -0700 Subject: [PATCH 2/4] Stabilize ghost-text size per focus session Ghost font size is derived from the resolved caret height, but AX caret geometry is eventually consistent: the same field yields a tight line-height caret on one poll and the full field-height AXFrame fallback on the next. That made ghost text balloon whenever the coarse fallback won a poll. Track the smallest caret height seen during a focus session (keyed by FocusTracker's focusChangeSequence) and clamp larger readings down to it, so the suggestion stays the size of the real text line. The baseline resets on field switch or focus loss. The existing font-size floor bounds how small a spurious low reading can make the text; positioning is intentionally unchanged. Collapse presentOverlay's loose geometry parameters into the FocusedInputContext they already came from, which also threads focusChangeSequence through to the overlay. --- Cotabby.xcodeproj/project.pbxproj | 4 ++ .../SuggestionCoordinator+Acceptance.swift | 21 +++---- .../SuggestionCoordinator+Prediction.swift | 8 +-- Cotabby/Models/SuggestionModels.swift | 20 +++++++ Cotabby/Services/UI/OverlayController.swift | 18 ++++-- Cotabby/Support/GhostFontSizeStabilizer.swift | 43 ++++++++++++++ .../GhostFontSizeStabilizerTests.swift | 57 +++++++++++++++++++ 7 files changed, 148 insertions(+), 23 deletions(-) create mode 100644 Cotabby/Support/GhostFontSizeStabilizer.swift create mode 100644 CotabbyTests/GhostFontSizeStabilizerTests.swift diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index dd0e859..1060d06 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -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 */; }; @@ -69,6 +70,7 @@ E10000112F93000100DDD011 /* PromptContextSanitizerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PromptContextSanitizerTests.swift; sourceTree = ""; }; E10000122F93000100DDD012 /* PermissionAndContextModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PermissionAndContextModelTests.swift; sourceTree = ""; }; E10000132F93000100DDD013 /* GhostSuggestionLayoutTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GhostSuggestionLayoutTests.swift; sourceTree = ""; }; + E10000A92F93000100DDD0A9 /* GhostFontSizeStabilizerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GhostFontSizeStabilizerTests.swift; sourceTree = ""; }; E10000142F93000100DDD014 /* BundledRuntimeLocatorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BundledRuntimeLocatorTests.swift; sourceTree = ""; }; E10000152F93000100DDD015 /* DisplayCoordinateConverterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DisplayCoordinateConverterTests.swift; sourceTree = ""; }; F10000112FA0000100EEE011 /* TextDirectionDetectorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TextDirectionDetectorTests.swift; sourceTree = ""; }; @@ -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 */, @@ -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 */, diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift index db41e1b..72a3160 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift @@ -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() @@ -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", @@ -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, diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift index 9f91a94..ac38c65 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift @@ -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( @@ -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 { diff --git a/Cotabby/Models/SuggestionModels.swift b/Cotabby/Models/SuggestionModels.swift index 9fd4b2c..3ae951a 100644 --- a/Cotabby/Models/SuggestionModels.swift +++ b/Cotabby/Models/SuggestionModels.swift @@ -353,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 diff --git a/Cotabby/Services/UI/OverlayController.swift b/Cotabby/Services/UI/OverlayController.swift index f85b49f..6f66122 100644 --- a/Cotabby/Services/UI/OverlayController.swift +++ b/Cotabby/Services/UI/OverlayController.swift @@ -32,6 +32,11 @@ final class OverlayController: SuggestionOverlayControlling { /// instead of a full view rebuild + layout pass. private var hostingView: NSHostingView? + /// 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 } @@ -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( @@ -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 diff --git a/Cotabby/Support/GhostFontSizeStabilizer.swift b/Cotabby/Support/GhostFontSizeStabilizer.swift new file mode 100644 index 0000000..d8ca885 --- /dev/null +++ b/Cotabby/Support/GhostFontSizeStabilizer.swift @@ -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 + } +} diff --git a/CotabbyTests/GhostFontSizeStabilizerTests.swift b/CotabbyTests/GhostFontSizeStabilizerTests.swift new file mode 100644 index 0000000..7e81ac2 --- /dev/null +++ b/CotabbyTests/GhostFontSizeStabilizerTests.swift @@ -0,0 +1,57 @@ +import CoreGraphics +import XCTest +@testable import Cotabby + +final class GhostFontSizeStabilizerTests: XCTestCase { + + func test_firstReadingEstablishesBaseline() { + var stabilizer = GhostFontSizeStabilizer() + XCTAssertEqual(stabilizer.stabilizedCaretHeight(18, focusSessionKey: 1), 18) + } + + func test_largerReadingInSameSessionClampsToMinimum() { + var stabilizer = GhostFontSizeStabilizer() + _ = stabilizer.stabilizedCaretHeight(18, focusSessionKey: 1) + // A later poll falls back to the full field height; we keep the smaller real line height. + XCTAssertEqual(stabilizer.stabilizedCaretHeight(120, focusSessionKey: 1), 18) + } + + func test_smallerReadingLowersMinimumForRestOfSession() { + var stabilizer = GhostFontSizeStabilizer() + _ = stabilizer.stabilizedCaretHeight(40, focusSessionKey: 7) + XCTAssertEqual(stabilizer.stabilizedCaretHeight(22, focusSessionKey: 7), 22) + // The new lower floor sticks even when a tall reading returns later in the session. + XCTAssertEqual(stabilizer.stabilizedCaretHeight(90, focusSessionKey: 7), 22) + } + + func test_sessionChangeResetsBaseline() { + var stabilizer = GhostFontSizeStabilizer() + _ = stabilizer.stabilizedCaretHeight(16, focusSessionKey: 1) + // Switching fields must not pin a tall field to the previous field's short line height. + XCTAssertEqual(stabilizer.stabilizedCaretHeight(48, focusSessionKey: 2), 48) + } + + func test_reentryWithNewSessionKeyResetsEvenWhenLarger() { + var stabilizer = GhostFontSizeStabilizer() + _ = stabilizer.stabilizedCaretHeight(18, focusSessionKey: 3) + _ = stabilizer.stabilizedCaretHeight(18, focusSessionKey: 3) + // focusChangeSequence increments on focus loss + re-entry, so the larger reading is honored. + XCTAssertEqual(stabilizer.stabilizedCaretHeight(30, focusSessionKey: 4), 30) + } + + func test_nonPositiveHeightPassesThroughWithoutPoisoningCache() { + var stabilizer = GhostFontSizeStabilizer() + _ = stabilizer.stabilizedCaretHeight(20, focusSessionKey: 5) + // A transient empty rect should not become the session minimum. + XCTAssertEqual(stabilizer.stabilizedCaretHeight(0, focusSessionKey: 5), 0) + XCTAssertEqual(stabilizer.stabilizedCaretHeight(20, focusSessionKey: 5), 20) + } + + func test_genuinelyLargeFieldStaysLarge() { + var stabilizer = GhostFontSizeStabilizer() + // Every poll agrees the line is tall; nothing should shrink it. + XCTAssertEqual(stabilizer.stabilizedCaretHeight(60, focusSessionKey: 9), 60) + XCTAssertEqual(stabilizer.stabilizedCaretHeight(60, focusSessionKey: 9), 60) + XCTAssertEqual(stabilizer.stabilizedCaretHeight(62, focusSessionKey: 9), 60) + } +} From 95c2efa906a1795ac51680663bcdd679de87caa4 Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Mon, 25 May 2026 06:56:43 -0700 Subject: [PATCH 3/4] Fix stale maxPredictionTokens assertion for the 12-20 word preset The token budget for .twelveToTwenty was bumped to 45, but this test still expected the old 30 and was failing on main. Update it to match the intended budget. --- CotabbyTests/SuggestionRequestFactoryTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CotabbyTests/SuggestionRequestFactoryTests.swift b/CotabbyTests/SuggestionRequestFactoryTests.swift index ec11431..8a6ad90 100644 --- a/CotabbyTests/SuggestionRequestFactoryTests.swift +++ b/CotabbyTests/SuggestionRequestFactoryTests.swift @@ -119,7 +119,7 @@ final class SuggestionRequestFactoryTests: XCTestCase { result.request.completionLengthInstruction, "Return only the next 12 to 20 words." ) - XCTAssertEqual(result.request.maxPredictionTokens, 30) + XCTAssertEqual(result.request.maxPredictionTokens, 45) XCTAssertEqual(result.promptPreview, result.request.prompt) } From 03eb9ffcf439310e339d1464b23288bd1f60b3ae Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Mon, 25 May 2026 07:15:25 -0700 Subject: [PATCH 4/4] Rename models to tabby-*, drop Qwen3.5, add full-accept reset, FM length cue - Model display names back to tabby-fast-1 / tabby-balanced-1; drop the Qwen3.5 entry (unrecognized series), prefer gemma-4-E2B then Qwen3-0.6B - Add a Reset-to-default (backtick) button to the full-accept keybind in settings and onboarding, so it can't get stuck disabled - Re-add the completion-length cue to the Foundation Model prompt while the llama path stays token-budget only - Stop tracking .writing/, remove the old Tabby->Cotabby rename docs --- .gitignore | 1 + Cotabby/Models/LlamaRuntimeModels.swift | 19 +- .../FoundationModelPromptRenderer.swift | 4 +- Cotabby/UI/SettingsView.swift | 10 + Cotabby/UI/WelcomeView.swift | 5 +- .../ModelAndPresentationValueTests.swift | 15 +- CotabbyTests/PromptPolicyTests.swift | 6 +- RENAME_MANUAL_STEPS.md | 186 ------------- RENAME_TRANSITION.md | 253 ------------------ 9 files changed, 28 insertions(+), 471 deletions(-) delete mode 100644 RENAME_MANUAL_STEPS.md delete mode 100644 RENAME_TRANSITION.md diff --git a/.gitignore b/.gitignore index 173fc9c..66995bc 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ venv/ # Personal notes launch.txt posts.txt +.writing/ # Claude Code .claude/worktrees/ diff --git a/Cotabby/Models/LlamaRuntimeModels.swift b/Cotabby/Models/LlamaRuntimeModels.swift index 93e6962..4039116 100644 --- a/Cotabby/Models/LlamaRuntimeModels.swift +++ b/Cotabby/Models/LlamaRuntimeModels.swift @@ -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 } @@ -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"), @@ -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, diff --git a/Cotabby/Support/FoundationModelPromptRenderer.swift b/Cotabby/Support/FoundationModelPromptRenderer.swift index 9ecd3bd..b5d106f 100644 --- a/Cotabby/Support/FoundationModelPromptRenderer.swift +++ b/Cotabby/Support/FoundationModelPromptRenderer.swift @@ -33,9 +33,7 @@ enum FoundationModelPromptRenderer { + "that exact phrase and you are finishing it.", "Continue the existing sentence or thought — extend it, never restart it.", "Return exactly one continuation fragment.", - // Experiment: the explicit word-range cue (`request.completionLengthInstruction`) is - // omitted here too, matching the local-model path. Length is governed solely by the - // shared token budget (`maximumResponseTokens` ← `request.maxPredictionTokens`). + request.completionLengthInstruction, "Do not repeat or quote the existing text.", "Match the existing tone, language, casing, and punctuation.", "Use clipboard and screen context only when it directly helps the inline continuation.", diff --git a/Cotabby/UI/SettingsView.swift b/Cotabby/UI/SettingsView.swift index 4f21b9d..88a3fa3 100644 --- a/Cotabby/UI/SettingsView.swift +++ b/Cotabby/UI/SettingsView.swift @@ -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() diff --git a/Cotabby/UI/WelcomeView.swift b/Cotabby/UI/WelcomeView.swift index 385dcdc..18b9b8b 100644 --- a/Cotabby/UI/WelcomeView.swift +++ b/Cotabby/UI/WelcomeView.swift @@ -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 ) } diff --git a/CotabbyTests/ModelAndPresentationValueTests.swift b/CotabbyTests/ModelAndPresentationValueTests.swift index 8b5da48..8f65497 100644 --- a/CotabbyTests/ModelAndPresentationValueTests.swift +++ b/CotabbyTests/ModelAndPresentationValueTests.swift @@ -143,22 +143,21 @@ final class RuntimeAndInputModelValueTests: XCTestCase { func test_runtimeModelCatalogMapsKnownNamesAndLeavesCustomNamesAlone() { XCTAssertEqual( RuntimeModelCatalog.displayName(for: "Qwen3-0.6B-Q4_K_M.gguf"), - "cotabby-swift-1" + "tabby-fast-1" ) + XCTAssertEqual( + RuntimeModelCatalog.displayName(for: "gemma-4-E2B-it-Q4_K_M.gguf"), + "tabby-balanced-1" + ) + // Retired models fall back to their raw filename like any unknown local GGUF. XCTAssertEqual( RuntimeModelCatalog.displayName(for: "Qwen3.5-0.8B-Q4_K_M.gguf"), - "cotabby-balanced-1" + "Qwen3.5-0.8B-Q4_K_M.gguf" ) - // The retired gemma-3-1b model is no longer in the catalog, so it falls - // back to its raw filename like any unknown local GGUF. XCTAssertEqual( RuntimeModelCatalog.displayName(for: "gemma-3-1b-it-Q4_K_M.gguf"), "gemma-3-1b-it-Q4_K_M.gguf" ) - XCTAssertEqual( - RuntimeModelCatalog.displayName(for: "gemma-4-E2B-it-Q4_K_M.gguf"), - "cotabby-careful-1" - ) XCTAssertEqual( RuntimeModelCatalog.displayName(for: "custom-local-model.gguf"), "custom-local-model.gguf" diff --git a/CotabbyTests/PromptPolicyTests.swift b/CotabbyTests/PromptPolicyTests.swift index bcc6cd6..57515a9 100644 --- a/CotabbyTests/PromptPolicyTests.swift +++ b/CotabbyTests/PromptPolicyTests.swift @@ -15,8 +15,7 @@ final class FoundationModelPromptRendererTests: XCTestCase { let instructions = FoundationModelPromptRenderer.sessionInstructions(for: request) XCTAssertTrue(instructions.contains("text-continuation engine")) - // The word-range cue is no longer injected — length is token-budget-only on both engines. - XCTAssertFalse(instructions.contains("UNIQUE_LENGTH_POLICY")) + XCTAssertTrue(instructions.contains("UNIQUE_LENGTH_POLICY")) XCTAssertTrue(instructions.contains("Do not repeat or quote the existing text.")) } @@ -100,8 +99,7 @@ final class FoundationModelPromptRendererTests: XCTestCase { let preview = FoundationModelPromptRenderer.promptPreview(for: request) XCTAssertTrue(preview.contains("Instructions:\n")) - // Length cue removed from the prompt; it should not surface in the diagnostics preview either. - XCTAssertFalse(preview.contains("UNIQUE_LENGTH_POLICY")) + XCTAssertTrue(preview.contains("UNIQUE_LENGTH_POLICY")) XCTAssertTrue(preview.contains("Prompt:\n")) XCTAssertTrue(preview.contains("UNIQUE_APPLE_SCREEN_CONTEXT")) } diff --git a/RENAME_MANUAL_STEPS.md b/RENAME_MANUAL_STEPS.md deleted file mode 100644 index 91ffa1d..0000000 --- a/RENAME_MANUAL_STEPS.md +++ /dev/null @@ -1,186 +0,0 @@ -# Tabby → Cotabby Rename: Manual Update Checklist - -Everything you need to do **outside** the codebase (or that can't be verified -by `xcodebuild`). Check each box as you complete it. - ---- - -## 1. DNS & Domain - -- [ ] **Register/configure `cotabby.app` domain** — the landing page URL - (`https://cotabby.app`) and feedback URL (`https://www.cotabby.app/feedback`) - must resolve. -- [ ] **Create CNAME record** `updates.cotabby.app` → your GitHub Pages site - (e.g. `fujacob.github.io`). The release workflow writes a CNAME file with - this value and publishes the Sparkle appcast there. -- [ ] **Enable GitHub Pages custom domain** in repo Settings → Pages, set to - `updates.cotabby.app`, and enforce HTTPS. - ---- - -## 2. Apple Developer Portal - -- [ ] **Register new bundle identifier** `com.jacobfu.cotabby` in the Apple - Developer portal (Certificates, Identifiers & Profiles → Identifiers). -- [ ] **Create/update provisioning profile** if you use one for Developer ID - distribution. -- [ ] **Verify Developer ID Application certificate** is still valid and matches - the `DEVELOPER_ID_APPLICATION_CERT` GitHub secret (the cert itself doesn't - change, but the signed app will now have the new bundle ID). - -> **User impact:** Existing users on `com.jacobfu.tabby` will see Cotabby as a -> **new app** — separate Accessibility trust, separate preferences, separate -> Login Items entry. Consider shipping a migration note or blog post. - ---- - -## 3. GitHub Repository - -- [ ] **Rename the repo** from `FuJacob/Tabby` to `FuJacob/Cotabby`. GitHub - auto-redirects old URLs, but update bookmarks and CI badge URLs. -- [ ] **Update repo description and topics** in Settings to say "Cotabby". -- [ ] **Update GitHub Pages source** if it changed during the rename. - ---- - -## 4. GitHub Secrets (Settings → Secrets and variables → Actions) - -These secrets are already stored — **no values change**, but verify they're -present and that the workflow can still read them after a repo rename: - -| Secret | Used for | -|--------|----------| -| `DEVELOPER_ID_APPLICATION_CERT` | Base64-encoded Developer ID cert for codesigning | -| `DEVELOPER_ID_CERT_PASSWORD` | Password for the cert import | -| `APPLE_ID` | Apple ID for notarization (`xcrun notarytool`) | -| `APPLE_TEAM_ID` | Team ID for codesigning and notarization | -| `APPLE_APP_SPECIFIC_PASSWORD` | App-specific password for notarytool | -| `SPARKLE_ED25519_PRIVATE_KEY` | Sparkle signing key for appcast deltas | - -> The keychain profile names (`cotabby-release.keychain-db`, -> `cotabby-notarytool-profile`) are **runtime artifacts** created inside the -> workflow — they don't map to stored secrets. They're already updated in -> `release.yml`. - ---- - -## 5. Sparkle Update Feed - -- [ ] **Verify DNS** for `updates.cotabby.app` resolves (see section 1). -- [ ] **Back up the Sparkle private key** — the workflow reads it from - `SPARKLE_ED25519_PRIVATE_KEY`. Your local backup should be at - `~/secure/Cotabby-key.txt` (referenced in `RELEASING.md`). -- [ ] **Do NOT rotate the Sparkle key pair** unless absolutely necessary — - existing installs validate updates against the public key - (`SUPublicEDKey` in `CotabbyInfo.plist`). Rotating breaks OTA updates for - every prior install. - ---- - -## 6. Buy Me a Coffee - -- [ ] **Create the account** `cotabbyapp` on Buy Me a Coffee (or verify it - exists). The handle is referenced in `.github/FUNDING.yml`. - ---- - -## 7. Landing Page & Feedback Form - -- [ ] **Deploy a page** at `https://cotabby.app` (linked from `README.md`). -- [ ] **Deploy a feedback form** at `https://www.cotabby.app/feedback` - (linked from the in-app "Send Feedback" menu item in `CotabbyApp.swift`). - ---- - -## 8. Remaining In-Code Renames (PR follow-ups) - -These are code changes that weren't included in the rename PR but should be -done as follow-ups: - -### 8a. `TabbyLogger` → `CotabbyLogger` - -`TabbyLogger` is still used across **25 source files (86 occurrences)**. -The logger subsystem strings also still say `com.tabby.*`: - -| File | What to change | -|------|---------------| -| `Cotabby/Support/CotabbyDebugOptions.swift:38` | Rename `enum TabbyLogger` → `CotabbyLogger` | -| `CotabbyDebugOptions.swift:50–56` | Change labels: `com.tabby.app` → `com.cotabby.app`, etc. (7 loggers) | -| `CotabbyDebugOptions.swift:70` | Change subsystem: `com.tabby.app` → `com.cotabby.app` | -| `CotabbyDebugOptions.swift:37` | Update comment: process "tabby" → "cotabby" | -| `CotabbyDebugOptions.swift:68` | Update comment: "all Tabby output" → "all Cotabby output" | -| 24 other Swift files | Replace all `TabbyLogger.` → `CotabbyLogger.` references | - -> **Impact:** Changing `com.tabby.*` subsystem strings means any developer -> Console.app filters for `com.tabby.*` will stop matching. This is cosmetic -> but worth noting in release notes. - -### 8b. AppDelegate log message - -| File | Line | Current | Should be | -|------|------|---------|-----------| -| `Cotabby/App/Core/AppDelegate.swift` | 117 | `"Tabby \(version) (build \(build))..."` | `"Cotabby \(version)..."` | - -### 8c. LlamaMiddleware / TabbyInference package - -The local package at `../LlamaMiddleware` still exports a product called -`TabbyInference`. This is a separate repo/package — rename independently: - -- [ ] Rename the product in `LlamaMiddleware/Package.swift` from - `TabbyInference` to `CotabbyInference` (or similar). -- [ ] Update `import TabbyInference` in `LlamaRuntimeCore.swift` and - `TabbyInference` references in `Cotabby.xcodeproj/project.pbxproj`. - -### 8d. Old `tabby.xcodeproj` skeleton - -The directory `tabby.xcodeproj/` still exists with leftover user data -(`xcuserdata/`). It's not tracked by git but clutters the working tree: - -```bash -rm -rf tabby.xcodeproj -``` - -### 8e. Archived marketing text - -`posts.txt` and `launch.txt` in the repo root contain old "Tabby" marketing -copy. Decide whether to update or delete them. - ---- - -## 9. Post-Merge Verification - -After merging and completing the above: - -- [ ] **Tag a test release** and confirm the full release workflow succeeds - (codesign, notarize, DMG, appcast publish). -- [ ] **Verify appcast** is reachable at - `https://updates.cotabby.app/appcast.xml`. -- [ ] **Install from DMG** on a clean machine and confirm: - - App name shows "Cotabby" in menu bar, About, and Activity Monitor. - - Accessibility permission prompt references "Cotabby". - - "Check for Updates" points to the new feed URL. - - "Send Feedback" opens `https://www.cotabby.app/feedback`. -- [ ] **Verify Login Items** — if users had "Tabby" in Login Items, they'll - need to re-add "Cotabby" manually. -- [ ] **Communicate to users** about the rename — existing installs will not - auto-update to the new bundle ID. Users need to download the new app. - ---- - -## Quick Reference: What Changed Where - -| Item | Old value | New value | Where | -|------|-----------|-----------|-------| -| Bundle ID | `com.jacobfu.tabby` | `com.jacobfu.cotabby` | `project.pbxproj` | -| App name | Tabby | Cotabby | Everywhere | -| Xcode project | `tabby.xcodeproj` | `Cotabby.xcodeproj` | Repo root | -| Scheme | `tabby` | `Cotabby` | `.xcscheme` | -| Source dir | `tabby/` | `Cotabby/` | Repo root | -| Test dir | `tabbyTests/` | `CotabbyTests/` | Repo root | -| Info.plist | `TabbyInfo.plist` | `CotabbyInfo.plist` | Repo root | -| Update feed | `updates.tabbyapp.dev` | `updates.cotabby.app` | `CotabbyInfo.plist`, `release.yml` | -| Landing page | `tabbyapp.dev` | `cotabby.app` | `README.md`, `CotabbyApp.swift` | -| BMAC handle | `tabbyapp` | `cotabbyapp` | `FUNDING.yml` | -| DMG volume | "Tabby" | "Cotabby" | `release.yml` | -| Debug options | `TabbyDebugOptions` | `CotabbyDebugOptions` | Source | -| Launch arg | `-tabby-debug` | `-cotabby-debug` | Source | diff --git a/RENAME_TRANSITION.md b/RENAME_TRANSITION.md deleted file mode 100644 index 313a54c..0000000 --- a/RENAME_TRANSITION.md +++ /dev/null @@ -1,253 +0,0 @@ -# Tabby → Cotabby Rename Transition - -This document records what changed, what intentionally stayed the same, and -what would break if touched in the future. Keep it as a reference for any -follow-up work on the rename. - ---- - -## What Was Renamed (Visual / Cosmetic) - -These changes shipped in the rename PR and are safe — they don't affect -existing installs, preferences, permissions, or update delivery. - -| Item | Old | New | -|------|-----|-----| -| Source directory | `tabby/` | `Cotabby/` | -| Test directory | `tabbyTests/` | `CotabbyTests/` | -| Xcode project | `tabby.xcodeproj` | `Cotabby.xcodeproj` | -| Xcode scheme | `tabby` | `Cotabby` | -| Info.plist | `TabbyInfo.plist` | `CotabbyInfo.plist` | -| App struct | `TabbyApp` | `CotabbyApp` | -| Environment | `TabbyAppEnvironment` | `CotabbyAppEnvironment` | -| Debug options | `TabbyDebugOptions` | `CotabbyDebugOptions` | -| Launch argument | `-tabby-debug` | `-cotabby-debug` | -| Test fixtures | `TabbyTestFixtures` | `CotabbyTestFixtures` | -| DMG volume name | `Tabby` | `Cotabby` | -| DMG filename | `tabby.dmg` | `Cotabby.dmg` | -| CI workflow names | References to "Tabby" | References to "Cotabby" | -| README, docs, comments | "Tabby" | "Cotabby" | -| Accessibility description | "Tabby needs..." | "Cotabby needs..." | -| UI strings, log messages | "Tabby" | "Cotabby" | -| Keychain profile names | `tabby-release.keychain-db` | `cotabby-release.keychain-db` | -| Notarytool profile | `tabby-notarytool-profile` | `cotabby-notarytool-profile` | - -Keychain and notarytool profile names are runtime artifacts created inside -the CI workflow — they don't map to stored secrets or external state. - ---- - -## What Was Intentionally NOT Renamed - -These identifiers are functional — changing them would break existing user -installs. They must stay as-is unless a migration path is implemented. - -### Bundle Identifier: `com.jacobfu.tabby` - -**What depends on it:** -- macOS Accessibility trust (TCC database keyed by bundle ID) -- Input Monitoring permission grant -- Screen Recording permission grant -- Login Items / `SMAppService` registration -- `UserDefaults` storage domain (implicitly `com.jacobfu.tabby`) -- macOS Gatekeeper / quarantine state -- Any MDM or enterprise allowlist entries users may have configured - -**What would break:** -Changing the bundle ID makes macOS treat the app as a completely new -application. Every user would need to: -- Re-grant Accessibility permission (re-drag in System Settings) -- Re-grant Input Monitoring permission -- Re-grant Screen Recording permission -- Re-enable Login Items -- Lose all saved preferences (engine choice, keybindings, disabled apps, etc.) - -### UserDefaults Keys: `tabby*` Prefix - -All 14 preference keys use the `tabby` prefix: - -| Key | Purpose | -|-----|---------| -| `tabbyGloballyEnabled` | Master on/off toggle | -| `tabbyDisabledAppRules` | Per-app blocklist | -| `tabbyShowCaretIndicator` | Caret indicator visibility | -| `tabbySelectedIndicatorMode` | Indicator style | -| `tabbyCustomSuggestionTextColorHex` | Ghost text color | -| `tabbyClipboardContextEnabled` | Clipboard context toggle | -| `tabbyUserName` | User's name for prompts | -| `tabbyDebounceMilliseconds` | Input debounce timing | -| `tabbyFocusPollIntervalMilliseconds` | Focus poll interval | -| `tabbyAcceptanceKeyCode` | Partial accept key | -| `tabbyAcceptanceKeyLabel` | Partial accept key label | -| `tabbyFullAcceptanceKeyCode` | Full accept key | -| `tabbyFullAcceptanceKeyLabel` | Full accept key label | -| `selectedSuggestionEngine` | Engine choice (no prefix) | -| `selectedSuggestionWordCountPreset` | Word count preset (no prefix) | - -**What would break:** Renaming these keys silently resets every user's -preferences to defaults on next launch. The two keys without the `tabby` -prefix (`selectedSuggestionEngine`, `selectedSuggestionWordCountPreset`) -were never renamed and should also stay as-is. - -### Sparkle Feed URL: `https://updates.tabbyapp.dev/appcast.xml` - -The `SUFeedURL` in `CotabbyInfo.plist` points to the old domain. Existing -installs have this URL baked into their running binary and poll it for -updates. - -**Current setup:** -- `updates.tabbyapp.dev` has a Cloudflare 301 redirect → - `updates.cotabby.app` (same path preserved) -- GitHub Pages serves the appcast at `updates.cotabby.app` -- Sparkle follows the redirect transparently - -**What would break:** Removing the `updates.tabbyapp.dev` domain or its -redirect before all users have updated to a version with the new feed URL -would silently break OTA updates. Users would never see new versions. - -**Future migration path (when ready):** -1. ✅ Confirm `updates.tabbyapp.dev` → `updates.cotabby.app` redirect is live - (done). -2. ✅ Ship a release with `SUFeedURL` changed to - `https://updates.cotabby.app/appcast.xml` in `CotabbyInfo.plist` (done — the - `SUFeedURL` now points at `updates.cotabby.app`; takes effect on the next - tagged release). -3. ⏳ Keep the `tabbyapp.dev` redirect alive for at least 6 months to catch - users who don't update immediately. **Do not retire it yet.** -4. After sufficient time, retire `updates.tabbyapp.dev`. - -### Sparkle Signing Key - -The Ed25519 key pair must never change unless you're willing to break all -OTA updates for every existing install: - -- **Public key** (`SUPublicEDKey` in `CotabbyInfo.plist`): - `efJeZNfUISOs6npbxI2MLLe7sBB5tT/sVnTk9t/qBSY=` -- **Private key**: stored in the `SPARKLE_ED25519_PRIVATE_KEY` GitHub - secret and backed up locally at `~/secure/Cotabby-key.txt` (per - `RELEASING.md`). - -Rotating this key means every previously-shipped build will reject all -future updates as untrusted. - -### Logger Subsystem: `com.tabby.*` → `com.cotabby.*` ✅ DONE - -The `TabbyLogger` enum and its `com.tabby.app`, `com.tabby.runtime`, etc. -subsystem strings have been renamed to `CotabbyLogger` and `com.cotabby.*`. -These appear in Console.app as filterable subsystem identifiers. - -This had no user-facing impact (the only cost was invalidating any developer's -Console.app saved filters keyed to the old subsystem), so it was completed as -part of the rename cleanup. `FileLogHandler.category(from:)` derives the -category from the third dotted component, so it keeps working unchanged. - -### PAGES_CUSTOM_DOMAIN: `updates.tabbyapp.dev` - -The release workflow's `PAGES_CUSTOM_DOMAIN` environment variable is set to -`updates.tabbyapp.dev`. This controls the CNAME file written to the GitHub -Pages deployment. - -**Current state:** GitHub Pages is actually deployed under -`updates.cotabby.app` (set via the republish-pages workflow). The release -workflow still writes `updates.tabbyapp.dev` — this will be overwritten on -the next release unless updated. - -**✅ DONE:** `PAGES_CUSTOM_DOMAIN` in `release.yml` (and the -`republish-pages.yml` default) now point at `updates.cotabby.app`, so the -release workflow deploys Pages under the correct domain and no longer reverts -it. This is safe because the `tabbyapp.dev` redirect handles old installs. - ---- - -## Follow-Up Candidates - -These are low-priority items renamed without breaking anything for users. - -### `TabbyLogger` → `CotabbyLogger` ✅ DONE - -The logger factory enum has been renamed from `TabbyLogger` to `CotabbyLogger` -across all source and test files. Purely cosmetic — no runtime or persistence -impact. - -### `AppDelegate.swift` Log Message ✅ DONE - -The launch log line now reads `"Cotabby \(version) (build \(build))..."`. - -### LlamaMiddleware / `TabbyInference` Package ✅ DONE - -The inference dependency was replaced with `CotabbyInference` (see the -"Rename project to Cotabby and replace LlamaSwift with CotabbyInference" -change). No remaining `TabbyInference` references exist in the app target. - -No user impact — this is a build-time dependency name only. - -### Archived Marketing Text ✅ DONE - -`posts.txt` and `launch.txt` are no longer present in the repo root. - -### Old `tabby.xcodeproj` Skeleton ✅ DONE - -No `tabby.xcodeproj/` directory remains in the working tree. - ---- - -## External Services Checklist - -| Service | Account / Identifier | Status | -|---------|---------------------|--------| -| GitHub repo | `FuJacob/tabby` | Not yet renamed to `FuJacob/Cotabby` | -| GitHub Pages | `updates.cotabby.app` | Live, serving appcast | -| DNS redirect | `updates.tabbyapp.dev` → `updates.cotabby.app` | Live (Cloudflare 301) | -| Buy Me a Coffee | `cotabbyapp` | Verify account exists | -| Landing page | `cotabby.app` | Verify deployed | -| Feedback form | `www.cotabby.app/feedback` | Verify deployed | -| Apple Developer | Bundle ID `com.jacobfu.tabby` | Registered (do NOT change) | -| Apple notarization | Team ID `C4BVFMK9V2` | No change needed | -| Sparkle key pair | Ed25519, stored in GitHub Secrets | No change needed | - ---- - -## GitHub Secrets (No Changes Needed) - -These secrets are stored in the repo's Actions settings. None need to be -renamed or rotated for the Cotabby rename: - -| Secret | Purpose | -|--------|---------| -| `DEVELOPER_ID_APPLICATION_CERT` | Base64-encoded Developer ID certificate | -| `DEVELOPER_ID_CERT_PASSWORD` | Certificate import password | -| `APPLE_ID` | Apple ID for notarization | -| `APPLE_TEAM_ID` | Developer Team ID | -| `APPLE_APP_SPECIFIC_PASSWORD` | App-specific password for notarytool | -| `SPARKLE_ED25519_PRIVATE_KEY` | Sparkle appcast signing key | - ---- - -## DNS Architecture - -``` -Existing installs: - App polls → updates.tabbyapp.dev/appcast.xml - → Cloudflare 301 redirect - → updates.cotabby.app/appcast.xml - → GitHub Pages serves appcast - -New installs (after feed URL migration): - App polls → updates.cotabby.app/appcast.xml - → GitHub Pages serves appcast -``` - -Both paths serve the same appcast from the same GitHub Pages deployment. - ---- - -## Decision Log - -| Decision | Rationale | -|----------|-----------| -| Keep bundle ID as `com.jacobfu.tabby` | Changing it resets all macOS permissions and treats the app as a new install | -| Keep UserDefaults keys as `tabby*` | Changing them silently wipes user preferences | -| Keep feed URL as `tabbyapp.dev` in shipped binary | Existing installs have this URL baked in; redirect handles the transition | -| Redirect `tabbyapp.dev` via Cloudflare | Zero-downtime migration; Sparkle follows 301s transparently | -| Rename source dirs, types, and UI strings | Purely cosmetic; no runtime or persistence impact | -| Keep Sparkle key pair unchanged | Rotating breaks OTA updates for all existing installs |