Skip to content

Commit 34cfeb6

Browse files
fix: show only direct conversation initiated by the user via the :conversation command (#3510)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 1ca2237 commit 34cfeb6

6 files changed

Lines changed: 142 additions & 13 deletions

File tree

crates/forge_app/src/fmt/todo_fmt.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ fn format_todo_line(todo: &Todo, line_style: TodoLineStyle) -> String {
2121
TodoStatus::Completed => "󰄵",
2222
TodoStatus::InProgress => "󰄗",
2323
TodoStatus::Pending => "󰄱",
24-
TodoStatus::Cancelled => "󰅙",
24+
TodoStatus::Cancelled => "",
2525
};
2626

2727
let content = match todo.status {

crates/forge_main/src/cli.rs

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,10 @@ pub enum SelectCommand {
237237
/// Initial query text pre-filled in the search box.
238238
#[arg(long, short = 'q')]
239239
query: Option<String>,
240+
241+
/// Show child conversations of a parent conversation.
242+
#[arg(long)]
243+
parent: Option<ConversationId>,
240244
},
241245

242246
/// Select a file interactively with a preview pane.
@@ -454,7 +458,11 @@ pub enum ListCommand {
454458

455459
/// List conversation history.
456460
#[command(alias = "session")]
457-
Conversation,
461+
Conversation {
462+
/// Show child conversations of a parent conversation.
463+
#[arg(long)]
464+
parent: Option<ConversationId>,
465+
},
458466

459467
/// List custom commands.
460468
#[command(alias = "cmds")]
@@ -706,7 +714,6 @@ pub struct ConversationCommandGroup {
706714
#[command(subcommand)]
707715
pub command: ConversationCommand,
708716
}
709-
710717
#[derive(Subcommand, Debug, Clone)]
711718
pub enum ConversationCommand {
712719
/// List conversation history.
@@ -1293,7 +1300,9 @@ mod tests {
12931300
fn test_list_conversation_command() {
12941301
let fixture = Cli::parse_from(["forge", "list", "conversation"]);
12951302
let is_conversation_list = match fixture.subcommands {
1296-
Some(TopLevelCommand::List(list)) => matches!(list.command, ListCommand::Conversation),
1303+
Some(TopLevelCommand::List(list)) => {
1304+
matches!(list.command, ListCommand::Conversation { .. })
1305+
}
12971306
_ => false,
12981307
};
12991308
assert_eq!(is_conversation_list, true);
@@ -1303,7 +1312,9 @@ mod tests {
13031312
fn test_list_session_alias_command() {
13041313
let fixture = Cli::parse_from(["forge", "list", "session"]);
13051314
let is_conversation_list = match fixture.subcommands {
1306-
Some(TopLevelCommand::List(list)) => matches!(list.command, ListCommand::Conversation),
1315+
Some(TopLevelCommand::List(list)) => {
1316+
matches!(list.command, ListCommand::Conversation { .. })
1317+
}
13071318
_ => false,
13081319
};
13091320
assert_eq!(is_conversation_list, true);

crates/forge_main/src/model.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ impl ForgeCommandManager {
114114
| "logout"
115115
| "retry"
116116
| "conversations"
117+
| "conversation-tree"
118+
| "ct"
117119
| "list"
118120
| "commit"
119121
| "rename"
@@ -650,6 +652,13 @@ pub enum AppCommand {
650652
id: Option<String>,
651653
},
652654

655+
/// Show nested conversations spawned by the current conversation
656+
#[strum(props(
657+
usage = "Show nested conversations spawned by the current conversation [alias: ct]"
658+
))]
659+
#[command(name = "conversation-tree", alias = "ct")]
660+
ConversationTree,
661+
653662
/// Delete a conversation permanently
654663
#[strum(props(usage = "Delete a conversation permanently"))]
655664
#[command(skip)]
@@ -716,6 +725,7 @@ impl AppCommand {
716725
AppCommand::Logout => "logout",
717726
AppCommand::Retry => "retry",
718727
AppCommand::Conversations { .. } => "conversation",
728+
AppCommand::ConversationTree => "conversation-tree",
719729
AppCommand::Delete => "delete",
720730
AppCommand::Rename { .. } => "rename",
721731
AppCommand::AgentSwitch(agent_id) => agent_id,

crates/forge_main/src/ui.rs

Lines changed: 106 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::collections::HashMap;
1+
use std::collections::{HashMap, HashSet};
22
use std::path::PathBuf;
33
use std::str::FromStr;
44
use std::sync::Arc;
@@ -501,8 +501,53 @@ impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> A + Send + Sync> UI
501501
ListCommand::Mcp => {
502502
self.on_show_mcp_servers(porcelain).await?;
503503
}
504-
ListCommand::Conversation => {
505-
self.on_show_conversations(porcelain).await?;
504+
ListCommand::Conversation { parent } => {
505+
if let Some(parent_id) = parent {
506+
let parent_conv = self.validate_conversation_exists(&parent_id).await?;
507+
let children = self.fetch_related_conversations(&parent_conv).await;
508+
509+
if children.is_empty() {
510+
self.writeln_title(TitleFormat::info(
511+
"No child conversations found.",
512+
))?;
513+
} else {
514+
let mut info = Info::new();
515+
for conv in children.into_iter() {
516+
let title = conv
517+
.title
518+
.as_deref()
519+
.map(|t| t.to_string())
520+
.unwrap_or_else(|| markers::EMPTY.to_string());
521+
522+
let duration = chrono::Utc::now().signed_duration_since(
523+
conv.metadata
524+
.updated_at
525+
.unwrap_or(conv.metadata.created_at),
526+
);
527+
let duration = std::time::Duration::from_secs(
528+
(duration.num_minutes() * 60).max(0) as u64,
529+
);
530+
let time_ago = if duration.is_zero() {
531+
"now".to_string()
532+
} else {
533+
format!("{} ago", humantime::format_duration(duration))
534+
};
535+
536+
info = info
537+
.add_title(conv.id)
538+
.add_key_value("Title", title)
539+
.add_key_value("Updated", time_ago);
540+
}
541+
542+
let porcelain = Porcelain::from(&info)
543+
.drop_col(3)
544+
.truncate(1, 60)
545+
.uppercase_headers();
546+
self.writeln(porcelain)?;
547+
}
548+
} else {
549+
self.on_show_conversations(porcelain).await?;
550+
}
506551
}
507552
ListCommand::Cmd => {
508553
self.on_show_custom_commands(porcelain).await?;
@@ -835,10 +880,16 @@ impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> A + Send + Sync> UI
835880
self.select_row_output("Command", query.clone(), rows)?;
836881
}
837882
}
838-
SelectCommand::Conversation { query } => {
839-
let max_conversations = self.config.max_conversations;
840-
let conversations =
841-
self.api.get_conversations(Some(max_conversations)).await?;
883+
SelectCommand::Conversation { query, parent } => {
884+
let conversations = if let Some(parent_id) = parent {
885+
let parent_conv = self.validate_conversation_exists(parent_id).await?;
886+
self.fetch_related_conversations(&parent_conv).await
887+
} else {
888+
let max_conversations = self.config.max_conversations;
889+
let conversations =
890+
self.api.get_conversations(Some(max_conversations)).await?;
891+
Self::user_initiated_conversations(conversations)
892+
};
842893

843894
if !conversations.is_empty()
844895
&& let Some(conversation) = ConversationSelector::select_conversation(
@@ -955,7 +1006,6 @@ impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> A + Send + Sync> UI
9551006
)))?;
9561007
}
9571008
}
958-
9591009
Ok(())
9601010
}
9611011

@@ -1998,6 +2048,7 @@ impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> A + Send + Sync> UI
19982048
self.spinner.start(Some("Loading Conversations"))?;
19992049
let max_conversations = self.config.max_conversations;
20002050
let conversations = self.api.get_conversations(Some(max_conversations)).await?;
2051+
let conversations = Self::user_initiated_conversations(conversations);
20012052
self.spinner.stop(None)?;
20022053

20032054
if conversations.is_empty() {
@@ -2035,6 +2086,7 @@ impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> A + Send + Sync> UI
20352086
async fn on_show_conversations(&mut self, porcelain: bool) -> anyhow::Result<()> {
20362087
let max_conversations = self.config.max_conversations;
20372088
let conversations = self.api.get_conversations(Some(max_conversations)).await?;
2089+
let conversations = Self::user_initiated_conversations(conversations);
20382090

20392091
if conversations.is_empty() {
20402092
return Ok(());
@@ -2086,6 +2138,25 @@ impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> A + Send + Sync> UI
20862138
Ok(())
20872139
}
20882140

2141+
fn user_initiated_conversations(conversations: Vec<Conversation>) -> Vec<Conversation> {
2142+
let related_ids: HashSet<ConversationId> = conversations
2143+
.iter()
2144+
.flat_map(Conversation::related_conversation_ids)
2145+
.collect();
2146+
2147+
conversations
2148+
.into_iter()
2149+
.filter(|conversation| {
2150+
conversation
2151+
.context
2152+
.as_ref()
2153+
.and_then(|context| context.initiator.as_deref())
2154+
.is_none_or(|initiator| initiator == "user")
2155+
&& !related_ids.contains(&conversation.id)
2156+
})
2157+
.collect()
2158+
}
2159+
20892160
async fn on_command(&mut self, command: AppCommand) -> anyhow::Result<bool> {
20902161
match command {
20912162
AppCommand::Conversations { id } => {
@@ -2104,6 +2175,33 @@ impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> A + Send + Sync> UI
21042175
self.list_conversations().await?;
21052176
}
21062177
}
2178+
AppCommand::ConversationTree => {
2179+
let conversation_id = self
2180+
.state
2181+
.conversation_id
2182+
.ok_or_else(|| anyhow::anyhow!("No active conversation"))?;
2183+
let parent = self.validate_conversation_exists(&conversation_id).await?;
2184+
let children = self.fetch_related_conversations(&parent).await;
2185+
2186+
if children.is_empty() {
2187+
self.writeln_title(TitleFormat::info("No child conversations found."))?;
2188+
} else if let Some(conversation) = ConversationSelector::select_conversation(
2189+
&children,
2190+
self.state.conversation_id,
2191+
None,
2192+
)
2193+
.await?
2194+
{
2195+
let conversation_id = conversation.id;
2196+
self.state.conversation_id = Some(conversation_id);
2197+
self.on_show_last_message(conversation, false).await?;
2198+
self.writeln_title(TitleFormat::info(format!(
2199+
"Switched to conversation {}",
2200+
conversation_id.into_string().bold()
2201+
)))?;
2202+
self.on_info(false, Some(conversation_id)).await?;
2203+
}
2204+
}
21072205
AppCommand::Compact => {
21082206
self.spinner.start(Some("Compacting"))?;
21092207
self.on_compaction().await?;

shell-plugin/lib/actions/conversation.zsh

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
# - :conversation - List and switch conversations (with interactive picker)
77
# - :conversation <id> - Switch to specific conversation by ID
88
# - :conversation - - Toggle between current and previous conversation (like cd -)
9+
# - :conversation-tree - Show nested conversations spawned by current conversation
910
# - :clone - Clone current or selected conversation
1011
# - :clone <id> - Clone specific conversation by ID
1112
# - :copy - Copy last assistant message to OS clipboard as raw markdown
@@ -115,6 +116,12 @@ function _forge_action_conversation() {
115116
fi
116117
}
117118

119+
120+
# Action handler: Show nested conversations spawned by current conversation
121+
function _forge_action_conversation_tree() {
122+
_forge_select conversation --parent "$_FORGE_CONVERSATION_ID"
123+
}
124+
118125
# Action handler: Clone conversation
119126
function _forge_action_clone() {
120127
local input_text="$1"

shell-plugin/lib/dispatcher.zsh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,9 @@ function forge-accept-line() {
169169
conversation|c)
170170
_forge_action_conversation "$input_text"
171171
;;
172+
conversation-tree|ct)
173+
_forge_action_conversation_tree
174+
;;
172175
config-model|cm)
173176
_forge_action_model "$input_text"
174177
;;

0 commit comments

Comments
 (0)