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 tests;
18
19use std::collections::{HashMap, VecDeque};
20use std::io;
21
22use qframe::prelude::*;
23use qframe::runtime::{Task, TaskId, Update};
24use qframe::storage::{Ecosystem, Settings};
25
26use crate::actions::{Action, Outcome, Preview, Workspace};
27use crate::config::{self, Missing, Provider, UpdateFolders};
28use crate::conversation::{self, Call, Conversation};
29use crate::instructions;
30use crate::provider::{Delta, KeyError, ProviderError, Reply, Stop};
31use crate::store::Store;
32
33pub use form::{Field as FormField, ProviderForm};
34
35/// The keys of the application, layered over the framework's.
36pub const KEYMAP: &str = include_str!("../../keymap.toml");
37
38/// How many requests one turn may make before it stops and says so. A model that keeps asking
39/// for tools would otherwise go on spending the person's money without an end.
40pub const MAX_ROUNDS: usize = 50;
41
42/// What the model is told when the person declines an action.
43const DECLINED: &str = "The person declined this action.";
44
45/// What calls still waiting are answered with when the person stops the turn.
46const STOPPED: &str = "Not done: the person stopped the turn.";
47
48/// Everything that can happen.
49#[derive(Debug, Clone)]
50pub enum Msg {
51    /// The text in the writing field changed.
52    Draft(String),
53    /// The person sent what they wrote.
54    Send,
55    /// A piece of the reply of round `.0` arrived.
56    Delta(u64, Delta),
57    /// The reply of round `.0` is complete, or failed.
58    Replied(u64, Result<Reply, ProviderError>),
59    /// The action of round `.0` for call `.1` is done.
60    Done(u64, String, Outcome),
61    /// The person allows the action on the card.
62    Allow,
63    /// The person declines the action on the card.
64    Deny,
65    /// Stop the reply or the action going on.
66    Stop,
67    /// Put the conversation aside and start a new one.
68    New,
69    /// Open the provider form.
70    OpenProvider,
71    /// A field of the provider form changed.
72    Form(FormField, String),
73    /// Save the provider form.
74    SaveProvider,
75    /// Close the provider form without saving.
76    CloseProvider,
77    /// A newer version of qcli is out.
78    NewVersion(Update),
79}
80
81/// What the chat is doing.
82#[derive(Debug, Clone, PartialEq, Default)]
83pub enum Phase {
84    /// Waiting for the person to write.
85    #[default]
86    Idle,
87    /// A reply is streaming in.
88    Replying,
89    /// An action waits for the person's answer.
90    Asking {
91        /// The call.
92        call: Call,
93        /// The action it asks for.
94        action: Action,
95        /// What it would do.
96        preview: Preview,
97    },
98    /// An action is being done.
99    Acting {
100        /// The call.
101        call: Call,
102        /// The action.
103        action: Action,
104    },
105}
106
107/// Something the screen says under the conversation until the next turn.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub enum Notice {
110    /// The provider could not be asked or did not answer.
111    Provider(String),
112    /// A field of the provider settings is empty.
113    Unset,
114    /// The key file gave no key.
115    Key(KeyError),
116    /// The person stopped the turn.
117    Stopped,
118    /// The reply ran out of tokens.
119    Cut,
120    /// The turn made as many requests as one may.
121    Rounds,
122    /// The conversation could not be saved.
123    Unsaved(String),
124}
125
126/// A piece of the reply as it streams in.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum Live {
129    /// Thinking.
130    Thinking(String),
131    /// Text.
132    Text(String),
133    /// A tool call by its tool's name.
134    Call(String),
135}
136
137/// The application.
138pub struct QCli {
139    workspace: Workspace,
140    store: Store,
141    conversation: Conversation,
142    settings: Settings,
143    provider: Provider,
144    draft: String,
145    phase: Phase,
146    live: Vec<Live>,
147    /// The calls of the last reply still to be taken, and the results of those taken.
148    queue: VecDeque<Call>,
149    results: Vec<Value>,
150    /// What each edit and command showed before it was allowed, by call id.
151    previews: HashMap<String, Preview>,
152    /// Requests made in the current turn.
153    rounds: usize,
154    /// The number of the current round. A message of an earlier one, which arrives after the
155    /// person stopped it, is ignored.
156    round: u64,
157    task: Option<TaskId>,
158    notice: Option<Notice>,
159    form: Option<ProviderForm>,
160    /// The number of the turn, part of the conversation view's id: a new turn opens the view at
161    /// its end even when the person had scrolled up to read.
162    turn: u64,
163    /// Where the ecosystem's update notice looks; `None` asks nothing.
164    updates: Option<UpdateFolders>,
165}
166
167type Value = serde_json::Value;
168
169impl QCli {
170    /// The chat in `workspace`, going on with the conversation `store` keeps, talking to the
171    /// provider in `settings`. The provider form opens at once when the provider is not set up.
172    #[must_use]
173    pub fn new(workspace: Workspace, store: Store, settings: Settings) -> Self {
174        let provider = Provider::from_settings(&settings);
175        let form = provider.missing_field().is_some().then(|| ProviderForm::from_provider(&provider));
176        Self {
177            workspace,
178            conversation: store.load(),
179            store,
180            settings,
181            provider,
182            draft: String::new(),
183            phase: Phase::Idle,
184            live: Vec::new(),
185            queue: VecDeque::new(),
186            results: Vec::new(),
187            previews: HashMap::new(),
188            rounds: 0,
189            round: 0,
190            task: None,
191            notice: None,
192            form,
193            turn: 0,
194            updates: None,
195        }
196    }
197
198    /// The conversation so far.
199    #[must_use]
200    pub fn conversation(&self) -> &Conversation {
201        &self.conversation
202    }
203
204    /// What the chat is doing.
205    #[must_use]
206    pub fn phase(&self) -> &Phase {
207        &self.phase
208    }
209
210    /// What the screen says under the conversation, if anything.
211    #[must_use]
212    pub fn notice(&self) -> Option<&Notice> {
213        self.notice.as_ref()
214    }
215
216    fn busy(&self) -> bool {
217        self.phase != Phase::Idle
218    }
219
220    /// Gives the writing field the focus: after a card is answered its buttons are gone, and the
221    /// next key the person presses belongs to the field.
222    fn to_input() -> Command<Msg> {
223        Command::focus(view::INPUT)
224    }
225
226    fn save(&mut self) {
227        if let Err(error) = self.store.save(&self.conversation) {
228            self.notice = Some(Notice::Unsaved(error.to_string()));
229        }
230    }
231
232    fn send(&mut self) -> Command<Msg> {
233        let text = self.draft.trim().to_owned();
234        if text.is_empty() || self.busy() {
235            return Command::none();
236        }
237        if self.provider.missing_field().is_some() {
238            self.form = Some(ProviderForm::from_provider(&self.provider));
239            return Command::none();
240        }
241        self.draft.clear();
242        self.notice = None;
243        self.conversation.say(&text);
244        self.save();
245        self.rounds = 0;
246        self.turn += 1;
247        Command::batch([self.ask(), Self::to_input()])
248    }
249
250    /// Sends the conversation as it is and waits for the reply.
251    fn ask(&mut self) -> Command<Msg> {
252        let endpoint = match self.provider.endpoint() {
253            Ok(endpoint) => endpoint,
254            Err(Missing::Field(_)) => return self.stop_with(Notice::Unset),
255            Err(Missing::Key(error)) => return self.stop_with(Notice::Key(error)),
256        };
257        if self.rounds >= MAX_ROUNDS {
258            return self.stop_with(Notice::Rounds);
259        }
260        self.rounds += 1;
261        self.round += 1;
262        self.live.clear();
263        self.phase = Phase::Replying;
264        let round = self.round;
265        let body = instructions::body(&self.conversation, &instructions::system(self.workspace.root()));
266        let task = Task::new("reply", move |cx| {
267            let reply = endpoint.stream(&body, &mut |delta| cx.send(Msg::Delta(round, delta)), &|| cx.is_cancelled());
268            Ok(Msg::Replied(round, reply))
269        });
270        self.task = Some(task.id());
271        Command::task(task)
272    }
273
274    fn stop_with(&mut self, notice: Notice) -> Command<Msg> {
275        self.phase = Phase::Idle;
276        self.notice = Some(notice);
277        Self::to_input()
278    }
279
280    fn replied(&mut self, reply: Result<Reply, ProviderError>) -> Command<Msg> {
281        self.task = None;
282        self.live.clear();
283        let reply = match reply {
284            Ok(reply) => reply,
285            Err(error) => return self.stop_with(Notice::Provider(error.to_string())),
286        };
287        let stop = reply.stop.clone();
288        self.conversation.reply(reply.content);
289        self.save();
290        match stop {
291            Stop::ToolUse => {
292                self.queue = self.conversation.open_calls().into();
293                self.results.clear();
294                self.next()
295            }
296            Stop::MaxTokens => self.stop_with(Notice::Cut),
297            Stop::EndTurn | Stop::Other(_) => {
298                self.phase = Phase::Idle;
299                Self::to_input()
300            }
301        }
302    }
303
304    /// Takes the next call of the reply, or sends the results when every call has one.
305    fn next(&mut self) -> Command<Msg> {
306        let Some(call) = self.queue.pop_front() else {
307            self.conversation.results(std::mem::take(&mut self.results));
308            self.save();
309            return Command::batch([self.ask(), Self::to_input()]);
310        };
311        let action = match Action::from_call(&call.name, &call.input) {
312            Ok(action) => action,
313            Err(reason) => return self.answer(&call.id, &reason, true),
314        };
315        if !action.needs_approval() {
316            return self.act(call, action);
317        }
318        match self.workspace.preview(&action) {
319            Ok(preview) => {
320                self.previews.insert(call.id.clone(), preview.clone());
321                self.phase = Phase::Asking { call, action, preview };
322                Command::focus(view::ALLOW)
323            }
324            Err(reason) => self.answer(&call.id, &reason, true),
325        }
326    }
327
328    /// Records the result of call `id` and goes on with the next.
329    fn answer(&mut self, id: &str, text: &str, is_error: bool) -> Command<Msg> {
330        self.results.push(conversation::result(id, text, is_error));
331        self.next()
332    }
333
334    fn act(&mut self, call: Call, action: Action) -> Command<Msg> {
335        let workspace = self.workspace.clone();
336        let round = self.round;
337        let id = call.id.clone();
338        let doing = action.clone();
339        let task = Task::new("action", move |cx| {
340            let outcome = workspace.perform(&doing, &|| cx.is_cancelled());
341            Ok(Msg::Done(round, id, outcome))
342        });
343        self.task = Some(task.id());
344        self.phase = Phase::Acting { call, action };
345        Command::batch([Command::task(task), Self::to_input()])
346    }
347
348    /// Stops what is going on. A reply keeps the text that had come; calls still waiting are
349    /// answered as stopped.
350    fn stop(&mut self) -> Command<Msg> {
351        let cancel = self.task.take().map_or_else(Command::none, Command::cancel_task);
352        self.round += 1;
353        match std::mem::take(&mut self.phase) {
354            Phase::Idle => return cancel,
355            Phase::Replying => {
356                let text: String = self
357                    .live
358                    .iter()
359                    .filter_map(|live| if let Live::Text(text) = live { Some(text.as_str()) } else { None })
360                    .collect();
361                if !text.trim().is_empty() {
362                    self.conversation.reply(vec![serde_json::json!({"type": "text", "text": text})]);
363                }
364                self.live.clear();
365            }
366            Phase::Asking { call, .. } | Phase::Acting { call, .. } => {
367                self.results.push(conversation::result(&call.id, STOPPED, true));
368                for waiting in std::mem::take(&mut self.queue) {
369                    self.results.push(conversation::result(&waiting.id, STOPPED, true));
370                }
371                self.conversation.results(std::mem::take(&mut self.results));
372            }
373        }
374        self.save();
375        self.notice = Some(Notice::Stopped);
376        Command::batch([cancel, Self::to_input()])
377    }
378
379    fn new_conversation(&mut self) -> Command<Msg> {
380        let stopped = self.stop();
381        if let Err(error) = self.store.put_aside() {
382            self.notice = Some(Notice::Unsaved(error.to_string()));
383            return stopped;
384        }
385        self.conversation = Conversation::default();
386        self.previews.clear();
387        self.notice = None;
388        self.turn += 1;
389        Command::batch([stopped, Self::to_input()])
390    }
391
392    fn save_provider(&mut self) -> Command<Msg> {
393        let Some(form) = self.form.as_mut() else { return Command::none() };
394        let Some(provider) = form.check() else {
395            return form.first_problem().map_or_else(Command::none, Command::focus);
396        };
397        provider.write(&mut self.settings);
398        if let Err(error) = self.settings.save() {
399            form.failed(&error);
400            return Command::none();
401        }
402        self.provider = provider;
403        self.form = None;
404        if matches!(self.notice, Some(Notice::Unset | Notice::Key(_))) {
405            self.notice = None;
406        }
407        Self::to_input()
408    }
409}
410
411impl App for QCli {
412    type Msg = Msg;
413
414    fn init(&mut self) -> Command<Msg> {
415        let focus = match &self.form {
416            Some(_) => Command::focus(form::FIRST),
417            None => Self::to_input(),
418        };
419        Command::batch([focus, self.ask_for_update()])
420    }
421
422    fn update(&mut self, msg: Msg) -> Command<Msg> {
423        match msg {
424            Msg::Draft(text) => {
425                self.draft = text;
426                Command::none()
427            }
428            Msg::Send => self.send(),
429            Msg::Delta(round, delta) if round == self.round => {
430                match (delta, self.live.last_mut()) {
431                    (Delta::Text(piece), Some(Live::Text(text)))
432                    | (Delta::Thinking(piece), Some(Live::Thinking(text))) => {
433                        text.push_str(&piece);
434                    }
435                    (Delta::Text(piece), _) => self.live.push(Live::Text(piece)),
436                    (Delta::Thinking(piece), _) => self.live.push(Live::Thinking(piece)),
437                    (Delta::ToolCall(name), _) => self.live.push(Live::Call(name)),
438                }
439                Command::none()
440            }
441            Msg::Replied(round, reply) if round == self.round => self.replied(reply),
442            Msg::Done(round, id, outcome) if round == self.round => {
443                self.task = None;
444                self.answer(&id, &outcome.text, outcome.is_error)
445            }
446            Msg::Delta(..) | Msg::Replied(..) | Msg::Done(..) => Command::none(),
447            Msg::Allow => match std::mem::take(&mut self.phase) {
448                Phase::Asking { call, action, .. } => self.act(call, action),
449                other => {
450                    self.phase = other;
451                    Command::none()
452                }
453            },
454            Msg::Deny => match std::mem::take(&mut self.phase) {
455                Phase::Asking { call, .. } => {
456                    self.phase = Phase::Idle;
457                    self.answer(&call.id, DECLINED, true)
458                }
459                other => {
460                    self.phase = other;
461                    Command::none()
462                }
463            },
464            Msg::Stop => self.stop(),
465            Msg::New => self.new_conversation(),
466            Msg::OpenProvider => {
467                self.form = Some(ProviderForm::from_provider(&self.provider));
468                Command::focus(form::FIRST)
469            }
470            Msg::Form(field, value) => {
471                if let Some(form) = self.form.as_mut() {
472                    form.set(field, value);
473                }
474                Command::none()
475            }
476            Msg::SaveProvider => self.save_provider(),
477            Msg::NewVersion(update) => Command::toast(update.toast()),
478            Msg::CloseProvider => {
479                self.form = None;
480                Self::to_input()
481            }
482        }
483    }
484
485    fn action(&self, name: &str) -> Option<Msg> {
486        if self.form.is_some() {
487            return None;
488        }
489        match name {
490            "stop" if self.busy() => Some(Msg::Stop),
491            "allow" if matches!(self.phase, Phase::Asking { .. }) => Some(Msg::Allow),
492            "deny" if matches!(self.phase, Phase::Asking { .. }) => Some(Msg::Deny),
493            "new" => Some(Msg::New),
494            "provider" => Some(Msg::OpenProvider),
495            _ => None,
496        }
497    }
498
499    fn view(&self, ui: &mut View<'_, Msg>) {
500        view::view(self, ui);
501    }
502}
503
504/// Opens qcli in the folder it was started in.
505///
506/// # Errors
507///
508/// When the folder cannot be read, or the terminal cannot be used.
509pub fn run() -> io::Result<()> {
510    let folder = std::env::current_dir()?;
511    let workspace = Workspace::new(&folder)?;
512    let conversations = Ecosystem::QUVYTA
513        .state_dir(config::APP)
514        .unwrap_or_else(|| std::env::temp_dir().join("quvyta-cli"))
515        .join("conversations");
516    let store = Store::new(&conversations, workspace.root());
517    let settings = config::load();
518    let preferences = config::preferences();
519    let app = QCli::new(workspace, store, settings.clone()).update_notice(UpdateFolders::here());
520    let mut runtime =
521        Runtime::new(app).settings(&settings).preferences(&preferences).keymap_source("keymap.toml", KEYMAP);
522    for &(file, text) in crate::locales() {
523        runtime = runtime.locale_source(file, text);
524    }
525    runtime.run()
526}