use chrono::Local;
use crossterm::event::{KeyCode, KeyEvent};
use super::types::*;
use super::wizard::OnboardingWizard;
impl OnboardingWizard {
pub(super) fn handle_brain_setup_key(&mut self, event: KeyEvent) -> WizardAction {
if self.brain_generating {
if matches!(event.code, KeyCode::Esc | KeyCode::Enter) {
self.brain_generating = false;
self.step = OnboardingStep::Complete;
return WizardAction::Complete;
}
return WizardAction::None;
}
if self.brain_generated || self.brain_error.is_some() {
if event.code == KeyCode::Enter {
self.next_step();
return WizardAction::Complete;
}
return WizardAction::None;
}
match event.code {
KeyCode::Esc => {
self.step = OnboardingStep::Complete;
return WizardAction::Complete;
}
KeyCode::Tab => {
self.brain_field = match self.brain_field {
BrainField::AboutMe => BrainField::AboutAgent,
BrainField::AboutAgent => BrainField::AboutMe,
};
}
KeyCode::BackTab => {
self.brain_field = match self.brain_field {
BrainField::AboutMe => BrainField::AboutAgent,
BrainField::AboutAgent => BrainField::AboutMe,
};
}
KeyCode::Enter => {
if self.brain_field == BrainField::AboutAgent {
if self.about_me.is_empty() && self.about_opencrabs.is_empty() {
self.step = OnboardingStep::Complete;
return WizardAction::Complete;
}
if !self.brain_inputs_changed() && !self.original_about_me.is_empty() {
self.step = OnboardingStep::Complete;
return WizardAction::Complete;
}
self.normalize_brain_inputs();
self.preview_shown = true;
return WizardAction::GenerateBrain;
}
self.brain_field = BrainField::AboutAgent;
}
KeyCode::Char(c) => {
self.mark_brain_field_edited();
self.active_brain_field_mut().push(c);
}
KeyCode::Backspace => {
if !self.is_brain_field_edited() && !self.active_brain_field().is_empty() {
self.active_brain_field_mut().clear();
self.mark_brain_field_edited();
} else {
self.active_brain_field_mut().pop();
}
}
KeyCode::Delete
if !self.is_brain_field_edited() && !self.active_brain_field().is_empty() =>
{
self.active_brain_field_mut().clear();
self.mark_brain_field_edited();
}
KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down => {
self.mark_brain_field_edited();
}
_ => {}
}
WizardAction::None
}
fn active_brain_field(&self) -> &str {
match self.brain_field {
BrainField::AboutMe => &self.about_me,
BrainField::AboutAgent => &self.about_opencrabs,
}
}
fn active_brain_field_mut(&mut self) -> &mut String {
match self.brain_field {
BrainField::AboutMe => &mut self.about_me,
BrainField::AboutAgent => &mut self.about_opencrabs,
}
}
fn is_brain_field_edited(&self) -> bool {
match self.brain_field {
BrainField::AboutMe => self.brain_me_edited,
BrainField::AboutAgent => self.brain_agent_edited,
}
}
fn mark_brain_field_edited(&mut self) {
match self.brain_field {
BrainField::AboutMe => self.brain_me_edited = true,
BrainField::AboutAgent => self.brain_agent_edited = true,
}
}
fn brain_inputs_changed(&self) -> bool {
self.about_me != self.original_about_me
|| self.about_opencrabs != self.original_about_opencrabs
}
pub fn truncate_preview(content: &str, max_chars: usize) -> String {
let trimmed = content.trim();
if trimmed.len() <= max_chars {
trimmed.to_string()
} else {
let truncated = &trimmed[..trimmed.floor_char_boundary(max_chars)];
format!("{}...", truncated.trim_end())
}
}
pub fn normalize_brain_inputs(&mut self) {
self.formatted_about_me = auto_format_markdown(&self.about_me, "About Me");
self.formatted_about_agent = auto_format_markdown(&self.about_opencrabs, "About The Agent");
}
pub fn build_brain_prompt(&self) -> String {
let today = Local::now().format("%Y-%m-%d").to_string();
let workspace = std::path::Path::new(&self.workspace_path);
let soul_template_static = include_str!("../../docs/reference/templates/SOUL.md");
let user_template_static = include_str!("../../docs/reference/templates/USER.md");
let agents_template_static = include_str!("../../docs/reference/templates/AGENTS.md");
let tools_template_static = include_str!("../../docs/reference/templates/TOOLS.md");
let memory_template_static = include_str!("../../docs/reference/templates/MEMORY.md");
let soul_template = std::fs::read_to_string(workspace.join("SOUL.md"))
.unwrap_or_else(|_| soul_template_static.to_string());
let user_template = std::fs::read_to_string(workspace.join("USER.md"))
.unwrap_or_else(|_| user_template_static.to_string());
let agents_template = std::fs::read_to_string(workspace.join("AGENTS.md"))
.unwrap_or_else(|_| agents_template_static.to_string());
let tools_template = std::fs::read_to_string(workspace.join("TOOLS.md"))
.unwrap_or_else(|_| tools_template_static.to_string());
let memory_template = std::fs::read_to_string(workspace.join("MEMORY.md"))
.unwrap_or_else(|_| memory_template_static.to_string());
format!(
r#"You are setting up a personal AI agent's brain — its entire workspace of markdown files that define who it is, who its human is, and how it operates.
The user dumped two blocks of info. One about themselves (name, role, links, projects, whatever they shared). One about how they want their agent to be (personality, vibe, behavior). Use EVERYTHING they gave you to personalize ALL six template files below.
=== ABOUT THE USER ===
{about_me}
=== ABOUT THE AGENT ===
{about_opencrabs}
=== TODAY'S DATE ===
{date}
Below are the 5 template files. Replace ALL <placeholder> tags and HTML comments with real values based on what the user provided. Keep the exact markdown structure. Fill what you can from the user's info, leave sensible defaults for anything not provided. Don't invent facts — if the user didn't mention something, use a reasonable placeholder like "TBD" or remove that line.
===TEMPLATE: SOUL.md===
{soul}
===TEMPLATE: USER.md===
{user}
===TEMPLATE: AGENTS.md===
{agents}
===TEMPLATE: TOOLS.md===
{tools}
===TEMPLATE: MEMORY.md===
{memory}
CRITICAL OUTPUT RULES:
1. Start your response IMMEDIATELY with ---SOUL--- (no preamble, no "Here are", no commentary)
2. Use EXACTLY these delimiters on their own line: ---SOUL--- ---USER--- ---AGENTS--- ---TOOLS--- ---MEMORY---
3. After the last section (MEMORY content), STOP. No closing remarks, no "Let me know", no summary, no notes.
4. Do NOT wrap output in markdown code fences (no ```). Raw content only.
5. Each section is the complete file content — valid markdown, ready to save as-is.
---SOUL---
(generated SOUL.md content)
---USER---
(generated USER.md content)
---AGENTS---
(generated AGENTS.md content)
---TOOLS---
(generated TOOLS.md content)
---MEMORY---
(generated MEMORY.md content)"#,
about_me = if !self.formatted_about_me.is_empty() {
self.formatted_about_me.as_str()
} else if self.about_me.is_empty() {
"Not provided"
} else {
self.about_me.as_str()
},
about_opencrabs = if !self.formatted_about_agent.is_empty() {
self.formatted_about_agent.as_str()
} else if self.about_opencrabs.is_empty() {
"Not provided"
} else {
self.about_opencrabs.as_str()
},
date = today,
soul = soul_template,
user = user_template,
agents = agents_template,
tools = tools_template,
memory = memory_template,
)
}
pub fn apply_generated_brain(&mut self, response: &str) {
let parsed = parse_brain_sections(response);
if parsed[0].is_none() || parsed[1].is_none() || parsed[2].is_none() {
self.brain_error = Some("Couldn't parse AI response — using defaults".to_string());
self.brain_generating = false;
return;
}
self.generated_soul = parsed[0].clone();
self.generated_user = parsed[1].clone();
self.generated_agents = parsed[2].clone();
self.generated_tools = parsed[3].clone();
self.generated_memory = parsed[4].clone();
self.brain_generated = true;
self.brain_generating = false;
}
}
pub(crate) fn parse_brain_sections(response: &str) -> [Option<String>; 5] {
const NAMES: [&str; 5] = ["SOUL", "USER", "AGENTS", "TOOLS", "MEMORY"];
let mut hits: Vec<(usize, usize, usize)> = Vec::new();
for (i, name) in NAMES.iter().enumerate() {
if let Some((pos, len)) = find_section_header(response, name) {
hits.push((i, pos, len));
}
}
hits.sort_by_key(|(_, pos, _)| *pos);
let mut out: [Option<String>; 5] = Default::default();
for (idx, &(section, pos, len)) in hits.iter().enumerate() {
let start = pos + len;
let end = if idx + 1 < hits.len() {
hits[idx + 1].1
} else {
response.len()
};
if start > end || start > response.len() {
continue;
}
let content = response[start..end.min(response.len())].trim();
if !content.is_empty() {
out[section] = Some(content.to_string());
}
}
out
}
fn find_section_header(response: &str, name: &str) -> Option<(usize, usize)> {
let strict = format!("---{}---", name);
if let Some(pos) = response.find(&strict) {
return Some((pos, strict.len()));
}
let mut byte_offset = 0usize;
for line in response.split_inclusive('\n') {
let trimmed = line.trim();
if header_line_matches(trimmed, name) {
return Some((byte_offset, line.len()));
}
byte_offset += line.len();
}
None
}
fn header_line_matches(line: &str, name: &str) -> bool {
let stripped = line
.trim_matches(|c: char| {
c == '#'
|| c == '*'
|| c == '-'
|| c == '='
|| c == '_'
|| c == ':'
|| c.is_whitespace()
})
.to_ascii_uppercase();
let name_upper = name.to_ascii_uppercase();
stripped == name_upper || stripped == format!("{}.MD", name_upper)
}
fn looks_like_markdown(text: &str) -> bool {
let t = text.trim();
t.contains('#')
|| t.contains("```")
|| t.contains("- ")
|| t.contains("* ")
|| t.contains("##")
|| t.contains("[")
|| t.contains("![]")
|| t.contains("__")
|| t.contains("**")
|| t.starts_with("> ")
|| t.contains("|")
}
fn auto_format_markdown(input: &str, section_title: &str) -> String {
let trimmed = input.trim();
if trimmed.is_empty() || looks_like_markdown(trimmed) {
return trimmed.to_string();
}
format!(
"# {}\n\n{}\n\n## Preferences\n\n- \n\n## Boundaries\n\n- ",
section_title, trimmed
)
}