1mod 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
51pub const KEYMAP: &str = include_str!("../../keymap.toml");
53
54pub const MAX_ROUNDS: usize = 50;
57
58pub const QUIET: std::time::Duration = std::time::Duration::from_millis(700);
63
64const DECLINED: &str = "The person declined this action.";
66
67const PERSON_STOPPED: &str = "Not done: the person stopped the turn.";
69
70const 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
75const TURN_ENDED: &str = "Not done: the turn ended before this ran.";
77
78#[derive(Debug, Clone)]
80pub enum Msg {
81 Draft(String),
83 Send,
85 Delta(u64, Delta),
87 Replied(u64, Result<Reply, ProviderError>),
89 Done(u64, String, Outcome),
91 Tail(u64, String),
93 Quiet(u64, bool),
96 Allow,
98 Deny,
100 Stop,
102 New,
104 OpenProvider,
106 Form(FormField, String),
108 SaveProvider,
110 PrivateKeyFile,
112 CloseProvider,
114 NewVersion(Update),
116}
117
118#[derive(Debug, Clone, PartialEq, Default)]
120pub enum Phase {
121 #[default]
123 Idle,
124 Replying,
126 Asking {
128 call: Call,
130 action: Action,
132 preview: Preview,
134 ready: bool,
137 },
138 Acting {
140 call: Call,
142 action: Action,
144 },
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
149pub enum Notice {
150 Provider(String),
152 Unset,
154 Key(KeyError),
156 Stopped,
158 Cut,
160 Rounds,
162 Unsaved(String),
164 Profile,
166}
167
168#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
173struct Tokens {
174 read: u64,
176 written: u64,
178}
179
180impl Tokens {
181 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 fn any(&self) -> bool {
190 self.read + self.written > 0
191 }
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
196pub enum Live {
197 Thinking(String),
199 Text(String),
201 Call(String),
203}
204
205pub 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 tail: String,
217 queue: VecDeque<Call>,
219 results: Vec<Value>,
220 previews: HashMap<String, Preview>,
222 rounds: usize,
224 tokens: Tokens,
227 round: u64,
230 task: Option<TaskId>,
231 notice: Option<Notice>,
232 form: Option<ProviderForm>,
233 card: u64,
235 turn: u64,
238 updates: Option<UpdateFolders>,
240 agents: bool,
243 answers: view::Answers,
246}
247
248type Value = serde_json::Value;
249
250impl QCli {
251 #[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 #[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 #[must_use]
299 pub fn conversation(&self) -> &Conversation {
300 &self.conversation
301 }
302
303 #[must_use]
305 pub fn phase(&self) -> &Phase {
306 &self.phase
307 }
308
309 #[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 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 self.rounds = 0;
348 self.tokens = Tokens::default();
349 self.turn += 1;
350 Command::batch([self.ask(), Self::to_input()])
351 }
352
353 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 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 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 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 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 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 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 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 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 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
678pub fn run() -> io::Result<()> {
684 let folder = std::env::current_dir()?;
685 let workspace = Workspace::new(&folder)?;
686 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
703fn 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
723fn 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}