mirror of
https://github.com/khodges42/exoshell.git
synced 2026-06-14 18:08:37 +00:00
Lightweight project inventory
This commit is contained in:
parent
b6db491caa
commit
cae34e0c86
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -100,7 +100,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "exoshell"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"futures-util",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "exoshell"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
edition = "2024"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
|
|
|
|||
|
|
@ -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, initial Git-native project detection, attachable Git status context, and attachable Git diff context.
|
||||
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, attachable Git diff context, and attachable recent commit context.
|
||||
|
||||
The broader roadmap is tracked in [docs/PHASES.md](docs/PHASES.md) and [docs/tasks](docs/tasks).
|
||||
|
||||
|
|
|
|||
|
|
@ -217,6 +217,14 @@ exo> /add-diff --staged
|
|||
exo> /add-diff --staged src/app.rs
|
||||
```
|
||||
|
||||
Attach recent commit history:
|
||||
|
||||
```text
|
||||
exo> /add-commits
|
||||
exo> /add-commits --count 10
|
||||
exo> /add-commits --author=alice src/app.rs
|
||||
```
|
||||
|
||||
Ask a question:
|
||||
|
||||
```text
|
||||
|
|
|
|||
|
|
@ -106,3 +106,4 @@ Historical codenames should be tracked in docs/versioning.md below
|
|||
* 0.5.0 branch-oracle
|
||||
* 0.6.0 status-satellite
|
||||
* 0.7.0 diff-lantern
|
||||
* 0.8.0 commit-oracle
|
||||
|
|
|
|||
69
src/app.rs
69
src/app.rs
|
|
@ -242,6 +242,11 @@ impl App {
|
|||
return self.add_git_diff_context(args);
|
||||
}
|
||||
|
||||
if trimmed == "/add-commits" || trimmed.starts_with("/add-commits ") {
|
||||
let args = trimmed.strip_prefix("/add-commits").unwrap_or("").trim();
|
||||
return self.add_git_commit_context(args);
|
||||
}
|
||||
|
||||
Err(ContextError::InvalidInput(format!("unknown context command: {trimmed}")).into())
|
||||
}
|
||||
|
||||
|
|
@ -324,6 +329,19 @@ impl App {
|
|||
)
|
||||
}
|
||||
|
||||
pub fn add_git_commit_context(&mut self, input: &str) -> Result<String, AppError> {
|
||||
let options = parse_git_commit_args(input)?;
|
||||
let path = self.project_context_path()?;
|
||||
self.add_context(
|
||||
"git_commits",
|
||||
ContextProviderRequest {
|
||||
path: Some(path),
|
||||
provider_options: options,
|
||||
..ContextProviderRequest::default()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn project_context_path(&self) -> Result<PathBuf, AppError> {
|
||||
let cwd = std::env::current_dir().map_err(|error| ProjectError::Read {
|
||||
path: PathBuf::from("."),
|
||||
|
|
@ -625,6 +643,7 @@ fn help_overview() -> &'static str {
|
|||
/add-dir <path> 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-commits [options] [path] attach recent Git commit history
|
||||
/add-output paste command output as explicit context
|
||||
/stance [name] show or set operator, audit, teach, or quiet
|
||||
/copy <cmd-id> print a suggested command; does not execute it
|
||||
|
|
@ -647,7 +666,7 @@ fn help_topic(topic: &str) -> &'static str {
|
|||
"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 <path> to attach read-only diff context."
|
||||
"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 <path> to attach read-only diff context. Use /add-commits, /add-commits --count 10, /add-commits --author=alice, or /add-commits src/app.rs to attach recent history."
|
||||
}
|
||||
"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."
|
||||
|
|
@ -684,6 +703,36 @@ fn parse_git_diff_args(input: &str) -> Result<(&'static str, Option<String>), Co
|
|||
Ok((mode, file))
|
||||
}
|
||||
|
||||
fn parse_git_commit_args(input: &str) -> Result<HashMap<String, String>, ContextError> {
|
||||
let mut options = HashMap::new();
|
||||
let mut parts = input.split_whitespace();
|
||||
while let Some(part) = parts.next() {
|
||||
if part == "--count" {
|
||||
let count = parts
|
||||
.next()
|
||||
.ok_or_else(|| ContextError::InvalidInput("--count requires a value".into()))?;
|
||||
options.insert("count".into(), count.to_string());
|
||||
} else if let Some(author) = part.strip_prefix("--author=") {
|
||||
if author.trim().is_empty() {
|
||||
return Err(ContextError::InvalidInput(
|
||||
"--author requires a non-empty value".into(),
|
||||
));
|
||||
}
|
||||
options.insert("author".into(), author.to_string());
|
||||
} else if part.starts_with('-') {
|
||||
return Err(ContextError::InvalidInput(format!(
|
||||
"unknown /add-commits option: {part}"
|
||||
)));
|
||||
} else if options.insert("file".into(), part.to_string()).is_some() {
|
||||
return Err(ContextError::InvalidInput(
|
||||
"/add-commits accepts at most one path".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct CliOptions {
|
||||
pub config_path: Option<PathBuf>,
|
||||
|
|
@ -854,7 +903,8 @@ mod tests {
|
|||
"stdin".to_string(),
|
||||
"directory_summary".to_string(),
|
||||
"git_status".to_string(),
|
||||
"git_diff".to_string()
|
||||
"git_diff".to_string(),
|
||||
"git_commits".to_string()
|
||||
]
|
||||
);
|
||||
assert_eq!(app.context_store.total_size().characters, 0);
|
||||
|
|
@ -1068,6 +1118,21 @@ mod tests {
|
|||
assert!(parse_git_diff_args("one two").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_git_commit_arguments() {
|
||||
assert!(parse_git_commit_args("").expect("default").is_empty());
|
||||
|
||||
let options =
|
||||
parse_git_commit_args("--count 10 --author=alice src/app.rs").expect("commit args");
|
||||
assert_eq!(options.get("count"), Some(&"10".to_string()));
|
||||
assert_eq!(options.get("author"), Some(&"alice".to_string()));
|
||||
assert_eq!(options.get("file"), Some(&"src/app.rs".to_string()));
|
||||
|
||||
assert!(parse_git_commit_args("--count").is_err());
|
||||
assert!(parse_git_commit_args("--author=").is_err());
|
||||
assert!(parse_git_commit_args("one two").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn over_budget_context_fails_before_provider_request() {
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
|
|
|
|||
163
src/context.rs
163
src/context.rs
|
|
@ -65,6 +65,7 @@ pub enum ContextKind {
|
|||
CommandOutput,
|
||||
DirectorySummary,
|
||||
GitDiff,
|
||||
GitHistory,
|
||||
GitStatus,
|
||||
Log,
|
||||
Note,
|
||||
|
|
@ -81,6 +82,7 @@ impl fmt::Display for ContextKind {
|
|||
Self::CommandOutput => formatter.write_str("command_output"),
|
||||
Self::DirectorySummary => formatter.write_str("directory_summary"),
|
||||
Self::GitDiff => formatter.write_str("git_diff"),
|
||||
Self::GitHistory => formatter.write_str("git_history"),
|
||||
Self::GitStatus => formatter.write_str("git_status"),
|
||||
Self::Log => formatter.write_str("log"),
|
||||
Self::Note => formatter.write_str("note"),
|
||||
|
|
@ -314,6 +316,7 @@ pub fn register_default_context_providers(
|
|||
registry.register(Box::new(DirectorySummaryContextProvider::default()))?;
|
||||
registry.register(Box::new(GitStatusContextProvider))?;
|
||||
registry.register(Box::new(GitDiffContextProvider::default()))?;
|
||||
registry.register(Box::new(GitCommitContextProvider))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -602,6 +605,57 @@ impl ContextProvider for GitStatusContextProvider {
|
|||
}
|
||||
}
|
||||
|
||||
pub struct GitCommitContextProvider;
|
||||
|
||||
impl ContextProvider for GitCommitContextProvider {
|
||||
fn metadata(&self) -> ContextProviderMetadata {
|
||||
ContextProviderMetadata {
|
||||
name: "git_commits".into(),
|
||||
kind: ContextKind::GitHistory,
|
||||
description: "captures recent Git commits and changed files".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn collect(&self, request: ContextProviderRequest) -> Result<ContextEntry, ContextError> {
|
||||
let path = request
|
||||
.path
|
||||
.or(request.cwd)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
let count = parse_git_commit_count(request.provider_options.get("count"))?;
|
||||
let author = request.provider_options.get("author").cloned();
|
||||
let file = request.provider_options.get("file").cloned();
|
||||
let log = git_log_output(&path, count, author.as_deref(), file.as_deref())?;
|
||||
let content = if log.trim().is_empty() {
|
||||
"recent commits: none".to_string()
|
||||
} else {
|
||||
log
|
||||
};
|
||||
|
||||
let mut provenance = ContextProvenance::new(ContextOrigin::Git);
|
||||
provenance.source_path = Some(path);
|
||||
provenance
|
||||
.provider_details
|
||||
.insert("count".into(), count.to_string());
|
||||
provenance.provider_details.insert(
|
||||
"author_filter".into(),
|
||||
author.unwrap_or_else(|| "none".into()),
|
||||
);
|
||||
provenance
|
||||
.provider_details
|
||||
.insert("path_filter".into(), file.unwrap_or_else(|| "none".into()));
|
||||
|
||||
Ok(ContextEntry::new(
|
||||
"",
|
||||
ContextKind::GitHistory,
|
||||
request
|
||||
.title
|
||||
.unwrap_or_else(|| format!("recent git commits ({count})")),
|
||||
provenance,
|
||||
content,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GitDiffContextProvider {
|
||||
pub max_characters: usize,
|
||||
|
|
@ -761,6 +815,72 @@ fn render_path_list(rendered: &mut String, paths: &[String]) {
|
|||
}
|
||||
}
|
||||
|
||||
fn parse_git_commit_count(value: Option<&String>) -> Result<usize, ContextError> {
|
||||
let Some(value) = value else {
|
||||
return Ok(5);
|
||||
};
|
||||
let count = value.parse::<usize>().map_err(|_| {
|
||||
ContextError::InvalidInput(format!(
|
||||
"git commit count must be a positive integer: {value}"
|
||||
))
|
||||
})?;
|
||||
if count == 0 || count > 100 {
|
||||
return Err(ContextError::InvalidInput(
|
||||
"git commit count must be between 1 and 100".into(),
|
||||
));
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
fn git_log_output(
|
||||
path: &Path,
|
||||
count: usize,
|
||||
author: Option<&str>,
|
||||
file: Option<&str>,
|
||||
) -> Result<String, ContextError> {
|
||||
let mut command = Command::new("git");
|
||||
command
|
||||
.arg("-C")
|
||||
.arg(path)
|
||||
.arg("log")
|
||||
.arg(format!("--max-count={count}"))
|
||||
.arg("--date=iso-strict")
|
||||
.arg("--pretty=format:commit: %H%nshort: %h%nauthor: %an <%ae>%ndate: %ad%nsubject: %s")
|
||||
.arg("--name-only");
|
||||
if let Some(author) = author {
|
||||
command.arg(format!("--author={author}"));
|
||||
}
|
||||
if let Some(file) = file {
|
||||
command.arg("--").arg(file);
|
||||
}
|
||||
|
||||
let output = command.output().map_err(|error| {
|
||||
ContextError::InternalFailure(format!("failed to run git log: {error}"))
|
||||
})?;
|
||||
if output.status.success() {
|
||||
return String::from_utf8(output.stdout).map_err(|error| {
|
||||
ContextError::UnsupportedContent(format!("git log output was not valid UTF-8: {error}"))
|
||||
});
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
if stderr.contains("does not have any commits yet")
|
||||
|| stderr.contains("your current branch") && stderr.contains("no commits")
|
||||
{
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
Err(ContextError::InternalFailure(format!(
|
||||
"git log failed for {}: {}",
|
||||
path.display(),
|
||||
if stderr.is_empty() {
|
||||
output.status.to_string()
|
||||
} else {
|
||||
stderr
|
||||
}
|
||||
)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct TruncatedContent {
|
||||
content: String,
|
||||
|
|
@ -1659,7 +1779,8 @@ mod tests {
|
|||
"stdin".to_string(),
|
||||
"directory_summary".to_string(),
|
||||
"git_status".to_string(),
|
||||
"git_diff".to_string()
|
||||
"git_diff".to_string(),
|
||||
"git_commits".to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
@ -1717,6 +1838,46 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_commit_provider_metadata_is_git_context() {
|
||||
let metadata = GitCommitContextProvider.metadata();
|
||||
|
||||
assert_eq!(metadata.name, "git_commits");
|
||||
assert_eq!(metadata.kind, ContextKind::GitHistory);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_commit_count_is_bounded() {
|
||||
assert_eq!(parse_git_commit_count(None).expect("default"), 5);
|
||||
assert_eq!(
|
||||
parse_git_commit_count(Some(&"10".to_string())).expect("count"),
|
||||
10
|
||||
);
|
||||
assert!(parse_git_commit_count(Some(&"0".to_string())).is_err());
|
||||
assert!(parse_git_commit_count(Some(&"101".to_string())).is_err());
|
||||
assert!(parse_git_commit_count(Some(&"abc".to_string())).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_commit_provider_handles_repositories_without_commits() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
std::process::Command::new("git")
|
||||
.arg("init")
|
||||
.arg(tempdir.path())
|
||||
.output()
|
||||
.expect("git init");
|
||||
|
||||
let entry = GitCommitContextProvider
|
||||
.collect(ContextProviderRequest {
|
||||
path: Some(tempdir.path().to_path_buf()),
|
||||
..ContextProviderRequest::default()
|
||||
})
|
||||
.expect("empty git log");
|
||||
|
||||
assert_eq!(entry.kind, ContextKind::GitHistory);
|
||||
assert_eq!(entry.content, "recent commits: none");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_provider_rejects_empty_context() {
|
||||
let error = ManualContextProvider
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ impl Repl {
|
|||
|| input == "/add-git-status"
|
||||
|| input == "/add-diff"
|
||||
|| input.starts_with("/add-diff ")
|
||||
|| input == "/add-commits"
|
||||
|| input.starts_with("/add-commits ")
|
||||
{
|
||||
match self.app.handle_command(&input) {
|
||||
Ok(message) => println!("{message}"),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user