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
123 changes: 123 additions & 0 deletions bridge/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid wiping files before Keychain can fail

When includeKeys is enabled and any Keychain delete returns an error (for example the user denies Keychain access), this removes both data directories first and then returns without relaunching or clearing defaults. That leaves the running app in a partially reset state even though SettingsViewModel.resetAppData treats failures as “nothing is relaunched”; deleting the Keychain entries before the filesystem wipe would avoid losing app data on a later Keychain failure.

Useful? React with 👍 / 👎.

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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear account-login tokens during reset

When a user has signed in with ChatGPT and then chooses Reset app data with key removal, this loop only deletes KEYCHAIN_ACCOUNTS. Provider account tokens are stored in the same Keychain bundle under LLM_OPENAI_ACCOUNT_TOKENS via account_auth::store_account_tokens, and that account is not in KEYCHAIN_ACCOUNTS, so the relaunched “fresh” app can still report the provider account as signed in and retain OAuth tokens after the privacy reset.

Useful? React with 👍 / 👎.

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
Expand All @@ -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<std::path::PathBuf> {
let config_dir = Config::config_dir();
let settings_dir = codescribe_core::config::UserSettings::settings_dir();
Comment on lines +706 to +707

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Include the actual MCP config path in reset scope

When CODESCRIBE_DATA_DIR is set, these are the only reset targets, but the MCP management/probe path still comes from default_mcp_config_path() as $HOME/.codescribe/mcp.json. In that supported override context, Reset app data leaves the configured MCP servers file behind, so the relaunched app is not fresh and still shows the old MCP configuration despite the confirmation saying MCP configuration is deleted.

Useful? React with 👍 / 👎.

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";
Expand Down Expand Up @@ -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"),
}
}
}
}
38 changes: 38 additions & 0 deletions macos/Codescribe/Screens/Onboarding/OnboardingSteps.swift
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,9 @@ struct AgenticReadinessStepView: View {
.foregroundStyle(CSColor.textFaint)
.padding(.top, 4)
statusCard(rows: mcp.rows)
} else if !model.mcpSetupDismissed {
mcpSetupPrompt
Comment on lines +268 to +269

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Show the MCP setup prompt for the no-config row

When mcp_status() runs with no mcp.json, it still returns a non-empty neutral row (probe_mcp_status_at reports no mcp.json), so after the readiness refresh model.mcpStatus satisfies the preceding if let mcp..., !mcp.rows.isEmpty and this new else never renders. In the fresh-install/no-MCP scenario the user only sees the status card, not the new setup/skip actions, so the onboarding route added here is effectively unreachable.

Useful? React with 👍 / 👎.

.padding(.top, 4)
}

OnboardingButton(title: "Refresh", kind: .secondary) {
Expand All @@ -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
Expand Down
23 changes: 23 additions & 0 deletions macos/Codescribe/Screens/Onboarding/OnboardingViewModel.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AppKit
import SwiftUI

// State machine for the first-run wizard: current step index (persisted on every
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Comment on lines +225 to +227

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use the supported Settings opener

For the setup button path, this relies on the private showSettingsWindow:/old showPreferencesWindow: responder chain. The tray settings entry in this repo was already moved to @Environment(\.openSettings) because that selector stopped working on newer macOS; in those environments clicking “Set up MCP servers” merely sets pendingSection and neither action opens the window, leaving the onboarding user with no route to the MCP editor.

Useful? React with 👍 / 👎.

}
}

/// 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()
Expand Down
86 changes: 86 additions & 0 deletions macos/Codescribe/Screens/Settings/EnginePanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
11 changes: 8 additions & 3 deletions macos/Codescribe/Screens/Settings/MCPServersSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions macos/Codescribe/Screens/Settings/SettingsEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion macos/Codescribe/Screens/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading