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 @@ -102,6 +102,7 @@
61EC9D635D416115E7C96E0F /* PermissionOverlayWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92C6EB9FDA48ADF425A116A9 /* PermissionOverlayWindowController.swift */; };
63054CBDCA87560130BF5ADC /* ExtendedContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54BC85605541E913EE57B258 /* ExtendedContextTests.swift */; };
644EEF959D07D54CC779BBF6 /* SettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3350EDE01ED5125520C79D53 /* SettingsCoordinator.swift */; };
64DA031AEAC20AC6C852A24A /* OnboardingTemplateFeatureList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24F613F0E2F7046E6532A09C /* OnboardingTemplateFeatureList.swift */; };
65478B0DABF5460C32D4C458 /* ModelFileValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A829F28F01FAE76CA7244BBC /* ModelFileValidatorTests.swift */; };
66C23A7C2FCDE0266FF425F8 /* ApplicationBundleMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 352AF5B2834FEE1F597394E4 /* ApplicationBundleMetadata.swift */; };
66D9E37B12A9265D4733E72E /* LlamaRuntimeCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 944065A858D9BC936CB12B23 /* LlamaRuntimeCore.swift */; };
Expand Down Expand Up @@ -184,6 +185,7 @@
D800277BE3296DE2FB8198A8 /* ChromiumAccessibilityEnabler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8896D976C7F116EBA0F3969F /* ChromiumAccessibilityEnabler.swift */; };
D90C889A623A928F4F5FDC7B /* HardwareCapabilityProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41BBD5A4BA08CABE77860886 /* HardwareCapabilityProbe.swift */; };
D9C51DEDF01033E276A479CE /* AXTextGeometryResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB58035EFFD65B767949BAE6 /* AXTextGeometryResolver.swift */; };
DA23422A2CF77CFD3B1283C8 /* OnboardingTemplateFeatureListTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D814BBA41CF29E8DD9954651 /* OnboardingTemplateFeatureListTests.swift */; };
DD7FA343F1C21C4569F6D181 /* ScreenshotContextGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B84BAE361626891F19DC9DB /* ScreenshotContextGenerator.swift */; };
DDEDCBAA2196303455F6926A /* AcceptanceModePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5DAF68AEBFE334F68A65E82 /* AcceptanceModePickerView.swift */; };
DE236C9285635C686D66A2F6 /* TerminalAppDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43E37A7E835D3BDE6265843C /* TerminalAppDetectorTests.swift */; };
Expand Down Expand Up @@ -254,6 +256,7 @@
21CB3008986BE7FD2A4D9132 /* WelcomeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeCoordinator.swift; sourceTree = "<group>"; };
220CD4AFA1E96A37BC4514AD /* LaunchAtLoginService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLoginService.swift; sourceTree = "<group>"; };
22544F4B756E3E4144497D17 /* SuggestionCoordinator+Input.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionCoordinator+Input.swift"; sourceTree = "<group>"; };
24F613F0E2F7046E6532A09C /* OnboardingTemplateFeatureList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTemplateFeatureList.swift; sourceTree = "<group>"; };
262BE2F1E97389FE8D7A5FB9 /* Cotabby.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Cotabby.app; sourceTree = BUILT_PRODUCTS_DIR; };
264CA64B2AB1611F82E5B760 /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = "<group>"; };
273B4DC844F79B4BE2C8910F /* FocusPollBackoffTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusPollBackoffTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -401,6 +404,7 @@
D4F6D5F94B238F7B4BE7C247 /* FocusCapabilityResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusCapabilityResolverTests.swift; sourceTree = "<group>"; };
D504BEB224E0C176F5FCFF6E /* CompletionRenderModePolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionRenderModePolicyTests.swift; sourceTree = "<group>"; };
D5D6C2318E405AA717D1C256 /* WelcomePermissionStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomePermissionStepView.swift; sourceTree = "<group>"; };
D814BBA41CF29E8DD9954651 /* OnboardingTemplateFeatureListTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTemplateFeatureListTests.swift; sourceTree = "<group>"; };
D84D4528EEC9EFEB8AE8E318 /* ActivationIndicatorController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivationIndicatorController.swift; sourceTree = "<group>"; };
D9C1C921A1CDA2ADFC39EA01 /* AppsPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppsPaneView.swift; sourceTree = "<group>"; };
DB0CE9AB1286367BA2E82392 /* SettingsContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsContainerView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -669,6 +673,7 @@
FC83D14A7557BC0196E59007 /* MirrorOverlayLayoutTests.swift */,
03766F6253FF17639230C0F6 /* ModelAndPresentationValueTests.swift */,
A829F28F01FAE76CA7244BBC /* ModelFileValidatorTests.swift */,
D814BBA41CF29E8DD9954651 /* OnboardingTemplateFeatureListTests.swift */,
01B72736E416910878E8E493 /* OnboardingTemplateRecommenderTests.swift */,
E7F42112F14026E6253BB865 /* PermissionAndContextModelTests.swift */,
12DD19BCE610808F1E38702D /* PermissionOverlayTrackerTests.swift */,
Expand Down Expand Up @@ -806,6 +811,7 @@
8D610FCA3A97249DCCE7D0B8 /* LLMIOFileHandler.swift */,
A863F41C0C03D7B4AC5DC002 /* MarkerSelectionSynthesizer.swift */,
54150A507B03221F137D539B /* MirrorOverlayLayout.swift */,
24F613F0E2F7046E6532A09C /* OnboardingTemplateFeatureList.swift */,
FA878B447441BB4F3E327CC8 /* OnboardingTemplateRecommender.swift */,
E6423D6CC8CC371D2DA899DE /* PermissionOverlayTracker.swift */,
FA4B45B91D4DEAC979C3113E /* PromptContextSanitizer.swift */,
Expand Down Expand Up @@ -1030,6 +1036,7 @@
2F227738D7834B1A7A81D1D6 /* ModelDownloadManager.swift in Sources */,
317883210D1D1D5CD654E562 /* ModelFileValidator.swift in Sources */,
FC6B0524B774F20C18BD6889 /* OnboardingTemplate.swift in Sources */,
64DA031AEAC20AC6C852A24A /* OnboardingTemplateFeatureList.swift in Sources */,
DF8794793110A8ED234CBA96 /* OnboardingTemplateRecommender.swift in Sources */,
0DDC0CFF5558A8F4355836B2 /* OverlayController.swift in Sources */,
0187EAA1D37B92DD5B264016 /* PermissionDragSourceView.swift in Sources */,
Expand Down Expand Up @@ -1131,6 +1138,7 @@
14D77F0B8A195AC2FA8D24A9 /* MirrorOverlayLayoutTests.swift in Sources */,
25D4FC8D191A50F63E6391F9 /* ModelAndPresentationValueTests.swift in Sources */,
65478B0DABF5460C32D4C458 /* ModelFileValidatorTests.swift in Sources */,
DA23422A2CF77CFD3B1283C8 /* OnboardingTemplateFeatureListTests.swift in Sources */,
D648DD70AD847F67B77CE052 /* OnboardingTemplateRecommenderTests.swift in Sources */,
15FA56CEF6FB5FF54C2FBA6F /* PermissionAndContextModelTests.swift in Sources */,
4F38CE1C2602CF4F41323032 /* PermissionOverlayTrackerTests.swift in Sources */,
Expand Down
16 changes: 11 additions & 5 deletions Cotabby/Models/OnboardingTemplate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ enum OnboardingTemplate: String, CaseIterable, Identifiable, Equatable, Sendable
case .everyday:
return "A balance of speed and quality for everyday writing."
case .powerful:
return "Longer, multi-line suggestions for the most capable completions."
return "Longer suggestions and higher quality on harder prompts."
}
}

