Skip to content

Commit 6ae207b

Browse files
tivrisautofix-ci[bot]tusharmath
authored
feat(conversation): add :rename command to set custom conversation titles (#2760)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Tushar <tusharmath@gmail.com>
1 parent a5e9005 commit 6ae207b

File tree

9 files changed

+273
-0
lines changed

9 files changed

+273
-0
lines changed

crates/forge_api/src/api.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,20 @@ pub trait API: Sync + Send {
7171
/// Returns an error if the operation fails
7272
async fn delete_conversation(&self, conversation_id: &ConversationId) -> Result<()>;
7373

74+
/// Renames a conversation by setting its title
75+
///
76+
/// # Arguments
77+
/// * `conversation_id` - The ID of the conversation to rename
78+
/// * `title` - The new title for the conversation
79+
///
80+
/// # Errors
81+
/// Returns an error if the conversation is not found or the operation fails
82+
async fn rename_conversation(
83+
&self,
84+
conversation_id: &ConversationId,
85+
title: String,
86+
) -> Result<()>;
87+
7488
/// Compacts the context of the main agent for the given conversation and
7589
/// persists it. Returns metrics about the compaction (original vs.
7690
/// compacted tokens and messages).

crates/forge_api/src/forge_api.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,18 @@ impl<A: Services, F: CommandInfra + EnvironmentInfra + SkillRepository + GrpcInf
169169
self.services.delete_conversation(conversation_id).await
170170
}
171171

172+
async fn rename_conversation(
173+
&self,
174+
conversation_id: &ConversationId,
175+
title: String,
176+
) -> anyhow::Result<()> {
177+
self.services
178+
.modify_conversation(conversation_id, |conv| {
179+
conv.title = Some(title);
180+
})
181+
.await
182+
}
183+
172184
async fn execute_shell_command(
173185
&self,
174186
command: &str,

crates/forge_main/src/built_in_commands.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,14 @@
115115
"command": "clone",
116116
"description": "Clone and manage conversation context"
117117
},
118+
{
119+
"command": "rename",
120+
"description": "Rename the current conversation [alias: rn]"
121+
},
122+
{
123+
"command": "conversation-rename",
124+
"description": "Rename a conversation by ID or interactively"
125+
},
118126
{
119127
"command": "copy",
120128
"description": "Copy last assistant message to clipboard as raw markdown"

crates/forge_main/src/cli.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,15 @@ pub enum ConversationCommand {
645645
/// Conversation ID to delete.
646646
id: String,
647647
},
648+
649+
/// Rename a conversation.
650+
Rename {
651+
/// Conversation ID to rename.
652+
id: ConversationId,
653+
654+
/// New name for the conversation.
655+
name: String,
656+
},
648657
}
649658

650659
/// Command group for provider authentication management.

crates/forge_main/src/model.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ impl ForgeCommandManager {
9898
| "conversations"
9999
| "list"
100100
| "commit"
101+
| "rename"
102+
| "rn"
101103
)
102104
}
103105

