use std::collections::HashMap;
use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::layout::{Constraint, Flex, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
use uuid::Uuid;
use crate::entities::profile::Profile;
use crate::entities::sampling::{ReasoningEffort, SamplingConfig, Verbosity};
use crate::features::profiles::ProfileEdit;
use crate::features::tools::meta::{ToolGate, ToolInfo};
use crate::shared::config::{
AppConfig, AutoTitleMode, CloudProvider, CloudSettings, FlashAttn, ImpersonationMode,
ManagedSettings, McpServerConfig, MediaResolution, NoteOrder, PythonMode, SecretSlot,
ServerMode, SpecType, Theme, TtsCloudSettings, TtsMode,
};
use crate::shared::embed_prefix::EmbedConvention;
use crate::shared::i18n::Locale;
use crate::shared::keys;
use crate::shared::mcp::valid_server_id as valid_mcp_server_id;
use crate::shared::osc52::Osc52Mode;
use crate::shared::secrets::SecretKey;
use crate::shared::server::{ServerStatus, ServerStatuses};
use crate::shared::theme::Palette;
use crate::shared::ui::{ListScroll, dim_background, render_scrollbar, screen_chrome};
use crate::widgets::help_dialog::{HelpContext, HelpSection};
use crate::widgets::input_box::{InputBox, RenderOpts};
pub(crate) static HELP_SECTION: HelpSection = HelpSection {
title: "ui.help.sec.settings",
context: Some(HelpContext::Settings),
rows: &[
("Tab / Shift+Tab", "ui.help.set_sections"),
("↑/↓", "ui.help.set_rows"),
("Enter", "ui.help.set_enter"),
("←/→", "ui.help.set_cycle"),
("Space", "ui.help.set_toggle"),
("Del", "ui.help.set_reset"),
("Esc", "ui.help.set_back"),
("/", "ui.help.set_search"),
("Ctrl+Z / Ctrl+Y", "ui.help.set_undo"),
("Ctrl+R", "ui.help.set_refresh_models"),
("Ctrl+N", "ui.help.set_new"),
("Ctrl+D", "ui.help.set_delete"),
],
openers: &["/"],
};
#[derive(Debug, Clone, PartialEq)]
pub enum SettingsIntent {
Close,
Quit,
SaveConfig(Box<AppConfig>),
SaveProfile { id: Uuid, edit: Box<ProfileEdit> },
CreateProfile {
name: String,
system_message: String,
},
DeleteProfile(Uuid),
ConfirmMcpCatalog(String),
ReconnectMcpServer(String),
SetSecret {
key: crate::shared::secrets::SecretKey,
value: String,
},
ImportMcpServers(String),
ListModels(crate::shared::api::catalogue::ModelSlot),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Section {
Model,
Sampling,
Tools,
Plugins,
Memory,
Data,
Profiles,
Interface,
}
const SECTIONS: [Section; 8] = [
Section::Model,
Section::Sampling,
Section::Tools,
Section::Plugins,
Section::Memory,
Section::Data,
Section::Profiles,
Section::Interface,
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Subsection {
Assistant,
Impersonation,
}
const SUB_TAB_KEYS: [&str; 2] = ["ui.settings.tab.assistant", "ui.settings.tab.impersonation"];
impl Subsection {
fn label(self, loc: &Locale) -> String {
loc.t(SUB_TAB_KEYS[self as usize]).to_string()
}
fn toggled(self) -> Self {
match self {
Subsection::Assistant => Subsection::Impersonation,
Subsection::Impersonation => Subsection::Assistant,
}
}
const ALL: [Subsection; 2] = [Subsection::Assistant, Subsection::Impersonation];
fn from_index(i: usize) -> Self {
Self::ALL.get(i).copied().unwrap_or(Subsection::Assistant)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ModelTab {
Assistant,
Impersonation,
Embeddings,
Tts,
}
const MODEL_TAB_KEYS: [&str; 4] = [
"ui.settings.tab.assistant",
"ui.settings.tab.impersonation",
"ui.settings.tab.embeddings",
"ui.settings.tab.tts",
];
impl ModelTab {
fn label(self, loc: &Locale) -> String {
loc.t(MODEL_TAB_KEYS[self as usize]).to_string()
}
const ALL: [ModelTab; 4] = [
ModelTab::Assistant,
ModelTab::Impersonation,
ModelTab::Embeddings,
ModelTab::Tts,
];
fn cycle(self, dir: i32) -> Self {
let idx = self as i32;
let n = Self::ALL.len() as i32;
Self::ALL[(((idx + dir) % n + n) % n) as usize]
}
fn from_index(i: usize) -> Self {
Self::ALL.get(i).copied().unwrap_or(ModelTab::Assistant)
}
}
impl Section {
fn title(self, loc: &'static Locale) -> &'static str {
loc.t(match self {
Section::Model => "ui.settings.section.model",
Section::Sampling => "ui.settings.section.sampling",
Section::Tools => "ui.settings.section.tools",
Section::Plugins => "ui.settings.section.plugins",
Section::Memory => "ui.settings.section.memory",
Section::Data => "ui.settings.section.data",
Section::Profiles => "ui.settings.section.profiles",
Section::Interface => "ui.settings.section.interface",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NumKind {
Int,
Float,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SamplingParam {
Temp,
DynatempRange,
DynatempExp,
TopK,
TopP,
MinP,
TopNSigma,
TypicalP,
AdaptiveTarget,
AdaptiveDecay,
FreqPen,
PresPen,
RepeatPenalty,
RepeatLastN,
DryMultiplier,
DryBase,
DryAllowedLength,
DryPenaltyLastN,
DrySeqBreakers,
XtcProbability,
XtcThreshold,
Mirostat,
MirostatTau,
MirostatEta,
MaxTokens,
Seed,
Samplers,
Thinking,
Reasoning,
Verbosity,
}
const SAMPLING_PARAMS: &[SamplingParam] = {
use SamplingParam::*;
&[
Temp,
TopK,
TopP,
MaxTokens,
Seed,
DynatempRange,
DynatempExp,
MinP,
TopNSigma,
TypicalP,
AdaptiveTarget,
AdaptiveDecay,
XtcProbability,
XtcThreshold,
FreqPen,
PresPen,
RepeatPenalty,
RepeatLastN,
DryMultiplier,
DryBase,
DryAllowedLength,
DryPenaltyLastN,
DrySeqBreakers,
Mirostat,
MirostatTau,
MirostatEta,
Samplers,
Thinking,
Reasoning,
Verbosity,
]
};
impl SamplingParam {
fn json_name(self) -> &'static str {
use SamplingParam::*;
match self {
Temp => "temperature",
DynatempRange => "dynatemp_range",
DynatempExp => "dynatemp_exponent",
TopK => "top_k",
TopP => "top_p",
MinP => "min_p",
TopNSigma => "top_n_sigma",
TypicalP => "typical_p",
AdaptiveTarget => "adaptive_target",
AdaptiveDecay => "adaptive_decay",
FreqPen => "frequency_penalty",
PresPen => "presence_penalty",
RepeatPenalty => "repeat_penalty",
RepeatLastN => "repeat_last_n",
DryMultiplier => "dry_multiplier",
DryBase => "dry_base",
DryAllowedLength => "dry_allowed_length",
DryPenaltyLastN => "dry_penalty_last_n",
DrySeqBreakers => "dry_sequence_breakers",
XtcProbability => "xtc_probability",
XtcThreshold => "xtc_threshold",
Mirostat => "mirostat",
MirostatTau => "mirostat_tau",
MirostatEta => "mirostat_eta",
MaxTokens => "max_tokens",
Seed => "seed",
Samplers => "samplers",
Thinking => "thinking",
Reasoning => "reasoning_effort",
Verbosity => "verbosity",
}
}
fn label(self, loc: &'static Locale) -> &'static str {
use SamplingParam::*;
match self {
Temp => loc.t("ui.settings.sampling.temp"),
Thinking => loc.t("ui.settings.sampling.thinking"),
_ => self.json_name(),
}
}
fn field_name(self) -> &'static str {
self.json_name()
}
fn num_kind(self) -> Option<NumKind> {
use SamplingParam::*;
match self {
TopK | RepeatLastN | DryAllowedLength | DryPenaltyLastN | Mirostat | MaxTokens
| Seed => Some(NumKind::Int),
DrySeqBreakers | Samplers | Thinking | Reasoning | Verbosity => None,
_ => Some(NumKind::Float),
}
}
fn group(self, loc: &'static Locale) -> &'static str {
use SamplingParam::*;
match self {
Temp | TopK | TopP | MaxTokens | Seed => loc.t("ui.settings.sampling.group.basic"),
DynatempRange | DynatempExp => loc.t("ui.settings.sampling.group.dynatemp"),
MinP | TopNSigma | TypicalP | AdaptiveTarget | AdaptiveDecay | XtcProbability
| XtcThreshold => loc.t("ui.settings.sampling.group.diversity"),
FreqPen | PresPen | RepeatPenalty | RepeatLastN => {
loc.t("ui.settings.sampling.group.penalty")
}
DryMultiplier | DryBase | DryAllowedLength | DryPenaltyLastN | DrySeqBreakers => {
loc.t("ui.settings.sampling.group.dry")
}
Mirostat | MirostatTau | MirostatEta => "Mirostat",
Samplers => loc.t("ui.settings.sampling.group.samplers"),
Thinking | Reasoning | Verbosity => loc.t("ui.settings.sampling.group.reasoning"),
}
}
fn description(self, loc: &'static Locale) -> Option<&'static str> {
use SamplingParam::*;
Some(loc.t(match self {
Temp => "ui.settings.sampling.desc.temp",
TopK => "ui.settings.sampling.desc.topk",
TopP => "ui.settings.sampling.desc.topp",
FreqPen => "ui.settings.sampling.desc.freqpen",
PresPen => "ui.settings.sampling.desc.prespen",
DynatempRange => "ui.settings.sampling.desc.dynatemp_range",
DynatempExp => "ui.settings.sampling.desc.dynatemp_exp",
AdaptiveTarget => "ui.settings.sampling.desc.adaptive_target",
AdaptiveDecay => "ui.settings.sampling.desc.adaptive_decay",
DrySeqBreakers => "ui.settings.sampling.desc.dry_seq_breakers",
Samplers => "ui.settings.sampling.desc.samplers",
MinP => "ui.settings.sampling.desc.minp",
TopNSigma => "ui.settings.sampling.desc.top_n_sigma",
TypicalP => "ui.settings.sampling.desc.typical_p",
RepeatPenalty => "ui.settings.sampling.desc.repeat_penalty",
RepeatLastN => "ui.settings.sampling.desc.repeat_last_n",
DryMultiplier => "ui.settings.sampling.desc.dry_multiplier",
DryBase => "ui.settings.sampling.desc.dry_base",
DryAllowedLength => "ui.settings.sampling.desc.dry_allowed_length",
DryPenaltyLastN => "ui.settings.sampling.desc.dry_penalty_last_n",
XtcProbability => "ui.settings.sampling.desc.xtc_probability",
XtcThreshold => "ui.settings.sampling.desc.xtc_threshold",
Mirostat => "ui.settings.sampling.desc.mirostat",
MirostatTau => "ui.settings.sampling.desc.mirostat_tau",
MirostatEta => "ui.settings.sampling.desc.mirostat_eta",
Seed => "ui.settings.sampling.desc.seed",
MaxTokens => "ui.settings.sampling.desc.max_tokens",
Thinking => "ui.settings.sampling.desc.thinking",
Reasoning => "ui.settings.sampling.desc.reasoning",
Verbosity => "ui.settings.sampling.desc.verbosity",
}))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FieldId {
ModelSub,
SamplingSub,
ProfileSub,
XMode,
XUrl,
XBinary,
XModel,
XMmproj,
XModelName,
XApiKeyEnv,
XApiKey,
XNgl,
XBatch,
XCtx,
XFlashAttn,
XJinja,
XNoMmap,
XSpecType,
XDraftModel,
XDraftNgl,
XDraftNMax,
XDraftNMin,
XHost,
XPort,
XSessions,
XConcurrent,
IxMode,
IxUrl,
IxBinary,
IxModel,
IxMmproj,
IxModelName,
IxApiKeyEnv,
IxApiKey,
IxNgl,
IxBatch,
IxCtx,
IxFlashAttn,
IxJinja,
IxNoMmap,
IxSpecType,
IxDraftModel,
IxDraftNgl,
IxDraftNMax,
IxDraftNMin,
IxHost,
IxPort,
MaxToolRounds,
S(SamplingParam),
IS(SamplingParam),
TWeb,
TWebFetch,
TWebAllowPrivate,
TWebProvider,
TWebTavilyKey,
TWebTavilyKeyEnv,
TPython,
TPythonMode,
TPythonPath,
TPythonNet,
TPythonImages,
TPythonWasmTimeout,
TPythonWasmMemory,
TPythonLocalMemory,
VideoModel,
VideoResolution,
VideoMaxMinutes,
VideoApiKey,
VideoApiKeyEnv,
TFs,
TFsRoot,
WsTimeout,
WsOutput,
WsMaxRounds,
TMcpEnabled,
TMcpServer(usize),
McpSelect,
McpId,
McpCommand,
McpArgs,
McpEnv,
McpEnvSecret(usize),
McpEnvSource(usize),
McpImport,
McpEnabled,
McpTimeout,
McpMaxResult,
TSubMaxTokens,
TSubTimeout,
TSubParallel,
TSubBackground,
TSubBackgroundWake,
TSubBackgroundMax,
TDialogueTimeout,
TQuitSettle,
TConfirmDangerous,
TMcpImages,
EMode,
EUrl,
EBinary,
EModel,
EModelName,
EApiKeyEnv,
EApiKey,
EPort,
EConvention,
TtsMode,
TtsModelName,
TtsVoice,
TtsUserVoice,
TtsInstructions,
TtsSpeed,
TtsApiKey,
TtsApiKeyEnv,
TtsUrl,
TtsSpeakRoles,
TtsStopOnSwitch,
TtsStopOnGeneration,
BackupPassword,
CompactEnabled,
CompactWords,
CompactTail,
CompactThreshold,
CompactContext,
CompactPage,
RagTarget,
RagOverlap,
RagMax,
AttachMaxFile,
AttachMaxTotal,
AttachExcerpt,
AttachPage,
ImageMaxCount,
ImageMaxBytes,
ImageDownscale,
SmMaxNarrative,
SmNarrativeInPrompt,
SmPromptCap,
SmSummaryTarget,
SmAutoReflect,
SmAutoConsolidate,
SmProtocol,
NotesAutoConsolidate,
NotesRecallIncludesSelf,
ITheme,
IClipboardOsc52,
IAutoTitle,
ILanguage,
ICompat,
ITableSeparators,
IMermaid,
IModelName,
ISmNoteOrder,
ISpell,
IDicts,
IConfirmKeys,
ICopyThoughts,
ICopyToolCalls,
ICopyToolResults,
PSelect,
PName,
PLanguage,
PSystem,
PGreeting,
PUserName,
PAssistantName,
PImpProfile,
PTool(usize),
IpSelect,
IpName,
IpSystem,
}
enum FieldKind {
Toggle(bool),
Choice(String),
Text(String),
}
struct FieldRow {
id: FieldId,
label: String,
kind: FieldKind,
group: &'static str,
hint: Option<&'static str>,
description: Option<String>,
warn: bool,
warn_note: Option<String>,
}
impl FieldRow {
fn describe(mut self, d: impl Into<String>) -> Self {
self.description = Some(d.into());
self
}
}
struct Editor {
field: FieldId,
input: InputBox,
multiline: bool,
error: Option<&'static str>,
}
#[derive(PartialEq)]
enum Focus {
Menu,
Fields,
}
enum EditValue {
Config(Box<AppConfig>),
Profile(Box<Profile>),
}
struct EditStep {
before: EditValue,
field: Option<FieldId>,
}
struct PendingEdit {
config: AppConfig,
profiles: Vec<Profile>,
field: Option<FieldId>,
}
const UNDO_CAP: usize = 50;
struct SearchHit {
id: FieldId,
section_idx: usize,
subsection: Option<usize>,
field_idx: usize,
crumb: String,
value: String,
haystack: String,
}
struct SearchState {
input: InputBox,
scroll: ListScroll,
all: Vec<SearchHit>,
results: Vec<usize>,
selected: usize,
}
struct ChoiceState {
field: FieldId,
options: Vec<String>,
selected: usize,
scroll: ListScroll,
}
pub struct SettingsScreen {
config: AppConfig,
profiles: Vec<Profile>,
section_idx: usize,
field_idx: usize,
focus: Focus,
profile_idx: usize,
imp_profile_idx: usize,
mcp_server_idx: usize,
pending_profile_select: bool,
model_sub: ModelTab,
sampling_sub: Subsection,
profile_sub: Subsection,
editor: Option<Editor>,
search: Option<SearchState>,
choice: Option<ChoiceState>,
picker: Option<picker::PickerState>,
catalogues: Vec<(
crate::shared::api::catalogue::ModelSlot,
String,
crate::shared::api::catalogue::CatalogueAnswer,
)>,
asked: Vec<(crate::shared::api::catalogue::ModelSlot, String)>,
statuses: ServerStatuses,
engine_slots: Option<u32>,
engine_sampling_fields: Option<std::sync::Arc<[String]>>,
language_locked: Vec<uuid::Uuid>,
mcp: crate::features::tools::mcp::McpSnapshot,
secrets_present: Vec<crate::shared::secrets::SecretKey>,
mcp_import_result: Option<String>,
undo: Vec<EditStep>,
redo: Vec<EditStep>,
menu_scroll: ListScroll,
fields_scroll: ListScroll,
}
mod apply;
mod catalog;
mod choice;
mod helpers;
mod picker;
mod render;
mod search;
mod spec;
#[cfg(test)]
mod tests;