use crate::panel::Key;
use std::io;
use newt_core::BackendKind;
use crate::config_panel::{
clamp_step, command_line, hint_line, render_panel, status_line, Dial, ModelChoice, RowView,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum BackendSelection {
Named(String),
Kind(&'static str),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum BackendSource {
UserDropIn,
UserDropInOverInline,
Inline,
ShadowedByProject,
KindToggle,
}
impl BackendSource {
fn editable(self) -> bool {
matches!(self, Self::UserDropIn | Self::UserDropInOverInline)
}
fn provenance(self) -> &'static str {
match self {
Self::UserDropIn => "drop-in — e edits",
Self::UserDropInOverInline => "drop-in + inline entry",
Self::Inline => "inline config.toml",
Self::ShadowedByProject => "shadowed by project config",
Self::KindToggle => "session-only toggle",
}
}
fn describe(self) -> &'static str {
match self {
Self::UserDropIn => "~/.newt/backends/",
Self::UserDropInOverInline => "~/.newt/backends/ (shadowing an inline entry)",
Self::Inline => "config.toml",
Self::ShadowedByProject => "a project .newt/backends drop-in",
Self::KindToggle => "this session",
}
}
fn refusal(self, name: &str) -> String {
match self {
Self::UserDropIn | Self::UserDropInOverInline => String::new(),
Self::Inline => format!(
"'{name}' lives inline in config.toml — edit that file (the panel edits \
~/.newt/backends/ drop-ins)"
),
Self::ShadowedByProject => format!(
"'{name}' is shadowed by a project .newt/backends drop-in — edit that file \
(a change here would not take effect)"
),
Self::KindToggle => {
"the wire-kind toggles aren't editable — `a` adds a named backend".to_string()
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct BackendOption {
pub name: String,
pub selection: BackendSelection,
pub source: BackendSource,
pub kind: Option<BackendKind>,
pub endpoint: String,
pub model: Option<String>,
pub api_key_env: Option<String>,
pub api_key_file: Option<String>,
}
impl BackendOption {
pub(crate) fn kind_fallback(kind: &'static str) -> Self {
Self {
name: kind.to_string(),
selection: BackendSelection::Kind(kind),
source: BackendSource::KindToggle,
kind: None,
endpoint: String::new(),
model: None,
api_key_env: None,
api_key_file: None,
}
}
fn editable(&self) -> bool {
self.source.editable()
}
}
pub(crate) struct PanelSeed {
pub options: Vec<BackendOption>,
pub active: Option<usize>,
pub default_backend: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) struct DirtyFields {
pub kind: bool,
pub endpoint: bool,
pub model: bool,
pub api_key_env: bool,
pub api_key_file: bool,
}
impl DirtyFields {
fn all() -> Self {
Self {
kind: true,
endpoint: true,
model: true,
api_key_env: true,
api_key_file: true,
}
}
fn any(self) -> bool {
self.kind || self.endpoint || self.model || self.api_key_env || self.api_key_file
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct BackendEdit {
pub name: String,
pub kind: Option<BackendKind>,
pub endpoint: String,
pub model: Option<String>,
pub api_key_env: Option<String>,
pub api_key_file: Option<String>,
pub dirty: DirtyFields,
pub replace: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum BackendSaveResult {
Saved { note: String },
Failed(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct PanelClose {
pub apply: Option<BackendSelection>,
pub remove_after_apply: Option<String>,
pub changes: Vec<String>,
}
impl PanelClose {
pub(crate) fn cancelled() -> Self {
Self::default()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Field {
Name,
Kind,
Url,
Model,
KeyEnv,
KeyFile,
}
const FIELDS: [Field; 6] = [
Field::Name,
Field::Kind,
Field::Url,
Field::Model,
Field::KeyEnv,
Field::KeyFile,
];
const KIND_LADDER: [Option<BackendKind>; 4] = [
None,
Some(BackendKind::Ollama),
Some(BackendKind::Openai),
Some(BackendKind::Anthropic),
];
fn kind_label(kind: Option<BackendKind>) -> &'static str {
match kind {
None => "auto (probe)",
Some(k) => k.label(),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct FormState {
editing: Option<String>,
sel: usize,
name: String,
kind_idx: usize,
url: String,
model: String,
key_env: String,
key_file: String,
original: FormValues,
models: Option<Result<Vec<ModelChoice>, String>>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct FormValues {
kind_idx: usize,
url: String,
model: String,
key_env: String,
key_file: String,
}
impl FormState {
fn add() -> Self {
Self {
editing: None,
sel: 0,
name: String::new(),
kind_idx: 0,
url: String::new(),
model: String::new(),
key_env: String::new(),
key_file: String::new(),
original: FormValues::default(),
models: None,
}
}
fn edit(opt: &BackendOption) -> Self {
let kind_idx = KIND_LADDER.iter().position(|k| *k == opt.kind).unwrap_or(0);
Self {
editing: Some(opt.name.clone()),
sel: 0,
name: opt.name.clone(),
kind_idx,
url: opt.endpoint.clone(),
model: opt.model.clone().unwrap_or_default(),
key_env: opt.api_key_env.clone().unwrap_or_default(),
key_file: opt.api_key_file.clone().unwrap_or_default(),
models: None,
original: FormValues {
kind_idx,
url: opt.endpoint.clone(),
model: opt.model.clone().unwrap_or_default(),
key_env: opt.api_key_env.clone().unwrap_or_default(),
key_file: opt.api_key_file.clone().unwrap_or_default(),
},
}
}
fn kind(&self) -> Option<BackendKind> {
KIND_LADDER.get(self.kind_idx).copied().flatten()
}
fn dirty(&self) -> DirtyFields {
if self.editing.is_none() {
return DirtyFields::all();
}
let changed = |now: &str, before: &str| now.trim() != before.trim();
DirtyFields {
kind: self.kind_idx != self.original.kind_idx,
endpoint: changed(&self.url, &self.original.url),
model: changed(&self.model, &self.original.model),
api_key_env: changed(&self.key_env, &self.original.key_env),
api_key_file: changed(&self.key_file, &self.original.key_file),
}
}
fn field_mut(&mut self) -> Option<&mut String> {
match FIELDS[self.sel] {
Field::Name => Some(&mut self.name),
Field::Kind => None,
Field::Url => Some(&mut self.url),
Field::Model => Some(&mut self.model),
Field::KeyEnv => Some(&mut self.key_env),
Field::KeyFile => Some(&mut self.key_file),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Mode {
Choose,
Command(String),
Form(Box<FormState>),
Confirm(Confirm),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Confirm {
prompt: String,
action: Pending,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Pending {
RemoveBackend(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PanelState {
options: Vec<BackendOption>,
active: Option<usize>,
default_backend: Option<String>,
pick: Dial<usize>,
mode: Mode,
status: Option<String>,
changes: Vec<String>,
pending_remove: Option<String>,
}
impl PanelState {
pub(crate) fn new(seed: PanelSeed) -> Self {
let PanelSeed {
options,
active,
default_backend,
} = seed;
let pick = active.unwrap_or(0).min(options.len().saturating_sub(1));
Self {
options,
active,
default_backend,
pick: Dial::Inherit(pick),
mode: Mode::Choose,
status: None,
changes: Vec::new(),
pending_remove: None,
}
}
fn selected(&self) -> &BackendOption {
&self.options[self.pick.value()]
}
fn in_command(&self) -> bool {
matches!(self.mode, Mode::Command(_))
}
fn in_form(&self) -> bool {
matches!(self.mode, Mode::Form(_))
}
fn named_index(&self, name: &str) -> Option<usize> {
self.options
.iter()
.position(|o| matches!(&o.selection, BackendSelection::Named(n) if n == name))
}
pub(crate) fn cycle(&mut self, dir: i32) {
self.status = None;
self.pick
.set(clamp_step(self.pick.value(), dir, self.options.len()));
}
pub(crate) fn is_noop(&self) -> bool {
!self.pick.is_dirty()
}
pub(crate) fn begin_edit(&mut self) {
self.status = None;
let opt = self.selected().clone();
if !opt.editable() {
self.status = Some(opt.source.refusal(&opt.name));
} else if !KIND_LADDER.contains(&opt.kind) {
let label = kind_label(opt.kind);
let name = &opt.name;
self.status = Some(format!(
"'{name}' is a {label} backend — the panel edits http backends only; \
edit ~/.newt/backends/{name}.toml"
));
} else {
self.mode = Mode::Form(Box::new(FormState::edit(&opt)));
}
}
pub(crate) fn begin_add(&mut self) {
self.status = None;
self.mode = Mode::Form(Box::new(FormState::add()));
}
pub(crate) fn begin_remove(&mut self) {
self.status = None;
let BackendSelection::Named(name) = &self.selected().selection else {
self.status = Some("select a named backend to remove".into());
return;
};
let name = name.clone();
if let Some(refusal) = self.remove_refusal(&name) {
self.status = Some(refusal);
return;
}
self.mode = Mode::Confirm(Confirm {
prompt: self.remove_prompt(&name),
action: Pending::RemoveBackend(name),
});
}
fn remove_prompt(&self, name: &str) -> String {
let where_from = self
.named_index(name)
.map_or("configuration", |i| self.options[i].source.describe());
format!("delete backend '{name}' from {where_from}? this cannot be undone [y/N]")
}
fn answer_confirm(
&mut self,
yes: bool,
remove: &mut dyn FnMut(&str) -> Result<String, String>,
) -> Option<bool> {
let Mode::Confirm(confirm) = &self.mode else {
return None;
};
let action = confirm.action.clone();
self.mode = Mode::Choose;
if !yes {
self.status = Some("cancelled — nothing was deleted".to_string());
return None;
}
match action {
Pending::RemoveBackend(name) => self.remove_command(Some(&name), remove),
}
}
pub(crate) fn in_confirm(&self) -> bool {
matches!(self.mode, Mode::Confirm(_))
}
pub(crate) fn begin_command(&mut self, prefill: &str) {
self.status = None;
self.mode = Mode::Command(prefill.to_string());
}
fn command_char(&mut self, c: char) {
if let Mode::Command(buf) = &mut self.mode {
buf.push(c);
}
}
fn command_backspace(&mut self) {
if let Mode::Command(buf) = &mut self.mode {
buf.pop();
}
}
fn cancel_command(&mut self) {
self.mode = Mode::Choose;
}
fn form_nav(&mut self, dir: i32) {
self.status = None;
if let Mode::Form(form) = &mut self.mode {
let n = FIELDS.len() as i32;
form.sel = ((form.sel as i32 + dir).rem_euclid(n)) as usize;
}
}
fn form_cycle(&mut self, dir: i32) {
self.status = None;
if let Mode::Form(form) = &mut self.mode {
if FIELDS[form.sel] == Field::Kind {
form.kind_idx = clamp_step(form.kind_idx, dir, KIND_LADDER.len());
form.models = None;
} else if FIELDS[form.sel] == Field::Model {
if let Some(Ok(models)) = &form.models {
let at = if form.model.is_empty() {
Some(0)
} else {
models
.iter()
.position(|m| m.name == form.model)
.map(|i| i + 1)
};
let count = models.len() + 1;
let next = at.map_or_else(
|| if dir < 0 { count - 1 } else { 0 },
|i| (i as i32 + dir).rem_euclid(count as i32) as usize,
);
form.model = next
.checked_sub(1)
.map_or_else(String::new, |i| models[i].name.clone());
} else if let Some(Err(error)) = &form.models {
self.status = Some(format!("Cannot list models: {error}; r retries"));
}
}
}
}
fn form_input(&mut self, c: char) {
let Mode::Form(form) = &mut self.mode else {
return;
};
if FIELDS[form.sel] == Field::Name && form.editing.is_some() {
self.status = Some(
"the name is fixed while editing — `a` add under the new name, then :d the old"
.into(),
);
return;
}
if matches!(
FIELDS[form.sel],
Field::Url | Field::KeyEnv | Field::KeyFile
) {
form.models = None;
}
if let Some(field) = form.field_mut() {
if !c.is_control() {
field.push(c);
self.status = None;
}
}
}
fn form_backspace(&mut self) {
if let Mode::Form(form) = &mut self.mode {
if matches!(
FIELDS[form.sel],
Field::Url | Field::KeyEnv | Field::KeyFile
) {
form.models = None;
}
if !(FIELDS[form.sel] == Field::Name && form.editing.is_some()) {
if let Some(field) = form.field_mut() {
field.pop();
}
}
}
}
fn cancel_form(&mut self) {
self.status = None;
self.mode = Mode::Choose;
}
pub(crate) fn submit_form(
&mut self,
persist: &mut dyn FnMut(&BackendEdit) -> BackendSaveResult,
) -> bool {
let Mode::Form(form) = &self.mode else {
return false;
};
let edit = match validate_form(form, &self.options) {
Ok(edit) => edit,
Err(msg) => {
self.status = Some(msg);
return false;
}
};
if edit.replace && !edit.dirty.any() {
self.status = Some(format!("no changes to backend '{}'", edit.name));
self.mode = Mode::Choose;
return false;
}
let source = self
.named_index(&edit.name)
.filter(|_| edit.replace)
.map_or(BackendSource::UserDropIn, |i| self.options[i].source);
match persist(&edit) {
BackendSaveResult::Saved { note } => {
let opt = BackendOption {
name: edit.name.clone(),
selection: BackendSelection::Named(edit.name.clone()),
source,
kind: edit.kind,
endpoint: edit.endpoint.clone(),
model: edit.model.clone(),
api_key_env: edit.api_key_env.clone(),
api_key_file: edit.api_key_file.clone(),
};
match self.named_index(&edit.name) {
Some(i) if edit.replace => self.options[i] = opt,
_ => self.insert_named_option(opt),
}
self.changes.push(note);
self.status = Some(format!("saved backend '{}'", edit.name));
self.mode = Mode::Choose;
true
}
BackendSaveResult::Failed(err) => {
self.status = Some(format!("save failed: {err}"));
false
}
}
}
fn insert_named_option(&mut self, opt: BackendOption) {
let idx = self
.options
.iter()
.position(|o| matches!(o.selection, BackendSelection::Kind(_)))
.unwrap_or(self.options.len());
self.options.insert(idx, opt);
if let Some(a) = &mut self.active {
if *a >= idx {
*a += 1;
}
}
let cur = self.pick.value();
if cur >= idx {
self.reposition(cur + 1);
}
}
fn remove_option(&mut self, idx: usize) {
self.options.remove(idx);
self.active = match self.active {
Some(a) if a == idx => None,
Some(a) if a > idx => Some(a - 1),
other => other,
};
let cur = self.pick.value();
if cur == idx {
self.pick = Dial::Inherit(self.active.unwrap_or(0));
} else if cur > idx {
self.reposition(cur - 1);
}
}
fn reposition(&mut self, to: usize) {
self.pick = match self.pick {
Dial::Inherit(_) => Dial::Inherit(to),
Dial::Set(_) => Dial::Set(to),
};
}
pub(crate) fn run_command(
&mut self,
remove: &mut dyn FnMut(&str) -> Result<String, String>,
) -> Option<bool> {
let cmd = match &self.mode {
Mode::Command(buf) => buf.trim().to_string(),
_ => return None,
};
self.mode = Mode::Choose;
let mut it = cmd.split_whitespace();
let verb = it.next().unwrap_or("");
let arg = it.next();
match verb {
"" => None,
"q" => Some(false),
"d" => self.remove_command(arg, remove),
other => {
self.status = Some(format!("unknown command ':{other}' (:d <name> | :q)"));
None
}
}
}
fn remove_refusal(&self, name: &str) -> Option<String> {
let Some(idx) = self.named_index(name) else {
return Some(format!("no configured backend named '{name}'"));
};
if !self.options[idx].editable() {
return Some(self.options[idx].source.refusal(name));
}
let is_active = self.active == Some(idx);
let is_default = self.default_backend.as_deref() == Some(name);
if (is_active || is_default) && !self.picked_other_named(idx) {
let role = match (is_active, is_default) {
(true, true) => "the active backend and config.toml's default_backend",
(true, false) => "the active backend",
_ => "config.toml's default_backend",
};
return Some(format!(
"'{name}' is {role} — dial another named backend first; then :d {name} \
switches and removes it in one transaction"
));
}
None
}
fn picked_other_named(&self, idx: usize) -> bool {
self.pick.is_dirty()
&& self.pick.value() != idx
&& matches!(
self.options[self.pick.value()].selection,
BackendSelection::Named(_)
)
}
fn remove_command(
&mut self,
name: Option<&str>,
remove: &mut dyn FnMut(&str) -> Result<String, String>,
) -> Option<bool> {
let Some(name) = name.map(str::trim).filter(|s| !s.is_empty()) else {
self.status = Some("remove needs a name: :d <name>".to_string());
return None;
};
if let Some(refusal) = self.remove_refusal(name) {
self.status = Some(refusal);
return None;
}
let Some(idx) = self.named_index(name) else {
self.status = Some(format!("no configured backend named '{name}'"));
return None;
};
if self.active == Some(idx) || self.default_backend.as_deref() == Some(name) {
self.pending_remove = Some(name.to_string());
return Some(true);
}
match remove(name) {
Ok(note) => {
self.remove_option(idx);
self.changes.push(note);
self.status = Some(format!("removed backend '{name}'"));
None
}
Err(err) => {
self.status = Some(format!("remove failed: {err}"));
None
}
}
}
fn pick_label(&self) -> String {
let opt = self.selected();
let base = match &opt.selection {
BackendSelection::Named(n) => n.clone(),
BackendSelection::Kind(k) => format!("{k} — wire kind"),
};
if Some(self.pick.value()) == self.active {
format!("{base} (active)")
} else if self.pick.is_dirty() {
format!("{base} (pending)")
} else {
base
}
}
fn view_rows(&self) -> Vec<RowView> {
match &self.mode {
Mode::Form(form) => form_rows(form),
_ => self.chooser_rows(),
}
}
fn chooser_rows(&self) -> Vec<RowView> {
let opt = self.selected();
let is_kind = matches!(opt.selection, BackendSelection::Kind(_));
let mut rows = vec![RowView {
label: "backend",
value: self.pick_label(),
provenance: opt.source.provenance().to_string(),
selected: true,
editable: true,
}];
if is_kind {
rows.push(detail_row("kind", opt.name.clone()));
rows.push(detail_row(
"",
format!("forces the {} wire protocol for this session", opt.name),
));
rows.push(detail_row("", String::new()));
rows.push(detail_row("", String::new()));
} else {
rows.push(detail_row("kind", kind_label(opt.kind).to_string()));
rows.push(detail_row(
"model",
opt.model
.clone()
.unwrap_or_else(|| "(server decides)".to_string()),
));
rows.push(detail_row("url", opt.endpoint.clone()));
rows.push(detail_row(
"auth",
auth_label(opt.api_key_env.as_deref(), opt.api_key_file.as_deref()),
));
}
rows
}
}
fn detail_row(label: &'static str, value: String) -> RowView {
RowView {
label,
value,
provenance: String::new(),
selected: false,
editable: false,
}
}
fn auth_label(env: Option<&str>, file: Option<&str>) -> String {
match (env, file) {
(Some(e), Some(f)) => format!("env {e} · file {f}"),
(Some(e), None) => format!("env {e}"),
(None, Some(f)) => format!("file {f}"),
(None, None) => "—".to_string(),
}
}
fn form_rows(form: &FormState) -> Vec<RowView> {
let cursor = |sel: bool, s: &str| {
if sel {
format!("{s}▏")
} else {
s.to_string()
}
};
FIELDS
.iter()
.enumerate()
.map(|(i, field)| {
let sel = form.sel == i;
let (label, value) = match field {
Field::Name => (
"name",
if form.editing.is_some() {
format!("{} (fixed)", form.name)
} else {
cursor(sel, &form.name)
},
),
Field::Kind => ("kind", kind_label(form.kind()).to_string()),
Field::Url => ("url", cursor(sel, &form.url)),
Field::Model => (
"model",
if form.model.is_empty() {
"(server default)".into()
} else {
form.model.clone()
},
),
Field::KeyEnv => ("key env", cursor(sel, &form.key_env)),
Field::KeyFile => ("key file", cursor(sel, &form.key_file)),
};
RowView {
label,
value,
provenance: String::new(),
selected: sel,
editable: !(*field == Field::Name && form.editing.is_some()),
}
})
.collect()
}
fn valid_name_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')
}
fn none_if_empty(s: &str) -> Option<String> {
let s = s.trim();
if s.is_empty() {
None
} else {
Some(s.to_string())
}
}
fn validate_form(form: &FormState, options: &[BackendOption]) -> Result<BackendEdit, String> {
let name = form.name.trim().to_string();
if name.is_empty() {
return Err("backend needs a name".to_string());
}
if !name.chars().all(valid_name_char) {
return Err("name may use letters, digits, '.', '-', '_' only".to_string());
}
if form.editing.is_none()
&& options
.iter()
.any(|o| matches!(&o.selection, BackendSelection::Named(n) if *n == name))
{
return Err(format!("'{name}' already exists — select it and press e"));
}
let url = form.url.trim().to_string();
let dirty = form.dirty();
if dirty.endpoint {
if url.is_empty() {
return Err("backend needs a url (e.g. http://host:11434)".to_string());
}
match reqwest::Url::parse(&url) {
Ok(parsed) if matches!(parsed.scheme(), "http" | "https") => {}
_ => return Err(format!("invalid url '{url}' — needs http:// or https://")),
}
}
let key_env = form.key_env.trim();
if key_env.chars().any(|c| c.is_whitespace() || c == '=') {
return Err("api-key env must be a bare variable name (e.g. OPENAI_API_KEY)".to_string());
}
Ok(BackendEdit {
name,
kind: form.kind(),
endpoint: url,
model: none_if_empty(&form.model),
api_key_env: none_if_empty(&form.key_env),
api_key_file: none_if_empty(&form.key_file),
dirty,
replace: form.editing.is_some(),
})
}
pub(crate) fn saved_note(name: &str, path: &std::path::Path, also_inline: bool) -> String {
let mut note = format!("saved backend '{name}' → {}", path.display());
if also_inline {
note.push_str(&format!(
" (note: config.toml also declares [[backends]] '{name}' — fields this drop-in \
omits, including a cleared api-key, are re-inherited from it; edit that entry too)"
));
}
note
}
pub(crate) const PANEL_HEIGHT: u16 = 10;
fn draw(f: &mut ratatui::Frame, state: &PanelState) {
let title = match &state.mode {
Mode::Form(form) if form.editing.is_some() => " backend — edit ",
Mode::Form(_) => " backend — add ",
Mode::Confirm(_) => " backend — confirm delete ",
_ => " backend — chooser ",
};
let bottom = if let Mode::Confirm(confirm) = &state.mode {
status_line(&confirm.prompt)
} else if let Mode::Command(buf) = &state.mode {
command_line(buf)
} else if let Some(status) = &state.status {
status_line(status)
} else if matches!(state.mode, Mode::Form(_)) {
hint_line("↑↓ field · ←→ kind/model · r refresh models · Enter save · Esc back")
} else {
hint_line("←→ choose · ↓ edit · Enter apply · e edit · a add · d remove · Esc cancel")
};
render_panel(f, title, &state.view_rows(), bottom, 9, 34);
}
fn close_outcome(applied: bool, state: &PanelState) -> PanelClose {
let apply = if applied && !state.is_noop() {
Some(state.selected().selection.clone())
} else {
None
};
let remove_after_apply = match (&apply, &state.pending_remove) {
(Some(BackendSelection::Named(new)), Some(old)) if new != old => Some(old.clone()),
_ => None,
};
PanelClose {
apply,
remove_after_apply,
changes: state.changes.clone(),
}
}
#[derive(Debug)]
pub(crate) struct PanelRunError {
pub error: io::Error,
pub close: PanelClose,
}
impl From<io::Error> for PanelRunError {
fn from(error: io::Error) -> Self {
Self {
error,
close: PanelClose::cancelled(),
}
}
}
fn finish(
loop_result: io::Result<()>,
applied: bool,
state: &PanelState,
) -> Result<PanelClose, PanelRunError> {
match loop_result {
Ok(()) => Ok(close_outcome(applied, state)),
Err(error) => Err(PanelRunError {
error,
close: close_outcome(false, state),
}),
}
}
pub(crate) fn run(
seed: PanelSeed,
persist: impl FnMut(&BackendEdit) -> BackendSaveResult,
remove: impl FnMut(&str) -> Result<String, String>,
window: Option<crate::session_worker::PanelWindow>,
mut fetch_models: impl FnMut(&BackendEdit) -> Result<Vec<ModelChoice>, String>,
) -> Result<PanelClose, PanelRunError> {
if seed.options.is_empty() {
return Ok(PanelClose::cancelled());
}
let mut screen = BackendScreen {
state: PanelState::new(seed),
persist,
remove,
};
loop {
let driven = crate::panel::drive(&mut screen, PANEL_HEIGHT, window.as_ref());
if matches!(driven, Ok(false)) {
if let Mode::Form(form) = &mut screen.state.mode {
if FIELDS[form.sel] == Field::Model && form.models.is_none() {
eprintln!("Fetching models from {}…", form.url);
let result = validate_form(form, &screen.state.options)
.and_then(|edit| fetch_models(&edit));
screen.state.status = Some(match &result {
Ok(models) if models.is_empty() => {
"Server returned no models; saved model retained".into()
}
Ok(models) => format!(
"{} models · ←→ select · /model manages loaded models",
models.len()
),
Err(error) => format!("Cannot list models: {error}; saved model retained"),
});
form.models = Some(result);
continue;
}
}
}
return match driven {
Ok(applied) => finish(Ok(()), applied, &screen.state),
Err(error) => finish(Err(error), false, &screen.state),
};
}
}
struct BackendScreen<P, R>
where
P: FnMut(&BackendEdit) -> BackendSaveResult,
R: FnMut(&str) -> Result<String, String>,
{
state: PanelState,
persist: P,
remove: R,
}
impl<P, R> crate::panel::Screen for BackendScreen<P, R>
where
P: FnMut(&BackendEdit) -> BackendSaveResult,
R: FnMut(&str) -> Result<String, String>,
{
fn draw(&self, frame: &mut ratatui::Frame) {
draw(frame, &self.state);
}
fn key(&mut self, key: Key) -> crate::panel::Flow {
use crate::panel::Flow;
if self.state.in_confirm() {
match key {
Key::Char('y') | Key::Char('Y') => {
if let Some(apply) = self.state.answer_confirm(true, &mut self.remove) {
return Flow::Close(apply);
}
}
_ => {
self.state.answer_confirm(false, &mut self.remove);
}
}
return Flow::Stay;
}
if self.state.in_command() {
match key {
Key::Char(c) => self.state.command_char(c),
Key::Backspace => self.state.command_backspace(),
Key::Esc => self.state.cancel_command(),
Key::Enter => {
if let Some(apply) = self.state.run_command(&mut self.remove) {
return Flow::Close(apply);
}
}
_ => {}
}
return Flow::Stay;
}
if self.state.in_form() {
match key {
Key::Char('r') if matches!(&self.state.mode, Mode::Form(f) if FIELDS[f.sel] == Field::Model) => {
if let Mode::Form(form) = &mut self.state.mode {
form.models = None;
}
}
Key::Up => self.state.form_nav(-1),
Key::Down => self.state.form_nav(1),
Key::Left => self.state.form_cycle(-1),
Key::Right => self.state.form_cycle(1),
Key::Backspace if !matches!(&self.state.mode, Mode::Form(f) if FIELDS[f.sel] == Field::Model) =>
{
self.state.form_backspace();
}
Key::Enter => {
self.state.submit_form(&mut self.persist);
}
Key::Esc => self.state.cancel_form(),
Key::Char(c) if !matches!(&self.state.mode, Mode::Form(f) if FIELDS[f.sel] == Field::Model) =>
{
self.state.form_input(c);
}
_ => {}
}
if matches!(&self.state.mode, Mode::Form(f) if FIELDS[f.sel] == Field::Model && f.models.is_none())
{
return Flow::Close(false);
}
return Flow::Stay;
}
match key {
Key::Left => self.state.cycle(-1),
Key::Right => self.state.cycle(1),
Key::Down | Key::Char('e') => self.state.begin_edit(),
Key::Char('a') => self.state.begin_add(),
Key::Char('d') => self.state.begin_remove(),
Key::Char(':') => self.state.begin_command(""),
Key::Enter => return Flow::Close(true),
Key::Esc | Key::Char('q') => return Flow::Close(false),
_ => {}
}
Flow::Stay
}
}
#[cfg(test)]
#[path = "backend_panel_tests/mod.rs"]
mod backend_panel_tests;