Skip to main content

qcode/ui/providers/
mod.rs

1//! The providers screen: the model services a person added, what each one offers, and the two
2//! context figures that are never the same number.
3//!
4//! Three things make this screen what it is.
5//!
6//! **Nothing goes out unasked.** Opening the page reads a file and touches no network. Every
7//! request leaves only because the person pressed the thing that sends it, and the address it
8//! will go to stands on the page above that button, before it goes.
9//!
10//! **The key file is named out loud.** Not in a footnote: a line of its own says where the key
11//! is written and that a backup of the home folder carries it in plain text, so that nobody
12//! shares a backup and their key together without knowing.
13//!
14//! **Both windows are shown.** What the model's own record claims and what this server was
15//! measured giving, side by side, because they are often an order of magnitude apart and a
16//! coding agent whose instructions are silently thrown away looks stupid for no visible reason.
17//! When the measured window is small the page says so plainly and says what the person can do
18//! about it on their own machine — guidance QCode prints and never something QCode does to them.
19
20mod draft;
21mod lineup;
22#[cfg(test)]
23mod tests;
24mod tried;
25
26use std::path::PathBuf;
27
28use qframe::diagnostics::Diagnostic;
29use qframe::prelude::*;
30use qframe::widgets::{EmptyState, Field, Modal, RadioGroup, TextInput};
31
32use crate::provider::{
33    Ask, AskError, CRAMPED, Key, Measured, Model, PermissionProblem, Price, ProviderEntry, ProviderKind,
34    Providers as ProviderFile, Reached, Web, Wire, ask, permission_problems,
35};
36use crate::store::Loaded;
37use crate::ui::page;
38
39pub use draft::{BASE_FIELD, Draft, KEY_FIELD, Problem, TAG_FIELD};
40pub use lineup::{Editor, Lineups, Refusal};
41
42use lineup::{FILTER_FIELD, LIST as LINEUPS, MODELS as LINEUP_MODELS, NAME_FIELD, STEPS as LINEUP_STEPS};
43use tried::{Reason, TRIED, Verdict};
44
45/// Width of the add dialog, and of the lineups dialog beside it. Wide enough for an address to
46/// read as one line, the longest being a ready-made service's API root with the name of its shape
47/// in front of it, and for a model's own name in a list of them.
48const DIALOG_WIDTH: u16 = 72;
49
50/// The name of the list of providers, for the focus and the tests.
51const LIST: &str = "providers";
52
53/// The name of the list of models.
54const MODELS: &str = "provider-models";
55
56/// The rows the lineups dialog spends on its own frame: the title and the blank row under it, the
57/// row of buttons, the row of hints under them, the air above and below, and the row the
58/// framework leaves either side of the body. Inside a layer the view reports the size of the whole
59/// screen, so this and the rows below are measured against what the terminal is, not against what
60/// the dialog is given of it.
61const DIALOG_FRAME: u16 = 8;
62
63/// Rows the editor spends on everything that is not the list of the provider's models: the name
64/// with its label and the line of help under it, the filter with its label, the lineup's own
65/// steps, the two lines a warning about a price or a refusal takes, and the blank row between
66/// each pair. A dialog is measured to what is inside it, so a list left to itself is as tall as
67/// the one or two models in it and there is nothing to scroll in. The steps are counted here at
68/// the two rows they take on the shortest terminal, and the rows a taller screen has over and
69/// above those two are the steps' to grow into, the models having stopped at their own most.
70const ROWS_AROUND_MODELS: u16 = 13;
71
72/// Fewest rows the list of the provider's models takes: a list of one row is no list to move
73/// through, and on the shortest terminal there is room for nothing more.
74const MODEL_ROWS_MIN: u16 = 2;
75
76/// Most: a list taller than this is further from the steps under it than the eye follows in one
77/// go, and the rows a tall terminal has spare are better spent on the order than on more of the
78/// hundred models the filter is there to find.
79const MODEL_ROWS_MAX: u16 = 12;
80
81/// Fewest rows the lineup's own steps take, and what they take wherever the models list has been
82/// given every row there was. An order is a handful of models: the list grows and scrolls rather
83/// than taking the row of buttons away.
84const STEP_ROWS: u16 = 2;
85
86/// Most: an order is a handful of models, so a list of steps taller than this is taller than any
87/// lineup there is, and the rows beyond it are better left to the page behind. The steps are
88/// given their rows only out of what the models list left over, so nothing above them is cut
89/// short to reach this many.
90const STEP_ROWS_MAX: u16 = 8;
91
92/// The rows of the screen the editor's two lists share between them once the dialog's own rows
93/// are off it, before either list has been given what it may take of them. Both lists are
94/// measured against this, so their rows together come to no more than the screen holds.
95fn rows_for_the_two_lists(ui: &View<'_, Msg>) -> u16 {
96    ui.size().height.saturating_sub(DIALOG_FRAME + ROWS_AROUND_MODELS)
97}
98
99/// The rows the list of the provider's models takes. The dialog's own rows come off the screen
100/// first, so however many models a provider offers, and however short the terminal is, the
101/// buttons under them stay where a hand can reach them.
102fn model_rows(ui: &View<'_, Msg>) -> u16 {
103    rows_for_the_two_lists(ui).clamp(MODEL_ROWS_MIN, MODEL_ROWS_MAX)
104}
105
106/// The rows the lineup's own steps take. They begin at the two the shortest terminal can spare,
107/// where an order is read two steps at a time, and grow into what the models list left over: a
108/// lineup of six steps is not read at all through two rows of it, and the rows a tall terminal
109/// has spare say more of the order rather than more of the hundred models above it.
110fn step_rows(ui: &View<'_, Msg>) -> u16 {
111    let spare = rows_for_the_two_lists(ui);
112    (STEP_ROWS + spare.saturating_sub(model_rows(ui))).clamp(STEP_ROWS, STEP_ROWS_MAX)
113}
114
115/// What the screen is waiting for, so a button that has been pressed shows the work and takes no
116/// second press.
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum Busy {
119    /// The connection is being tried.
120    Trying,
121    /// The provider is being asked what it offers.
122    Listing,
123    /// A model's real window is being measured.
124    Measuring,
125}
126
127/// The last thing that happened, kept as a value so that the view is the only place that turns
128/// it into a sentence.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub enum Notice {
131    /// The connection was tried and something answered.
132    Reached(Reached),
133    /// A request did not get an answer it could use.
134    Trouble(AskError),
135    /// The providers file could not be written; the text is what the file system said.
136    NotWritten(String),
137}
138
139/// Everything that can happen on the providers screen.
140#[derive(Debug, Clone)]
141pub enum Msg {
142    /// Read the providers file again. This is also how the screen is started.
143    Reload,
144    /// The file was read, and how wide its permissions and its folder's are was looked at.
145    Loaded(Box<Loaded<ProviderFile>>, Vec<PermissionProblem>),
146    /// The selection moved to a provider.
147    Select(usize),
148    /// The selection moved to a model.
149    PickModel(usize),
150    /// Open the add dialog.
151    New,
152    /// Leave the add dialog, keeping nothing.
153    Cancel,
154    /// The tag was typed.
155    Tag(String),
156    /// The address was typed.
157    Base(String),
158    /// The key was pasted.
159    PasteKey(String),
160    /// A kind was chosen.
161    PickKind(usize),
162    /// Where a ready-made provider answers was chosen, by its place among its kind's regions.
163    PickRegion(usize),
164    /// Add the provider the dialog describes.
165    Add,
166    /// Deleting the chosen provider was asked for.
167    DeleteAsked,
168    /// The question was answered with yes.
169    DeleteConfirmed,
170    /// Forget the chosen provider's key, keeping the provider.
171    ForgetKey,
172    /// Try the connection to the chosen provider. Nothing else on this screen sends a request.
173    Try,
174    /// The trial answered.
175    Tried(Box<Result<Reached, AskError>>),
176    /// Ask the chosen provider what it offers.
177    Refresh,
178    /// The provider answered with what it offers.
179    Listed(Box<Result<Vec<Model>, AskError>>),
180    /// Measure what the chosen model really gets on this server.
181    MeasureAsked,
182    /// The measurement finished, for the model of that name.
183    Measured(String, Box<Result<Measured, AskError>>),
184    /// Open the lineups of the chosen provider.
185    LineupsAsked,
186    /// The lineups dialog was closed, keeping whatever was already saved.
187    LineupsClosed,
188    /// A lineup of the list was chosen.
189    LineupChosen(usize),
190    /// Start a lineup that is not there yet.
191    LineupNew,
192    /// Start the chosen lineup for changing.
193    LineupEditAsked,
194    /// Deleting the chosen lineup was asked for.
195    LineupDeleteAsked,
196    /// The question was answered with yes.
197    LineupDeleteConfirmed,
198    /// The name of the lineup being written was typed.
199    LineupName(String),
200    /// The provider's models were filtered.
201    LineupFilter(String),
202    /// A model of the list was pointed at.
203    LineupLooked(String),
204    /// The model at that place of the filtered list was put at the end of the lineup.
205    LineupAdded(usize),
206    /// A step of the lineup was chosen.
207    LineupStep(usize),
208    /// The chosen step goes one place earlier.
209    LineupUp,
210    /// The chosen step goes one place later.
211    LineupDown,
212    /// The chosen step comes out of the lineup.
213    LineupRemove,
214    /// The lineup being written is kept.
215    LineupSave,
216    /// Leave the lineup being written, keeping nothing of it.
217    LineupCancel,
218}
219
220/// The providers screen's state.
221#[derive(Debug, Clone)]
222pub struct Providers {
223    /// Where the providers file is, or `None` on a machine that gives QCode no data folder.
224    path: Option<PathBuf>,
225    file: ProviderFile,
226    problems: Vec<Diagnostic>,
227    permissions: Vec<PermissionProblem>,
228    selected: usize,
229    model: usize,
230    draft: Option<Draft>,
231    lineups: Option<Lineups>,
232    web: Web,
233    busy: Option<Busy>,
234    notice: Option<Notice>,
235    loading: bool,
236}
237
238impl Providers {
239    /// A screen over the providers file at `path`, asking questions through `web`.
240    ///
241    /// Both are parameters: a test gives a file of its own and a web that answers from a string,
242    /// so the whole screen is driven without a data folder or a server anywhere.
243    #[must_use]
244    pub fn new(path: Option<PathBuf>, web: Web) -> Self {
245        Self {
246            path,
247            file: ProviderFile::in_memory(),
248            problems: Vec::new(),
249            permissions: Vec::new(),
250            selected: 0,
251            model: 0,
252            draft: None,
253            lineups: None,
254            web,
255            busy: None,
256            notice: None,
257            loading: false,
258        }
259    }
260
261    /// The providers file this screen reads and writes, or `None` on a machine that gives QCode
262    /// no data folder.
263    #[must_use]
264    pub fn path(&self) -> Option<&std::path::Path> {
265        self.path.as_deref()
266    }
267
268    /// The providers, in the order they were added.
269    #[must_use]
270    pub fn entries(&self) -> &[ProviderEntry] {
271        self.file.entries()
272    }
273
274    /// The provider the person is looking at.
275    #[must_use]
276    pub fn selected(&self) -> Option<&ProviderEntry> {
277        self.entries().get(self.selected)
278    }
279
280    /// The model of that provider the person is looking at.
281    #[must_use]
282    pub fn chosen_model(&self) -> Option<&Model> {
283        self.selected()?.models.get(self.model)
284    }
285
286    /// What was wrong with the providers file.
287    #[must_use]
288    pub fn problems(&self) -> &[Diagnostic] {
289        &self.problems
290    }
291
292    /// The places, the file or its folder, whose permissions let others on this machine read the
293    /// key, as they were when the file was last read or written.
294    #[must_use]
295    pub fn permissions(&self) -> &[PermissionProblem] {
296        &self.permissions
297    }
298
299    /// The add dialog, while one is open.
300    #[must_use]
301    pub fn draft(&self) -> Option<&Draft> {
302        self.draft.as_ref()
303    }
304
305    /// The lineups dialog of the chosen provider, while one is open.
306    #[must_use]
307    pub fn lineups(&self) -> Option<&Lineups> {
308        self.lineups.as_ref()
309    }
310
311    /// What the screen is waiting for.
312    #[must_use]
313    pub fn busy(&self) -> Option<Busy> {
314        self.busy
315    }
316
317    /// The last thing that happened.
318    #[must_use]
319    pub fn notice(&self) -> Option<&Notice> {
320        self.notice.as_ref()
321    }
322
323    /// Writes the file and remembers a refusal, which the page says out loud rather than losing.
324    fn save(&mut self) {
325        if let Err(reason) = self.file.save() {
326            self.notice = Some(Notice::NotWritten(reason));
327        }
328        // Saving narrows the file and its folder, so a warning read before it may no longer hold;
329        // one that still does, because the folder could not be narrowed, stays.
330        if let Some(path) = &self.path {
331            self.permissions = permission_problems(path);
332        }
333    }
334}
335
336/// The control that takes the keyboard when the screen opens, once there is one: the list, or on
337/// an empty screen its one button.
338#[must_use]
339pub fn entry(state: &Providers) -> Option<&'static str> {
340    if state.loading {
341        None
342    } else if state.entries().is_empty() {
343        Some("providers-empty")
344    } else {
345        Some(LIST)
346    }
347}
348
349/// The keys of the providers screen that are not in the keymap, for the key list.
350#[must_use]
351pub fn hints(icons: &qframe::icons::Icons) -> Vec<(String, String)> {
352    let move_keys = format!("{}{}", icons.glyph("arrow-up"), icons.glyph("arrow-down"));
353    vec![(move_keys, t!("hints.move")), (icons.glyph("enter").into_owned(), t!("hints.open"))]
354}
355
356/// Applies a providers screen message.
357pub fn update(state: &mut Providers, message: Msg) -> Command<Msg> {
358    match message {
359        Msg::Reload => {
360            state.loading = true;
361            let path = state.path.clone();
362            Command::perform(move || {
363                let (loaded, permissions) = match &path {
364                    Some(path) => (ProviderFile::open(path), permission_problems(path)),
365                    None => (Loaded { value: ProviderFile::in_memory(), diagnostics: Vec::new() }, Vec::new()),
366                };
367                Msg::Loaded(Box::new(loaded), permissions)
368            })
369        }
370        Msg::Loaded(loaded, permissions) => {
371            let Loaded { mut value, diagnostics } = *loaded;
372            state.permissions = permissions;
373            if let Some(path) = &state.path {
374                value = value.at(path);
375            }
376            state.file = value;
377            // A file written before the record of tried models, or before a model in it was
378            // tried, is shown in the same order a fresh answer would be.
379            for mut entry in state.file.entries().to_vec() {
380                ordered(&mut entry);
381                let tag = entry.tag.as_str().to_owned();
382                state.file.replace(&tag, entry);
383            }
384            state.problems = diagnostics;
385            state.loading = false;
386            state.selected = state.selected.min(state.entries().len().saturating_sub(1));
387            state.model = 0;
388            match entry(state) {
389                Some(control) => Command::focus(control),
390                None => Command::none(),
391            }
392        }
393        Msg::Select(index) => {
394            if index < state.entries().len() && index != state.selected {
395                state.selected = index;
396                // The models, the notice and anything being waited for all belong to the
397                // provider that was chosen, not to this one.
398                state.model = 0;
399                state.notice = None;
400            }
401            Command::none()
402        }
403        Msg::PickModel(index) => {
404            if index < state.selected().map_or(0, |entry| entry.models.len()) {
405                state.model = index;
406            }
407            Command::none()
408        }
409        Msg::New => {
410            state.draft = Some(Draft::new());
411            Command::focus(TAG_FIELD)
412        }
413        Msg::Cancel => {
414            // The key the person was typing goes with the dialog: nothing keeps it.
415            state.draft = None;
416            match entry(state) {
417                Some(control) => Command::focus(control),
418                None => Command::none(),
419            }
420        }
421        Msg::Tag(text) => {
422            if let Some(draft) = &mut state.draft {
423                draft.tag = text;
424                draft.problem = None;
425            }
426            Command::none()
427        }
428        Msg::Base(text) => {
429            if let Some(draft) = &mut state.draft {
430                draft.base = text;
431                draft.problem = None;
432            }
433            Command::none()
434        }
435        Msg::PasteKey(text) => {
436            if let Some(draft) = &mut state.draft {
437                draft.key = text;
438                draft.problem = None;
439            }
440            Command::none()
441        }
442        Msg::PickKind(index) => {
443            if let (Some(draft), Some(kind)) = (&mut state.draft, ProviderKind::ALL.get(index).copied()) {
444                draft.pick_kind(kind);
445            }
446            Command::none()
447        }
448        Msg::PickRegion(index) => {
449            if let Some(draft) = &mut state.draft {
450                draft.pick_region(index);
451            }
452            Command::none()
453        }
454        Msg::Add => add(state),
455        Msg::DeleteAsked => match state.selected() {
456            Some(entry) => Command::confirm(
457                Confirm::new(t!("provider.delete-title", tag = entry.tag.as_str()), Msg::DeleteConfirmed)
458                    .message(t!("provider.delete-message"))
459                    .confirm_label(t!("provider.delete"))
460                    .danger(),
461            ),
462            None => Command::none(),
463        },
464        Msg::DeleteConfirmed => {
465            let Some(tag) = state.selected().map(|entry| entry.tag.as_str().to_owned()) else {
466                return Command::none();
467            };
468            state.file.remove(&tag);
469            state.selected = state.selected.min(state.entries().len().saturating_sub(1));
470            state.model = 0;
471            state.notice = None;
472            state.save();
473            Command::none()
474        }
475        Msg::ForgetKey => {
476            let Some(tag) = state.selected().map(|entry| entry.tag.as_str().to_owned()) else {
477                return Command::none();
478            };
479            state.file.forget_key(&tag);
480            state.save();
481            Command::none()
482        }
483        Msg::Try => start(state, Busy::Trying, |web, entry| Msg::Tried(Box::new(ask::try_connection(&web, &entry)))),
484        Msg::Tried(answer) => {
485            state.busy = None;
486            state.notice = Some(match *answer {
487                Ok(reached) => Notice::Reached(reached),
488                Err(trouble) => Notice::Trouble(trouble),
489            });
490            Command::none()
491        }
492        Msg::Refresh => start(state, Busy::Listing, |web, entry| Msg::Listed(Box::new(ask::list_models(&web, &entry)))),
493        Msg::Listed(answer) => {
494            state.busy = None;
495            match *answer {
496                Ok(models) => {
497                    let Some(mut entry) = state.selected().cloned() else { return Command::none() };
498                    // A measurement is work the person asked for and waited through; asking a
499                    // provider what it offers again must not quietly throw it away.
500                    entry.models = models
501                        .into_iter()
502                        .map(|mut model| {
503                            model.measured = entry.model(&model.id).and_then(|before| before.measured);
504                            model
505                        })
506                        .collect();
507                    ordered(&mut entry);
508                    let tag = entry.tag.as_str().to_owned();
509                    state.file.replace(&tag, entry);
510                    state.model = 0;
511                    state.notice = None;
512                    state.save();
513                }
514                Err(trouble) => state.notice = Some(Notice::Trouble(trouble)),
515            }
516            Command::none()
517        }
518        Msg::MeasureAsked => {
519            let (Some(entry), Some(model)) = (state.selected().cloned(), state.chosen_model().cloned()) else {
520                return Command::none();
521            };
522            if state.busy.is_some() {
523                return Command::none();
524            }
525            state.busy = Some(Busy::Measuring);
526            state.notice = None;
527            let web = state.web.clone();
528            let id = model.id.clone();
529            Command::perform(move || {
530                let measured = ask::measure(&web, &entry, &id);
531                Msg::Measured(id, Box::new(measured))
532            })
533        }
534        Msg::Measured(id, answer) => {
535            state.busy = None;
536            match *answer {
537                Ok(measured) => {
538                    let Some(mut entry) = state.selected().cloned() else { return Command::none() };
539                    if let Some(model) = entry.models.iter_mut().find(|model| model.id == id) {
540                        model.measured = Some(measured);
541                    }
542                    let tag = entry.tag.as_str().to_owned();
543                    state.file.replace(&tag, entry);
544                    state.save();
545                }
546                Err(trouble) => state.notice = Some(Notice::Trouble(trouble)),
547            }
548            Command::none()
549        }
550        Msg::LineupsAsked => {
551            let Some(entry) = state.selected() else { return Command::none() };
552            let mut dialog = Lineups::new();
553            dialog.open(entry);
554            state.lineups = Some(dialog);
555            Command::focus(LINEUPS)
556        }
557        Msg::LineupsClosed => {
558            // A lineup left half-written is not kept: it reaches the file when it is saved, as
559            // every other thing on this page.
560            state.lineups = None;
561            back(state)
562        }
563        Msg::LineupChosen(index) => {
564            let chosen =
565                state.selected().and_then(|entry| entry.lineups.get(index)).map(|l| l.name.as_str().to_owned());
566            if let (Some(name), Some(dialog)) = (chosen, state.lineups.as_mut()) {
567                dialog.choose(&name);
568            }
569            Command::none()
570        }
571        Msg::LineupNew => {
572            if let Some(dialog) = state.lineups.as_mut() {
573                dialog.new_lineup();
574            }
575            Command::focus(NAME_FIELD)
576        }
577        Msg::LineupEditAsked => {
578            // The lineup is read out of the file before the dialog is touched: what is edited is
579            // what the file holds now, not what this screen last drew of it.
580            let editing = state
581                .lineups
582                .as_ref()
583                .and_then(|dialog| dialog.chosen.clone())
584                .and_then(|name| state.selected().and_then(|entry| entry.lineup(&name)).cloned());
585            if let (Some(lineup), Some(dialog)) = (editing, state.lineups.as_mut()) {
586                dialog.edit(&lineup);
587            }
588            Command::focus(NAME_FIELD)
589        }
590        Msg::LineupDeleteAsked => {
591            let chosen = state.lineups.as_ref().and_then(|dialog| dialog.chosen.clone());
592            let Some(name) = chosen else { return Command::none() };
593            Command::confirm(
594                Confirm::new(t!("provider.lineup-delete-title", name = name.as_str()), Msg::LineupDeleteConfirmed)
595                    .message(t!("provider.lineup-delete-message"))
596                    .confirm_label(t!("provider.lineup-delete"))
597                    .danger(),
598            )
599        }
600        Msg::LineupDeleteConfirmed => {
601            let chosen = state.lineups.as_ref().and_then(|dialog| dialog.chosen.clone());
602            let Some(name) = chosen else { return Command::none() };
603            let Some(tag) = state.selected().map(|entry| entry.tag.as_str().to_owned()) else {
604                return Command::none();
605            };
606            state.file.remove_lineup(&tag, &name);
607            state.save();
608            // The list under the hand has one row fewer: the lineup that was there keeps the
609            // choice if it is still there, and the first of what is left takes it when it is not.
610            let left = lineup_names(&state.file, &tag);
611            if let Some(dialog) = state.lineups.as_mut() {
612                dialog.kept(&left);
613            }
614            Command::none()
615        }
616        Msg::LineupName(text) => {
617            if let Some(dialog) = state.lineups.as_mut() {
618                dialog.name(text);
619            }
620            Command::none()
621        }
622        Msg::LineupFilter(text) => {
623            if let Some(dialog) = state.lineups.as_mut() {
624                dialog.filter_for(text);
625            }
626            Command::none()
627        }
628        Msg::LineupLooked(id) => {
629            if let Some(dialog) = state.lineups.as_mut() {
630                dialog.look_at(&id);
631            }
632            Command::none()
633        }
634        Msg::LineupAdded(index) => {
635            // The index is where the model stood in the list the filter leaves. The filter is
636            // written as it is typed, so the model is read out of that list by its place in it and
637            // the person is credited with the row they pointed at, not with a number.
638            let id = state
639                .lineups
640                .as_ref()
641                .zip(state.selected())
642                .and_then(|(dialog, entry)| dialog.shown(entry).get(index).map(|model| model.id.clone()));
643            if let (Some(id), Some(dialog)) = (id, state.lineups.as_mut()) {
644                dialog.add(&id);
645            }
646            Command::none()
647        }
648        Msg::LineupStep(index) => {
649            if let Some(dialog) = state.lineups.as_mut() {
650                dialog.pick_step(index);
651            }
652            Command::none()
653        }
654        Msg::LineupUp | Msg::LineupDown | Msg::LineupRemove => {
655            if let Some(dialog) = state.lineups.as_mut() {
656                match message {
657                    Msg::LineupUp => dialog.up(),
658                    Msg::LineupDown => dialog.down(),
659                    _ => dialog.remove(),
660                }
661            }
662            Command::none()
663        }
664        Msg::LineupSave => save_lineup(state),
665        Msg::LineupCancel => {
666            if let Some(dialog) = state.lineups.as_mut() {
667                dialog.cancelled();
668            }
669            Command::none()
670        }
671    }
672}
673
674/// Where the keyboard goes when a dialog over this page closes. The list is what both ways out of
675/// it mean to leave it, the chosen provider named or nothing changed.
676fn back(state: &Providers) -> Command<Msg> {
677    match entry(state) {
678        Some(control) => Command::focus(control),
679        None => Command::none(),
680    }
681}
682
683/// The names of the lineups the provider tagged `tag` has, in the order it has them.
684fn lineup_names(file: &ProviderFile, tag: &str) -> Vec<String> {
685    file.get(tag)
686        .map_or_else(Vec::new, |entry| entry.lineups.iter().map(|lineup| lineup.name.as_str().to_owned()).collect())
687}
688
689/// Keeps the lineup the editor describes, or leaves the editor open with the reason it cannot be
690/// kept. A lineup is written into the file as it is on any other change here, and the page shows
691/// it the moment it is saved.
692fn save_lineup(state: &mut Providers) -> Command<Msg> {
693    let (Some(tag), Some(dialog)) =
694        (state.selected().map(|entry| entry.tag.as_str().to_owned()), state.lineups.as_ref())
695    else {
696        return Command::none();
697    };
698    let file = &state.file;
699    let taken = |name: &str| file.get(&tag).and_then(|entry| entry.lineup(name)).is_some();
700    match dialog.build(taken) {
701        Ok(lineup) => {
702            let name = lineup.name.as_str().to_owned();
703            state.file.set_lineup(&tag, lineup);
704            state.save();
705            if let Some(dialog) = state.lineups.as_mut() {
706                dialog.saved(&name);
707            }
708            Command::focus(LINEUPS)
709        }
710        Err(refusal) => {
711            // The name is what a refusal about the name belongs beside, as in the add dialog; the
712            // steps are the only thing wrong otherwise, and the line about it stands under them.
713            let beside_name = matches!(refusal, Refusal::Name(_) | Refusal::Taken(_));
714            if let Some(editor) = state.lineups.as_mut().and_then(|dialog| dialog.editor.as_mut()) {
715                editor.refusal = Some(refusal);
716            }
717            if beside_name { Command::focus(NAME_FIELD) } else { Command::none() }
718        }
719    }
720}
721
722/// Adds the provider the dialog describes, or leaves the dialog open with the reason it cannot.
723fn add(state: &mut Providers) -> Command<Msg> {
724    let Some(draft) = state.draft.clone() else { return Command::none() };
725    let taken = |tag: &str| state.file.get(tag).is_some();
726    match draft.build(taken) {
727        Ok(entry) => {
728            let tag = entry.tag.as_str().to_owned();
729            // The list already refused a tag it holds, so this cannot fail; if it ever did, the
730            // dialog stays open rather than the provider quietly disappearing.
731            if state.file.add(entry).is_err() {
732                return Command::none();
733            }
734            state.draft = None;
735            state.selected = state.entries().iter().position(|entry| entry.tag.as_str() == tag).unwrap_or(0);
736            state.model = 0;
737            state.notice = None;
738            state.save();
739            Command::focus(LIST)
740        }
741        Err(problem) => {
742            let field = problem.field();
743            if let Some(draft) = &mut state.draft {
744                draft.problem = Some(problem);
745            }
746            Command::focus(field)
747        }
748    }
749}
750
751/// Starts a request against the chosen provider, if one is not already out.
752fn start(
753    state: &mut Providers,
754    busy: Busy,
755    work: impl FnOnce(Web, ProviderEntry) -> Msg + Send + 'static,
756) -> Command<Msg> {
757    let Some(entry) = state.selected().cloned() else { return Command::none() };
758    if state.busy.is_some() {
759        return Command::none();
760    }
761    state.busy = Some(busy);
762    state.notice = None;
763    let web = state.web.clone();
764    Command::perform(move || work(web, entry))
765}
766
767/// Draws the screen: the providers, with the add dialog or the lineups dialog over them.
768pub fn view(state: &Providers, ui: &mut View<'_, Msg>) {
769    draw_list(state, ui);
770    if let Some(draft) = state.draft() {
771        draw_dialog(draft, ui);
772    }
773    if let (Some(dialog), Some(entry)) = (state.lineups(), state.selected()) {
774        draw_lineups(dialog, entry, ui);
775    }
776}
777
778/// The providers, the key-file line and everything about the chosen one.
779fn draw_list(state: &Providers, ui: &mut View<'_, Msg>) {
780    page::column(ui, page::WIDTH, |ui| {
781        ui.column(|ui| {
782            // The page's own name, and the one button that acts on the page rather than on a
783            // provider in it. They share a row because a page a provider is on has a row of facts,
784            // two lists and three rows of buttons under its name, and a terminal with few rows
785            // gives them away to the button that adds a provider: nothing else on the page adds
786            // one. An empty page has no providers to leave room for, and its own empty state says
787            // so with a button of its own.
788            ui.row(|ui| {
789                ui.add(Text::new(t!("provider.title")).bold());
790                if !state.entries().is_empty() {
791                    ui.spacer();
792                    ui.add(Button::new(t!("provider.new")).icon("add").on_press(Msg::New)).id("provider-new");
793                }
794            })
795            .fill_width();
796            // Where the key is kept and what carries it, in a line of its own. This is not a
797            // footnote and it is not behind anything: a person who shares a backup should know what
798            // they are sharing before they do it.
799            ui.add(Text::new(key_file_line(state)).color("warning")).fill_width();
800            for problem in state.permissions() {
801                let (place, mode, wanted) = (
802                    problem.place.display().to_string(),
803                    format!("{:04o}", problem.mode),
804                    format!("{:04o}", problem.wanted),
805                );
806                let said =
807                    t!("provider.permissions", place = place.as_str(), mode = mode.as_str(), wanted = wanted.as_str());
808                ui.add(Text::new(said).color("warning")).fill_width();
809            }
810            for problem in state.problems() {
811                ui.add(Text::new(problem.to_string()).color("warning")).fill_width();
812            }
813            if state.loading {
814                ui.add(Text::new(t!("provider.reading")).role("secondary"));
815                return;
816            }
817            if state.entries().is_empty() {
818                ui.add(
819                    EmptyState::new(t!("provider.empty-title"))
820                        .icon("inbox")
821                        .message(t!("provider.empty-message"))
822                        .action(Button::new(t!("provider.new")).variant("primary").on_press(Msg::New)),
823                )
824                .id("providers-empty")
825                .fill();
826                return;
827            }
828            let items = state.entries().iter().map(|entry| {
829                ListItem::new(entry.tag.as_str().to_owned()).detail(format!(
830                    "{}  {}",
831                    kind_word(entry.kind),
832                    entry.base
833                ))
834            });
835            let list = List::new(items).label_first(true).selected(Some(state.selected)).on_select(Msg::Select);
836            ui.add(list.wrap(true)).id(LIST).fill_width();
837
838            if let Some(entry) = state.selected() {
839                draw_chosen(state, entry, ui);
840            }
841        })
842        .fill()
843        .gap(1);
844    });
845}
846
847/// Everything about the provider the person is looking at.
848fn draw_chosen(state: &Providers, entry: &ProviderEntry, ui: &mut View<'_, Msg>) {
849    let busy = state.busy();
850    // What this provider is reached with, and what orders of its models it has: two lines of
851    // facts about this one provider, with no blank row between them, since a blank row of the
852    // page is a row of a twenty-four row terminal that one of the lists could have. A long order
853    // is cut to the width rather than wrapped over the rows below, since the dialog behind the
854    // button beside Try is where a long order is read.
855    ui.column(|ui| {
856        ui.add(Text::new(key_line(entry)).role("secondary")).fill_width();
857        ui.add(Text::new(lineups_line(entry)).role("secondary").no_wrap()).fill_width();
858    })
859    .fill_width()
860    .gap(0);
861    if !entry.kind.regions().is_empty() {
862        ui.column(|ui| draw_speaks(entry.kind, &entry.base, ui)).fill_width();
863    }
864
865    if entry.models.is_empty() {
866        ui.add(Text::new(t!("provider.no-models")).role("secondary")).fill_width();
867    } else {
868        let items = entry.models.iter().map(|model| model_row(model, entry.kind));
869        // A server can offer more models than the screen has rows. The list takes the rows the
870        // rest of the page leaves and scrolls inside them, so the answer to Try and every button
871        // under it stay where a hand can reach them.
872        let list = List::new(items).selected(Some(state.model)).on_select(Msg::PickModel);
873        ui.add(list.wrap(true)).id(MODELS).fill();
874    }
875    // What QCode saw of the chosen model in a harness, in words, beside the mark on its row.
876    if let Some(model) = state.chosen_model()
877        && let Some((text, tone)) = verdict_line(model, entry.kind)
878    {
879        ui.add(Text::new(text).color(tone)).fill_width();
880    }
881    // A figure QCode did not ask the service for says where it was read.
882    if let Some(model) = state.chosen_model()
883        && let Some(source) = model.published_at(entry.kind)
884    {
885        ui.add(Text::new(t!("provider.published-at", model = model.id.as_str(), source = source)).role("secondary"))
886            .fill_width();
887    }
888    // A window that is really this small is the one thing a person has to be told before they
889    // point a coding agent at it, with what they can do about it on their own machine.
890    if let Some(model) = state.chosen_model()
891        && model.measured.is_some_and(Measured::is_cramped)
892    {
893        ui.add(Text::new(t!("provider.cramped", tokens = tokens(CRAMPED))).color("warning")).fill_width();
894        ui.add(Text::new(t!("provider.cramped-remedy", model = model.id.as_str())).role("secondary")).fill_width();
895    }
896    if let Some(notice) = state.notice() {
897        let (text, tone) = notice_line(notice, entry);
898        ui.add(Text::new(text).color(tone)).fill_width();
899    }
900
901    // Where the request would go, before anything sends one.
902    ui.add(Text::new(t!("provider.going-to", line = ask::trial(entry).line().as_str())).role("secondary")).fill_width();
903
904    ui.row(|ui| {
905        ui.add(pressable(t!("provider.try"), t!("provider.trying"), busy == Some(Busy::Trying), busy, Msg::Try))
906            .id("provider-try");
907        let refresh = pressable(
908            t!("provider.refresh"),
909            t!("provider.refreshing"),
910            busy == Some(Busy::Listing),
911            busy,
912            Msg::Refresh,
913        );
914        ui.add(refresh).id("provider-refresh");
915        if state.chosen_model().is_some() {
916            let measure = pressable(
917                t!("provider.measure"),
918                t!("provider.measuring"),
919                busy == Some(Busy::Measuring),
920                busy,
921                Msg::MeasureAsked,
922            );
923            ui.add(measure).id("provider-measure");
924        }
925        // A lineup is a decision about which of the models above to lean on, so its button stands
926        // with the other two that ask this provider something. The row wraps rather than cutting
927        // its last button off the edge: four labels do not fit a narrow terminal, and a button
928        // that is not on the screen is a button nobody can press.
929        ui.add(Button::new(t!("provider.lineup-button")).on_press(Msg::LineupsAsked)).id("provider-lineups");
930        ui.spacer();
931    })
932    .fill_width()
933    .wrap(true)
934    .gap(1);
935    ui.row(|ui| {
936        if entry.key.is_some() {
937            ui.add(Button::new(t!("provider.forget-key")).on_press(Msg::ForgetKey)).id("provider-forget");
938        }
939        ui.add(Button::new(t!("provider.delete")).variant("danger").on_press(Msg::DeleteAsked)).id("provider-delete");
940        ui.spacer();
941    })
942    .fill_width()
943    .gap(1);
944}
945
946/// A button that shows the work while it is out and takes no second press, and that is not
947/// pressable at all while something else is out.
948fn pressable(label: String, working: String, mine: bool, busy: Option<Busy>, message: Msg) -> Button<Msg> {
949    let button = Button::new(if mine { working } else { label });
950    if busy.is_some() { button } else { button.on_press(message) }
951}
952
953/// The add dialog.
954fn draw_dialog(draft: &Draft, ui: &mut View<'_, Msg>) {
955    let problem = draft.problem.as_ref();
956    let at = |field: &str| problem.filter(|problem| problem.field() == field).map(problem_message);
957    let chosen = ProviderKind::ALL.iter().position(|kind| *kind == draft.kind);
958    let dialog = Modal::new().title(t!("provider.new-title")).width(DIALOG_WIDTH).on_close(Msg::Cancel);
959    ui.add_with(dialog, |ui| {
960        ui.column(|ui| {
961            let kinds = RadioGroup::new(ProviderKind::ALL.map(kind_word)).selected(chosen).on_select(Msg::PickKind);
962            ui.add(kinds.wrap(true)).id("provider-kind");
963            if !draft.kind.regions().is_empty() {
964                draw_ready_made(draft, ui);
965            }
966            let tag_error = at(TAG_FIELD);
967            ui.add_with(
968                Field::new(t!("provider.tag")).hint(t!("provider.tag-hint")).error(tag_error.clone()).required(true),
969                |ui| {
970                    ui.add(TextInput::new(draft.tag.clone()).invalid(tag_error.is_some()).on_change(Msg::Tag))
971                        .id(TAG_FIELD)
972                        .fill_width();
973                },
974            )
975            .fill_width();
976            let base_error = at(BASE_FIELD);
977            ui.add_with(
978                Field::new(t!("provider.base")).hint(t!("provider.base-hint")).error(base_error.clone()).required(true),
979                |ui| {
980                    // The offered address is a suggestion, not a start: a person who tabs in and
981                    // types their own replaces it instead of writing after it.
982                    ui.add(
983                        TextInput::new(draft.base.clone())
984                            .select_all_on_focus()
985                            .invalid(base_error.is_some())
986                            .on_change(Msg::Base),
987                    )
988                    .id(BASE_FIELD)
989                    .fill_width();
990                },
991            )
992            .fill_width();
993            if draft.kind.needs_key() {
994                let key_error = at(KEY_FIELD);
995                ui.add_with(
996                    Field::new(t!("provider.key"))
997                        .hint(t!("provider.key-hint"))
998                        .error(key_error.clone())
999                        .required(true),
1000                    |ui| {
1001                        // Never readable, not even by the person typing it: what is pasted here
1002                        // is shown again only as its last four characters, and only after it has
1003                        // been added.
1004                        ui.add(
1005                            TextInput::new(draft.key.clone())
1006                                .password(true)
1007                                .invalid(key_error.is_some())
1008                                .on_change(Msg::PasteKey),
1009                        )
1010                        .id(KEY_FIELD)
1011                        .fill_width();
1012                    },
1013                )
1014                .fill_width();
1015            }
1016            ui.row(|ui| {
1017                ui.spacer();
1018                ui.add(Button::new(t!("provider.cancel")).on_press(Msg::Cancel)).id("provider-cancel");
1019                ui.add(Button::new(t!("provider.add")).variant("primary").on_press(Msg::Add)).id("provider-add");
1020            })
1021            .fill_width()
1022            .gap(1);
1023        })
1024        .gap(1);
1025    });
1026}
1027
1028/// The lineups of the chosen provider: their list, or the one being written in its place.
1029fn draw_lineups(dialog: &Lineups, entry: &ProviderEntry, ui: &mut View<'_, Msg>) {
1030    let title = t!("provider.lineup-title", tag = entry.tag.as_str());
1031    let mut modal = Modal::new().title(title).width(DIALOG_WIDTH).on_close(Msg::LineupsClosed);
1032    if dialog.editor.is_some() {
1033        modal = modal
1034            .action(Button::new(t!("provider.lineup-up")).on_press(Msg::LineupUp))
1035            .action(Button::new(t!("provider.lineup-down")).on_press(Msg::LineupDown))
1036            .action(Button::new(t!("provider.lineup-remove")).variant("danger").on_press(Msg::LineupRemove))
1037            .action(Button::new(t!("provider.lineup-cancel")).on_press(Msg::LineupCancel))
1038            .action(Button::new(t!("provider.lineup-save")).variant("primary").on_press(Msg::LineupSave));
1039    } else {
1040        modal = modal
1041            .action(Button::new(t!("provider.lineup-new")).on_press(Msg::LineupNew))
1042            .action(Button::new(t!("provider.lineup-edit")).on_press(Msg::LineupEditAsked))
1043            .action(Button::new(t!("provider.lineup-delete")).variant("danger").on_press(Msg::LineupDeleteAsked))
1044            .action(Button::new(t!("provider.lineup-close")).on_press(Msg::LineupsClosed));
1045    }
1046    ui.add_with(modal, |ui| {
1047        // The body takes the whole of what the title and the buttons leave, so the two lists
1048        // inside it have rows to scroll in rather than the height of the two or three models
1049        // that happen to be in them.
1050        ui.column(|ui| {
1051            if let Some(editor) = &dialog.editor {
1052                draw_editor(dialog, editor, entry, ui);
1053            } else {
1054                draw_list_of_lineups(dialog, entry, ui);
1055            }
1056        })
1057        .fill_height()
1058        .gap(1);
1059    });
1060}
1061
1062/// What this provider has named: each lineup with the models it tries, in that order.
1063fn draw_list_of_lineups(dialog: &Lineups, entry: &ProviderEntry, ui: &mut View<'_, Msg>) {
1064    ui.add(Text::new(t!("provider.lineup-lead")).role("secondary")).fill_width();
1065    if entry.lineups.is_empty() {
1066        ui.add(Text::new(t!("provider.lineup-empty")).role("secondary")).fill_width();
1067        return;
1068    }
1069    let items = entry.lineups.iter().map(|lineup| {
1070        let steps: Vec<&str> = lineup.models.iter().map(String::as_str).collect();
1071        ListItem::new(lineup.name.as_str().to_owned()).detail(steps.join(" → "))
1072    });
1073    let chosen = dialog.chosen.as_deref().and_then(|name| entry.lineups.iter().position(|l| l.name.as_str() == name));
1074    let list = List::new(items).selected(chosen).on_select(Msg::LineupChosen);
1075    ui.add(list.wrap(true)).id(LINEUPS).fill();
1076}
1077
1078/// The lineup being written: its name, the provider's models to pick from, and the order itself.
1079///
1080/// The two lists share whatever the dialog has left of the screen, so however many models the
1081/// provider offers, and however long the order grows, the buttons under them stay where a hand
1082/// can reach them.
1083fn draw_editor(dialog: &Lineups, editor: &Editor, entry: &ProviderEntry, ui: &mut View<'_, Msg>) {
1084    let refusal = editor.refusal.as_ref();
1085    let name_error = name_refusal(refusal);
1086    ui.add_with(
1087        Field::new(t!("provider.lineup-name"))
1088            .hint(t!("provider.lineup-name-hint"))
1089            .error(name_error.clone())
1090            .required(true),
1091        |ui| {
1092            ui.add(TextInput::new(editor.name.clone()).invalid(name_error.is_some()).on_change(Msg::LineupName))
1093                .id(NAME_FIELD)
1094                .fill_width();
1095        },
1096    )
1097    .fill_width();
1098    ui.add_with(Field::new(t!("provider.lineup-models")), |ui| {
1099        ui.add(
1100            TextInput::new(editor.filter.clone())
1101                .placeholder(t!("provider.lineup-filter"))
1102                .on_change(Msg::LineupFilter),
1103        )
1104        .id(FILTER_FIELD)
1105        .fill_width();
1106    })
1107    .fill_width();
1108
1109    let shown = dialog.shown(entry);
1110    let rows = model_rows(ui);
1111    // A model is under the hand from the first moment: the first of the ones the filter leaves, or
1112    // the one the person pointed at while it was still there. A list with nothing chosen in it
1113    // would swallow every Return the person presses looking for the model they just typed.
1114    let chosen = editor
1115        .model
1116        .as_deref()
1117        .and_then(|id| shown.iter().position(|model| model.id == id))
1118        .or((!shown.is_empty()).then_some(0));
1119    // The model the person pointed at, by its place among the ones the filter leaves: the filter
1120    // is written as it is typed, so a number the person chose an hour ago no longer means the
1121    // same row, while a model's own name always does.
1122    let ids: Vec<String> = shown.iter().map(|model| model.id.clone()).collect();
1123    let items =
1124        shown.iter().map(|model| ListItem::new(model.id.clone()).detail(price_words(model.price).unwrap_or_default()));
1125    let list = List::new(items)
1126        .empty_text(t!("provider.lineup-none-match"))
1127        .selected(chosen)
1128        .on_select(move |index| Msg::LineupLooked(ids.get(index).cloned().unwrap_or_default()))
1129        .on_activate(Msg::LineupAdded);
1130    ui.add(list.wrap(true)).id(LINEUP_MODELS).height(Length::Cells(rows));
1131
1132    let steps = dialog.steps();
1133    let step_height = step_rows(ui);
1134    let rows: Vec<ListItem> = steps
1135        .iter()
1136        .enumerate()
1137        .map(|(at, id)| {
1138            let price = entry.model(id).and_then(|model| price_words(model.price)).unwrap_or_default();
1139            ListItem::new(format!("{}. {id}", at + 1)).detail(price)
1140        })
1141        .collect();
1142    let step_list = List::new(rows)
1143        .empty_text(t!("provider.lineup-none-yet"))
1144        .selected((!steps.is_empty()).then(|| dialog.step().min(steps.len() - 1)))
1145        .on_select(Msg::LineupStep);
1146    ui.add(step_list.wrap(true)).id(LINEUP_STEPS).height(Length::Cells(step_height));
1147
1148    // A step that costs money is said out loud where the order is chosen, not in a bill later.
1149    let paid = dialog.paid(entry);
1150    if !paid.is_empty() {
1151        ui.add(Text::new(t!("provider.lineup-paid", models = paid.join(", ").as_str())).color("warning")).fill_width();
1152    }
1153    if let Some(Refusal::Empty) = refusal {
1154        ui.add(Text::new(t!("provider.lineup-refused-empty")).color("warning")).fill_width();
1155    }
1156}
1157
1158/// One line naming the provider's lineups compactly, or that it has none.
1159fn lineups_line(entry: &ProviderEntry) -> String {
1160    if entry.lineups.is_empty() {
1161        return t!("provider.lineup-none");
1162    }
1163    let named: Vec<String> = entry
1164        .lineups
1165        .iter()
1166        .map(|lineup| {
1167            let steps: Vec<&str> = lineup.models.iter().map(String::as_str).collect();
1168            format!("{} ({})", lineup.name, steps.join(" → "))
1169        })
1170        .collect();
1171    t!("provider.lineup-summary", lineups = named.join(", ").as_str())
1172}
1173
1174/// What using a model costs, in one word, when the service says what it is.
1175fn price_words(price: Option<Price>) -> Option<String> {
1176    match price {
1177        Some(Price::Free) => Some(t!("provider.price-free")),
1178        Some(Price::Paid) => Some(t!("provider.price-paid")),
1179        None => None,
1180    }
1181}
1182
1183/// What stopped the name being used, in the words the add dialog uses for a tag, since a lineup's
1184/// name is checked by the same rules.
1185fn name_refusal(refusal: Option<&Refusal>) -> Option<String> {
1186    use crate::provider::TagError;
1187
1188    match refusal? {
1189        Refusal::Name(TagError::Empty) => Some(t!("provider.tag-empty")),
1190        Refusal::Name(TagError::TooLong { length }) => {
1191            Some(t!("provider.tag-long", length = i64::try_from(*length).unwrap_or(i64::MAX)))
1192        }
1193        Refusal::Name(TagError::BadStart { character }) => {
1194            Some(t!("provider.tag-start", character = character.to_string().as_str()))
1195        }
1196        Refusal::Name(TagError::Illegal { position, character }) => Some(t!(
1197            "provider.tag-character",
1198            character = character.to_string().as_str(),
1199            position = i64::try_from(*position).unwrap_or(i64::MAX)
1200        )),
1201        Refusal::Taken(name) => Some(t!("provider.lineup-name-taken", name = name.as_str())),
1202        Refusal::Empty => None,
1203    }
1204}
1205
1206/// What a ready-made kind fills in, said before the person types their key: where its
1207/// subscription answers, where each shape is asked, the header the key goes in, and the models
1208/// it offers.
1209fn draw_ready_made(draft: &Draft, ui: &mut View<'_, Msg>) {
1210    let regions = draft.kind.regions().iter().map(|region| region_word(region.id));
1211    let group = RadioGroup::new(regions).horizontal(true).selected(draft.region()).on_select(Msg::PickRegion);
1212    ui.add(group.wrap(true)).id("provider-region");
1213    // One block, read together: it is what picking the kind filled in.
1214    ui.column(|ui| {
1215        draw_speaks(draft.kind, &draft.base, ui);
1216        let models: Vec<&str> = draft.kind.published().iter().map(|model| model.id).collect();
1217        ui.add(Text::new(t!("provider.ready-made-models", models = models.join(", ").as_str())).role("secondary"))
1218            .fill_width();
1219    })
1220    .fill_width();
1221}
1222
1223/// Where a provider of `kind` at `base` is asked in each shape, written as the base a client of
1224/// that shape is given, and the header its key goes in: the same places the relay and the page's
1225/// own requests go.
1226fn draw_speaks(kind: ProviderKind, base: &str, ui: &mut View<'_, Msg>) {
1227    let (header, _) = kind.key_header();
1228    let anthropic = kind.api_address(base, Wire::Anthropic, "");
1229    let openai = kind.api_address(base, Wire::OpenAi, "/v1");
1230    ui.add(Text::new(t!("provider.speaks-anthropic", address = anthropic.as_str())).role("secondary")).fill_width();
1231    ui.add(Text::new(t!("provider.speaks-openai", address = openai.as_str())).role("secondary")).fill_width();
1232    ui.add(Text::new(t!("provider.key-header", header = header)).role("secondary")).fill_width();
1233}
1234
1235/// How a region is named on the page.
1236fn region_word(id: &str) -> String {
1237    match id {
1238        "europe" => t!("provider.region-europe"),
1239        "singapore" => t!("provider.region-singapore"),
1240        "china" => t!("provider.region-china"),
1241        _ => t!("provider.region-overseas"),
1242    }
1243}
1244
1245/// The line that says where the key is written and what carries it away.
1246fn key_file_line(state: &Providers) -> String {
1247    match &state.path {
1248        Some(path) => t!("provider.key-file", path = path.display().to_string().as_str()),
1249        None => t!("provider.key-file-nowhere"),
1250    }
1251}
1252
1253/// What is shown of a provider's key: the last four characters, or that it has none.
1254fn key_line(entry: &ProviderEntry) -> String {
1255    match entry.key.as_ref().map(Key::last_four) {
1256        Some(Some(tail)) => t!("provider.key-shown", tail = tail.as_str()),
1257        Some(None) => t!("provider.key-hidden"),
1258        None => t!("provider.key-none"),
1259    }
1260}
1261
1262/// Puts the free models QCode saw work first and the ones it saw fail last. Only OpenRouter's
1263/// free models were tried; any other provider's list stays as the provider gave it.
1264fn ordered(entry: &mut ProviderEntry) {
1265    if entry.kind == ProviderKind::OpenRouter {
1266        TRIED.order(&mut entry.models);
1267    }
1268}
1269
1270/// What QCode saw of `model` on a provider of `kind`, when it was tried.
1271fn verdict(model: &Model, kind: ProviderKind) -> Option<Verdict> {
1272    (kind == ProviderKind::OpenRouter).then(|| TRIED.verdict(&model.id)).flatten()
1273}
1274
1275/// A model's row: a mark for one seen working, and for one seen failing a faint row whose detail
1276/// is the reason, since its windows do not matter to anyone who cannot use it.
1277///
1278/// What a model costs stands at the end of every row that knows it, next to the two windows: a
1279/// person choosing models to lean on is choosing what a request that falls through them will cost,
1280/// and a price nobody published is left off the row rather than guessed at.
1281fn model_row(model: &Model, kind: ProviderKind) -> ListItem {
1282    let row = ListItem::new(model.id.clone());
1283    match verdict(model, kind) {
1284        Some(Verdict::Works(_)) => row.icon("check", Some("success")).detail(about_model(model, kind)),
1285        Some(Verdict::Fails(harness, reason)) => {
1286            row.icon("warning", Some("warning")).faint(true).detail(fails(harness, reason))
1287        }
1288        None => row.detail(about_model(model, kind)),
1289    }
1290}
1291
1292/// The two windows of a model and what it costs, in the order a person reads them.
1293fn about_model(model: &Model, kind: ProviderKind) -> String {
1294    match price_words(model.price) {
1295        Some(price) => format!("{} · {price}", windows(model, kind)),
1296        None => windows(model, kind),
1297    }
1298}
1299
1300/// Why a model does not work, in the fewest words.
1301fn fails(harness: crate::profile::HarnessKind, reason: Reason) -> String {
1302    let harness = harness.record().display_name;
1303    match reason {
1304        Reason::Empty => t!("provider.fails-empty", harness = harness),
1305        Reason::Tools => t!("provider.fails-tools", harness = harness),
1306        Reason::Half => t!("provider.fails-half", harness = harness),
1307    }
1308}
1309
1310/// The line under the list for a chosen model QCode tried, and its tone.
1311fn verdict_line(model: &Model, kind: ProviderKind) -> Option<(String, &'static str)> {
1312    match verdict(model, kind)? {
1313        Verdict::Works(harnesses) => {
1314            let names: Vec<&str> = harnesses.iter().map(|harness| harness.record().display_name).collect();
1315            Some((t!("provider.tried", harnesses = names.join(", ").as_str()), "success"))
1316        }
1317        Verdict::Fails(harness, reason) => Some((fails(harness, reason), "warning")),
1318    }
1319}
1320
1321/// The two windows of a model, side by side, each one saying plainly when it is not known, and a
1322/// claim that is the maker's published figure rather than the service's answer saying so.
1323fn windows(model: &Model, kind: ProviderKind) -> String {
1324    let claimed = match model.claimed {
1325        Some(claimed) if model.published_at(kind).is_some() => {
1326            t!("provider.claims-published", claimed = tokens(claimed))
1327        }
1328        Some(claimed) => t!("provider.claims", claimed = tokens(claimed)),
1329        None => t!("provider.claims-unknown"),
1330    };
1331    let given = match model.measured {
1332        Some(Measured::About(number)) => t!("provider.gives-about", tokens = tokens(number)),
1333        Some(Measured::AtLeast(number)) => t!("provider.gives-at-least", tokens = tokens(number)),
1334        None => t!("provider.gives-unknown"),
1335    };
1336    format!("{claimed} · {given}")
1337}
1338
1339/// A count of tokens, as a number the person reads.
1340fn tokens(count: u64) -> String {
1341    count.to_string()
1342}
1343
1344/// The sentence for one thing that happened, and the tone it is said in.
1345fn notice_line(notice: &Notice, entry: &ProviderEntry) -> (String, &'static str) {
1346    match notice {
1347        Notice::Reached(Reached::Version(version)) => {
1348            (t!("provider.reached-version", tag = entry.tag.as_str(), version = version.as_str()), "success")
1349        }
1350        Notice::Reached(Reached::KeyAccepted) => (t!("provider.reached-key"), "success"),
1351        Notice::Trouble(AskError::Unreachable { url, reason }) => {
1352            (t!("provider.unreachable", url = url.as_str(), reason = reason.as_str()), "danger")
1353        }
1354        // Too many requests is not a refusal of the key or the address, and saying "refused"
1355        // sends a person to check both. Free models hit it within a minute of use; the person is
1356        // told it is the service asking them to wait, and what to do meanwhile.
1357        Notice::Trouble(AskError::Refused { url, status: 429, said }) => {
1358            (t!("provider.rate-limited", url = url.as_str(), said = said.as_str()), "warning")
1359        }
1360        Notice::Trouble(AskError::Refused { url, status, said }) => {
1361            (t!("provider.refused", url = url.as_str(), status = i64::from(*status), said = said.as_str()), "danger")
1362        }
1363        Notice::Trouble(AskError::Unreadable { url, wanted }) => {
1364            (t!("provider.unreadable", url = url.as_str(), wanted = wanted.as_str()), "danger")
1365        }
1366        Notice::NotWritten(reason) => (t!("provider.save-failed", reason = reason.as_str()), "danger"),
1367    }
1368}
1369
1370/// The sentence for one reason a provider could not be added.
1371fn problem_message(problem: &Problem) -> String {
1372    use crate::provider::TagError;
1373
1374    match problem {
1375        Problem::Tag(TagError::Empty) => t!("provider.tag-empty"),
1376        Problem::Tag(TagError::TooLong { length }) => {
1377            t!("provider.tag-long", length = i64::try_from(*length).unwrap_or(i64::MAX))
1378        }
1379        Problem::Tag(TagError::BadStart { character }) => {
1380            t!("provider.tag-start", character = character.to_string().as_str())
1381        }
1382        Problem::Tag(TagError::Illegal { position, character }) => t!(
1383            "provider.tag-character",
1384            character = character.to_string().as_str(),
1385            position = i64::try_from(*position).unwrap_or(i64::MAX)
1386        ),
1387        Problem::Taken(tag) => t!("provider.tag-taken", tag = tag.as_str()),
1388        Problem::NoBase => t!("provider.base-empty"),
1389        Problem::BaseNotAnAddress(written) => t!("provider.base-not-an-address", written = written.as_str()),
1390        Problem::BaseUnusable(written) => t!("provider.base-unusable", written = written.as_str()),
1391        Problem::NoKey(ProviderKind::OpenRouter) => t!("provider.key-needed"),
1392        Problem::NoKey(kind) => t!("provider.key-needed-for", kind = kind_word(*kind).as_str()),
1393    }
1394}
1395
1396/// How a kind is named on the page. These are the services' own names, written as they write
1397/// them, so they are the same word in every language.
1398fn kind_word(kind: ProviderKind) -> String {
1399    match kind {
1400        ProviderKind::Ollama => "ollama".to_owned(),
1401        ProviderKind::OpenRouter => "OpenRouter".to_owned(),
1402        ProviderKind::MimoTokenPlan => "Xiaomi MiMo Token Plan".to_owned(),
1403        ProviderKind::KimiCode => "Kimi Code".to_owned(),
1404    }
1405}
1406
1407/// The request the trial would send, for anything that needs to name it outside this module.
1408#[must_use]
1409pub fn trial_of(entry: &ProviderEntry) -> Ask {
1410    ask::trial(entry)
1411}