diff --git a/src/app/runtime/actions/mod.rs b/src/app/runtime/actions/mod.rs index 85419a6..8e6fad8 100644 --- a/src/app/runtime/actions/mod.rs +++ b/src/app/runtime/actions/mod.rs @@ -60,6 +60,14 @@ pub enum Action { name: String, command: String, }, + ModelList, + ModelUse { + provider: String, + }, + ModelAdd { + name: String, + base_url: String, + }, } pub fn apply_action(state: &mut AppStateRest, action: Action) { @@ -184,6 +192,44 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) { } } } + Action::ModelList => { + state.misc.selected_index = 0; + state.misc.overlay = Overlay::ModelSelector; + state.dirty = true; + } + Action::ModelUse { provider } => { + if state.app_config.providers.contains_key(&provider) { + let default_model = state.app_config.providers[&provider] + .default_model.clone() + .unwrap_or_else(|| "claude-opus-4-8".to_string()); + state.settings.provider = provider.clone(); + state.settings.model = default_model; + let _ = state.settings.save(); + state.push_toast(Toast::new(ToastKind::Success, + format!("Switched to provider '{}' (model: {})", provider, state.settings.model))); + } else { + state.push_toast(Toast::new(ToastKind::Error, + format!("Unknown provider '{}'. Use /model ls to see available ones.", provider))); + } + state.dirty = true; + } + Action::ModelAdd { name, base_url } => { + if base_url.is_empty() { + state.push_toast(Toast::new(ToastKind::Error, "Usage: /model add ".to_string())); + } else { + let cfg = crate::model::app_config::ProviderConfig { + api_base: base_url.clone(), + api_key_env: None, + default_model: Some("claude-opus-4-8".to_string()), + }; + state.app_config.providers.insert(name.clone(), cfg); + state.push_toast(Toast::new(ToastKind::Success, + format!("Added provider '{}' at {}", name, base_url))); + state.push_toast(Toast::new(ToastKind::Info, + "Use /model use to switch, or /edit app_config.json to configure further".to_string())); + state.dirty = true; + } + } Action::CloseOverlay => { // If the overlay is the Editor, dismiss it properly first if state.misc.overlay == Overlay::Editor { diff --git a/src/app/runtime/commands.rs b/src/app/runtime/commands.rs index 5a4300f..cba27a2 100644 --- a/src/app/runtime/commands.rs +++ b/src/app/runtime/commands.rs @@ -34,6 +34,9 @@ pub fn apply_command(command: Command) -> Vec { Command::Mode(mode) => { vec![Action::SwitchMode(mode)] } + Command::ClearConfirm => { + vec![Action::OpenOverlay(Overlay::ClearConfirm)] + } Command::Clear => { vec![Action::SystemNote { kind: "clear".to_string(), @@ -43,12 +46,27 @@ pub fn apply_command(command: Command) -> Vec { Command::Login { provider } => { vec![Action::StartOAuth { provider }] } + Command::Edit(path) if path == "." || path.is_empty() => { + vec![Action::SystemNote { + kind: "info".to_string(), + message: "Usage: /edit \nOpens a file for inline editing.\nExample: /edit src/main.rs".to_string(), + }] + } Command::Edit(path) => { vec![Action::OpenEditor { path }] } Command::McpAdd { name, command } => { vec![Action::McpAdd { name, command }] } + Command::ModelList => { + vec![Action::ModelList] + } + Command::ModelUse { provider } => { + vec![Action::ModelUse { provider }] + } + Command::ModelAdd { name, base_url } => { + vec![Action::ModelAdd { name, base_url }] + } Command::Unknown(cmd) => { vec![Action::SystemNote { kind: "error".to_string(), diff --git a/src/app/state/misc.rs b/src/app/state/misc.rs index 16120be..7260795 100644 --- a/src/app/state/misc.rs +++ b/src/app/state/misc.rs @@ -62,6 +62,7 @@ pub struct InputState { pub autocomplete_prefix: String, pub autocomplete_candidates: Vec, pub autocomplete_idx: usize, + pub autocomplete_visible: bool, } const COMMANDS: &[&str] = &[ @@ -87,6 +88,9 @@ const COMMANDS: &[&str] = &[ "/login openai", "/edit", "/mcp add", + "/model", + "/model ls", + "/model add", ]; impl InputState { @@ -99,36 +103,63 @@ impl InputState { autocomplete_prefix: String::new(), autocomplete_candidates: Vec::new(), autocomplete_idx: 0, + autocomplete_visible: false, } } - pub fn tab_complete(&mut self) { - let trimmed = self.buffer.trim().to_string(); - if trimmed.is_empty() { - return; - } + pub fn close_autocomplete(&mut self) { + self.autocomplete_visible = false; + self.autocomplete_candidates.clear(); + self.autocomplete_prefix.clear(); + self.autocomplete_idx = 0; + } - if !trimmed.starts_with('/') { + pub fn open_autocomplete(&mut self) { + let trimmed = self.buffer.trim().to_string(); + if trimmed.is_empty() || !trimmed.starts_with('/') { + self.close_autocomplete(); return; } let prefix = trimmed.to_lowercase(); + self.autocomplete_candidates = COMMANDS + .iter() + .filter(|c| c.starts_with(&prefix)) + .map(|c| c.to_string()) + .collect(); + self.autocomplete_prefix = prefix; + self.autocomplete_idx = 0; + self.autocomplete_visible = !self.autocomplete_candidates.is_empty(); + } - if prefix != self.autocomplete_prefix || self.autocomplete_candidates.is_empty() { - self.autocomplete_candidates = COMMANDS - .iter() - .filter(|c| c.starts_with(&prefix)) - .map(|c| c.to_string()) - .collect(); - self.autocomplete_prefix = prefix; - self.autocomplete_idx = 0; + pub fn cycle_autocomplete(&mut self, forward: bool) { + let n = self.autocomplete_candidates.len(); + if n == 0 { return; } + if forward { + self.autocomplete_idx = (self.autocomplete_idx + 1) % n; } else { - self.autocomplete_idx = (self.autocomplete_idx + 1) % self.autocomplete_candidates.len(); + self.autocomplete_idx = if self.autocomplete_idx == 0 { n - 1 } else { self.autocomplete_idx - 1 }; } + } + pub fn select_autocomplete(&mut self) -> bool { if let Some(candidate) = self.autocomplete_candidates.get(self.autocomplete_idx) { self.buffer = candidate.clone(); self.cursor = self.buffer.len(); + self.close_autocomplete(); + true + } else { + false + } + } + + pub fn tab_complete(&mut self) { + // Legacy inline tab-complete — used as a fallback when the dropdown + // isn't visible yet. Opens the dropdown on the first Tab press. + if !self.autocomplete_visible { + self.open_autocomplete(); + } else { + self.cycle_autocomplete(true); } } diff --git a/src/app/state/types.rs b/src/app/state/types.rs index b5062e1..815368b 100644 --- a/src/app/state/types.rs +++ b/src/app/state/types.rs @@ -85,6 +85,10 @@ pub enum Overlay { Learning, Usage, Loading, + ModelSelector, + ClearConfirm, + ModePicker, + LoginPicker, } impl Overlay { diff --git a/src/controller/command.rs b/src/controller/command.rs index 1bdd11e..818be41 100644 --- a/src/controller/command.rs +++ b/src/controller/command.rs @@ -12,12 +12,21 @@ pub enum Command { LessonList, Mode(ModeKind), Clear, + ClearConfirm, Login { provider: String }, Edit(String), McpAdd { name: String, command: String, }, + ModelList, + ModelUse { + provider: String, + }, + ModelAdd { + name: String, + base_url: String, + }, Unknown(String), } @@ -33,7 +42,9 @@ pub fn parse_command(text: &str) -> Command { match cmd { "/help" => Command::Help, "/quit" => Command::Quit, + "/clear" if arg1.is_empty() => Command::ClearConfirm, "/clear" => Command::Clear, + "/mode" if arg1.is_empty() => Command::Help, // open help to show available modes "/mode" => { let mode = match arg1 { "chat" | "c" => ModeKind::Chat, @@ -53,25 +64,36 @@ pub fn parse_command(text: &str) -> Command { "/lesson" if arg1 == "reject" && !arg2.is_empty() => Command::LessonReject(arg2.to_string()), "/lesson" if !arg1.is_empty() => Command::LessonCreate(arg1.to_string()), "/lesson" => Command::LessonList, + "/login" if arg1.is_empty() => Command::LessonList, "/login" if !arg1.is_empty() => Command::Login { provider: arg1.to_string() }, - "/login" => Command::Login { provider: "zen".to_string() }, "/edit" if !arg1.is_empty() => Command::Edit(arg1.to_string()), "/edit" => Command::Edit(".".to_string()), + "/mcp" if arg1.is_empty() => { + Command::Mode(ModeKind::Mcp) + } "/mcp" if arg1 == "add" && !arg2.is_empty() => { - // /mcp add [args...] - // name is the first word of arg2, the rest is the command let rest = arg2.trim(); if let Some(space) = rest.find(' ') { let name = rest[..space].to_string(); let command = rest[space + 1..].trim().to_string(); Command::McpAdd { name, command } } else { - Command::McpAdd { - name: rest.to_string(), - command: String::new(), - } + Command::McpAdd { name: rest.to_string(), command: String::new() } } } + "/model" if arg1 == "ls" || arg1 == "list" => Command::ModelList, + "/model" if arg1 == "add" && !arg2.is_empty() => { + let rest = arg2.trim(); + if let Some(space) = rest.find(' ') { + let name = rest[..space].to_string(); + let base_url = rest[space + 1..].trim().to_string(); + Command::ModelAdd { name, base_url } + } else { + Command::ModelAdd { name: rest.to_string(), base_url: String::new() } + } + } + "/model" if arg1.is_empty() => Command::ModelList, + "/model" => Command::ModelUse { provider: arg1.to_string() }, _ => Command::Unknown(cmd.to_string()), } } diff --git a/src/controller/input.rs b/src/controller/input.rs index 1c6344b..6e7e5cf 100644 --- a/src/controller/input.rs +++ b/src/controller/input.rs @@ -63,6 +63,11 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { vec![Action::CloseOverlay] } KeyCode::Enter => { + if state.input.autocomplete_visible { + state.input.select_autocomplete(); + state.dirty = true; + return Vec::new(); + } if state.misc.overlay.is_active() { return handle_overlay_enter(state); } @@ -73,9 +78,19 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { vec![Action::SubmitInput(text)] } KeyCode::Backspace => { + if state.input.autocomplete_visible { + state.input.close_autocomplete(); + state.dirty = true; + return Vec::new(); + } vec![Action::DeleteChar] } KeyCode::Delete => { + if state.input.autocomplete_visible { + state.input.close_autocomplete(); + state.dirty = true; + return Vec::new(); + } vec![Action::DeleteCharRight] } KeyCode::Left => { @@ -85,7 +100,11 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { vec![Action::CursorRight] } KeyCode::Up => { - if key.modifiers.contains(KeyModifiers::CONTROL) { + if state.input.autocomplete_visible { + state.input.cycle_autocomplete(false); + state.dirty = true; + Vec::new() + } else if key.modifiers.contains(KeyModifiers::CONTROL) { vec![Action::HistoryUp] } else if state.misc.overlay == Overlay::Effort { mode::effort::cycle_effort(state); @@ -100,12 +119,30 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { state.misc.selected_index = if state.misc.selected_index == 0 { n.saturating_sub(1) } else { state.misc.selected_index - 1 }; state.dirty = true; Vec::new() + } else if state.misc.overlay == Overlay::ModelSelector { + let n = state.app_config.providers.len(); + state.misc.selected_index = if state.misc.selected_index == 0 { n.saturating_sub(1) } else { state.misc.selected_index - 1 }; + state.dirty = true; + Vec::new() + } else if state.misc.overlay == Overlay::ModePicker { + state.misc.selected_index = if state.misc.selected_index == 0 { 3 } else { state.misc.selected_index - 1 }; + state.dirty = true; + Vec::new() + } else if state.misc.overlay == Overlay::LoginPicker { + let n = crate::app::mode::onboard_provider::PROVIDERS.len(); + state.misc.selected_index = if state.misc.selected_index == 0 { n.saturating_sub(1) } else { state.misc.selected_index - 1 }; + state.dirty = true; + Vec::new() } else { vec![Action::ScrollUp] } } KeyCode::Down => { - if key.modifiers.contains(KeyModifiers::CONTROL) { + if state.input.autocomplete_visible { + state.input.cycle_autocomplete(true); + state.dirty = true; + Vec::new() + } else if key.modifiers.contains(KeyModifiers::CONTROL) { vec![Action::HistoryDown] } else if state.misc.overlay == Overlay::Effort { mode::effort::cycle_effort(state); @@ -120,6 +157,20 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { state.misc.selected_index = if n == 0 { 0 } else { (state.misc.selected_index + 1) % n }; state.dirty = true; Vec::new() + } else if state.misc.overlay == Overlay::ModelSelector { + let n = state.app_config.providers.len(); + state.misc.selected_index = if n == 0 { 0 } else { (state.misc.selected_index + 1) % n }; + state.dirty = true; + Vec::new() + } else if state.misc.overlay == Overlay::ModePicker { + state.misc.selected_index = (state.misc.selected_index + 1) % 4; + state.dirty = true; + Vec::new() + } else if state.misc.overlay == Overlay::LoginPicker { + let n = crate::app::mode::onboard_provider::PROVIDERS.len(); + state.misc.selected_index = (state.misc.selected_index + 1) % n; + state.dirty = true; + Vec::new() } else { vec![Action::ScrollDown] } @@ -158,7 +209,11 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { vec![Action::OpenOverlay(Overlay::KeyInput)] } KeyCode::Esc => { - if state.misc.overlay.is_active() { + if state.input.autocomplete_visible { + state.input.close_autocomplete(); + state.dirty = true; + Vec::new() + } else if state.misc.overlay.is_active() { vec![Action::CloseOverlay] } else { Vec::new() @@ -166,7 +221,11 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { } KeyCode::Tab => { if state.input.buffer.starts_with('/') { - state.input.tab_complete(); + if state.input.autocomplete_visible { + state.input.cycle_autocomplete(true); + } else { + state.input.tab_complete(); + } state.dirty = true; } Vec::new() @@ -178,6 +237,10 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { vec![Action::OpenOverlay(Overlay::Usage)] } KeyCode::Char(c) => { + if state.input.autocomplete_visible { + state.input.close_autocomplete(); + state.dirty = true; + } vec![Action::InsertChar(c)] } _ => Vec::new(), @@ -251,6 +314,49 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec { mode::rewind::rewind_to(state, idx); Vec::new() } + Overlay::ModelSelector => { + let providers: Vec = state.app_config.providers.keys().cloned().collect(); + if let Some(provider) = providers.get(state.misc.selected_index) { + if let Some(cfg) = state.app_config.providers.get(provider) { + let model = cfg.default_model.clone().unwrap_or_else(|| "claude-opus-4-8".to_string()); + state.settings.provider = provider.clone(); + state.settings.model = model.clone(); + let _ = state.settings.save(); + state.push_toast(crate::app::state::types::Toast::new( + crate::app::state::types::ToastKind::Success, + format!("Switched to {} / {}", provider, model), + )); + } + } + state.misc.overlay = Overlay::None; + state.dirty = true; + Vec::new() + } + Overlay::ClearConfirm => { + state.push_toast(crate::app::state::types::Toast::new( + crate::app::state::types::ToastKind::Info, + "Transcript cleared".to_string(), + )); + state.misc.overlay = Overlay::None; + state.dirty = true; + Vec::new() + } + Overlay::ModePicker => { + let mode = state.mode.cycle(); + state.mode = mode; + state.push_toast(crate::app::state::types::Toast::new( + crate::app::state::types::ToastKind::Info, + format!("Mode: {}", mode.name()), + )); + state.misc.overlay = Overlay::None; + state.dirty = true; + Vec::new() + } + Overlay::LoginPicker => { + state.misc.overlay = Overlay::OnboardProvider; + state.dirty = true; + Vec::new() + } _ => Vec::new(), } } diff --git a/src/main.rs b/src/main.rs index 43e24b9..086cd75 100644 --- a/src/main.rs +++ b/src/main.rs @@ -230,6 +230,10 @@ fn apply_client_update( Some("Learning") => Overlay::Learning, Some("Usage") => Overlay::Usage, Some("Loading") => Overlay::Loading, + Some("ModelSelector") => Overlay::ModelSelector, + Some("ClearConfirm") => Overlay::ClearConfirm, + Some("ModePicker") => Overlay::ModePicker, + Some("LoginPicker") => Overlay::LoginPicker, _ => Overlay::None, }; diff --git a/src/view/mod.rs b/src/view/mod.rs index dbb3e6d..4218cee 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -558,10 +558,156 @@ fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::typ .block(block); frame.render_widget(paragraph, overlay_area); } + crate::app::state::types::Overlay::ModelSelector => { + let block = block.title(" Model Selector "); + let mut lines: Vec = vec![ + Line::from(Span::styled( + format!("Current: {} / {}", state.settings.provider, state.settings.model), + Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled("", Style::default())), + Line::from(Span::styled("Providers:", Style::default().fg(Theme::DIM))), + ]; + let providers: Vec<(&String, &crate::model::app_config::ProviderConfig)> = state.app_config.providers.iter().collect(); + for (i, (name, cfg)) in providers.iter().enumerate() { + let is_current = *name == &state.settings.provider; + let is_selected = i == state.misc.selected_index; + let prefix = if is_selected { "▸ " } else { " " }; + let model_str = cfg.default_model.as_deref().unwrap_or("(any)"); + let label = format!("{}{} ({})", prefix, name, model_str); + let style = if is_current { + Style::default().fg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD) + } else if is_selected { + Style::default().fg(Theme::BG).bg(Theme::HIGHLIGHT) + } else { + Style::default().fg(Theme::TEXT) + }; + lines.push(Line::from(Span::styled(label, style))); + } + lines.push(Line::from(Span::styled("", Style::default()))); + lines.push(Line::from(Span::styled( + "↑↓ navigate · Enter select · Esc close · /model add to add", + Style::default().fg(Theme::DIM), + ))); + let paragraph = Paragraph::new(lines).block(block); + frame.render_widget(paragraph, overlay_area); + } + crate::app::state::types::Overlay::ClearConfirm => { + let block = block.title(" Clear Transcript "); + let lines = vec![ + Line::from(Span::styled( + "Clear all messages from the transcript?", + Style::default().fg(Theme::TEXT), + )), + Line::from(Span::styled("", Style::default())), + Line::from(Span::styled( + "Enter to confirm · Esc to cancel", + Style::default().fg(Theme::DIM), + )), + ]; + let paragraph = Paragraph::new(lines).block(block); + frame.render_widget(paragraph, overlay_area); + } + crate::app::state::types::Overlay::ModePicker => { + let block = block.title(" Mode "); + let modes = [ + ("Auto", "auto-approve tools"), + ("Normal", "ask before risky tools"), + ("Plan", "plan-only mode"), + ("Yolo", "full auto"), + ]; + let current = state.mode.name(); + let mut lines: Vec = vec![ + Line::from(Span::styled( + format!("Current: {}", current), + Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled("", Style::default())), + ]; + for (i, (name, desc)) in modes.iter().enumerate() { + let is_selected = i == state.misc.selected_index; + let prefix = if is_selected { "▸ " } else { " " }; + let label = format!("{}{} ({})", prefix, name, desc); + let style = if *name == current { + Style::default().fg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD).add_modifier(Modifier::ITALIC) + } else if is_selected { + Style::default().fg(Theme::BG).bg(Theme::HIGHLIGHT) + } else { + Style::default().fg(Theme::TEXT) + }; + lines.push(Line::from(Span::styled(label, style))); + } + lines.push(Line::from(Span::styled("", Style::default()))); + lines.push(Line::from(Span::styled( + "↑↓ navigate · Enter select · Esc close", + Style::default().fg(Theme::DIM), + ))); + let paragraph = Paragraph::new(lines).block(block); + frame.render_widget(paragraph, overlay_area); + } + crate::app::state::types::Overlay::LoginPicker => { + let block = block.title(" Login "); + let providers = crate::app::mode::onboard_provider::PROVIDERS; + let mut lines: Vec = vec![ + Line::from(Span::styled( + "Select a provider to log in:", + Style::default().fg(Theme::TEXT), + )), + Line::from(Span::styled("", Style::default())), + ]; + for (i, p) in providers.iter().enumerate() { + let is_selected = i == state.misc.selected_index; + let prefix = if is_selected { "▸ " } else { " " }; + let style = if is_selected { + Style::default().fg(Theme::BG).bg(Theme::HIGHLIGHT) + } else { + Style::default().fg(Theme::TEXT) + }; + lines.push(Line::from(Span::styled(format!("{}{}", prefix, p), style))); + } + lines.push(Line::from(Span::styled("", Style::default()))); + lines.push(Line::from(Span::styled( + "↑↓ navigate · Enter select · Esc close", + Style::default().fg(Theme::DIM), + ))); + let paragraph = Paragraph::new(lines).block(block); + frame.render_widget(paragraph, overlay_area); + } } } fn render_input_bar(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { + // Render autocomplete dropdown if visible + if state.input.autocomplete_visible && !state.input.autocomplete_candidates.is_empty() { + let n = state.input.autocomplete_candidates.len().min(10) as u16; + let dropdown_height = n + 2; // border + items + let dropdown_area = Rect { + x: area.x, + y: area.y.saturating_sub(dropdown_height), + width: area.width.min(40), + height: dropdown_height, + }; + let dropdown_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Theme::BORDER)) + .title(" Commands "); + + let mut lines: Vec = Vec::new(); + let selected = state.input.autocomplete_idx; + for (i, candidate) in state.input.autocomplete_candidates.iter().enumerate().take(10) { + let prefix = if i == selected { "▸ " } else { " " }; + let style = if i == selected { + Style::default().fg(Theme::BG).bg(Theme::HIGHLIGHT) + } else { + Style::default().fg(Theme::TEXT) + }; + let label = format!("{}{}", prefix, candidate); + lines.push(Line::from(Span::styled(label, style))); + } + let dropdown = Paragraph::new(lines).block(dropdown_block); + frame.render_widget(dropdown, dropdown_area); + } + let block = Block::default() .borders(Borders::TOP) .border_style(Style::default().fg(Theme::BORDER));