diff --git a/src/arxiv_explorer/cli/daily.py b/src/arxiv_explorer/cli/daily.py index d2155bc..6565279 100644 --- a/src/arxiv_explorer/cli/daily.py +++ b/src/arxiv_explorer/cli/daily.py @@ -239,6 +239,12 @@ def show( arxiv_id, paper.title, paper.abstract, detailed=detailed, force=force ) + if (summary or detailed) and paper_summary is None: + import sys + + print("Failed to generate summary (check provider settings)", file=sys.stderr) + raise typer.Exit(1) + paper_translation = None if translate: translator = TranslationService() @@ -252,6 +258,12 @@ def show( arxiv_id, paper.title, paper.abstract, force=force ) + if translate and paper_translation is None: + import sys + + print("Failed to generate translation (check provider settings)", file=sys.stderr) + raise typer.Exit(1) + print_paper_detail(paper, paper_summary, paper_translation) diff --git a/src/arxiv_explorer/core/database.py b/src/arxiv_explorer/core/database.py index d565b7a..2ca5af1 100644 --- a/src/arxiv_explorer/core/database.py +++ b/src/arxiv_explorer/core/database.py @@ -100,6 +100,15 @@ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); +-- Custom AI providers +CREATE TABLE IF NOT EXISTS custom_providers ( + name TEXT PRIMARY KEY NOT NULL, + preset TEXT NOT NULL, + command_template TEXT NOT NULL, + default_model TEXT DEFAULT '', + added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + -- Paper review sections (incremental cache) CREATE TABLE IF NOT EXISTS paper_review_sections ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/src/arxiv_explorer/core/models.py b/src/arxiv_explorer/core/models.py index 766910b..6d08a77 100644 --- a/src/arxiv_explorer/core/models.py +++ b/src/arxiv_explorer/core/models.py @@ -38,6 +38,14 @@ class Language(str, Enum): KO = "ko" +@dataclass +class CustomProviderConfig: + name: str + preset: str + command_template: str + default_model: str = "" + + class JobType(Enum): SUMMARIZE = "summarize" TRANSLATE = "translate" diff --git a/src/arxiv_explorer/services/arxiv_client.py b/src/arxiv_explorer/services/arxiv_client.py index 0552d99..cc3cc2c 100644 --- a/src/arxiv_explorer/services/arxiv_client.py +++ b/src/arxiv_explorer/services/arxiv_client.py @@ -40,8 +40,9 @@ def _build_query(query: str) -> str: import re # Already formatted: contains field prefix or boolean operator - if re.search(r'\b(all|ti|au|abs|cat|co|jr|rn|id):', query) or \ - re.search(r'\b(AND|OR|ANDNOT)\b', query): + if re.search(r"\b(all|ti|au|abs|cat|co|jr|rn|id):", query) or re.search( + r"\b(AND|OR|ANDNOT)\b", query + ): return query words = query.split() diff --git a/src/arxiv_explorer/services/providers.py b/src/arxiv_explorer/services/providers.py index 91950ac..03910e4 100644 --- a/src/arxiv_explorer/services/providers.py +++ b/src/arxiv_explorer/services/providers.py @@ -161,12 +161,29 @@ def build_command(self, prompt: str, model: str = "") -> list[str]: } -def get_provider(provider_type: AIProviderType) -> AIProvider: - """Return a provider instance. If custom, load the template from settings.""" - provider = PROVIDERS[provider_type] - if provider_type == AIProviderType.CUSTOM: - from .settings_service import SettingsService - - template = SettingsService().get("custom_command") - provider.configure(template) - return provider +def get_provider(provider_name: str | AIProviderType) -> AIProvider: + """Return a provider instance. Checks built-in registry first, then custom_providers table.""" + # Normalize to string + name = provider_name.value if isinstance(provider_name, AIProviderType) else provider_name + + # Try built-in registry + for ptype, prov in PROVIDERS.items(): + if ptype.value == name: + if ptype == AIProviderType.CUSTOM: + from .settings_service import SettingsService + + template = SettingsService().get("custom_command") + prov.configure(template) + return prov + + # Try custom_providers table + from .settings_service import SettingsService + + for cp in SettingsService().get_custom_providers(): + if cp.name == name: + provider = CustomProvider() + provider.configure(cp.command_template) + return provider + + # Fallback to gemini + return PROVIDERS[AIProviderType.GEMINI] diff --git a/src/arxiv_explorer/services/settings_service.py b/src/arxiv_explorer/services/settings_service.py index 00d53e9..ad0c3d7 100644 --- a/src/arxiv_explorer/services/settings_service.py +++ b/src/arxiv_explorer/services/settings_service.py @@ -70,9 +70,9 @@ def get_all(self) -> dict[str, str]: settings[row["key"]] = row["value"] return settings - def get_provider(self) -> AIProviderType: - """Get the current AI provider.""" - return AIProviderType(self.get("ai_provider")) + def get_provider(self) -> str: + """Return the active provider name as a string.""" + return self.get("ai_provider") def get_model(self) -> str: """Get the current AI model override.""" @@ -104,3 +104,47 @@ def set_weights(self, weights: dict[str, int]) -> None: def reset_weights(self) -> None: """Reset recommendation weights to defaults.""" self.set_weights(DEFAULT_WEIGHTS) + + # Reserved names that cannot be used for custom providers + RESERVED_PROVIDERS = {"gemini", "claude", "openai", "ollama", "opencode", "custom"} + + def get_custom_providers(self) -> list: + """Return all custom providers as list of CustomProviderConfig.""" + from ..core.models import CustomProviderConfig + + with get_connection() as conn: + rows = conn.execute( + "SELECT name, preset, command_template, default_model FROM custom_providers ORDER BY name" + ).fetchall() + return [ + CustomProviderConfig( + name=r["name"], + preset=r["preset"], + command_template=r["command_template"], + default_model=r["default_model"] or "", + ) + for r in rows + ] + + def add_custom_provider( + self, name: str, preset: str, command_template: str, default_model: str = "" + ) -> None: + """Register a custom provider. Raises ValueError if name is reserved or duplicate.""" + if name.lower() in self.RESERVED_PROVIDERS: + raise ValueError(f"'{name}' is a reserved provider name") + with get_connection() as conn: + conn.execute( + "INSERT OR REPLACE INTO custom_providers (name, preset, command_template, default_model) " + "VALUES (?, ?, ?, ?)", + (name, preset, command_template, default_model), + ) + conn.commit() + + def remove_custom_provider(self, name: str) -> None: + """Remove a custom provider. If it's the active provider, switch to gemini.""" + with get_connection() as conn: + conn.execute("DELETE FROM custom_providers WHERE name = ?", (name,)) + conn.commit() + # If active provider was deleted, reset to gemini + if self.get("ai_provider") == name: + self.set("ai_provider", "gemini") diff --git a/src/arxiv_explorer/services/summarization.py b/src/arxiv_explorer/services/summarization.py index 4ab46d6..7570aa3 100644 --- a/src/arxiv_explorer/services/summarization.py +++ b/src/arxiv_explorer/services/summarization.py @@ -63,6 +63,9 @@ def summarize( settings = SettingsService() provider = get_provider(settings.get_provider()) if not provider.is_available(): + import sys + + print("Summary generation failed: provider not available", file=sys.stderr) return None output = provider.invoke( prompt, @@ -70,6 +73,9 @@ def summarize( timeout=settings.get_timeout(), ) if output is None: + import sys + + print("Summary generation failed: provider returned no output", file=sys.stderr) return None # Extract JSON block (may be in ```json ... ``` format) if "```json" in output: @@ -82,13 +88,9 @@ def summarize( try: data = json.loads(output) except json.JSONDecodeError as e: - # JSON parse failure - print debug info and return None import sys - if "--verbose" in sys.argv or "-v" in sys.argv: - print(f"\nSummary generation failed ({arxiv_id}): JSON parse error") - print(f"Error: {e}") - print(f"Output sample: {output[:300]}...") + print(f"Summary generation failed: JSON parse error: {e}", file=sys.stderr) return None summary = PaperSummary( @@ -106,11 +108,9 @@ def summarize( return summary except Exception as e: - # Other error - fail silently import sys - if "--verbose" in sys.argv or "-v" in sys.argv: - print(f"\nError during summary generation ({arxiv_id}): {e}") + print(f"Summary generation failed: {e}", file=sys.stderr) return None def _get_cached(self, arxiv_id: str) -> PaperSummary | None: diff --git a/src/arxiv_explorer/services/translation.py b/src/arxiv_explorer/services/translation.py index 749f1ea..45dd827 100644 --- a/src/arxiv_explorer/services/translation.py +++ b/src/arxiv_explorer/services/translation.py @@ -72,6 +72,9 @@ def translate( settings = SettingsService() provider = get_provider(settings.get_provider()) if not provider.is_available(): + import sys + + print("Translation failed: provider not available", file=sys.stderr) return None output = provider.invoke( prompt, @@ -79,6 +82,9 @@ def translate( timeout=settings.get_timeout(), ) if output is None: + import sys + + print("Translation failed: provider returned no output", file=sys.stderr) return None # Extract JSON block @@ -94,10 +100,7 @@ def translate( except json.JSONDecodeError as e: import sys - if "--verbose" in sys.argv or "-v" in sys.argv: - print(f"\nTranslation failed ({arxiv_id}): JSON parse error") - print(f"Error: {e}") - print(f"Output sample: {output[:300]}...") + print(f"Translation failed: JSON parse error: {e}", file=sys.stderr) return None translation = PaperTranslation( @@ -115,8 +118,7 @@ def translate( except Exception as e: import sys - if "--verbose" in sys.argv or "-v" in sys.argv: - print(f"\nTranslation error ({arxiv_id}): {e}") + print(f"Translation failed: {e}", file=sys.stderr) return None def _get_cached(self, arxiv_id: str, target_language: Language) -> PaperTranslation | None: diff --git a/tests/test_database.py b/tests/test_database.py index 9366b23..98a95cf 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -21,6 +21,7 @@ "paper_review_sections", "preferred_authors", "daily_fetch_cache", + "custom_providers", } diff --git a/tui-rs/src/app.rs b/tui-rs/src/app.rs index e2a135f..8d7df0a 100644 --- a/tui-rs/src/app.rs +++ b/tui-rs/src/app.rs @@ -191,6 +191,8 @@ pub struct PrefsState { pub weights: [i64; 4], pub provider: String, pub language: String, + pub custom_providers: Vec, + pub custom_provider_selected: usize, pub selected: usize, // cursor in weights section pub focus_section: usize, // 0=cats, 1=keywords, 2=authors, 3=weights, 4=config pub section_selected: [usize; 5], // cursor per section (section 4: 0=provider, 1=language) @@ -205,6 +207,8 @@ impl Default for PrefsState { weights: [60, 20, 15, 5], provider: "gemini".to_string(), language: "en".to_string(), + custom_providers: vec![], + custom_provider_selected: 0, selected: 0, focus_section: 0, section_selected: [0; 5], @@ -220,6 +224,7 @@ impl Default for PrefsState { pub enum ConfirmAction { RegenerateSummary, RegenerateTranslation, + RemoveCustomProvider(String), } // ============================================================================= @@ -251,6 +256,18 @@ pub enum OverlayMode { AuthorInput { text: String, }, + PresetPicker { + selected: usize, + }, + ProviderNameInput { + preset: String, + text: String, + }, + CommandTemplateInput { + preset: String, + name: String, + text: String, + }, } // ============================================================================= @@ -353,6 +370,8 @@ impl App { weights, provider, language, + custom_providers: vec![], + custom_provider_selected: 0, selected: 0, focus_section: 0, section_selected: [0; 5], diff --git a/tui-rs/src/db/mod.rs b/tui-rs/src/db/mod.rs index 1b1b753..da92f5c 100644 --- a/tui-rs/src/db/mod.rs +++ b/tui-rs/src/db/mod.rs @@ -24,7 +24,9 @@ impl Database { } let conn = Connection::open(path)?; conn.execute_batch("PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL;")?; - Ok(Self { conn }) + let db = Self { conn }; + db.ensure_custom_providers_table()?; + Ok(db) } /// Return the default database path. @@ -475,6 +477,56 @@ impl Database { Ok(()) } + // ========================================================================= + // Custom Providers + // ========================================================================= + + pub fn ensure_custom_providers_table(&self) -> Result<()> { + self.conn.execute_batch( + "CREATE TABLE IF NOT EXISTS custom_providers ( + name TEXT PRIMARY KEY NOT NULL, + preset TEXT NOT NULL, + command_template TEXT NOT NULL, + default_model TEXT DEFAULT '', + added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + );" + )?; + Ok(()) + } + + pub fn get_custom_providers(&self) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT name, preset, command_template, default_model FROM custom_providers ORDER BY name" + )?; + let rows = stmt.query_map([], |row| { + Ok(CustomProviderEntry { + name: row.get(0)?, + preset: row.get(1)?, + command_template: row.get(2)?, + default_model: row.get::<_, Option>(3)?.unwrap_or_default(), + }) + })?; + rows.collect() + } + + pub fn add_custom_provider(&self, entry: &CustomProviderEntry) -> Result<()> { + self.conn.execute( + "INSERT OR REPLACE INTO custom_providers (name, preset, command_template, default_model) VALUES (?1, ?2, ?3, ?4)", + params![entry.name, entry.preset, entry.command_template, entry.default_model], + )?; + Ok(()) + } + + pub fn remove_custom_provider(&self, name: &str) -> Result<()> { + self.conn.execute("DELETE FROM custom_providers WHERE name = ?1", params![name])?; + // If active provider was deleted, reset to gemini + let current = self.get_setting("ai_provider", "gemini")?; + if current == name { + self.set_setting("ai_provider", "gemini")?; + } + Ok(()) + } + // ========================================================================= // Summaries & Translations // ========================================================================= diff --git a/tui-rs/src/db/models.rs b/tui-rs/src/db/models.rs index cb8526d..00970ad 100644 --- a/tui-rs/src/db/models.rs +++ b/tui-rs/src/db/models.rs @@ -102,6 +102,14 @@ pub struct AppSetting { pub value: String, } +#[derive(Debug, Clone)] +pub struct CustomProviderEntry { + pub name: String, + pub preset: String, + pub command_template: String, + pub default_model: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ScoredPaper { pub arxiv_id: String, diff --git a/tui-rs/src/events.rs b/tui-rs/src/events.rs index 714d97b..e2127ce 100644 --- a/tui-rs/src/events.rs +++ b/tui-rs/src/events.rs @@ -79,6 +79,7 @@ pub fn handle_key(app: &mut App, key: KeyCode) -> bool { app.prefs.weights = app.db.get_weights().unwrap_or([60, 20, 15, 5]); app.prefs.provider = app.db.get_setting("ai_provider", "gemini").unwrap_or_else(|_| "gemini".to_string()); app.prefs.language = app.db.get_setting("language", "en").unwrap_or_else(|_| "en".to_string()); + app.prefs.custom_providers = app.db.get_custom_providers().unwrap_or_default(); } // Delegate to per-tab handler _ => match app.active_tab { @@ -134,6 +135,16 @@ pub fn handle_confirm_key(app: &mut App, key: KeyCode) { match action { ConfirmAction::RegenerateSummary => trigger_summarize(app), ConfirmAction::RegenerateTranslation => trigger_translate(app), + ConfirmAction::RemoveCustomProvider(name) => { + let _ = app.db.remove_custom_provider(&name); + app.prefs.custom_providers = app.db.get_custom_providers().unwrap_or_default(); + if app.prefs.provider == name { + app.prefs.provider = "gemini".to_string(); + } + app.prefs.custom_provider_selected = app.prefs.custom_provider_selected + .min(app.prefs.custom_providers.len().saturating_sub(1)); + app.push_toast(format!("Removed provider: {name}"), false); + } } } _ => { @@ -292,6 +303,121 @@ pub fn handle_overlay_key(app: &mut App, key: KeyCode) { } } } + crate::app::OverlayMode::PresetPicker { mut selected } => { + match key { + KeyCode::Esc => { + // overlay already taken, just don't re-assign + } + KeyCode::Up => { + if selected > 0 { + selected -= 1; + } + app.overlay = Some(crate::app::OverlayMode::PresetPicker { selected }); + } + KeyCode::Down => { + if selected + 1 < crate::presets::PRESETS.len() { + selected += 1; + } + app.overlay = Some(crate::app::OverlayMode::PresetPicker { selected }); + } + KeyCode::Enter => { + let preset_name = crate::presets::PRESETS[selected].name.to_string(); + app.overlay = Some(crate::app::OverlayMode::ProviderNameInput { + preset: preset_name, + text: String::new(), + }); + } + _ => { + app.overlay = Some(crate::app::OverlayMode::PresetPicker { selected }); + } + } + } + crate::app::OverlayMode::ProviderNameInput { mut text, preset } => { + match key { + KeyCode::Esc => { + // overlay already taken, just don't re-assign + } + KeyCode::Enter => { + let name = text.trim().to_string(); + if name.is_empty() { + app.push_toast("Name cannot be empty".to_string(), true); + app.overlay = Some(crate::app::OverlayMode::ProviderNameInput { preset, text }); + } else if crate::presets::is_reserved(&name) { + app.push_toast(format!("'{name}' is a reserved name"), true); + app.overlay = Some(crate::app::OverlayMode::ProviderNameInput { preset, text }); + } else if app.prefs.custom_providers.iter().any(|cp| cp.name == name) { + app.push_toast(format!("'{name}' already exists"), true); + app.overlay = Some(crate::app::OverlayMode::ProviderNameInput { preset, text }); + } else { + let preset_entry = crate::presets::PRESETS.iter() + .find(|p| p.name == preset.as_str()); + let template = match preset_entry { + Some(p) => p.template.replace("{name}", &name), + None => name.clone(), + }; + app.overlay = Some(crate::app::OverlayMode::CommandTemplateInput { + preset, + name, + text: template, + }); + } + } + KeyCode::Backspace => { + text.pop(); + app.overlay = Some(crate::app::OverlayMode::ProviderNameInput { preset, text }); + } + KeyCode::Char(c) => { + text.push(c); + app.overlay = Some(crate::app::OverlayMode::ProviderNameInput { preset, text }); + } + _ => { + app.overlay = Some(crate::app::OverlayMode::ProviderNameInput { preset, text }); + } + } + } + crate::app::OverlayMode::CommandTemplateInput { preset, name, mut text } => { + match key { + KeyCode::Esc => { + // overlay already taken, just don't re-assign + } + KeyCode::Enter => { + let template = text.trim().to_string(); + if template.is_empty() { + app.push_toast("Command cannot be empty".to_string(), true); + app.overlay = Some(crate::app::OverlayMode::CommandTemplateInput { preset, name, text }); + } else { + let entry = crate::db::models::CustomProviderEntry { + name: name.clone(), + preset: preset.clone(), + command_template: template, + default_model: String::new(), + }; + match app.db.add_custom_provider(&entry) { + Ok(_) => { + app.prefs.custom_providers = app.db.get_custom_providers().unwrap_or_default(); + app.push_toast(format!("Added provider: {}", name), false); + // overlay already taken, don't re-assign (dismiss) + } + Err(e) => { + app.push_toast(format!("Error: {e}"), true); + app.overlay = Some(crate::app::OverlayMode::CommandTemplateInput { preset, name, text }); + } + } + } + } + KeyCode::Backspace => { + text.pop(); + app.overlay = Some(crate::app::OverlayMode::CommandTemplateInput { preset, name, text }); + } + KeyCode::Char(c) => { + text.push(c); + app.overlay = Some(crate::app::OverlayMode::CommandTemplateInput { preset, name, text }); + } + _ => { + app.overlay = Some(crate::app::OverlayMode::CommandTemplateInput { preset, name, text }); + } + } + } } } @@ -1043,7 +1169,7 @@ pub fn handle_prefs_key(app: &mut App, key: KeyCode) { 1 => app.prefs.keywords.len(), 2 => app.prefs.authors.len(), 3 => 4, - 4 => 2, // provider (0) and language (1) + 4 => 3, // provider (0), language (1), custom (2) _ => 1, }; if max > 0 && app.prefs.section_selected[sec] + 1 < max { @@ -1098,7 +1224,13 @@ pub fn handle_prefs_key(app: &mut App, key: KeyCode) { } } 4 => { - cycle_config_option(app, false); + if app.prefs.section_selected[4] == 2 { + if app.prefs.custom_provider_selected > 0 { + app.prefs.custom_provider_selected -= 1; + } + } else { + cycle_config_option(app, false); + } } _ => {} } @@ -1137,7 +1269,14 @@ pub fn handle_prefs_key(app: &mut App, key: KeyCode) { } } 4 => { - cycle_config_option(app, true); + if app.prefs.section_selected[4] == 2 { + let max = app.prefs.custom_providers.len().saturating_sub(1); + if app.prefs.custom_provider_selected < max { + app.prefs.custom_provider_selected += 1; + } + } else { + cycle_config_option(app, true); + } } _ => {} } @@ -1163,6 +1302,13 @@ pub fn handle_prefs_key(app: &mut App, key: KeyCode) { text: String::new(), }); } + 4 => { + if app.prefs.section_selected[4] == 2 { + app.overlay = Some(crate::app::OverlayMode::PresetPicker { + selected: 0, + }); + } + } _ => {} } } @@ -1199,6 +1345,15 @@ pub fn handle_prefs_key(app: &mut App, key: KeyCode) { .min(app.prefs.authors.len().saturating_sub(1)); } } + 4 => { + if app.prefs.section_selected[4] == 2 { + let custom_sel = app.prefs.custom_provider_selected; + if let Some(cp) = app.prefs.custom_providers.get(custom_sel) { + let name = cp.name.clone(); + app.confirm_action = Some(crate::app::ConfirmAction::RemoveCustomProvider(name)); + } + } + } _ => {} } } @@ -1215,26 +1370,32 @@ pub fn handle_prefs_key(app: &mut App, key: KeyCode) { .db .get_setting("language", "en") .unwrap_or_else(|_| "en".to_string()); + app.prefs.custom_providers = app.db.get_custom_providers().unwrap_or_default(); } _ => {} } } -/// Cycle through config options for the currently selected config item (section 4). -/// `forward=true` goes to the next option, `forward=false` goes to the previous. fn cycle_config_option(app: &mut App, forward: bool) { - let item = app.prefs.section_selected[4]; // 0=provider, 1=language + let item = app.prefs.section_selected[4]; match item { 0 => { - let providers = ["gemini", "claude", "ollama", "openai", "opencode", "custom"]; - let current = providers.iter().position(|&p| p == app.prefs.provider).unwrap_or(0); + // Build provider list: built-in + custom names + let mut providers: Vec = vec![ + "gemini".into(), "claude".into(), "ollama".into(), + "openai".into(), "opencode".into(), + ]; + for cp in &app.prefs.custom_providers { + providers.push(cp.name.clone()); + } + let current = providers.iter().position(|p| p == &app.prefs.provider).unwrap_or(0); let next = if forward { (current + 1) % providers.len() } else { if current == 0 { providers.len() - 1 } else { current - 1 } }; - app.prefs.provider = providers[next].to_string(); - let _ = app.db.set_setting("ai_provider", providers[next]); + app.prefs.provider = providers[next].clone(); + let _ = app.db.set_setting("ai_provider", &providers[next]); app.push_toast(format!("Provider: {}", providers[next]), false); } 1 => { diff --git a/tui-rs/src/main.rs b/tui-rs/src/main.rs index 234e4c4..b6faab4 100644 --- a/tui-rs/src/main.rs +++ b/tui-rs/src/main.rs @@ -12,10 +12,11 @@ use ratatui::{ }; mod app; +mod categories; mod commands; mod db; -mod categories; mod events; +mod presets; use app::{App, ConfirmAction, Tab}; @@ -152,6 +153,9 @@ fn render(f: &mut Frame, app: &mut App) { app::OverlayMode::CategoryPicker { .. } => render_category_picker(f, app), app::OverlayMode::KeywordInput { .. } => render_keyword_input(f, app), app::OverlayMode::AuthorInput { .. } => render_author_input(f, app), + app::OverlayMode::PresetPicker { .. } => render_preset_picker(f, app), + app::OverlayMode::ProviderNameInput { .. } => render_provider_name_input(f, app), + app::OverlayMode::CommandTemplateInput { .. } => render_command_template_input(f, app), } } @@ -1115,7 +1119,7 @@ fn render_prefs(f: &mut Frame, app: &App, area: Rect) { // Config { let block = Block::default() - .title(" Config ") + .title(" Config [a:Add Del:Rm] ") .borders(Borders::ALL) .border_style(sec_border(4)) .style(Style::default().bg(BG)); @@ -1125,29 +1129,47 @@ fn render_prefs(f: &mut Frame, app: &App, area: Rect) { let focused = app.prefs.focus_section == 4; let sel = app.prefs.section_selected[4]; - let provider_style = if focused && sel == 0 { - Style::default().fg(ACCENT).bold() - } else { - Style::default().fg(TEXT) + let item_style = |idx: usize| -> Style { + if focused && sel == idx { + Style::default().fg(ACCENT).bold() + } else { + Style::default().fg(TEXT) + } }; - let language_style = if focused && sel == 1 { - Style::default().fg(ACCENT).bold() + let prefix = |idx: usize| -> &str { + if focused && sel == idx { "► " } else { " " } + }; + + // Custom providers display + let custom_names: Vec = app.prefs.custom_providers.iter().map(|cp| cp.name.clone()).collect(); + let custom_display = if custom_names.is_empty() { + "(none)".to_string() } else { - Style::default().fg(TEXT) + let cp_sel = app.prefs.custom_provider_selected; + custom_names.iter().enumerate().map(|(i, n)| { + if focused && sel == 2 && i == cp_sel { + format!("[{n}]") + } else { + n.clone() + } + }).collect::>().join(", ") }; - let provider_prefix = if focused && sel == 0 { "► " } else { " " }; - let language_prefix = if focused && sel == 1 { "► " } else { " " }; let lines = vec![ Line::from(vec![ - Span::styled(provider_prefix, provider_style), + Span::styled(prefix(0), item_style(0)), Span::styled("Provider: ", Style::default().fg(ACCENT).bold()), - Span::styled(app.prefs.provider.clone(), provider_style), + Span::styled(app.prefs.provider.clone(), item_style(0)), ]), Line::from(vec![ - Span::styled(language_prefix, language_style), + Span::styled(prefix(1), item_style(1)), Span::styled("Language: ", Style::default().fg(ACCENT).bold()), - Span::styled(app.prefs.language.clone(), language_style), + Span::styled(app.prefs.language.clone(), item_style(1)), + ]), + Line::from(vec![ + Span::styled(prefix(2), item_style(2)), + Span::styled("Custom: ", Style::default().fg(ACCENT).bold()), + Span::styled(custom_display, item_style(2)), ]), ]; f.render_widget(Paragraph::new(lines).style(Style::default().bg(BG)), inner); @@ -1417,14 +1439,18 @@ fn render_confirm_dialog(f: &mut Frame, app: &App) { f.render_widget(Clear, overlay); - let (title, body) = match &app.confirm_action { + let (title, body): (&str, String) = match &app.confirm_action { Some(ConfirmAction::RegenerateSummary) => ( " Regenerate? ", - "Summary already exists.\nRegenerate? [y]es / [n]o", + "Summary already exists.\nRegenerate? [y]es / [n]o".into(), ), Some(ConfirmAction::RegenerateTranslation) => ( " Regenerate? ", - "Translation already exists.\nRegenerate? [y]es / [n]o", + "Translation already exists.\nRegenerate? [y]es / [n]o".into(), + ), + Some(ConfirmAction::RemoveCustomProvider(name)) => ( + " Remove Provider? ", + format!("Remove custom provider '{name}'?\n[y]es / [n]o"), ), None => return, }; @@ -1718,6 +1744,183 @@ fn render_author_input(f: &mut Frame, app: &App) { } } +// ============================================================================= +// Preset picker overlay +// ============================================================================= + +fn render_preset_picker(f: &mut Frame, app: &App) { + let area = f.area(); + let w = (area.width * 60 / 100).max(50).min(area.width); + let h: u16 = (crate::presets::PRESETS.len() as u16 + 4).min(area.height); + let x = (area.width.saturating_sub(w)) / 2; + let y = (area.height.saturating_sub(h)) / 2; + let overlay = Rect::new(x, y, w, h); + + f.render_widget(Clear, overlay); + + let block = Block::default() + .title(" Select Preset ") + .borders(Borders::ALL) + .border_style(Style::default().fg(ACCENT)) + .style(Style::default().bg(BG)); + let inner = block.inner(overlay); + f.render_widget(block, overlay); + + if let Some(crate::app::OverlayMode::PresetPicker { selected }) = &app.overlay { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(1)]) + .split(inner); + + let mut lines: Vec = Vec::new(); + for (i, preset) in crate::presets::PRESETS.iter().enumerate() { + let is_sel = i == *selected; + let prefix = if is_sel { "► " } else { " " }; + let style = if is_sel { + Style::default().fg(BG).bg(ACCENT).bold() + } else { + Style::default().fg(TEXT).bg(BG) + }; + lines.push(Line::from(Span::styled( + format!("{prefix}{:<14} {}", preset.name, preset.description), + style, + ))); + } + f.render_widget(Paragraph::new(lines).style(Style::default().bg(BG)), chunks[0]); + + f.render_widget( + Paragraph::new(" [↑↓] navigate [Enter] select [Esc] close") + .style(Style::default().fg(TEXT_DIM).bg(SURFACE)), + chunks[1], + ); + } +} + +// ============================================================================= +// Provider name input overlay +// ============================================================================= + +fn render_provider_name_input(f: &mut Frame, app: &App) { + let area = f.area(); + let w: u16 = 50; + let h: u16 = 6; + let x = (area.width.saturating_sub(w)) / 2; + let y = (area.height.saturating_sub(h)) / 2; + let overlay = Rect::new(x, y, w, h); + + f.render_widget(Clear, overlay); + + let block = Block::default() + .title(" Provider Name ") + .borders(Borders::ALL) + .border_style(Style::default().fg(ACCENT)) + .style(Style::default().bg(BG)); + let inner = block.inner(overlay); + f.render_widget(block, overlay); + + if let Some(crate::app::OverlayMode::ProviderNameInput { preset, text }) = &app.overlay { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(inner); + + let preset_line = Line::from(vec![ + Span::styled(" Preset: ", Style::default().fg(TEXT_DIM)), + Span::styled(preset.as_str(), Style::default().fg(ACCENT)), + ]); + f.render_widget(Paragraph::new(preset_line).style(Style::default().bg(BG)), chunks[0]); + + let name_line = Line::from(vec![ + Span::styled(" Name: ", Style::default().fg(ACCENT).bold()), + Span::styled(format!("{text}_"), Style::default().fg(TEXT)), + ]); + f.render_widget(Paragraph::new(name_line).style(Style::default().bg(BG)), chunks[1]); + + f.render_widget( + Paragraph::new("─".repeat(chunks[2].width as usize)) + .style(Style::default().fg(TEXT_DIM).bg(BG)), + chunks[2], + ); + + f.render_widget( + Paragraph::new(" [Enter] next [Esc] cancel") + .style(Style::default().fg(TEXT_DIM).bg(SURFACE)), + chunks[3], + ); + } +} + +// ============================================================================= +// Command template input overlay +// ============================================================================= + +fn render_command_template_input(f: &mut Frame, app: &App) { + let area = f.area(); + let w = (area.width * 80 / 100).max(60).min(area.width); + let h: u16 = 8; + let x = (area.width.saturating_sub(w)) / 2; + let y = (area.height.saturating_sub(h)) / 2; + let overlay = Rect::new(x, y, w, h); + + f.render_widget(Clear, overlay); + + let block = Block::default() + .title(" Command Template ") + .borders(Borders::ALL) + .border_style(Style::default().fg(ACCENT)) + .style(Style::default().bg(BG)); + let inner = block.inner(overlay); + f.render_widget(block, overlay); + + if let Some(crate::app::OverlayMode::CommandTemplateInput { preset: _, name, text }) = &app.overlay { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(inner); + + let name_line = Line::from(vec![ + Span::styled(" Name: ", Style::default().fg(TEXT_DIM)), + Span::styled(name.as_str(), Style::default().fg(ACCENT)), + ]); + f.render_widget(Paragraph::new(name_line).style(Style::default().bg(BG)), chunks[0]); + + let hint_line = Line::from(vec![ + Span::styled(" Placeholders: ", Style::default().fg(TEXT_DIM)), + Span::styled("{prompt} {model}", Style::default().fg(AUTHOR_HL)), + ]); + f.render_widget(Paragraph::new(hint_line).style(Style::default().bg(BG)), chunks[1]); + + let cmd_line = Line::from(vec![ + Span::styled(" Command: ", Style::default().fg(ACCENT).bold()), + Span::styled(format!("{text}_"), Style::default().fg(TEXT)), + ]); + f.render_widget(Paragraph::new(cmd_line).style(Style::default().bg(BG)), chunks[2]); + + f.render_widget( + Paragraph::new("─".repeat(chunks[3].width as usize)) + .style(Style::default().fg(TEXT_DIM).bg(BG)), + chunks[3], + ); + + f.render_widget( + Paragraph::new(" [Enter] save [Esc] cancel") + .style(Style::default().fg(TEXT_DIM).bg(SURFACE)), + chunks[4], + ); + } +} + // ============================================================================= // Stars display helper // ============================================================================= diff --git a/tui-rs/src/presets.rs b/tui-rs/src/presets.rs new file mode 100644 index 0000000..c45e402 --- /dev/null +++ b/tui-rs/src/presets.rs @@ -0,0 +1,47 @@ +/// Built-in provider presets for custom provider creation. +pub struct Preset { + pub name: &'static str, + pub description: &'static str, + /// Template with `{name}`, `{prompt}`, `{model}` placeholders. + pub template: &'static str, +} + +pub const PRESETS: &[Preset] = &[ + Preset { + name: "Claude-like", + description: "CLI with -p/--model/--output-format (e.g. zai, claude)", + template: "{name} -p {prompt} --model {model} --output-format text", + }, + Preset { + name: "Codex-like", + description: "CLI with --prompt/--model (e.g. codex)", + template: "{name} --model {model} --prompt {prompt}", + }, + Preset { + name: "Gemini-like", + description: "CLI with -p/-m (e.g. gemini)", + template: "{name} -m {model} -p {prompt}", + }, + Preset { + name: "Ollama-like", + description: "CLI with run subcommand (e.g. ollama)", + template: "{name} run {model} {prompt}", + }, + Preset { + name: "OpenRouter", + description: "HTTP API via curl ($OPENROUTER_API_KEY env var)", + template: "curl -sS https://openrouter.ai/api/v1/chat/completions -H \"Content-Type: application/json\" -H \"Authorization: Bearer $OPENROUTER_API_KEY\" -d '{{\"model\":\"{model}\",\"messages\":[{{\"role\":\"user\",\"content\":\"{prompt}\"}}]}}'", + }, + Preset { + name: "Manual", + description: "Write the full command yourself", + template: "{name} {prompt}", + }, +]; + +/// Reserved names that cannot be used for custom providers. +pub const RESERVED: &[&str] = &["gemini", "claude", "ollama", "openai", "opencode", "custom"]; + +pub fn is_reserved(name: &str) -> bool { + RESERVED.iter().any(|&r| r.eq_ignore_ascii_case(name)) +}