File size: 6,514 Bytes
d90101d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 | use anyhow::Result;
use chrono::Utc;
use forge_api::Conversation;
use forge_domain::ConversationId;
use forge_select::{ForgeWidget, PreviewLayout, PreviewPlacement, SelectRow};
use crate::display_constants::markers;
use crate::info::Info;
use crate::porcelain::Porcelain;
/// Logic for selecting conversations from a list
pub struct ConversationSelector;
impl ConversationSelector {
/// Select a conversation from the provided list using a custom TUI with
/// a preview pane showing conversation details.
///
/// The preview command uses `forge conversation info` and
/// `forge conversation show` to display the selected conversation's
/// metadata and last message side-by-side with the picker list.
///
/// Returns the selected conversation, or None if the user cancelled.
pub async fn select_conversation(
conversations: &[Conversation],
_current_conversation_id: Option<ConversationId>,
query: Option<String>,
) -> Result<Option<Conversation>> {
if conversations.is_empty() {
return Ok(None);
}
// Filter to conversations with titles and context
let valid_conversations: Vec<&Conversation> = conversations
.iter()
.filter(|c| c.context.is_some())
.collect();
if valid_conversations.is_empty() {
return Ok(None);
}
// Build Info structure for display
let now = Utc::now();
let mut info = Info::new();
for conv in &valid_conversations {
let title = conv
.title
.as_deref()
.map(|t| t.to_string())
.unwrap_or_else(|| markers::EMPTY.to_string());
let duration = now.signed_duration_since(
conv.metadata.updated_at.unwrap_or(conv.metadata.created_at),
);
let duration =
std::time::Duration::from_secs((duration.num_minutes() * 60).max(0) as u64);
let time_ago = if duration.is_zero() {
"now".to_string()
} else {
format!("{} ago", humantime::format_duration(duration))
};
info = info
.add_title(conv.id)
.add_key_value("Title", title)
.add_key_value("Updated", time_ago);
}
// Convert to porcelain, drop the UUID title column (col 0), truncate the
// Title column for display, uppercase headers
let porcelain_output = Porcelain::from(&info)
.drop_col(0)
.truncate(0, 60)
.uppercase_headers();
let porcelain_str = porcelain_output.to_string();
let all_lines: Vec<&str> = porcelain_str.lines().collect();
if all_lines.is_empty() {
return Ok(None);
}
// Build SelectRow items for the shared Rust selector UI.
// Each row stores the UUID in `fields[0]` so that `{1}` in the preview
// command resolves to the conversation ID. The `raw` field is what gets
// returned on selection (the UUID).
let mut rows: Vec<SelectRow> = Vec::with_capacity(all_lines.len());
// Header row (non-selectable via header_lines=1)
if let Some(header) = all_lines.first() {
rows.push(SelectRow::header(header.to_string()));
}
// Data rows: each maps to a conversation
for (i, line) in all_lines.iter().skip(1).enumerate() {
if let Some(conv) = valid_conversations.get(i) {
let uuid = conv.id.to_string();
rows.push(SelectRow {
raw: uuid.clone(),
display: line.to_string(),
search: line.to_string(),
fields: vec![uuid],
});
}
}
// Build a lookup map from UUID to Conversation for the result
let conv_map: std::collections::HashMap<String, Conversation> = valid_conversations
.into_iter()
.map(|c| (c.id.to_string(), c.clone()))
.collect();
let preview_command =
"CLICOLOR_FORCE=1 forge conversation info {1}; echo; CLICOLOR_FORCE=1 forge conversation show {1}"
.to_string();
let selected_uuid = tokio::task::spawn_blocking(move || -> Result<Option<String>> {
Ok(ForgeWidget::select_rows("Conversation", rows)
.query(query)
.header_lines(1_usize)
.preview(Some(preview_command))
.preview_layout(PreviewLayout { placement: PreviewPlacement::Bottom, percent: 60 })
.prompt()?
.map(|row| row.raw))
})
.await??;
Ok(selected_uuid.and_then(|uuid| conv_map.get(&uuid).cloned()))
}
}
#[cfg(test)]
mod tests {
use chrono::Utc;
use forge_api::Conversation;
use forge_domain::{ConversationId, MetaData, Metrics};
use pretty_assertions::assert_eq;
use super::*;
fn create_test_conversation(id: &str, title: Option<&str>) -> Conversation {
let now = Utc::now();
Conversation {
id: ConversationId::parse(id).unwrap(),
title: title.map(|t| t.to_string()),
context: None,
metrics: Metrics::default().started_at(now),
metadata: MetaData { created_at: now, updated_at: Some(now) },
}
}
#[tokio::test]
async fn test_select_conversation_empty_list() {
let conversations = vec![];
let result = ConversationSelector::select_conversation(&conversations, None, None)
.await
.unwrap();
assert!(result.is_none());
}
#[test]
fn test_select_conversation_with_titles() {
let conversations = [
create_test_conversation(
"550e8400-e29b-41d4-a716-446655440000",
Some("First Conversation"),
),
create_test_conversation(
"550e8400-e29b-41d4-a716-446655440001",
Some("Second Conversation"),
),
];
assert_eq!(conversations.len(), 2);
}
#[test]
fn test_select_conversation_without_titles() {
let conversations = [
create_test_conversation("550e8400-e29b-41d4-a716-446655440002", None),
create_test_conversation("550e8400-e29b-41d4-a716-446655440003", None),
];
assert_eq!(conversations.len(), 2);
}
}
|