From 72f3f231ca33d8244624cbb4883f0de23b8de6e0 Mon Sep 17 00:00:00 2001 From: m-szymanska Date: Sun, 5 Jul 2026 21:55:27 -0700 Subject: [PATCH 1/2] feat(onboarding): guide MCP setup instead of dead end When the agentic-readiness step finds no MCP server configured, show a short human explainer plus "Set up MCP servers" (deep-links to Settings > Engine) and "Skip for now" actions, replacing the old blank dead end. Enrich the Settings MCP empty-state with the same optional-tool framing. A one-shot SettingsDeepLink lets the wizard open the Settings window on the MCP section. Co-Authored-By: Claude Fable 5 --- .../Screens/Onboarding/OnboardingSteps.swift | 38 +++++++++++++++++++ .../Onboarding/OnboardingViewModel.swift | 23 +++++++++++ .../Screens/Settings/MCPServersSection.swift | 11 ++++-- .../Screens/Settings/SettingsView.swift | 9 ++++- .../Screens/Settings/SettingsViewModel.swift | 15 ++++++++ 5 files changed, 92 insertions(+), 4 deletions(-) diff --git a/macos/Codescribe/Screens/Onboarding/OnboardingSteps.swift b/macos/Codescribe/Screens/Onboarding/OnboardingSteps.swift index b45302b0..e5775403 100644 --- a/macos/Codescribe/Screens/Onboarding/OnboardingSteps.swift +++ b/macos/Codescribe/Screens/Onboarding/OnboardingSteps.swift @@ -265,6 +265,9 @@ struct AgenticReadinessStepView: View { .foregroundStyle(CSColor.textFaint) .padding(.top, 4) statusCard(rows: mcp.rows) + } else if !model.mcpSetupDismissed { + mcpSetupPrompt + .padding(.top, 4) } OnboardingButton(title: "Refresh", kind: .secondary) { @@ -277,6 +280,41 @@ struct AgenticReadinessStepView: View { } } + /// Shown on the readiness step when no MCP server is configured yet: a short, + /// human explainer plus a route into the real setup surface and a no-guilt skip. + /// Replaces the old dead end where a missing `mcp.json` showed nothing at all. + private var mcpSetupPrompt: some View { + VStack(alignment: .leading, spacing: 10) { + Text("MCP servers (optional)") + .font(CSFont.mono(10, .semibold)) + .tracking(0.4) + .foregroundStyle(CSColor.textFaint) + Text("MCP servers give the agent extra tools — things like code search, " + + "PR review, or web search. It's entirely optional: skip it now and " + + "wire servers any time from Settings › Engine.") + .font(CSFont.ui(13)) + .lineSpacing(3) + .foregroundStyle(CSColor.textMutedAlt) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 10) { + OnboardingButton(title: "Set up MCP servers", kind: .primary) { + model.openMcpSettings() + } + OnboardingButton(title: "Skip for now", kind: .secondary) { + model.dismissMcpSetupPrompt() + } + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(CSColor.surfaceRaised(0.02))) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(CSColor.hairline(0.07), lineWidth: 1)) + } + private func readinessPill(ready: Bool) -> some View { let accent = ready ? CSColor.olive : CSColor.terracotta let accentLight = ready ? CSColor.oliveLight : CSColor.terracottaLight diff --git a/macos/Codescribe/Screens/Onboarding/OnboardingViewModel.swift b/macos/Codescribe/Screens/Onboarding/OnboardingViewModel.swift index 65d6c1e9..b3b1d8ac 100644 --- a/macos/Codescribe/Screens/Onboarding/OnboardingViewModel.swift +++ b/macos/Codescribe/Screens/Onboarding/OnboardingViewModel.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI // State machine for the first-run wizard: current step index (persisted on every @@ -115,6 +116,11 @@ final class OnboardingViewModel: ObservableObject { @Published private(set) var readiness: CsAgenticReadiness? @Published private(set) var mcpStatus: CsMcpStatusReport? + /// Whether the user dismissed the "set up MCP" prompt shown when no MCP server + /// is configured. Session-only: skipping keeps the readiness step moving without + /// implying an error, and re-opening the wizard offers the prompt again. + @Published private(set) var mcpSetupDismissed = false + // API-key step state. @Published private(set) var providers: [CsProviderOption] = [] @Published var selectedProviderId: String @@ -211,6 +217,23 @@ final class OnboardingViewModel: ObservableObject { mcpStatus = agentStatus.mcpStatus() } + /// Route the user to the live MCP management surface (Settings › Engine) via a + /// one-shot deep-link, then open the standard Settings window. The wizard stays + /// open behind it, so the user can wire a server and return to continue. + func openMcpSettings() { + SettingsDeepLink.pendingSection = .engine + if !NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) { + // Older selector name kept as a defensive fallback. + _ = NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) + } + } + + /// Dismiss the MCP setup prompt for this session so onboarding proceeds without + /// implying MCP is required. + func dismissMcpSetupPrompt() { + mcpSetupDismissed = true + } + private func loadProvidersIfNeeded() { guard providers.isEmpty else { return } providers = engine.availableProviders() diff --git a/macos/Codescribe/Screens/Settings/MCPServersSection.swift b/macos/Codescribe/Screens/Settings/MCPServersSection.swift index 4c8ad295..ed10b718 100644 --- a/macos/Codescribe/Screens/Settings/MCPServersSection.swift +++ b/macos/Codescribe/Screens/Settings/MCPServersSection.swift @@ -45,11 +45,16 @@ struct MCPServersSection: View { } private var emptyState: some View { - HStack(spacing: 8) { - Text("●").font(CSFont.mono(11, .medium)).foregroundStyle(CSColor.textFaint) - Text("no MCP servers configured — add one below") + VStack(alignment: .leading, spacing: 6) { + Text("No MCP servers yet — this is optional.") + .font(CSFont.ui(12.5, .semibold)) + .foregroundStyle(CSColor.textBody) + Text("MCP servers extend the agent with extra tools like code search, " + + "PR review, or web search. Add your first server below, or skip it " + + "and wire one any time.") .font(CSFont.mono(11, .medium)) .foregroundStyle(CSColor.textFaint) + .fixedSize(horizontal: false, vertical: true) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 16) diff --git a/macos/Codescribe/Screens/Settings/SettingsView.swift b/macos/Codescribe/Screens/Settings/SettingsView.swift index 40a4b411..35d5b27d 100644 --- a/macos/Codescribe/Screens/Settings/SettingsView.swift +++ b/macos/Codescribe/Screens/Settings/SettingsView.swift @@ -26,7 +26,14 @@ struct SettingsView: View { .frame(minWidth: 880, maxWidth: .infinity, minHeight: 620, maxHeight: .infinity) .background(Self.windowGradient.ignoresSafeArea()) .preferredColorScheme(.dark) - .onAppear { model.refresh() } + .onAppear { + model.refresh() + // Honour a one-shot deep-link (e.g. onboarding routing to MCP setup) + // so the window lands on the requested section instead of the default. + if let target = SettingsDeepLink.consume() { + model.select(target) + } + } } @ViewBuilder diff --git a/macos/Codescribe/Screens/Settings/SettingsViewModel.swift b/macos/Codescribe/Screens/Settings/SettingsViewModel.swift index bce97baf..178c73cb 100644 --- a/macos/Codescribe/Screens/Settings/SettingsViewModel.swift +++ b/macos/Codescribe/Screens/Settings/SettingsViewModel.swift @@ -22,6 +22,21 @@ enum SettingsSection: String, CaseIterable, Identifiable { } } +/// One-shot deep-link target for the Settings window. A surface outside Settings +/// (e.g. the onboarding wizard routing the user to MCP setup) sets this before +/// opening the window; `SettingsView` consumes it once on appear and navigates to +/// the requested section. Nil means "open on the last/default section". +@MainActor +enum SettingsDeepLink { + static var pendingSection: SettingsSection? + + /// Take the pending target (if any), clearing it so a later open is unaffected. + static func consume() -> SettingsSection? { + defer { pendingSection = nil } + return pendingSection + } +} + /// View-model owning the Settings screen state. Seeded with mock data so the /// #Preview renders standalone; the live app injects `RealSettingsEngine` /// (over the `CodescribeConfig` bridge) + the native permission probe. From 384ce9a16c1d2a95800ddefbb23458b01a8c4a84 Mon Sep 17 00:00:00 2001 From: m-szymanska Date: Sun, 5 Jul 2026 21:56:03 -0700 Subject: [PATCH 2/2] feat(settings): reset app data with optional key removal Add a destructive "Reset app data" danger zone at the foot of the Engine panel: a two-step confirm that wipes all local codescribe data and, when opted in, the Keychain API keys. The wipe logic lives in the Rust bridge as reset_app_data(include_keys), removing the config/logs/transcription tree and the Application Support store via the existing path helpers (honoring CODESCRIBE_DATA_DIR, never hardcoding ~), plus the Keychain bundle. Swift clears the UserDefaults domain and relaunches so the app comes up fresh (onboarding from the top). TCC grants are left untouched. Unit test asserts the reset scope follows CODESCRIBE_DATA_DIR and is idempotent. Co-Authored-By: Claude Fable 5 --- bridge/src/config.rs | 123 ++++++++++++++++++ .../Screens/Settings/EnginePanel.swift | 86 ++++++++++++ .../Screens/Settings/SettingsEngine.swift | 9 ++ .../Screens/Settings/SettingsViewModel.swift | 40 ++++++ 4 files changed, 258 insertions(+) diff --git a/bridge/src/config.rs b/bridge/src/config.rs index c790d642..3fd0e126 100644 --- a/bridge/src/config.rs +++ b/bridge/src/config.rs @@ -643,6 +643,41 @@ impl CodescribeConfig { msg: error.to_string(), }) } + + /// Wipe all local codescribe data for a privacy "Reset app data" action. + /// + /// Removes the two app-owned data trees — the config / logs / transcription + /// directory (`~/.codescribe`) and the Application Support store + /// (`~/Library/Application Support/Codescribe`, i.e. settings.json + thread + /// history) — sourced from the same runtime path helpers the app reads, so a + /// `CODESCRIBE_DATA_DIR` override is honored and no `~` is ever hardcoded. + /// + /// When `include_keys` is true, also removes every Keychain-backed API key + /// (`KEYCHAIN_ACCOUNTS`) and clears it from the live process env. Deleting the + /// data trees clears the `setup_done` sentinel, so the next launch replays the + /// first-run wizard. TCC / permission grants are deliberately NOT touched — + /// those are the user's to manage in System Settings. + /// + /// UserDefaults (window frames / SwiftUI scene restoration) is a CFPreferences + /// domain with no core helper; the Swift caller clears it before relaunch. + pub fn reset_app_data(&self, include_keys: bool) -> Result<(), CsError> { + for dir in app_data_dirs() { + remove_dir_all_tolerant(&dir).map_err(|error| CsError::Config { + msg: format!("failed to remove {}: {error}", dir.display()), + })?; + } + if include_keys { + for account in KEYCHAIN_ACCOUNTS { + delete_key(account).map_err(|error| CsError::Config { + msg: format!("failed to remove keychain key {account}: {error}"), + })?; + // SAFETY: same single-writer invariant as `set_api_key` / + // `clear_api_key` — settings writes are serialized on one Swift actor. + unsafe { std::env::remove_var(account) }; + } + } + Ok(()) + } } /// Re-seed the live hotkey detector atomics after a settings write so mode @@ -663,6 +698,31 @@ fn reload_hotkey_runtime() { codescribe::os::hotkeys::apply_hotkey_config(&Config::load_without_keychain()); } +/// App-owned data directories a full "Reset app data" wipes: the config / logs / +/// transcription tree first, then the Application Support store. Both come from +/// the runtime path helpers (honoring `CODESCRIBE_DATA_DIR`); when an override +/// collapses them onto one path the duplicate is dropped so we never remove twice. +fn app_data_dirs() -> Vec { + let config_dir = Config::config_dir(); + let settings_dir = codescribe_core::config::UserSettings::settings_dir(); + if settings_dir == config_dir { + vec![config_dir] + } else { + vec![config_dir, settings_dir] + } +} + +/// `remove_dir_all` that treats an already-absent directory as success: a reset +/// must not fail just because a tree (e.g. an Application Support store that was +/// never created) does not exist yet. +fn remove_dir_all_tolerant(dir: &std::path::Path) -> std::io::Result<()> { + match fs::remove_dir_all(dir) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + /// Built-in default workspace root when `AGENT_WORKSPACE_ROOTS` is unset. Kept in /// sync with the `list_projects` tool default (`app/agent/tools/workspace.rs`). const DEFAULT_AGENT_WORKSPACE_ROOT: &str = "~/Git"; @@ -778,3 +838,66 @@ fn write_prompt(path: &std::path::Path, content: &str) -> Result<(), CsError> { fs::write(path, content)?; Ok(()) } + +#[cfg(test)] +mod reset_tests { + use super::{app_data_dirs, remove_dir_all_tolerant}; + use serial_test::serial; + use std::time::{SystemTime, UNIX_EPOCH}; + + /// Unique scratch directory under the OS temp dir (never the real home). + fn scratch(tag: &str) -> std::path::PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + std::env::temp_dir().join(format!("cs_reset_{}_{tag}_{nanos}", std::process::id())) + } + + /// The reset scope must follow the `CODESCRIBE_DATA_DIR` override and wipe + /// exactly that tree — never anything outside it — and a repeat wipe of an + /// already-gone tree must succeed (idempotent). `#[serial]`: mutates the global + /// `CODESCRIBE_DATA_DIR` env var read by the core path helpers. + #[test] + #[serial] + fn reset_scope_follows_data_dir_and_is_idempotent() { + let root = scratch("scope"); + std::fs::create_dir_all(root.join("transcriptions")).unwrap(); + std::fs::write(root.join("settings.json"), b"{}").unwrap(); + std::fs::write(root.join("transcriptions/a.txt"), b"hi").unwrap(); + let root_canon = root.canonicalize().unwrap(); + + let previous = std::env::var("CODESCRIBE_DATA_DIR").ok(); + // SAFETY: single-threaded test body, serialized via `#[serial]`. + unsafe { std::env::set_var("CODESCRIBE_DATA_DIR", &root) }; + + let dirs = app_data_dirs(); + assert!(!dirs.is_empty(), "reset must target at least one dir"); + // Every target resolves to the override root — nothing escapes it. + for dir in &dirs { + let resolved = dir.canonicalize().unwrap_or_else(|_| dir.clone()); + assert_eq!( + resolved, root_canon, + "reset target escaped the data dir: {dir:?}" + ); + } + + for dir in &dirs { + remove_dir_all_tolerant(dir).unwrap(); + } + assert!(!root_canon.exists(), "data tree survived reset"); + + // Idempotent: wiping an absent tree is a no-op, not an error. + for dir in &dirs { + remove_dir_all_tolerant(dir).unwrap(); + } + + // SAFETY: restore prior env, same serialized single-thread context. + unsafe { + match previous { + Some(value) => std::env::set_var("CODESCRIBE_DATA_DIR", value), + None => std::env::remove_var("CODESCRIBE_DATA_DIR"), + } + } + } +} diff --git a/macos/Codescribe/Screens/Settings/EnginePanel.swift b/macos/Codescribe/Screens/Settings/EnginePanel.swift index 0d28bbc5..f3f1916a 100644 --- a/macos/Codescribe/Screens/Settings/EnginePanel.swift +++ b/macos/Codescribe/Screens/Settings/EnginePanel.swift @@ -75,6 +75,9 @@ struct EnginePanel: View { MCPServersSection(model: model) .padding(.top, 26) + + ResetAppDataSection(model: model) + .padding(.top, 30) } .padding(.horizontal, 28) .padding(.vertical, 24) @@ -393,6 +396,89 @@ private struct PermissionMatrixCell: View { } } +// MARK: - Reset app data (destructive privacy action) + +/// Danger-zone control at the foot of the Engine panel: a two-step, destructive +/// "Reset app data" flow. The checkbox opts into removing Keychain API keys too; +/// the button arms a confirmation alert that spells out exactly what disappears +/// before the wipe + relaunch runs. +private struct ResetAppDataSection: View { + @ObservedObject var model: SettingsViewModel + @State private var includeKeys = false + @State private var confirming = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SettingsSectionLabel("Reset app data") + + Text("Permanently delete all local codescribe data on this Mac: " + + "conversation history, transcription history, logs, preferences, " + + "and MCP configuration. This cannot be undone.") + .font(CSFont.mono(11, .medium)) + .foregroundStyle(CSColor.textFaint) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 5) + + Toggle(isOn: $includeKeys) { + Text("Also remove API keys from Keychain") + .font(CSFont.ui(12.5, .medium)) + .foregroundStyle(CSColor.textBody) + } + .toggleStyle(.checkbox) + .padding(.top, 13) + + Button { + confirming = true + } label: { + Text("Reset app data…") + .font(CSFont.ui(12, .semibold)) + .foregroundStyle(CSColor.terracottaLight) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .fill(CSColor.terracotta.opacity(0.14)) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder(CSColor.terracotta.opacity(0.30), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .padding(.top, 13) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 16) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(CSColor.terracotta.opacity(0.04)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(CSColor.terracotta.opacity(0.16), lineWidth: 1) + ) + .alert("Reset app data?", isPresented: $confirming) { + Button("Cancel", role: .cancel) {} + Button("Reset & Relaunch", role: .destructive) { + model.resetAppData(includeKeys: includeKeys) + } + } message: { + Text(confirmMessage) + } + } + + private var confirmMessage: String { + var text = "This permanently deletes conversation history, transcription " + + "history, logs, preferences, and MCP configuration." + if includeKeys { + text += " Your API keys will also be removed from the Keychain." + } + text += " codescribe will then relaunch as a fresh install." + return text + } +} + // MARK: - Shared section label (mono, muted, wide tracking) — used by all panels. struct SettingsSectionLabel: View { diff --git a/macos/Codescribe/Screens/Settings/SettingsEngine.swift b/macos/Codescribe/Screens/Settings/SettingsEngine.swift index db497d15..b88d759f 100644 --- a/macos/Codescribe/Screens/Settings/SettingsEngine.swift +++ b/macos/Codescribe/Screens/Settings/SettingsEngine.swift @@ -51,6 +51,10 @@ protocol SettingsEngine { func setFormattingPrompt(content: String) throws func setAssistivePrompt(content: String) throws func resetPromptsToDefaults() throws + + // Destructive privacy action: wipe all local app data (config / logs / + // transcriptions + Application Support store), optionally the Keychain keys. + func resetAppData(includeKeys: Bool) throws } // MARK: - Real engine (UniFFI bridge adapter) @@ -103,6 +107,10 @@ final class RealSettingsEngine: SettingsEngine { try config.setAssistivePrompt(content: content) } func resetPromptsToDefaults() throws { try config.resetPromptsToDefaults() } + + func resetAppData(includeKeys: Bool) throws { + try config.resetAppData(includeKeys: includeKeys) + } } // MARK: - Mock engine (previews) @@ -161,6 +169,7 @@ struct MockSettingsEngine: SettingsEngine { func setFormattingPrompt(content: String) throws {} func setAssistivePrompt(content: String) throws {} func resetPromptsToDefaults() throws {} + func resetAppData(includeKeys: Bool) throws {} } // MARK: - Bridge value helpers diff --git a/macos/Codescribe/Screens/Settings/SettingsViewModel.swift b/macos/Codescribe/Screens/Settings/SettingsViewModel.swift index 178c73cb..9d1e2e5d 100644 --- a/macos/Codescribe/Screens/Settings/SettingsViewModel.swift +++ b/macos/Codescribe/Screens/Settings/SettingsViewModel.swift @@ -37,6 +37,29 @@ enum SettingsDeepLink { } } +/// Clears the app's preferences domain and relaunches a fresh instance. Used by +/// the destructive "Reset app data" flow so restored window frames / SwiftUI scene +/// state do not survive the wipe. The relaunch is deferred via a detached `open` +/// so this instance fully exits first — otherwise AppDelegate's duplicate-instance +/// guard would terminate the freshly-launched copy. +@MainActor +enum AppRelaunch { + static func clearDefaultsAndRelaunch() { + if let bundleId = Bundle.main.bundleIdentifier { + UserDefaults.standard.removePersistentDomain(forName: bundleId) + UserDefaults.standard.synchronize() + } + let bundlePath = Bundle.main.bundlePath + let task = Process() + task.launchPath = "/bin/sh" + // `$0` carries the bundle path as a positional arg so it is safely quoted, + // never interpolated into the script string. + task.arguments = ["-c", "sleep 1; open \"$0\"", bundlePath] + try? task.run() + NSApp.terminate(nil) + } +} + /// View-model owning the Settings screen state. Seeded with mock data so the /// #Preview renders standalone; the live app injects `RealSettingsEngine` /// (over the `CodescribeConfig` bridge) + the native permission probe. @@ -307,6 +330,23 @@ final class SettingsViewModel: ObservableObject { } } + // MARK: - Reset app data (destructive privacy action) + + /// Wipe all local app data through the Rust bridge, clear the app's + /// UserDefaults domain, then relaunch so codescribe comes up fresh (first-run + /// wizard from the top). `includeKeys` also removes the Keychain API keys. + /// On failure the error surfaces in `lastError` and nothing is relaunched. + func resetAppData(includeKeys: Bool) { + guard let engine else { return } + do { + try engine.resetAppData(includeKeys: includeKeys) + } catch { + lastError = String(describing: error) + return + } + AppRelaunch.clearDefaultsAndRelaunch() + } + // MARK: - Engine-panel derived values (runtime truth) var activeSTT: String {