mod form;
mod updates;
mod view;
#[cfg(test)]
mod action_tests;
#[cfg(test)]
mod hostile_tests;
#[cfg(test)]
mod member_tests;
#[cfg(test)]
mod profile_tests;
#[cfg(test)]
mod request_tests;
#[cfg(test)]
mod sizes_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod turn_tests;
#[cfg(test)]
mod view_tests;
use std::collections::{HashMap, VecDeque};
use std::io;
use qframe::prelude::*;
use qframe::runtime::{Task, TaskId, Update};
use qframe::storage::{Ecosystem, Settings};
use crate::actions::{Action, Outcome, Preview, Workspace};
use crate::config::{self, Missing, Provider, Source, UpdateFolders};
use crate::conversation::{self, Call, Conversation};
use crate::instructions;
use crate::provider::{Delta, KeyError, ProviderError, Reply, Stop};
use crate::store::Store;
pub use form::{Field as FormField, ProviderForm};
pub const KEYMAP: &str = include_str!("../../keymap.toml");
pub const MAX_ROUNDS: usize = 50;
pub const QUIET: std::time::Duration = std::time::Duration::from_millis(700);
const DECLINED: &str = "The person declined this action.";
const PERSON_STOPPED: &str = "Not done: the person stopped the turn.";
const 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.";
const TURN_ENDED: &str = "Not done: the turn ended before this ran.";
#[derive(Debug, Clone)]
pub enum Msg {
Draft(String),
Send,
Delta(u64, Delta),
Replied(u64, Result<Reply, ProviderError>),
Done(u64, String, Outcome),
Tail(u64, String),
Quiet(u64, bool),
Allow,
Deny,
Stop,
New,
OpenProvider,
Form(FormField, String),
SaveProvider,
PrivateKeyFile,
CloseProvider,
NewVersion(Update),
}
#[derive(Debug, Clone, PartialEq, Default)]
pub enum Phase {
#[default]
Idle,
Replying,
Asking {
call: Call,
action: Action,
preview: Preview,
ready: bool,
},
Acting {
call: Call,
action: Action,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Notice {
Provider(String),
Unset,
Key(KeyError),
Stopped,
Cut,
Rounds,
Unsaved(String),
Profile,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct Tokens {
read: u64,
written: u64,
}
impl Tokens {
fn add(&mut self, reply: &Reply) {
self.read += reply.input_tokens.unwrap_or(0);
self.written += reply.output_tokens.unwrap_or(0);
}
fn any(&self) -> bool {
self.read + self.written > 0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Live {
Thinking(String),
Text(String),
Call(String),
}
pub struct QCli {
workspace: Workspace,
store: Store,
conversation: Conversation,
settings: Settings,
provider: Provider,
draft: String,
phase: Phase,
live: Vec<Live>,
tail: String,
queue: VecDeque<Call>,
results: Vec<Value>,
previews: HashMap<String, Preview>,
rounds: usize,
tokens: Tokens,
round: u64,
task: Option<TaskId>,
notice: Option<Notice>,
form: Option<ProviderForm>,
card: u64,
turn: u64,
updates: Option<UpdateFolders>,
agents: bool,
answers: view::Answers,
}
type Value = serde_json::Value;
impl QCli {
#[must_use]
pub fn new(workspace: Workspace, store: Store, settings: Settings) -> Self {
let provider = Provider::from_settings(&settings);
let form = provider.missing_field().is_some().then(|| ProviderForm::from_provider(&provider));
let mut store = store;
let conversation = store.load();
let agents = instructions::agents(workspace.root()).is_some();
Self {
agents,
answers: view::Answers::default(),
workspace,
store,
conversation,
settings,
provider,
draft: String::new(),
phase: Phase::Idle,
live: Vec::new(),
tail: String::new(),
queue: VecDeque::new(),
results: Vec::new(),
previews: HashMap::new(),
rounds: 0,
tokens: Tokens::default(),
round: 0,
task: None,
notice: None,
form,
card: 0,
turn: 0,
updates: None,
}
}
#[must_use]
pub fn on_profile(mut self, provider: Provider) -> Self {
self.provider = provider;
self.form = None;
self
}
#[must_use]
pub fn conversation(&self) -> &Conversation {
&self.conversation
}
#[must_use]
pub fn phase(&self) -> &Phase {
&self.phase
}
#[must_use]
pub fn notice(&self) -> Option<&Notice> {
self.notice.as_ref()
}
fn busy(&self) -> bool {
self.phase != Phase::Idle
}
fn to_input() -> Command<Msg> {
Command::focus(view::INPUT)
}
fn save(&mut self) {
if let Err(error) = self.store.save(&self.conversation) {
self.notice = Some(Notice::Unsaved(error.to_string()));
}
}
fn send(&mut self) -> Command<Msg> {
let text = self.draft.trim().to_owned();
if text.is_empty() || self.busy() {
return Command::none();
}
if self.provider.missing_field().is_some() {
self.form = Some(ProviderForm::from_provider(&self.provider));
return Command::none();
}
self.draft.clear();
self.notice = None;
self.agents = instructions::agents(self.workspace.root()).is_some();
self.conversation.say(&text);
self.save();
self.rounds = 0;
self.tokens = Tokens::default();
self.turn += 1;
Command::batch([self.ask(), Self::to_input()])
}
fn ask(&mut self) -> Command<Msg> {
let endpoint = match self.provider.endpoint() {
Ok(endpoint) => endpoint,
Err(Missing::Field(_)) => return self.stop_with(Notice::Unset),
Err(Missing::Key(error)) => return self.stop_with(Notice::Key(error)),
};
if self.rounds >= MAX_ROUNDS {
return self.stop_with(Notice::Rounds);
}
self.rounds += 1;
self.round += 1;
self.live.clear();
self.phase = Phase::Replying;
let round = self.round;
let body = instructions::body(&self.conversation, &instructions::system(self.workspace.root()));
let task = Task::new("reply", move |cx| {
let reply = endpoint.stream(&body, &mut |delta| cx.send(Msg::Delta(round, delta)), &|| cx.is_cancelled());
Ok(Msg::Replied(round, reply))
});
self.task = Some(task.id());
Command::task(task)
}
fn stop_with(&mut self, notice: Notice) -> Command<Msg> {
self.phase = Phase::Idle;
self.notice = Some(notice);
Self::to_input()
}
fn replied(&mut self, reply: Result<Reply, ProviderError>) -> Command<Msg> {
self.task = None;
self.live.clear();
let reply = match reply {
Ok(reply) => reply,
Err(error) => return self.stop_with(Notice::Provider(error.to_string())),
};
self.tokens.add(&reply);
let stop = reply.stop.clone();
self.conversation.reply(reply.content);
self.save();
match stop {
Stop::ToolUse => {
self.queue = self.conversation.open_calls().into();
self.results.clear();
self.next()
}
Stop::MaxTokens => {
self.answer_untaken();
self.stop_with(Notice::Cut)
}
Stop::EndTurn | Stop::Other(_) => {
self.answer_untaken();
self.phase = Phase::Idle;
Self::to_input()
}
}
}
fn answer_untaken(&mut self) {
let open = self.conversation.open_calls();
if open.is_empty() {
return;
}
let results: Vec<Value> = open
.iter()
.map(|call| {
let reason = Action::from_call(&call.name, &call.input).err().unwrap_or_else(|| TURN_ENDED.to_owned());
conversation::result(&call.id, &reason, true)
})
.collect();
self.conversation.results(results);
self.save();
}
fn next(&mut self) -> Command<Msg> {
let Some(call) = self.queue.pop_front() else {
self.conversation.results(std::mem::take(&mut self.results));
self.save();
return Command::batch([self.ask(), Self::to_input()]);
};
let action = match Action::from_call(&call.name, &call.input) {
Ok(action) => action,
Err(reason) => return self.answer(&call.id, &reason, true),
};
if !action.needs_approval() {
return self.act(call, action);
}
match self.workspace.preview(&action) {
Ok(preview) => {
self.previews.insert(call.id.clone(), preview.clone());
self.card += 1;
self.phase = Phase::Asking { call, action, preview, ready: false };
Command::none()
}
Err(reason) => self.answer(&call.id, &reason, true),
}
}
fn answer(&mut self, id: &str, text: &str, is_error: bool) -> Command<Msg> {
self.results.push(conversation::result(id, text, is_error));
self.next()
}
fn act(&mut self, call: Call, action: Action) -> Command<Msg> {
let workspace = self.workspace.clone();
let round = self.round;
let id = call.id.clone();
let doing = action.clone();
let shown = self.previews.get(&call.id).cloned();
let task = Task::new("action", move |cx| {
let mut on_tail = |tail: &str| cx.send(Msg::Tail(round, tail.to_owned()));
let outcome = workspace.perform_watching(&doing, shown.as_ref(), &|| cx.is_cancelled(), &mut on_tail);
Ok(Msg::Done(round, id, outcome))
});
self.tail.clear();
self.task = Some(task.id());
self.phase = Phase::Acting { call, action };
Command::batch([Command::task(task), Self::to_input()])
}
fn stop(&mut self) -> Command<Msg> {
let cancel = self.task.take().map_or_else(Command::none, Command::cancel_task);
self.round += 1;
match std::mem::take(&mut self.phase) {
Phase::Idle => return cancel,
Phase::Replying => {
let text: String = self
.live
.iter()
.filter_map(|live| if let Live::Text(text) = live { Some(text.as_str()) } else { None })
.collect();
if !text.trim().is_empty() {
self.conversation.reply(vec![serde_json::json!({"type": "text", "text": text})]);
}
self.live.clear();
}
Phase::Asking { call, .. } => self.answer_stopped(&call, PERSON_STOPPED),
Phase::Acting { call, .. } => self.answer_stopped(&call, STOPPED_WHILE_DOING),
}
self.save();
self.notice = Some(Notice::Stopped);
Command::batch([cancel, Self::to_input()])
}
fn answer_stopped(&mut self, call: &Call, sentence: &str) {
self.results.push(conversation::result(&call.id, sentence, true));
for waiting in std::mem::take(&mut self.queue) {
self.results.push(conversation::result(&waiting.id, PERSON_STOPPED, true));
}
self.conversation.results(std::mem::take(&mut self.results));
}
fn new_conversation(&mut self) -> Command<Msg> {
let stopped = self.stop();
if let Err(error) = self.store.put_aside() {
self.notice = Some(Notice::Unsaved(error.to_string()));
return stopped;
}
self.conversation = Conversation::default();
self.agents = instructions::agents(self.workspace.root()).is_some();
self.previews.clear();
self.notice = None;
self.tokens = Tokens::default();
self.turn += 1;
Command::batch([stopped, Self::to_input()])
}
fn save_provider(&mut self) -> Command<Msg> {
if self.provider.source == Source::Profile {
return Command::none();
}
let Some(form) = self.form.as_mut() else { return Command::none() };
let Some(provider) = form.check() else {
return form.errors().focus_first();
};
provider.write(&mut self.settings);
if let Err(error) = self.settings.save() {
form.failed(&error);
return Command::none();
}
self.provider = provider;
self.form = None;
if matches!(self.notice, Some(Notice::Unset | Notice::Key(_))) {
self.notice = None;
}
Self::to_input()
}
}
impl App for QCli {
type Msg = Msg;
fn init(&mut self) -> Command<Msg> {
let focus = match &self.form {
Some(_) => Command::focus(form::FIRST),
None => Self::to_input(),
};
Command::batch([focus, self.ask_for_update()])
}
fn update(&mut self, msg: Msg) -> Command<Msg> {
match msg {
Msg::Draft(text) => {
self.draft = text;
Command::none()
}
Msg::Send => self.send(),
Msg::Delta(round, delta) if round == self.round => {
match (delta, self.live.last_mut()) {
(Delta::Text(piece), Some(Live::Text(text)))
| (Delta::Thinking(piece), Some(Live::Thinking(text))) => {
text.push_str(&piece);
}
(Delta::Text(piece), _) => self.live.push(Live::Text(piece)),
(Delta::Thinking(piece), _) => self.live.push(Live::Thinking(piece)),
(Delta::ToolCall(name), _) => self.live.push(Live::Call(name)),
}
Command::none()
}
Msg::Replied(round, reply) if round == self.round => self.replied(reply),
Msg::Tail(round, tail) if round == self.round => {
self.tail = tail;
Command::none()
}
Msg::Done(round, id, outcome) if round == self.round => {
self.tail.clear();
self.task = None;
self.answer(&id, &outcome.text, outcome.is_error)
}
Msg::Delta(..) | Msg::Replied(..) | Msg::Done(..) | Msg::Tail(..) => Command::none(),
Msg::Quiet(card, true) if card == self.card => match &mut self.phase {
Phase::Asking { ready, .. } => {
*ready = true;
Command::none()
}
_ => Command::none(),
},
Msg::Quiet(..) => Command::none(),
Msg::Allow => match std::mem::take(&mut self.phase) {
Phase::Asking { call, action, ready: true, .. } => self.act(call, action),
other => {
self.phase = other;
Command::none()
}
},
Msg::Deny => match std::mem::take(&mut self.phase) {
Phase::Asking { call, ready: true, .. } => {
self.phase = Phase::Idle;
self.answer(&call.id, DECLINED, true)
}
other => {
self.phase = other;
Command::none()
}
},
Msg::Stop => self.stop(),
Msg::New => self.new_conversation(),
Msg::OpenProvider if self.provider.source == Source::Profile => {
self.notice = Some(Notice::Profile);
Command::none()
}
Msg::OpenProvider => {
self.form = Some(ProviderForm::from_provider(&self.provider));
Command::focus(form::FIRST)
}
Msg::Form(field, value) => {
if let Some(form) = self.form.as_mut() {
form.set(field, value);
}
Command::none()
}
Msg::SaveProvider => self.save_provider(),
Msg::PrivateKeyFile => {
if let Some(form) = self.form.as_mut() {
form.make_key_file_private();
}
Command::none()
}
Msg::NewVersion(update) => Command::toast(update.toast()),
Msg::CloseProvider => {
self.form = None;
Self::to_input()
}
}
}
fn action(&self, name: &str) -> Option<Msg> {
if self.form.is_some() {
return None;
}
match name {
"stop" if self.busy() => Some(Msg::Stop),
"allow" if matches!(self.phase, Phase::Asking { ready: true, .. }) => Some(Msg::Allow),
"deny" if matches!(self.phase, Phase::Asking { ready: true, .. }) => Some(Msg::Deny),
"new" => Some(Msg::New),
"provider" => Some(Msg::OpenProvider),
_ => None,
}
}
fn view(&self, ui: &mut View<'_, Msg>) {
view::view(self, ui);
}
}
pub fn run() -> io::Result<()> {
let folder = std::env::current_dir()?;
let workspace = Workspace::new(&folder)?;
let conversations = Ecosystem::QUVYTA
.state_dir(config::APP)
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"qcli keeps its conversations in the state folder (XDG_STATE_HOME, or ~/.local/state), and neither is known",
)
})?
.join("conversations");
let store = Store::new(&conversations, workspace.root());
let app = opening(workspace, store, config::load(), |name| std::env::var(name).ok(), UpdateFolders::here())
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
runtime(app).run()
}
fn opening(
workspace: Workspace,
store: Store,
settings: Settings,
read: impl Fn(&str) -> Option<String>,
updates: Option<UpdateFolders>,
) -> Result<QCli, config::ProfileError> {
let app = QCli::new(workspace, store, settings);
Ok(match Provider::from_environment(read)? {
Some(provider) => app.on_profile(provider),
None => app.update_notice(updates),
})
}
fn runtime(app: QCli) -> Runtime<QCli> {
let mut runtime = Runtime::new(app).member(Ecosystem::QUVYTA, config::APP).keymap_source("keymap.toml", KEYMAP);
for &(file, text) in crate::locales() {
runtime = runtime.locale_source(file, text);
}
runtime
}