Expand Down Expand Up @@ -83,16 +83,21 @@ enum OnboardingTemplate: String, CaseIterable, Identifiable, Equatable, Sendable
self == .quick
}

/// Powerful opts into multi-line continuations since its model can sustain them.
var enablesMultiLine: Bool {
self == .powerful
/// Multi-line is off in every tier — the onboarding presets stay single-line so a fresh user
/// doesn't get long block completions before they've chosen that tradeoff in General.
var enablesMultiLine: Bool { false }

/// Quick stays lean by skipping the per-keystroke clipboard read and the extra prompt bytes
/// it adds; Everyday and Powerful pay that small cost for the extra signal.
var enablesClipboardContext: Bool {
self != .quick
}

/// The local GGUF this template installs when the Open Source engine is selected.
var openSourceModelFilename: String {
switch self {
case .quick:
return "Qwen3-0.6B-Q4_K_M.gguf"
return "SmolLM2-135M-Instruct-q8_0.gguf"
case .everyday:
return "gemma-4-E2B-it-Q4_K_M.gguf"
case .powerful:
Expand All @@ -110,6 +115,7 @@ struct ResolvedTemplatePlan: Equatable, Sendable {
let wordCountPreset: SuggestionWordCountPreset
let enablesFastMode: Bool
let enablesMultiLine: Bool
let enablesClipboardContext: Bool
}

/// Whether a template can be offered on the current Mac, plus optional advisory copy.
Expand Down
51 changes: 51 additions & 0 deletions Cotabby/Support/OnboardingTemplateFeatureList.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Foundation

/// File overview:
/// Pure description of which behaviors each `OnboardingTemplate` turns on or off. The onboarding
/// template card uses this to render a collapsible "what's included" section so the user can see
/// exactly what they're opting into before tapping a card.
///
/// Keeping this list as a pure helper (instead of inlining it in the SwiftUI view) means the same
/// summary is unit-testable and stays in lock-step with the source-of-truth properties on
/// `OnboardingTemplate`. If a template gains a new behavior flag, the row order here is the only
/// place to extend — the UI walks whatever rows this returns.

enum OnboardingTemplateFeatureValue: Equatable, Sendable {
/// Toggled on by the template. UI renders a positive affordance (e.g., a check).
case enabled
/// Explicitly off under the template. UI renders a neutral/negative affordance (e.g., a dash).
case disabled
/// A non-boolean setting that takes a value (e.g., suggestion length preset). UI shows the
/// trailing text alongside the row title.
case detail(String)
}

struct OnboardingTemplateFeatureRow: Equatable, Sendable, Identifiable {
let title: String
let value: OnboardingTemplateFeatureValue

/// `title` is unique within a template's row list, so it doubles as the SwiftUI identity key.
var id: String { title }
}

enum OnboardingTemplateFeatureList {
/// Returns the ordered rows shown in the template card's disclosure section.
/// Order matters: the most user-visible knob (suggestion length) comes first, then behavior
/// flags. Adding a new row here automatically surfaces it in the UI.
static func rows(for template: OnboardingTemplate) -> [OnboardingTemplateFeatureRow] {
[
OnboardingTemplateFeatureRow(
title: "Suggestion length",
value: .detail(template.wordCountPreset.displayLabel)
),
OnboardingTemplateFeatureRow(
title: "Fast mode (skip screen context)",
value: template.enablesFastMode ? .enabled : .disabled
),
OnboardingTemplateFeatureRow(
title: "Clipboard context",
value: template.enablesClipboardContext ? .enabled : .disabled
)
]
}
}
3 changes: 2 additions & 1 deletion Cotabby/Support/OnboardingTemplateRecommender.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ enum OnboardingTemplateRecommender {
modelToDownload: model,
wordCountPreset: template.wordCountPreset,
enablesFastMode: template.enablesFastMode,
enablesMultiLine: template.enablesMultiLine
enablesMultiLine: template.enablesMultiLine,
enablesClipboardContext: template.enablesClipboardContext
)
}

Expand Down
126 changes: 110 additions & 16 deletions Cotabby/UI/WelcomeTemplateStepView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,35 @@ private struct TemplateCard: View {
let downloadState: ModelDownloadState?
let onTap: () -> Void

/// Each card tracks its own disclosure state so opening one doesn't expand the others.
/// Collapsed by default — users who trust the recommendation should never have to see the
/// row list, and short cards preserve the "pick one" feel of this step.
@State private var isFeatureListExpanded = false

var body: some View {
VStack(spacing: 0) {
selectionButton
featureDisclosure
}
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(.regularMaterial)
.shadow(color: .black.opacity(0.06), radius: 2, y: 1)
)
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(
isSelected && !availability.isDisabled
? Color.accentColor.opacity(0.45) : Color.white.opacity(0.08),
lineWidth: isSelected && !availability.isDisabled ? 1.5 : 0.5
)
)
.opacity(availability.isDisabled ? 0.55 : 1.0)
}

/// Main card surface that selects the template. The feature disclosure is rendered as a sibling
/// view below this button so its toggle taps never double-fire selection.
private var selectionButton: some View {
Button(action: onTap) {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 14) {
Expand Down Expand Up @@ -166,25 +194,91 @@ private struct TemplateCard: View {
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(.regularMaterial)
.shadow(color: .black.opacity(0.06), radius: 2, y: 1)
)
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(
isSelected && !availability.isDisabled
? Color.accentColor.opacity(0.45) : Color.white.opacity(0.08),
lineWidth: isSelected && !availability.isDisabled ? 1.5 : 0.5
)
)
.opacity(availability.isDisabled ? 0.55 : 1.0)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(availability.isDisabled)
}

/// Collapsible "what's included" section. Rendering it outside the selection button keeps the
/// expand/collapse tap target separate so opening the list doesn't change the selected card.
private var featureDisclosure: some View {
let rows = OnboardingTemplateFeatureList.rows(for: template)
return VStack(alignment: .leading, spacing: 0) {
Divider().opacity(0.4)

Button {
withAnimation(.easeInOut(duration: 0.18)) {
isFeatureListExpanded.toggle()
}
} label: {
HStack(spacing: 6) {
Text(isFeatureListExpanded ? "Hide details" : "What's included")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
Image(systemName: "chevron.down")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.secondary)
.rotationEffect(.degrees(isFeatureListExpanded ? 0 : -90))
Spacer(minLength: 0)
}
.padding(.horizontal, 16)
.padding(.vertical, 9)
.contentShape(Rectangle())
}
.buttonStyle(.plain)

if isFeatureListExpanded {
VStack(alignment: .leading, spacing: 6) {
ForEach(rows) { row in
featureRowView(row)
}
}
.padding(.horizontal, 16)
.padding(.bottom, 12)
.transition(.opacity.combined(with: .move(edge: .top)))
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
}

@ViewBuilder
private func featureRowView(_ row: OnboardingTemplateFeatureRow) -> some View {
HStack(spacing: 8) {
featureRowIcon(for: row.value)
.frame(width: 14, alignment: .center)

Text(row.title)
.font(.system(size: 11))
.foregroundStyle(.secondary)

Spacer(minLength: 4)

if case .detail(let value) = row.value {
Text(value)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
}
}
}

@ViewBuilder
private func featureRowIcon(for value: OnboardingTemplateFeatureValue) -> some View {
switch value {
case .enabled:
Image(systemName: "checkmark")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(Color.accentColor)
case .disabled:
Image(systemName: "minus")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.tertiary)
case .detail:
Image(systemName: "slider.horizontal.3")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.tertiary)
}
}

