implement multi-steps function calling

pull/514/head
sigoden 2 weeks ago
parent 3e5b78e2f8
commit ca8060519b

11
Cargo.lock generated

@ -58,6 +58,7 @@ dependencies = [
"log",
"mime_guess",
"nu-ansi-term 0.50.0",
"num_cpus",
"parking_lot",
"reedline",
"reqwest",
@ -71,6 +72,7 @@ dependencies = [
"simplelog",
"syntect",
"textwrap",
"threadpool",
"time",
"tokio",
"tokio-graceful",
@ -2229,6 +2231,15 @@ dependencies = [
"once_cell",
]
[[package]]
name = "threadpool"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa"
dependencies = [
"num_cpus",
]
[[package]]
name = "time"
version = "0.3.36"

@ -45,7 +45,7 @@ shell-words = "1.1.0"
mime_guess = "2.0.4"
sha2 = "0.10.8"
unicode-width = "0.1.11"
async-recursion = "1.1.0"
async-recursion = "1.1.1"
http = "1.1.0"
http-body-util = "0.1"
hyper = { version = "1.0", features = ["full"] }
@ -56,6 +56,8 @@ hmac = "0.12.1"
aws-smithy-eventstream = "0.60.4"
urlencoding = "2.1.3"
unicode-segmentation = "1.11.0"
num_cpus = "1.16.0"
threadpool = "1.8.1"
[dependencies.reqwest]
version = "0.12.0"

@ -126,6 +126,7 @@ pub fn claude_build_body(data: SendData, model: &Model) -> Result<Value> {
}
})
.collect(),
MessageContent::ToolCall(_) => vec![],
};
json!({ "role": role, "content": content })
})

