1mod 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
35pub const KEYMAP: &str = include_str!("../../keymap.toml");
37
38pub const MAX_ROUNDS: usize = 50;
41
42const DECLINED: &str = "The person declined this action.";
44
45const STOPPED: &str = "Not done: the person stopped the turn.";
47
48#[derive(Debug, Clone)]
50pub enum Msg {
51 Draft(String),
53 Send,
55 Delta(u64, Delta),
57 Replied(u64, Result<Reply, ProviderError>),
59 Done(u64, String, Outcome),
61 Allow,
63 Deny,
65 Stop,
67 New,
69 OpenProvider,
71 Form(FormField, String),
73 SaveProvider,
75 CloseProvider,
77 NewVersion(Update),
79}
80
81#[derive(Debug, Clone, PartialEq, Default)]
83pub enum Phase {
84 #[default]
86 Idle,
87 Replying,
89 Asking {
91 call: Call,
93 action: Action,
95 preview: Preview,
97 },
98 Acting {
100 call: Call,
102 action: Action,
104 },
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
109pub enum Notice {
110 Provider(String),
112 Unset,
114 Key(KeyError),
116 Stopped,
118 Cut,
120 Rounds,
122 Unsaved(String),
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum Live {
129 Thinking(String),
131 Text(String),
133 Call(String),
135}
136
137pub 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 queue: VecDeque<Call>,
149 results: Vec<Value>,
150 previews: HashMap<String, Preview>,
152 rounds: usize,
154 round: u64,
157 task: Option<TaskId>,
158 notice: Option<Notice>,
159 form: Option<ProviderForm>,
160 turn: u64,
163 updates: Option<UpdateFolders>,
165}
166
167type Value = serde_json::Value;
168
169impl QCli {
170 #[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 #[must_use]
200 pub fn conversation(&self) -> &Conversation {
201 &self.conversation
202 }
203
204 #[must_use]
206 pub fn phase(&self) -> &Phase {
207 &self.phase
208 }
209
210 #[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 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 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 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 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 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
504pub 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}