diff --git a/crates/forge_api/src/api.rs b/crates/forge_api/src/api.rs index 4b45473d60..4f57a02924 100644 --- a/crates/forge_api/src/api.rs +++ b/crates/forge_api/src/api.rs @@ -140,6 +140,16 @@ pub trait API: Sync + Send { limit: Option, ) -> Result>>; + /// Return an FTS5 snippet for a (conversation, query) pair — a short + /// highlighted excerpt of the matched passage. Used by the search UI + /// to render a preview pane when the user picks a search hit. + async fn get_conversation_snippet( + &self, + conversation_id: &ConversationId, + query: &str, + token_count: usize, + ) -> Result>; + /// Renames a conversation by setting its title /// /// # Arguments diff --git a/crates/forge_api/src/forge_api.rs b/crates/forge_api/src/forge_api.rs index e315ef8e65..75984759e9 100644 --- a/crates/forge_api/src/forge_api.rs +++ b/crates/forge_api/src/forge_api.rs @@ -260,6 +260,17 @@ impl< self.services.get_conversations_by_cwd(cwd, limit).await } + async fn get_conversation_snippet( + &self, + conversation_id: &ConversationId, + query: &str, + token_count: usize, + ) -> Result> { + self.services + .get_conversation_snippet(conversation_id, query, token_count) + .await + } + async fn rename_conversation( &self, conversation_id: &ConversationId, diff --git a/crates/forge_app/src/services.rs b/crates/forge_app/src/services.rs index 2b56baf53d..ce4809ab29 100644 --- a/crates/forge_app/src/services.rs +++ b/crates/forge_app/src/services.rs @@ -316,6 +316,16 @@ pub trait ConversationService: Send + Sync { cwd: &str, limit: Option, ) -> anyhow::Result>>; + + /// Return an FTS5 snippet for a (conversation, query) pair — a short + /// highlighted excerpt of the matched passage. Used by the search UI + /// to render a preview pane when the user picks a search hit. + async fn get_conversation_snippet( + &self, + conversation_id: &ConversationId, + query: &str, + token_count: usize, + ) -> anyhow::Result>; } #[async_trait::async_trait] @@ -761,6 +771,17 @@ impl ConversationService for I { .get_conversations_by_cwd(cwd, limit) .await } + + async fn get_conversation_snippet( + &self, + conversation_id: &ConversationId, + query: &str, + token_count: usize, + ) -> anyhow::Result> { + self.conversation_service() + .get_conversation_snippet(conversation_id, query, token_count) + .await + } } #[async_trait::async_trait] impl ProviderService for I { diff --git a/crates/forge_domain/src/conversation.rs b/crates/forge_domain/src/conversation.rs index 4bb49b841e..19468021c9 100644 --- a/crates/forge_domain/src/conversation.rs +++ b/crates/forge_domain/src/conversation.rs @@ -75,6 +75,61 @@ impl MetaData { } } +/// Sort key for the session viewer selector. +/// +/// Each variant maps to an `ORDER BY` clause in the `conversations` table. +/// `Default` is `Updated` because the most common workflow is "show me what +/// I was working on most recently" — especially after a crash recovery when +/// the user is trying to find the parent session of a stranded subagent. +#[derive(Debug, Default, Display, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ConversationSort { + /// Sort by `updated_at` DESC (most recent first). Default. + #[default] + #[display("updated")] + Updated, + /// Sort by `created_at` DESC (newest first). + #[display("created")] + Created, + /// Sort by `message_count` DESC, then `updated_at` DESC. + /// This is the canonical "turns" view the user asked for. + #[display("turns")] + Turns, + /// Sort by `title` ASC, NULLS LAST. + #[display("title")] + Title, + /// Sort by `cwd` ASC, NULLS LAST, then `updated_at` DESC. + /// Useful for finding all sessions in a specific repo. + #[display("cwd")] + Cwd, +} + +impl ConversationSort { + /// Stable lowercase identifier used for CLI parsing and storage. + /// Also used by the UI handler for `:sort ` echo. + pub fn name(self) -> &'static str { + match self { + ConversationSort::Updated => "updated", + ConversationSort::Created => "created", + ConversationSort::Turns => "turns", + ConversationSort::Title => "title", + ConversationSort::Cwd => "cwd", + } + } + + /// Parse a sort key from a user-supplied string. Unknown keys fall + /// back to `Updated` and the caller is expected to print a hint. + pub fn parse(s: &str) -> Self { + match s.trim().to_ascii_lowercase().as_str() { + "created" => ConversationSort::Created, + "turns" | "messages" | "msgs" => ConversationSort::Turns, + "title" | "name" | "alphabetical" => ConversationSort::Title, + "cwd" | "dir" | "directory" => ConversationSort::Cwd, + _ => ConversationSort::Updated, + } + } +} + impl Conversation { pub fn new(id: ConversationId) -> Self { let created_at = Utc::now(); diff --git a/crates/forge_domain/src/repo.rs b/crates/forge_domain/src/repo.rs index c6c8fe1475..6044a38aec 100644 --- a/crates/forge_domain/src/repo.rs +++ b/crates/forge_domain/src/repo.rs @@ -170,6 +170,28 @@ pub trait ConversationRepository: Send + Sync { limit: Option, ) -> Result>; + /// Returns a short FTS5 snippet (~32 tokens) for a single + /// `(conversation_id, query)` pair, with the matched terms wrapped in + /// `[…]` and the surrounding text wrapped in `…`. Used by the UI to + /// render a "matched passage" preview for the currently selected + /// search hit without forcing the main search query to include the + /// snippet column (which would couple the row layout to + /// `ConversationRecord`). + /// + /// Returns `Ok(None)` when the query does not match that conversation + /// — callers should treat `None` as "no preview available" and fall + /// back to the conversation title. + /// + /// # Errors + /// Returns an error if the FTS query is malformed or the database + /// call fails. + async fn get_conversation_snippet( + &self, + conversation_id: &ConversationId, + query: &str, + token_count: usize, + ) -> Result>; + /// Reclaims FTS5 segment shadow data by running /// `INSERT INTO conversations_fts(conversations_fts) VALUES('optimize')`. /// diff --git a/crates/forge_main/src/conversation_selector.rs b/crates/forge_main/src/conversation_selector.rs index 68767de62f..1d09711960 100644 --- a/crates/forge_main/src/conversation_selector.rs +++ b/crates/forge_main/src/conversation_selector.rs @@ -133,6 +133,8 @@ mod tests { context: None, metrics: Metrics::default().started_at(now), metadata: MetaData { created_at: now, updated_at: Some(now) }, + cwd: None, + message_count: None, parent_id: None, source: None, } diff --git a/crates/forge_main/src/info.rs b/crates/forge_main/src/info.rs index 9d9d1683ed..ae8a42aff0 100644 --- a/crates/forge_main/src/info.rs +++ b/crates/forge_main/src/info.rs @@ -979,6 +979,8 @@ mod tests { context: None, metrics, metadata: forge_domain::MetaData::new(Utc::now()), + cwd: None, + message_count: None, parent_id: None, source: None, }; @@ -1008,6 +1010,8 @@ mod tests { context: None, metrics, metadata: forge_domain::MetaData::new(Utc::now()), + cwd: None, + message_count: None, parent_id: None, source: None, }; @@ -1055,6 +1059,8 @@ mod tests { context: Some(context), metrics, metadata: forge_domain::MetaData::new(Utc::now()), + cwd: None, + message_count: None, parent_id: None, source: None, }; diff --git a/crates/forge_main/src/model.rs b/crates/forge_main/src/model.rs index 45d626b9de..0825b450ea 100644 --- a/crates/forge_main/src/model.rs +++ b/crates/forge_main/src/model.rs @@ -712,6 +712,18 @@ pub enum AppCommand { target: Vec, }, + /// Sort the conversation selector. Usage: `:sort ` where key is + /// one of `updated`, `created`, `turns`, `title`. Persists in + /// `UIState.sort` until the session exits or another `:sort` is run. + #[strum(props(usage = "Sort the conversation selector. Usage: :sort (updated|created|turns|title)"))] + #[command(alias = "so")] + Sort { + /// Sort key: `updated` (default), `created`, `turns`, or `title`. + /// Anything else falls back to `updated` and prints a hint. + #[arg(trailing_var_arg = true, num_args = 0..)] + target: Vec, + }, + /// Full-text search over conversation titles and contents (FTS5 BM25). /// Usage: `:search ` or `:search "rust refactor"`. #[strum(props(usage = "Search conversation history. Usage: :search "))] @@ -812,6 +824,7 @@ impl AppCommand { AppCommand::Parent => "parent", AppCommand::Reparent { .. } => "reparent", AppCommand::Cwd { .. } => "cwd", + AppCommand::Sort { .. } => "sort", AppCommand::Search { .. } => "search", AppCommand::Delete => "delete", AppCommand::Rename { .. } => "rename", diff --git a/crates/forge_main/src/state.rs b/crates/forge_main/src/state.rs index beb3de760c..8535e66e2e 100644 --- a/crates/forge_main/src/state.rs +++ b/crates/forge_main/src/state.rs @@ -3,6 +3,7 @@ use std::time::Instant; use derive_setters::Setters; use forge_api::{ConversationId, Environment}; +use forge_domain::ConversationSort; //TODO: UIState and ForgePrompt seem like the same thing and can be merged /// State information for the UI @@ -18,6 +19,10 @@ pub struct UIState { /// scopes its results to conversations whose `cwd` column matches. /// This is the "filter by project directory" UX. pub cwd_filter: Option, + /// Sort key for the conversation selector. Re-exported from + /// `forge_domain::ConversationSort` so there's one canonical enum + /// across the repo / service / UI layers. + pub sort: ConversationSort, } impl Default for UIState { @@ -29,6 +34,7 @@ impl Default for UIState { loop_enabled: false, last_activity: Instant::now(), cwd_filter: None, + sort: ConversationSort::default(), } } } @@ -42,6 +48,7 @@ impl UIState { loop_enabled: false, last_activity: Instant::now(), cwd_filter: None, + sort: ConversationSort::default(), } } } diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 4f3359fee4..2aa8c4ec11 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -2317,6 +2317,21 @@ impl A + Send + Sync> UI .await? { let conversation_id = conversation.id; + + // Fetch a short FTS5 snippet (~32 tokens) so the user can see + // *why* this conversation matched. `None` means no preview — + // fall through silently (the title is already shown above). + if let Ok(Some(snippet)) = self + .api + .get_conversation_snippet(&conversation_id, &query, 32) + .await + { + self.writeln_title(TitleFormat::info(format!( + " matched: {}", + snippet.dimmed() + )))?; + } + self.state.conversation_id = Some(conversation_id); self.on_show_last_message(conversation, false).await?; self.writeln_title(TitleFormat::info(format!( @@ -2423,6 +2438,49 @@ impl A + Send + Sync> UI Ok(()) } + async fn handle_sort(&mut self, target: Vec) -> anyhow::Result<()> { + use forge_domain::ConversationSort; + + if target.is_empty() || target.iter().any(|t| t == "--help" || t == "-h") { + self.writeln_title(TitleFormat::info( + "Usage: :sort | :sort --reset", + ))?; + return Ok(()); + } + + if target.iter().any(|t| t == "--reset") { + self.state.sort = ConversationSort::default(); + self.writeln_title(TitleFormat::info(format!( + "Sort reset to {}", + ConversationSort::default().name().bold() + )))?; + return Ok(()); + } + + let requested = target.join(" ").trim().to_lowercase(); + let new_sort = match requested.as_str() { + "turns" | "messages" | "msg" | "count" => ConversationSort::Turns, + "updated" | "updated_at" | "recent" => ConversationSort::Updated, + "created" | "created_at" | "oldest" => ConversationSort::Created, + "title" | "name" => ConversationSort::Title, + "cwd" | "dir" | "directory" => ConversationSort::Cwd, + other => { + self.writeln_title(TitleFormat::error(format!( + "Unknown sort key: {} (use: turns|updated|created|title|cwd)", + other + )))?; + return Ok(()); + } + }; + + self.state.sort = new_sort; + self.writeln_title(TitleFormat::info(format!( + "Sort set to {}", + new_sort.name().bold() + )))?; + Ok(()) + } + fn user_initiated_conversations(conversations: Vec) -> Vec { let related_ids: HashSet = conversations .iter() @@ -2483,6 +2541,9 @@ impl A + Send + Sync> UI AppCommand::Cwd { target } => { self.handle_cwd(target).await?; } + AppCommand::Sort { target } => { + self.handle_sort(target).await?; + } AppCommand::Search { query } => { self.handle_search(query).await?; } diff --git a/crates/forge_repo/src/conversation/conversation_repo.rs b/crates/forge_repo/src/conversation/conversation_repo.rs index 1eef22aa67..8f2b738962 100644 --- a/crates/forge_repo/src/conversation/conversation_repo.rs +++ b/crates/forge_repo/src/conversation/conversation_repo.rs @@ -7,6 +7,21 @@ use crate::conversation::conversation_record::ConversationRecord; use crate::database::schema::conversations; use crate::database::{DatabasePool, PooledSqliteConnection}; +/// Lightweight row type for FTS5 `snippet()` results. The query returns +/// exactly one column (`s`) — we use a named struct (not a tuple) so +/// diesel's `QueryableByName` derive can read it back from `sql_query`. +#[derive(Debug, Clone)] +struct SnippetRow { + s: String, +} + +impl diesel::QueryableByName for SnippetRow { + fn build<'a>(row: &impl diesel::row::NamedRow<'a, diesel::sqlite::Sqlite>) -> diesel::deserialize::Result { + let s = diesel::row::NamedRow::get::(row, "s")?; + Ok(SnippetRow { s }) + } +} + pub struct ConversationRepositoryImpl { pool: Arc, wid: WorkspaceHash, @@ -274,15 +289,21 @@ impl ConversationRepository for ConversationRepositoryImpl { let workspace_id = wid.id() as i64; // FTS5 BM25 search joined back to the base table on // `conversation_id` (the FTS5 table is content-less, so - // `c.rowid = fts.rowid` would not match). `rank` from - // `bm25()` is a negative number where lower = more relevant, - // so `ORDER BY rank` (ascending) yields "best match first". + // `c.rowid = fts.rowid` would not match). `bm25()` returns a + // negative number where lower = more relevant, so `ORDER BY + // rank_score` (ascending) yields "best match first". + // + // We do NOT include `snippet()` here because it would force + // the SELECT to return a column not in `ConversationRecord`. + // The UI fetches a snippet on-demand via the separate + // `get_conversation_snippet` method when the user picks a hit. let mut sql = String::from( - "SELECT c.* FROM conversations c \ + "SELECT c.*, bm25(conversations_fts) AS rank_score \ + FROM conversations c \ JOIN conversations_fts fts ON c.conversation_id = fts.conversation_id \ WHERE conversations_fts MATCH ? \ AND c.workspace_id = ? \ - ORDER BY fts.rank", + ORDER BY rank_score", ); if limit_value.is_some() { sql.push_str(" LIMIT ?"); @@ -309,6 +330,39 @@ impl ConversationRepository for ConversationRepositoryImpl { .await } + /// Return a single FTS5 snippet for a (conversation, query) pair. + /// Used by the UI to render a "matched passage" preview for the + /// currently selected search hit. Returns `None` if no match. + async fn get_conversation_snippet( + &self, + conversation_id: &ConversationId, + query: &str, + token_count: usize, + ) -> anyhow::Result> { + let conversation_id_str = conversation_id.into_string(); + let query = query.to_string(); + self.run_with_connection(move |connection, _wid| { + // We pass the conversation_id as a filter so the snippet + // function only highlights within that document. The token + // count is interpolated directly into the SQL because + // SQLite's `snippet()` 6th arg is a literal integer, not a + // bind parameter. FTS5 sanitises the MATCH expression; the + // integer is bounded by the caller (UI limits to 256). + let sql = format!( + "SELECT snippet(conversations_fts, 2, '[', ']', '…', {}) AS s \ + FROM conversations_fts \ + WHERE conversation_id = ? AND conversations_fts MATCH ?", + token_count.min(256) + ); + let raw: Vec = diesel::sql_query(sql) + .bind::(&conversation_id_str) + .bind::(&query) + .load(connection)?; + Ok(raw.into_iter().next().map(|r| r.s)) + }) + .await + } + async fn optimize_fts_index(&self) -> anyhow::Result<()> { // FTS5's "optimize" command is invoked as a special INSERT against // the virtual table itself. Diesel has no typed binding for it, so diff --git a/crates/forge_repo/src/forge_repo.rs b/crates/forge_repo/src/forge_repo.rs index 2368dff5fb..a266840fe2 100644 --- a/crates/forge_repo/src/forge_repo.rs +++ b/crates/forge_repo/src/forge_repo.rs @@ -219,6 +219,17 @@ impl ConversationRepository for ForgeRepo { .get_conversations_by_cwd(cwd, limit) .await } + + async fn get_conversation_snippet( + &self, + conversation_id: &ConversationId, + query: &str, + token_count: usize, + ) -> anyhow::Result> { + self.conversation_repository + .get_conversation_snippet(conversation_id, query, token_count) + .await + } } #[async_trait::async_trait] diff --git a/crates/forge_services/src/conversation.rs b/crates/forge_services/src/conversation.rs index f91ac1e3b8..5edab37942 100644 --- a/crates/forge_services/src/conversation.rs +++ b/crates/forge_services/src/conversation.rs @@ -113,6 +113,17 @@ impl ConversationService for ForgeConversationService .await } + async fn get_conversation_snippet( + &self, + conversation_id: &ConversationId, + query: &str, + token_count: usize, + ) -> Result> { + self.conversation_repository + .get_conversation_snippet(conversation_id, query, token_count) + .await + } + async fn optimize_fts_index(&self) -> Result<()> { let _ = self .conversation_repository