private var iconBadge: some View {
ZStack {
RoundedRectangle(cornerRadius: 10, style: .continuous)
Expand All @@ -208,8 +302,8 @@ private struct TemplateCard: View {
case .appleIntelligence:
return "Apple Intelligence · built into macOS"
case .llamaOpenSource:
let size = plan.modelToDownload?.approximateSizeLabel ?? ""
return "Local model · \(size) download"
guard let model = plan.modelToDownload else { return "Local model" }
return "\(model.displayName) · \(model.approximateSizeLabel) download"
}
}

Expand Down
1 change: 1 addition & 0 deletions Cotabby/UI/WelcomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,7 @@ extension WelcomeView {
suggestionSettings.selectWordCountPreset(plan.wordCountPreset)
suggestionSettings.setFastModeEnabled(plan.enablesFastMode)
suggestionSettings.setMultiLineEnabled(plan.enablesMultiLine)
suggestionSettings.setClipboardContextEnabled(plan.enablesClipboardContext)

guard let model = plan.modelToDownload else {
return
Expand Down
30 changes: 30 additions & 0 deletions CotabbyTests/OnboardingTemplateFeatureListTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import XCTest
@testable import Cotabby

final class OnboardingTemplateFeatureListTests: XCTestCase {
func testQuickShowsShortLengthAndFastModeOnAndClipboardOff() {
let rows = OnboardingTemplateFeatureList.rows(for: .quick)
XCTAssertEqual(rows.map(\.title), [
"Suggestion length",
"Fast mode (skip screen context)",
"Clipboard context"
])
XCTAssertEqual(rows[0].value, .detail(OnboardingTemplate.quick.wordCountPreset.displayLabel))
XCTAssertEqual(rows[1].value, .enabled)
XCTAssertEqual(rows[2].value, .disabled)
}

func testEverydayShowsMediumLengthFastModeOffAndClipboardOn() {
let rows = OnboardingTemplateFeatureList.rows(for: .everyday)
XCTAssertEqual(rows[0].value, .detail(OnboardingTemplate.everyday.wordCountPreset.displayLabel))
XCTAssertEqual(rows[1].value, .disabled)
XCTAssertEqual(rows[2].value, .enabled)
}

func testPowerfulShowsLongLengthFastModeOffAndClipboardOn() {
let rows = OnboardingTemplateFeatureList.rows(for: .powerful)
XCTAssertEqual(rows[0].value, .detail(OnboardingTemplate.powerful.wordCountPreset.displayLabel))
XCTAssertEqual(rows[1].value, .disabled)
XCTAssertEqual(rows[2].value, .enabled)
}
}
Loading