From 58dcc6219bb348793c12a3e2e8f30d7555cec5d7 Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Fri, 6 Feb 2026 15:37:39 -0800 Subject: [PATCH 1/8] feat(prompts): Add skeleton for prompts command with list, view, and delete subcommands --- src/main.rs | 4 +++ src/prompts/api.rs | 0 src/prompts/delete.rs | 6 +++++ src/prompts/list.rs | 6 +++++ src/prompts/mod.rs | 61 +++++++++++++++++++++++++++++++++++++++++++ src/prompts/view.rs | 6 +++++ 6 files changed, 83 insertions(+) create mode 100644 src/prompts/api.rs create mode 100644 src/prompts/delete.rs create mode 100644 src/prompts/list.rs create mode 100644 src/prompts/mod.rs create mode 100644 src/prompts/view.rs diff --git a/src/main.rs b/src/main.rs index baabc0d..fa93c36 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ mod eval; mod http; mod login; mod projects; +mod prompts; mod self_update; mod sql; mod ui; @@ -34,6 +35,8 @@ enum Commands { #[command(name = "self")] /// Self-management commands SelfCommand(self_update::SelfArgs), + /// Manage prompts + Prompts(CLIArgs), } #[tokio::main] @@ -48,6 +51,7 @@ async fn main() -> Result<()> { Commands::Eval(cmd) => eval::run(cmd.base, cmd.args).await?, Commands::Projects(cmd) => projects::run(cmd.base, cmd.args).await?, Commands::SelfCommand(args) => self_update::run(args).await?, + Commands::Prompts(cmd) => prompts::run(cmd.base, cmd.args).await?, } Ok(()) diff --git a/src/prompts/api.rs b/src/prompts/api.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/prompts/delete.rs b/src/prompts/delete.rs new file mode 100644 index 0000000..8129663 --- /dev/null +++ b/src/prompts/delete.rs @@ -0,0 +1,6 @@ +use anyhow::Result; + +pub async fn run() -> Result<()> { + println!("delete: not implemented"); + Ok(()) +} diff --git a/src/prompts/list.rs b/src/prompts/list.rs new file mode 100644 index 0000000..dd928a8 --- /dev/null +++ b/src/prompts/list.rs @@ -0,0 +1,6 @@ +use anyhow::Result; + +pub async fn run() -> Result<()> { + println!("list: not implemented"); + Ok(()) +} diff --git a/src/prompts/mod.rs b/src/prompts/mod.rs new file mode 100644 index 0000000..cc546ff --- /dev/null +++ b/src/prompts/mod.rs @@ -0,0 +1,61 @@ +use anyhow::Result; +use clap::{Args, Subcommand}; + +use crate::{args::BaseArgs, http::ApiClient, login::login}; + +mod delete; +mod list; +mod view; + +#[derive(Debug, Clone, Args)] +pub struct PromptsArgs { + #[command(subcommand)] + command: Option, +} + +#[derive(Debug, Clone, Subcommand)] +enum PromptsCommands { + List, + View(ViewArgs), + Delete(DeleteArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct ViewArgs { + /// Prompt name (positional) + #[arg(value_name = "NAME")] + name_positional: Option, + + /// Prompt name (flag) + #[arg(long = "name", short = 'n')] + name_flag: Option, +} + +// impl ViewArgs { +// fn name(&self) -> Option<&str> { +// self.name_positional +// .as_deref() +// .or(self.name_flag.as_deref()) +// } +// } + +#[derive(Debug, Clone, Args)] +pub struct DeleteArgs { + /// Name of the project to delete + name: Option, +} + +pub async fn run(base: BaseArgs, args: PromptsArgs) -> Result<()> { + let ctx = login(&base).await?; + let _client = ApiClient::new(&ctx)?; + let _project = &base + .project + .as_deref() + .ok_or_else(|| anyhow::anyhow!("--project required (or set BRAINTRUST_DEFAULT_PROJECT"))?; + + match args.command { + None | Some(PromptsCommands::List) => list::run().await, + Some(PromptsCommands::View(_p)) => view::run().await, + Some(PromptsCommands::Delete(_p)) => delete::run().await, + } +} diff --git a/src/prompts/view.rs b/src/prompts/view.rs new file mode 100644 index 0000000..0159bf4 --- /dev/null +++ b/src/prompts/view.rs @@ -0,0 +1,6 @@ +use anyhow::Result; + +pub async fn run() -> Result<()> { + println!("view: not implemented"); + Ok(()) +} From 26013178834409cad1cf8ea117137a06404247d5 Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Fri, 6 Feb 2026 17:00:20 -0800 Subject: [PATCH 2/8] feat(prompts): implement list command --- Cargo.toml | 1 + src/main.rs | 1 + src/prompts/api.rs | 54 ++++++++++++++++++++++++++++++ src/prompts/list.rs | 80 ++++++++++++++++++++++++++++++++++++++++++-- src/prompts/mod.rs | 9 +++-- src/utils/mod.rs | 3 ++ src/utils/plurals.rs | 10 ++++++ 7 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 src/utils/mod.rs create mode 100644 src/utils/plurals.rs diff --git a/Cargo.toml b/Cargo.toml index 9cd362c..b4df087 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ dialoguer = { version = "0.11", features = ["fuzzy-select"] } dotenvy = "0.15" open = "5" urlencoding = "2" +comfy-table = "7.2.2" [profile.dist] inherits = "release" diff --git a/src/main.rs b/src/main.rs index fa93c36..1d82b52 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ mod prompts; mod self_update; mod sql; mod ui; +mod utils; use crate::args::CLIArgs; diff --git a/src/prompts/api.rs b/src/prompts/api.rs index e69de29..02c017a 100644 --- a/src/prompts/api.rs +++ b/src/prompts/api.rs @@ -0,0 +1,54 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use urlencoding::encode; + +use crate::http::ApiClient; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Prompt { + pub id: String, + pub name: String, + pub slug: String, + pub project_id: String, + #[serde(default)] + pub description: Option, +} + +#[derive(Debug, Deserialize)] +struct ListResponse { + objects: Vec, +} + +pub async fn list_prompts(client: &ApiClient, project: &str) -> Result> { + let path = format!( + "/v1/prompt?org_name={}&project_name={}", + encode(client.org_name()), + encode(project) + ); + let list: ListResponse = client.get(&path).await?; + + Ok(list.objects) +} + +#[allow(dead_code)] +pub async fn get_prompt_by_slug( + client: &ApiClient, + project: &str, + slug: &str, +) -> Result> { + let path = format!( + "/v1/prompt?org_name={}&project_name={}&slug={}", + encode(client.org_name()), + encode(project), + encode(slug) + ); + + let list: ListResponse = client.get(&path).await?; + Ok(list.objects.into_iter().next()) +} + +#[allow(dead_code)] +pub async fn delete_prompt(client: &ApiClient, prompt_id: &str) -> Result<()> { + let path = format!("/v1/prompt/{}", encode(prompt_id)); + client.delete(&path).await +} diff --git a/src/prompts/list.rs b/src/prompts/list.rs index dd928a8..f4bc5ce 100644 --- a/src/prompts/list.rs +++ b/src/prompts/list.rs @@ -1,6 +1,82 @@ use anyhow::Result; +use dialoguer::console; +use unicode_width::UnicodeWidthStr; + +use crate::{http::ApiClient, ui::with_spinner, utils::pluralize}; + +use super::api; + +pub async fn run(client: &ApiClient, project: &str, org: &str, json: bool) -> Result<()> { + let prompts = with_spinner("Loading prompts...", api::list_prompts(client, project)).await?; + + if json { + println!("{}", serde_json::to_string(&prompts)?); + } else { + println!( + "{} found in {}\n", + console::style(format!( + "{} {}", + &prompts.len(), + pluralize(&prompts.len(), "prompt", None) + )), + &format!( + "{} {} {}", + console::style(org).bold(), + console::style("/").dim().bold(), + console::style(project).bold() + ) + ); + + let name_width = prompts + .iter() + .map(|p| p.name.width()) + .max() + .unwrap_or(24) + .max(20); + + let description_width = prompts + .iter() + .map(|p| p.description.as_deref().unwrap_or("").width()) + .max() + .unwrap_or(24) + .max(32); + + // Table Header + println!( + "{} {} {}", + console::style(format!("{:width$}", "Prompt name", width = name_width)) + .dim() + .bold(), + console::style(format!( + "{:width$}", + "Description", + width = description_width + )) + .dim() + .bold(), + console::style("Slug").dim().bold() + ); + + for prompt in &prompts { + let name_padding = name_width - prompt.name.width(); + let desc = prompt + .description + .as_deref() + .filter(|s| !s.is_empty()) + .unwrap_or("-"); + let desc_padding = description_width - desc.width(); + println!( + "{}{:np$} {}{:dp$} {}", + prompt.name, + "", + desc, + "", + prompt.slug, + np = name_padding, + dp = desc_padding + ); + } + } -pub async fn run() -> Result<()> { - println!("list: not implemented"); Ok(()) } diff --git a/src/prompts/mod.rs b/src/prompts/mod.rs index cc546ff..f96fe8f 100644 --- a/src/prompts/mod.rs +++ b/src/prompts/mod.rs @@ -3,6 +3,7 @@ use clap::{Args, Subcommand}; use crate::{args::BaseArgs, http::ApiClient, login::login}; +mod api; mod delete; mod list; mod view; @@ -47,14 +48,16 @@ pub struct DeleteArgs { pub async fn run(base: BaseArgs, args: PromptsArgs) -> Result<()> { let ctx = login(&base).await?; - let _client = ApiClient::new(&ctx)?; - let _project = &base + let client = ApiClient::new(&ctx)?; + let project = &base .project .as_deref() .ok_or_else(|| anyhow::anyhow!("--project required (or set BRAINTRUST_DEFAULT_PROJECT"))?; match args.command { - None | Some(PromptsCommands::List) => list::run().await, + None | Some(PromptsCommands::List) => { + list::run(&client, project, &ctx.login.org_name, base.json).await + } Some(PromptsCommands::View(_p)) => view::run().await, Some(PromptsCommands::Delete(_p)) => delete::run().await, } diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..ef1b708 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,3 @@ +mod plurals; + +pub use plurals::pluralize; diff --git a/src/utils/plurals.rs b/src/utils/plurals.rs new file mode 100644 index 0000000..932ee2b --- /dev/null +++ b/src/utils/plurals.rs @@ -0,0 +1,10 @@ +pub fn pluralize(count: &usize, singular: &str, plural: Option<&str>) -> String { + if *count == 1 { + return singular.to_string(); + } + + match plural { + Some(p) => p.to_string(), + None => format!("{singular}s"), + } +} From 19a89c39e735c7429b28649d101d195ff715962c Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Fri, 6 Feb 2026 18:25:23 -0800 Subject: [PATCH 3/8] refactor(prompts): replace manual table formatting with comfy_table --- src/projects/list.rs | 35 +++++---------------- src/prompts/list.rs | 75 ++++++++++++-------------------------------- src/ui/mod.rs | 3 +- src/ui/table.rs | 15 +++++++++ src/utils/plurals.rs | 4 +-- 5 files changed, 46 insertions(+), 86 deletions(-) create mode 100644 src/ui/table.rs diff --git a/src/projects/list.rs b/src/projects/list.rs index 7854366..05c7e7c 100644 --- a/src/projects/list.rs +++ b/src/projects/list.rs @@ -1,9 +1,8 @@ use anyhow::Result; use dialoguer::console; -use unicode_width::UnicodeWidthStr; use crate::http::ApiClient; -use crate::ui::with_spinner; +use crate::ui::{header, styled_table, with_spinner}; use super::api; @@ -15,43 +14,23 @@ pub async fn run(client: &ApiClient, org_name: &str, json: bool) -> Result<()> { } else { println!( "{} projects found in {}\n", - console::style(&projects.len()), + console::style(projects.len()), console::style(org_name).bold() ); - // Calculate column widths - let name_width = projects - .iter() - .map(|p| p.name.width()) - .max() - .unwrap_or(20) - .max(20); + let mut table = styled_table(); + table.set_header(vec![header("Name"), header("Description")]); - // Print header - println!( - "{} {}", - console::style(format!("{:width$}", "Project name", width = name_width)) - .dim() - .bold(), - console::style("Description").dim().bold() - ); - - // Print rows for project in &projects { let desc = project .description .as_deref() .filter(|s| !s.is_empty()) .unwrap_or("-"); - let padding = name_width - project.name.width(); - println!( - "{}{:padding$} {}", - project.name, - "", - desc, - padding = padding - ); + table.add_row(vec![&project.name, desc]); } + + println!("{table}"); } Ok(()) diff --git a/src/prompts/list.rs b/src/prompts/list.rs index f4bc5ce..364d4fc 100644 --- a/src/prompts/list.rs +++ b/src/prompts/list.rs @@ -1,8 +1,11 @@ use anyhow::Result; use dialoguer::console; -use unicode_width::UnicodeWidthStr; -use crate::{http::ApiClient, ui::with_spinner, utils::pluralize}; +use crate::{ + http::ApiClient, + ui::{header, styled_table, with_spinner}, + utils::pluralize, +}; use super::api; @@ -12,70 +15,32 @@ pub async fn run(client: &ApiClient, project: &str, org: &str, json: bool) -> Re if json { println!("{}", serde_json::to_string(&prompts)?); } else { - println!( - "{} found in {}\n", - console::style(format!( - "{} {}", - &prompts.len(), - pluralize(&prompts.len(), "prompt", None) - )), - &format!( - "{} {} {}", - console::style(org).bold(), - console::style("/").dim().bold(), - console::style(project).bold() - ) + let count = format!( + "{} {}", + prompts.len(), + pluralize(prompts.len(), "prompt", None) ); - - let name_width = prompts - .iter() - .map(|p| p.name.width()) - .max() - .unwrap_or(24) - .max(20); - - let description_width = prompts - .iter() - .map(|p| p.description.as_deref().unwrap_or("").width()) - .max() - .unwrap_or(24) - .max(32); - - // Table Header println!( - "{} {} {}", - console::style(format!("{:width$}", "Prompt name", width = name_width)) - .dim() - .bold(), - console::style(format!( - "{:width$}", - "Description", - width = description_width - )) - .dim() - .bold(), - console::style("Slug").dim().bold() + "{} found in {} {} {}\n", + console::style(count), + console::style(org).bold(), + console::style("/").dim().bold(), + console::style(project).bold() ); + let mut table = styled_table(); + table.set_header(vec![header("Name"), header("Description"), header("Slug")]); + for prompt in &prompts { - let name_padding = name_width - prompt.name.width(); let desc = prompt .description .as_deref() .filter(|s| !s.is_empty()) .unwrap_or("-"); - let desc_padding = description_width - desc.width(); - println!( - "{}{:np$} {}{:dp$} {}", - prompt.name, - "", - desc, - "", - prompt.slug, - np = name_padding, - dp = desc_padding - ); + table.add_row(vec![&prompt.name, desc, &prompt.slug]); } + + println!("{table}"); } Ok(()) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 8f0cbe3..a41cf81 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -2,9 +2,10 @@ mod select; mod shell; mod spinner; mod status; +mod table; pub use select::fuzzy_select; pub use shell::print_env_export; pub use spinner::{with_spinner, with_spinner_visible}; - pub use status::{print_command_status, CommandStatus}; +pub use table::{header, styled_table}; diff --git a/src/ui/table.rs b/src/ui/table.rs new file mode 100644 index 0000000..31ee19c --- /dev/null +++ b/src/ui/table.rs @@ -0,0 +1,15 @@ +use comfy_table::{presets::NOTHING, Attribute, Cell, Table}; + +/// Create a table with the standard CLI styling (no borders) +pub fn styled_table() -> Table { + let mut table = Table::new(); + table.load_preset(NOTHING); + table +} + +/// Create a header cell with dim + bold styling +pub fn header(text: &str) -> Cell { + Cell::new(text) + .add_attribute(Attribute::Bold) + .add_attribute(Attribute::Dim) +} diff --git a/src/utils/plurals.rs b/src/utils/plurals.rs index 932ee2b..0fc7064 100644 --- a/src/utils/plurals.rs +++ b/src/utils/plurals.rs @@ -1,5 +1,5 @@ -pub fn pluralize(count: &usize, singular: &str, plural: Option<&str>) -> String { - if *count == 1 { +pub fn pluralize(count: usize, singular: &str, plural: Option<&str>) -> String { + if count == 1 { return singular.to_string(); } From e500261db3154f2c03db59c8993207f52803f386 Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Fri, 6 Feb 2026 19:58:16 -0800 Subject: [PATCH 4/8] feat(prompts): implement delete and view commands with table improvements --- src/projects/list.rs | 8 +++-- src/prompts/api.rs | 1 - src/prompts/delete.rs | 76 ++++++++++++++++++++++++++++++++++++++++--- src/prompts/list.rs | 8 +++-- src/prompts/mod.rs | 27 ++++++++++----- src/prompts/view.rs | 48 +++++++++++++++++++++++++-- src/ui/mod.rs | 2 +- src/ui/table.rs | 23 +++++++++++-- 8 files changed, 167 insertions(+), 26 deletions(-) diff --git a/src/projects/list.rs b/src/projects/list.rs index 05c7e7c..8937c5b 100644 --- a/src/projects/list.rs +++ b/src/projects/list.rs @@ -2,7 +2,7 @@ use anyhow::Result; use dialoguer::console; use crate::http::ApiClient; -use crate::ui::{header, styled_table, with_spinner}; +use crate::ui::{apply_column_padding, header, styled_table, truncate, with_spinner}; use super::api; @@ -20,14 +20,16 @@ pub async fn run(client: &ApiClient, org_name: &str, json: bool) -> Result<()> { let mut table = styled_table(); table.set_header(vec![header("Name"), header("Description")]); + apply_column_padding(&mut table, (0, 6)); for project in &projects { let desc = project .description .as_deref() .filter(|s| !s.is_empty()) - .unwrap_or("-"); - table.add_row(vec![&project.name, desc]); + .map(|s| truncate(s, 60)) + .unwrap_or_else(|| "-".to_string()); + table.add_row(vec![&project.name, &desc]); } println!("{table}"); diff --git a/src/prompts/api.rs b/src/prompts/api.rs index 02c017a..7239bd9 100644 --- a/src/prompts/api.rs +++ b/src/prompts/api.rs @@ -47,7 +47,6 @@ pub async fn get_prompt_by_slug( Ok(list.objects.into_iter().next()) } -#[allow(dead_code)] pub async fn delete_prompt(client: &ApiClient, prompt_id: &str) -> Result<()> { let path = format!("/v1/prompt/{}", encode(prompt_id)); client.delete(&path).await diff --git a/src/prompts/delete.rs b/src/prompts/delete.rs index 8129663..e8cec4d 100644 --- a/src/prompts/delete.rs +++ b/src/prompts/delete.rs @@ -1,6 +1,74 @@ -use anyhow::Result; +use std::io::IsTerminal; -pub async fn run() -> Result<()> { - println!("delete: not implemented"); - Ok(()) +use anyhow::{anyhow, bail, Result}; +use dialoguer::Confirm; + +use crate::{ + http::ApiClient, + prompts::api::{self, Prompt}, + ui::{self, print_command_status, with_spinner, CommandStatus}, +}; + +pub async fn run(client: &ApiClient, project: &str, name: Option<&str>) -> Result<()> { + let prompt = match name { + Some(n) => { + let prompts = api::list_prompts(client, project).await?; + + prompts + .into_iter() + .find(|p| p.name == n) + .ok_or_else(|| anyhow!("prompt '{n}' not found"))? + } + None => { + if !std::io::stdin().is_terminal() { + bail!("prompt name required. Use: bt prompts delete "); + } + select_prompt_interactive(client, project).await? + } + }; + + if std::io::stdin().is_terminal() { + let confirm = Confirm::new() + .with_prompt(format!( + "Delete prompt '{}' from {}?", + &prompt.name, project + )) + .default(false) + .interact()?; + + if !confirm { + return Ok(()); + } + } + + match with_spinner("Deleting prompt...", api::delete_prompt(client, &prompt.id)).await { + Ok(_) => { + print_command_status( + CommandStatus::Success, + &format!("Deleted '{}'", prompt.name), + ); + Ok(()) + } + Err(e) => { + print_command_status( + CommandStatus::Error, + &format!("Failed to delete '{}'", prompt.name), + ); + Err(e) + } + } +} + +pub async fn select_prompt_interactive(client: &ApiClient, project: &str) -> Result { + let mut prompts = + with_spinner("Loading prompts...", api::list_prompts(client, project)).await?; + if prompts.is_empty() { + bail!("no prompts found"); + } + + prompts.sort_by(|a, b| a.name.cmp(&b.name)); + let names: Vec<&str> = prompts.iter().map(|p| p.name.as_str()).collect(); + + let selection = ui::fuzzy_select("Select prompt", &names)?; + Ok(prompts[selection].clone()) } diff --git a/src/prompts/list.rs b/src/prompts/list.rs index 364d4fc..7c08189 100644 --- a/src/prompts/list.rs +++ b/src/prompts/list.rs @@ -3,7 +3,7 @@ use dialoguer::console; use crate::{ http::ApiClient, - ui::{header, styled_table, with_spinner}, + ui::{apply_column_padding, header, styled_table, truncate, with_spinner}, utils::pluralize, }; @@ -30,14 +30,16 @@ pub async fn run(client: &ApiClient, project: &str, org: &str, json: bool) -> Re let mut table = styled_table(); table.set_header(vec![header("Name"), header("Description"), header("Slug")]); + apply_column_padding(&mut table, (0, 6)); for prompt in &prompts { let desc = prompt .description .as_deref() .filter(|s| !s.is_empty()) - .unwrap_or("-"); - table.add_row(vec![&prompt.name, desc, &prompt.slug]); + .map(|s| truncate(s, 60)) + .unwrap_or_else(|| "-".to_string()); + table.add_row(vec![&prompt.name, &desc, &prompt.slug]); } println!("{table}"); diff --git a/src/prompts/mod.rs b/src/prompts/mod.rs index f96fe8f..3f7dc51 100644 --- a/src/prompts/mod.rs +++ b/src/prompts/mod.rs @@ -32,13 +32,13 @@ pub struct ViewArgs { name_flag: Option, } -// impl ViewArgs { -// fn name(&self) -> Option<&str> { -// self.name_positional -// .as_deref() -// .or(self.name_flag.as_deref()) -// } -// } +impl ViewArgs { + fn name(&self) -> Option<&str> { + self.name_positional + .as_deref() + .or(self.name_flag.as_deref()) + } +} #[derive(Debug, Clone, Args)] pub struct DeleteArgs { @@ -58,7 +58,16 @@ pub async fn run(base: BaseArgs, args: PromptsArgs) -> Result<()> { None | Some(PromptsCommands::List) => { list::run(&client, project, &ctx.login.org_name, base.json).await } - Some(PromptsCommands::View(_p)) => view::run().await, - Some(PromptsCommands::Delete(_p)) => delete::run().await, + Some(PromptsCommands::View(p)) => { + view::run( + &client, + &ctx.app_url, + project, + &ctx.login.org_name, + p.name(), + ) + .await + } + Some(PromptsCommands::Delete(p)) => delete::run(&client, project, p.name.as_deref()).await, } } diff --git a/src/prompts/view.rs b/src/prompts/view.rs index 0159bf4..7155ea0 100644 --- a/src/prompts/view.rs +++ b/src/prompts/view.rs @@ -1,6 +1,48 @@ -use anyhow::Result; +use std::io::IsTerminal; + +use anyhow::{anyhow, bail, Result}; +use urlencoding::encode; + +use crate::http::ApiClient; +use crate::prompts::delete::select_prompt_interactive; +use crate::ui::{print_command_status, CommandStatus}; + +use super::api; + +pub async fn run( + client: &ApiClient, + app_url: &str, + project: &str, + org_name: &str, + name: Option<&str>, +) -> Result<()> { + let prompt = match name { + Some(n) => { + let prompts = api::list_prompts(client, project).await?; + + prompts + .into_iter() + .find(|p| p.name == n) + .ok_or_else(|| anyhow!("prompt '{n}' not found"))? + } + None => { + if !std::io::stdin().is_terminal() { + bail!("prompt name required. Use: bt prompts view ") + } + select_prompt_interactive(client, project).await? + } + }; + + let url = format!( + "{}/app/{}/p/{}/prompts/{}", + app_url.trim_end_matches('/'), + encode(org_name), + encode(project), + encode(&prompt.id) + ); + + open::that(&url)?; + print_command_status(CommandStatus::Success, &format!("Opened {url} in browser")); -pub async fn run() -> Result<()> { - println!("view: not implemented"); Ok(()) } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index a41cf81..57ca5d7 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -8,4 +8,4 @@ pub use select::fuzzy_select; pub use shell::print_env_export; pub use spinner::{with_spinner, with_spinner_visible}; pub use status::{print_command_status, CommandStatus}; -pub use table::{header, styled_table}; +pub use table::{apply_column_padding, header, styled_table, truncate}; diff --git a/src/ui/table.rs b/src/ui/table.rs index 31ee19c..ff0082d 100644 --- a/src/ui/table.rs +++ b/src/ui/table.rs @@ -1,12 +1,31 @@ -use comfy_table::{presets::NOTHING, Attribute, Cell, Table}; +use comfy_table::{presets::NOTHING, Attribute, Cell, ContentArrangement, Table}; -/// Create a table with the standard CLI styling (no borders) +/// Create a table with the standard CLI styling (no borders, no wrapping) pub fn styled_table() -> Table { let mut table = Table::new(); table.load_preset(NOTHING); + table.set_content_arrangement(ContentArrangement::Disabled); table } +/// Truncate text to max length with ellipsis +pub fn truncate(text: &str, max_len: usize) -> String { + if text.len() <= max_len { + text.to_string() + } else { + format!("{}…", &text[..max_len.saturating_sub(1)]) + } +} + +/// Apply padding to all columns (call after setting headers) +pub fn apply_column_padding(table: &mut Table, padding: (u16, u16)) { + for i in 0..table.column_count() { + if let Some(col) = table.column_mut(i) { + col.set_padding(padding); + } + } +} + /// Create a header cell with dim + bold styling pub fn header(text: &str) -> Cell { Cell::new(text) From d69c84c377ae5e9399839bc082440b1b28975299 Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Sat, 7 Feb 2026 17:50:32 -0800 Subject: [PATCH 5/8] fix(ui): handle multi-byte characters in text truncation --- src/ui/table.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/ui/table.rs b/src/ui/table.rs index ff0082d..26e2084 100644 --- a/src/ui/table.rs +++ b/src/ui/table.rs @@ -10,11 +10,13 @@ pub fn styled_table() -> Table { /// Truncate text to max length with ellipsis pub fn truncate(text: &str, max_len: usize) -> String { - if text.len() <= max_len { - text.to_string() - } else { - format!("{}…", &text[..max_len.saturating_sub(1)]) + if text.chars().count() <= max_len { + return text.to_string(); } + + let keep = max_len.saturating_sub(1); + let truncated: String = text.chars().take(keep).collect(); + format!("{truncated}…") } /// Apply padding to all columns (call after setting headers) From c7a7e2bba6f86805042edc4222b3f8258fe7f2e1 Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Sat, 7 Feb 2026 18:11:24 -0800 Subject: [PATCH 6/8] refactor(prompts): use API query parameters and add pager support --- src/projects/api.rs | 2 +- src/projects/list.rs | 16 ++++++++++++---- src/prompts/api.rs | 17 +++++++---------- src/prompts/delete.rs | 11 ++--------- src/prompts/list.rs | 14 ++++++++++---- src/prompts/mod.rs | 4 ++-- src/prompts/view.rs | 13 +++---------- src/ui/mod.rs | 2 ++ src/ui/pager.rs | 41 +++++++++++++++++++++++++++++++++++++++++ src/ui/table.rs | 9 ++++----- 10 files changed, 84 insertions(+), 45 deletions(-) create mode 100644 src/ui/pager.rs diff --git a/src/projects/api.rs b/src/projects/api.rs index fd24213..fc92703 100644 --- a/src/projects/api.rs +++ b/src/projects/api.rs @@ -36,7 +36,7 @@ pub async fn delete_project(client: &ApiClient, project_id: &str) -> Result<()> pub async fn get_project_by_name(client: &ApiClient, name: &str) -> Result> { let path = format!( - "/v1/project?org_name={}&name={}", + "/v1/project?org_name={}&project_name={}", encode(client.org_name()), encode(name) ); diff --git a/src/projects/list.rs b/src/projects/list.rs index 8937c5b..7d230c4 100644 --- a/src/projects/list.rs +++ b/src/projects/list.rs @@ -1,8 +1,12 @@ +use std::fmt::Write as _; + use anyhow::Result; use dialoguer::console; use crate::http::ApiClient; -use crate::ui::{apply_column_padding, header, styled_table, truncate, with_spinner}; +use crate::ui::{ + apply_column_padding, header, print_with_pager, styled_table, truncate, with_spinner, +}; use super::api; @@ -12,11 +16,14 @@ pub async fn run(client: &ApiClient, org_name: &str, json: bool) -> Result<()> { if json { println!("{}", serde_json::to_string(&projects)?); } else { - println!( + let mut output = String::new(); + + writeln!( + output, "{} projects found in {}\n", console::style(projects.len()), console::style(org_name).bold() - ); + )?; let mut table = styled_table(); table.set_header(vec![header("Name"), header("Description")]); @@ -32,7 +39,8 @@ pub async fn run(client: &ApiClient, org_name: &str, json: bool) -> Result<()> { table.add_row(vec![&project.name, &desc]); } - println!("{table}"); + write!(output, "{table}")?; + print_with_pager(&output)?; } Ok(()) diff --git a/src/prompts/api.rs b/src/prompts/api.rs index 7239bd9..39253bd 100644 --- a/src/prompts/api.rs +++ b/src/prompts/api.rs @@ -30,21 +30,18 @@ pub async fn list_prompts(client: &ApiClient, project: &str) -> Result Result> { +pub async fn get_prompt_by_name(client: &ApiClient, project: &str, name: &str) -> Result { let path = format!( - "/v1/prompt?org_name={}&project_name={}&slug={}", + "/v1/prompt?org_name={}&project_name={}&prompt_name={}", encode(client.org_name()), encode(project), - encode(slug) + encode(name) ); - let list: ListResponse = client.get(&path).await?; - Ok(list.objects.into_iter().next()) + list.objects + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("prompt '{name}' not found")) } pub async fn delete_prompt(client: &ApiClient, prompt_id: &str) -> Result<()> { diff --git a/src/prompts/delete.rs b/src/prompts/delete.rs index e8cec4d..b209c3e 100644 --- a/src/prompts/delete.rs +++ b/src/prompts/delete.rs @@ -1,6 +1,6 @@ use std::io::IsTerminal; -use anyhow::{anyhow, bail, Result}; +use anyhow::{bail, Result}; use dialoguer::Confirm; use crate::{ @@ -11,14 +11,7 @@ use crate::{ pub async fn run(client: &ApiClient, project: &str, name: Option<&str>) -> Result<()> { let prompt = match name { - Some(n) => { - let prompts = api::list_prompts(client, project).await?; - - prompts - .into_iter() - .find(|p| p.name == n) - .ok_or_else(|| anyhow!("prompt '{n}' not found"))? - } + Some(n) => api::get_prompt_by_name(client, project, n).await?, None => { if !std::io::stdin().is_terminal() { bail!("prompt name required. Use: bt prompts delete "); diff --git a/src/prompts/list.rs b/src/prompts/list.rs index 7c08189..bf58922 100644 --- a/src/prompts/list.rs +++ b/src/prompts/list.rs @@ -1,9 +1,11 @@ +use std::fmt::Write as _; + use anyhow::Result; use dialoguer::console; use crate::{ http::ApiClient, - ui::{apply_column_padding, header, styled_table, truncate, with_spinner}, + ui::{apply_column_padding, header, print_with_pager, styled_table, truncate, with_spinner}, utils::pluralize, }; @@ -15,18 +17,21 @@ pub async fn run(client: &ApiClient, project: &str, org: &str, json: bool) -> Re if json { println!("{}", serde_json::to_string(&prompts)?); } else { + let mut output = String::new(); + let count = format!( "{} {}", prompts.len(), pluralize(prompts.len(), "prompt", None) ); - println!( + writeln!( + output, "{} found in {} {} {}\n", console::style(count), console::style(org).bold(), console::style("/").dim().bold(), console::style(project).bold() - ); + )?; let mut table = styled_table(); table.set_header(vec![header("Name"), header("Description"), header("Slug")]); @@ -42,7 +47,8 @@ pub async fn run(client: &ApiClient, project: &str, org: &str, json: bool) -> Re table.add_row(vec![&prompt.name, &desc, &prompt.slug]); } - println!("{table}"); + write!(output, "{table}")?; + print_with_pager(&output)?; } Ok(()) diff --git a/src/prompts/mod.rs b/src/prompts/mod.rs index 3f7dc51..f9f80c7 100644 --- a/src/prompts/mod.rs +++ b/src/prompts/mod.rs @@ -42,7 +42,7 @@ impl ViewArgs { #[derive(Debug, Clone, Args)] pub struct DeleteArgs { - /// Name of the project to delete + /// Name of the prompt to delete name: Option, } @@ -52,7 +52,7 @@ pub async fn run(base: BaseArgs, args: PromptsArgs) -> Result<()> { let project = &base .project .as_deref() - .ok_or_else(|| anyhow::anyhow!("--project required (or set BRAINTRUST_DEFAULT_PROJECT"))?; + .ok_or_else(|| anyhow::anyhow!("--project required (or set BRAINTRUST_DEFAULT_PROJECT)"))?; match args.command { None | Some(PromptsCommands::List) => { diff --git a/src/prompts/view.rs b/src/prompts/view.rs index 7155ea0..a057ec4 100644 --- a/src/prompts/view.rs +++ b/src/prompts/view.rs @@ -1,6 +1,6 @@ use std::io::IsTerminal; -use anyhow::{anyhow, bail, Result}; +use anyhow::{bail, Result}; use urlencoding::encode; use crate::http::ApiClient; @@ -17,17 +17,10 @@ pub async fn run( name: Option<&str>, ) -> Result<()> { let prompt = match name { - Some(n) => { - let prompts = api::list_prompts(client, project).await?; - - prompts - .into_iter() - .find(|p| p.name == n) - .ok_or_else(|| anyhow!("prompt '{n}' not found"))? - } + Some(n) => api::get_prompt_by_name(client, project, n).await?, None => { if !std::io::stdin().is_terminal() { - bail!("prompt name required. Use: bt prompts view ") + bail!("prompt name required. Use: bt prompts view "); } select_prompt_interactive(client, project).await? } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 57ca5d7..097b702 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,9 +1,11 @@ +mod pager; mod select; mod shell; mod spinner; mod status; mod table; +pub use pager::print_with_pager; pub use select::fuzzy_select; pub use shell::print_env_export; pub use spinner::{with_spinner, with_spinner_visible}; diff --git a/src/ui/pager.rs b/src/ui/pager.rs new file mode 100644 index 0000000..d19dd0a --- /dev/null +++ b/src/ui/pager.rs @@ -0,0 +1,41 @@ +use std::io::{self, IsTerminal, Write}; +use std::process::{Command, Stdio}; + +pub fn print_with_pager(output: &str) -> io::Result<()> { + let stdout = io::stdout(); + if !stdout.is_terminal() { + println!("{output}"); + return Ok(()); + } + + let (_, term_height) = crossterm::terminal::size().unwrap_or((80, 24)); + let line_count = output.lines().count(); + if line_count <= term_height as usize { + println!("{output}"); + return Ok(()); + } + + let pager = std::env::var("PAGER") + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "less -R".into()); + + let mut parts = pager.split_whitespace(); + let cmd = parts.next().unwrap_or("less"); + let args: Vec<&str> = parts.collect(); + + let mut child = match Command::new(cmd).args(args).stdin(Stdio::piped()).spawn() { + Ok(c) => c, + Err(_) => { + println!("{output}"); + return Ok(()); + } + }; + + if let Some(mut stdin) = child.stdin.take() { + let _ = writeln!(stdin, "{output}"); + } + + let _ = child.wait(); + Ok(()) +} diff --git a/src/ui/table.rs b/src/ui/table.rs index 26e2084..1fbaac9 100644 --- a/src/ui/table.rs +++ b/src/ui/table.rs @@ -1,14 +1,15 @@ use comfy_table::{presets::NOTHING, Attribute, Cell, ContentArrangement, Table}; -/// Create a table with the standard CLI styling (no borders, no wrapping) pub fn styled_table() -> Table { let mut table = Table::new(); table.load_preset(NOTHING); - table.set_content_arrangement(ContentArrangement::Disabled); + if let Ok((width, _)) = crossterm::terminal::size() { + table.set_width(width); + } + table.set_content_arrangement(ContentArrangement::Dynamic); table } -/// Truncate text to max length with ellipsis pub fn truncate(text: &str, max_len: usize) -> String { if text.chars().count() <= max_len { return text.to_string(); @@ -19,7 +20,6 @@ pub fn truncate(text: &str, max_len: usize) -> String { format!("{truncated}…") } -/// Apply padding to all columns (call after setting headers) pub fn apply_column_padding(table: &mut Table, padding: (u16, u16)) { for i in 0..table.column_count() { if let Some(col) = table.column_mut(i) { @@ -28,7 +28,6 @@ pub fn apply_column_padding(table: &mut Table, padding: (u16, u16)) { } } -/// Create a header cell with dim + bold styling pub fn header(text: &str) -> Cell { Cell::new(text) .add_attribute(Attribute::Bold) From 4edb9eced38edb462b88c6667c8e539e75f0e500 Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Sat, 7 Feb 2026 18:25:36 -0800 Subject: [PATCH 7/8] feat(args): make base arguments globally available in CLI --- src/args.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/args.rs b/src/args.rs index d5e928e..27a67a4 100644 --- a/src/args.rs +++ b/src/args.rs @@ -4,23 +4,23 @@ use std::path::PathBuf; #[derive(Debug, Clone, Args)] pub struct BaseArgs { /// Output as JSON - #[arg(short = 'j', long)] + #[arg(short = 'j', long, global = true)] pub json: bool, /// Override active project - #[arg(short = 'p', long, env = "BRAINTRUST_DEFAULT_PROJECT")] + #[arg(short = 'p', long, env = "BRAINTRUST_DEFAULT_PROJECT", global = true)] pub project: Option, /// Override stored API key (or via BRAINTRUST_API_KEY) - #[arg(long, env = "BRAINTRUST_API_KEY")] + #[arg(long, env = "BRAINTRUST_API_KEY", global = true)] pub api_key: Option, /// Override API URL (or via BRAINTRUST_API_URL) - #[arg(long, env = "BRAINTRUST_API_URL")] + #[arg(long, env = "BRAINTRUST_API_URL", global = true)] pub api_url: Option, /// Override app URL (or via BRAINTRUST_APP_URL) - #[arg(long, env = "BRAINTRUST_APP_URL")] + #[arg(long, env = "BRAINTRUST_APP_URL", global = true)] pub app_url: Option, /// Path to a .env file to load before running commands. From 2b5741fdd8288df7e6d87014c77b5ce36e62802a Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Sat, 7 Feb 2026 18:34:53 -0800 Subject: [PATCH 8/8] refactor(prompts): add project validation before command execution --- Cargo.lock | 45 +++++++++++++++++++++++++++++++++++++++++++-- src/projects/mod.rs | 2 +- src/prompts/mod.rs | 8 ++++++-- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8cd98a6..3bb74ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -135,7 +135,8 @@ dependencies = [ "anyhow", "braintrust-sdk-rust", "clap", - "crossterm", + "comfy-table", + "crossterm 0.28.1", "dialoguer", "dotenvy", "indicatif", @@ -259,6 +260,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "comfy-table" +version = "7.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" +dependencies = [ + "crossterm 0.29.0", + "unicode-segmentation", + "unicode-width 0.2.0", +] + [[package]] name = "compact_str" version = "0.8.1" @@ -308,6 +320,20 @@ dependencies = [ "winapi", ] +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "document-features", + "parking_lot", + "rustix 1.1.3", + "winapi", +] + [[package]] name = "crossterm_winapi" version = "0.9.1" @@ -376,6 +402,15 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -936,6 +971,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -1216,7 +1257,7 @@ dependencies = [ "bitflags", "cassowary", "compact_str", - "crossterm", + "crossterm 0.28.1", "indoc", "instability", "itertools", diff --git a/src/projects/mod.rs b/src/projects/mod.rs index b24cc43..4b89675 100644 --- a/src/projects/mod.rs +++ b/src/projects/mod.rs @@ -5,7 +5,7 @@ use crate::args::BaseArgs; use crate::http::ApiClient; use crate::login::login; -mod api; +pub mod api; mod create; mod delete; mod list; diff --git a/src/prompts/mod.rs b/src/prompts/mod.rs index f9f80c7..e61943d 100644 --- a/src/prompts/mod.rs +++ b/src/prompts/mod.rs @@ -1,7 +1,7 @@ -use anyhow::Result; +use anyhow::{anyhow, Result}; use clap::{Args, Subcommand}; -use crate::{args::BaseArgs, http::ApiClient, login::login}; +use crate::{args::BaseArgs, http::ApiClient, login::login, projects::api::get_project_by_name}; mod api; mod delete; @@ -54,6 +54,10 @@ pub async fn run(base: BaseArgs, args: PromptsArgs) -> Result<()> { .as_deref() .ok_or_else(|| anyhow::anyhow!("--project required (or set BRAINTRUST_DEFAULT_PROJECT)"))?; + get_project_by_name(&client, project) + .await? + .ok_or_else(|| anyhow!("project '{project}' not found"))?; + match args.command { None | Some(PromptsCommands::List) => { list::run(&client, project, &ctx.login.org_name, base.json).await