use serde_json::Value;
use tabled::{
Table, Tabled,
settings::{Color, Modify, Padding, Style, object::Rows},
};
use terminal_size::{Width, terminal_size};
use crate::index::{IndexEntry, format_id};
use crate::message::{ConversationMessage, MessageKind};
use crate::text;
#[derive(Tabled)]
struct SessionRow {
#[tabled(rename = "ID")]
id: String,
#[tabled(rename = "PARENT")]
parent: String,
#[tabled(rename = "PROVIDER")]
provider: String,
#[tabled(rename = "TIMESTAMP")]
timestamp: String,
#[tabled(rename = "CWD")]
cwd: String,
#[tabled(rename = "MSGS")]
count: String,
#[tabled(rename = "NAME")]
name: String,
#[tabled(rename = "TIME")]
time: String,
}
pub fn print_table<T>(
items: &[T],
writer: &mut dyn std::io::Write,
is_tty: bool,
) -> anyhow::Result<()>
where
T: Tabled,
{
if items.is_empty() {
return Ok(());
}
let mut table = Table::new(items);
table.with(Style::blank()).with(Padding::new(0, 2, 0, 0));
if is_tty {
table.with(Modify::new(Rows::first()).with(Color::BOLD));
}
writeln!(writer, "{table}")?;
Ok(())
}
pub fn print_sessions(
entries: &[&IndexEntry],
writer: &mut dyn std::io::Write,
is_tty: bool,
) -> anyhow::Result<()> {
let mut rows: Vec<SessionRow> = entries
.iter()
.map(|entry| SessionRow {
id: format_id(entry.id),
parent: entry.parent_id.map_or_else(|| "-".to_string(), format_id),
provider: entry.provider.as_str().to_string(),
timestamp: display_cell(entry.provider_id.timestamp()),
cwd: display_cell(entry.provider_id.cwd()),
count: entry.provider_id.count(),
name: display_cell(entry.provider_id.name()),
time: entry.provider_id.time_range(),
})
.collect();
if let Some(width) = cwd_width(&rows, is_tty) {
for row in &mut rows {
row.cwd = truncate_middle(&row.cwd, width);
}
}
print_table(&rows, writer, is_tty)
}
fn display_cell(value: &str) -> String {
if value.is_empty() {
"-".to_string()
} else {
value.to_string()
}
}
fn cwd_width(rows: &[SessionRow], is_tty: bool) -> Option<usize> {
if !is_tty {
return None;
}
let (Width(width), _) = terminal_size()?;
let fixed = column_width(rows, "ID", |row| &row.id)
+ column_width(rows, "PARENT", |row| &row.parent)
+ column_width(rows, "PROVIDER", |row| &row.provider)
+ column_width(rows, "TIMESTAMP", |row| &row.timestamp)
+ column_width(rows, "MSGS", |row| &row.count)
+ column_width(rows, "NAME", |row| &row.name)
+ column_width(rows, "TIME", |row| &row.time)
+ 16;
Some(usize::from(width).saturating_sub(fixed).max(12))
}
fn column_width(rows: &[SessionRow], header: &str, value: impl Fn(&SessionRow) -> &str) -> usize {
rows.iter()
.map(|row| value(row).chars().count())
.max()
.unwrap_or(0)
.max(header.len())
}
fn truncate_middle(value: &str, width: usize) -> String {
let len = value.chars().count();
if len <= width {
return value.to_string();
}
if width <= 3 {
return ".".repeat(width);
}
let keep = width - 3;
let head = keep / 2;
let tail = keep - head;
let prefix: String = value.chars().take(head).collect();
let suffix: String = value
.chars()
.rev()
.take(tail)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("{prefix}...{suffix}")
}
pub fn message_files(msg: &ConversationMessage) -> Vec<String> {
match &msg.kind {
MessageKind::AssistantResponse(ar) => ar
.tool_calls
.iter()
.filter_map(|tc| path_argument(&tc.arguments))
.collect(),
_ => Vec::new(),
}
}
pub fn searchable_text(msg: &ConversationMessage) -> String {
match &msg.kind {
MessageKind::TextContent(tc) => text::sanitize(&tc.text),
MessageKind::AssistantResponse(ar) => {
let mut parts: Vec<String> = ar.thinking.clone();
if !ar.text.is_empty() {
parts.push(ar.text.clone());
}
for tc in &ar.tool_calls {
parts.push(format!(
"{} {}",
tc.name,
summarize_tool_args(&tc.arguments)
));
}
text::sanitize(&text::join_lines(&parts, " "))
}
MessageKind::ToolResultData(tr) => {
if tr.content.is_empty() {
text::sanitize(&tr.tool_name)
} else {
text::sanitize(&format!("{} {}", tr.tool_name, tr.content))
}
}
MessageKind::BashOutput(bo) => {
if bo.command.is_empty() {
text::sanitize(&bo.output)
} else if bo.output.is_empty() {
text::sanitize(&bo.command)
} else {
text::sanitize(&format!("{} {}", bo.command, bo.output))
}
}
}
}
pub fn summarize_tool_args(arguments: &Value) -> String {
if let Some(obj) = arguments.as_object() {
if let Some(path) = path_argument(arguments) {
return format!("path={path}");
}
for key in &["command", "query", "pattern", "description"] {
if let Some(val) = obj.get(*key) {
if let Some(s) = val.as_str() {
return format!("{}={}", key, text::clip(s, 160));
}
}
}
if obj.is_empty() {
return String::new();
}
let mut keys: Vec<&str> = obj.keys().map(std::string::String::as_str).collect();
keys.sort_unstable();
return keys.join(", ");
}
if let Some(s) = arguments.as_str() {
return text::clip(s, 160);
}
if arguments.is_null() {
return String::new();
}
text::clip(&arguments.to_string(), 160)
}
pub fn path_argument(arguments: &Value) -> Option<String> {
let obj = arguments.as_object()?;
for key in &["path", "file_path", "filePath", "file"] {
if let Some(val) = obj.get(*key) {
if let Some(s) = val.as_str() {
return Some(s.to_string());
}
}
}
None
}