Skip to main content

qcli/app/
mod.rs

1//! The chat: one screen where the person writes, the reply streams in, and the actions the model
2//! asks for are done, or shown and asked about first.
3//!
4//! A turn goes round like this. What the person wrote joins the conversation and a request goes
5//! out on a task, which hands the reply back piece by piece. When the reply asks for tools, each
6//! call is taken in order: reading, listing and searching are done at once; an edit or a command
7//! is shown on a card and waits for the person. When every call has its result the results go
8//! back to the model in the next request, until it ends its turn. Esc stops whatever is going on
9//! and every call still waiting is answered as stopped, so the conversation stays one the API
10//! accepts.
11
12mod form;
13mod updates;
14mod view;
15
16#[cfg(test)]
17mod action_tests;
18#[cfg(test)]
19mod hostile_tests;
20#[cfg(test)]
21mod member_tests;
22#[cfg(test)]
23mod profile_tests;
24#[cfg(test)]
25mod request_tests;
26#[cfg(test)]
27mod sizes_tests;
28#[cfg(test)]
29mod tests;
30#[cfg(test)]
31mod turn_tests;
32#[cfg(test)]
33mod view_tests;
34
35use std::collections::{HashMap, VecDeque};
36use std::io;
37
38use qframe::prelude::*;
39use qframe::runtime::{Task, TaskId, Update};
40use qframe::storage::{Ecosystem, Settings};
41
42use crate::actions::{Action, Outcome, Preview, Workspace};
43use crate::config::{self, Missing, Provider, Source, UpdateFolders};
44use crate::conversation::{self, Call, Conversation};
45use crate::instructions;
46use crate::provider::{Delta, KeyError, ProviderError, Reply, Stop};
47use crate::store::Store;
48
49pub use form::{Field as FormField, ProviderForm};
50
51/// The keys of the application, layered over the framework's.
52pub const KEYMAP: &str = include_str!("../../keymap.toml");
53
54/// How many requests one turn may make before it stops and says so. A model that keeps asking
55/// for tools would otherwise go on spending the person's money without an end.
56pub const MAX_ROUNDS: usize = 50;
57
58/// How long a card stands in front of the person, with no key pressed and no click, before it
59/// takes an answer. A card can come up while the person is typing their next message, and the
60/// letter that allows it or the space and Enter that press a button would otherwise answer an
61/// edit or a command they never saw.
62pub const QUIET: std::time::Duration = std::time::Duration::from_millis(700);
63
64/// What the model is told when the person declines an action.
65const DECLINED: &str = "The person declined this action.";
66
67/// What calls still waiting are answered with when the person stops the turn before they run.
68const PERSON_STOPPED: &str = "Not done: the person stopped the turn.";
69
70/// What the action being done is answered with when the person stops the turn: an edit may
71/// already be written and a command may have done part of its work, so the model is not told it
72/// was not done.
73const STOPPED_WHILE_DOING: &str = "Stopped by the person while this was being done: it may have done part or all of its work. Check before relying on it.";
74
75/// What a call is answered with when the reply that made it ends before it is taken.
76const TURN_ENDED: &str = "Not done: the turn ended before this ran.";
77
78/// Everything that can happen.
79#[derive(Debug, Clone)]
80pub enum Msg {
81    /// The text in the writing field changed.
82    Draft(String),
83    /// The person sent what they wrote.
84    Send,
85    /// A piece of the reply of round `.0` arrived.
86    Delta(u64, Delta),
87    /// The reply of round `.0` is complete, or failed.
88    Replied(u64, Result<Reply, ProviderError>),
89    /// The action of round `.0` for call `.1` is done.
90    Done(u64, String, Outcome),
91    /// The end of the output of round `.0`'s command so far, while it runs.
92    Tail(u64, String),
93    /// Card `.0` has stood in front of the person without a key or a click for [`QUIET`]
94    /// (`.1` true), or the next input came (`.1` false).
95    Quiet(u64, bool),
96    /// The person allows the action on the card.
97    Allow,
98    /// The person declines the action on the card.
99    Deny,
100    /// Stop the reply or the action going on.
101    Stop,
102    /// Put the conversation aside and start a new one.
103    New,
104    /// Open the provider form.
105    OpenProvider,
106    /// A field of the provider form changed.
107    Form(FormField, String),
108    /// Save the provider form.
109    SaveProvider,
110    /// Make the key file the form names readable by its owner alone.
111    PrivateKeyFile,
112    /// Close the provider form without saving.
113    CloseProvider,
114    /// A newer version of qcli is out.
115    NewVersion(Update),
116}
117
118/// What the chat is doing.
119#[derive(Debug, Clone, PartialEq, Default)]
120pub enum Phase {
121    /// Waiting for the person to write.
122    #[default]
123    Idle,
124    /// A reply is streaming in.
125    Replying,
126    /// An action waits for the person's answer.
127    Asking {
128        /// The call.
129        call: Call,
130        /// The action it asks for.
131        action: Action,
132        /// What it would do.
133        preview: Preview,
134        /// Whether the card takes an answer yet: it does once it has stood in front of the person
135        /// for [`QUIET`] with no key pressed.
136        ready: bool,
137    },
138    /// An action is being done.
139    Acting {
140        /// The call.
141        call: Call,
142        /// The action.
143        action: Action,
144    },
145}
146
147/// Something the screen says under the conversation until the next turn.
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub enum Notice {
150    /// The provider could not be asked or did not answer.
151    Provider(String),
152    /// A field of the provider settings is empty.
153    Unset,
154    /// The key file gave no key.
155    Key(KeyError),
156    /// The person stopped the turn.
157    Stopped,
158    /// The reply ran out of tokens.
159    Cut,
160    /// The turn made as many requests as one may.
161    Rounds,
162    /// The conversation could not be saved.
163    Unsaved(String),
164    /// The person asked for the provider settings, and the provider is the QCode profile's.
165    Profile,
166}
167
168/// What the current turn has cost: the tokens the provider counted as read and as written over
169/// every reply of it. The person pays per token, and one turn is many requests, each of which
170/// carries the whole conversation, so a turn's own total is what they want to see rather than
171/// what any one reply said.
172#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
173struct Tokens {
174    /// The tokens the replies of the turn read between them.
175    read: u64,
176    /// The tokens the replies of the turn wrote between them.
177    written: u64,
178}
179
180impl Tokens {
181    /// Adds what one reply counted. A reply that counted neither is worth nothing to the sum: the
182    /// provider said nothing, and guessing a zero would say the turn was cheaper than it was.
183    fn add(&mut self, reply: &Reply) {
184        self.read += reply.input_tokens.unwrap_or(0);
185        self.written += reply.output_tokens.unwrap_or(0);
186    }
187
188    /// Whether the turn used a token at all, which is what decides whether the screen says so.
189    fn any(&self) -> bool {
190        self.read + self.written > 0
191    }
192}
193
194/// A piece of the reply as it streams in.
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub enum Live {
197    /// Thinking.
198    Thinking(String),
199    /// Text.
200    Text(String),
201    /// A tool call by its tool's name.
202    Call(String),
203}
204
205/// The application.
206pub struct QCli {
207    workspace: Workspace,
208    store: Store,
209    conversation: Conversation,
210    settings: Settings,
211    provider: Provider,
212    draft: String,
213    phase: Phase,
214    live: Vec<Live>,
215    /// The end of what the command being run has written so far, shown under its indicator.
216    tail: String,
217    /// The calls of the last reply still to be taken, and the results of those taken.
218    queue: VecDeque<Call>,
219    results: Vec<Value>,
220    /// What each edit and command showed before it was allowed, by call id.
221    previews: HashMap<String, Preview>,
222    /// Requests made in the current turn.
223    rounds: usize,
224    /// The tokens the current turn has cost. They are said once it is over and are never saved:
225    /// what a turn cost is the person's own business on this screen, not part of the thread.
226    tokens: Tokens,
227    /// The number of the current round. A message of an earlier one, which arrives after the
228    /// person stopped it, is ignored.
229    round: u64,
230    task: Option<TaskId>,
231    notice: Option<Notice>,
232    form: Option<ProviderForm>,
233    /// The number of the card shown last, so the quiet a card waits for is its own.
234    card: u64,
235    /// The number of the turn, part of the conversation view's id: a new turn opens the view at
236    /// its end even when the person had scrolled up to read.
237    turn: u64,
238    /// Where the ecosystem's update notice looks; `None` asks nothing.
239    updates: Option<UpdateFolders>,
240    /// Whether the folder has an `AGENTS.md`, looked at when qcli opens and at every message, so
241    /// drawing a frame never reads the disk.
242    agents: bool,
243    /// The saved answers as parsed and laid out documents, so a frame of a long conversation
244    /// does not parse every answer again.
245    answers: view::Answers,
246}
247
248type Value = serde_json::Value;
249
250impl QCli {
251    /// The chat in `workspace`, going on with the conversation `store` keeps, talking to the
252    /// provider in `settings`. The provider form opens at once when the provider is not set up.
253    #[must_use]
254    pub fn new(workspace: Workspace, store: Store, settings: Settings) -> Self {
255        let provider = Provider::from_settings(&settings);
256        let form = provider.missing_field().is_some().then(|| ProviderForm::from_provider(&provider));
257        let mut store = store;
258        let conversation = store.load();
259        let agents = instructions::agents(workspace.root()).is_some();
260        Self {
261            agents,
262            answers: view::Answers::default(),
263            workspace,
264            store,
265            conversation,
266            settings,
267            provider,
268            draft: String::new(),
269            phase: Phase::Idle,
270            live: Vec::new(),
271            tail: String::new(),
272            queue: VecDeque::new(),
273            results: Vec::new(),
274            previews: HashMap::new(),
275            rounds: 0,
276            tokens: Tokens::default(),
277            round: 0,
278            task: None,
279            notice: None,
280            form,
281            card: 0,
282            turn: 0,
283            updates: None,
284        }
285    }
286
287    /// The same chat on `provider`, which the QCode profile qcli runs in described, in place of
288    /// the one in the settings. The provider form never opens on it: the profile is where it is
289    /// changed.
290    #[must_use]
291    pub fn on_profile(mut self, provider: Provider) -> Self {
292        self.provider = provider;
293        self.form = None;
294        self
295    }
296
297    /// The conversation so far.
298    #[must_use]
299    pub fn conversation(&self) -> &Conversation {
300        &self.conversation
301    }
302
303    /// What the chat is doing.
304    #[must_use]
305    pub fn phase(&self) -> &Phase {
306        &self.phase
307    }
308
309    /// What the screen says under the conversation, if anything.
310    #[must_use]
311    pub fn notice(&self) -> Option<&Notice> {
312        self.notice.as_ref()
313    }
314
315    fn busy(&self) -> bool {
316        self.phase != Phase::Idle
317    }
318
319    /// Gives the writing field the focus: after a card is answered its buttons are gone, and the
320    /// next key the person presses belongs to the field.
321    fn to_input() -> Command<Msg> {
322        Command::focus(view::INPUT)
323    }
324
325    fn save(&mut self) {
326        if let Err(error) = self.store.save(&self.conversation) {
327            self.notice = Some(Notice::Unsaved(error.to_string()));
328        }
329    }
330
331    fn send(&mut self) -> Command<Msg> {
332        let text = self.draft.trim().to_owned();
333        if text.is_empty() || self.busy() {
334            return Command::none();
335        }
336        if self.provider.missing_field().is_some() {
337            self.form = Some(ProviderForm::from_provider(&self.provider));
338            return Command::none();
339        }
340        self.draft.clear();
341        self.notice = None;
342        self.agents = instructions::agents(self.workspace.root()).is_some();
343        self.conversation.say(&text);
344        self.save();
345        // What a turn costs is the turn's own: these sums start again here, where the request
346        // count does, so the line under the conversation never carries an earlier turn's numbers.
347        self.rounds = 0;
348        self.tokens = Tokens::default();
349        self.turn += 1;
350        Command::batch([self.ask(), Self::to_input()])
351    }
352
353    /// Sends the conversation as it is and waits for the reply.
354    fn ask(&mut self) -> Command<Msg> {
355        let endpoint = match self.provider.endpoint() {
356            Ok(endpoint) => endpoint,
357            Err(Missing::Field(_)) => return self.stop_with(Notice::Unset),
358            Err(Missing::Key(error)) => return self.stop_with(Notice::Key(error)),
359        };
360        if self.rounds >= MAX_ROUNDS {
361            return self.stop_with(Notice::Rounds);
362        }
363        self.rounds += 1;
364        self.round += 1;
365        self.live.clear();
366        self.phase = Phase::Replying;
367        let round = self.round;
368        let body = instructions::body(&self.conversation, &instructions::system(self.workspace.root()));
369        let task = Task::new("reply", move |cx| {
370            let reply = endpoint.stream(&body, &mut |delta| cx.send(Msg::Delta(round, delta)), &|| cx.is_cancelled());
371            Ok(Msg::Replied(round, reply))
372        });
373        self.task = Some(task.id());
374        Command::task(task)
375    }
376
377    fn stop_with(&mut self, notice: Notice) -> Command<Msg> {
378        self.phase = Phase::Idle;
379        self.notice = Some(notice);
380        Self::to_input()
381    }
382
383    fn replied(&mut self, reply: Result<Reply, ProviderError>) -> Command<Msg> {
384        self.task = None;
385        self.live.clear();
386        let reply = match reply {
387            Ok(reply) => reply,
388            Err(error) => return self.stop_with(Notice::Provider(error.to_string())),
389        };
390        // What this request cost goes on the turn's total, whether the reply ends the turn or
391        // asks for tools: one turn is many requests and the person pays for all of them.
392        self.tokens.add(&reply);
393        let stop = reply.stop.clone();
394        self.conversation.reply(reply.content);
395        self.save();
396        match stop {
397            Stop::ToolUse => {
398                self.queue = self.conversation.open_calls().into();
399                self.results.clear();
400                self.next()
401            }
402            Stop::MaxTokens => {
403                self.answer_untaken();
404                self.stop_with(Notice::Cut)
405            }
406            Stop::EndTurn | Stop::Other(_) => {
407                self.answer_untaken();
408                self.phase = Phase::Idle;
409                Self::to_input()
410            }
411        }
412    }
413
414    /// Answers the calls of this reply that the turn is not going to take, so the next request is
415    /// one the API accepts: a `tool_use` whose result never comes is refused, and it would be
416    /// refused again on every request after that, leaving the person with a thread that cannot
417    /// go on. A reply that ends in a way qcli does not know leaves its calls this way.
418    fn answer_untaken(&mut self) {
419        let open = self.conversation.open_calls();
420        if open.is_empty() {
421            return;
422        }
423        let results: Vec<Value> = open
424            .iter()
425            .map(|call| {
426                // A call that names no tool could never have been done, so that is what it is
427                // told; one that could have been is told the turn ended first.
428                let reason = Action::from_call(&call.name, &call.input).err().unwrap_or_else(|| TURN_ENDED.to_owned());
429                conversation::result(&call.id, &reason, true)
430            })
431            .collect();
432        self.conversation.results(results);
433        self.save();
434    }
435
436    /// Takes the next call of the reply, or sends the results when every call has one.
437    fn next(&mut self) -> Command<Msg> {
438        let Some(call) = self.queue.pop_front() else {
439            self.conversation.results(std::mem::take(&mut self.results));
440            self.save();
441            return Command::batch([self.ask(), Self::to_input()]);
442        };
443        let action = match Action::from_call(&call.name, &call.input) {
444            Ok(action) => action,
445            Err(reason) => return self.answer(&call.id, &reason, true),
446        };
447        if !action.needs_approval() {
448            return self.act(call, action);
449        }
450        match self.workspace.preview(&action) {
451            Ok(preview) => {
452                self.previews.insert(call.id.clone(), preview.clone());
453                self.card += 1;
454                self.phase = Phase::Asking { call, action, preview, ready: false };
455                Command::none()
456            }
457            Err(reason) => self.answer(&call.id, &reason, true),
458        }
459    }
460
461    /// Records the result of call `id` and goes on with the next.
462    fn answer(&mut self, id: &str, text: &str, is_error: bool) -> Command<Msg> {
463        self.results.push(conversation::result(id, text, is_error));
464        self.next()
465    }
466
467    fn act(&mut self, call: Call, action: Action) -> Command<Msg> {
468        let workspace = self.workspace.clone();
469        let round = self.round;
470        let id = call.id.clone();
471        let doing = action.clone();
472        let shown = self.previews.get(&call.id).cloned();
473        let task = Task::new("action", move |cx| {
474            let mut on_tail = |tail: &str| cx.send(Msg::Tail(round, tail.to_owned()));
475            let outcome = workspace.perform_watching(&doing, shown.as_ref(), &|| cx.is_cancelled(), &mut on_tail);
476            Ok(Msg::Done(round, id, outcome))
477        });
478        self.tail.clear();
479        self.task = Some(task.id());
480        self.phase = Phase::Acting { call, action };
481        Command::batch([Command::task(task), Self::to_input()])
482    }
483
484    /// Stops what is going on. A reply keeps the text that had come; calls still waiting are
485    /// answered as stopped.
486    fn stop(&mut self) -> Command<Msg> {
487        let cancel = self.task.take().map_or_else(Command::none, Command::cancel_task);
488        self.round += 1;
489        match std::mem::take(&mut self.phase) {
490            Phase::Idle => return cancel,
491            Phase::Replying => {
492                let text: String = self
493                    .live
494                    .iter()
495                    .filter_map(|live| if let Live::Text(text) = live { Some(text.as_str()) } else { None })
496                    .collect();
497                if !text.trim().is_empty() {
498                    self.conversation.reply(vec![serde_json::json!({"type": "text", "text": text})]);
499                }
500                self.live.clear();
501            }
502            Phase::Asking { call, .. } => self.answer_stopped(&call, PERSON_STOPPED),
503            Phase::Acting { call, .. } => self.answer_stopped(&call, STOPPED_WHILE_DOING),
504        }
505        self.save();
506        self.notice = Some(Notice::Stopped);
507        Command::batch([cancel, Self::to_input()])
508    }
509
510    /// Answers `call`, the one on the card or being done when the person stopped the turn, with
511    /// `sentence`, and every call still waiting after it as not done.
512    fn answer_stopped(&mut self, call: &Call, sentence: &str) {
513        self.results.push(conversation::result(&call.id, sentence, true));
514        for waiting in std::mem::take(&mut self.queue) {
515            self.results.push(conversation::result(&waiting.id, PERSON_STOPPED, true));
516        }
517        self.conversation.results(std::mem::take(&mut self.results));
518    }
519
520    fn new_conversation(&mut self) -> Command<Msg> {
521        let stopped = self.stop();
522        if let Err(error) = self.store.put_aside() {
523            self.notice = Some(Notice::Unsaved(error.to_string()));
524            return stopped;
525        }
526        self.conversation = Conversation::default();
527        self.agents = instructions::agents(self.workspace.root()).is_some();
528        self.previews.clear();
529        self.notice = None;
530        // The turn that was going on is over, and so is what it had cost: the next turn is
531        // counted from nothing.
532        self.tokens = Tokens::default();
533        self.turn += 1;
534        Command::batch([stopped, Self::to_input()])
535    }
536
537    fn save_provider(&mut self) -> Command<Msg> {
538        if self.provider.source == Source::Profile {
539            return Command::none();
540        }
541        let Some(form) = self.form.as_mut() else { return Command::none() };
542        let Some(provider) = form.check() else {
543            return form.errors().focus_first();
544        };
545        provider.write(&mut self.settings);
546        if let Err(error) = self.settings.save() {
547            form.failed(&error);
548            return Command::none();
549        }
550        self.provider = provider;
551        self.form = None;
552        if matches!(self.notice, Some(Notice::Unset | Notice::Key(_))) {
553            self.notice = None;
554        }
555        Self::to_input()
556    }
557}
558
559impl App for QCli {
560    type Msg = Msg;
561
562    fn init(&mut self) -> Command<Msg> {
563        let focus = match &self.form {
564            Some(_) => Command::focus(form::FIRST),
565            None => Self::to_input(),
566        };
567        Command::batch([focus, self.ask_for_update()])
568    }
569
570    fn update(&mut self, msg: Msg) -> Command<Msg> {
571        match msg {
572            Msg::Draft(text) => {
573                self.draft = text;
574                Command::none()
575            }
576            Msg::Send => self.send(),
577            Msg::Delta(round, delta) if round == self.round => {
578                match (delta, self.live.last_mut()) {
579                    (Delta::Text(piece), Some(Live::Text(text)))
580                    | (Delta::Thinking(piece), Some(Live::Thinking(text))) => {
581                        text.push_str(&piece);
582                    }
583                    (Delta::Text(piece), _) => self.live.push(Live::Text(piece)),
584                    (Delta::Thinking(piece), _) => self.live.push(Live::Thinking(piece)),
585                    (Delta::ToolCall(name), _) => self.live.push(Live::Call(name)),
586                }
587                Command::none()
588            }
589            Msg::Replied(round, reply) if round == self.round => self.replied(reply),
590            Msg::Tail(round, tail) if round == self.round => {
591                self.tail = tail;
592                Command::none()
593            }
594            Msg::Done(round, id, outcome) if round == self.round => {
595                self.tail.clear();
596                self.task = None;
597                self.answer(&id, &outcome.text, outcome.is_error)
598            }
599            Msg::Delta(..) | Msg::Replied(..) | Msg::Done(..) | Msg::Tail(..) => Command::none(),
600            Msg::Quiet(card, true) if card == self.card => match &mut self.phase {
601                // No button takes the focus: Enter and space, the keys that send a message and
602                // separate its words, answer nothing, and a person scrolled up to read is not
603                // pulled down to the card.
604                Phase::Asking { ready, .. } => {
605                    *ready = true;
606                    Command::none()
607                }
608                _ => Command::none(),
609            },
610            Msg::Quiet(..) => Command::none(),
611            Msg::Allow => match std::mem::take(&mut self.phase) {
612                Phase::Asking { call, action, ready: true, .. } => self.act(call, action),
613                other => {
614                    self.phase = other;
615                    Command::none()
616                }
617            },
618            Msg::Deny => match std::mem::take(&mut self.phase) {
619                Phase::Asking { call, ready: true, .. } => {
620                    self.phase = Phase::Idle;
621                    self.answer(&call.id, DECLINED, true)
622                }
623                other => {
624                    self.phase = other;
625                    Command::none()
626                }
627            },
628            Msg::Stop => self.stop(),
629            Msg::New => self.new_conversation(),
630            Msg::OpenProvider if self.provider.source == Source::Profile => {
631                self.notice = Some(Notice::Profile);
632                Command::none()
633            }
634            Msg::OpenProvider => {
635                self.form = Some(ProviderForm::from_provider(&self.provider));
636                Command::focus(form::FIRST)
637            }
638            Msg::Form(field, value) => {
639                if let Some(form) = self.form.as_mut() {
640                    form.set(field, value);
641                }
642                Command::none()
643            }
644            Msg::SaveProvider => self.save_provider(),
645            Msg::PrivateKeyFile => {
646                if let Some(form) = self.form.as_mut() {
647                    form.make_key_file_private();
648                }
649                Command::none()
650            }
651            Msg::NewVersion(update) => Command::toast(update.toast()),
652            Msg::CloseProvider => {
653                self.form = None;
654                Self::to_input()
655            }
656        }
657    }
658
659    fn action(&self, name: &str) -> Option<Msg> {
660        if self.form.is_some() {
661            return None;
662        }
663        match name {
664            "stop" if self.busy() => Some(Msg::Stop),
665            "allow" if matches!(self.phase, Phase::Asking { ready: true, .. }) => Some(Msg::Allow),
666            "deny" if matches!(self.phase, Phase::Asking { ready: true, .. }) => Some(Msg::Deny),
667            "new" => Some(Msg::New),
668            "provider" => Some(Msg::OpenProvider),
669            _ => None,
670        }
671    }
672
673    fn view(&self, ui: &mut View<'_, Msg>) {
674        view::view(self, ui);
675    }
676}
677
678/// Opens qcli in the folder it was started in.
679///
680/// # Errors
681///
682/// When the folder cannot be read, or the terminal cannot be used.
683pub fn run() -> io::Result<()> {
684    let folder = std::env::current_dir()?;
685    let workspace = Workspace::new(&folder)?;
686    // Conversations hold what was read and run, so they are never kept in a folder every user of
687    // the machine can reach, such as the system's temporary one.
688    let conversations = Ecosystem::QUVYTA
689        .state_dir(config::APP)
690        .ok_or_else(|| {
691            io::Error::new(
692                io::ErrorKind::NotFound,
693                "qcli keeps its conversations in the state folder (XDG_STATE_HOME, or ~/.local/state), and neither is known",
694            )
695        })?
696        .join("conversations");
697    let store = Store::new(&conversations, workspace.root());
698    let app = opening(workspace, store, config::load(), |name| std::env::var(name).ok(), UpdateFolders::here())
699        .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
700    runtime(app).run()
701}
702
703/// The chat `run()` opens: on the provider a QCode profile describes in the environment `read`
704/// gives, when it describes one, and on the one in `settings` otherwise.
705///
706/// In a profile the ecosystem's update notice is not asked for: qcli came with the profile's
707/// image and is renewed when the image is rebuilt in QCode, so "a newer version is out" would ask
708/// the person for something they cannot do in the tab.
709fn opening(
710    workspace: Workspace,
711    store: Store,
712    settings: Settings,
713    read: impl Fn(&str) -> Option<String>,
714    updates: Option<UpdateFolders>,
715) -> Result<QCli, config::ProfileError> {
716    let app = QCli::new(workspace, store, settings);
717    Ok(match Provider::from_environment(read)? {
718        Some(provider) => app.on_profile(provider),
719        None => app.update_notice(updates),
720    })
721}
722
723/// The runtime `run()` starts: qcli as a member of the ecosystem, with its own keys and language
724/// files. The member start reads qcli's own settings file and the ecosystem's and follows both
725/// while qcli runs. Tests open the same runtime without a terminal, so a line dropped here shows.
726fn runtime(app: QCli) -> Runtime<QCli> {
727    let mut runtime = Runtime::new(app).member(Ecosystem::QUVYTA, config::APP).keymap_source("keymap.toml", KEYMAP);
728    for &(file, text) in crate::locales() {
729        runtime = runtime.locale_source(file, text);
730    }
731    runtime
732}