use std::path::{Path, PathBuf};
use argonauts_core::NoteLevel;
use argonauts_core::app::{AppEvent, ChoiceKind};
use argonauts_core::events::approval::{AnswerAction, AnswerOption};
use argonauts_core::layout::{project_files, user_files};
use argonauts_core::names::{ChoiceId, ModelRef, RoleName, TurnId};
use argonauts_core::prompt_files::{self, PROJECT_DIR, PromptDirs, RoleFileMode};
use argonauts_core::prompt_parts::Readers;
use argonauts_core::required::{self, RequiredStep};
use argonauts_core::settings::SettingScope;
use argonauts_core::settings::crew::CrewRole;
use argonauts_core::settings::limit::OnHit;
use unicode_width::UnicodeWidthStr;
use super::form::{Place, Proposal, RoleChange};
use super::{Call, ConversationState, fail};
use crate::choices::{self, choose, option};
use crate::controller::{Ctx, Effect};
pub(super) struct Open {
turn: TurnId,
call: Call,
proposal: Proposal,
}
const TITLE: &str = "Your setup";
const NOT_NOW: &str = "not_now";
const MODS_DIR: &str = "mods";
pub(super) fn open(
ctx: &mut Ctx,
conversation: &mut ConversationState,
call: Call,
proposal: Proposal,
) {
let id = ctx.next_choice_id();
let mut body: Vec<String> = rows(ctx, &proposal).iter().map(Row::marked).collect();
body.push(String::new());
body.extend(what_each_place_writes(ctx, &proposal));
let mut choice = choices::app_choice(
id.clone(),
ChoiceKind::Setup,
TITLE,
body,
options(ctx, &proposal),
);
choice.bell = true;
conversation.talk.questions.insert(
id,
Open {
turn: call.turn.clone(),
call,
proposal,
},
);
ctx.send(AppEvent::Choice { choice });
}
pub(super) fn keep_as_said(
ctx: &mut Ctx,
conversation: &mut ConversationState,
call: Call,
proposal: Proposal,
place: Place,
autopilot: bool,
) -> Vec<Effect> {
let said = format!("{TITLE} · {}, as you said", label(place));
if !autopilot {
let kept = Kept {
place,
picked: Picked::NoQuestion,
told_first: String::new(),
};
return keep(ctx, conversation, call, proposal, kept, &said);
}
let (proposal, yours) = without_yours(ctx, proposal);
if only_yours(&proposal, &yours) {
return nothing_changed(ctx, call, &yours);
}
let heading = match yours.as_slice() {
[] => said,
yours => format!("{said}, {}.", except(yours)),
};
let kept = Kept {
place,
picked: Picked::NothingToConfirm,
told_first: told_yours(&yours),
};
keep(ctx, conversation, call, proposal, kept, &heading)
}
const NOBODY_ASKED: &str = "The user is on autopilot, so nobody was asked where it applies. ";
pub(super) fn keep_on_autopilot(
ctx: &mut Ctx,
conversation: &mut ConversationState,
call: Call,
proposal: Proposal,
) -> Vec<Effect> {
let (proposal, yours) = without_yours(ctx, proposal);
if only_yours(&proposal, &yours) {
return nothing_changed(ctx, call, &yours);
}
let (kept, heading) = kept_on_autopilot(&proposal, &yours);
keep(ctx, conversation, call, proposal, kept, &heading)
}
fn kept_on_autopilot(proposal: &Proposal, yours: &[String]) -> (Kept, String) {
let (place, held) = if proposal.request_too {
(Place::Request, "this request")
} else {
(Place::Conversation, "this conversation")
};
let heading = match yours {
[] => format!(
"Decided on autopilot · your setup holds for {held}. Ask to save it for this \
repository or all of them."
),
yours => format!(
"Decided on autopilot · your setup holds for {held}, {}. Ask to save the rest for \
this repository or all of them.",
except(yours)
),
};
let kept = Kept {
place,
picked: Picked::NothingToConfirm,
told_first: format!("{NOBODY_ASKED}{}", told_yours(yours)),
};
(kept, heading)
}
pub(super) fn settle_on_autopilot(
ctx: &mut Ctx,
conversation: &mut ConversationState,
) -> Vec<Effect> {
let mut open: Vec<ChoiceId> = conversation.talk.questions.keys().cloned().collect();
open.sort_by_key(|id| id.to_string());
let mut effects = Vec::new();
for id in open {
let Some(Open { call, proposal, .. }) = conversation.talk.questions.remove(&id) else {
continue;
};
let (proposal, yours) = without_yours(ctx, proposal);
if only_yours(&proposal, &yours) {
ctx.send(AppEvent::ChoiceClosed {
id,
outcome: nothing_changed_line(&yours),
});
call.answer(format!("{NOBODY_ASKED}{}", told_yours(&yours)));
continue;
}
let (kept, outcome) = kept_on_autopilot(&proposal, &yours);
ctx.send(AppEvent::ChoiceClosed { id, outcome });
effects.extend(apply_and_answer(ctx, conversation, call, &proposal, kept));
}
effects
}
fn only_yours(proposal: &Proposal, yours: &[String]) -> bool {
!yours.is_empty()
&& proposal.settings.is_empty()
&& proposal.roles.is_empty()
&& proposal.prompts.is_empty()
&& proposal.mods.is_empty()
&& proposal.steps.is_empty()
}
fn nothing_changed(ctx: &Ctx, call: Call, yours: &[String]) -> Vec<Effect> {
ctx.notice(NoteLevel::Info, nothing_changed_line(yours));
call.answer(format!("{NOBODY_ASKED}{}", told_yours(yours)));
Vec::new()
}
fn nothing_changed_line(yours: &[String]) -> String {
let (are, them) = match yours {
[_] => ("is a safety setting", "it"),
_ => ("are safety settings", "them"),
};
format!(
"Decided on autopilot · nothing changed: {} {are}, yours to confirm, and /config sets \
{them}.",
in_words(yours)
)
}
fn without_yours(ctx: &Ctx, mut proposal: Proposal) -> (Proposal, Vec<String>) {
let now = toml::Value::try_from(ctx.settings()).ok();
let mut yours = Vec::new();
proposal.settings.retain(|(key, value)| {
let safety = argonauts_registry::safety_keys_in(key, value);
if safety.is_empty() || only_tightens(now.as_ref(), key, value) {
return true;
}
yours.extend(safety);
false
});
(proposal, yours)
}
fn only_tightens(now: Option<&toml::Value>, key: &str, value: &str) -> bool {
let new = format!("value = {value}")
.parse::<toml::Table>()
.ok()
.and_then(|mut table| table.remove("value"));
let current = now.and_then(|now| {
key.split('.')
.try_fold(now, |value, segment| value.as_table()?.get(segment))
});
new.is_some_and(|new| argonauts_registry::tightens(key, &new, current))
}
fn except(yours: &[String]) -> String {
let (a_setting, it) = match yours {
[_] => ("a safety setting is", "it"),
_ => ("safety settings are", "them"),
};
format!(
"except {}: {a_setting} yours to confirm, and /config sets {it}",
in_words(yours)
)
}
fn told_yours(yours: &[String]) -> String {
match yours {
[] => String::new(),
[_] => format!(
"{} wasn't changed: it is a safety setting, which only the user confirms, with \
/config. ",
in_words(yours)
),
_ => format!(
"{} weren't changed: they are safety settings, which only the user confirms, with \
/config. ",
in_words(yours)
),
}
}
fn in_words(names: &[String]) -> String {
match names {
[] => String::new(),
[one] => one.clone(),
[rest @ .., last] => format!("{} and {last}", rest.join(", ")),
}
}
struct Kept {
place: Place,
picked: Picked,
told_first: String,
}
fn keep(
ctx: &mut Ctx,
conversation: &mut ConversationState,
call: Call,
proposal: Proposal,
kept: Kept,
heading: &str,
) -> Vec<Effect> {
let mut lines = vec![heading.to_owned()];
lines.extend(rows(ctx, &proposal).iter().map(Row::plain_text));
ctx.notice(NoteLevel::Info, lines.join("\n"));
apply_and_answer(ctx, conversation, call, &proposal, kept)
}
fn apply_and_answer(
ctx: &mut Ctx,
conversation: &mut ConversationState,
call: Call,
proposal: &Proposal,
kept: Kept,
) -> Vec<Effect> {
let turn = call.turn.clone();
match apply(ctx, conversation, &turn, proposal, kept.place, kept.picked) {
Ok((effects, told)) => {
call.answer(format!("{}{told}", kept.told_first));
effects
}
Err(err) => {
fail(
call,
format!("Writing it failed: {err:#}. Nothing else was changed."),
);
ctx.notice(NoteLevel::Warn, format!("The setup wasn't kept: {err:#}"));
Vec::new()
}
}
}
pub(super) fn answer(
ctx: &mut Ctx,
conversation: &mut ConversationState,
id: ChoiceId,
action: AnswerAction,
) -> anyhow::Result<Vec<Effect>> {
let place = match &action {
AnswerAction::Choose { value } if value == NOT_NOW => None,
AnswerAction::Cancel => None,
AnswerAction::Choose { value } => match Place::parse(value) {
Some(place) => Some(place),
None => anyhow::bail!("{value:?} isn't a place to keep a setup"),
},
other => anyhow::bail!("{other:?} doesn't say where to keep the setup"),
};
let Some(open) = conversation.talk.questions.remove(&id) else {
ctx.notice(NoteLevel::Info, "That question is already closed.");
return Ok(Vec::new());
};
let Some(place) = place else {
close_not_now(ctx, id, open);
return Ok(Vec::new());
};
ctx.send(AppEvent::ChoiceClosed {
id,
outcome: label(place).to_owned(),
});
match apply(
ctx,
conversation,
&open.turn,
&open.proposal,
place,
Picked::OnTheQuestion,
) {
Ok((effects, told)) => {
open.call.answer(told);
Ok(effects)
}
Err(err) => {
fail(
open.call,
format!(
"The user picked {}, and writing it failed: {err:#}",
label(place)
),
);
Err(err)
}
}
}
pub(super) fn not_now(
ctx: &mut Ctx,
conversation: &mut ConversationState,
id: ChoiceId,
) -> Vec<Effect> {
if let Some(open) = conversation.talk.questions.remove(&id) {
close_not_now(ctx, id, open);
}
Vec::new()
}
fn close_not_now(ctx: &Ctx, id: ChoiceId, open: Open) {
ctx.send(AppEvent::ChoiceClosed {
id,
outcome: "Not now. Nothing changed.".to_owned(),
});
open.call
.answer("The user said not now, so nothing changed. Don't ask again unless they do.");
}
fn label(place: Place) -> &'static str {
match place {
Place::Request => "This request",
Place::Conversation => "This conversation",
Place::Repository => "Save for this repository",
Place::AllRepositories => "Save for all my repositories",
}
}
fn options(ctx: &Ctx, proposal: &Proposal) -> Vec<AnswerOption> {
let mut places = Vec::new();
if proposal.request_too {
places.push(('r', Place::Request));
}
places.extend([
('c', Place::Conversation),
('p', Place::Repository),
('e', Place::AllRepositories),
]);
let mut options: Vec<AnswerOption> = places
.into_iter()
.enumerate()
.map(|(at, (key, place))| {
let mut picked = option(key, label(place), choose(place.value()), at == 0);
picked.detail = detail(ctx, proposal, place);
picked
})
.collect();
options.push(option('n', "Not now", choose(NOT_NOW), false));
options.push(option(
'd',
"Show what would be written",
AnswerAction::ShowDiff,
false,
));
options
}
fn detail(ctx: &Ctx, proposal: &Proposal, place: Place) -> Option<String> {
match place {
Place::Request | Place::Conversation => {
let waits = !proposal.prompts.is_empty() || !proposal.mods.is_empty();
waits.then(|| "prompts and mods are kept only by a save".to_owned())
}
Place::Repository | Place::AllRepositories => {
let files = files(ctx, proposal, place).len();
let folder = match place {
Place::Repository => format!("{PROJECT_DIR}/"),
_ => ctx.env.display(ctx.env.paths.home()) + "/",
};
let writes = match files {
1 => format!("writes 1 file in {folder}"),
n => format!("writes {n} files in {folder}"),
};
Some(match place {
Place::Repository => format!("{writes} · d shows the diff"),
_ => writes,
})
}
}
}
fn rows(ctx: &Ctx, proposal: &Proposal) -> Vec<Row> {
let mut cells = crew_cells(ctx, proposal);
for role in &proposal.roles {
let verb = match role.mode {
RoleFileMode::Add => "adds to",
RoleFileMode::Replace => "replaces",
};
let mut cell = Cell::new(
role.readers.heading(),
format!(
"{}/{}.md {}",
prompt_files::ROLES_DIR,
role.readers.as_str(),
quoted(&role.text)
),
);
cell.verb = Some(verb);
cells.push(cell);
}
for (key, value) in &proposal.settings {
if CrewSetting::of(key).is_some() || noted_on_its_crew_row(proposal, key) {
continue;
}
let mut cell = match key.split_once('.') {
Some((section, rest)) => Cell::new(section, format!("{rest} = {value}")),
None => Cell::new(key, value.clone()),
};
cell.note = limit_note(ctx, proposal, key);
cells.push(cell);
}
for prompt in &proposal.prompts {
cells.push(Cell::new(
"prompt",
format!(
"{}/{}.md {}",
prompt_files::PROMPTS_DIR,
prompt.template.id,
quoted(&prompt.text)
),
));
}
for file in &proposal.mods {
cells.push(Cell::new("mod", format!("{MODS_DIR}/{}.toml", file.name)));
}
for step in &proposal.steps {
let mut cell = Cell::new(
"workflow",
format!(
"{}/{} {}",
project_files::WORKFLOWS,
required::FILE,
step_said(step)
),
);
cell.verb = Some("adds to");
cells.push(cell);
}
let width = cells
.iter()
.map(|cell| cell.section.width())
.max()
.unwrap_or(0)
.max(12)
+ 2;
let noted = cells
.iter()
.filter(|cell| cell.note.is_some())
.map(Cell::width)
.max()
.unwrap_or(0)
+ 3;
let mut last = String::new();
cells
.into_iter()
.map(|cell| {
let shown = if cell.section == last {
""
} else {
&cell.section
};
let mut row = Row::default();
row.plain(format!("{shown}{}", " ".repeat(width - shown.width())));
if let Some(verb) = cell.verb {
row.dim(verb.to_owned());
row.plain(" ".to_owned());
}
row.plain(cell.text.clone());
if let Some(note) = &cell.note {
row.plain(" ".repeat(noted - cell.width()));
row.dim(note.clone());
}
last = cell.section;
row
})
.collect()
}
struct Cell {
section: String,
verb: Option<&'static str>,
text: String,
note: Option<String>,
}
impl Cell {
fn new(section: &str, text: String) -> Self {
Self {
section: section.to_owned(),
verb: None,
text,
note: None,
}
}
fn width(&self) -> usize {
self.verb.map_or(0, |verb| verb.width() + 1) + self.text.width()
}
}
#[derive(Debug, Default)]
struct Row {
parts: Vec<(String, bool)>,
}
impl Row {
fn plain(&mut self, text: String) {
self.parts.push((text, false));
}
fn dim(&mut self, text: String) {
self.parts.push((text, true));
}
fn plain_text(&self) -> String {
self.parts.iter().map(|(text, _)| text.as_str()).collect()
}
fn marked(&self) -> String {
let mut out = String::new();
let mut dim = false;
for (text, is_dim) in &self.parts {
if *is_dim != dim {
out.push('\t');
dim = *is_dim;
}
out.push_str(text);
}
out
}
}
fn noted_on_its_crew_row(proposal: &Proposal, key: &str) -> bool {
goal_role(key).is_some_and(|role| {
proposal.settings.iter().any(|(other, _)| {
CrewSetting::of(other).is_some_and(|setting| setting.role() == Some(role))
})
})
}
fn goal_role(key: &str) -> Option<RoleName> {
RoleName::parse(key.strip_prefix("roles.")?.strip_suffix(".goal")?)
}
fn goal_note(value: &str, agents: Option<&ModelRef>, ctx: &Ctx) -> Option<String> {
match value.trim_matches('"') {
"cli" => Some(match agents {
Some(agent) => format!("each task as {}'s own goal", ctx.names.cli(&agent.agent)),
None => "each task as its CLI's own goal".to_owned(),
}),
"argonauts" => Some("each task as a goal Argonauts Code keeps".to_owned()),
"off" => Some("no goal per task".to_owned()),
_ => None,
}
}
fn limit_note(ctx: &Ctx, proposal: &Proposal, key: &str) -> Option<String> {
const LIMITS: [&str; 3] = [
"limits.spend_per_run",
"limits.spend_per_day",
"limits.plan_usage",
];
if !LIMITS.contains(&key)
|| proposal
.settings
.iter()
.any(|(set, _)| set == "limits.when_reached")
{
return None;
}
let when = match ctx.settings().limits.spend.when_reached {
OnHit::Fallback => "fallback",
OnHit::Stop => "stop",
};
Some(format!("when reached: {when}"))
}
const CREW: &str = "crew";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum CrewSetting {
Main,
Members,
Role {
crew: CrewRole,
finer: bool,
role: RoleName,
},
}
impl CrewSetting {
fn of(key: &str) -> Option<Self> {
match key {
"crew.main" => Some(Self::Main),
CREW => Some(Self::Members),
_ if key.starts_with("crew.") => {
let role = match &key["crew.".len()..] {
"orchestrator" => RoleName::Orchestrator,
"worker" => RoleName::Worker,
"reviewer" => RoleName::Reviewer,
"judge" => RoleName::Judge,
_ => return None,
};
Some(Self::crew(role))
}
_ => {
let role = key.strip_prefix("roles.")?.strip_suffix(".models")?;
let role = RoleName::parse(role)?;
let crew = CrewRole::read_by(role);
let finer = CrewRole::parse(role.as_str()).is_none();
Some(Self::Role { crew, finer, role })
}
}
}
fn crew(role: RoleName) -> Self {
Self::Role {
crew: CrewRole::read_by(role),
finer: false,
role,
}
}
fn role(self) -> Option<RoleName> {
match self {
Self::Role { role, .. } => Some(role),
Self::Main | Self::Members => None,
}
}
fn word(self) -> &'static str {
match self {
Self::Main => "main",
Self::Members => "members",
Self::Role {
crew, finer: false, ..
} => crew.plural(),
Self::Role { role, .. } => role.as_str(),
}
}
}
fn crew_cells(ctx: &Ctx, proposal: &Proposal) -> Vec<Cell> {
let mut rows: Vec<(CrewSetting, String, Option<String>)> = proposal
.settings
.iter()
.filter_map(|(key, value)| {
let setting = CrewSetting::of(key)?;
let goal = setting.role().and_then(|role| {
let (_, goal) = proposal
.settings
.iter()
.find(|(key, _)| goal_role(key) == Some(role))?;
goal_note(goal, first_agent(value).as_ref(), ctx)
});
Some((setting, agents_named(ctx, value), goal))
})
.collect();
let crew = &ctx.settings().crew;
for (follower, followed) in [
(CrewRole::Worker, CrewRole::Orchestrator),
(CrewRole::Judge, CrewRole::Reviewer),
] {
let row_of = |role: CrewRole| {
rows
.iter()
.find(|(setting, ..)| matches!(setting, CrewSetting::Role { crew: of, finer: false, .. } if *of == role))
};
let follows = crew.followed(follower) == Some(followed);
if let (true, None, Some((_, agents, _))) = (follows, row_of(follower), row_of(followed)) {
let role = match follower {
CrewRole::Worker => RoleName::Worker,
_ => RoleName::Judge,
};
let note = format!("follows the {}", followed.plural());
rows.push((CrewSetting::crew(role), agents.clone(), Some(note)));
}
}
rows.sort_by_key(|(setting, ..)| *setting);
let width = rows
.iter()
.map(|(setting, ..)| setting.word().len())
.max()
.unwrap_or(0)
+ 3;
rows.into_iter()
.map(|(setting, agents, note)| Cell {
section: CREW.to_owned(),
verb: None,
text: format!("{:width$}{agents}", setting.word()),
note,
})
.collect()
}
fn first_agent(value: &str) -> Option<ModelRef> {
let parsed = toml::from_str::<toml::Table>(&format!("v = {value}"))
.ok()?
.remove("v")?;
let id = match parsed {
toml::Value::String(id) => id,
toml::Value::Array(ids) => ids.first()?.as_str()?.to_owned(),
_ => return None,
};
id.parse().ok()
}
fn agents_named(ctx: &Ctx, value: &str) -> String {
let named = |id: &str| match id.parse::<ModelRef>() {
Ok(worker) => ctx.names.worker(&worker),
Err(_) => id.to_owned(),
};
let parsed = toml::from_str::<toml::Table>(&format!("v = {value}"))
.ok()
.and_then(|mut table| table.remove("v"));
match parsed {
Some(toml::Value::String(id)) => named(&id),
Some(toml::Value::Array(ids)) if ids.is_empty() => "picked from the crew".to_owned(),
Some(toml::Value::Array(ids)) => {
let names: Option<Vec<String>> = ids.iter().map(|id| id.as_str().map(&named)).collect();
names.map_or_else(|| value.to_owned(), |names| names.join(", "))
}
_ => value.to_owned(),
}
}
fn step_said(step: &RequiredStep) -> String {
let when = step.when.as_deref().unwrap_or("before the sign-off");
format!("\"{}\" {when}, by {}", step.name, step.role.the_agents())
}
fn quoted(text: &str) -> String {
const MAX: usize = 60;
let line = text
.lines()
.find(|line| !line.trim().is_empty())
.unwrap_or_default()
.trim();
let more = text.trim().lines().count() > 1;
let mut shown: String = line.chars().take(MAX).collect();
if more || line.chars().count() > MAX {
shown.push('…');
}
format!("\"{shown}\"")
}
fn what_each_place_writes(ctx: &Ctx, proposal: &Proposal) -> Vec<String> {
let mut lines = vec![format!("You asked: {}", proposal.asked), String::new()];
for place in Place::ALL {
if place == Place::Request && !proposal.request_too {
continue;
}
lines.push(label(place).to_owned());
match place {
Place::Request | Place::Conversation => {
for (key, value) in session_settings(ctx, proposal) {
lines.push(format!(" {key} = {value}"));
}
for step in &proposal.steps {
lines.push(format!(" every workflow has {}", step_said(step)));
}
if !proposal.prompts.is_empty() || !proposal.mods.is_empty() {
lines.push(" Prompts and mods are kept only by a save.".to_owned());
}
}
Place::Repository | Place::AllRepositories => {
for (file, said) in files(ctx, proposal, place) {
lines.push(format!(" {}", ctx.env.shown(&file)));
lines.extend(said.into_iter().map(|line| format!(" {line}")));
}
}
}
lines.push(String::new());
}
lines.pop();
lines
}
fn files(ctx: &Ctx, proposal: &Proposal, place: Place) -> Vec<(PathBuf, Vec<String>)> {
let Some(dir) = folder(ctx, place) else {
return Vec::new();
};
let mut files = Vec::new();
if !proposal.settings.is_empty() {
let config = match place {
Place::Repository => ctx.env.project_config(),
_ => ctx.env.global_config(),
};
let said = proposal
.settings
.iter()
.map(|(key, value)| format!("{key} = {value}"))
.collect();
files.push((config, said));
}
for role in &proposal.roles {
let said = match role.mode {
RoleFileMode::Add => format!("adds {}", quoted(&role.text)),
RoleFileMode::Replace => format!("replaces what it reads with {}", quoted(&role.text)),
};
files.push((role_path(&dir, role.readers), vec![said]));
}
for prompt in &proposal.prompts {
let path = dir
.join(prompt_files::PROMPTS_DIR)
.join(format!("{}.md", prompt.template.id));
files.push((path, vec![quoted(&prompt.text)]));
}
for file in &proposal.mods {
let path = dir.join(MODS_DIR).join(format!("{}.toml", file.name));
files.push((path, Vec::new()));
}
if let Some(path) = always_file(&dir, place).filter(|_| !proposal.steps.is_empty()) {
let said = proposal
.steps
.iter()
.map(|step| format!("adds {}", step_said(step)))
.collect();
files.push((path, said));
}
files
}
fn always_file(dir: &Path, place: Place) -> Option<PathBuf> {
let workflows = match place {
Place::Repository => project_files::WORKFLOWS,
Place::AllRepositories => user_files::WORKFLOWS,
Place::Request | Place::Conversation => return None,
};
Some(dir.join(workflows).join(required::FILE))
}
fn folder(ctx: &Ctx, place: Place) -> Option<PathBuf> {
match place {
Place::Repository => Some(ctx.env.repo_root.join(PROJECT_DIR)),
Place::AllRepositories => Some(ctx.env.paths.home().to_path_buf()),
Place::Request | Place::Conversation => None,
}
}
fn role_path(dir: &Path, readers: Readers) -> PathBuf {
dir.join(prompt_files::ROLES_DIR)
.join(format!("{}.md", readers.as_str()))
}
fn apply(
ctx: &mut Ctx,
conversation: &mut ConversationState,
turn: &TurnId,
proposal: &Proposal,
place: Place,
picked: Picked,
) -> anyhow::Result<(Vec<Effect>, String)> {
match place {
Place::Request => {
conversation.talk.for_request = Some((turn.clone(), session_settings(ctx, proposal)));
conversation.talk.steps_for_request = Some((turn.clone(), proposal.steps.clone()));
Ok((
Vec::new(),
format!(
"The user kept it for this request: the workflow this message starts uses \
it, and nothing after it does.{} Start that workflow now.",
left_out(proposal)
),
))
}
Place::Conversation => {
for step in &proposal.steps {
conversation
.talk
.steps_for_conversation
.retain(|known| !required::same_name(&known.name, &step.name));
conversation.talk.steps_for_conversation.push(step.clone());
}
let pairs = session_settings(ctx, proposal);
let effects = save(pairs, SettingScope::Session, picked);
Ok((
effects,
format!(
"The user kept it for this conversation, until they quit Argonauts Code.{}",
left_out(proposal)
),
))
}
Place::Repository | Place::AllRepositories => {
let written = write_files(ctx, proposal, place)?;
let mut kept = written.clone();
if !proposal.settings.is_empty() && place == Place::Repository {
kept.push(ctx.env.project_config());
}
crate::controller::conversation::direct::setup_wrote(conversation, turn, &kept);
let scope = match place {
Place::Repository => SettingScope::Project,
_ => SettingScope::Global,
};
let effects = save(proposal.settings.clone(), scope, picked);
let mut shown: Vec<String> = written.iter().map(|path| ctx.env.shown(path)).collect();
if !proposal.settings.is_empty() {
let config = match place {
Place::Repository => ctx.env.project_config(),
_ => ctx.env.global_config(),
};
shown.insert(0, ctx.env.shown(&config));
}
if !written.is_empty() {
let names: Vec<String> = written.iter().map(|path| ctx.env.shown(path)).collect();
ctx.notice(NoteLevel::Info, format!("Wrote {}", names.join(", ")));
}
let whose = match place {
Place::Repository => "this repository",
_ => "all their repositories",
};
Ok((
effects,
format!("The user saved it for {whose}: {}.", shown.join(", ")),
))
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Picked {
OnTheQuestion,
NoQuestion,
NothingToConfirm,
}
fn save(pairs: Vec<(String, String)>, scope: SettingScope, picked: Picked) -> Vec<Effect> {
if pairs.is_empty() {
return Vec::new();
}
match picked {
Picked::OnTheQuestion | Picked::NothingToConfirm => {
vec![Effect::SaveConfirmedSettings { pairs, scope }]
}
Picked::NoQuestion => vec![Effect::SaveSettings { pairs, scope }],
}
}
fn left_out(proposal: &Proposal) -> &'static str {
if proposal.prompts.is_empty() && proposal.mods.is_empty() {
""
} else {
" Prompts and mods are kept only by a save to a repository or to all of them, so \
they weren't written."
}
}
fn session_settings(ctx: &Ctx, proposal: &Proposal) -> Vec<(String, String)> {
let mut pairs = proposal.settings.clone();
for change in &proposal.roles {
for role in readers_of(change) {
let before = ctx
.settings()
.roles
.get(&role)
.and_then(|settings| settings.instructions.clone())
.filter(|text| !text.trim().is_empty());
let text = match (change.mode, before) {
(RoleFileMode::Add, Some(before)) => format!("{before}\n{}", change.text),
_ => change.text.clone(),
};
let value = toml::Value::String(text).to_string();
pairs.push((format!("roles.{}.instructions", role.as_str()), value));
}
}
pairs
}
fn readers_of(change: &RoleChange) -> Vec<RoleName> {
match change.readers {
Readers::Finer(role) => vec![role],
Readers::Crew(crew) => RoleName::ALL
.into_iter()
.filter(|role| CrewRole::read_by(*role) == crew)
.collect(),
}
}
fn write_files(ctx: &Ctx, proposal: &Proposal, place: Place) -> anyhow::Result<Vec<PathBuf>> {
let Some(dir) = folder(ctx, place) else {
return Ok(Vec::new());
};
let dirs = match place {
Place::Repository => PromptDirs::new(Some(&ctx.env.repo_root), None),
_ => PromptDirs::new(None, Some(ctx.env.paths.home())),
};
let mut written = Vec::new();
for change in &proposal.roles {
let (mode, text) = match dirs.own_role(change.readers) {
Some(Ok(file)) if change.mode == RoleFileMode::Add && !file.text.trim().is_empty() => (
file.mode,
format!("{}\n\n{}", file.text.trim(), change.text),
),
_ => (change.mode, change.text.clone()),
};
written.push(prompt_files::save_role_file(
&dir,
change.readers,
mode,
&text,
)?);
}
for prompt in &proposal.prompts {
written.push(prompt_files::save_template(
&dir,
&prompt.template,
&prompt.text,
)?);
}
if place == Place::Repository && !written.is_empty() {
argonauts_crew::prompts::trust::trust(&ctx.env.state_dir, &written)?;
}
for file in &proposal.mods {
let path =
argonauts_core::settings::mods::save::save(&dir.join(MODS_DIR), &file.name, &file.text)
.map_err(|problems| anyhow::anyhow!("{}: {}", file.name, problems.join("; ")))?;
written.push(path);
}
if let Some(path) = always_file(&dir, place).filter(|_| !proposal.steps.is_empty()) {
argonauts_registry::save_required_steps(&path, &proposal.steps)
.map_err(anyhow::Error::msg)?;
written.push(path);
}
Ok(written)
}