fix: Ctrl+P provider swap with visual feedback + clippy cleanup

Ctrl+P now inserts a sentinel char (\x01) that the highlighter renders
as a cyan "[Provider Swap]" prompt. User presses Enter to confirm and
launch the setup wizard. Returns ReadOutcome::ProviderSwap so the REPL
loop can hot-swap the model and reprint the connection line.

Also fixes clippy warnings: merged duplicate match arms in
provider_config_value, doc_markdown on ProviderKind, map_unwrap_or
idioms in setup_wizard.rs, and pre-existing clippy issues in main.rs
and commands/lib.rs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
TheArchitectit 2026-04-26 17:36:58 -05:00
parent da19b7c997
commit 050d505b9b
3 changed files with 37 additions and 32 deletions

View File

@ -408,14 +408,14 @@ pub fn provider_config_value(key: &str) -> Option<String> {
let provider = config.provider();
let kind = provider.kind()?;
match (key, kind) {
("ANTHROPIC_API_KEY" | "ANTHROPIC_AUTH_TOKEN", "anthropic") => provider.api_key().map(ToOwned::to_owned),
("ANTHROPIC_BASE_URL", "anthropic") => provider.base_url().map(ToOwned::to_owned),
("XAI_API_KEY", "xai") => provider.api_key().map(ToOwned::to_owned),
("XAI_BASE_URL", "xai") => provider.base_url().map(ToOwned::to_owned),
("OPENAI_API_KEY", "openai") => provider.api_key().map(ToOwned::to_owned),
("OPENAI_BASE_URL", "openai") => provider.base_url().map(ToOwned::to_owned),
("DASHSCOPE_API_KEY", "dashscope") => provider.api_key().map(ToOwned::to_owned),
("DASHSCOPE_BASE_URL", "dashscope") => provider.base_url().map(ToOwned::to_owned),
("ANTHROPIC_API_KEY" | "ANTHROPIC_AUTH_TOKEN", "anthropic")
| ("XAI_API_KEY", "xai")
| ("OPENAI_API_KEY", "openai")
| ("DASHSCOPE_API_KEY", "dashscope") => provider.api_key().map(ToOwned::to_owned),
("ANTHROPIC_BASE_URL", "anthropic")
| ("XAI_BASE_URL", "xai")
| ("OPENAI_BASE_URL", "openai")
| ("DASHSCOPE_BASE_URL", "dashscope") => provider.base_url().map(ToOwned::to_owned),
_ => None,
}
}
@ -437,7 +437,7 @@ pub fn read_env_or_config(key: &str) -> Result<Option<String>, ApiError> {
Ok(None)
}
/// Return the stored ProviderKind from config, if set.
/// Return the stored `ProviderKind` from config, if set.
fn stored_provider_kind() -> Option<ProviderKind> {
let cwd = std::env::current_dir().ok()?;
let config = runtime::ConfigLoader::default_for(&cwd).load().ok()?;

View File

@ -18,12 +18,12 @@ pub enum ReadOutcome {
Submit(String),
Cancel,
Exit,
ProviderSwap,
}
struct SlashCommandHelper {
completions: Vec<String>,
current_line: RefCell<String>,
ctrl_p_pending: RefCell<bool>,
}
impl SlashCommandHelper {
@ -31,7 +31,6 @@ impl SlashCommandHelper {
Self {
completions: normalize_completions(completions),
current_line: RefCell::new(String::new()),
ctrl_p_pending: RefCell::new(false),
}
}
@ -88,19 +87,19 @@ impl Hinter for SlashCommandHelper {
impl Highlighter for SlashCommandHelper {
fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
self.set_current_line(line);
Cow::Borrowed(line)
// When sentinel is present, show visible prompt instead of invisible char
if line.contains('\x01') {
let display = line.replace('\x01', "\x1b[36m[Provider Swap]\x1b[0m ");
Cow::Owned(display)
} else {
Cow::Borrowed(line)
}
}
fn highlight_char(&self, line: &str, _pos: usize, _kind: CmdKind) -> bool {
// Detect Ctrl+P: when previous line was empty and the new line is
// just "P", that's Ctrl+P on an empty buffer (AcceptLine inserts
// the character then submits). Set flag so read_line can intercept.
let prev = self.current_line();
if prev.is_empty() && line == "P" {
*self.ctrl_p_pending.borrow_mut() = true;
}
self.set_current_line(line);
false
// Re-highlight when sentinel is present to show the prompt
line.contains('\x01')
}
}
@ -124,10 +123,12 @@ impl LineEditor {
editor.set_helper(Some(SlashCommandHelper::new(completions)));
editor.bind_sequence(KeyEvent(KeyCode::Char('J'), Modifiers::CTRL), Cmd::Newline);
editor.bind_sequence(KeyEvent(KeyCode::Enter, Modifiers::SHIFT), Cmd::Newline);
// Ctrl+P: accept line to trigger provider wizard in the REPL loop
// Ctrl+P inserts a sentinel character that triggers provider swap.
// The sentinel is invisible but the highlighter shows "[Provider Swap]" prompt.
// User must press Enter to confirm (rustyline cannot chain commands).
editor.bind_sequence(
KeyEvent(KeyCode::Char('P'), Modifiers::CTRL),
Cmd::AcceptLine,
Cmd::SelfInsert(1, '\x01'),
);
Self {
@ -162,16 +163,10 @@ impl LineEditor {
match self.editor.readline(&self.prompt) {
Ok(line) => {
// Check if Ctrl+P was detected by the highlighter.
// The highlighter sets a flag when it sees the previous
// empty line change to uppercase "P", which is what
// Ctrl+P + AcceptLine produces on an empty buffer.
let is_ctrl_p = self
.editor
.helper()
.is_some_and(|h| h.ctrl_p_pending.replace(false));
if is_ctrl_p {
return Ok(ReadOutcome::Submit("/setup".to_string()));
// Ctrl+P inserts \x01 sentinel — triggers provider swap wizard.
// The sentinel is stripped and we return ProviderSwap to the REPL loop.
if line.contains('\x01') {
return Ok(ReadOutcome::ProviderSwap);
}
Ok(ReadOutcome::Submit(line))
}

View File

@ -7102,6 +7102,16 @@ fn run_repl(
cli.record_prompt_history(&trimmed);
cli.run_turn(&trimmed)?;
}
input::ReadOutcome::ProviderSwap => {
// Ctrl+P triggered — launch setup wizard and hot-swap model
setup_wizard::run_setup_wizard()?;
let cwd = std::env::current_dir().unwrap_or_default();
let config = runtime::ConfigLoader::default_for(&cwd).load().ok();
if let Some(new_model) = config.as_ref().and_then(|c| c.provider().model().map(str::to_string)) {
cli.set_model(Some(new_model))?;
}
println!("{}", format_connected_line(&cli.model));
}
input::ReadOutcome::Cancel => {}
input::ReadOutcome::Exit => {
cli.persist_session()?;