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