Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/forge_app/src/fmt/todo_fmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ fn format_todo_line(todo: &Todo, line_style: TodoLineStyle) -> String {
TodoStatus::Completed => "󰄵",
TodoStatus::InProgress => "󰄗",
TodoStatus::Pending => "󰄱",
TodoStatus::Cancelled => "󰅙",
TodoStatus::Cancelled => "",
};

let content = match todo.status {
Expand Down
19 changes: 15 additions & 4 deletions crates/forge_main/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,10 @@ pub enum SelectCommand {
/// Initial query text pre-filled in the search box.
#[arg(long, short = 'q')]
query: Option<String>,

/// Show child conversations of a parent conversation.
#[arg(long)]
parent: Option<ConversationId>,
},

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

/// List conversation history.
#[command(alias = "session")]
Conversation,
Conversation {
/// Show child conversations of a parent conversation.
#[arg(long)]
parent: Option<ConversationId>,
},

/// List custom commands.
#[command(alias = "cmds")]
Expand Down Expand Up @@ -706,7 +714,6 @@ pub struct ConversationCommandGroup {
#[command(subcommand)]
pub command: ConversationCommand,
}

#[derive(Subcommand, Debug, Clone)]
pub enum ConversationCommand {
/// List conversation history.
Expand Down Expand Up @@ -1293,7 +1300,9 @@ mod tests {
fn test_list_conversation_command() {
let fixture = Cli::parse_from(["forge", "list", "conversation"]);
let is_conversation_list = match fixture.subcommands {
Some(TopLevelCommand::List(list)) => matches!(list.command, ListCommand::Conversation),
Some(TopLevelCommand::List(list)) => {
matches!(list.command, ListCommand::Conversation { .. })
}
_ => false,
};
assert_eq!(is_conversation_list, true);
Expand All @@ -1303,7 +1312,9 @@ mod tests {
fn test_list_session_alias_command() {
let fixture = Cli::parse_from(["forge", "list", "session"]);
let is_conversation_list = match fixture.subcommands {
Some(TopLevelCommand::List(list)) => matches!(list.command, ListCommand::Conversation),
Some(TopLevelCommand::List(list)) => {
matches!(list.command, ListCommand::Conversation { .. })
}
_ => false,
};
assert_eq!(is_conversation_list, true);
Expand Down
10 changes: 10 additions & 0 deletions crates/forge_main/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ impl ForgeCommandManager {
| "logout"
| "retry"
| "conversations"
| "conversation-tree"
| "ct"
| "list"
| "commit"
| "rename"
Expand Down Expand Up @@ -650,6 +652,13 @@ pub enum AppCommand {
id: Option<String>,
},

/// Show nested conversations spawned by the current conversation
#[strum(props(
usage = "Show nested conversations spawned by the current conversation [alias: ct]"
))]
#[command(name = "conversation-tree", alias = "ct")]
ConversationTree,

/// Delete a conversation permanently
#[strum(props(usage = "Delete a conversation permanently"))]
#[command(skip)]
Expand Down Expand Up @@ -716,6 +725,7 @@ impl AppCommand {
AppCommand::Logout => "logout",
AppCommand::Retry => "retry",
AppCommand::Conversations { .. } => "conversation",
AppCommand::ConversationTree => "conversation-tree",
AppCommand::Delete => "delete",
AppCommand::Rename { .. } => "rename",
AppCommand::AgentSwitch(agent_id) => agent_id,
Expand Down
114 changes: 106 additions & 8 deletions crates/forge_main/src/ui.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::Arc;
Expand Down Expand Up @@ -501,8 +501,53 @@ impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> A + Send + Sync> UI
ListCommand::Mcp => {
self.on_show_mcp_servers(porcelain).await?;
}
ListCommand::Conversation => {
self.on_show_conversations(porcelain).await?;
ListCommand::Conversation { parent } => {
if let Some(parent_id) = parent {
let parent_conv = self.validate_conversation_exists(&parent_id).await?;
let children = self.fetch_related_conversations(&parent_conv).await;

if children.is_empty() {
self.writeln_title(TitleFormat::info(
"No child conversations found.",
))?;
} else {
let mut info = Info::new();
for conv in children.into_iter() {
let title = conv
.title
.as_deref()
.map(|t| t.to_string())
.unwrap_or_else(|| markers::EMPTY.to_string());

let duration = chrono::Utc::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);
}

let porcelain = Porcelain::from(&info)
.drop_col(3)
.truncate(1, 60)
.uppercase_headers();
self.writeln(porcelain)?;
}
} else {
self.on_show_conversations(porcelain).await?;
}
}
ListCommand::Cmd => {
self.on_show_custom_commands(porcelain).await?;
Expand Down Expand Up @@ -835,10 +880,16 @@ impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> A + Send + Sync> UI
self.select_row_output("Command", query.clone(), rows)?;
}
}
SelectCommand::Conversation { query } => {
let max_conversations = self.config.max_conversations;
let conversations =
self.api.get_conversations(Some(max_conversations)).await?;
SelectCommand::Conversation { query, parent } => {
let conversations = if let Some(parent_id) = parent {
let parent_conv = self.validate_conversation_exists(parent_id).await?;
self.fetch_related_conversations(&parent_conv).await
} else {
let max_conversations = self.config.max_conversations;
let conversations =
self.api.get_conversations(Some(max_conversations)).await?;
Self::user_initiated_conversations(conversations)
};

if !conversations.is_empty()
&& let Some(conversation) = ConversationSelector::select_conversation(
Expand Down Expand Up @@ -955,7 +1006,6 @@ impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> A + Send + Sync> UI
)))?;
}
}

Ok(())
}

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

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

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

fn user_initiated_conversations(conversations: Vec<Conversation>) -> Vec<Conversation> {
let related_ids: HashSet<ConversationId> = conversations
.iter()
.flat_map(Conversation::related_conversation_ids)
.collect();

conversations
.into_iter()
.filter(|conversation| {
conversation
.context
.as_ref()
.and_then(|context| context.initiator.as_deref())
.is_none_or(|initiator| initiator == "user")
&& !related_ids.contains(&conversation.id)
})
.collect()
}

async fn on_command(&mut self, command: AppCommand) -> anyhow::Result<bool> {
match command {
AppCommand::Conversations { id } => {
Expand All @@ -2104,6 +2175,33 @@ impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> A + Send + Sync> UI
self.list_conversations().await?;
}
}
AppCommand::ConversationTree => {
let conversation_id = self
.state
.conversation_id
.ok_or_else(|| anyhow::anyhow!("No active conversation"))?;
let parent = self.validate_conversation_exists(&conversation_id).await?;
let children = self.fetch_related_conversations(&parent).await;

if children.is_empty() {
self.writeln_title(TitleFormat::info("No child conversations found."))?;
} else if let Some(conversation) = ConversationSelector::select_conversation(
&children,
self.state.conversation_id,
None,
)
.await?
{
let conversation_id = conversation.id;
self.state.conversation_id = Some(conversation_id);
self.on_show_last_message(conversation, false).await?;
self.writeln_title(TitleFormat::info(format!(
"Switched to conversation {}",
conversation_id.into_string().bold()
)))?;
self.on_info(false, Some(conversation_id)).await?;
}
}
AppCommand::Compact => {
self.spinner.start(Some("Compacting"))?;
self.on_compaction().await?;
Expand Down
7 changes: 7 additions & 0 deletions shell-plugin/lib/actions/conversation.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
# - :conversation - List and switch conversations (with interactive picker)
# - :conversation <id> - Switch to specific conversation by ID
# - :conversation - - Toggle between current and previous conversation (like cd -)
# - :conversation-tree - Show nested conversations spawned by current conversation
# - :clone - Clone current or selected conversation
# - :clone <id> - Clone specific conversation by ID
# - :copy - Copy last assistant message to OS clipboard as raw markdown
Expand Down Expand Up @@ -115,6 +116,12 @@ function _forge_action_conversation() {
fi
}


# Action handler: Show nested conversations spawned by current conversation
function _forge_action_conversation_tree() {
_forge_select conversation --parent "$_FORGE_CONVERSATION_ID"
}

# Action handler: Clone conversation
function _forge_action_clone() {
local input_text="$1"
Expand Down
3 changes: 3 additions & 0 deletions shell-plugin/lib/dispatcher.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ function forge-accept-line() {
conversation|c)
_forge_action_conversation "$input_text"
;;
conversation-tree|ct)
_forge_action_conversation_tree
;;
config-model|cm)
_forge_action_model "$input_text"
;;
Expand Down
Loading