diff --git a/scripts/completions/aichat.bash b/scripts/completions/aichat.bash index c335a6e3..47080775 100644 --- a/scripts/completions/aichat.bash +++ b/scripts/completions/aichat.bash @@ -17,7 +17,7 @@ _aichat() { case "${cmd}" in aichat) - opts="-m -r -s -a -e -c -f -S -h -V --model --prompt --role --session --empty-session --save-session --agent --agent-variable --rag --rebuild-rag --serve --execute --code --file --no-stream --dry-run --info --list-models --list-roles --list-sessions --list-agents --list-rags --help --version" + opts="-m -r -s -a -e -c -f -S -h -V --model --prompt --role --session --empty-session --save-session --agent --agent-variable --rag --rebuild-rag --macro --serve --execute --code --file --no-stream --dry-run --info --list-models --list-roles --list-sessions --list-agents --list-rags --list-macros --help --version" if [[ ${cur} == -* || ${cword} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -53,6 +53,11 @@ _aichat() { __ltrim_colon_completions "$cur" return 0 ;; + --macro) + COMPREPLY=($(compgen -W "$("$1" --list-macros)" -- "${cur}")) + __ltrim_colon_completions "$cur" + return 0 + ;; -f|--file) local oldifs if [[ -v IFS ]]; then diff --git a/scripts/completions/aichat.fish b/scripts/completions/aichat.fish index 19446f8f..3618a5d4 100644 --- a/scripts/completions/aichat.fish +++ b/scripts/completions/aichat.fish @@ -8,6 +8,7 @@ complete -c aichat -s a -l agent -x -a "(aichat --list-agents)" -d 'Start a age complete -c aichat -l agent-variable -d 'Set agent variables' complete -c aichat -l rag -x -a"(aichat --list-rags)" -d 'Start a RAG' -r complete -c aichat -l rebuild-rag -d 'Rebuild the RAG to sync document changes' +complete -c aichat -l macro -x -a"(aichat --list-macros)" -d 'Execute a macro' -r complete -c aichat -l serve -d 'Serve the LLM API and WebAPP' complete -c aichat -s e -l execute -d 'Execute commands in natural language' complete -c aichat -s c -l code -d 'Output code only' @@ -20,5 +21,6 @@ complete -c aichat -l list-roles -d 'List all roles' complete -c aichat -l list-sessions -d 'List all sessions' complete -c aichat -l list-agents -d 'List all agents' complete -c aichat -l list-rags -d 'List all RAGs' +complete -c aichat -l list-macros -d 'List all macros' complete -c aichat -s h -l help -d 'Print help' complete -c aichat -s V -l version -d 'Print version' diff --git a/scripts/completions/aichat.nu b/scripts/completions/aichat.nu index ca415185..bbea5e27 100644 --- a/scripts/completions/aichat.nu +++ b/scripts/completions/aichat.nu @@ -34,6 +34,12 @@ module completions { | parse "{value}" } + def "nu-complete aichat macro" [] { + ^aichat --list-macros | + | lines + | parse "{value}" + } + # All-in-one chat and copilot CLI that integrates 10+ AI platforms export extern aichat [ --model(-m): string@"nu-complete aichat model" # Select a LLM model @@ -46,6 +52,7 @@ module completions { --agent-variable # Set agent variables --rag: string@"nu-complete aichat rag" # Start a RAG --rebuild-rag # Rebuild the RAG to sync document changes + --macro: string@"nu-complete aichat macro" # Execute a macro --serve # Serve the LLM API and WebAPP --execute(-e) # Execute commands in natural language --code(-c) # Output code only @@ -58,6 +65,7 @@ module completions { --list-sessions # List all sessions --list-agents # List all agents --list-rags # List all RAGs + --list-macros # List all macros ...text: string # Input text --help(-h) # Print help --version(-V) # Print version diff --git a/scripts/completions/aichat.ps1 b/scripts/completions/aichat.ps1 index 88273519..dc4ef649 100644 --- a/scripts/completions/aichat.ps1 +++ b/scripts/completions/aichat.ps1 @@ -34,6 +34,7 @@ Register-ArgumentCompleter -Native -CommandName 'aichat' -ScriptBlock { [CompletionResult]::new('--agent-variable', '--agent-variable', [CompletionResultType]::ParameterName, 'Set agent variables') [CompletionResult]::new('--rag', '--rag', [CompletionResultType]::ParameterName, 'Start a RAG') [CompletionResult]::new('--rebuild-rag', '--rebuild-rag', [CompletionResultType]::ParameterName, 'Rebuild the RAG to sync document changes') + [CompletionResult]::new('--macro', '--macro', [CompletionResultType]::ParameterName, 'Execute a macro') [CompletionResult]::new('--serve', '--serve', [CompletionResultType]::ParameterName, 'Serve the LLM API and WebAPP') [CompletionResult]::new('-e', '-e', [CompletionResultType]::ParameterName, 'Execute commands in natural language') [CompletionResult]::new('--execute', '--execute', [CompletionResultType]::ParameterName, 'Execute commands in natural language') @@ -50,6 +51,7 @@ Register-ArgumentCompleter -Native -CommandName 'aichat' -ScriptBlock { [CompletionResult]::new('--list-sessions', '--list-sessions', [CompletionResultType]::ParameterName, 'List all sessions') [CompletionResult]::new('--list-agents', '--list-agents', [CompletionResultType]::ParameterName, 'List all agents') [CompletionResult]::new('--list-rags', '--list-rags', [CompletionResultType]::ParameterName, 'List all RAGs') + [CompletionResult]::new('--list-macros', '--list-macros', [CompletionResultType]::ParameterName, 'List all macros') [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') [CompletionResult]::new('-V', '-V', [CompletionResultType]::ParameterName, 'Print version') @@ -77,8 +79,10 @@ Register-ArgumentCompleter -Native -CommandName 'aichat' -ScriptBlock { $completions = Get-AichatValues "--list-sessions" } elseif ($flag -ceq "-a" -or $flag -eq "--agent") { $completions = Get-AichatValues "--list-agents" - } elseif ($flag -ceq "-R" -or $flag -eq "--rag") { + } elseif ($flag -eq "--rag") { $completions = Get-AichatValues "--list-rags" + } elseif ($flag -eq "--macro") { + $completions = Get-AichatValues "--list-macros" } elseif ($flag -ceq "-f" -or $flag -eq "--file") { $completions = @() } diff --git a/scripts/completions/aichat.zsh b/scripts/completions/aichat.zsh index bb6fcda8..15cbdce0 100644 --- a/scripts/completions/aichat.zsh +++ b/scripts/completions/aichat.zsh @@ -29,6 +29,7 @@ _aichat() { '--agent-variable[Set agent variables]' \ '--rag[Start a RAG]:RAG:->rags' \ '--rebuild-rag[Rebuild the RAG to sync document changes]' \ +'--macro[Execute a macro]:MACRO:->macros' \ '--serve[Serve the LLM API and WebAPP]' \ '-e[Execute commands in natural language]' \ '--execute[Execute commands in natural language]' \ @@ -45,6 +46,7 @@ _aichat() { '--list-sessions[List all sessions]' \ '--list-agents[List all agents]' \ '--list-rags[List all RAGs]' \ +'--list-macros[List all macros]' \ '-h[Print help]' \ '--help[Print help]' \ '-V[Print version]' \ @@ -56,7 +58,7 @@ _aichat() { _arguments "${_arguments_options[@]}" $common \ && ret=0 case $state in - models|roles|sessions|agents|rags) + models|roles|sessions|agents|rags|macros) local -a values expl values=( ${(f)"$(_call_program values aichat --list-$state)"} ) _wanted values expl $state compadd -a values && ret=0 diff --git a/src/cli.rs b/src/cli.rs index 73fede2e..de88776f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,7 @@ +use anyhow::{Context, Result}; use clap::Parser; +use is_terminal::IsTerminal; +use std::io::{stdin, Read}; #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] @@ -33,6 +36,9 @@ pub struct Cli { /// Rebuild the RAG to sync document changes #[clap(long)] pub rebuild_rag: bool, + /// Execute a macro + #[clap(long = "macro", value_name = "MACRO")] + pub macro_name: Option, /// Serve the LLM API and WebAPP #[clap(long, value_name = "ADDRESS")] pub serve: Option>, @@ -69,17 +75,52 @@ pub struct Cli { /// List all RAGs #[clap(long)] pub list_rags: bool, + /// List all macros + #[clap(long)] + pub list_macros: bool, /// Input text #[clap(trailing_var_arg = true)] text: Vec, } impl Cli { - pub fn text(&self) -> Option { - let text = self.text.to_vec().join(" "); - if text.is_empty() { - return None; + pub fn text(&self) -> Result> { + let mut stdin_text = String::new(); + if !stdin().is_terminal() { + let _ = stdin() + .read_to_string(&mut stdin_text) + .context("Invalid stdin pipe")?; + }; + match self.text.is_empty() { + true => { + if stdin_text.is_empty() { + Ok(None) + } else { + Ok(Some(stdin_text)) + } + } + false => { + if self.macro_name.is_some() { + let text = self + .text + .iter() + .map(|v| shell_words::quote(v)) + .collect::>() + .join(" "); + if stdin_text.is_empty() { + Ok(Some(text)) + } else { + Ok(Some(format!("{} -- {}", text, stdin_text))) + } + } else { + let text = self.text.join(" "); + if stdin_text.is_empty() { + Ok(Some(text)) + } else { + Ok(Some(format!("{}\n{}", text, stdin_text))) + } + } + } } - Some(text) } } diff --git a/src/client/message.rs b/src/client/message.rs index f6b7f6d2..2cfc1832 100644 --- a/src/client/message.rs +++ b/src/client/message.rs @@ -1,4 +1,4 @@ -use crate::{function::ToolResult, utils::dimmed_text}; +use crate::{function::ToolResult, multiline_text, utils::dimmed_text}; use serde::{Deserialize, Serialize}; @@ -83,7 +83,7 @@ impl MessageContent { agent_info: &Option<(String, Vec)>, ) -> String { match self { - MessageContent::Text(text) => text.to_string(), + MessageContent::Text(text) => multiline_text(text), MessageContent::Array(list) => { let (mut concated_text, mut files) = (String::new(), vec![]); for item in list { @@ -97,7 +97,7 @@ impl MessageContent { } } if !concated_text.is_empty() { - concated_text = format!(" -- {concated_text}") + concated_text = format!(" -- {}", multiline_text(&concated_text)) } format!(".file {}{}", files.join(" "), concated_text) } diff --git a/src/client/model.rs b/src/client/model.rs index 47f65988..60320cb5 100644 --- a/src/client/model.rs +++ b/src/client/model.rs @@ -5,7 +5,7 @@ use super::{ }; use crate::config::Config; -use crate::utils::{estimate_token_length, format_option_value}; +use crate::utils::estimate_token_length; use anyhow::{bail, Result}; use serde::{Deserialize, Serialize}; @@ -133,10 +133,10 @@ impl Model { supports_function_calling, .. } = &self.data; - let max_input_tokens = format_option_value(max_input_tokens); - let max_output_tokens = format_option_value(max_output_tokens); - let input_price = format_option_value(input_price); - let output_price = format_option_value(output_price); + let max_input_tokens = stringify_option_value(max_input_tokens); + let max_output_tokens = stringify_option_value(max_output_tokens); + let input_price = stringify_option_value(input_price); + let output_price = stringify_option_value(output_price); let mut capabilities = vec![]; if *supports_vision { capabilities.push('👁'); @@ -161,9 +161,9 @@ impl Model { max_batch_size, .. } = &self.data; - let max_tokens = format_option_value(max_tokens_per_chunk); - let max_batch = format_option_value(max_batch_size); - let price = format_option_value(input_price); + let max_tokens = stringify_option_value(max_tokens_per_chunk); + let max_batch = stringify_option_value(max_batch_size); + let price = stringify_option_value(input_price); format!("max-tokens:{max_tokens};max-batch:{max_batch};price:{price}") } ModelType::Reranker => String::new(), @@ -366,3 +366,13 @@ impl ModelType { } } } + +fn stringify_option_value(value: &Option) -> String +where + T: std::fmt::Display, +{ + match value { + Some(value) => value.to_string(), + None => "-".to_string(), + } +} diff --git a/src/config/agent.rs b/src/config/agent.rs index 731dd91c..533e7a91 100644 --- a/src/config/agent.rs +++ b/src/config/agent.rs @@ -68,7 +68,7 @@ impl Agent { let rag = if rag_path.exists() { Some(Arc::new(Rag::load(config, DEFAULT_AGENT_NAME, &rag_path)?)) - } else if !definition.documents.is_empty() && !config.read().cli_info_flag { + } else if !definition.documents.is_empty() && !config.read().info_flag { let mut ans = false; if *IS_STDOUT_TERMINAL { ans = Confirm::new("The agent has the documents, init RAG?") diff --git a/src/config/input.rs b/src/config/input.rs index 70a01cf2..c21bc621 100644 --- a/src/config/input.rs +++ b/src/config/input.rs @@ -27,8 +27,8 @@ pub struct Input { medias: Vec, data_urls: HashMap, tool_calls: Option, - rag_name: Option, role: Role, + rag_name: Option, with_session: bool, with_agent: bool, } @@ -47,8 +47,8 @@ impl Input { medias: Default::default(), data_urls: Default::default(), tool_calls: None, - rag_name: None, role, + rag_name: None, with_session, with_agent, } @@ -128,8 +128,8 @@ impl Input { medias, data_urls, tool_calls: Default::default(), - rag_name: None, role, + rag_name: None, with_session, with_agent, }) diff --git a/src/config/mod.rs b/src/config/mod.rs index 975c8eab..7e2f275a 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -17,6 +17,7 @@ use crate::client::{ use crate::function::{FunctionDeclaration, Functions, ToolResult}; use crate::rag::Rag; use crate::render::{MarkdownRender, RenderOptions}; +use crate::repl::{run_repl_command, split_params_text}; use crate::utils::*; use anyhow::{anyhow, bail, Context, Result}; @@ -49,6 +50,7 @@ const LIGHT_THEME: &[u8] = include_bytes!("../../assets/monokai-extended-light.t const CONFIG_FILE_NAME: &str = "config.yaml"; const ROLES_DIR_NAME: &str = "roles"; +const MACROS_DIR_NAME: &str = "macros"; const ENV_FILE_NAME: &str = ".env"; const MESSAGES_FILE_NAME: &str = "messages.md"; const SESSIONS_DIR_NAME: &str = "sessions"; @@ -142,13 +144,12 @@ pub struct Config { pub clients: Vec, #[serde(skip)] - pub role: Option, - #[serde(skip)] - pub session: Option, + pub macro_flag: bool, #[serde(skip)] - pub rag: Option>, + pub info_flag: bool, #[serde(skip)] - pub agent: Option, + pub cli_agent_variables: Option, + #[serde(skip)] pub model: Model, #[serde(skip)] @@ -159,9 +160,13 @@ pub struct Config { pub last_message: Option, #[serde(skip)] - pub cli_info_flag: bool, + pub role: Option, #[serde(skip)] - pub cli_agent_variables: Option, + pub session: Option, + #[serde(skip)] + pub rag: Option>, + #[serde(skip)] + pub agent: Option, } impl Default for Config { @@ -212,17 +217,19 @@ impl Default for Config { clients: vec![], - role: None, - session: None, - rag: None, - agent: None, + macro_flag: false, + info_flag: false, + cli_agent_variables: None, + model: Default::default(), functions: Default::default(), working_mode: WorkingMode::Cmd, last_message: None, - cli_info_flag: false, - cli_agent_variables: None, + role: None, + session: None, + rag: None, + agent: None, } } } @@ -296,6 +303,17 @@ impl Config { Self::roles_dir().join(format!("{name}.md")) } + pub fn macros_dir() -> PathBuf { + match env::var(get_env_name("macros_dir")) { + Ok(value) => PathBuf::from(value), + Err(_) => Self::local_path(MACROS_DIR_NAME), + } + } + + pub fn macro_file(name: &str) -> PathBuf { + Self::macros_dir().join(format!("{name}.yaml")) + } + pub fn env_file() -> PathBuf { match env::var(get_env_name("env_file")) { Ok(value) => PathBuf::from(value), @@ -537,23 +555,16 @@ impl Config { let role = self.extract_role(); let mut items = vec![ ("model", role.model().id()), + ("temperature", format_option_value(&role.temperature())), + ("top_p", format_option_value(&role.top_p())), + ("use_tools", format_option_value(&role.use_tools())), ( "max_output_tokens", self.model .max_tokens_param() .map(|v| format!("{v} (current model)")) - .unwrap_or_else(|| "-".into()), + .unwrap_or_else(|| "null".into()), ), - ("temperature", format_option_value(&role.temperature())), - ("top_p", format_option_value(&role.top_p())), - ("dry_run", self.dry_run.to_string()), - ("stream", self.stream.to_string()), - ("save", self.save.to_string()), - ("keybindings", self.keybindings.clone()), - ("wrap", wrap), - ("wrap_code", self.wrap_code.to_string()), - ("function_calling", self.function_calling.to_string()), - ("use_tools", format_option_value(&role.use_tools())), ("save_session", format_option_value(&self.save_session)), ("compress_threshold", self.compress_threshold.to_string()), ( @@ -561,6 +572,13 @@ impl Config { format_option_value(&rag_reranker_model), ), ("rag_top_k", rag_top_k.to_string()), + ("dry_run", self.dry_run.to_string()), + ("function_calling", self.function_calling.to_string()), + ("stream", self.stream.to_string()), + ("save", self.save.to_string()), + ("keybindings", self.keybindings.clone()), + ("wrap", wrap), + ("wrap_code", self.wrap_code.to_string()), ("highlight", self.highlight.to_string()), ("light_theme", self.light_theme.to_string()), ("config_file", display_path(&Self::config_file())), @@ -568,6 +586,7 @@ impl Config { ("roles_dir", display_path(&Self::roles_dir())), ("sessions_dir", display_path(&self.sessions_dir())), ("rags_dir", display_path(&Self::rags_dir())), + ("macros_dir", display_path(&Self::macros_dir())), ("functions_dir", display_path(&Self::functions_dir())), ("messages_file", display_path(&self.messages_file())), ]; @@ -590,10 +609,6 @@ impl Config { let key = parts[0]; let value = parts[1]; match key { - "max_output_tokens" => { - let value = parse_value(value)?; - config.write().set_max_output_tokens(value); - } "temperature" => { let value = parse_value(value)?; config.write().set_temperature(value); @@ -602,29 +617,14 @@ impl Config { let value = parse_value(value)?; config.write().set_top_p(value); } - "dry_run" => { - let value = value.parse().with_context(|| "Invalid value")?; - config.write().dry_run = value; - } - "stream" => { - let value = value.parse().with_context(|| "Invalid value")?; - config.write().stream = value; - } - "save" => { - let value = value.parse().with_context(|| "Invalid value")?; - config.write().save = value; - } - "function_calling" => { - let value = value.parse().with_context(|| "Invalid value")?; - if value && config.write().functions.is_empty() { - bail!("Function calling cannot be enabled because no functions are installed.") - } - config.write().function_calling = value; - } "use_tools" => { let value = parse_value(value)?; config.write().set_use_tools(value); } + "max_output_tokens" => { + let value = parse_value(value)?; + config.write().set_max_output_tokens(value); + } "save_session" => { let value = parse_value(value)?; config.write().set_save_session(value); @@ -641,6 +641,25 @@ impl Config { let value = value.parse().with_context(|| "Invalid value")?; Self::set_rag_top_k(config, value)?; } + "dry_run" => { + let value = value.parse().with_context(|| "Invalid value")?; + config.write().dry_run = value; + } + "function_calling" => { + let value = value.parse().with_context(|| "Invalid value")?; + if value && config.write().functions.is_empty() { + bail!("Function calling cannot be enabled because no functions are installed.") + } + config.write().function_calling = value; + } + "stream" => { + let value = value.parse().with_context(|| "Invalid value")?; + config.write().stream = value; + } + "save" => { + let value = value.parse().with_context(|| "Invalid value")?; + config.write().save = value; + } "highlight" => { let value = value.parse().with_context(|| "Invalid value")?; config.write().highlight = value; @@ -655,6 +674,7 @@ impl Config { "role" => (Self::roles_dir(), Some(".md")), "session" => (config.read().sessions_dir(), Some(".yaml")), "rag" => (Self::rags_dir(), Some(".yaml")), + "macro" => (Self::macros_dir(), Some(".yaml")), "agent-data" => (Self::agents_data_dir(), None), _ => bail!("Unknown kind '{kind}'"), }; @@ -887,31 +907,38 @@ impl Config { } pub fn new_role(&mut self, name: &str) -> Result<()> { + if self.macro_flag { + bail!("No role"); + } let ans = Confirm::new("Create a new role?") .with_default(true) .prompt()?; if ans { self.upsert_role(name)?; + } else { + bail!("No role"); } Ok(()) } pub fn edit_role(&mut self) -> Result<()> { + let role_name; if let Some(session) = self.session.as_ref() { if let Some(name) = session.role_name().map(|v| v.to_string()) { if session.is_empty() { - self.upsert_role(&name) + role_name = Some(name); } else { bail!("Cannot perform this operation because you are in a non-empty session") } } else { bail!("No role") } - } else if let Some(name) = self.role.as_ref().map(|v| v.name().to_string()) { - self.upsert_role(&name) } else { - bail!("No role") + role_name = self.role.as_ref().map(|v| v.name().to_string()); } + let name = role_name.ok_or_else(|| anyhow!("No role"))?; + self.upsert_role(&name)?; + self.use_role(&name) } pub fn upsert_role(&mut self, name: &str) -> Result<()> { @@ -921,7 +948,6 @@ impl Config { ensure_parent_exists(&role_path)?; let editor = self.editor()?; edit_file(&editor, &role_path)?; - self.use_role(name)?; Ok(()) } @@ -1444,9 +1470,13 @@ impl Config { bail!("Already in a agent, please run '.exit agent' first to exit the current agent."); } let agent = Agent::init(config, agent_name, abort_signal).await?; - let session = session_name - .map(|v| v.to_string()) - .or_else(|| agent.agent_prelude().map(|v| v.to_string())); + let session = session_name.map(|v| v.to_string()).or_else(|| { + if config.read().macro_flag { + None + } else { + agent.agent_prelude().map(|v| v.to_string()) + } + }); config.write().rag = agent.rag(); config.write().agent = Some(agent); if let Some(session) = session { @@ -1516,8 +1546,43 @@ impl Config { Ok(()) } + pub fn list_macros() -> Vec { + list_file_names(Self::macros_dir(), ".yaml") + } + + pub fn load_macro(name: &str) -> Result { + let path = Self::macro_file(name); + let err = || format!("Failed to load macro '{name}' at '{}'", path.display()); + let content = read_to_string(&path).with_context(err)?; + let value: Macro = serde_yaml::from_str(&content).with_context(err)?; + Ok(value) + } + + pub fn has_macro(name: &str) -> bool { + let names = Self::list_macros(); + names.contains(&name.to_string()) + } + + pub fn new_macro(&mut self, name: &str) -> Result<()> { + if self.macro_flag { + bail!("No macro"); + } + let ans = Confirm::new("Create a new macro?") + .with_default(true) + .prompt()?; + if ans { + let macro_path = Self::macro_file(name); + ensure_parent_exists(¯o_path)?; + let editor = self.editor()?; + edit_file(&editor, ¯o_path)?; + } else { + bail!("No macro"); + } + Ok(()) + } + pub fn apply_prelude(&mut self) -> Result<()> { - if !self.state().is_empty() { + if self.macro_flag || !self.state().is_empty() { return Ok(()); } let prelude = match self.working_mode { @@ -1672,6 +1737,7 @@ impl Config { } ".rag" => map_completion_values(Self::list_rags()), ".agent" => map_completion_values(list_agents()), + ".macro" => map_completion_values(Self::list_macros()), ".starter" => match &self.agent { Some(agent) => map_completion_values(agent.conversation_staters().to_vec()), None => vec![], @@ -1686,18 +1752,18 @@ impl Config { }, ".set" => { let mut values = vec![ - "max_output_tokens", "temperature", "top_p", - "dry_run", - "stream", - "save", - "function_calling", "use_tools", "save_session", "compress_threshold", "rag_reranker_model", "rag_top_k", + "max_output_tokens", + "dry_run", + "function_calling", + "stream", + "save", "highlight", ]; values.sort_unstable(); @@ -1706,7 +1772,9 @@ impl Config { .map(|v| (format!("{v} "), None)) .collect() } - ".delete" => map_completion_values(vec!["role", "session", "rag", "agent-data"]), + ".delete" => { + map_completion_values(vec!["role", "session", "rag", "macro", "agent-data"]) + } _ => vec![], }; filter = args[0] @@ -2009,11 +2077,11 @@ impl Config { let new_variables = Agent::init_agent_variables( agent.defined_variables(), &config_variables, - self.cli_info_flag, + self.info_flag, )?; agent.set_shared_variables(new_variables); } - if !self.cli_info_flag { + if !self.info_flag { agent.update_shared_dynamic_instructions(false)?; } Ok(()) @@ -2035,7 +2103,7 @@ impl Config { let new_variables = Agent::init_agent_variables( agent.defined_variables(), &config_variables, - self.cli_info_flag, + self.info_flag, )?; agent.set_shared_variables(new_variables.clone()); new_variables @@ -2043,7 +2111,7 @@ impl Config { shared_variables }; agent.set_session_variables(session_variables); - if !self.cli_info_flag { + if !self.info_flag { agent.update_session_dynamic_instructions(None)?; } session.sync_agent(agent); @@ -2319,6 +2387,106 @@ impl WorkingMode { } } +#[async_recursion::async_recursion] +pub async fn macro_execute( + config: &GlobalConfig, + name: &str, + args: Option<&str>, + abort_signal: AbortSignal, +) -> Result<()> { + let macro_value = Config::load_macro(name)?; + let (mut new_args, text) = split_params_text(args.unwrap_or_default(), cfg!(windows)); + if !text.is_empty() { + new_args.push(text.to_string()); + } + let variables = macro_value + .resolve_variables(&new_args) + .map_err(|err| anyhow!("{err}. Usage: {}", macro_value.usage(name)))?; + let role = config.read().extract_role(); + let mut config = config.read().clone(); + config.temperature = role.temperature(); + config.top_p = role.top_p(); + config.use_tools = role.use_tools().clone(); + config.macro_flag = true; + config.model = role.model().clone(); + config.role = None; + config.session = None; + config.rag = None; + config.agent = None; + config.discontinuous_last_message(); + let config = Arc::new(RwLock::new(config)); + config.write().macro_flag = true; + for step in ¯o_value.steps { + let command = Macro::interpolate_command(step, &variables); + println!(">> {}", multiline_text(&command)); + run_repl_command(&config, abort_signal.clone(), &command).await?; + } + Ok(()) +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Macro { + #[serde(default)] + pub variables: Vec, + pub steps: Vec, +} + +impl Macro { + pub fn resolve_variables(&self, args: &[String]) -> Result> { + let mut output = IndexMap::new(); + for (i, variable) in self.variables.iter().enumerate() { + let value = if variable.rest && i == self.variables.len() - 1 { + if args.len() > i { + Some(args[i..].join(" ")) + } else { + variable.default.clone() + } + } else { + args.get(i) + .map(|v| v.to_string()) + .or_else(|| variable.default.clone()) + }; + let value = + value.ok_or_else(|| anyhow!("Missing value for variable '{}'", variable.name))?; + output.insert(variable.name.clone(), value); + } + Ok(output) + } + + pub fn usage(&self, name: &str) -> String { + let mut parts = vec![name.to_string()]; + for (i, variable) in self.variables.iter().enumerate() { + let part = match ( + variable.rest && i == self.variables.len() - 1, + variable.default.is_some(), + ) { + (true, true) => format!("[{}]...", variable.name), + (true, false) => format!("<{}>...", variable.name), + (false, true) => format!("[{}]", variable.name), + (false, false) => format!("<{}>", variable.name), + }; + parts.push(part); + } + parts.join(" ") + } + + pub fn interpolate_command(command: &str, variables: &IndexMap) -> String { + let mut output = command.to_string(); + for (key, value) in variables { + output = output.replace(&format!("{{{{{key}}}}}"), value); + } + output + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct MacroVariable { + pub name: String, + #[serde(default)] + pub rest: bool, + pub default: Option, +} + #[derive(Debug, Clone)] pub struct LastMessage { pub input: Input, @@ -2487,3 +2655,13 @@ where config.write().rag = Some(Arc::new(rag)); Ok(()) } + +fn format_option_value(value: &Option) -> String +where + T: std::fmt::Display, +{ + match value { + Some(value) => value.to_string(), + None => "null".to_string(), + } +} diff --git a/src/config/session.rs b/src/config/session.rs index 227a23c2..13af7512 100644 --- a/src/config/session.rs +++ b/src/config/session.rs @@ -234,8 +234,7 @@ impl Session { } MessageRole::User => { lines.push(format!( - "{}){}", - self.name, + ">> {}", message.content.render_input(resolve_url_fn, agent_info) )); } diff --git a/src/main.rs b/src/main.rs index 056133cc..48eb2b9a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,8 +17,8 @@ use crate::client::{ call_chat_completions, call_chat_completions_streaming, list_models, ModelType, }; use crate::config::{ - ensure_parent_exists, list_agents, load_env_file, Config, GlobalConfig, Input, WorkingMode, - CODE_ROLE, EXPLAIN_SHELL_ROLE, SHELL_ROLE, TEMP_SESSION_NAME, + ensure_parent_exists, list_agents, load_env_file, macro_execute, Config, GlobalConfig, Input, + WorkingMode, CODE_ROLE, EXPLAIN_SHELL_ROLE, SHELL_ROLE, TEMP_SESSION_NAME, }; use crate::render::render_error; use crate::repl::Repl; @@ -31,19 +31,13 @@ use inquire::Text; use is_terminal::IsTerminal; use parking_lot::RwLock; use simplelog::{format_description, ConfigBuilder, LevelFilter, SimpleLogger, WriteLogger}; -use std::{ - env, - io::{stdin, Read}, - process, - sync::Arc, -}; +use std::{env, io::stdin, process, sync::Arc}; #[tokio::main] async fn main() -> Result<()> { load_env_file()?; let cli = Cli::parse(); - let text = cli.text(); - let text = aggregate_text(text)?; + let text = cli.text()?; let working_mode = if cli.serve.is_some() { WorkingMode::Serve } else if text.is_none() && cli.file.is_empty() { @@ -67,7 +61,7 @@ async fn run(config: GlobalConfig, cli: Cli, text: Option) -> Result<()> return serve::run(config, addr).await; } if cli.info { - config.write().cli_info_flag = true; + config.write().info_flag = true; } if cli.list_models { @@ -91,6 +85,12 @@ async fn run(config: GlobalConfig, cli: Cli, text: Option) -> Result<()> println!("{rags}"); return Ok(()); } + if cli.list_macros { + let macros = Config::list_macros().join("\n"); + println!("{macros}"); + return Ok(()); + } + if cli.dry_run { config.write().dry_run = true; } @@ -158,6 +158,10 @@ async fn run(config: GlobalConfig, cli: Cli, text: Option) -> Result<()> return Ok(()); } } + if let Some(name) = &cli.macro_name { + macro_execute(&config, name, text.as_deref(), abort_signal.clone()).await?; + return Ok(()); + } if cli.execute && !is_repl { if cfg!(target_os = "macos") && !stdin().is_terminal() { bail!("Unable to read the pipe for shell execution on MacOS") @@ -318,21 +322,6 @@ async fn shell_execute( Ok(()) } -fn aggregate_text(text: Option) -> Result> { - let text = if stdin().is_terminal() { - text - } else { - let mut stdin_text = String::new(); - stdin().read_to_string(&mut stdin_text)?; - if let Some(text) = text { - Some(format!("{text}\n{stdin_text}")) - } else { - Some(stdin_text) - } - }; - Ok(text) -} - async fn create_input( config: &GlobalConfig, text: Option, diff --git a/src/repl/mod.rs b/src/repl/mod.rs index 136d113b..87befd36 100644 --- a/src/repl/mod.rs +++ b/src/repl/mod.rs @@ -7,7 +7,9 @@ use self::highlighter::ReplHighlighter; use self::prompt::ReplPrompt; use crate::client::{call_chat_completions, call_chat_completions_streaming}; -use crate::config::{AssertState, Config, GlobalConfig, Input, LastMessage, StateFlags}; +use crate::config::{ + macro_execute, AssertState, Config, GlobalConfig, Input, LastMessage, StateFlags, +}; use crate::render::render_error; use crate::utils::{ abortable_run_with_spinner, create_abort_signal, set_text, temp_file, AbortSignal, @@ -26,7 +28,7 @@ use std::{env, process}; const MENU_NAME: &str = "completion_menu"; lazy_static::lazy_static! { - static ref REPL_COMMANDS: [ReplCommand; 34] = [ + static ref REPL_COMMANDS: [ReplCommand; 35] = [ ReplCommand::new(".help", "Show this help message", AssertState::pass()), ReplCommand::new(".info", "View system info", AssertState::pass()), ReplCommand::new(".model", "Change the current LLM", AssertState::pass()), @@ -146,6 +148,11 @@ lazy_static::lazy_static! { "Leave the RAG", AssertState::TrueFalse(StateFlags::RAG, StateFlags::AGENT), ), + ReplCommand::new( + ".macro", + "Execute a macro", + AssertState::pass() + ), ReplCommand::new( ".file", "Include files, directories, URLs or commands", @@ -192,7 +199,13 @@ impl Repl { if AssertState::False(StateFlags::AGENT | StateFlags::RAG) .assert(self.config.read().state()) { - self.banner(); + print!( + r#"Welcome to {} {} +Type ".help" for additional help. +"#, + env!("CARGO_CRATE_NAME"), + env!("CARGO_PKG_VERSION"), + ) } loop { @@ -203,7 +216,7 @@ impl Repl { match sig { Ok(Signal::Success(line)) => { self.abort_signal.reset(); - match self.handle(&line).await { + match run_repl_command(&self.config, self.abort_signal.clone(), &line).await { Ok(exit) => { if exit { break; @@ -230,313 +243,6 @@ impl Repl { Ok(()) } - async fn handle(&self, mut line: &str) -> Result { - if let Ok(Some(captures)) = MULTILINE_RE.captures(line) { - if let Some(text_match) = captures.get(1) { - line = text_match.as_str(); - } - } - match parse_command(line) { - Some((cmd, args)) => match cmd { - ".help" => { - dump_repl_help(); - } - ".info" => match args { - Some("role") => { - let info = self.config.read().role_info()?; - print!("{}", info); - } - Some("session") => { - let info = self.config.read().session_info()?; - print!("{}", info); - } - Some("rag") => { - let info = self.config.read().rag_info()?; - print!("{}", info); - } - Some("agent") => { - let info = self.config.read().agent_info()?; - print!("{}", info); - } - Some(_) => unknown_command()?, - None => { - let output = self.config.read().sysinfo()?; - print!("{}", output); - } - }, - ".model" => match args { - Some(name) => { - self.config.write().set_model(name)?; - } - None => println!("Usage: .model "), - }, - ".prompt" => match args { - Some(text) => { - self.config.write().use_prompt(text)?; - } - None => println!("Usage: .prompt ..."), - }, - ".role" => match args { - Some(args) => match args.split_once(['\n', ' ']) { - Some((name, text)) => { - let role = self.config.read().retrieve_role(name.trim())?; - let input = Input::from_str(&self.config, text, Some(role)); - ask(&self.config, self.abort_signal.clone(), input, false).await?; - } - None => { - let name = args; - if Config::has_role(name) { - self.config.write().use_role(name)?; - } else { - self.config.write().new_role(name)?; - } - } - }, - None => println!( - r#"Usage: - .role # If the role exists, switch to it; otherwise, create a new role - .role [text]... # Temporarily switch to the role, send the text, and switch back"# - ), - }, - ".session" => { - self.config.write().use_session(args)?; - Config::maybe_autoname_session(self.config.clone()); - } - ".rag" => { - Config::use_rag(&self.config, args, self.abort_signal.clone()).await?; - } - ".agent" => match split_args(args) { - Some((agent_name, session_name)) => { - Config::use_agent( - &self.config, - agent_name, - session_name, - self.abort_signal.clone(), - ) - .await?; - } - None => println!(r#"Usage: .agent [session-name]"#), - }, - ".starter" => match args { - Some(value) => { - let input = Input::from_str(&self.config, value, None); - ask(&self.config, self.abort_signal.clone(), input, true).await?; - } - None => { - let banner = self.config.read().agent_banner()?; - self.config.read().print_markdown(&banner)?; - } - }, - ".variable" => match args { - Some(args) => { - self.config.write().set_agent_variable(args)?; - } - _ => { - println!("Usage: .variable ") - } - }, - ".save" => match split_args(args) { - Some(("role", name)) => { - self.config.write().save_role(name)?; - } - Some(("session", name)) => { - self.config.write().save_session(name)?; - } - _ => { - println!(r#"Usage: .save [name]"#) - } - }, - ".edit" => match args { - Some("role") => { - self.config.write().edit_role()?; - } - Some("session") => { - self.config.write().edit_session()?; - } - Some("rag-docs") => { - Config::edit_rag_docs(&self.config, self.abort_signal.clone()).await?; - } - _ => { - println!(r#"Usage: .edit "#) - } - }, - ".compress" => match args { - Some("session") => { - abortable_run_with_spinner( - Config::compress_session(&self.config), - "Compressing", - self.abort_signal.clone(), - ) - .await?; - println!("✓ Successfully compressed the session."); - } - _ => { - println!(r#"Usage: .compress session"#) - } - }, - ".empty" => match args { - Some("session") => { - self.config.write().empty_session()?; - } - _ => { - println!(r#"Usage: .empty session"#) - } - }, - ".rebuild" => match args { - Some("rag") => { - Config::rebuild_rag(&self.config, self.abort_signal.clone()).await?; - } - _ => { - println!(r#"Usage: .rebuild rag"#) - } - }, - ".sources" => match args { - Some("rag") => { - let output = Config::rag_sources(&self.config)?; - println!("{}", output); - } - _ => { - println!(r#"Usage: .sources rag"#) - } - }, - ".file" => match args { - Some(args) => { - let (files, text) = split_files_text(args, cfg!(windows)); - let input = Input::from_files_with_spinner( - &self.config, - text, - files, - None, - self.abort_signal.clone(), - ) - .await?; - ask(&self.config, self.abort_signal.clone(), input, true).await?; - } - None => println!( - r#"Usage: .file ... [-- ...] - -.file /tmp/file.txt -.file src/ Cargo.toml -- analyze -.file https://example.com/file.txt -- summarize -.file https://example.com/image.png -- recognize text -.file %% -- translate last reply to english -.file `git diff` -- Generate git commit message"# - ), - }, - ".continue" => { - let LastMessage { - mut input, output, .. - } = match self - .config - .read() - .last_message - .as_ref() - .filter(|v| v.continuous && !v.output.is_empty()) - .cloned() - { - Some(v) => v, - None => bail!("Unable to continue the response"), - }; - input.set_continue_output(&output); - ask(&self.config, self.abort_signal.clone(), input, true).await?; - } - ".regenerate" => { - let LastMessage { mut input, .. } = match self - .config - .read() - .last_message - .as_ref() - .filter(|v| v.continuous) - .cloned() - { - Some(v) => v, - None => bail!("Unable to regenerate the response"), - }; - input.set_regenerate(); - ask(&self.config, self.abort_signal.clone(), input, true).await?; - } - ".set" => match args { - Some(args) => { - Config::update(&self.config, args)?; - } - _ => { - println!("Usage: .set ...") - } - }, - ".delete" => match args { - Some(args) => { - Config::delete(&self.config, args)?; - } - _ => { - println!("Usage: .delete ") - } - }, - ".copy" => { - let output = match self - .config - .read() - .last_message - .as_ref() - .filter(|v| v.continuous && !v.output.is_empty()) - .map(|v| v.output.clone()) - { - Some(v) => v, - None => bail!("No chat response to copy"), - }; - self.copy(&output) - .with_context(|| "Failed to copy the last chat response")?; - } - ".exit" => match args { - Some("role") => { - self.config.write().exit_role()?; - } - Some("session") => { - if self.config.read().agent.is_some() { - self.config.write().exit_agent_session()?; - } else { - self.config.write().exit_session()?; - } - } - Some("rag") => { - self.config.write().exit_rag()?; - } - Some("agent") => { - self.config.write().exit_agent()?; - } - Some(_) => unknown_command()?, - None => { - return Ok(true); - } - }, - ".clear" => match args { - Some("messages") => { - bail!("Use '.empty session' instead"); - } - _ => unknown_command()?, - }, - _ => unknown_command()?, - }, - None => { - let input = Input::from_str(&self.config, line, None); - ask(&self.config, self.abort_signal.clone(), input, true).await?; - } - } - - println!(); - - Ok(false) - } - - fn banner(&self) { - let name = env!("CARGO_CRATE_NAME"); - let version = env!("CARGO_PKG_VERSION"); - print!( - r#"Welcome to {name} {version} -Type ".help" for additional help. -"# - ) - } - fn create_editor(config: &GlobalConfig) -> Result { let completer = ReplCompleter::new(config); let highlighter = ReplHighlighter::new(config); @@ -602,14 +308,6 @@ Type ".help" for additional help. let completion_menu = ColumnarMenu::default().with_name(MENU_NAME); ReedlineMenu::EngineCompleter(Box::new(completion_menu)) } - - fn copy(&self, text: &str) -> Result<()> { - if text.is_empty() { - bail!("No text to copy") - } - set_text(text)?; - Ok(()) - } } #[derive(Debug, Clone)] @@ -647,6 +345,314 @@ impl Validator for ReplValidator { } } +pub async fn run_repl_command( + config: &GlobalConfig, + abort_signal: AbortSignal, + mut line: &str, +) -> Result { + if let Ok(Some(captures)) = MULTILINE_RE.captures(line) { + if let Some(text_match) = captures.get(1) { + line = text_match.as_str(); + } + } + match parse_command(line) { + Some((cmd, args)) => match cmd { + ".help" => { + dump_repl_help(); + } + ".info" => match args { + Some("role") => { + let info = config.read().role_info()?; + print!("{}", info); + } + Some("session") => { + let info = config.read().session_info()?; + print!("{}", info); + } + Some("rag") => { + let info = config.read().rag_info()?; + print!("{}", info); + } + Some("agent") => { + let info = config.read().agent_info()?; + print!("{}", info); + } + Some(_) => unknown_command()?, + None => { + let output = config.read().sysinfo()?; + print!("{}", output); + } + }, + ".model" => match args { + Some(name) => { + config.write().set_model(name)?; + } + None => println!("Usage: .model "), + }, + ".prompt" => match args { + Some(text) => { + config.write().use_prompt(text)?; + } + None => println!("Usage: .prompt ..."), + }, + ".role" => match args { + Some(args) => match args.split_once(['\n', ' ']) { + Some((name, text)) => { + let role = config.read().retrieve_role(name.trim())?; + let input = Input::from_str(config, text, Some(role)); + ask(config, abort_signal.clone(), input, false).await?; + } + None => { + let name = args; + if !Config::has_role(name) { + config.write().new_role(name)?; + } + config.write().use_role(name)?; + } + }, + None => println!( + r#"Usage: + .role # If the role exists, switch to it; otherwise, create a new role + .role [text]... # Temporarily switch to the role, send the text, and switch back"# + ), + }, + ".session" => { + config.write().use_session(args)?; + Config::maybe_autoname_session(config.clone()); + } + ".rag" => { + Config::use_rag(config, args, abort_signal.clone()).await?; + } + ".agent" => match split_first_arg(args) { + Some((agent_name, session_name)) => { + Config::use_agent(config, agent_name, session_name, abort_signal.clone()) + .await?; + } + None => println!(r#"Usage: .agent [session-name]"#), + }, + ".starter" => match args { + Some(value) => { + let input = Input::from_str(config, value, None); + ask(config, abort_signal.clone(), input, true).await?; + } + None => { + let banner = config.read().agent_banner()?; + config.read().print_markdown(&banner)?; + } + }, + ".variable" => match args { + Some(args) => { + config.write().set_agent_variable(args)?; + } + _ => { + println!("Usage: .variable ") + } + }, + ".save" => match split_first_arg(args) { + Some(("role", name)) => { + config.write().save_role(name)?; + } + Some(("session", name)) => { + config.write().save_session(name)?; + } + _ => { + println!(r#"Usage: .save [name]"#) + } + }, + ".edit" => { + if config.read().macro_flag { + bail!("Cannot perform this operation because you are in a macro") + } + match args { + Some("role") => { + config.write().edit_role()?; + } + Some("session") => { + config.write().edit_session()?; + } + Some("rag-docs") => { + Config::edit_rag_docs(config, abort_signal.clone()).await?; + } + _ => { + println!(r#"Usage: .edit "#) + } + } + } + ".compress" => match args { + Some("session") => { + abortable_run_with_spinner( + Config::compress_session(config), + "Compressing", + abort_signal.clone(), + ) + .await?; + println!("✓ Successfully compressed the session."); + } + _ => { + println!(r#"Usage: .compress session"#) + } + }, + ".empty" => match args { + Some("session") => { + config.write().empty_session()?; + } + _ => { + println!(r#"Usage: .empty session"#) + } + }, + ".rebuild" => match args { + Some("rag") => { + Config::rebuild_rag(config, abort_signal.clone()).await?; + } + _ => { + println!(r#"Usage: .rebuild rag"#) + } + }, + ".sources" => match args { + Some("rag") => { + let output = Config::rag_sources(config)?; + println!("{}", output); + } + _ => { + println!(r#"Usage: .sources rag"#) + } + }, + ".macro" => match split_first_arg(args) { + Some((name, extra)) => { + if !Config::has_macro(name) && extra.is_none() { + config.write().new_macro(name)?; + } else { + macro_execute(config, name, extra, abort_signal.clone()).await?; + } + } + None => println!("Usage: .macro ..."), + }, + ".file" => match args { + Some(args) => { + let (files, text) = split_params_text(args, cfg!(windows)); + let input = Input::from_files_with_spinner( + config, + text, + files, + None, + abort_signal.clone(), + ) + .await?; + ask(config, abort_signal.clone(), input, true).await?; + } + None => println!( + r#"Usage: .file ... [-- ...] + +.file /tmp/file.txt +.file src/ Cargo.toml -- analyze +.file https://example.com/file.txt -- summarize +.file https://example.com/image.png -- recognize text +.file %% -- translate last reply to english +.file `git diff` -- Generate git commit message"# + ), + }, + ".continue" => { + let LastMessage { + mut input, output, .. + } = match config + .read() + .last_message + .as_ref() + .filter(|v| v.continuous && !v.output.is_empty()) + .cloned() + { + Some(v) => v, + None => bail!("Unable to continue the response"), + }; + input.set_continue_output(&output); + ask(config, abort_signal.clone(), input, true).await?; + } + ".regenerate" => { + let LastMessage { mut input, .. } = match config + .read() + .last_message + .as_ref() + .filter(|v| v.continuous) + .cloned() + { + Some(v) => v, + None => bail!("Unable to regenerate the response"), + }; + input.set_regenerate(); + ask(config, abort_signal.clone(), input, true).await?; + } + ".set" => match args { + Some(args) => { + Config::update(config, args)?; + } + _ => { + println!("Usage: .set ...") + } + }, + ".delete" => match args { + Some(args) => { + Config::delete(config, args)?; + } + _ => { + println!("Usage: .delete ") + } + }, + ".copy" => { + let output = match config + .read() + .last_message + .as_ref() + .filter(|v| v.continuous && !v.output.is_empty()) + .map(|v| v.output.clone()) + { + Some(v) => v, + None => bail!("No chat response to copy"), + }; + set_text(&output).context("Failed to copy the last chat response")?; + } + ".exit" => match args { + Some("role") => { + config.write().exit_role()?; + } + Some("session") => { + if config.read().agent.is_some() { + config.write().exit_agent_session()?; + } else { + config.write().exit_session()?; + } + } + Some("rag") => { + config.write().exit_rag()?; + } + Some("agent") => { + config.write().exit_agent()?; + } + Some(_) => unknown_command()?, + None => { + return Ok(true); + } + }, + ".clear" => match args { + Some("messages") => { + bail!("Use '.empty session' instead"); + } + _ => unknown_command()?, + }, + _ => unknown_command()?, + }, + None => { + let input = Input::from_str(config, line, None); + ask(config, abort_signal.clone(), input, true).await?; + } + } + + if !config.read().macro_flag { + println!(); + } + + Ok(false) +} + #[async_recursion::async_recursion] async fn ask( config: &GlobalConfig, @@ -720,14 +726,14 @@ fn parse_command(line: &str) -> Option<(&str, Option<&str>)> { } } -fn split_args(args: Option<&str>) -> Option<(&str, Option<&str>)> { +fn split_first_arg(args: Option<&str>) -> Option<(&str, Option<&str>)> { args.map(|v| match v.split_once(' ') { Some((subcmd, args)) => (subcmd, Some(args.trim())), None => (v, None), }) } -fn split_files_text(line: &str, is_win: bool) -> (Vec, &str) { +pub fn split_params_text(line: &str, is_win: bool) -> (Vec, &str) { let mut words = Vec::new(); let mut word = String::new(); let mut unbalance: Option = None; @@ -823,44 +829,45 @@ mod tests { } #[test] - fn test_split_files_text() { + fn test_split_params_text() { + assert_eq!(split_params_text("", false), (vec![], "")); assert_eq!( - split_files_text("file.txt", false), + split_params_text("file.txt", false), (vec!["file.txt".into()], "") ); assert_eq!( - split_files_text("file.txt --", false), + split_params_text("file.txt --", false), (vec!["file.txt".into()], "") ); assert_eq!( - split_files_text("file.txt -- hello", false), + split_params_text("file.txt -- hello", false), (vec!["file.txt".into()], "hello") ); assert_eq!( - split_files_text("file.txt -- \thello", false), + split_params_text("file.txt -- \thello", false), (vec!["file.txt".into()], "\thello") ); assert_eq!( - split_files_text("file.txt --\nhello", false), + split_params_text("file.txt --\nhello", false), (vec!["file.txt".into()], "hello") ); assert_eq!( - split_files_text("file.txt --\r\nhello", false), + split_params_text("file.txt --\r\nhello", false), (vec!["file.txt".into()], "hello") ); assert_eq!( - split_files_text("file.txt --\rhello", false), + split_params_text("file.txt --\rhello", false), (vec!["file.txt".into()], "hello") ); assert_eq!( - split_files_text(r#"file1.txt 'file2.txt' "file3.txt""#, false), + split_params_text(r#"file1.txt 'file2.txt' "file3.txt""#, false), ( vec!["file1.txt".into(), "file2.txt".into(), "file3.txt".into()], "" ) ); assert_eq!( - split_files_text(r#"./file1.txt 'file1 - Copy.txt' file\ 2.txt"#, false), + split_params_text(r#"./file1.txt 'file1 - Copy.txt' file\ 2.txt"#, false), ( vec![ "./file1.txt".into(), @@ -871,7 +878,7 @@ mod tests { ) ); assert_eq!( - split_files_text(r#".\file.txt C:\dir\file.txt"#, true), + split_params_text(r#".\file.txt C:\dir\file.txt"#, true), (vec![".\\file.txt".into(), "C:\\dir\\file.txt".into()], "") ); } diff --git a/src/utils/mod.rs b/src/utils/mod.rs index c880252e..ecc81aa1 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -112,16 +112,6 @@ pub fn extract_block(input: &str) -> String { } } -pub fn format_option_value(value: &Option) -> String -where - T: std::fmt::Display, -{ - match value { - Some(value) => value.to_string(), - None => "-".to_string(), - } -} - pub fn convert_option_string(value: &str) -> Option { if value.is_empty() { None @@ -199,6 +189,21 @@ pub fn dimmed_text(input: &str) -> String { nu_ansi_term::Style::new().dimmed().paint(input).to_string() } +pub fn multiline_text(input: &str) -> String { + input + .split('\n') + .enumerate() + .map(|(i, v)| { + if i == 0 { + v.to_string() + } else { + format!(".. {v}") + } + }) + .collect::>() + .join("\n") +} + pub fn temp_file(prefix: &str, suffix: &str) -> PathBuf { env::temp_dir().join(format!( "{}-{}{prefix}{}{suffix}",