Skip to content
Open
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
27 changes: 24 additions & 3 deletions scripts/benchmark_cc_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@
"open_providers",
"provider_switch_a_to_b",
}
PROVIDER_SWITCH_CONFLICT_INPUT = b"\x1b[B\n" * 32
TUI_PROVIDER_SWITCH_CONFLICT_INPUT = b"c" * 32


class BenchmarkAbort(RuntimeError):
Expand Down Expand Up @@ -1408,8 +1410,12 @@ def timed_cli(
args: list[str],
timeout: float = 60.0,
fail_fast: bool = False,
runner: Callable[[list[str], float, bool], RunResult] | None = None,
) -> RunResult:
result = run_command([str(binary), *args], timeout=timeout, capture_timeout=fail_fast)
if runner is None:
result = run_command([str(binary), *args], timeout=timeout, capture_timeout=fail_fast)
else:
result = runner([str(binary), *args], timeout, fail_fast)
if result.code == 0:
metrics.add(surface, app, operation, result.ms)
else:
Expand All @@ -1420,8 +1426,12 @@ def timed_cli(
return result


def run_provider_switch_cli(args: list[str], timeout: float = 60.0, _capture_timeout: bool = False) -> RunResult:
return run_pty_command(args, send=PROVIDER_SWITCH_CONFLICT_INPUT, timeout=timeout)


def reset_provider_cli(binary: Path, app: str, provider_id: str) -> None:
result = run_command([str(binary), "--app", app, "provider", "switch", provider_id], timeout=60)
result = run_provider_switch_cli([str(binary), "--app", app, "provider", "switch", provider_id], timeout=60)
if result.code != 0:
raise RuntimeError(f"failed to reset {app} to {provider_id}: {result.stderr or result.stdout}")

Expand Down Expand Up @@ -1477,7 +1487,16 @@ def benchmark_cli(

if cli_enabled("provider_switch_a_to_b"):
reset_provider_cli(binary, app, a)
switched = timed_cli(prefix_metrics, binary, "CLI", app, "provider_switch_a_to_b", ["--app", app, "provider", "switch", b], fail_fast=fail_fast and record)
switched = timed_cli(
prefix_metrics,
binary,
"CLI",
app,
"provider_switch_a_to_b",
["--app", app, "provider", "switch", b],
fail_fast=fail_fast and record,
runner=run_provider_switch_cli,
)
if switched.code != 0:
reset_provider_cli(binary, app, a)
continue
Expand Down Expand Up @@ -1823,6 +1842,8 @@ def switch_body(session: TuiSession) -> float:
session.clear()
start_switch = time.perf_counter()
session.send(b" ")
time.sleep(0.15)
session.send(TUI_PROVIDER_SWITCH_CONFLICT_INPUT)
wait_until_tui(session, lambda: effective_current_provider(paths, app) == b, timeout=8)
return (time.perf_counter() - start_switch) * 1000

Expand Down
6 changes: 4 additions & 2 deletions src-tauri/src/app_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -866,8 +866,10 @@ mod tests {
crate::test_support::set_test_home_override(Some(home));
crate::settings::reload_test_settings();

let mut settings = crate::settings::AppSettings::default();
settings.gemini_config_dir = Some(stale_gemini_dir.to_string_lossy().into_owned());
let settings = crate::settings::AppSettings {
gemini_config_dir: Some(stale_gemini_dir.to_string_lossy().into_owned()),
..Default::default()
};
settings.save().expect("save stale settings");
crate::settings::reload_test_settings();

Expand Down
23 changes: 20 additions & 3 deletions src-tauri/src/cli/claude_temp_launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ mod tests {
#[cfg(unix)]
use std::process::Stdio;
#[cfg(unix)]
use std::time::Duration;
use std::time::{Duration, Instant};
use tempfile::TempDir;

#[cfg(unix)]
Expand All @@ -322,6 +322,19 @@ mod tests {
path
}

#[cfg(unix)]
fn wait_until_exists(path: &Path) {
let deadline = Instant::now() + Duration::from_secs(5);
while !path.exists() {
assert!(
Instant::now() < deadline,
"timed out waiting for {}",
path.display()
);
std::thread::sleep(Duration::from_millis(10));
}
}

#[cfg(unix)]
#[test]
fn unix_handoff_command_wraps_claude_and_cleans_up_temp_settings() {
Expand Down Expand Up @@ -360,10 +373,14 @@ mod tests {
#[test]
fn interrupting_handoff_still_cleans_up_temp_settings() {
let temp_dir = TempDir::new().expect("create temp dir");
let ready_path = temp_dir.path().join("claude-ready");
let executable = write_test_executable(
&temp_dir,
"claude-stub.sh",
"trap 'exit 130' INT TERM HUP\nwhile :; do sleep 1; done",
&format!(
"trap 'exit 130' INT TERM HUP\nprintf '%s\\n' ready > {:?}\nwhile :; do sleep 1; done",
ready_path
),
);
let settings_path = temp_dir.path().join("cc-switch-claude-settings.json");
std::fs::write(&settings_path, "{}").expect("seed temp settings");
Expand All @@ -384,7 +401,7 @@ mod tests {
}

let mut child = command.spawn().expect("spawn handoff");
std::thread::sleep(Duration::from_millis(150));
wait_until_exists(&ready_path);
let kill_result = unsafe { libc::kill(-(child.id() as i32), libc::SIGINT) };
assert_eq!(kill_result, 0, "send SIGINT to handoff process group");

Expand Down
23 changes: 20 additions & 3 deletions src-tauri/src/cli/codex_temp_launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ mod tests {
#[cfg(unix)]
use std::process::Stdio;
#[cfg(unix)]
use std::time::Duration;
use std::time::{Duration, Instant};
use tempfile::TempDir;

#[cfg(unix)]
Expand All @@ -345,6 +345,19 @@ mod tests {
path
}

#[cfg(unix)]
fn wait_until_exists(path: &Path) {
let deadline = Instant::now() + Duration::from_secs(5);
while !path.exists() {
assert!(
Instant::now() < deadline,
"timed out waiting for {}",
path.display()
);
std::thread::sleep(Duration::from_millis(10));
}
}

fn provider_with(config: &str, auth: Option<Value>) -> Provider {
let mut settings = serde_json::Map::new();
settings.insert("config".to_string(), Value::String(config.to_string()));
Expand Down Expand Up @@ -405,10 +418,14 @@ mod tests {
#[test]
fn interrupting_handoff_still_cleans_up_temp_codex_home() {
let temp_dir = TempDir::new().expect("create temp dir");
let ready_path = temp_dir.path().join("codex-ready");
let executable = write_test_executable(
&temp_dir,
"codex-stub.sh",
"trap 'exit 130' INT TERM HUP\nwhile :; do sleep 1; done",
&format!(
"trap 'exit 130' INT TERM HUP\nprintf '%s\\n' ready > {:?}\nwhile :; do sleep 1; done",
ready_path
),
);
let codex_home = temp_dir.path().join("cc-switch-codex-home");
std::fs::create_dir_all(&codex_home).expect("create temp codex home");
Expand All @@ -433,7 +450,7 @@ mod tests {
}

let mut child = command.spawn().expect("spawn handoff");
std::thread::sleep(Duration::from_millis(150));
wait_until_exists(&ready_path);
let kill_result = unsafe { libc::kill(-(child.id() as i32), libc::SIGINT) };
assert_eq!(kill_result, 0, "send SIGINT to handoff process group");

Expand Down
4 changes: 2 additions & 2 deletions src-tauri/src/cli/commands/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ fn show_path() -> Result<(), AppError> {
Ok(())
}

fn export_config(file: &PathBuf) -> Result<(), AppError> {
fn export_config(file: &Path) -> Result<(), AppError> {
println!(
"{}",
info(&format!("Exporting configuration to {}...", file.display()))
Expand Down Expand Up @@ -174,7 +174,7 @@ fn export_config(file: &PathBuf) -> Result<(), AppError> {
Ok(())
}

fn import_config(file: &PathBuf) -> Result<(), AppError> {
fn import_config(file: &Path) -> Result<(), AppError> {
println!(
"{}",
info(&format!(
Expand Down
55 changes: 40 additions & 15 deletions src-tauri/src/cli/commands/config_common.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
use clap::Subcommand;
use std::cell::RefCell;
use std::fs;
use std::path::Path;

use super::live_conflict::PromptConflictResolver;
use crate::app_config::AppType;
use crate::cli::i18n::texts;
use crate::cli::ui::{highlight, info, success};
use crate::error::AppError;
use crate::services::ProviderService;
use crate::services::{provider::live_merge, ProviderService};
use crate::store::AppState;

#[derive(Subcommand, Debug, Clone)]
Expand Down Expand Up @@ -114,6 +116,14 @@ fn get_state() -> Result<AppState, AppError> {
AppState::try_new()
}

fn with_prompt_conflict_resolution<T>(
f: impl FnOnce(live_merge::ConflictResolution<'_>) -> Result<T, AppError>,
) -> Result<T, AppError> {
let mut resolver = PromptConflictResolver;
let resolver = RefCell::new(&mut resolver as &mut dyn live_merge::ConflictResolver);
f(live_merge::ConflictResolution::Resolver(&resolver))
}

fn no_current_provider_message(action: CommonConfigSnippetAction) -> &'static str {
match action {
CommonConfigSnippetAction::Set => texts::common_config_snippet_no_current_provider(),
Expand Down Expand Up @@ -185,7 +195,7 @@ fn canonical_common_snippet(app_type: AppType, raw: &str) -> Result<Option<Strin
| AppType::OpenCode
| AppType::Hermes
| AppType::OpenClaw => {
let value: serde_json::Value = serde_json::from_str(&raw).map_err(|e| {
let value: serde_json::Value = serde_json::from_str(raw).map_err(|e| {
AppError::InvalidInput(texts::tui_toast_invalid_json(&e.to_string()))
})?;
if !value.is_object() {
Expand Down Expand Up @@ -237,7 +247,14 @@ fn set(
let snippet = canonical_common_snippet(app_type.clone(), &raw)?.unwrap_or_default();

let state = get_state()?;
ProviderService::set_common_config_snippet(&state, app_type.clone(), Some(snippet))?;
with_prompt_conflict_resolution(|resolution| {
ProviderService::set_common_config_snippet_with_resolution(
&state,
app_type.clone(),
Some(snippet),
resolution,
)
})?;

println!(
"{}",
Expand Down Expand Up @@ -296,11 +313,14 @@ fn extract(

if save {
let snippet = canonical_common_snippet(app_type.clone(), &extracted)?.unwrap_or_default();
ProviderService::set_common_config_snippet(
&state,
app_type.clone(),
Some(snippet.clone()),
)?;
with_prompt_conflict_resolution(|resolution| {
ProviderService::set_common_config_snippet_with_resolution(
&state,
app_type.clone(),
Some(snippet.clone()),
resolution,
)
})?;
println!("{}", success(texts::common_config_snippet_extracted()));
if !snippet.trim().is_empty() {
println!();
Expand All @@ -317,7 +337,14 @@ fn extract(

fn clear(app_type: AppType, _apply: bool) -> Result<(), AppError> {
let state = get_state()?;
ProviderService::clear_common_config_snippet(&state, app_type.clone())?;
with_prompt_conflict_resolution(|resolution| {
ProviderService::set_common_config_snippet_with_resolution(
&state,
app_type.clone(),
None,
resolution,
)
})?;

println!(
"{}",
Expand Down Expand Up @@ -389,11 +416,12 @@ mod tests {
&get_claude_settings_path(),
&json!({
"env": {
"ANTHROPIC_BASE_URL": "https://stale.example"
"ANTHROPIC_BASE_URL": "https://provider.example",
"LOCAL_ONLY": "preserve-me"
}
}),
)
.expect("seed stale live settings");
.expect("seed live settings");

(temp_home, env)
}
Expand Down Expand Up @@ -678,9 +706,6 @@ mod tests {

#[test]
fn follow_up_message_is_omitted_for_additive_apps() {
assert!(matches!(
follow_up_message(AppType::OpenCode, CommonConfigSnippetAction::Set, ""),
None
));
assert!(follow_up_message(AppType::OpenCode, CommonConfigSnippetAction::Set, "").is_none());
}
}
41 changes: 41 additions & 0 deletions src-tauri/src/cli/commands/live_conflict.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
use inquire::Select;

use crate::cli::i18n::texts;
use crate::error::AppError;
use crate::services::provider::live_merge::{self, ConfigConflict};

pub(crate) struct PromptConflictResolver;

impl live_merge::ConflictResolver for PromptConflictResolver {
fn resolve_conflict(
&mut self,
conflict: &ConfigConflict,
) -> Result<live_merge::ConflictChoice, AppError> {
let keep_local = "Keep local value".to_string();
let use_incoming = "Use cc-switch value".to_string();
let prompt = format!(
"Live configuration conflict\nApplication: {}\nTarget: {}\nField: {}\nLocal value: {}\ncc-switch value: {}\nChoose value:",
conflict.app_type.as_str(),
conflict.target,
conflict.path,
conflict.local,
conflict.incoming,
);

let selected = Select::new(&prompt, vec![keep_local.clone(), use_incoming.clone()])
.prompt()
.map_err(|err| match err {
inquire::error::InquireError::OperationCanceled
| inquire::error::InquireError::OperationInterrupted => {
AppError::Message(texts::selection_cancelled().to_string())
}
other => AppError::Message(texts::input_failed_error(&other.to_string())),
})?;

if selected == use_incoming {
Ok(live_merge::ConflictChoice::UseIncoming)
} else {
Ok(live_merge::ConflictChoice::KeepLocal)
}
}
}
1 change: 1 addition & 0 deletions src-tauri/src/cli/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub mod env;
pub mod failover;
pub mod hermes;
pub mod internal;
pub(crate) mod live_conflict;
pub mod mcp;
pub mod prompts;
pub mod provider;
Expand Down
Loading
Loading