diff --git a/Cargo.lock b/Cargo.lock index 9e98201..a7cba60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,7 +100,7 @@ dependencies = [ [[package]] name = "exoshell" -version = "0.5.0" +version = "0.7.0" dependencies = [ "async-trait", "futures-util", diff --git a/Cargo.toml b/Cargo.toml index 8a5df7d..5f609e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "exoshell" -version = "0.5.0" +version = "0.7.0" edition = "2024" license = "GPL-3.0-or-later" diff --git a/README.md b/README.md index 14ffa39..65a1dfd 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ The current implementation supports the first shell-adjacent model chat mileston The codebase also contains the Phase 1.5 context engine foundation: context entries, provenance metadata, priority and size estimates, a session context store, provider registry, default manual/file/command-output/stdin/directory-summary providers, context REPL commands, deterministic pruning, budget checks, transcript events, startup context flags, piped stdin import, and prompt-context rendering. -The active milestone is Phase 3. The current implementation adds stance selection, explicit prompt assembly, visible prompt/context budget estimates, command suggestion IDs, simple risky-command warnings, command copy/explain/discard actions, a plain terminal session panel, Phase 2 help text, configurable model routing, and initial Git-native project detection. +The active milestone is Phase 3. The current implementation adds stance selection, explicit prompt assembly, visible prompt/context budget estimates, command suggestion IDs, simple risky-command warnings, command copy/explain/discard actions, a plain terminal session panel, Phase 2 help text, configurable model routing, initial Git-native project detection, attachable Git status context, and attachable Git diff context. The broader roadmap is tracked in [docs/PHASES.md](docs/PHASES.md) and [docs/tasks](docs/tasks). diff --git a/docs/quickstart.md b/docs/quickstart.md index 2814582..dfd83b4 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -203,6 +203,20 @@ Inspect detected Git project state: exo> /project ``` +Attach current Git branch and working tree state as explicit context: + +```text +exo> /add-git-status +``` + +Attach repository changes as explicit context: + +```text +exo> /add-diff +exo> /add-diff --staged +exo> /add-diff --staged src/app.rs +``` + Ask a question: ```text diff --git a/docs/versioning.md b/docs/versioning.md index 941586e..a480165 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -104,3 +104,5 @@ Historical codenames should be tracked in docs/versioning.md below * 0.3.0 stance-lantern * 0.4.0 switchboard-relic * 0.5.0 branch-oracle +* 0.6.0 status-satellite +* 0.7.0 diff-lantern diff --git a/src/app.rs b/src/app.rs index 6aa29eb..fe10815 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::path::PathBuf; use std::time::Duration; @@ -232,6 +233,15 @@ impl App { ); } + if trimmed == "/add-git-status" { + return self.add_git_status_context(); + } + + if trimmed == "/add-diff" || trimmed.starts_with("/add-diff ") { + let args = trimmed.strip_prefix("/add-diff").unwrap_or("").trim(); + return self.add_git_diff_context(args); + } + Err(ContextError::InvalidInput(format!("unknown context command: {trimmed}")).into()) } @@ -284,6 +294,47 @@ impl App { ) } + pub fn add_git_status_context(&mut self) -> Result { + let path = self.project_context_path()?; + self.add_context( + "git_status", + ContextProviderRequest { + path: Some(path), + ..ContextProviderRequest::default() + }, + ) + } + + pub fn add_git_diff_context(&mut self, input: &str) -> Result { + let (mode, file) = parse_git_diff_args(input)?; + let mut provider_options = HashMap::new(); + provider_options.insert("mode".into(), mode.to_string()); + if let Some(file) = file { + provider_options.insert("file".into(), file); + } + + let path = self.project_context_path()?; + self.add_context( + "git_diff", + ContextProviderRequest { + path: Some(path), + provider_options, + ..ContextProviderRequest::default() + }, + ) + } + + fn project_context_path(&self) -> Result { + let cwd = std::env::current_dir().map_err(|error| ProjectError::Read { + path: PathBuf::from("."), + error: error.to_string(), + })?; + Ok(detect_project(&cwd, self.config.project.root.as_deref())? + .map(|project| project.root) + .or_else(|| self.config.project.root.clone()) + .unwrap_or(cwd)) + } + pub fn save_transcript(&self) -> Result, AppError> { if !self.config.transcript.enabled { return Ok(None); @@ -572,6 +623,8 @@ fn help_overview() -> &'static str { /add-note attach manual context /add-file attach a UTF-8 file /add-dir attach a shallow directory summary +/add-git-status attach current Git branch and status +/add-diff [--staged] [path] attach unstaged or staged Git diff context /add-output paste command output as explicit context /stance [name] show or set operator, audit, teach, or quiet /copy print a suggested command; does not execute it @@ -593,6 +646,9 @@ fn help_topic(topic: &str) -> &'static str { "project" => { "Project detection walks upward from the current directory or configured project.root to find a Git repository. Use /project or /panel to inspect the detected root and branch." } + "git" => { + "Use /add-git-status to attach current branch, staged files, modified files, and untracked files. Use /add-diff, /add-diff --staged, or /add-diff --staged to attach read-only diff context." + } "stance" => { "Stances change the compact prompt fragment used for the next request: operator is concise and action-oriented, audit focuses on risks, teach explains more, and quiet minimizes prose while keeping safety warnings." } @@ -603,11 +659,31 @@ fn help_topic(topic: &str) -> &'static str { "The current line REPL does not install advanced terminal keybindings. Use /keys to see the predictable slash-command fallbacks for copy, explain, discard, context, and stance actions." } _ => { - "Unknown help topic. Try /help context, /help project, /help stance, /help commands, or /help keys." + "Unknown help topic. Try /help context, /help project, /help git, /help stance, /help commands, or /help keys." } } } +fn parse_git_diff_args(input: &str) -> Result<(&'static str, Option), ContextError> { + let mut mode = "unstaged"; + let mut file = None; + for part in input.split_whitespace() { + if part == "--staged" { + mode = "staged"; + } else if part.starts_with('-') { + return Err(ContextError::InvalidInput(format!( + "unknown /add-diff option: {part}" + ))); + } else if file.replace(part.to_string()).is_some() { + return Err(ContextError::InvalidInput( + "/add-diff accepts at most one path".into(), + )); + } + } + + Ok((mode, file)) +} + #[derive(Debug, Default, PartialEq, Eq)] pub struct CliOptions { pub config_path: Option, @@ -776,7 +852,9 @@ mod tests { "file".to_string(), "command_output".to_string(), "stdin".to_string(), - "directory_summary".to_string() + "directory_summary".to_string(), + "git_status".to_string(), + "git_diff".to_string() ] ); assert_eq!(app.context_store.total_size().characters, 0); @@ -949,6 +1027,16 @@ mod tests { .expect("help project") .contains("Git repository") ); + assert!( + app.handle_command("/help git") + .expect("help git") + .contains("/add-git-status") + ); + assert!( + app.handle_command("/help git") + .expect("help git") + .contains("/add-diff --staged") + ); assert!( app.handle_command("/help commands") .expect("help") @@ -966,6 +1054,20 @@ mod tests { ); } + #[test] + fn parses_git_diff_arguments() { + assert_eq!( + parse_git_diff_args("").expect("default"), + ("unstaged", None) + ); + assert_eq!( + parse_git_diff_args("--staged src/app.rs").expect("staged file"), + ("staged", Some("src/app.rs".to_string())) + ); + assert!(parse_git_diff_args("--cached").is_err()); + assert!(parse_git_diff_args("one two").is_err()); + } + #[tokio::test] async fn over_budget_context_fails_before_provider_request() { let seen = Arc::new(Mutex::new(Vec::new())); diff --git a/src/context.rs b/src/context.rs index 6143f6f..719ac95 100644 --- a/src/context.rs +++ b/src/context.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::fmt; use std::fs; use std::path::{Path, PathBuf}; +use std::process::Command; use std::str::FromStr; #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -63,6 +64,8 @@ pub enum ContextKind { File, CommandOutput, DirectorySummary, + GitDiff, + GitStatus, Log, Note, SearchResult, @@ -77,6 +80,8 @@ impl fmt::Display for ContextKind { Self::File => formatter.write_str("file"), Self::CommandOutput => formatter.write_str("command_output"), Self::DirectorySummary => formatter.write_str("directory_summary"), + Self::GitDiff => formatter.write_str("git_diff"), + Self::GitStatus => formatter.write_str("git_status"), Self::Log => formatter.write_str("log"), Self::Note => formatter.write_str("note"), Self::SearchResult => formatter.write_str("search_result"), @@ -307,6 +312,8 @@ pub fn register_default_context_providers( registry.register(Box::new(CommandOutputContextProvider))?; registry.register(Box::new(StdinContextProvider))?; registry.register(Box::new(DirectorySummaryContextProvider::default()))?; + registry.register(Box::new(GitStatusContextProvider))?; + registry.register(Box::new(GitDiffContextProvider::default()))?; Ok(()) } @@ -551,6 +558,267 @@ impl ContextProvider for DirectorySummaryContextProvider { } } +pub struct GitStatusContextProvider; + +impl ContextProvider for GitStatusContextProvider { + fn metadata(&self) -> ContextProviderMetadata { + ContextProviderMetadata { + name: "git_status".into(), + kind: ContextKind::GitStatus, + description: "captures read-only Git branch and working tree status".into(), + } + } + + fn collect(&self, request: ContextProviderRequest) -> Result { + let path = request + .path + .or(request.cwd) + .unwrap_or_else(|| PathBuf::from(".")); + let status = git_output(&path, &["status", "--porcelain=v1", "-b"], "git status")?; + let parsed = parse_git_status_porcelain(&status); + + let mut provenance = ContextProvenance::new(ContextOrigin::Git); + provenance.source_path = Some(path.clone()); + provenance + .provider_details + .insert("branch".into(), parsed.branch.clone()); + provenance + .provider_details + .insert("staged_count".into(), parsed.staged.len().to_string()); + provenance + .provider_details + .insert("modified_count".into(), parsed.modified.len().to_string()); + provenance + .provider_details + .insert("untracked_count".into(), parsed.untracked.len().to_string()); + + Ok(ContextEntry::new( + "", + ContextKind::GitStatus, + request.title.unwrap_or_else(|| "git status".into()), + provenance, + render_git_status_context(&parsed), + )) + } +} + +#[derive(Debug, Clone)] +pub struct GitDiffContextProvider { + pub max_characters: usize, +} + +impl Default for GitDiffContextProvider { + fn default() -> Self { + Self { + max_characters: 20_000, + } + } +} + +impl ContextProvider for GitDiffContextProvider { + fn metadata(&self) -> ContextProviderMetadata { + ContextProviderMetadata { + name: "git_diff".into(), + kind: ContextKind::GitDiff, + description: "captures read-only staged or unstaged Git diffs".into(), + } + } + + fn collect(&self, request: ContextProviderRequest) -> Result { + let path = request + .path + .or(request.cwd) + .unwrap_or_else(|| PathBuf::from(".")); + let mode = request + .provider_options + .get("mode") + .map(String::as_str) + .unwrap_or("unstaged"); + let file = request.provider_options.get("file").cloned(); + + let mut args = vec!["diff"]; + match mode { + "staged" => args.push("--staged"), + "unstaged" => {} + other => { + return Err(ContextError::InvalidInput(format!( + "unsupported git diff mode '{other}', expected staged or unstaged" + ))); + } + } + if file.is_some() { + args.push("--"); + } + if let Some(file) = file.as_deref() { + args.push(file); + } + + let diff = git_output(&path, &args, "git diff")?; + let truncated = truncate_visible(&diff, self.max_characters); + + let mut provenance = ContextProvenance::new(ContextOrigin::Git); + provenance.source_path = Some(path.clone()); + provenance + .provider_details + .insert("mode".into(), mode.to_string()); + provenance + .provider_details + .insert("file".into(), file.clone().unwrap_or_else(|| "all".into())); + provenance.provider_details.insert( + "truncated".into(), + (truncated.omitted_characters > 0).to_string(), + ); + provenance.provider_details.insert( + "omitted_characters".into(), + truncated.omitted_characters.to_string(), + ); + + Ok(ContextEntry::new( + "", + ContextKind::GitDiff, + request + .title + .unwrap_or_else(|| git_diff_title(mode, file.as_deref())), + provenance, + if truncated.content.trim().is_empty() { + format!("mode: {mode}\ndiff: none") + } else { + format!("mode: {mode}\n{}", truncated.content) + }, + )) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ParsedGitStatus { + branch: String, + staged: Vec, + modified: Vec, + untracked: Vec, +} + +fn parse_git_status_porcelain(output: &str) -> ParsedGitStatus { + let mut parsed = ParsedGitStatus { + branch: "unknown".into(), + staged: Vec::new(), + modified: Vec::new(), + untracked: Vec::new(), + }; + + for line in output.lines() { + if let Some(branch) = line.strip_prefix("## ") { + parsed.branch = branch.trim().to_string(); + continue; + } + + if line.len() < 3 { + continue; + } + + let mut chars = line.chars(); + let index_status = chars.next().unwrap_or(' '); + let worktree_status = chars.next().unwrap_or(' '); + let path = chars.as_str().trim().to_string(); + if path.is_empty() { + continue; + } + + if index_status == '?' && worktree_status == '?' { + parsed.untracked.push(path); + continue; + } + + if index_status != ' ' { + parsed.staged.push(path.clone()); + } + if worktree_status != ' ' { + parsed.modified.push(path); + } + } + + parsed +} + +fn render_git_status_context(status: &ParsedGitStatus) -> String { + let mut rendered = String::new(); + rendered.push_str(&format!("branch: {}\n", status.branch)); + rendered.push_str("staged:\n"); + render_path_list(&mut rendered, &status.staged); + rendered.push_str("modified:\n"); + render_path_list(&mut rendered, &status.modified); + rendered.push_str("untracked:\n"); + render_path_list(&mut rendered, &status.untracked); + rendered.trim_end().to_string() +} + +fn render_path_list(rendered: &mut String, paths: &[String]) { + if paths.is_empty() { + rendered.push_str("- none\n"); + } else { + for path in paths { + rendered.push_str(&format!("- {path}\n")); + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct TruncatedContent { + content: String, + omitted_characters: usize, +} + +fn truncate_visible(content: &str, max_characters: usize) -> TruncatedContent { + let total = content.chars().count(); + if total <= max_characters { + return TruncatedContent { + content: content.to_string(), + omitted_characters: 0, + }; + } + + let kept = content.chars().take(max_characters).collect::(); + let omitted = total.saturating_sub(max_characters); + TruncatedContent { + content: format!("{kept}\n\n[truncated: omitted {omitted} characters]"), + omitted_characters: omitted, + } +} + +fn git_diff_title(mode: &str, file: Option<&str>) -> String { + match file { + Some(file) => format!("git diff ({mode}): {file}"), + None => format!("git diff ({mode})"), + } +} + +fn git_output(path: &Path, args: &[&str], label: &str) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(path) + .args(args) + .output() + .map_err(|error| { + ContextError::InternalFailure(format!("failed to run {label}: {error}")) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(ContextError::InternalFailure(format!( + "{label} failed for {}: {}", + path.display(), + if stderr.is_empty() { + output.status.to_string() + } else { + stderr + } + ))); + } + + String::from_utf8(output.stdout).map_err(|error| { + ContextError::UnsupportedContent(format!("{label} output was not valid UTF-8: {error}")) + }) +} + #[derive(Debug, Clone, Default)] pub struct SessionContextStore { entries: Vec, @@ -1389,11 +1657,66 @@ mod tests { "file".to_string(), "command_output".to_string(), "stdin".to_string(), - "directory_summary".to_string() + "directory_summary".to_string(), + "git_status".to_string(), + "git_diff".to_string() ] ); } + #[test] + fn parses_git_status_porcelain_into_context_sections() { + let parsed = parse_git_status_porcelain( + "## main...origin/main\n M src/app.rs\nM Cargo.toml\nMM src/context.rs\n?? notes.md\n", + ); + + assert_eq!(parsed.branch, "main...origin/main"); + assert_eq!( + parsed.staged, + vec!["Cargo.toml".to_string(), "src/context.rs".to_string()] + ); + assert_eq!( + parsed.modified, + vec!["src/app.rs".to_string(), "src/context.rs".to_string()] + ); + assert_eq!(parsed.untracked, vec!["notes.md".to_string()]); + + let rendered = render_git_status_context(&parsed); + assert!(rendered.contains("branch: main...origin/main")); + assert!(rendered.contains("staged:")); + assert!(rendered.contains("- Cargo.toml")); + assert!(rendered.contains("untracked:")); + } + + #[test] + fn git_status_provider_metadata_is_git_context() { + let metadata = GitStatusContextProvider.metadata(); + + assert_eq!(metadata.name, "git_status"); + assert_eq!(metadata.kind, ContextKind::GitStatus); + } + + #[test] + fn git_diff_provider_metadata_is_git_context() { + let metadata = GitDiffContextProvider::default().metadata(); + + assert_eq!(metadata.name, "git_diff"); + assert_eq!(metadata.kind, ContextKind::GitDiff); + } + + #[test] + fn git_diff_truncation_is_visible() { + let truncated = truncate_visible("abcdef", 3); + + assert_eq!(truncated.omitted_characters, 3); + assert!(truncated.content.contains("abc")); + assert!( + truncated + .content + .contains("[truncated: omitted 3 characters]") + ); + } + #[test] fn manual_provider_rejects_empty_context() { let error = ManualContextProvider diff --git a/src/repl.rs b/src/repl.rs index bd93249..f389269 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -56,6 +56,9 @@ impl Repl { || input.starts_with("/add-note ") || input.starts_with("/add-file ") || input.starts_with("/add-dir ") + || input == "/add-git-status" + || input == "/add-diff" + || input.starts_with("/add-diff ") { match self.app.handle_command(&input) { Ok(message) => println!("{message}"),