use serde_json::Value;
use tabled::{
builder::Builder,
settings::{Color, Modify, Padding, Style, object::Rows},
};
use terminal_size::{Width, terminal_size};
use crate::index::{IndexEntry, format_id};
use crate::message::{ConversationMessage, MessageKind, ProviderId};
use crate::text;
use chrono::{DateTime, Utc};
use std::path::PathBuf;
struct SessionRow {
id: u32,
parent: Option<u32>,
provider: String,
label: Option<String>,
cwd: PathBuf,
count: u32,
from: Option<DateTime<Utc>>,
until: Option<DateTime<Utc>>,
}
impl From<&IndexEntry> for SessionRow {
fn from(entry: &IndexEntry) -> Self {
let ProviderId {
label,
cwd,
count,
from,
until,
} = &entry.provider_id;
Self {
id: entry.id,
parent: entry.parent_id,
provider: entry.provider.as_str().to_string(),
label: label.clone(),
cwd: cwd.clone(),
count: *count,
from: *from,
until: *until,
}
}
}
struct Column {
header: &'static str,
values: Vec<String>,
optional: bool,
}
impl Column {
fn new(header: &'static str, optional: bool) -> Self {
Self {
header,
values: Vec::new(),
optional,
}
}
fn push(&mut self, value: String) {
self.values.push(value);
}
fn max_width(&self) -> usize {
self.values
.iter()
.map(|v| v.chars().count())
.max()
.unwrap_or(0)
.max(self.header.len())
}
fn has_data(&self) -> bool {
self.values.iter().any(|v| !v.is_empty())
}
fn truncate(&mut self, width: usize) {
for val in &mut self.values {
if val.chars().count() > width {
*val = truncate_middle(val, width);
}
}
}
}
const COLUMNS: &[(&str, bool)] = &[
("ID", false),
("PARENT", false),
("PROVIDER", false),
("CWD", false),
("MSGS", false),
("LABEL", true),
("FROM", true),
("UNTIL", true),
];
pub fn print_sessions(
entries: &[&IndexEntry],
writer: &mut dyn std::io::Write,
is_tty: bool,
) -> anyhow::Result<()> {
if entries.is_empty() {
return Ok(());
}
let rows: Vec<SessionRow> = entries.iter().map(|e| SessionRow::from(*e)).collect();
let mut cols: Vec<Column> = COLUMNS
.iter()
.map(|(h, opt)| Column::new(h, *opt))
.collect();
for row in &rows {
cols[0].push(format_id(row.id));
cols[1].push(row.parent.map_or_else(String::new, format_id));
cols[2].push(row.provider.clone());
cols[3].push(row.cwd.display().to_string());
cols[4].push(row.count.to_string());
cols[5].push(row.label.clone().unwrap_or_default());
cols[6].push(
row.from
.map_or_else(String::new, |dt| dt.format("%Y-%m-%d %H:%M").to_string()),
);
cols[7].push(
row.until
.map_or_else(String::new, |dt| dt.format("%Y-%m-%d %H:%M").to_string()),
);
}
if is_tty {
if let Some((Width(tw), _)) = terminal_size() {
truncate_to_terminal(&mut cols, usize::from(tw));
}
}
let visible: Vec<usize> = (0..cols.len())
.filter(|&i| !cols[i].optional || cols[i].has_data())
.collect();
let mut builder = Builder::default();
builder.push_record(visible.iter().map(|&i| cols[i].header).collect::<Vec<_>>());
for row_idx in 0..rows.len() {
let record: Vec<String> = visible
.iter()
.map(|&i| cols[i].values[row_idx].clone())
.collect();
builder.push_record(record);
}
let mut table = builder.build();
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(())
}
fn truncate_to_terminal(cols: &mut [Column], term_width: usize) {
let visible: Vec<usize> = (0..cols.len())
.filter(|&i| !cols[i].optional || cols[i].has_data())
.collect();
let n_visible = visible.len();
let padding = 3 * n_visible;
let fixed: usize = visible
.iter()
.filter(|&&i| i != 3 && i != 5)
.map(|&i| cols[i].max_width())
.sum();
let available = term_width.saturating_sub(fixed + padding);
if available == 0 {
return;
}
let cwd_max = cols[3].max_width();
let label_max = cols[5].max_width();
if cwd_max + label_max <= available {
return;
}
let cwd_w = cwd_max.min(available / 2);
cols[3].truncate(cwd_w);
let cwd_actual = cols[3].max_width();
let label_w = available.saturating_sub(cwd_actual);
cols[5].truncate(label_w);
}
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(&parts.join(" "))
}
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
}