@@ -107,6 +109,7 @@ impl ForgeCommandManager {
107109
.filter(|command| !matches!(command, SlashCommand::Custom(_)))
108110
.filter(|command| !matches!(command, SlashCommand::Shell(_)))
109111
.filter(|command| !matches!(command, SlashCommand::AgentSwitch(_)))
112+
.filter(|command| !matches!(command, SlashCommand::Rename(_)))
110113
.map(|command| ForgeCommand {
111114
name: command.name().to_string(),
112115
description: command.usage().to_string(),
@@ -282,6 +285,16 @@ impl ForgeCommandManager {
282285
Ok(SlashCommand::Commit { max_diff_size })
283286
}
284287
"/index" => Ok(SlashCommand::Index),
288+
"/rename" | "/rn" => {
289+
let name = parameters.join(" ");
290+
let name = name.trim().to_string();
291+
if name.is_empty() {
292+
return Err(anyhow::anyhow!(
293+
"Usage: /rename <name>. Please provide a name for the conversation."
294+
));
295+
}
296+
Ok(SlashCommand::Rename(name))
297+
}
285298
text => {
286299
let parts = text.split_ascii_whitespace().collect::<Vec<&str>>();
287300

@@ -420,6 +433,10 @@ pub enum SlashCommand {
420433
#[strum(props(usage = "Delete a conversation permanently"))]
421434
Delete,
422435

436+
/// Rename the current conversation
437+
#[strum(props(usage = "Rename the current conversation. Usage: /rename <name>"))]
438+
Rename(String),
439+
423440
/// Switch directly to a specific agent by ID
424441
#[strum(props(usage = "Switch directly to a specific agent"))]
425442
AgentSwitch(String),
@@ -467,6 +484,7 @@ impl SlashCommand {
467484
SlashCommand::Retry => "retry",
468485
SlashCommand::Conversations => "conversation",
469486
SlashCommand::Delete => "delete",
487+
SlashCommand::Rename(_) => "rename",
470488
SlashCommand::AgentSwitch(agent_id) => agent_id,
471489
SlashCommand::Index => "index",
472490
}
@@ -1242,4 +1260,55 @@ mod tests {
12421260
let expected = SlashCommand::Dump { html: true };
12431261
assert_eq!(actual, expected);
12441262
}
1263+
1264+
#[test]
1265+
fn test_parse_rename_command() {
1266+
let fixture = ForgeCommandManager::default();
1267+
let actual = fixture.parse("/rename my-session").unwrap();
1268+
assert_eq!(actual, SlashCommand::Rename("my-session".to_string()));
1269+
}
1270+
1271+
#[test]
1272+
fn test_parse_rename_command_multi_word() {
1273+
let fixture = ForgeCommandManager::default();
1274+
let actual = fixture.parse("/rename auth refactor work").unwrap();
1275+
assert_eq!(
1276+
actual,
1277+
SlashCommand::Rename("auth refactor work".to_string())
1278+
);
1279+
}
1280+
1281+
#[test]
1282+
fn test_parse_rename_command_no_name() {
1283+
let fixture = ForgeCommandManager::default();
1284+
let result = fixture.parse("/rename");
1285+
assert!(result.is_err());
1286+
assert!(result.unwrap_err().to_string().contains("provide a name"));
1287+
}
1288+
1289+
#[test]
1290+
fn test_parse_rename_alias() {
1291+
let fixture = ForgeCommandManager::default();
1292+
let actual = fixture.parse("/rn my-session").unwrap();
1293+
assert_eq!(actual, SlashCommand::Rename("my-session".to_string()));
1294+
}
1295+
1296+
#[test]
1297+
fn test_parse_rename_trims_whitespace() {
1298+
let fixture = ForgeCommandManager::default();
1299+
let actual = fixture.parse("/rename my title ").unwrap();
1300+
assert_eq!(actual, SlashCommand::Rename("my title".to_string()));
1301+
}
1302+
1303+
#[test]
1304+
fn test_rename_is_reserved_command() {
1305+
assert!(ForgeCommandManager::is_reserved_command("rename"));
1306+
assert!(ForgeCommandManager::is_reserved_command("rn"));
1307+
}
1308+
1309+
#[test]
1310+
fn test_rename_command_name() {
1311+
let cmd = SlashCommand::Rename("test".to_string());
1312+
assert_eq!(cmd.name(), "rename");
1313+
}
12451314
}

crates/forge_main/src/ui.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,21 @@ impl<A: API + ConsoleWriter + 'static, F: Fn() -> A + Send + Sync> UI<A, F> {
768768
self.on_clone_conversation(conversation, porcelain).await?;
769769
self.spinner.stop(None)?;
770770
}
771+
ConversationCommand::Rename { id, name } => {
772+
self.validate_conversation_exists(&id).await?;
773+
774+
let name = name.trim().to_string();
775+
if name.is_empty() {
776+
return Err(anyhow::anyhow!(
777+
"Please provide a name for the conversation."
778+
));
779+
}
780+
self.api.rename_conversation(&id, name.clone()).await?;
781+
self.writeln_title(TitleFormat::info(format!(
782+
"Conversation renamed to '{}'",
783+
name.bold()
784+
)))?;
785+
}
771786
}
772787

773788
Ok(())
@@ -1814,6 +1829,9 @@ impl<A: API + ConsoleWriter + 'static, F: Fn() -> A + Send + Sync> UI<A, F> {
18141829
SlashCommand::Delete => {
18151830
self.handle_delete_conversation().await?;
18161831
}
1832+
SlashCommand::Rename(ref name) => {
1833+
self.handle_rename_conversation(name.clone()).await?;
1834+
}
18171835
SlashCommand::Dump { html } => {
18181836
self.spinner.start(Some("Dumping"))?;
18191837
self.on_dump(html).await?;
@@ -2007,6 +2025,18 @@ impl<A: API + ConsoleWriter + 'static, F: Fn() -> A + Send + Sync> UI<A, F> {
20072025
Ok(())
20082026
}
20092027

2028+
async fn handle_rename_conversation(&mut self, name: String) -> anyhow::Result<()> {
2029+
let conversation_id = self.init_conversation().await?;
2030+
self.api
2031+
.rename_conversation(&conversation_id, name.clone())
2032+
.await?;
2033+
self.writeln_title(TitleFormat::info(format!(
2034+
"Conversation renamed to '{}'",
2035+
name.bold()
2036+
)))?;
2037+
Ok(())
2038+
}
2039+
20102040
/// Select a model from all configured providers using porcelain-style
20112041
/// tabular display matching the shell plugin's `:model` UI.
20122042
///

crates/forge_repo/src/conversation/conversation_repo.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -894,6 +894,43 @@ mod tests {
894894
Ok(())
895895
}
896896

897+
#[tokio::test]
898+
async fn test_rename_conversation_via_upsert() -> anyhow::Result<()> {
899+
let repo = repository()?;
900+
let conversation =
901+
Conversation::new(ConversationId::generate()).title(Some("Original Title".to_string()));
902+
903+
repo.upsert_conversation(conversation.clone()).await?;
904+
905+
// Rename by upserting with a new title
906+
let renamed = conversation
907+
.clone()
908+
.title(Some("Renamed Session".to_string()));
909+
repo.upsert_conversation(renamed).await?;
910+
911+
let actual = repo.get_conversation(&conversation.id).await?.unwrap();
912+
assert_eq!(actual.title, Some("Renamed Session".to_string()));
913+
Ok(())
914+
}
915+
916+
#[tokio::test]
917+
async fn test_rename_conversation_from_none() -> anyhow::Result<()> {
918+
let repo = repository()?;
919+
let conversation = Conversation::new(ConversationId::generate());
920+
921+
// Start with no title
922+
assert!(conversation.title.is_none());
923+
repo.upsert_conversation(conversation.clone()).await?;
924+
925+
// Rename it
926+
let renamed = conversation.clone().title(Some("My Session".to_string()));
927+
repo.upsert_conversation(renamed).await?;
928+
929+
let actual = repo.get_conversation(&conversation.id).await?.unwrap();
930+
assert_eq!(actual.title, Some("My Session".to_string()));
931+
Ok(())
932+
}
933+
897934
#[test]
898935
fn test_legacy_tool_value_pair_deserialization() {
899936
use crate::conversation::conversation_record::ToolOutputRecord;

shell-plugin/lib/actions/conversation.zsh

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
# - :clone - Clone current or selected conversation
1010
# - :clone <id> - Clone specific conversation by ID
1111
# - :copy - Copy last assistant message to OS clipboard as raw markdown
12+
# - :rename <name> - Rename the current conversation
13+
# - :conversation-rename - Rename a conversation (interactive picker)
14+
# - :conversation-rename <id> <name> - Rename specific conversation by ID
1215
#
1316
# Helper Functions:
1417
# - _forge_switch_conversation <id> - Switch to a conversation and track previous
@@ -234,6 +237,91 @@ function _forge_action_copy() {
234237
_forge_log success "Copied to clipboard \033[90m[${line_count} lines, ${byte_count} bytes]\033[0m"
235238
}
236239

240+
# Action handler: Rename current conversation
241+
# Usage: :rename <name>
242+
function _forge_action_rename() {
243+
local input_text="$1"
244+
245+
echo
246+
247+
if [[ -z "$_FORGE_CONVERSATION_ID" ]]; then
248+
_forge_log error "No active conversation. Start a conversation first or use :conversation to select one"
249+
return 0
250+
fi
251+
252+
if [[ -z "$input_text" ]]; then
253+
_forge_log error "Usage: :rename <name>"
254+
return 0
255+
fi
256+
257+
_forge_exec conversation rename "$_FORGE_CONVERSATION_ID" $input_text
258+
}
259+
260+
# Action handler: Rename a conversation (interactive picker or by ID)
261+
# Usage: :conversation-rename [<id> <name>]
262+
function _forge_action_conversation_rename() {
263+
local input_text="$1"
264+
265+
echo
266+
267+
# If input looks like "<id> <name>", split and rename directly
268+
if [[ -n "$input_text" ]]; then
269+
local conversation_id="${input_text%% *}"
270+
local new_name="${input_text#* }"
271+
272+
if [[ "$conversation_id" == "$new_name" ]]; then
273+
# Only one arg provided — not enough
274+
_forge_log error "Usage: :conversation-rename <id> <name>"
275+
return 0
276+
fi
277+
278+
_forge_exec conversation rename "$conversation_id" $new_name
279+
return 0
280+
fi
281+
282+
# No args — show interactive picker
283+
local conversations_output
284+
conversations_output=$($_FORGE_BIN conversation list --porcelain 2>/dev/null)
285+
286+
if [[ -z "$conversations_output" ]]; then
287+
_forge_log error "No conversations found"
288+
return 0
289+
fi
290+
291+
local current_id="$_FORGE_CONVERSATION_ID"
292+
293+
local prompt_text="Rename Conversation ❯ "
294+
local fzf_args=(
295+
--prompt="$prompt_text"
296+
--delimiter="$_FORGE_DELIMITER"
297+
--with-nth="2,3"
298+
--preview="CLICOLOR_FORCE=1 $_FORGE_BIN conversation info {1}; echo; CLICOLOR_FORCE=1 $_FORGE_BIN conversation show {1}"
299+
$_FORGE_PREVIEW_WINDOW
300+
)
301+
302+
if [[ -n "$current_id" ]]; then
303+
local index=$(_forge_find_index "$conversations_output" "$current_id" 1)
304+
fzf_args+=(--bind="start:pos($index)")
305+
fi
306+
307+
local selected_conversation
308+
selected_conversation=$(echo "$conversations_output" | _forge_fzf --header-lines=1 "${fzf_args[@]}")
309+
310+
if [[ -n "$selected_conversation" ]]; then
311+
local conversation_id=$(echo "$selected_conversation" | sed -E 's/ .*//' | tr -d '\n')
312+
313+
# Prompt for new name
314+
echo -n "Enter new name: "
315+
read -r new_name </dev/tty
316+
317+
if [[ -n "$new_name" ]]; then
318+
_forge_exec conversation rename "$conversation_id" $new_name
319+
else
320+
_forge_log error "No name provided, rename cancelled"
321+
fi
322+
fi
323+
}
324+
237325
# Helper function to clone and switch to conversation
238326
function _forge_clone_and_switch() {
239327
local clone_target="$1"

shell-plugin/lib/dispatcher.zsh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,12 @@ function forge-accept-line() {
217217
clone)
218218
_forge_action_clone "$input_text"
219219
;;
220+
rename|rn)
221+
_forge_action_rename "$input_text"
222+
;;
223+
conversation-rename)
224+
_forge_action_conversation_rename "$input_text"
225+
;;
220226
copy)
221227
_forge_action_copy
222228
;;

0 commit comments

Comments
 (0)