use std::env;
use std::sync::mpsc::{self, Receiver, TryRecvError};
use std::thread;
use std::time::{Duration, Instant};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{
Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap,
};
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use serde_json::{Map, Value};
use shinkai_translator::config::{AppConfig, LoadedConfig};
use shinkai_translator::{
CueDisposition, PgsOcrLanguage, ProviderConfig, ThinkingMode, TranslatorError,
};
const CUSTOM_OPTION_VALUE: &str = "__custom__";
const ENV_API_KEY_NAME: &str = "SHINKAI_TRANSLATOR_API_KEY";
const SPINNER_FRAMES: [&str; 4] = ["|", "/", "-", "\\"];
const PROVIDER_PRESET_OPTIONS: [ChoiceOption; 8] = [
ChoiceOption {
value: "https://api.openai.com/v1",
label: "OpenAI",
detail: "Official OpenAI endpoint.",
},
ChoiceOption {
value: "https://generativelanguage.googleapis.com/v1beta/openai",
label: "Google Gemini",
detail: "Google Gemini OpenAI-compatible endpoint.",
},
ChoiceOption {
value: "https://openrouter.ai/api/v1",
label: "OpenRouter",
detail: "OpenRouter OpenAI-compatible gateway.",
},
ChoiceOption {
value: "http://localhost:11434/v1",
label: "Ollama",
detail: "Default local Ollama compatibility endpoint.",
},
ChoiceOption {
value: "http://localhost:1234/v1",
label: "LM Studio",
detail: "Default LM Studio local server.",
},
ChoiceOption {
value: "http://localhost:8080/v1",
label: "LocalAI",
detail: "Common LocalAI OpenAI-compatible URL.",
},
ChoiceOption {
value: "https://integrate.api.nvidia.com/v1",
label: "NVIDIA NIM",
detail: "NVIDIA-hosted OpenAI-compatible models.",
},
ChoiceOption {
value: CUSTOM_OPTION_VALUE,
label: "Custom...",
detail: "Type a custom OpenAI-compatible base URL.",
},
];
const THINKING_MODE_OPTIONS: [ChoiceOption; 3] = [
ChoiceOption {
value: "off",
label: "Off",
detail: "Disable reasoning/thinking flags.",
},
ChoiceOption {
value: "on",
label: "On",
detail: "Force provider thinking mode when supported.",
},
ChoiceOption {
value: "auto",
label: "Auto",
detail: "Let the provider-specific adapter decide.",
},
];
const OCR_LANGUAGE_OPTIONS: [ChoiceOption; 3] = [
ChoiceOption {
value: "auto",
label: "Auto",
detail: "Use the default OCR profile heuristic.",
},
ChoiceOption {
value: "english",
label: "English",
detail: "Bias OCR toward English glyphs.",
},
ChoiceOption {
value: "latin",
label: "Latin",
detail: "Bias OCR toward broad Latin-script content.",
},
];
const DISPOSITION_OPTIONS: [ChoiceOption; 3] = [
ChoiceOption {
value: "translate",
label: "Translate",
detail: "Translate this cue class like dialogue.",
},
ChoiceOption {
value: "preserve",
label: "Preserve",
detail: "Keep the cue class in the original language.",
},
ChoiceOption {
value: "review",
label: "Review",
detail: "Flag the cue class for manual review.",
},
];
const BOOLEAN_OPTIONS: [ChoiceOption; 2] = [
ChoiceOption {
value: "true",
label: "Enabled",
detail: "Use the feature with the current defaults.",
},
ChoiceOption {
value: "false",
label: "Disabled",
detail: "Turn the feature off.",
},
];
const SOURCE_LANGUAGE_OPTIONS: [ChoiceOption; 12] = [
ChoiceOption {
value: "",
label: "Auto detect",
detail: "Leave the source language unset and let the provider infer it.",
},
ChoiceOption {
value: "English",
label: "English",
detail: "English input subtitles.",
},
ChoiceOption {
value: "Japanese",
label: "Japanese",
detail: "Japanese input subtitles.",
},
ChoiceOption {
value: "Portuguese (Brazil)",
label: "Portuguese (Brazil)",
detail: "Brazilian Portuguese input subtitles.",
},
ChoiceOption {
value: "Portuguese",
label: "Portuguese",
detail: "Generic Portuguese input subtitles.",
},
ChoiceOption {
value: "Spanish",
label: "Spanish",
detail: "Spanish input subtitles.",
},
ChoiceOption {
value: "French",
label: "French",
detail: "French input subtitles.",
},
ChoiceOption {
value: "German",
label: "German",
detail: "German input subtitles.",
},
ChoiceOption {
value: "Italian",
label: "Italian",
detail: "Italian input subtitles.",
},
ChoiceOption {
value: "Korean",
label: "Korean",
detail: "Korean input subtitles.",
},
ChoiceOption {
value: "Chinese (Simplified)",
label: "Chinese (Simplified)",
detail: "Simplified Chinese input subtitles.",
},
ChoiceOption {
value: CUSTOM_OPTION_VALUE,
label: "Custom...",
detail: "Type a custom source language label.",
},
];
const TARGET_LANGUAGE_OPTIONS: [ChoiceOption; 13] = [
ChoiceOption {
value: "",
label: "Unset",
detail: "Leave the default target language empty.",
},
ChoiceOption {
value: "Portuguese (Brazil)",
label: "Portuguese (Brazil)",
detail: "Recommended default for pt-BR output.",
},
ChoiceOption {
value: "English",
label: "English",
detail: "English output subtitles.",
},
ChoiceOption {
value: "Japanese",
label: "Japanese",
detail: "Japanese output subtitles.",
},
ChoiceOption {
value: "Portuguese",
label: "Portuguese",
detail: "Generic Portuguese output subtitles.",
},
ChoiceOption {
value: "Spanish",
label: "Spanish",
detail: "Spanish output subtitles.",
},
ChoiceOption {
value: "French",
label: "French",
detail: "French output subtitles.",
},
ChoiceOption {
value: "German",
label: "German",
detail: "German output subtitles.",
},
ChoiceOption {
value: "Italian",
label: "Italian",
detail: "Italian output subtitles.",
},
ChoiceOption {
value: "Korean",
label: "Korean",
detail: "Korean output subtitles.",
},
ChoiceOption {
value: "Chinese (Simplified)",
label: "Chinese (Simplified)",
detail: "Simplified Chinese output subtitles.",
},
ChoiceOption {
value: "Chinese (Traditional)",
label: "Chinese (Traditional)",
detail: "Traditional Chinese output subtitles.",
},
ChoiceOption {
value: CUSTOM_OPTION_VALUE,
label: "Custom...",
detail: "Type a custom target language label.",
},
];
type FieldReader = fn(&LoadedConfig) -> String;
type FieldWriter = fn(&mut LoadedConfig, &str) -> Result<(), String>;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Section {
Provider,
Translation,
Ocr,
Tools,
Classification,
}
impl Section {
fn title(self) -> &'static str {
match self {
Self::Provider => "Provider",
Self::Translation => "Translation",
Self::Ocr => "OCR",
Self::Tools => "Tools",
Self::Classification => "Classification",
}
}
fn color(self) -> Color {
match self {
Self::Provider => Color::Cyan,
Self::Translation => Color::Green,
Self::Ocr => Color::Yellow,
Self::Tools => Color::Magenta,
Self::Classification => Color::Blue,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum FieldId {
ProviderBaseUrl,
ProviderModel,
ProviderApiKey,
ProviderThinkingMode,
ProviderTimeoutSeconds,
ProviderMaxRetries,
ProviderTemperature,
TranslationSourceLanguage,
TranslationTargetLanguage,
TranslationMaxBatchItems,
TranslationMaxBatchCharacters,
TranslationMaxParallelBatches,
TranslationSystemPrompt,
OcrLanguage,
OcrModelCacheDir,
ToolsFfmpeg,
ToolsFfprobe,
ClassificationKaraokePolicy,
ClassificationExplicitSongPolicy,
ClassificationInferredSongPolicy,
ClassificationEnableInferredSongDetection,
ClassificationMinInferredSongRunLength,
}
#[derive(Clone, Copy)]
enum FieldKind {
Text { secret: bool },
Select { options: &'static [ChoiceOption] },
}
#[derive(Clone, Copy)]
struct EditorField {
id: FieldId,
section: Section,
label: &'static str,
help: &'static str,
read: FieldReader,
write: FieldWriter,
kind: FieldKind,
}
#[derive(Clone, Copy)]
struct ChoiceOption {
value: &'static str,
label: &'static str,
detail: &'static str,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct SelectItem {
value: String,
label: String,
detail: String,
}
impl SelectItem {
fn from_choice(choice: &ChoiceOption) -> Self {
Self {
value: choice.value.to_owned(),
label: choice.label.to_owned(),
detail: choice.detail.to_owned(),
}
}
}
#[derive(Clone, Copy)]
enum StatusTone {
Info,
Success,
Warning,
Error,
}
impl StatusTone {
fn fg(self) -> Color {
match self {
Self::Info => Color::Cyan,
Self::Success => Color::Green,
Self::Warning => Color::Yellow,
Self::Error => Color::Red,
}
}
fn border(self) -> Color {
match self {
Self::Info => Color::Cyan,
Self::Success => Color::Green,
Self::Warning => Color::LightYellow,
Self::Error => Color::LightRed,
}
}
}
struct StatusMessage {
tone: StatusTone,
text: String,
}
impl StatusMessage {
fn info(text: impl Into<String>) -> Self {
Self {
tone: StatusTone::Info,
text: text.into(),
}
}
fn success(text: impl Into<String>) -> Self {
Self {
tone: StatusTone::Success,
text: text.into(),
}
}
fn warning(text: impl Into<String>) -> Self {
Self {
tone: StatusTone::Warning,
text: text.into(),
}
}
fn error(text: impl Into<String>) -> Self {
Self {
tone: StatusTone::Error,
text: text.into(),
}
}
}
enum Modal {
Input(InputModalState),
Select(SelectModalState),
Message(MessageModalState),
ConfirmQuit,
Loading(LoadingModalState),
}
struct InputModalState {
field_index: usize,
value: String,
cursor: usize,
secret: bool,
}
impl InputModalState {
fn new(field_index: usize, initial_value: String, secret: bool) -> Self {
let cursor = initial_value.chars().count();
Self {
field_index,
value: initial_value,
cursor,
secret,
}
}
fn insert_text(&mut self, text: &str) {
insert_text_at_cursor(&mut self.value, &mut self.cursor, text);
}
fn backspace(&mut self) {
remove_char_before_cursor(&mut self.value, &mut self.cursor);
}
fn delete(&mut self) {
remove_char_after_cursor(&mut self.value, self.cursor);
}
fn clear(&mut self) {
self.value.clear();
self.cursor = 0;
}
}
#[derive(Clone, Copy)]
enum SelectAction {
Field { field_index: usize },
ModelPicker,
}
struct SelectModalState {
title: String,
help: String,
search: String,
options: Vec<SelectItem>,
selected: usize,
action: SelectAction,
}
impl SelectModalState {
fn new(
title: impl Into<String>,
help: impl Into<String>,
options: Vec<SelectItem>,
selected: usize,
action: SelectAction,
) -> Self {
let mut modal = Self {
title: title.into(),
help: help.into(),
search: String::new(),
options,
selected,
action,
};
modal.clamp_selection();
modal
}
fn filtered_indices(&self) -> Vec<usize> {
let needle = self.search.trim().to_ascii_lowercase();
if needle.is_empty() {
return (0..self.options.len()).collect();
}
self.options
.iter()
.enumerate()
.filter_map(|(index, option)| {
let haystack = format!(
"{} {} {}",
option.label.to_ascii_lowercase(),
option.value.to_ascii_lowercase(),
option.detail.to_ascii_lowercase()
);
haystack.contains(&needle).then_some(index)
})
.collect()
}
fn selected_item(&self) -> Option<SelectItem> {
let filtered = self.filtered_indices();
if filtered.is_empty() {
None
} else {
let selected_index = if filtered.contains(&self.selected) {
self.selected
} else {
filtered[0]
};
self.options.get(selected_index).cloned()
}
}
fn move_selection(&mut self, delta: isize) {
let filtered = self.filtered_indices();
if filtered.is_empty() {
return;
}
let current_position = filtered
.iter()
.position(|index| *index == self.selected)
.unwrap_or(0) as isize;
let max_index = filtered.len().saturating_sub(1) as isize;
let next = (current_position + delta).clamp(0, max_index) as usize;
self.selected = filtered[next];
}
fn select_first(&mut self) {
let filtered = self.filtered_indices();
if let Some(index) = filtered.first() {
self.selected = *index;
}
}
fn select_last(&mut self) {
let filtered = self.filtered_indices();
if let Some(index) = filtered.last() {
self.selected = *index;
}
}
fn append_search(&mut self, text: &str) {
self.search.push_str(text);
self.clamp_selection();
}
fn pop_search(&mut self) {
self.search.pop();
self.clamp_selection();
}
fn clear_search(&mut self) {
self.search.clear();
self.clamp_selection();
}
fn clamp_selection(&mut self) {
let filtered = self.filtered_indices();
if filtered.is_empty() {
self.selected = 0;
return;
}
if !filtered.contains(&self.selected) {
self.selected = filtered[0];
}
}
}
struct MessageModalState {
title: String,
lines: Vec<String>,
tone: StatusTone,
}
impl MessageModalState {
fn new(title: impl Into<String>, lines: Vec<String>, tone: StatusTone) -> Self {
Self {
title: title.into(),
lines,
tone,
}
}
}
struct LoadingModalState {
title: String,
detail: String,
}
impl LoadingModalState {
fn new(title: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
title: title.into(),
detail: detail.into(),
}
}
}
#[derive(Clone, Copy)]
enum BackgroundTaskKind {
ProviderCheck,
ModelFetch,
}
struct BackgroundTask {
kind: BackgroundTaskKind,
receiver: Receiver<Result<ProviderModelReport, String>>,
started_at: Instant,
}
#[derive(Clone)]
struct ProviderModelReport {
endpoint: String,
auth_source: AuthSource,
models: Vec<SelectItem>,
}
#[derive(Clone, Copy)]
enum AuthSource {
Config,
Environment,
None,
}
impl AuthSource {
fn label(self) -> &'static str {
match self {
Self::Config => "config file",
Self::Environment => ENV_API_KEY_NAME,
Self::None => "none",
}
}
}
pub fn run_config_tui(loaded_config: &mut LoadedConfig) -> Result<(), TranslatorError> {
let mut editor = ConfigEditor::new(loaded_config.clone());
enable_raw_mode()?;
let mut stdout = std::io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = ratatui::backend::CrosstermBackend::new(stdout);
let mut terminal = ratatui::Terminal::new(backend)?;
let result = (|| -> Result<(), TranslatorError> {
loop {
editor.poll_background_task();
terminal.draw(|frame| editor.render(frame))?;
if !event::poll(Duration::from_millis(200))? {
continue;
}
match event::read()? {
Event::Key(key_event) => {
if editor.handle_key(key_event)? {
break;
}
}
Event::Paste(text) => editor.handle_paste(&text),
_ => {}
}
}
Ok(())
})();
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
if result.is_ok() {
*loaded_config = editor.into_loaded_config();
}
result
}
struct ConfigEditor {
loaded_config: LoadedConfig,
saved_snapshot: AppConfig,
fields: Vec<EditorField>,
selected: usize,
status: StatusMessage,
modal: Option<Modal>,
pending_task: Option<BackgroundTask>,
}
impl ConfigEditor {
fn new(loaded_config: LoadedConfig) -> Self {
let status = if loaded_config.exists {
StatusMessage::info(format!(
"Loaded config from {}. Save with s or Ctrl+S, check the provider with c, and browse models with m.",
loaded_config.path.display()
))
} else {
StatusMessage::warning(format!(
"Config file does not exist yet. Save with s or Ctrl+S to create {}.",
loaded_config.path.display()
))
};
Self {
saved_snapshot: loaded_config.data.clone(),
loaded_config,
fields: build_fields(),
selected: 0,
status,
modal: None,
pending_task: None,
}
}
fn into_loaded_config(self) -> LoadedConfig {
self.loaded_config
}
fn is_dirty(&self) -> bool {
self.loaded_config.data != self.saved_snapshot
}
fn current_field(&self) -> EditorField {
self.fields[self.selected]
}
fn field_index(&self, field_id: FieldId) -> usize {
self.fields
.iter()
.position(|field| field.id == field_id)
.expect("field should exist")
}
fn render(&self, frame: &mut Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(4),
Constraint::Min(16),
Constraint::Length(4),
Constraint::Length(3),
])
.split(frame.area());
self.render_header(frame, chunks[0]);
let body = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(56), Constraint::Percentage(44)])
.split(chunks[1]);
let right = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(10), Constraint::Length(8)])
.split(body[1]);
self.render_field_list(frame, body[0]);
self.render_detail(frame, right[0]);
self.render_actions(frame, right[1]);
self.render_hints(frame, chunks[2]);
self.render_status(frame, chunks[3]);
if let Some(modal) = &self.modal {
match modal {
Modal::Input(state) => self.render_input_modal(frame, state),
Modal::Select(state) => self.render_select_modal(frame, state),
Modal::Message(state) => self.render_message_modal(frame, state),
Modal::ConfirmQuit => self.render_confirm_quit_modal(frame),
Modal::Loading(state) => self.render_loading_modal(frame, state),
}
}
}
fn render_header(&self, frame: &mut Frame, area: Rect) {
let dirty_style = if self.is_dirty() {
Style::default()
.fg(Color::Black)
.bg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(Color::Black)
.bg(Color::Green)
.add_modifier(Modifier::BOLD)
};
let state_label = if self.is_dirty() { " UNSAVED " } else { " SAVED " };
let task_label = self
.pending_task
.as_ref()
.map(|task| match task.kind {
BackgroundTaskKind::ProviderCheck => " provider check running ",
BackgroundTaskKind::ModelFetch => " model fetch running ",
})
.map(|label| {
Span::styled(
label,
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
});
let mut title_spans = vec![
Span::styled(
"Shinkai Translator Config Studio",
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(state_label, dirty_style),
];
if let Some(task_span) = task_label {
title_spans.push(Span::raw(" "));
title_spans.push(task_span);
}
let path_line = Line::from(vec![
Span::styled(
"Path: ",
Style::default().fg(Color::Gray).add_modifier(Modifier::BOLD),
),
Span::raw(self.loaded_config.path.display().to_string()),
]);
let auth_line = Line::from(vec![
Span::styled(
"Provider auth: ",
Style::default().fg(Color::Gray).add_modifier(Modifier::BOLD),
),
Span::raw(current_auth_source_label(&self.loaded_config.data.provider)),
]);
let header = Paragraph::new(Text::from(vec![Line::from(title_spans), path_line, auth_line]))
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Cyan))
.title("Overview"),
)
.wrap(Wrap { trim: false });
frame.render_widget(header, area);
}
fn render_field_list(&self, frame: &mut Frame, area: Rect) {
let items = self
.fields
.iter()
.map(|field| {
let section = Span::styled(
format!("{:<14}", field.section.title().to_ascii_uppercase()),
Style::default()
.fg(field.section.color())
.add_modifier(Modifier::BOLD),
);
let label = Span::styled(
format!("{:<28}", field.label),
Style::default().add_modifier(Modifier::BOLD),
);
let value = Span::styled(
truncate_for_list(&self.display_field_value(*field)),
Style::default().fg(Color::Gray),
);
ListItem::new(Line::from(vec![section, Span::raw(" "), label, value]))
})
.collect::<Vec<_>>();
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::DarkGray))
.title("Fields"),
)
.highlight_style(
Style::default()
.fg(Color::White)
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("> ");
let mut list_state = ListState::default();
list_state.select(Some(self.selected));
frame.render_stateful_widget(list, area, &mut list_state);
}
fn render_detail(&self, frame: &mut Frame, area: Rect) {
let field = self.current_field();
let raw_value = (field.read)(&self.loaded_config);
let display_value = self.display_field_value(field);
let mut lines = vec![
Line::from(vec![
Span::styled(
format!("{} / ", field.section.title()),
Style::default()
.fg(field.section.color())
.add_modifier(Modifier::BOLD),
),
Span::styled(field.label, Style::default().add_modifier(Modifier::BOLD)),
]),
Line::from(String::new()),
Line::from(vec![
Span::styled(
"Current: ",
Style::default().fg(Color::Gray).add_modifier(Modifier::BOLD),
),
Span::raw(display_value.clone()),
]),
];
if !raw_value.trim().is_empty()
&& raw_value != display_value
&& !matches!(field.kind, FieldKind::Text { secret: true })
{
lines.push(Line::from(vec![
Span::styled(
"Stored value: ",
Style::default().fg(Color::Gray).add_modifier(Modifier::BOLD),
),
Span::raw(raw_value),
]));
}
lines.extend([
Line::from(String::new()),
Line::from(vec![
Span::styled(
"Help: ",
Style::default().fg(Color::Gray).add_modifier(Modifier::BOLD),
),
Span::raw(field.help),
]),
Line::from(String::new()),
Line::from(vec![
Span::styled(
"Editing mode: ",
Style::default().fg(Color::Gray).add_modifier(Modifier::BOLD),
),
Span::raw(match field.kind {
FieldKind::Text { .. } => "modal text editor",
FieldKind::Select { .. } => "selection modal with search",
}),
]),
]);
if field.id == FieldId::ProviderModel {
lines.push(Line::from(String::new()));
lines.push(Line::from(vec![
Span::styled(
"Shortcut: ",
Style::default().fg(Color::Gray).add_modifier(Modifier::BOLD),
),
Span::raw("Press m to fetch provider models and search before applying one."),
]));
}
if field.section == Section::Provider {
lines.push(Line::from(String::new()));
lines.push(Line::from(vec![
Span::styled(
"Provider tools: ",
Style::default().fg(Color::Gray).add_modifier(Modifier::BOLD),
),
Span::raw("c checks connectivity, m loads models, and presets are available on Base URL."),
]));
}
let detail = Paragraph::new(Text::from(lines))
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(field.section.color()))
.title("Details"),
)
.wrap(Wrap { trim: false });
frame.render_widget(detail, area);
}
fn render_actions(&self, frame: &mut Frame, area: Rect) {
let field = self.current_field();
let open_action = match field.kind {
FieldKind::Text { .. } => "Enter opens a text modal with cursor controls.",
FieldKind::Select { .. } => "Enter opens a searchable selection modal.",
};
let task_line = if let Some(task) = &self.pending_task {
format!(
"Task: {} ({}s)",
match task.kind {
BackgroundTaskKind::ProviderCheck => "Checking provider",
BackgroundTaskKind::ModelFetch => "Fetching models",
},
task.started_at.elapsed().as_secs()
)
} else {
"Task: idle".to_owned()
};
let lines = vec![
Line::from(vec![styled_key("Enter"), Span::raw(format!(" {open_action}"))]),
Line::from(vec![styled_key("s"), Span::raw(" Save config"), Span::raw(" "), styled_key("q"), Span::raw(" Quit")]),
Line::from(vec![styled_key("c"), Span::raw(" Check provider"), Span::raw(" "), styled_key("m"), Span::raw(" Browse provider models")]),
Line::from(vec![styled_key("Esc"), Span::raw(" Close the active modal"), Span::raw(" "), styled_key("Paste"), Span::raw(" Works in dialogs")]),
Line::from(String::new()),
Line::from(task_line),
Line::from("Marker lists and custom headers still live in the TOML file for now."),
];
let actions = Paragraph::new(Text::from(lines))
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::DarkGray))
.title("Actions"),
)
.wrap(Wrap { trim: false });
frame.render_widget(actions, area);
}
fn render_hints(&self, frame: &mut Frame, area: Rect) {
let hints = Paragraph::new(Text::from(vec![
Line::from("Arrows or j/k move between fields. Home/End jump to the first or last field."),
Line::from("Selection dialogs support search as you type. Text dialogs support paste, Backspace/Delete, and Left/Right/Home/End."),
]))
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::DarkGray))
.title("Hints"),
)
.wrap(Wrap { trim: false });
frame.render_widget(hints, area);
}
fn render_status(&self, frame: &mut Frame, area: Rect) {
let status = Paragraph::new(self.status.text.clone())
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(self.status.tone.border()))
.title("Status"),
)
.style(Style::default().fg(self.status.tone.fg()))
.wrap(Wrap { trim: false });
frame.render_widget(status, area);
}
fn render_input_modal(&self, frame: &mut Frame, modal: &InputModalState) {
let field = self.fields[modal.field_index];
let area = centered_rect(74, 64, frame.area());
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(field.section.color()))
.title(format!("Edit {} / {}", field.section.title(), field.label));
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(4),
Constraint::Min(6),
Constraint::Length(3),
Constraint::Length(2),
])
.split(inner);
let hint_lines = vec![
Line::from(field.help),
Line::from(if modal.secret {
"The value is visible while editing so you can paste and verify it precisely."
} else {
"Paste works here. Ctrl+U clears the field."
}),
];
let hint = Paragraph::new(Text::from(hint_lines))
.block(Block::default().borders(Borders::ALL).title("Context"))
.wrap(Wrap { trim: false });
frame.render_widget(hint, layout[0]);
let preview_text = if modal.value.trim().is_empty() {
"<empty>".to_owned()
} else {
modal.value.clone()
};
let preview = Paragraph::new(preview_text)
.block(Block::default().borders(Borders::ALL).title("Current Value"))
.wrap(Wrap { trim: false });
frame.render_widget(preview, layout[1]);
let input_width = layout[2].width.saturating_sub(2) as usize;
let (visible_input, cursor_offset) = visible_input_window(&modal.value, modal.cursor, input_width);
let input = Paragraph::new(visible_input)
.block(Block::default().borders(Borders::ALL).title("Editor"))
.alignment(Alignment::Left);
frame.render_widget(input, layout[2]);
frame.set_cursor_position((layout[2].x + 1 + cursor_offset as u16, layout[2].y + 1));
let footer = Paragraph::new("Enter applies, Esc cancels, Left/Right/Home/End move the cursor.")
.alignment(Alignment::Center);
frame.render_widget(footer, layout[3]);
}
fn render_select_modal(&self, frame: &mut Frame, modal: &SelectModalState) {
let area = centered_rect(76, 72, frame.area());
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Cyan))
.title(modal.title.clone());
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(8),
Constraint::Length(5),
Constraint::Length(2),
])
.split(inner);
let search = Paragraph::new(modal.search.clone())
.block(Block::default().borders(Borders::ALL).title("Search"));
frame.render_widget(search, layout[0]);
frame.set_cursor_position((
layout[0].x + 1 + modal.search.chars().count() as u16,
layout[0].y + 1,
));
let filtered = modal.filtered_indices();
if filtered.is_empty() {
let empty = Paragraph::new("No matches. Keep typing or Backspace to widen the search.")
.block(Block::default().borders(Borders::ALL).title("Matches"))
.alignment(Alignment::Center)
.wrap(Wrap { trim: false });
frame.render_widget(empty, layout[1]);
} else {
let items = filtered
.iter()
.map(|index| {
let option = &modal.options[*index];
ListItem::new(Line::from(vec![
Span::styled(option.label.clone(), Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" "),
Span::styled(
truncate_for_list(&option.detail),
Style::default().fg(Color::Gray),
),
]))
})
.collect::<Vec<_>>();
let selected_index = filtered
.iter()
.position(|index| *index == modal.selected)
.unwrap_or(0);
let mut state = ListState::default();
state.select(Some(selected_index));
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title("Matches"))
.highlight_style(
Style::default()
.fg(Color::White)
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("> ");
frame.render_stateful_widget(list, layout[1], &mut state);
}
let detail = if let Some(option) = modal.selected_item() {
Text::from(vec![
Line::from(vec![
Span::styled("Value: ", Style::default().fg(Color::Gray).add_modifier(Modifier::BOLD)),
Span::raw(option.value),
]),
Line::from(String::new()),
Line::from(option.detail),
Line::from(String::new()),
Line::from(modal.help.clone()),
])
} else {
Text::from(vec![Line::from(modal.help.clone())])
};
let detail_widget = Paragraph::new(detail)
.block(Block::default().borders(Borders::ALL).title("Selection"))
.wrap(Wrap { trim: false });
frame.render_widget(detail_widget, layout[2]);
let footer = Paragraph::new("Type to filter, Enter applies the selected option, Esc closes.")
.alignment(Alignment::Center);
frame.render_widget(footer, layout[3]);
}
fn render_message_modal(&self, frame: &mut Frame, modal: &MessageModalState) {
let area = centered_rect(62, 42, frame.area());
frame.render_widget(Clear, area);
let message = Paragraph::new(Text::from(
modal
.lines
.iter()
.cloned()
.map(Line::from)
.chain(std::iter::once(Line::from(String::new())))
.chain(std::iter::once(Line::from(
"Press Enter or Esc to close this dialog.",
)))
.collect::<Vec<_>>(),
))
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(modal.tone.border()))
.title(modal.title.clone()),
)
.wrap(Wrap { trim: false });
frame.render_widget(message, area);
}
fn render_confirm_quit_modal(&self, frame: &mut Frame) {
let area = centered_rect(56, 32, frame.area());
frame.render_widget(Clear, area);
let body = Paragraph::new(Text::from(vec![
Line::from("There are unsaved changes."),
Line::from(String::new()),
Line::from("Enter saves and quits."),
Line::from("q or d discards changes and quits."),
Line::from("Esc keeps the editor open."),
]))
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Yellow))
.title("Unsaved Changes"),
)
.alignment(Alignment::Center)
.wrap(Wrap { trim: false });
frame.render_widget(body, area);
}
fn render_loading_modal(&self, frame: &mut Frame, modal: &LoadingModalState) {
let area = centered_rect(52, 28, frame.area());
let spinner = self
.pending_task
.as_ref()
.map(|task| spinner_frame(task.started_at.elapsed()))
.unwrap_or("|");
frame.render_widget(Clear, area);
let loading = Paragraph::new(Text::from(vec![
Line::from(format!("[{}] {}", spinner, modal.title)),
Line::from(String::new()),
Line::from(modal.detail.clone()),
Line::from(String::new()),
Line::from("The editor stays responsive and will show the result when the request finishes."),
]))
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Cyan))
.title("Working"),
)
.alignment(Alignment::Center)
.wrap(Wrap { trim: false });
frame.render_widget(loading, area);
}
fn handle_key(&mut self, key_event: KeyEvent) -> Result<bool, TranslatorError> {
if key_event.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key_event.code, KeyCode::Char('s') | KeyCode::Char('S'))
{
self.save()?;
return Ok(false);
}
if self.modal.is_some() {
return self.handle_modal_key(key_event);
}
match key_event.code {
KeyCode::Char('q') => {
if self.is_dirty() {
self.modal = Some(Modal::ConfirmQuit);
self.status = StatusMessage::warning(
"Unsaved changes detected. Confirm whether to save or discard before quitting.",
);
Ok(false)
} else {
Ok(true)
}
}
KeyCode::Char('s') | KeyCode::Char('S') => {
self.save()?;
Ok(false)
}
KeyCode::Char('c') | KeyCode::Char('C') => {
self.start_background_task(BackgroundTaskKind::ProviderCheck);
Ok(false)
}
KeyCode::Char('m') | KeyCode::Char('M') => {
self.start_background_task(BackgroundTaskKind::ModelFetch);
Ok(false)
}
KeyCode::Up | KeyCode::Char('k') => {
self.selected = self.selected.saturating_sub(1);
Ok(false)
}
KeyCode::Down | KeyCode::Char('j') => {
self.selected = (self.selected + 1).min(self.fields.len().saturating_sub(1));
Ok(false)
}
KeyCode::Home => {
self.selected = 0;
Ok(false)
}
KeyCode::End => {
self.selected = self.fields.len().saturating_sub(1);
Ok(false)
}
KeyCode::Enter => {
self.activate_selected_field();
Ok(false)
}
_ => Ok(false),
}
}
fn handle_modal_key(&mut self, key_event: KeyEvent) -> Result<bool, TranslatorError> {
match self.modal.as_ref() {
Some(Modal::Input(_)) => self.handle_input_modal_key(key_event),
Some(Modal::Select(_)) => self.handle_select_modal_key(key_event),
Some(Modal::Message(_)) => {
if matches!(key_event.code, KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q')) {
self.modal = None;
}
Ok(false)
}
Some(Modal::ConfirmQuit) => self.handle_confirm_quit_key(key_event),
Some(Modal::Loading(_)) => {
if matches!(key_event.code, KeyCode::Esc) {
self.modal = None;
self.status = StatusMessage::info(
"Background task is still running. The result will appear when it completes.",
);
}
Ok(false)
}
None => Ok(false),
}
}
fn handle_input_modal_key(&mut self, key_event: KeyEvent) -> Result<bool, TranslatorError> {
let mut apply_value = None;
let mut close_modal = false;
if let Some(Modal::Input(input)) = self.modal.as_mut() {
match key_event.code {
KeyCode::Esc => {
close_modal = true;
self.status = StatusMessage::warning("Edit cancelled.");
}
KeyCode::Enter => apply_value = Some((input.field_index, input.value.clone())),
KeyCode::Left => input.cursor = input.cursor.saturating_sub(1),
KeyCode::Right => {
input.cursor = (input.cursor + 1).min(input.value.chars().count())
}
KeyCode::Home => input.cursor = 0,
KeyCode::End => input.cursor = input.value.chars().count(),
KeyCode::Backspace => input.backspace(),
KeyCode::Delete => input.delete(),
KeyCode::Char('u') | KeyCode::Char('U')
if key_event.modifiers.contains(KeyModifiers::CONTROL) =>
{
input.clear();
}
KeyCode::Char(character)
if !key_event.modifiers.contains(KeyModifiers::CONTROL)
&& !key_event.modifiers.contains(KeyModifiers::ALT) =>
{
input.insert_text(&character.to_string());
}
_ => {}
}
}
if close_modal {
self.modal = None;
return Ok(false);
}
if let Some((field_index, value)) = apply_value {
match self.apply_field_value(field_index, &value) {
Ok(()) => self.modal = None,
Err(error) => self.status = StatusMessage::error(error),
}
}
Ok(false)
}
fn handle_select_modal_key(&mut self, key_event: KeyEvent) -> Result<bool, TranslatorError> {
enum SelectDecision {
Close,
Apply(SelectAction, SelectItem),
None,
}
let mut decision = SelectDecision::None;
if let Some(Modal::Select(select)) = self.modal.as_mut() {
match key_event.code {
KeyCode::Esc => decision = SelectDecision::Close,
KeyCode::Up | KeyCode::Char('k') => select.move_selection(-1),
KeyCode::Down | KeyCode::Char('j') => select.move_selection(1),
KeyCode::Home => select.select_first(),
KeyCode::End => select.select_last(),
KeyCode::Backspace => select.pop_search(),
KeyCode::Char('u') | KeyCode::Char('U')
if key_event.modifiers.contains(KeyModifiers::CONTROL) =>
{
select.clear_search();
}
KeyCode::Enter => {
if let Some(option) = select.selected_item() {
decision = SelectDecision::Apply(select.action, option);
} else {
self.status = StatusMessage::warning(
"No option matches the current search filter.",
);
}
}
KeyCode::Char(character)
if !key_event.modifiers.contains(KeyModifiers::CONTROL)
&& !key_event.modifiers.contains(KeyModifiers::ALT) =>
{
select.append_search(&character.to_string());
}
_ => {}
}
}
match decision {
SelectDecision::Close => {
self.modal = None;
self.status = StatusMessage::warning("Selection closed.");
}
SelectDecision::Apply(SelectAction::Field { field_index }, option) => {
if option.value == CUSTOM_OPTION_VALUE {
self.open_input_modal(field_index, None);
} else if let Err(error) = self.apply_field_value(field_index, &option.value) {
self.status = StatusMessage::error(error);
} else {
self.modal = None;
}
}
SelectDecision::Apply(SelectAction::ModelPicker, option) => {
let field_index = self.field_index(FieldId::ProviderModel);
if let Err(error) = self.apply_field_value(field_index, &option.value) {
self.status = StatusMessage::error(error);
} else {
self.modal = None;
}
}
SelectDecision::None => {}
}
Ok(false)
}
fn handle_confirm_quit_key(&mut self, key_event: KeyEvent) -> Result<bool, TranslatorError> {
match key_event.code {
KeyCode::Esc => {
self.modal = None;
self.status = StatusMessage::info("Quit cancelled.");
Ok(false)
}
KeyCode::Enter | KeyCode::Char('s') | KeyCode::Char('S') => {
self.save()?;
Ok(true)
}
KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Char('d') | KeyCode::Char('D') => {
self.discard_unsaved_changes();
Ok(true)
}
_ => Ok(false),
}
}
fn handle_paste(&mut self, text: &str) {
let sanitized = sanitize_inline_input(text);
if sanitized.is_empty() {
return;
}
match self.modal.as_mut() {
Some(Modal::Input(input)) => input.insert_text(&sanitized),
Some(Modal::Select(select)) => select.append_search(&sanitized),
_ => {}
}
}
fn activate_selected_field(&mut self) {
let field = self.current_field();
match field.kind {
FieldKind::Text { .. } => self.open_input_modal(self.selected, None),
FieldKind::Select { options } => {
let current_value = (field.read)(&self.loaded_config);
let options = options.iter().map(SelectItem::from_choice).collect::<Vec<_>>();
let selected = options
.iter()
.position(|option| option.value == current_value)
.unwrap_or(0);
self.modal = Some(Modal::Select(SelectModalState::new(
format!("Select {} / {}", field.section.title(), field.label),
field.help,
options,
selected,
SelectAction::Field {
field_index: self.selected,
},
)));
self.status = StatusMessage::info(format!("Selecting {}.", field.label));
}
}
}
fn open_input_modal(&mut self, field_index: usize, initial_override: Option<String>) {
let field = self.fields[field_index];
let initial_value = initial_override.unwrap_or_else(|| (field.read)(&self.loaded_config));
let secret = matches!(field.kind, FieldKind::Text { secret: true });
self.modal = Some(Modal::Input(InputModalState::new(
field_index,
initial_value,
secret,
)));
self.status = StatusMessage::info(format!("Editing {}.", field.label));
}
fn apply_field_value(&mut self, field_index: usize, value: &str) -> Result<(), String> {
let field = self.fields[field_index];
(field.write)(&mut self.loaded_config, value)?;
self.status = StatusMessage::success(format!("Updated {}.", field.label));
Ok(())
}
fn save(&mut self) -> Result<(), TranslatorError> {
self.loaded_config.save()?;
self.saved_snapshot = self.loaded_config.data.clone();
self.status = StatusMessage::success(format!(
"Saved config to {}.",
self.loaded_config.path.display()
));
self.modal = None;
Ok(())
}
fn discard_unsaved_changes(&mut self) {
self.loaded_config.data = self.saved_snapshot.clone();
self.modal = None;
self.status = StatusMessage::warning("Discarded unsaved changes.");
}
fn start_background_task(&mut self, kind: BackgroundTaskKind) {
if self.pending_task.is_some() {
self.status = StatusMessage::warning(
"A provider request is already running. Wait for it to finish before starting another one.",
);
return;
}
let provider = self.loaded_config.data.provider.clone();
let (sender, receiver) = mpsc::channel();
thread::spawn(move || {
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(error) => {
let _ = sender.send(Err(format!("failed to start provider worker: {error}")));
return;
}
};
let result = runtime.block_on(fetch_provider_models(provider));
let _ = sender.send(result);
});
self.pending_task = Some(BackgroundTask {
kind,
receiver,
started_at: Instant::now(),
});
self.modal = Some(Modal::Loading(LoadingModalState::new(
match kind {
BackgroundTaskKind::ProviderCheck => "Checking provider",
BackgroundTaskKind::ModelFetch => "Fetching provider models",
},
"Calling GET /models with the current provider configuration.",
)));
self.status = StatusMessage::info(match kind {
BackgroundTaskKind::ProviderCheck => {
"Checking the provider endpoint and validating the current credentials."
}
BackgroundTaskKind::ModelFetch => {
"Loading provider models so you can filter and select one instead of typing it."
}
});
}
fn poll_background_task(&mut self) {
let outcome = match self.pending_task.as_ref() {
Some(task) => match task.receiver.try_recv() {
Ok(result) => Some((task.kind, result)),
Err(TryRecvError::Disconnected) => Some((
task.kind,
Err("provider background task stopped unexpectedly".to_owned()),
)),
Err(TryRecvError::Empty) => None,
},
None => None,
};
let Some((kind, result)) = outcome else {
return;
};
self.pending_task = None;
match result {
Ok(report) => match kind {
BackgroundTaskKind::ProviderCheck => {
let preview = report
.models
.iter()
.take(6)
.map(|model| model.value.as_str())
.collect::<Vec<_>>()
.join(", ");
self.modal = Some(Modal::Message(MessageModalState::new(
"Provider Check Passed",
vec![
format!("Endpoint: {}", report.endpoint),
format!("Authentication source: {}", report.auth_source.label()),
format!("Models available: {}", report.models.len()),
format!("Sample models: {preview}"),
],
StatusTone::Success,
)));
self.status = StatusMessage::success(format!(
"Provider reachable. Loaded {} models from {}.",
report.models.len(), report.endpoint
));
}
BackgroundTaskKind::ModelFetch => {
let selected = report
.models
.iter()
.position(|option| option.value == self.loaded_config.data.provider.model)
.unwrap_or(0);
self.modal = Some(Modal::Select(SelectModalState::new(
format!("Provider Models ({})", report.models.len()),
format!(
"Fetched from {} using {}. Search narrows the model list immediately.",
report.endpoint,
report.auth_source.label()
),
report.models,
selected,
SelectAction::ModelPicker,
)));
self.status = StatusMessage::success(
"Provider models loaded. Search and press Enter to apply one.",
);
}
},
Err(error) => {
self.modal = Some(Modal::Message(MessageModalState::new(
match kind {
BackgroundTaskKind::ProviderCheck => "Provider Check Failed",
BackgroundTaskKind::ModelFetch => "Model Fetch Failed",
},
vec![error.clone()],
StatusTone::Error,
)));
self.status = StatusMessage::error(error);
}
}
}
fn display_field_value(&self, field: EditorField) -> String {
let raw = (field.read)(&self.loaded_config);
match field.kind {
FieldKind::Text { secret: true } => display_secret_value(&raw),
FieldKind::Text { secret: false } => {
if raw.trim().is_empty() {
"<empty>".to_owned()
} else {
raw.replace('\n', " ")
}
}
FieldKind::Select { options } => {
if let Some(option) = options.iter().find(|option| option.value == raw) {
option.label.to_owned()
} else if raw.trim().is_empty() {
"<empty>".to_owned()
} else {
raw
}
}
}
}
}
fn build_fields() -> Vec<EditorField> {
vec![
EditorField {
id: FieldId::ProviderBaseUrl,
section: Section::Provider,
label: "Base URL",
help: "Pick a provider preset or enter a custom OpenAI-compatible /v1 endpoint.",
read: |loaded| loaded.data.provider.base_url.clone(),
write: |loaded, value| {
loaded.data.provider.base_url = require_non_empty(value, "provider base_url")?;
Ok(())
},
kind: FieldKind::Select {
options: &PROVIDER_PRESET_OPTIONS,
},
},
EditorField {
id: FieldId::ProviderModel,
section: Section::Provider,
label: "Model",
help: "Manual input still works, but pressing m loads the provider model list into a searchable modal.",
read: |loaded| loaded.data.provider.model.clone(),
write: |loaded, value| {
loaded.data.provider.model = require_non_empty(value, "provider model")?;
Ok(())
},
kind: FieldKind::Text { secret: false },
},
EditorField {
id: FieldId::ProviderApiKey,
section: Section::Provider,
label: "API Key",
help: "Optional API key. Leave it empty to rely on the SHINKAI_TRANSLATOR_API_KEY environment variable instead.",
read: |loaded| loaded.data.provider.api_key.clone().unwrap_or_default(),
write: |loaded, value| {
loaded.data.provider.api_key = optional_string(value);
Ok(())
},
kind: FieldKind::Text { secret: true },
},
EditorField {
id: FieldId::ProviderThinkingMode,
section: Section::Provider,
label: "Thinking Mode",
help: "Controls provider-specific reasoning flags when the backend supports them.",
read: |loaded| format!("{:?}", loaded.data.provider.thinking_mode).to_ascii_lowercase(),
write: |loaded, value| {
loaded.data.provider.thinking_mode = ThinkingMode::parse_name(value)
.ok_or_else(|| "invalid thinking mode; use off, on, or auto".to_owned())?;
Ok(())
},
kind: FieldKind::Select {
options: &THINKING_MODE_OPTIONS,
},
},
EditorField {
id: FieldId::ProviderTimeoutSeconds,
section: Section::Provider,
label: "Timeout Seconds",
help: "HTTP timeout used for provider requests and the provider check/model browser.",
read: |loaded| loaded.data.provider.timeout_seconds.to_string(),
write: |loaded, value| {
loaded.data.provider.timeout_seconds = parse_u64(value, "timeout_seconds")?;
Ok(())
},
kind: FieldKind::Text { secret: false },
},
EditorField {
id: FieldId::ProviderMaxRetries,
section: Section::Provider,
label: "Max Retries",
help: "Retry budget for transient provider failures.",
read: |loaded| loaded.data.provider.max_retries.to_string(),
write: |loaded, value| {
loaded.data.provider.max_retries = parse_u32(value, "max_retries")?;
Ok(())
},
kind: FieldKind::Text { secret: false },
},
EditorField {
id: FieldId::ProviderTemperature,
section: Section::Provider,
label: "Temperature",
help: "Sampling temperature forwarded to the provider. Zero is deterministic; higher values are more creative.",
read: |loaded| loaded.data.provider.temperature.to_string(),
write: |loaded, value| {
loaded.data.provider.temperature = parse_f32(value, "temperature")?;
Ok(())
},
kind: FieldKind::Text { secret: false },
},
EditorField {
id: FieldId::TranslationSourceLanguage,
section: Section::Translation,
label: "Source Language",
help: "Use a preset or pick Custom for a free-form source language hint.",
read: |loaded| loaded.data.translation.source_language.clone().unwrap_or_default(),
write: |loaded, value| {
loaded.data.translation.source_language = optional_string(value);
Ok(())
},
kind: FieldKind::Select {
options: &SOURCE_LANGUAGE_OPTIONS,
},
},
EditorField {
id: FieldId::TranslationTargetLanguage,
section: Section::Translation,
label: "Target Language",
help: "Default language used by translate when --target-language is omitted.",
read: |loaded| loaded.data.translation.target_language.clone().unwrap_or_default(),
write: |loaded, value| {
loaded.data.translation.target_language = optional_string(value);
Ok(())
},
kind: FieldKind::Select {
options: &TARGET_LANGUAGE_OPTIONS,
},
},
EditorField {
id: FieldId::TranslationMaxBatchItems,
section: Section::Translation,
label: "Max Batch Items",
help: "Maximum subtitle cues sent per provider batch.",
read: |loaded| loaded.data.translation.max_batch_items.to_string(),
write: |loaded, value| {
loaded.data.translation.max_batch_items = parse_usize(value, "max_batch_items")?;
Ok(())
},
kind: FieldKind::Text { secret: false },
},
EditorField {
id: FieldId::TranslationMaxBatchCharacters,
section: Section::Translation,
label: "Max Batch Characters",
help: "Maximum aggregate character count per provider request.",
read: |loaded| loaded.data.translation.max_batch_characters.to_string(),
write: |loaded, value| {
loaded.data.translation.max_batch_characters =
parse_usize(value, "max_batch_characters")?;
Ok(())
},
kind: FieldKind::Text { secret: false },
},
EditorField {
id: FieldId::TranslationMaxParallelBatches,
section: Section::Translation,
label: "Max Parallel Batches",
help: "How many provider requests may run at the same time.",
read: |loaded| loaded.data.translation.max_parallel_batches.to_string(),
write: |loaded, value| {
loaded.data.translation.max_parallel_batches =
parse_usize(value, "max_parallel_batches")?;
Ok(())
},
kind: FieldKind::Text { secret: false },
},
EditorField {
id: FieldId::TranslationSystemPrompt,
section: Section::Translation,
label: "System Prompt",
help: "Optional extra instruction appended to the translation system prompt. Paste works in the editor dialog.",
read: |loaded| loaded.data.translation.system_prompt.clone().unwrap_or_default(),
write: |loaded, value| {
loaded.data.translation.system_prompt = optional_string(value);
Ok(())
},
kind: FieldKind::Text { secret: false },
},
EditorField {
id: FieldId::OcrLanguage,
section: Section::Ocr,
label: "OCR Language",
help: "Language profile used by the PGS OCR step.",
read: |loaded| format!("{:?}", loaded.data.ocr.language).to_ascii_lowercase(),
write: |loaded, value| {
loaded.data.ocr.language = PgsOcrLanguage::parse_name(value)
.ok_or_else(|| "invalid OCR language; use auto, english, or latin".to_owned())?;
Ok(())
},
kind: FieldKind::Select {
options: &OCR_LANGUAGE_OPTIONS,
},
},
EditorField {
id: FieldId::OcrModelCacheDir,
section: Section::Ocr,
label: "Model Cache Dir",
help: "Optional override for the OCR model cache directory.",
read: |loaded| {
loaded
.data
.ocr
.model_cache_dir
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_default()
},
write: |loaded, value| {
loaded.data.ocr.model_cache_dir = optional_string(value).map(Into::into);
Ok(())
},
kind: FieldKind::Text { secret: false },
},
EditorField {
id: FieldId::ToolsFfmpeg,
section: Section::Tools,
label: "ffmpeg",
help: "Binary name or absolute path used to extract and mux subtitles.",
read: |loaded| loaded.data.tools.ffmpeg_bin.clone(),
write: |loaded, value| {
loaded.data.tools.ffmpeg_bin = require_non_empty(value, "ffmpeg_bin")?;
Ok(())
},
kind: FieldKind::Text { secret: false },
},
EditorField {
id: FieldId::ToolsFfprobe,
section: Section::Tools,
label: "ffprobe",
help: "Binary name or absolute path used to inspect media streams.",
read: |loaded| loaded.data.tools.ffprobe_bin.clone(),
write: |loaded, value| {
loaded.data.tools.ffprobe_bin = require_non_empty(value, "ffprobe_bin")?;
Ok(())
},
kind: FieldKind::Text { secret: false },
},
EditorField {
id: FieldId::ClassificationKaraokePolicy,
section: Section::Classification,
label: "Karaoke Policy",
help: "Disposition applied to cues already identified as karaoke.",
read: |loaded| {
format!("{:?}", loaded.data.classification.karaoke_policy).to_ascii_lowercase()
},
write: |loaded, value| {
loaded.data.classification.karaoke_policy = parse_disposition(value)?;
Ok(())
},
kind: FieldKind::Select {
options: &DISPOSITION_OPTIONS,
},
},
EditorField {
id: FieldId::ClassificationExplicitSongPolicy,
section: Section::Classification,
label: "Explicit Song Policy",
help: "Disposition applied when song cues are explicitly marked.",
read: |loaded| {
format!("{:?}", loaded.data.classification.explicit_song_policy)
.to_ascii_lowercase()
},
write: |loaded, value| {
loaded.data.classification.explicit_song_policy = parse_disposition(value)?;
Ok(())
},
kind: FieldKind::Select {
options: &DISPOSITION_OPTIONS,
},
},
EditorField {
id: FieldId::ClassificationInferredSongPolicy,
section: Section::Classification,
label: "Inferred Song Policy",
help: "Disposition applied when the classifier infers songs from marker runs.",
read: |loaded| {
format!("{:?}", loaded.data.classification.inferred_song_policy)
.to_ascii_lowercase()
},
write: |loaded, value| {
loaded.data.classification.inferred_song_policy = parse_disposition(value)?;
Ok(())
},
kind: FieldKind::Select {
options: &DISPOSITION_OPTIONS,
},
},
EditorField {
id: FieldId::ClassificationEnableInferredSongDetection,
section: Section::Classification,
label: "Enable Inferred Song Detection",
help: "Toggles heuristic detection for contiguous song-like ASS cues.",
read: |loaded| loaded.data.classification.enable_inferred_song_detection.to_string(),
write: |loaded, value| {
loaded.data.classification.enable_inferred_song_detection =
parse_bool(value, "enable_inferred_song_detection")?;
Ok(())
},
kind: FieldKind::Select {
options: &BOOLEAN_OPTIONS,
},
},
EditorField {
id: FieldId::ClassificationMinInferredSongRunLength,
section: Section::Classification,
label: "Min Inferred Song Run Length",
help: "Minimum cue run length that triggers inferred song detection.",
read: |loaded| loaded.data.classification.min_inferred_song_run_length.to_string(),
write: |loaded, value| {
loaded.data.classification.min_inferred_song_run_length =
parse_usize(value, "min_inferred_song_run_length")?;
Ok(())
},
kind: FieldKind::Text { secret: false },
},
]
}
fn optional_string(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_owned())
}
}
fn require_non_empty(value: &str, field_name: &str) -> Result<String, String> {
optional_string(value).ok_or_else(|| format!("{field_name} cannot be empty"))
}
fn parse_usize(value: &str, field_name: &str) -> Result<usize, String> {
value
.trim()
.parse::<usize>()
.map_err(|error| format!("invalid {field_name}: {error}"))
.and_then(|parsed| {
if parsed == 0 {
Err(format!("{field_name} must be greater than zero"))
} else {
Ok(parsed)
}
})
}
fn parse_u64(value: &str, field_name: &str) -> Result<u64, String> {
value
.trim()
.parse::<u64>()
.map_err(|error| format!("invalid {field_name}: {error}"))
.and_then(|parsed| {
if parsed == 0 {
Err(format!("{field_name} must be greater than zero"))
} else {
Ok(parsed)
}
})
}
fn parse_u32(value: &str, field_name: &str) -> Result<u32, String> {
value
.trim()
.parse::<u32>()
.map_err(|error| format!("invalid {field_name}: {error}"))
}
fn parse_f32(value: &str, field_name: &str) -> Result<f32, String> {
value
.trim()
.parse::<f32>()
.map_err(|error| format!("invalid {field_name}: {error}"))
.and_then(|parsed| {
if !parsed.is_finite() || parsed < 0.0 {
Err(format!("{field_name} must be a finite non-negative number"))
} else {
Ok(parsed)
}
})
}
fn parse_bool(value: &str, field_name: &str) -> Result<bool, String> {
match value.trim().to_ascii_lowercase().as_str() {
"true" | "yes" | "on" | "1" => Ok(true),
"false" | "no" | "off" | "0" => Ok(false),
_ => Err(format!(
"invalid {field_name}; use true/false, yes/no, on/off, or 1/0"
)),
}
}
fn parse_disposition(value: &str) -> Result<CueDisposition, String> {
CueDisposition::parse_name(value)
.ok_or_else(|| "invalid disposition; use translate, preserve, or review".to_owned())
}
fn sanitize_inline_input(value: &str) -> String {
value.replace("\r\n", " ").replace(['\n', '\r', '\t'], " ")
}
fn truncate_for_list(value: &str) -> String {
truncate_single_line(value, 38)
}
fn truncate_single_line(value: &str, max_chars: usize) -> String {
let single_line = value.replace('\n', " ");
let chars = single_line.chars().collect::<Vec<_>>();
if chars.len() <= max_chars {
single_line
} else {
chars[..max_chars].iter().collect::<String>() + "..."
}
}
fn styled_key(label: &str) -> Span<'static> {
Span::styled(
format!(" {label} "),
Style::default()
.fg(Color::Black)
.bg(Color::Gray)
.add_modifier(Modifier::BOLD),
)
}
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(vertical[1])[1]
}
fn spinner_frame(elapsed: Duration) -> &'static str {
let index = ((elapsed.as_millis() / 180) as usize) % SPINNER_FRAMES.len();
SPINNER_FRAMES[index]
}
fn display_secret_value(raw: &str) -> String {
if let Some(trimmed) = optional_string(raw) {
let visible_tail = trimmed.chars().rev().take(4).collect::<String>();
let visible_tail = visible_tail.chars().rev().collect::<String>();
let mask_len = trimmed.chars().count().saturating_sub(visible_tail.chars().count());
format!("{}{}", "*".repeat(mask_len.max(4)), visible_tail)
} else if env::var(ENV_API_KEY_NAME)
.ok()
.filter(|value| !value.trim().is_empty())
.is_some()
{
format!("<from {ENV_API_KEY_NAME}>")
} else {
"<empty>".to_owned()
}
}
fn current_auth_source_label(provider: &ProviderConfig) -> &'static str {
if provider
.api_key
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
{
"config file"
} else if env::var(ENV_API_KEY_NAME)
.ok()
.filter(|value| !value.trim().is_empty())
.is_some()
{
ENV_API_KEY_NAME
} else {
"none"
}
}
fn visible_input_window(value: &str, cursor: usize, max_chars: usize) -> (String, usize) {
if max_chars == 0 {
return (String::new(), 0);
}
let chars = value.chars().collect::<Vec<_>>();
if chars.len() <= max_chars {
return (value.replace('\n', " "), cursor.min(chars.len()));
}
let start = cursor.saturating_sub(max_chars.saturating_sub(1));
let end = (start + max_chars).min(chars.len());
let visible = chars[start..end]
.iter()
.collect::<String>()
.replace('\n', " ");
(visible, cursor.saturating_sub(start).min(max_chars))
}
fn char_to_byte_index(value: &str, char_index: usize) -> usize {
if char_index == 0 {
return 0;
}
value
.char_indices()
.nth(char_index)
.map(|(index, _)| index)
.unwrap_or(value.len())
}
fn insert_text_at_cursor(value: &mut String, cursor: &mut usize, text: &str) {
let byte_index = char_to_byte_index(value, *cursor);
value.insert_str(byte_index, text);
*cursor += text.chars().count();
}
fn remove_char_before_cursor(value: &mut String, cursor: &mut usize) {
if *cursor == 0 {
return;
}
let start = char_to_byte_index(value, cursor.saturating_sub(1));
let end = char_to_byte_index(value, *cursor);
value.replace_range(start..end, "");
*cursor = cursor.saturating_sub(1);
}
fn remove_char_after_cursor(value: &mut String, cursor: usize) {
if cursor >= value.chars().count() {
return;
}
let start = char_to_byte_index(value, cursor);
let end = char_to_byte_index(value, cursor + 1);
value.replace_range(start..end, "");
}
fn resolve_api_key(provider: &ProviderConfig) -> (Option<String>, AuthSource) {
if let Some(api_key) = provider
.api_key
.as_deref()
.filter(|value| !value.trim().is_empty())
{
return (Some(api_key.to_owned()), AuthSource::Config);
}
if let Ok(api_key) = env::var(ENV_API_KEY_NAME) {
if !api_key.trim().is_empty() {
return (Some(api_key), AuthSource::Environment);
}
}
(None, AuthSource::None)
}
fn build_provider_headers(provider: &ProviderConfig) -> Result<HeaderMap, String> {
let mut headers = HeaderMap::new();
for (name, value) in &provider.custom_headers {
let header_name = HeaderName::from_bytes(name.as_bytes())
.map_err(|error| format!("invalid custom header name {name:?}: {error}"))?;
let header_value = HeaderValue::from_str(value)
.map_err(|error| format!("invalid custom header value for {name:?}: {error}"))?;
headers.insert(header_name, header_value);
}
Ok(headers)
}
fn models_endpoint(base_url: &str) -> String {
let normalized = base_url.trim().trim_end_matches('/');
let base = strip_known_provider_suffix(normalized);
format!("{}/models", base.trim_end_matches('/'))
}
fn strip_known_provider_suffix(base_url: &str) -> &str {
[
"/chat/completions",
"/completions",
"/responses",
"/messages",
"/models",
]
.iter()
.find_map(|suffix| base_url.strip_suffix(suffix))
.unwrap_or(base_url)
}
fn ollama_tags_endpoint(base_url: &str) -> Option<String> {
let normalized = base_url.trim().trim_end_matches('/');
if !normalized.contains("11434") && !normalized.to_ascii_lowercase().contains("ollama") {
return None;
}
let base = normalized
.strip_suffix("/v1")
.or_else(|| normalized.strip_suffix("/api"))
.unwrap_or_else(|| strip_known_provider_suffix(normalized));
Some(format!("{}/api/tags", base.trim_end_matches('/')))
}
async fn fetch_provider_models(provider: ProviderConfig) -> Result<ProviderModelReport, String> {
provider
.validate()
.map_err(|error| format!("invalid provider config: {error}"))?;
let endpoint = models_endpoint(&provider.base_url);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(provider.timeout_seconds))
.build()
.map_err(|error| format!("failed to build HTTP client: {error}"))?;
let headers = build_provider_headers(&provider)?;
let (api_key, auth_source) = resolve_api_key(&provider);
let mut request = client.get(&endpoint).headers(headers);
if let Some(api_key) = api_key {
request = request.bearer_auth(api_key);
}
let response = request
.send()
.await
.map_err(|error| format!("failed to reach {endpoint}: {error}"))?;
let status = response.status();
let body = response
.text()
.await
.map_err(|error| format!("failed to read {endpoint}: {error}"))?;
if !status.is_success() {
if let Some(ollama_endpoint) = ollama_tags_endpoint(&provider.base_url) {
if endpoint != ollama_endpoint && matches!(status.as_u16(), 404 | 405) {
let mut fallback_request = client.get(&ollama_endpoint).headers(build_provider_headers(&provider)?);
if let Some(api_key) = resolve_api_key(&provider).0 {
fallback_request = fallback_request.bearer_auth(api_key);
}
let fallback_response = fallback_request
.send()
.await
.map_err(|error| format!("failed to reach {ollama_endpoint}: {error}"))?;
let fallback_status = fallback_response.status();
let fallback_body = fallback_response.text().await.map_err(|error| {
format!("failed to read {ollama_endpoint}: {error}")
})?;
if fallback_status.is_success() {
return parse_model_response(&ollama_endpoint, auth_source, &fallback_body);
}
}
}
return Err(format!(
"{} returned {}: {}",
endpoint,
status,
truncate_single_line(&body, 220)
));
}
parse_model_response(&endpoint, auth_source, &body)
}
fn parse_model_response(
endpoint: &str,
auth_source: AuthSource,
body: &str,
) -> Result<ProviderModelReport, String> {
let payload: Value = serde_json::from_str(body)
.map_err(|error| format!("provider returned invalid model JSON: {error}"))?;
let mut models = extract_model_items(&payload);
models.sort_by(|left, right| left.value.cmp(&right.value));
models.dedup_by(|left, right| left.value == right.value);
if models.is_empty() {
return Err(format!(
"{} returned a JSON payload but no recognizable models were found: {}",
endpoint,
truncate_single_line(body, 220)
));
}
Ok(ProviderModelReport {
endpoint: endpoint.to_owned(),
auth_source,
models,
})
}
fn extract_model_items(payload: &Value) -> Vec<SelectItem> {
if let Some(array) = payload.as_array() {
return array.iter().filter_map(value_to_model_item).collect();
}
if let Some(object) = payload.as_object() {
for key in ["data", "models", "items", "result"] {
if let Some(array) = object.get(key).and_then(Value::as_array) {
let items = array.iter().filter_map(value_to_model_item).collect::<Vec<_>>();
if !items.is_empty() {
return items;
}
}
if let Some(nested) = object.get(key) {
let items = extract_model_items(nested);
if !items.is_empty() {
return items;
}
}
}
}
value_to_model_item(payload).into_iter().collect()
}
fn value_to_model_item(value: &Value) -> Option<SelectItem> {
match value {
Value::String(model) => {
let trimmed = model.trim();
if trimmed.is_empty() {
None
} else {
Some(SelectItem {
label: trimmed.to_owned(),
value: trimmed.to_owned(),
detail: "provider model".to_owned(),
})
}
}
Value::Object(object) => {
let model_value = string_field(
object,
&["id", "model", "name", "slug", "canonical_slug", "key"],
)?;
let label = string_field(
object,
&[
"name",
"display_name",
"title",
"id",
"model",
"slug",
"canonical_slug",
],
)
.unwrap_or_else(|| model_value.clone());
Some(SelectItem {
detail: build_model_detail(object, &model_value, &label),
label,
value: model_value,
})
}
_ => None,
}
}
fn string_field(object: &Map<String, Value>, candidates: &[&str]) -> Option<String> {
candidates
.iter()
.find_map(|candidate| object.get(*candidate).and_then(string_value))
}
fn string_value(value: &Value) -> Option<String> {
match value {
Value::String(text) => optional_string(text),
Value::Number(number) => Some(number.to_string()),
Value::Bool(boolean) => Some(boolean.to_string()),
_ => None,
}
}
fn build_model_detail(object: &Map<String, Value>, model_value: &str, label: &str) -> String {
let mut parts = Vec::new();
if label.trim() != model_value.trim() {
parts.push(model_value.to_owned());
}
if let Some(owner) = string_field(
object,
&["owned_by", "provider", "organization", "family", "canonical_slug"],
) {
if owner != model_value && owner != label {
parts.push(owner);
}
}
if let Some(description) = string_field(object, &["description", "summary", "details"]) {
let description = truncate_single_line(&description, 88);
if !description.is_empty() {
parts.push(description);
}
}
if parts.is_empty() {
"provider model".to_owned()
} else {
parts.join(" · ")
}
}
#[cfg(test)]
mod tests {
use super::{
AuthSource, ConfigEditor, FieldId, Modal, models_endpoint, parse_model_response,
};
use crossterm::event::{KeyCode, KeyEvent};
use shinkai_translator::ThinkingMode;
use shinkai_translator::config::{AppConfig, LoadedConfig};
fn make_editor() -> ConfigEditor {
let temp_dir = tempfile::tempdir().expect("temp dir should exist");
let loaded = LoadedConfig {
path: temp_dir.path().join("config.toml"),
data: AppConfig::default(),
exists: false,
};
ConfigEditor::new(loaded)
}
#[test]
fn editor_updates_text_field_from_modal() {
let mut editor = make_editor();
editor.selected = editor.field_index(FieldId::ProviderModel);
editor
.handle_key(KeyEvent::from(KeyCode::Enter))
.expect("opening modal should succeed");
if let Some(Modal::Input(input)) = editor.modal.as_mut() {
input.value.clear();
input.cursor = 0;
}
editor.handle_paste("test-model");
editor
.handle_key(KeyEvent::from(KeyCode::Enter))
.expect("committing modal should succeed");
assert_eq!(editor.loaded_config.data.provider.model, "test-model");
}
#[test]
fn editor_updates_select_field_from_modal() {
let mut editor = make_editor();
editor.selected = editor.field_index(FieldId::ProviderThinkingMode);
editor
.handle_key(KeyEvent::from(KeyCode::Enter))
.expect("opening select should succeed");
editor.handle_paste("auto");
editor
.handle_key(KeyEvent::from(KeyCode::Enter))
.expect("committing select should succeed");
assert_eq!(editor.loaded_config.data.provider.thinking_mode, ThinkingMode::Auto);
}
#[test]
fn parse_model_response_sorts_and_deduplicates_ids() {
let report = parse_model_response(
"https://example.invalid/v1/models",
AuthSource::None,
r#"{"data":[{"id":"zeta"},{"id":"alpha","owned_by":"local"},{"id":"zeta"}]}"#,
)
.expect("model response should parse");
let ids = report
.models
.into_iter()
.map(|model| model.value)
.collect::<Vec<_>>();
assert_eq!(ids, vec!["alpha", "zeta"]);
}
#[test]
fn parse_model_response_supports_name_based_model_lists() {
let report = parse_model_response(
"http://localhost:11434/api/tags",
AuthSource::None,
r#"{"models":[{"name":"llama3.2:3b","model":"llama3.2:3b"},{"name":"qwen2.5:7b"}]}"#,
)
.expect("name-based model response should parse");
let ids = report
.models
.into_iter()
.map(|model| model.value)
.collect::<Vec<_>>();
assert_eq!(ids, vec!["llama3.2:3b", "qwen2.5:7b"]);
}
#[test]
fn parse_model_response_uses_human_name_as_label_when_available() {
let report = parse_model_response(
"https://openrouter.ai/api/v1/models",
AuthSource::None,
r#"{"data":[{"id":"openai/gpt-5.4-mini","name":"OpenAI: GPT-5.4 Mini","description":"Fast default model"}]}"#,
)
.expect("openrouter-style model response should parse");
assert_eq!(report.models[0].value, "openai/gpt-5.4-mini");
assert_eq!(report.models[0].label, "OpenAI: GPT-5.4 Mini");
assert!(report.models[0].detail.contains("openai/gpt-5.4-mini"));
}
#[test]
fn models_endpoint_strips_known_completion_suffixes() {
assert_eq!(
models_endpoint("https://example.invalid/v1/chat/completions"),
"https://example.invalid/v1/models"
);
assert_eq!(
models_endpoint("https://example.invalid/v1/models"),
"https://example.invalid/v1/models"
);
}
#[test]
fn provider_preset_options_includes_gemini() {
use super::PROVIDER_PRESET_OPTIONS;
let gemini_url = "https://generativelanguage.googleapis.com/v1beta/openai";
let has_gemini = PROVIDER_PRESET_OPTIONS.iter().any(|option| option.value == gemini_url);
assert!(has_gemini, "PROVIDER_PRESET_OPTIONS should contain a Gemini entry");
}
#[test]
fn models_endpoint_handles_gemini_base_url() {
assert_eq!(
models_endpoint("https://generativelanguage.googleapis.com/v1beta/openai"),
"https://generativelanguage.googleapis.com/v1beta/openai/models"
);
}
}