@ -1,6 +1,7 @@
use super::{
catch_error, extract_system_message, json_stream, message::*, CohereClient, CompletionOutput,
ExtraConfig, Model, ModelData, PromptAction, PromptKind, SendData, SseHandler, ToolCall,
ToolCallResult,
};
use anyhow::{bail, Result};
@ -74,7 +75,11 @@ async fn send_message_streaming(builder: RequestBuilder, handler: &mut SseHandle
if let (Some(name), Some(args)) =
(call["name"].as_str(), call["parameters"].as_object())
{
handler.tool_call(ToolCall::new(name.to_string(), json!(args)))?;
handler.tool_call(ToolCall::new(
name.to_string(),
json!(args),
None,
))?;
}
}
}
@ -98,37 +103,68 @@ fn build_body(data: SendData, model: &Model) -> Result<Value> {
let system_message = extract_system_message(&mut messages);
let mut image_urls = vec![];
let mut tool_calls: Vec<MessageToolCall> = vec![];
let mut tool_call_results: Vec<ToolCallResult> = vec![];
let mut messages: Vec<Value> = messages
.into_iter()
.map(|message| {
let role = match message.role {
MessageRole::User => "USER",
_ => "CHATBOT",
};
match message.content {
MessageContent::Text(text) => json!({
"role": role,
"message": text,
}),
MessageContent::Array(list) => {
let list: Vec<String> = list
.into_iter()
.filter_map(|item| match item {
MessageContentPart::Text { text } => Some(text),
MessageContentPart::ImageUrl {
image_url: ImageUrl { url },
} => {
image_urls.push(url.clone());
None
}
})
.collect();
json!({ "role": role, "message": list.join("\n\n") })
.filter_map(|message| {
if message.role == MessageRole::Tool {
if let MessageContent::ToolCall(result) = message.content {
tool_call_results.push(result);
}
None
} else if !message.tool_calls.is_empty() {
tool_calls = message.tool_calls;
None
} else {
let role = match message.role {
MessageRole::User => "USER",
_ => "CHATBOT",
};
let new_message = match message.content {
MessageContent::Text(text) => json!({
"role": role,
"message": text,
}),
MessageContent::Array(list) => {
let list: Vec<String> = list
.into_iter()
.filter_map(|item| match item {
MessageContentPart::Text { text } => Some(text),
MessageContentPart::ImageUrl {
image_url: ImageUrl { url },
} => {
image_urls.push(url.clone());
None
}
})
.collect();
json!({ "role": role, "message": list.join("\n\n") })
}
MessageContent::ToolCall(_) => json!({}),
};
Some(new_message)
}
})
.collect();
let tool_results: Vec<Value> = tool_calls
.into_iter()
.zip(tool_call_results)
.map(|(tool_call, tool_call_result)| {
json!({
"call": {
"name": tool_call.function.name,
"parameters": tool_call.function.arguments,
},
"outputs": [
tool_call_result.output,
]
})
})
.collect();
if !image_urls.is_empty() {
bail!("The model does not support images: {:?}", image_urls);
}
@ -184,6 +220,9 @@ fn build_body(data: SendData, model: &Model) -> Result<Value> {
})
.collect();
}
if !tool_results.is_empty() {
body["tool_results"] = json!(tool_results);
}
Ok(body)
}
@ -194,10 +233,10 @@ fn extract_completion(data: &Value) -> Result<CompletionOutput> {
tool_calls
.iter()
.filter_map(|call| {
if let (Some(name), Some(args)) =
if let (Some(name), Some(parameters)) =
(call["name"].as_str(), call["parameters"].as_object())
{
Some(ToolCall::new(name.to_string(), json!(args)))
Some(ToolCall::new(name.to_string(), json!(parameters), None))
} else {
None
}

@ -2,7 +2,7 @@ use super::{openai::OpenAIConfig, BuiltinModels, ClientConfig, Message, Model, S
use crate::{
config::{GlobalConfig, Input},
function::{run_tool_calls, FunctionDeclaration, ToolCall},
function::{run_tool_calls, FunctionDeclaration, ToolCall, ToolCallResult},
render::{render_error, render_stream},
utils::{prompt_input_integer, prompt_input_string, tokenize, AbortSignal, PromptKind},
};
@ -431,7 +431,7 @@ pub async fn send_stream(
client: &dyn Client,
config: &GlobalConfig,
abort: AbortSignal,
) -> Result<String> {
) -> Result<(String, Vec<ToolCallResult>)> {
let (tx, rx) = unbounded_channel();
let mut handler = SseHandler::new(tx, abort.clone());
@ -442,17 +442,15 @@ pub async fn send_stream(
if let Err(err) = rend_ret {
render_error(err, config.read().highlight);
}
let calls = handler.get_tool_calls();
let output = handler.get_buffer().to_string();
let (output, calls) = handler.take();
match send_ret {
Ok(_) => {
if !calls.is_empty() {
run_tool_calls(config, calls)?;
}
if calls.is_empty() && !output.ends_with('\n') {
let not_tool_call = calls.is_empty();
let tool_call_results = run_tool_calls(config, calls)?;
if not_tool_call && !output.ends_with('\n') {
println!();
}
Ok(output)
Ok((output, tool_call_results))
}
Err(err) => {
if !output.is_empty() {

@ -1,4 +1,4 @@
use crate::config::Input;
use crate::function::ToolCallResult;
use serde::{Deserialize, Serialize};
@ -6,13 +6,32 @@ use serde::{Deserialize, Serialize};
pub struct Message {
pub role: MessageRole,
pub content: MessageContent,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tool_calls: Vec<MessageToolCall>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
}
impl Message {
pub fn new(input: &Input) -> Self {
impl Default for Message {
fn default() -> Self {
Self {
role: MessageRole::User,
content: input.to_message_content(),
content: MessageContent::Text(String::new()),
name: None,
tool_calls: Default::default(),
tool_call_id: None,
}
}
}
impl Message {
pub fn new(role: MessageRole, content: MessageContent) -> Self {
Self {
role,
content,
..Default::default()
}
}
}
@ -23,6 +42,7 @@ pub enum MessageRole {
System,
Assistant,
User,
Tool,
}
#[allow(dead_code)]
@ -34,10 +54,6 @@ impl MessageRole {
pub fn is_user(&self) -> bool {
matches!(self, MessageRole::User)
}
pub fn is_assistant(&self) -> bool {
matches!(self, MessageRole::Assistant)
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@ -45,6 +61,8 @@ impl MessageRole {
pub enum MessageContent {
Text(String),
Array(Vec<MessageContentPart>),
// Note: This type is primarily for convenience and does not exist in OpenAI's API.
ToolCall(ToolCallResult),
}
impl MessageContent {
@ -68,6 +86,7 @@ impl MessageContent {
}
format!(".file {}{}", files.join(" "), concated_text)
}
MessageContent::ToolCall(_) => String::new(),
}
}
@ -83,6 +102,7 @@ impl MessageContent {
*text = replace_fn(text)
}
}
MessageContent::ToolCall(_) => {}
}
}
@ -98,6 +118,7 @@ impl MessageContent {
}
parts.join("\n\n")
}
MessageContent::ToolCall(_) => String::new(),
}
}
}
@ -114,6 +135,20 @@ pub struct ImageUrl {
pub url: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MessageToolCall {
pub id: Option<String>,
#[serde(rename = "type")]
pub typ: String,
pub function: MessageToolCallFunction,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MessageToolCallFunction {
pub name: String,
pub arguments: serde_json::Value,
}
pub fn patch_system_message(messages: &mut Vec<Message>) {
if messages[0].role.is_system() {
let system_message = messages.remove(0);

@ -6,7 +6,7 @@ mod model;
mod prompt_format;
mod sse_handler;
pub use crate::function::ToolCall;
pub use crate::function::{ToolCall, ToolCallResult};
pub use crate::utils::PromptKind;
pub use common::*;
pub use message::*;

@ -161,11 +161,10 @@ impl Model {
pub fn messages_tokens(&self, messages: &[Message]) -> usize {
messages
.iter()
.map(|v| {
match &v.content {
MessageContent::Text(text) => estimate_token_length(text),
MessageContent::Array(_) => 0, // TODO
}
.map(|v| match &v.content {
MessageContent::Text(text) => estimate_token_length(text),
MessageContent::Array(_) => 0,
MessageContent::ToolCall(_) => 0,
})
.sum()
}

@ -143,7 +143,8 @@ fn build_body(data: SendData, model: &Model) -> Result<Value> {
}
let content = content.join("\n\n");
json!({ "role": role, "content": content, "images": images })
}
},
MessageContent::ToolCall(_) => json!({}),
}
})
.collect();

@ -1,5 +1,5 @@
use super::{
catch_error, sse_stream, CompletionOutput, ExtraConfig, Model, ModelData, OpenAIClient,
catch_error, sse_stream, CompletionOutput, message::*, ExtraConfig, Model, ModelData, OpenAIClient,
PromptAction, PromptKind, SendData, SsMmessage, SseHandler, ToolCall,
};
@ -66,11 +66,16 @@ pub async fn openai_send_message_streaming(
) -> Result<()> {
let mut function_index = 0;
let mut function_name = String::new();
let mut function_args = String::new();
let mut function_arguments = String::new();
let mut function_id = String::new();
let handle = |message: SsMmessage| -> Result<bool> {
if message.data == "[DONE]" {
if !function_name.is_empty() {
handler.tool_call(ToolCall::new(function_name.clone(), json!(function_args)))?;
handler.tool_call(ToolCall::new(
function_name.clone(),
json!(function_arguments),
Some(function_id.clone()),
))?;
}
return Ok(true);
}
@ -78,24 +83,33 @@ pub async fn openai_send_message_streaming(
debug!("stream-data: {data}");
if let Some(text) = data["choices"][0]["delta"]["content"].as_str() {
handler.text(text)?;
} else if let (Some(index), Some(function_data)) = (
data["choices"][0]["delta"]["tool_calls"][0]["index"].as_u64(),
} else if let (Some(function), index, id) = (
data["choices"][0]["delta"]["tool_calls"][0]["function"].as_object(),
data["choices"][0]["delta"]["tool_calls"][0]["index"].as_u64(),
data["choices"][0]["delta"]["tool_calls"][0]["id"].as_str(),
) {
let index = index.unwrap_or_default();
if index != function_index {
if !function_name.is_empty() {
handler
.tool_call(ToolCall::new(function_name.clone(), json!(function_args)))?;
handler.tool_call(ToolCall::new(
function_name.clone(),
json!(function_arguments),
Some(function_id.clone()),
))?;
}
function_name.clear();
function_args.clear();
function_arguments.clear();
function_id.clear();
function_index = index;
}
if let Some(name) = function_data.get("name").and_then(|v| v.as_str()) {
if let Some(name) = function.get("name").and_then(|v| v.as_str()) {
function_name = name.to_string();
}
if let Some(arguments) = function_data.get("arguments").and_then(|v| v.as_str()) {
function_args.push_str(arguments);
if let Some(arguments) = function.get("arguments").and_then(|v| v.as_str()) {
function_arguments.push_str(arguments);
}
if let Some(id) = id {
function_id = id.to_string();
}
}
Ok(false)
@ -113,6 +127,22 @@ pub fn openai_build_body(data: SendData, model: &Model) -> Value {
stream,
} = data;
let messages: Vec<Value> = messages
.into_iter()
.map(|message| {
let mut new_message = json!(&message);
let content = match message.content {
MessageContent::ToolCall(result) => {
MessageContent::Text(json!(result.output).to_string())
},
_ => message.content,
};
new_message["content"] = json!(content);
new_message
})
.collect();
let mut body = json!({
"model": &model.name(),
"messages": messages,
@ -155,11 +185,16 @@ pub fn openai_extract_completion(data: &Value) -> Result<CompletionOutput> {
tools_call
.iter()
.filter_map(|call| {
if let (Some(name), Some(args)) = (
if let (Some(name), Some(arguments), Some(id)) = (
call["function"]["name"].as_str(),
call["function"]["arguments"].as_str(),
call["id"].as_str(),
) {
Some(ToolCall::new(name.to_string(), json!(args)))
Some(ToolCall::new(
name.to_string(),
json!(arguments),
Some(id.to_string()),
))
} else {
None
}

@ -108,6 +108,7 @@ pub fn generate_prompt(messages: &[Message], format: PromptFormat) -> anyhow::Re
}
parts.join("\n\n")
}
MessageContent::ToolCall(_) => String::new(),
};
match role {
MessageRole::System => prompt.push_str(&format!(
@ -119,6 +120,7 @@ pub fn generate_prompt(messages: &[Message], format: PromptFormat) -> anyhow::Re
MessageRole::User => {
prompt.push_str(&format!("{user_pre_message}{content}{user_post_message}"))
}
MessageRole::Tool => {}
}
}
if !image_urls.is_empty() {

@ -157,6 +157,7 @@ fn build_body(data: SendData, model: &Model, is_vl: bool) -> Result<(Value, bool
}
})
.collect(),
MessageContent::ToolCall(_) => vec![],
};
json!({ "role": role, "content": content })
})

@ -56,12 +56,11 @@ impl SseHandler {
self.abort.clone()
}
pub fn get_buffer(&self) -> &str {
&self.buffer
}
pub fn get_tool_calls(&self) -> &[ToolCall] {
&self.tool_calls
pub fn take(self) -> (String, Vec<ToolCall>) {
let Self {
buffer, tool_calls, ..
} = self;
(buffer, tool_calls)
}
fn safe_ret(&self, ret: Result<()>) -> Result<()> {

@ -1,7 +1,7 @@
use super::{access_token::*, ToolCall};
use super::{
catch_error, json_stream, message::*, patch_system_message, Client, CompletionOutput,
ExtraConfig, Model, ModelData, PromptAction, PromptKind, SendData, SseHandler, VertexAIClient,
access_token::*, catch_error, json_stream, message::*, patch_system_message, Client,
CompletionOutput, ExtraConfig, Model, ModelData, PromptAction, PromptKind, SendData,
SseHandler, ToolCall, VertexAIClient,
};
use anyhow::{anyhow, bail, Context, Result};
@ -122,7 +122,7 @@ pub async fn gemini_send_message_streaming(
part["functionCall"]["name"].as_str(),
part["functionCall"]["args"].as_object(),
) {
handler.tool_call(ToolCall::new(name.to_string(), json!(args)))?;
handler.tool_call(ToolCall::new(name.to_string(), json!(args), None))?;
}
}
}
@ -147,7 +147,7 @@ fn gemini_extract_completion_text(data: &Value) -> Result<CompletionOutput> {
part["functionCall"]["name"].as_str(),
part["functionCall"]["args"].as_object(),
) {
Some(ToolCall::new(name.to_string(), json!(args)))
Some(ToolCall::new(name.to_string(), json!(args), None))
} else {
None
}
@ -199,27 +199,53 @@ pub(crate) fn gemini_build_body(
MessageRole::User => "user",
_ => "model",
};
match message.content {
MessageContent::Text(text) => json!({
"role": role,
"parts": [{ "text": text }]
}),
MessageContent::Array(list) => {
let list: Vec<Value> = list
.into_iter()
.map(|item| match item {
MessageContentPart::Text { text } => json!({"text": text}),
MessageContentPart::ImageUrl { image_url: ImageUrl { url } } => {
if let Some((mime_type, data)) = url.strip_prefix("data:").and_then(|v| v.split_once(";base64,")) {
json!({ "inline_data": { "mime_type": mime_type, "data": data } })
} else {
network_image_urls.push(url.clone());
json!({ "url": url })
if !message.tool_calls.is_empty() {
let parts: Vec<Value> = message.tool_calls.iter().map(|tool_call| {
json!({
"functionCall": {
"name": tool_call.function.name,
"args": tool_call.function.arguments,
}
})
}).collect();
json!({ "role": role, "parts": parts })
} else {
match message.content {
MessageContent::Text(text) => json!({
"role": role,
"parts": [{ "text": text }]
}),
MessageContent::Array(list) => {
let parts: Vec<Value> = list
.into_iter()
.map(|item| match item {
MessageContentPart::Text { text } => json!({"text": text}),
MessageContentPart::ImageUrl { image_url: ImageUrl { url } } => {
if let Some((mime_type, data)) = url.strip_prefix("data:").and_then(|v| v.split_once(";base64,")) {
json!({ "inline_data": { "mime_type": mime_type, "data": data } })
} else {
network_image_urls.push(url.clone());
json!({ "url": url })
}
},
})
.collect();
json!({ "role": role, "parts": parts })
},
MessageContent::ToolCall(result) => {
let parts = vec![
json!({
"functionResponse": {
"name": result.call.name,
"response": {
"name": result.call.name,
"content": result.output,
}
}
},
})
.collect();
json!({ "role": role, "parts": list })
})
];
json!({ "role": role, "parts": parts })
}
}
}
})

@ -1,9 +1,10 @@
use super::{role::Role, session::Session, GlobalConfig};
use crate::client::{
init_client, list_models, Client, ImageUrl, Message, MessageContent, MessageContentPart, Model,
SendData,
init_client, list_models, Client, ImageUrl, Message, MessageContent, MessageContentPart,
MessageRole, Model, SendData,
};
use crate::function::ToolCallResult;
use crate::utils::{base64_encode, sha256};
use anyhow::{bail, Context, Result};
@ -30,6 +31,7 @@ pub struct Input {
text: String,
medias: Vec<String>,
data_urls: HashMap<String, String>,
tool_call_results: Vec<ToolCallResult>,
context: InputContext,
}
@ -40,6 +42,7 @@ impl Input {
text: text.to_string(),
medias: Default::default(),
data_urls: Default::default(),
tool_call_results: Default::default(),
context: context.unwrap_or_else(|| InputContext::from_config(config)),
}
}
@ -91,6 +94,7 @@ impl Input {
text: texts.join("\n"),
medias,
data_urls,
tool_call_results: Default::default(),
context: context.unwrap_or_else(|| InputContext::from_config(config)),
})
}
@ -111,6 +115,15 @@ impl Input {
self.text = text;
}
pub fn tool_call(mut self, tool_call_results: Vec<ToolCallResult>) -> Self {
self.tool_call_results = tool_call_results;
self
}
pub fn is_tool_call(&self) -> bool {
!self.tool_call_results.is_empty()
}
pub fn model(&self) -> Model {
let model = self.config.read().model.clone();
if let Some(model_id) = self.role().and_then(|v| v.model_id.clone()) {
@ -147,16 +160,15 @@ impl Input {
};
let mut functions = None;
if self.config.read().function_calling && model.supports_function_calling() {
if let Some(role) = self.role() {
let declarations = self
.config
.read()
.function
.filtered_declarations(&role.functions);
if !declarations.is_empty() {
functions = Some(declarations);
}
}
let config = self.config.read();
let function_filter = if let Some(session) = self.session(&config.session) {
session.function_filter()
} else if let Some(role) = self.role() {
role.function_filter.as_deref()
} else {
None
};
functions = config.function.filtered_declarations(function_filter);
};
Ok(SendData {
messages,
@ -168,14 +180,19 @@ impl Input {
}
pub fn build_messages(&self) -> Result<Vec<Message>> {
let messages = if let Some(session) = self.session(&self.config.read().session) {
let mut messages = if let Some(session) = self.session(&self.config.read().session) {
session.build_messages(self)
} else if let Some(role) = self.role() {
role.build_messages(self)
} else {
let message = Message::new(self);
let message = Message {
role: MessageRole::User,
content: self.message_content(),
..Default::default()
};
vec![message]
};
messages.extend(self.tool_messages());
Ok(messages)
}
@ -251,7 +268,7 @@ impl Input {
format!(".file {}{}", files.join(" "), text)
}
pub fn to_message_content(&self) -> MessageContent {
pub fn message_content(&self) -> MessageContent {
if self.medias.is_empty() {
MessageContent::Text(self.text.clone())
} else {
@ -274,6 +291,30 @@ impl Input {
MessageContent::Array(list)
}
}
pub fn tool_messages(&self) -> Vec<Message> {
if !self.is_tool_call() {
return vec![];
}
let mut messages = vec![Message {
role: MessageRole::Assistant,
content: MessageContent::Text(String::new()),
tool_calls: self
.tool_call_results
.iter()
.map(|v| v.build_message())
.collect(),
..Default::default()
}];
messages.extend(self.tool_call_results.iter().map(|tool_call| Message {
role: MessageRole::Tool,
content: MessageContent::ToolCall(tool_call.clone()),
name: Some(tool_call.call.name.clone()),
tool_calls: Default::default(),
tool_call_id: tool_call.call.id.clone(),
}));
messages
}
}
#[derive(Debug, Clone, Default)]

@ -10,7 +10,7 @@ use crate::client::{
create_client_config, list_client_types, list_models, ClientConfig, Model,
OPENAI_COMPATIBLE_PLATFORMS,
};
use crate::function::Function;
use crate::function::{Function, ToolCallResult};
use crate::render::{MarkdownRender, RenderOptions};
use crate::utils::{
format_option_value, fuzzy_match, get_env_name, light_theme_from_colorfgbg, now, render_prompt,
@ -221,15 +221,20 @@ impl Config {
Ok(path)
}
pub fn save_message(&mut self, input: Input, output: &str) -> Result<()> {
pub fn save_message(
&mut self,
input: &Input,
output: &str,
tool_call_results: &[ToolCallResult],
) -> Result<()> {
self.last_message = Some((input.clone(), output.to_string()));
if self.dry_run || output.is_empty() {
if self.dry_run || output.is_empty() || !tool_call_results.is_empty() {
return Ok(());
}
if let Some(session) = input.session_mut(&mut self.session) {
session.add_message(&input, output)?;
session.add_message(input, output)?;
return Ok(());
}
@ -307,8 +312,7 @@ impl Config {
pub fn set_role_obj(&mut self, role: Role) -> Result<()> {
if let Some(session) = self.session.as_mut() {
session.guard_empty()?;
session.set_temperature(role.temperature);
session.set_top_p(role.top_p);
session.set_role_properties(&role);
}
if let Some(model_id) = &role.model_id {
self.set_model(model_id)?;

@ -19,12 +19,17 @@ pub const INPUT_PLACEHOLDER: &str = "__INPUT__";
pub struct Role {
pub name: String,
pub prompt: String,
#[serde(rename(serialize = "model", deserialize = "model"))]
#[serde(
rename(serialize = "model", deserialize = "model"),
skip_serializing_if = "Option::is_none"
)]
pub model_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f64>,
#[serde(default)]
pub functions: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub function_filter: Option<String>,
}
impl Role {
@ -35,13 +40,13 @@ impl Role {
temperature: None,
model_id: None,
top_p: None,
functions: Vec::new(),
function_filter: None,
}
}
pub fn builtin() -> Vec<Role> {
[
(SHELL_ROLE, shell_prompt(), vec![]),
(SHELL_ROLE, shell_prompt(), None),
(
EXPLAIN_SHELL_ROLE,
r#"Provide a terse, single sentence description of the given shell command.
@ -49,7 +54,7 @@ Describe each argument and option of the command.
Provide short responses in about 80 words.
APPLY MARKDOWN formatting when possible."#
.into(),
vec![],
None,
),
(
CODE_ROLE,
@ -64,18 +69,18 @@ async function timeout(ms) {
```
"#
.into(),
vec![],
None,
),
("%functions%", String::new(), vec!["*".into()]),
("%functions%", String::new(), Some(".*".into())),
]
.into_iter()
.map(|(name, prompt, functions)| Self {
.map(|(name, prompt, function_filter)| Self {
name: name.into(),
prompt,
model_id: None,
temperature: None,
top_p: None,
functions,
function_filter,
})
.collect()
}
@ -133,46 +138,30 @@ async function timeout(ms) {
}
pub fn build_messages(&self, input: &Input) -> Vec<Message> {
let mut content = input.to_message_content();
let mut content = input.message_content();
if self.empty_prompt() {
vec![Message {
role: MessageRole::User,
content,
}]
vec![Message::new(MessageRole::User, content)]
} else if self.embedded_prompt() {
content.merge_prompt(|v: &str| self.prompt.replace(INPUT_PLACEHOLDER, v));
vec![Message {
role: MessageRole::User,
content,
}]
vec![Message::new(MessageRole::User, content)]
} else {
let mut messages = vec![];
let (system, cases) = parse_structure_prompt(&self.prompt);
if !system.is_empty() {
messages.push(Message {
role: MessageRole::System,
content: MessageContent::Text(system.to_string()),
})
messages.push(Message::new(
MessageRole::System,
MessageContent::Text(system.to_string()),
));
}
if !cases.is_empty() {
messages.extend(cases.into_iter().flat_map(|(i, o)| {
vec![
Message {
role: MessageRole::User,
content: MessageContent::Text(i.to_string()),
},
Message {
role: MessageRole::Assistant,
content: MessageContent::Text(o.to_string()),
},
Message::new(MessageRole::User, MessageContent::Text(i.to_string())),
Message::new(MessageRole::Assistant, MessageContent::Text(o.to_string())),
]
}));
}
messages.push(Message {
role: MessageRole::User,
content,
});
messages.push(Message::new(MessageRole::User, content));
messages
}
}

@ -1,5 +1,5 @@
use super::input::resolve_data_url;
use super::{Config, Input, Model};
use super::{Config, Input, Model, Role};
use crate::client::{Message, MessageContent, MessageRole};
use crate::render::MarkdownRender;
@ -17,15 +17,20 @@ pub const TEMP_SESSION_NAME: &str = "temp";
pub struct Session {
#[serde(rename(serialize = "model", deserialize = "model"))]
model_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
top_p: Option<f64>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
function_filter: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
save_session: Option<bool>,
messages: Vec<Message>,
#[serde(default)]
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
data_urls: HashMap<String, String>,
#[serde(default)]
#[serde(default, skip_serializing_if = "Vec::is_empty")]
compressed_messages: Vec<Message>,
#[serde(skip_serializing_if = "Option::is_none")]
compress_threshold: Option<usize>,
#[serde(skip)]
pub name: String,
@ -41,13 +46,14 @@ pub struct Session {
impl Session {
pub fn new(config: &Config, name: &str) -> Self {
Self {
let mut session = Self {
model_id: config.model.id(),
temperature: config.temperature,
top_p: config.top_p,
function_filter: None,
save_session: config.save_session,
messages: vec![],
compressed_messages: vec![],
messages: Default::default(),
compressed_messages: Default::default(),
compress_threshold: None,
data_urls: Default::default(),
name: name.to_string(),
@ -55,7 +61,11 @@ impl Session {
dirty: false,
compressing: false,
model: config.model.clone(),
};
if let Some(role) = &config.role {
session.set_role_properties(role);
}
session
}
pub fn load(name: &str, path: &Path) -> Result<Self> {
@ -86,6 +96,10 @@ impl Session {
self.top_p
}
pub fn function_filter(&self) -> Option<&str> {
self.function_filter.as_deref()
}
pub fn save_session(&self) -> Option<bool> {
self.save_session
}
@ -120,12 +134,15 @@ impl Session {
if let Some(top_p) = self.top_p() {
data["top_p"] = top_p.into();
}
if let Some(function_filter) = self.function_filter() {
data["function_filter"] = function_filter.into();
}
if let Some(save_session) = self.save_session() {
data["save_session"] = save_session.into();
}
data["total_tokens"] = tokens.into();
if let Some(context_window) = self.model.max_input_tokens() {
data["max_input_tokens"] = context_window.into();
if let Some(max_input_tokens) = self.model.max_input_tokens() {
data["max_input_tokens"] = max_input_tokens.into();
}
if percent != 0.0 {
data["total/max"] = format!("{}%", percent).into();
@ -153,6 +170,10 @@ impl Session {
items.push(("top_p", top_p.to_string()));
}
if let Some(function_filter) = self.function_filter() {
items.push(("function_filter", function_filter.into()));
}
if let Some(save_session) = self.save_session() {
items.push(("save_session", save_session.to_string()));
}
@ -192,6 +213,7 @@ impl Session {
message.content.render_input(resolve_url_fn)
));
}
MessageRole::Tool => {}
}
}
}
@ -226,6 +248,16 @@ impl Session {
}
}
pub fn set_functions(&mut self, function_filter: Option<&str>) {
self.function_filter = function_filter.map(|v| v.to_string());
}
pub fn set_role_properties(&mut self, role: &Role) {
self.set_temperature(role.temperature);
self.set_top_p(role.top_p);
self.set_functions(role.function_filter.as_deref());
}
pub fn set_save_session(&mut self, value: Option<bool>) {
if self.save_session != value {
self.save_session = value;
@ -251,10 +283,10 @@ impl Session {
pub fn compress(&mut self, prompt: String) {
self.compressed_messages.append(&mut self.messages);
self.messages.push(Message {
role: MessageRole::System,
content: MessageContent::Text(prompt),
});
self.messages.push(Message::new(
MessageRole::System,
MessageContent::Text(prompt),
));
self.dirty = true;
}
@ -300,16 +332,14 @@ impl Session {
}
}
if need_add_msg {
self.messages.push(Message {
role: MessageRole::User,
content: input.to_message_content(),
});
self.messages
.push(Message::new(MessageRole::User, input.message_content()));
}
self.data_urls.extend(input.data_urls());
self.messages.push(Message {
role: MessageRole::Assistant,
content: MessageContent::Text(output.to_string()),
});
self.messages.push(Message::new(
MessageRole::Assistant,
MessageContent::Text(output.to_string()),
));
self.dirty = true;
Ok(())
}
@ -340,10 +370,7 @@ impl Session {
.extend(self.compressed_messages[self.compressed_messages.len() - 2..].to_vec());
}
if need_add_msg {
messages.push(Message {
role: MessageRole::User,
content: input.to_message_content(),
});
messages.push(Message::new(MessageRole::User, input.message_content()));
}
messages
}

@ -1,21 +1,84 @@
use crate::{config::GlobalConfig, utils::exec_command};
use crate::{
client::{MessageToolCall, MessageToolCallFunction},
config::GlobalConfig,
utils::{dimmed_text, error_text, exec_command, spawn_command},
};
use anyhow::{anyhow, bail, Context, Result};
use fancy_regex::Regex;
use indexmap::{IndexMap, IndexSet};
use inquire::Confirm;
use is_terminal::IsTerminal;
use lazy_static::lazy_static;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
use serde_json::{json, Value};
use std::{collections::HashMap, fs, io::stdout, path::Path, sync::mpsc::channel};
use threadpool::ThreadPool;
const BIN_DIR_NAME: &str = "bin";
const DECLARATIONS_FILE_PATH: &str = "functions.json";
pub fn run_tool_calls(config: &GlobalConfig, calls: &[ToolCall]) -> Result<()> {
for call in calls {
call.run(config)?;
lazy_static! {
static ref THREAD_POOL: ThreadPool = ThreadPool::new(num_cpus::get());
}
pub fn run_tool_calls(config: &GlobalConfig, calls: Vec<ToolCall>) -> Result<Vec<ToolCallResult>> {
let mut output = vec![];
if calls.is_empty() {
return Ok(output);
}
let parallel = calls.len() > 1 && calls.iter().all(|v| !v.is_execute());
if parallel {
let (tx, rx) = channel();
let calls_len = calls.len();
for (index, call) in calls.into_iter().enumerate() {
let tx = tx.clone();
let config = config.clone();
THREAD_POOL.execute(move || {
let result = call.run(&config);
let _ = tx.send((index, call, result));
});
}
let mut list: Vec<(usize, ToolCall, Result<Option<Value>>)> =
rx.iter().take(calls_len).collect();
list.sort_by_key(|v| v.0);
for (_, call, result) in list {
let result = result?;
if let Some(result) = result {
output.push(ToolCallResult::new(call, result));
}
}
} else {
for call in calls {
if let Some(result) = call.run(config)? {
output.push(ToolCallResult::new(call, result));
}
}
}
Ok(output)
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ToolCallResult {
pub call: ToolCall,
pub output: Value,
}
impl ToolCallResult {
pub fn new(call: ToolCall, output: Value) -> Self {
Self { call, output }
}
pub fn build_message(&self) -> MessageToolCall {
MessageToolCall {
id: self.call.id.clone(),
typ: "function".into(),
function: MessageToolCallFunction {
name: self.call.name.clone(),
arguments: self.call.arguments.clone(),
},
}
}
Ok(())
}
#[derive(Debug, Clone, Default)]
@ -62,19 +125,19 @@ impl Function {
})
}
pub fn filtered_declarations(&self, filters: &[String]) -> Vec<FunctionDeclaration> {
if filters.is_empty() {
vec![]
} else if filters.len() == 1 && filters[0] == "*" {
self.declarations.clone()
} else if let Ok(re) = Regex::new(&filters.join("|")) {
self.declarations
.iter()
.filter(|v| re.is_match(&v.name).unwrap_or_default())
.cloned()
.collect()
pub fn filtered_declarations(&self, filter: Option<&str>) -> Option<Vec<FunctionDeclaration>> {
let filter = filter?;
let regex = Regex::new(&format!("^({filter})$")).ok()?;
let output: Vec<FunctionDeclaration> = self
.declarations
.iter()
.filter(|v| regex.is_match(&v.name).unwrap_or_default())
.cloned()
.collect();
if output.is_empty() {
None
} else {
vec![]
Some(output)
}
}
}
@ -107,37 +170,43 @@ pub struct JsonSchema {
pub required: Option<Vec<String>>,
}
#[derive(Debug, Clone, Default)]
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ToolCall {
pub name: String,
pub args: Value,
pub arguments: Value,
pub id: Option<String>,
}
impl ToolCall {
pub fn new(name: String, args: Value) -> Self {
Self { name, args }
pub fn new(name: String, arguments: Value, id: Option<String>) -> Self {
Self {
name,
arguments,
id,
}
}
pub fn run(&self, config: &GlobalConfig) -> Result<()> {
pub fn run(&self, config: &GlobalConfig) -> Result<Option<Value>> {
let name = self.name.clone();
if !config.read().function.names.contains(&name) {
bail!("Invalid call: {name} {}", self.args);
bail!("Unexpected call: {name} {}", self.arguments);
}
let args = if self.args.is_object() {
self.args.clone()
} else if let Some(args) = self.args.as_str() {
let args: Value =
serde_json::from_str(args).map_err(|_| anyhow!("Invalid call args: {args}"))?;
let arguments = if self.arguments.is_object() {
self.arguments.clone()
} else if let Some(arguments) = self.arguments.as_str() {
let args: Value = serde_json::from_str(arguments)
.map_err(|_| anyhow!("Invalid call arguments: {arguments}"))?;
args
} else {
bail!("Invalid call args: {}", self.args);
bail!("Invalid call arguments: {}", self.arguments);
};
let args = convert_args(&args);
let arguments = convert_arguments(&arguments);
let prompt_text = format!(
"call {} {}",
"Call {} {}",
name,
args.iter()
arguments
.iter()
.map(|v| shell_words::quote(v).to_string())
.collect::<Vec<String>>()
.join(" ")
@ -150,32 +219,45 @@ impl ToolCall {
} else {
None
};
let ans = Confirm::new(&prompt_text).with_default(true).prompt()?;
if ans {
#[cfg(windows)]
let name = {
let mut name = name;
let bin_dir = config.read().function.bin_dir.clone();
if let Ok(exts) = std::env::var("PATHEXT") {
if let Some(cmd_path) = exts
.split(';')
.map(|ext| bin_dir.join(format!("{}{}", self.name, ext)))
.find(|path| path.exists())
{
name = cmd_path.display().to_string();
}
}
name
let output = if self.is_execute() {
let proceed = if stdout().is_terminal() {
Confirm::new(&prompt_text).with_default(true).prompt()?
} else {
println!("{}", dimmed_text(&prompt_text));
true
};
exec_command(&name, &args, envs)?;
}
if proceed {
#[cfg(windows)]
let name = polyfill_cmd_name(name, &config.read().function.bin_dir);
spawn_command(&name, &arguments, envs)?;
}
None
} else {
println!("{}", dimmed_text(&prompt_text));
#[cfg(windows)]
let name = polyfill_cmd_name(name, &config.read().function.bin_dir);
let (success, stdout, stderr) = exec_command(&name, &arguments, envs)?;
if stderr.is_empty() {
eprintln!("{}", error_text(&stderr));
}
if success && !stdout.is_empty() {
serde_json::from_str(&stdout)
.ok()
.or_else(|| Some(json!({"output": stdout})))
} else {
None
}
};
Ok(output)
}
Ok(())
pub fn is_execute(&self) -> bool {
self.name.starts_with("execute_") || self.name.contains("__execute_")
}
}
fn convert_args(args: &Value) -> Vec<String> {
fn convert_arguments(args: &Value) -> Vec<String> {
let mut options: Vec<String> = Vec::new();
if let Value::Object(map) = args {
@ -215,6 +297,21 @@ fn prepend_env_path(bin_dir: &Path) -> Result<String> {
Ok(new_path)
}
#[cfg(windows)]
fn polyfill_cmd_name(name: &str, bin_dir: &std::path::Path) -> String {
let mut name = name.to_string();
if let Ok(exts) = std::env::var("PATHEXT") {
if let Some(cmd_path) = exts
.split(';')
.map(|ext| bin_dir.join(format!("{}{}", name, ext)))
.find(|path| path.exists())
{
name = cmd_path.display().to_string();
}
}
name
}
#[cfg(test)]
mod tests {
@ -228,7 +325,7 @@ mod tests {
"baz": ["v1", "v2"]
});
assert_eq!(
convert_args(&args),
convert_arguments(&args),
vec!["--foo", "--bar", "val", "--baz", "v1", "--baz", "v2"]
);
}

@ -13,18 +13,19 @@ mod utils;
extern crate log;
use crate::cli::Cli;
use crate::client::{list_models, send_stream};
use crate::client::{list_models, send_stream, CompletionOutput};
use crate::config::{
Config, GlobalConfig, Input, InputContext, WorkingMode, CODE_ROLE, EXPLAIN_SHELL_ROLE,
SHELL_ROLE,
};
use crate::function::run_tool_calls;
use crate::render::{render_error, MarkdownRender};
use crate::repl::Repl;
use crate::utils::{create_abort_signal, exec_command, extract_block, run_spinner, CODE_BLOCK_RE};
use crate::utils::{create_abort_signal, extract_block, run_spinner, spawn_command, CODE_BLOCK_RE};
use anyhow::{bail, Result};
use async_recursion::async_recursion;
use clap::Parser;
use function::run_tool_calls;
use inquire::{Select, Text};
use is_terminal::IsTerminal;
use parking_lot::RwLock;
@ -134,6 +135,7 @@ async fn main() -> Result<()> {
Ok(())
}
#[async_recursion]
async fn start_directive(
config: &GlobalConfig,
input: Input,
@ -143,13 +145,13 @@ async fn start_directive(
let client = input.create_client()?;
let is_terminal_stdout = stdout().is_terminal();
let extract_code = !is_terminal_stdout && code_mode;
let output = if no_stream || extract_code {
let output = client.send_message(input.clone()).await?;
if !output.tool_calls.is_empty() {
run_tool_calls(config, &output.tool_calls)?;
String::new()
let (output, tool_call_results) = if no_stream || extract_code {
let CompletionOutput {
text, tool_calls, ..
} = client.send_message(input.clone()).await?;
if !tool_calls.is_empty() {
(String::new(), run_tool_calls(config, tool_calls)?)
} else {
let text = output.text;
let text = if extract_code && text.trim_start().starts_with("```") {
extract_block(&text)
} else {
@ -162,15 +164,27 @@ async fn start_directive(
} else {
println!("{}", text);
}
text
(text, vec![])
}
} else {
let abort = create_abort_signal();
send_stream(&input, client.as_ref(), config, abort).await?
};
config.write().save_message(input, &output)?;
config
.write()
.save_message(&input, &output, &tool_call_results)?;
config.write().end_session()?;
Ok(())
if !tool_call_results.is_empty() {
start_directive(
config,
Input::tool_call(input, tool_call_results),
no_stream,
code_mode,
)
.await
} else {
Ok(())
}
}
async fn start_interactive(config: &GlobalConfig) -> Result<()> {
@ -200,7 +214,7 @@ async fn shell_execute(
if let Ok(true) = CODE_BLOCK_RE.is_match(&eval_str) {
eval_str = extract_block(&eval_str);
}
config.write().save_message(input.clone(), &eval_str)?;
config.write().save_message(&input, &eval_str, &[])?;
config.read().maybe_copy(&eval_str);
let render_options = config.read().get_render_options()?;
let mut markdown_render = MarkdownRender::init(render_options)?;
@ -219,7 +233,7 @@ async fn shell_execute(
match answer {
"✅ Execute" => {
debug!("{} {:?}", shell, &[shell_arg, &eval_str]);
let code = exec_command(shell, &[shell_arg, &eval_str], None)?;
let code = spawn_command(shell, &[shell_arg, &eval_str], None)?;
if code != 0 {
process::exit(code);
}

@ -4,12 +4,11 @@ mod stream;
pub use self::markdown::{MarkdownRender, RenderOptions};
use self::stream::{markdown_stream, raw_stream};
use crate::utils::AbortSignal;
use crate::utils::{error_text, AbortSignal};
use crate::{client::SseEvent, config::GlobalConfig};
use anyhow::Result;
use is_terminal::IsTerminal;
use nu_ansi_term::{Color, Style};
use std::io::stdout;
use tokio::sync::mpsc::UnboundedReceiver;
@ -30,8 +29,7 @@ pub async fn render_stream(
pub fn render_error(err: anyhow::Error, highlight: bool) {
let err = format!("{err:?}");
if highlight {
let style = Style::new().fg(Color::Red);
eprintln!("{}", style.paint(err));
eprintln!("{}", error_text(&err));
} else {
eprintln!("{err}");
}

@ -12,6 +12,7 @@ use crate::render::render_error;
use crate::utils::{create_abort_signal, set_text, AbortSignal};
use anyhow::{bail, Context, Result};
use async_recursion::async_recursion;
use fancy_regex::Regex;
use lazy_static::lazy_static;
use nu_ansi_term::Color;
@ -184,7 +185,7 @@ impl Repl {
text.trim(),
Some(InputContext::role(role)),
);
self.ask(input).await?;
ask(&self.config, self.abort.clone(), input).await?;
}
None => {
self.config.write().set_role(args)?;
@ -226,7 +227,7 @@ impl Repl {
let (files, text) = split_files_text(args);
let files = shell_words::split(files).with_context(|| "Invalid args")?;
let input = Input::new(&self.config, text, files, None)?;
self.ask(input).await?;
ask(&self.config, self.abort.clone(), input).await?;
}
None => println!("Usage: .file <files>... [-- <text>...]"),
},
@ -252,7 +253,7 @@ impl Repl {
},
None => {
let input = Input::from_str(&self.config, line, None);
self.ask(input).await?;
ask(&self.config, self.abort.clone(), input).await?;
}
}
@ -261,40 +262,6 @@ impl Repl {
Ok(false)
}
async fn ask(&self, input: Input) -> Result<()> {
if input.is_empty() {
return Ok(());
}
while self.config.read().is_compressing_session() {
std::thread::sleep(std::time::Duration::from_millis(100));
}
let client = input.create_client()?;
let output = send_stream(&input, client.as_ref(), &self.config, self.abort.clone()).await?;
self.config.write().save_message(input, &output)?;
self.config.read().maybe_copy(&output);
if self.config.write().should_compress_session() {
let config = self.config.clone();
let color = if config.read().light_theme {
Color::LightGray
} else {
Color::DarkGray
};
print!(
"\n📢 {}{}{}\n",
color.normal().paint(
"Session compression is being activated because the current tokens exceed `"
),
color.italic().paint("compress_threshold"),
color.normal().paint("`."),
);
tokio::spawn(async move {
let _ = compress_session(&config).await;
config.write().end_compressing_session();
});
}
Ok(())
}
fn banner(&self) {
let version = env!("CARGO_PKG_VERSION");
print!(
@ -415,6 +382,48 @@ impl Validator for ReplValidator {
}
}
#[async_recursion]
async fn ask(config: &GlobalConfig, abort: AbortSignal, input: Input) -> Result<()> {
if input.is_empty() {
return Ok(());
}
while config.read().is_compressing_session() {
std::thread::sleep(std::time::Duration::from_millis(100));
}
let client = input.create_client()?;
let (output, tool_call_results) =
send_stream(&input, client.as_ref(), config, abort.clone()).await?;
config
.write()
.save_message(&input, &output, &tool_call_results)?;
config.read().maybe_copy(&output);
if config.write().should_compress_session() {
let config = config.clone();
let color = if config.read().light_theme {
Color::LightGray
} else {
Color::DarkGray
};
print!(
"\n📢 {}{}{}\n",
color.normal().paint(
"Session compression is being activated because the current tokens exceed `"
),
color.italic().paint("compress_threshold"),
color.normal().paint("`."),
);
tokio::spawn(async move {
let _ = compress_session(&config).await;
config.write().end_compressing_session();
});
}
if tool_call_results.is_empty() {
Ok(())
} else {
ask(config, abort, input.tool_call(tool_call_results)).await
}
}
fn unknown_command() -> Result<()> {
bail!(r#"Unknown command. Type ".help" for additional help."#);
}

@ -1,5 +1,7 @@
use std::{collections::HashMap, env, ffi::OsStr, process::Command};
use anyhow::{Context, Result};
pub fn detect_os() -> String {
let os = env::consts::OS;
if os == "linux" {
@ -52,14 +54,29 @@ pub fn detect_shell() -> (String, String, &'static str) {
}
}
pub fn exec_command<T: AsRef<OsStr>>(
pub fn spawn_command<T: AsRef<OsStr>>(
cmd: &str,
args: &[T],
envs: Option<HashMap<String, String>>,
) -> anyhow::Result<i32> {
) -> Result<i32> {
let status = Command::new(cmd)
.args(args.iter())
.envs(envs.unwrap_or_default())
.status()?;
Ok(status.code().unwrap_or_default())
}
pub fn exec_command<T: AsRef<OsStr>>(
cmd: &str,
args: &[T],
envs: Option<HashMap<String, String>>,
) -> Result<(bool, String, String)> {
let output = Command::new(cmd)
.args(args.iter())
.envs(envs.unwrap_or_default())
.output()?;
let status = output.status;
let stdout = std::str::from_utf8(&output.stdout).context("Invalid UTF-8 in stdout")?;
let stderr = std::str::from_utf8(&output.stderr).context("Invalid UTF-8 in stderr")?;
Ok((status.success(), stdout.to_string(), stderr.to_string()))
}

@ -123,6 +123,17 @@ pub fn fuzzy_match(text: &str, pattern: &str) -> bool {
pattern_index == pattern_chars.len()
}
pub fn error_text(input: &str) -> String {
nu_ansi_term::Style::new()
.fg(nu_ansi_term::Color::Red)
.paint(input)
.to_string()
}
pub fn dimmed_text(input: &str) -> String {
nu_ansi_term::Style::new().dimmed().paint(input).to_string()
}
#[cfg(test)]
mod tests {
use super::*;

Loading…
Cancel
Save