1mod 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
34pub const KEYMAP: &str = include_str!("../../keymap.toml");
36
37pub const MAX_ROUNDS: usize = 50;
40
41const DECLINED: &str = "The person declined this action.";
43
44const STOPPED: &str = "Not done: the person stopped the turn.";
46
47#[derive(Debug, Clone)]
49pub enum Msg {
50 Draft(String),
52 Send,
54 Delta(u64, Delta),
56 Replied(u64, Result<Reply, ProviderError>),
58 Done(u64, String, Outcome),
60 Allow,
62 Deny,
64 Stop,
66 New,
68 OpenProvider,
70 Form(FormField, String),
72 SaveProvider,
74 CloseProvider,
76}
77
78#[derive(Debug, Clone, PartialEq, Default)]
80pub enum Phase {
81 #[default]
83 Idle,
84 Replying,
86 Asking {
88 call: Call,
90 action: Action,
92 preview: Preview,
94 },
95 Acting {
97 call: Call,
99 action: Action,
101 },
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum Notice {
107 Provider(String),
109 Unset,
111 Key(KeyError),
113 Stopped,
115 Cut,
117 Rounds,
119 Unsaved(String),
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum Live {
126 Thinking(String),
128 Text(String),
130 Call(String),
132}
133
134pub 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 queue: VecDeque<Call>,
146 results: Vec<Value>,
147 previews: HashMap<String, Preview>,
149 rounds: usize,
151 round: u64,
154 task: Option<TaskId>,
155 notice: Option<Notice>,
156 form: Option<ProviderForm>,
157 turn: u64,
160}
161
162type Value = serde_json::Value;
163
164impl QCli {
165 #[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 #[must_use]
194 pub fn conversation(&self) -> &Conversation {
195 &self.conversation
196 }
197
198 #[must_use]
200 pub fn phase(&self) -> &Phase {
201 &self.phase
202 }
203
204 #[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 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 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 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 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 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
496pub 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}