Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions crates/jp_cli/src/cmd/attachment/ls.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use comfy_table::{Cell, Row};
use jp_term::table::DetailRow;

use crate::{cmd::Output, ctx::Ctx, output::print_details};

Expand All @@ -19,9 +19,7 @@ impl Ls {

let mut rows = vec![];
for uri in uris {
let mut row = Row::new();
row.add_cell(Cell::new(uri.to_url()?));
rows.push(row);
rows.push(DetailRow::bare(uri.to_url()?));
}

print_details(&ctx.printer, title.as_deref(), rows);
Expand Down
15 changes: 14 additions & 1 deletion crates/jp_cli/src/cmd/conversation/show.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
use jp_conversation::Error as ConversationError;
use jp_storage::backend::StoragePresence;
use jp_workspace::ConversationHandle;

use crate::{
cmd::{ConversationLoadRequest, Output, conversation_id::PositionalIds},
ctx::Ctx,
format::conversation::DetailsFmt,
format::{attachment_detail_item, conversation::DetailsFmt},
output::print_details,
};

Expand All @@ -28,6 +29,17 @@ impl Show {
ctx.workspace.conversation_presence(&id) == Some(StoragePresence::UserLocalOnly);
let conversation = ctx.workspace.metadata(&handle)?;
let events = ctx.workspace.events(&handle)?;

let mut attachments = vec![];
for attachment in events
.config()
.map_err(ConversationError::from)?
.conversation
.attachments
{
attachments.push(attachment_detail_item(&attachment.to_url()?));
}

let details = DetailsFmt::new(id)
.with_last_message_at(events.last().map(|v| v.event.timestamp))
.with_event_count(events.len())
Expand All @@ -38,6 +50,7 @@ impl Show {
.with_local_flag(local)
.with_active_conversation(active_id.unwrap_or(id))
.with_expires_at(conversation.expires_at)
.with_attachments(attachments)
.with_pretty_printing(ctx.printer.pretty_printing_enabled());

print_details(&ctx.printer, details.title.as_deref(), details.rows());
Expand Down
32 changes: 32 additions & 0 deletions crates/jp_cli/src/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,38 @@ pub(crate) mod conversation;
pub(crate) mod datetime;

use jp_config::types::color::Color;
use jp_term::table::DetailItem;
use serde_json::json;
use url::Url;

/// Build a list item for an attachment URL.
///
/// The terminal text reads as `scheme (description): url` when the attachment
/// carries a `description` query parameter, and as the bare URL otherwise.
/// The JSON form is always an object with `scheme`, `description` (null when
/// absent), and the canonical `url`.
pub(crate) fn attachment_detail_item(url: &Url) -> DetailItem {
let scheme = url.scheme();
let description = url
.query_pairs()
.find(|(key, _)| key == "description")
.map(|(_, value)| value.into_owned());
let url_str = url.to_string();

let text = match &description {
Some(description) => format!("{scheme} ({description}): {url_str}"),
None => url_str.clone(),
};

DetailItem::new(
text,
json!({
"scheme": scheme,
"description": description,
"url": url_str,
}),
)
}

/// Convert a [`Color`] to an SGR background parameter string.
pub(crate) fn color_to_bg_param(color: Color) -> String {
Expand Down
138 changes: 66 additions & 72 deletions crates/jp_cli/src/format/conversation.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
use std::fmt;

use chrono::{DateTime, Utc};
use comfy_table::{Cell, CellAlignment, Row, Table};
use crossterm::style::Stylize as _;
use jp_conversation::ConversationId;
use jp_term::table::{DetailItem, DetailRow, details};

use super::datetime::DateTimeFmt;

Expand Down Expand Up @@ -43,6 +43,9 @@ pub struct DetailsFmt {
/// Display the timestamp of conversation expiration.
pub expires_at: Option<DateTime<Utc>>,

/// Attachments associated with the conversation.
pub attachments: Vec<DetailItem>,

/// Pretty-print the output.
pub pretty: bool,
}
Expand All @@ -62,6 +65,7 @@ impl DetailsFmt {
last_message_at: None,
last_activated_at: None,
expires_at: None,
attachments: vec![],
pretty: true,
}
}
Expand Down Expand Up @@ -96,6 +100,12 @@ impl DetailsFmt {
self
}

#[must_use]
pub fn with_attachments(mut self, attachments: Vec<DetailItem>) -> Self {
self.attachments = attachments;
self
}

#[must_use]
pub fn with_title(mut self, title: Option<impl Into<String>>) -> Self {
self.title = title.map(Into::into);
Expand Down Expand Up @@ -141,111 +151,95 @@ impl DetailsFmt {

/// Return rows for a table displaying the conversation details.
#[must_use]
pub fn rows(&self) -> Vec<Row> {
let mut map = vec![];
pub fn rows(&self) -> Vec<DetailRow> {
let mut rows = vec![];

map.push(("ID".to_owned(), self.id.to_string()));
rows.push(self.scalar("ID", self.id.to_string()));
if let Some(name) = self.assistant_name.clone() {
map.push(("Assistant".to_owned(), name));
rows.push(self.scalar("Assistant", name));
}

if self.message_count > 0 {
map.push(("Events".to_owned(), self.message_count.to_string()));
rows.push(self.scalar("Events", self.message_count.to_string()));
}

if self.turn_count > 0 {
map.push(("Turns".to_owned(), self.turn_count.to_string()));
rows.push(self.scalar("Turns", self.turn_count.to_string()));
}

if let Some(last_message_at) = self.last_message_at {
map.push((
"Latest Message".to_owned(),
rows.push(self.scalar(
"Latest Message",
DateTimeFmt::new(last_message_at).to_string(),
));
}

if let Some(active) = self.active_conversation {
map.push((
"Last Activated".to_owned(),
if active == self.id && self.pretty {
"Currently Active".green().bold().to_string()
} else if active == self.id {
"Currently Active".to_owned()
} else if let Some(last_activated_at) = self.last_activated_at {
DateTimeFmt::new(last_activated_at).to_string()
} else {
"Unknown".to_owned()
},
));
let value = if active == self.id && self.pretty {
"Currently Active".green().bold().to_string()
} else if active == self.id {
"Currently Active".to_owned()
} else if let Some(last_activated_at) = self.last_activated_at {
DateTimeFmt::new(last_activated_at).to_string()
} else {
"Unknown".to_owned()
};
rows.push(self.scalar("Last Activated", value));
}

if let Some(expires_at) = self.expires_at {
map.push((
"Expires In".to_owned(),
if expires_at < Utc::now() {
"On Deactivation".to_string()
} else {
DateTimeFmt::new(expires_at).to_string()
},
));
let value = if expires_at < Utc::now() {
"On Deactivation".to_string()
} else {
DateTimeFmt::new(expires_at).to_string()
};
rows.push(self.scalar("Expires In", value));
}

if let Some(pinned) = self.pinned {
map.push((
"Pinned".to_owned(),
if pinned {
"Yes".bold().blue().to_string()
} else {
"No".to_string()
},
));
let value = if pinned {
"Yes".bold().blue().to_string()
} else {
"No".to_string()
};
rows.push(self.scalar("Pinned", value));
}

if let Some(local) = self.local {
map.push((
"Local".to_owned(),
if local {
"Yes".bold().yellow().to_string()
} else {
"No".to_string()
},
));
let value = if local {
"Yes".bold().yellow().to_string()
} else {
"No".to_string()
};
rows.push(self.scalar("Local", value));
}

let mut rows = vec![];
for (key, value) in map {
let mut row = Row::new();
row.add_cell(
Cell::new(if self.pretty {
key.bold().to_string()
} else {
key
})
.set_alignment(CellAlignment::Right),
);
row.add_cell(Cell::new(value).set_alignment(CellAlignment::Left));
rows.push(row);
if !self.attachments.is_empty() {
rows.push(DetailRow::list(
self.styled_label("Attachments"),
self.attachments.clone(),
));
}

rows
}
}

impl fmt::Display for DetailsFmt {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let rows = self.rows();
let mut buf = String::new();

if let Some(title) = self.title() {
buf.push_str(title);
buf.push_str("\n\n");
/// Bold the label when pretty-printing is enabled.
fn styled_label(&self, label: &str) -> String {
if self.pretty {
label.bold().to_string()
} else {
label.to_owned()
}
}

let mut table = Table::new();
table.load_preset(comfy_table::presets::NOTHING);
table.add_rows(rows);
buf.push_str(&table.trim_fmt());
fn scalar(&self, label: &str, value: String) -> DetailRow {
DetailRow::scalar(self.styled_label(label), value)
}
}

write!(f, "{buf}")
impl fmt::Display for DetailsFmt {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", details(self.title(), self.rows()))
}
}
20 changes: 7 additions & 13 deletions crates/jp_cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ use clap::{
builder::{BoolValueParser, TypedValueParser as _},
};
use cmd::Commands;
use comfy_table::{Cell, CellAlignment, Row};
use crossterm::style::Stylize as _;
use ctx::{Ctx, IntoPartialAppConfig};
use error::{Error, Result};
Expand All @@ -50,7 +49,7 @@ use jp_config::{
};
use jp_printer::{OutputFormat, Printer};
use jp_storage::backend::{FsStorageBackend, NullLockBackend, NullPersistBackend};
use jp_term::table::{details, details_markdown};
use jp_term::table::{DetailRow, details, details_markdown};
use jp_workspace::{Workspace, user_data_dir};
use relative_path::RelativePath;
use serde_json::Value;
Expand Down Expand Up @@ -619,19 +618,14 @@ fn parse_error(error: cmd::Error, format: OutputFormat) -> (u8, String) {
} = error;

if !format.is_json() {
let rows: Vec<Row> = metadata
let rows: Vec<DetailRow> = metadata
.into_iter()
.map(|(k, v)| {
let mut row = Row::new();
row.add_cell(Cell::new(k).set_alignment(CellAlignment::Right))
.add_cell(
Cell::new(match v {
Value::String(s) => s,
v => format!("{v:#}"),
})
.set_alignment(CellAlignment::Left),
);
row
let value = match v {
Value::String(s) => s,
v => format!("{v:#}"),
};
DetailRow::scalar(k, value)
})
.collect();

Expand Down
6 changes: 4 additions & 2 deletions crates/jp_cli/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@

use comfy_table::Row;
use jp_printer::{OutputFormat, Printer};
use jp_term::table::{details, details_json, details_markdown, list, list_json, list_markdown};
use jp_term::table::{
DetailRow, details, details_json, details_markdown, list, list_json, list_markdown,
};
use serde_json::{Value, to_string, to_string_pretty};

/// Print a list table (header + rows) in the format dictated by the printer.
Expand Down Expand Up @@ -38,7 +40,7 @@ pub fn print_table(printer: &Printer, header: Row, rows: Vec<Row>, footer: bool)
/// - `TextPretty` → borderless aligned table with optional title
/// - `Text` → pipe-delimited markdown table with optional title
/// - `Json` / `JsonPretty` → JSON object
pub fn print_details(printer: &Printer, title: Option<&str>, rows: Vec<Row>) {
pub fn print_details(printer: &Printer, title: Option<&str>, rows: Vec<DetailRow>) {
let output = match printer.format() {
OutputFormat::TextPretty => details(title, rows),
OutputFormat::Text => details_markdown(title, rows),
Expand Down
Loading
Loading