mirror of
https://github.com/khodges42/exoshell.git
synced 2026-06-14 18:08:37 +00:00
188 lines
6.0 KiB
Rust
188 lines
6.0 KiB
Rust
use std::path::PathBuf;
|
|
|
|
use crate::config::Config;
|
|
use crate::prompts::phase1_system_prompt;
|
|
use crate::providers::{ChatMessage, ChatRequest, ChatResponse, ChatRole, Provider, ProviderError};
|
|
use crate::repl::ReplError;
|
|
use crate::shell::ShellFamily;
|
|
use crate::transcripts::{Transcript, TranscriptError};
|
|
|
|
pub struct App {
|
|
config: Config,
|
|
provider: Box<dyn Provider>,
|
|
messages: Vec<ChatMessage>,
|
|
transcript: Transcript,
|
|
}
|
|
|
|
impl App {
|
|
pub fn new(config: Config, provider: Box<dyn Provider>) -> Self {
|
|
let transcript = Transcript::new(
|
|
"openai-compatible".into(),
|
|
config.provider.model.clone(),
|
|
config.shell.family,
|
|
);
|
|
let messages = vec![ChatMessage::new(
|
|
ChatRole::System,
|
|
phase1_system_prompt(config.shell.family),
|
|
)];
|
|
|
|
Self {
|
|
config,
|
|
provider,
|
|
messages,
|
|
transcript,
|
|
}
|
|
}
|
|
|
|
pub async fn send(&mut self, input: String) -> Result<String, AppError> {
|
|
self.messages
|
|
.push(ChatMessage::new(ChatRole::User, input.clone()));
|
|
self.transcript.record_user(&input);
|
|
|
|
let request = ChatRequest {
|
|
messages: self.messages.clone(),
|
|
stream: false,
|
|
};
|
|
|
|
let response = match self.provider.chat(request).await {
|
|
Ok(ChatResponse::Complete(response)) => response,
|
|
Ok(ChatResponse::Stream(chunks)) => chunks.concat(),
|
|
Err(error) => {
|
|
self.transcript.record_error(&error.to_string());
|
|
return Err(error.into());
|
|
}
|
|
};
|
|
self.messages
|
|
.push(ChatMessage::new(ChatRole::Assistant, response.clone()));
|
|
self.transcript.record_assistant(&response);
|
|
|
|
Ok(response)
|
|
}
|
|
|
|
pub fn save_transcript(&self) -> Result<Option<PathBuf>, AppError> {
|
|
if !self.config.transcript.enabled {
|
|
return Ok(None);
|
|
}
|
|
|
|
let path = self
|
|
.transcript
|
|
.write_to_dir(&self.config.transcript.directory)?;
|
|
Ok(Some(path))
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum AppError {
|
|
#[error(transparent)]
|
|
Config(#[from] crate::config::ConfigError),
|
|
#[error(transparent)]
|
|
Provider(#[from] ProviderError),
|
|
#[error(transparent)]
|
|
Repl(#[from] ReplError),
|
|
#[error(transparent)]
|
|
Transcript(#[from] TranscriptError),
|
|
}
|
|
|
|
#[derive(Debug, Default, PartialEq, Eq)]
|
|
pub struct CliOptions {
|
|
pub config_path: Option<PathBuf>,
|
|
pub shell_family: Option<ShellFamily>,
|
|
pub transcript_enabled: Option<bool>,
|
|
pub transcript_directory: Option<PathBuf>,
|
|
pub no_color: bool,
|
|
pub show_help: bool,
|
|
}
|
|
|
|
impl CliOptions {
|
|
pub fn parse<I>(args: I) -> Result<Self, AppError>
|
|
where
|
|
I: IntoIterator<Item = String>,
|
|
{
|
|
let mut options = CliOptions::default();
|
|
let mut args = args.into_iter();
|
|
|
|
while let Some(arg) = args.next() {
|
|
match arg.as_str() {
|
|
"-h" | "--help" => options.show_help = true,
|
|
"--config" => {
|
|
let value = args.next().ok_or_else(|| {
|
|
crate::config::ConfigError::Invalid("--config requires a path".into())
|
|
})?;
|
|
options.config_path = Some(PathBuf::from(value));
|
|
}
|
|
"--shell" => {
|
|
let value = args.next().ok_or_else(|| {
|
|
crate::config::ConfigError::Invalid("--shell requires a value".into())
|
|
})?;
|
|
options.shell_family = Some(value.parse().map_err(
|
|
|error: crate::shell::ShellFamilyError| {
|
|
crate::config::ConfigError::Invalid(error.to_string())
|
|
},
|
|
)?);
|
|
}
|
|
"--no-transcript" => options.transcript_enabled = Some(false),
|
|
"--transcript-dir" => {
|
|
let value = args.next().ok_or_else(|| {
|
|
crate::config::ConfigError::Invalid(
|
|
"--transcript-dir requires a path".into(),
|
|
)
|
|
})?;
|
|
options.transcript_directory = Some(PathBuf::from(value));
|
|
options.transcript_enabled = Some(true);
|
|
}
|
|
"--no-color" => options.no_color = true,
|
|
other => {
|
|
return Err(crate::config::ConfigError::Invalid(format!(
|
|
"unknown argument: {other}"
|
|
))
|
|
.into());
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(options)
|
|
}
|
|
|
|
pub fn help() -> &'static str {
|
|
"Usage: exoshell [--config <path>] [--shell powershell|posix] [--no-transcript] [--transcript-dir <path>] [--no-color]\n\nStarts the Exoshell Phase 1 interactive model chat. Exoshell suggests commands; it does not execute them."
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parses_config_path() {
|
|
let options = CliOptions::parse(["--config".to_string(), "config.toml".to_string()])
|
|
.expect("options parse");
|
|
|
|
assert_eq!(options.config_path, Some(PathBuf::from("config.toml")));
|
|
}
|
|
|
|
#[test]
|
|
fn help_flag_is_supported() {
|
|
let options = CliOptions::parse(["--help".to_string()]).expect("options parse");
|
|
|
|
assert!(options.show_help);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_phase1_cli_overrides() {
|
|
let options = CliOptions::parse([
|
|
"--shell".to_string(),
|
|
"posix".to_string(),
|
|
"--no-transcript".to_string(),
|
|
"--transcript-dir".to_string(),
|
|
"out".to_string(),
|
|
"--no-color".to_string(),
|
|
])
|
|
.expect("options parse");
|
|
|
|
assert_eq!(options.shell_family, Some(ShellFamily::Posix));
|
|
assert_eq!(options.transcript_enabled, Some(true));
|
|
assert_eq!(options.transcript_directory, Some(PathBuf::from("out")));
|
|
assert!(options.no_color);
|
|
}
|
|
}
|