Add /project and /unproject commands for project-specific context
- Add Project struct in crates/g3-cli/src/project.rs with file loading logic - Load brief.md, contacts.yaml, status.md from project path - Load projects.md from workspace root for cross-project context - Project content appended to system message (survives compaction/dehydration) - /project <path> loads project and auto-submits prompt asking about state - /unproject clears project content and resets context - Add set_project_content(), clear_project_content(), has_project_content() to Agent - Add new_for_test_with_readme() for testing with custom README content - Add 6 unit tests for Project struct - Add 9 integration tests for project context behavior
This commit is contained in:
@@ -4,6 +4,8 @@
|
||||
|
||||
use anyhow::Result;
|
||||
use rustyline::Editor;
|
||||
use std::path::PathBuf;
|
||||
use crossterm::style::{Color, SetForegroundColor, ResetColor};
|
||||
|
||||
use g3_core::ui_writer::UiWriter;
|
||||
use g3_core::Agent;
|
||||
@@ -11,6 +13,7 @@ use g3_core::Agent;
|
||||
use crate::completion::G3Helper;
|
||||
use crate::g3_status::{G3Status, Status};
|
||||
use crate::simple_output::SimpleOutput;
|
||||
use crate::project::Project;
|
||||
use crate::template::process_template;
|
||||
use crate::task_execution::execute_task_with_retry;
|
||||
|
||||
@@ -18,7 +21,9 @@ use crate::task_execution::execute_task_with_retry;
|
||||
pub async fn handle_command<W: UiWriter>(
|
||||
input: &str,
|
||||
agent: &mut Agent<W>,
|
||||
workspace_dir: &std::path::Path,
|
||||
output: &SimpleOutput,
|
||||
active_project: &mut Option<Project>,
|
||||
rl: &mut Editor<G3Helper, rustyline::history::DefaultHistory>,
|
||||
show_prompt: bool,
|
||||
show_code: bool,
|
||||
@@ -34,6 +39,8 @@ pub async fn handle_command<W: UiWriter>(
|
||||
output.print(" /fragments - List dehydrated context fragments (ACD)");
|
||||
output.print(" /rehydrate - Restore a dehydrated fragment by ID");
|
||||
output.print(" /resume - List and switch to a previous session");
|
||||
output.print(" /project <path> - Load a project from the given absolute path");
|
||||
output.print(" /unproject - Unload the current project and reset context");
|
||||
output.print(" /dump - Dump entire context window to file for debugging");
|
||||
output.print(" /readme - Reload README.md and AGENTS.md from disk");
|
||||
output.print(" /stats - Show detailed context and performance statistics");
|
||||
@@ -317,6 +324,77 @@ pub async fn handle_command<W: UiWriter>(
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
cmd if cmd.starts_with("/project") => {
|
||||
let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
|
||||
if parts.len() < 2 || parts[1].trim().is_empty() {
|
||||
output.print("Usage: /project <absolute-path>");
|
||||
output.print("Loads project files (brief.md, contacts.yaml, status.md) from the given path.");
|
||||
} else {
|
||||
let project_path_str = parts[1].trim();
|
||||
|
||||
// Expand tilde if present
|
||||
let project_path = if project_path_str.starts_with("~/") {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(&project_path_str[2..])
|
||||
} else {
|
||||
PathBuf::from(project_path_str)
|
||||
}
|
||||
} else {
|
||||
PathBuf::from(project_path_str)
|
||||
};
|
||||
|
||||
// Validate path is absolute
|
||||
if !project_path.is_absolute() {
|
||||
output.print("❌ Project path must be absolute (e.g., /Users/name/projects/myproject)");
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// Validate path exists
|
||||
if !project_path.exists() {
|
||||
output.print(&format!("❌ Project path does not exist: {}", project_path.display()));
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// Load the project
|
||||
match Project::load(&project_path, workspace_dir) {
|
||||
Some(project) => {
|
||||
// Set project content in agent's system message
|
||||
if agent.set_project_content(Some(project.content.clone())) {
|
||||
// Print loaded status
|
||||
print!(
|
||||
"{}Project loaded:{} {}\n",
|
||||
SetForegroundColor(Color::Green),
|
||||
ResetColor,
|
||||
project.format_loaded_status()
|
||||
);
|
||||
|
||||
// Store active project
|
||||
*active_project = Some(project);
|
||||
|
||||
// Auto-submit the project status prompt
|
||||
let prompt = "what is the current state of the project? and what is your suggested next best step?";
|
||||
execute_task_with_retry(agent, prompt, show_prompt, show_code, output).await;
|
||||
} else {
|
||||
output.print("❌ Failed to set project content in agent context.");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
output.print("❌ No project files found (brief.md, contacts.yaml, status.md).");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
"/unproject" => {
|
||||
if active_project.is_some() {
|
||||
agent.clear_project_content();
|
||||
*active_project = None;
|
||||
output.print("✅ Project unloaded. Context reset to original system message.");
|
||||
} else {
|
||||
output.print("No project is currently loaded.");
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
_ => {
|
||||
output.print(&format!(
|
||||
"❌ Unknown command: {}. Type /help for available commands.",
|
||||
|
||||
@@ -14,6 +14,7 @@ use g3_core::Agent;
|
||||
use crate::commands::handle_command;
|
||||
use crate::display::{LoadedContent, print_loaded_status, print_project_heading, print_workspace_path};
|
||||
use crate::g3_status::{G3Status, Status};
|
||||
use crate::project::Project;
|
||||
use crate::project_files::extract_readme_heading;
|
||||
use crate::simple_output::SimpleOutput;
|
||||
use crate::task_execution::execute_task_with_retry;
|
||||
@@ -142,6 +143,9 @@ pub async fn run_interactive<W: UiWriter>(
|
||||
let mut multiline_buffer = String::new();
|
||||
let mut in_multiline = false;
|
||||
|
||||
// Track active project
|
||||
let mut active_project: Option<Project> = None;
|
||||
|
||||
loop {
|
||||
// Display context window progress bar before each prompt
|
||||
display_context_progress(&agent, &output);
|
||||
@@ -222,7 +226,7 @@ pub async fn run_interactive<W: UiWriter>(
|
||||
|
||||
// Check for control commands
|
||||
if input.starts_with('/') {
|
||||
if handle_command(&input, &mut agent, &output, &mut rl, show_prompt, show_code).await? {
|
||||
if handle_command(&input, &mut agent, workspace_path, &output, &mut active_project, &mut rl, show_prompt, show_code).await? {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ mod utils;
|
||||
mod g3_status;
|
||||
mod template;
|
||||
mod completion;
|
||||
mod project;
|
||||
|
||||
use anyhow::Result;
|
||||
use std::path::PathBuf;
|
||||
|
||||
194
crates/g3-cli/src/project.rs
Normal file
194
crates/g3-cli/src/project.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
//! Project loading and management for the /project command.
|
||||
//!
|
||||
//! Projects allow loading context from a specific project directory that persists
|
||||
//! in the system message and survives compaction/dehydration.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Represents an active project with its loaded content.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Project {
|
||||
/// Absolute path to the project directory
|
||||
pub path: PathBuf,
|
||||
/// Combined content blob to append to system message
|
||||
pub content: String,
|
||||
/// List of files that were successfully loaded
|
||||
pub loaded_files: Vec<String>,
|
||||
}
|
||||
|
||||
impl Project {
|
||||
/// Load a project from the given absolute path.
|
||||
///
|
||||
/// Loads the following files if present (skips missing silently):
|
||||
/// - brief.md
|
||||
/// - contacts.yaml
|
||||
/// - status.md
|
||||
///
|
||||
/// Also loads projects.md from the workspace root if present.
|
||||
pub fn load(project_path: &Path, workspace_dir: &Path) -> Option<Self> {
|
||||
let mut content_parts = Vec::new();
|
||||
let mut loaded_files = Vec::new();
|
||||
|
||||
// Load workspace-level projects.md if present
|
||||
let projects_md_path = workspace_dir.join("projects.md");
|
||||
if projects_md_path.exists() {
|
||||
if let Ok(projects_content) = std::fs::read_to_string(&projects_md_path) {
|
||||
content_parts.push(format!(
|
||||
"=== PROJECT INSTRUCTIONS ===\n{}\n=== END PROJECT INSTRUCTIONS ===",
|
||||
projects_content.trim()
|
||||
));
|
||||
loaded_files.push("projects.md".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Load project-specific files
|
||||
let project_files = ["brief.md", "contacts.yaml", "status.md"];
|
||||
let mut project_content_parts = Vec::new();
|
||||
|
||||
for filename in &project_files {
|
||||
let file_path = project_path.join(filename);
|
||||
if file_path.exists() {
|
||||
if let Ok(file_content) = std::fs::read_to_string(&file_path) {
|
||||
let section_name = match *filename {
|
||||
"brief.md" => "Brief",
|
||||
"contacts.yaml" => "Contacts",
|
||||
"status.md" => "Status",
|
||||
_ => filename,
|
||||
};
|
||||
project_content_parts.push(format!(
|
||||
"## {}\n{}",
|
||||
section_name,
|
||||
file_content.trim()
|
||||
));
|
||||
loaded_files.push(filename.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we loaded any project-specific files, add the active project header
|
||||
if !project_content_parts.is_empty() {
|
||||
content_parts.push(format!(
|
||||
"=== ACTIVE PROJECT: {} ===\n{}",
|
||||
project_path.display(),
|
||||
project_content_parts.join("\n\n")
|
||||
));
|
||||
}
|
||||
|
||||
// Only return a project if we loaded something
|
||||
if loaded_files.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Project {
|
||||
path: project_path.to_path_buf(),
|
||||
content: content_parts.join("\n\n"),
|
||||
loaded_files,
|
||||
})
|
||||
}
|
||||
|
||||
/// Format the loaded files status message (e.g., "✓ brief.md ✓ status.md")
|
||||
pub fn format_loaded_status(&self) -> String {
|
||||
self.loaded_files
|
||||
.iter()
|
||||
.map(|f| format!("✓ {}", f))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_format_loaded_status() {
|
||||
let project = Project {
|
||||
path: PathBuf::from("/test/project"),
|
||||
content: String::new(),
|
||||
loaded_files: vec!["brief.md".to_string(), "status.md".to_string()],
|
||||
};
|
||||
assert_eq!(project.format_loaded_status(), "✓ brief.md ✓ status.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_loaded_status_single_file() {
|
||||
let project = Project {
|
||||
path: PathBuf::from("/test/project"),
|
||||
content: String::new(),
|
||||
loaded_files: vec!["brief.md".to_string()],
|
||||
};
|
||||
assert_eq!(project.format_loaded_status(), "✓ brief.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_project_with_all_files() {
|
||||
let workspace = TempDir::new().unwrap();
|
||||
let project_dir = TempDir::new().unwrap();
|
||||
|
||||
// Create project files
|
||||
fs::write(project_dir.path().join("brief.md"), "Project brief").unwrap();
|
||||
fs::write(project_dir.path().join("contacts.yaml"), "contacts: []").unwrap();
|
||||
fs::write(project_dir.path().join("status.md"), "In progress").unwrap();
|
||||
|
||||
let project = Project::load(project_dir.path(), workspace.path()).unwrap();
|
||||
|
||||
assert_eq!(project.loaded_files.len(), 3);
|
||||
assert!(project.loaded_files.contains(&"brief.md".to_string()));
|
||||
assert!(project.loaded_files.contains(&"contacts.yaml".to_string()));
|
||||
assert!(project.loaded_files.contains(&"status.md".to_string()));
|
||||
assert!(project.content.contains("=== ACTIVE PROJECT:"));
|
||||
assert!(project.content.contains("## Brief"));
|
||||
assert!(project.content.contains("## Contacts"));
|
||||
assert!(project.content.contains("## Status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_project_with_workspace_projects_md() {
|
||||
let workspace = TempDir::new().unwrap();
|
||||
let project_dir = TempDir::new().unwrap();
|
||||
|
||||
// Create workspace projects.md
|
||||
fs::write(workspace.path().join("projects.md"), "Global project instructions").unwrap();
|
||||
|
||||
// Create one project file
|
||||
fs::write(project_dir.path().join("brief.md"), "Project brief").unwrap();
|
||||
|
||||
let project = Project::load(project_dir.path(), workspace.path()).unwrap();
|
||||
|
||||
assert_eq!(project.loaded_files.len(), 2);
|
||||
assert!(project.loaded_files.contains(&"projects.md".to_string()));
|
||||
assert!(project.loaded_files.contains(&"brief.md".to_string()));
|
||||
assert!(project.content.contains("=== PROJECT INSTRUCTIONS ==="));
|
||||
assert!(project.content.contains("=== END PROJECT INSTRUCTIONS ==="));
|
||||
assert!(project.content.contains("=== ACTIVE PROJECT:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_project_missing_files() {
|
||||
let workspace = TempDir::new().unwrap();
|
||||
let project_dir = TempDir::new().unwrap();
|
||||
|
||||
// Create only one file
|
||||
fs::write(project_dir.path().join("status.md"), "Status only").unwrap();
|
||||
|
||||
let project = Project::load(project_dir.path(), workspace.path()).unwrap();
|
||||
|
||||
assert_eq!(project.loaded_files.len(), 1);
|
||||
assert!(project.loaded_files.contains(&"status.md".to_string()));
|
||||
assert!(!project.content.contains("## Brief"));
|
||||
assert!(project.content.contains("## Status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_project_no_files() {
|
||||
let workspace = TempDir::new().unwrap();
|
||||
let project_dir = TempDir::new().unwrap();
|
||||
|
||||
// No files created
|
||||
let project = Project::load(project_dir.path(), workspace.path());
|
||||
|
||||
assert!(project.is_none());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user