feat(commands): add /rename command to set a custom session title (#836)
* feat(commands): add /rename command to set a custom session title Adds a `/rename <new title>` slash command that lets users set a human-readable name for the current session. The new title is persisted immediately to the session JSON file so it appears in the session picker on the next open. - Max title length capped at 100 characters (char-count aware, handles CJK) - Errors on missing/empty arg or no active session - Inner `rename_with_manager` helper keeps unit tests fully isolated from ~/.deepseek/sessions - Localized descriptions in en, ja, zh-Hans, pt-BR * fix(rename): sync App state before saving to prevent data loss Use update_session() to merge current in-memory messages and tokens into the session before writing the renamed title, preventing stale disk data from overwriting unsaved App state. * style: format rename command --------- Co-authored-by: Hunter Bown <hmbown@gmail.com>
This commit is contained in:
@@ -18,6 +18,7 @@ mod network;
|
||||
mod note;
|
||||
mod provider;
|
||||
mod queue;
|
||||
mod rename;
|
||||
mod restore;
|
||||
mod review;
|
||||
mod session;
|
||||
@@ -248,6 +249,12 @@ pub const COMMANDS: &[CommandInfo] = &[
|
||||
description_id: MessageId::CmdNetworkDescription,
|
||||
},
|
||||
// Session commands
|
||||
CommandInfo {
|
||||
name: "rename",
|
||||
aliases: &[],
|
||||
usage: "/rename <new title>",
|
||||
description_id: MessageId::CmdRenameDescription,
|
||||
},
|
||||
CommandInfo {
|
||||
name: "save",
|
||||
aliases: &[],
|
||||
@@ -503,6 +510,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
|
||||
"network" => network::network(app, arg),
|
||||
|
||||
// Session commands
|
||||
"rename" => rename::rename(app, arg),
|
||||
"save" => session::save(app, arg),
|
||||
"sessions" | "resume" => session::sessions(app, arg),
|
||||
"load" => session::load(app, arg),
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! `/rename` command — set a custom title for the current session.
|
||||
|
||||
use crate::session_manager::{SessionManager, update_session};
|
||||
use crate::tui::app::App;
|
||||
|
||||
use super::CommandResult;
|
||||
|
||||
const MAX_TITLE_LEN: usize = 100;
|
||||
|
||||
/// Rename the current session to the given title.
|
||||
///
|
||||
/// Usage: `/rename <new title>`
|
||||
///
|
||||
/// The new title is persisted immediately to `~/.deepseek/sessions/<id>.json`
|
||||
/// so the updated name is visible the next time the session picker is opened.
|
||||
pub fn rename(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
let new_title = match arg.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
Some(t) => t,
|
||||
None => return CommandResult::error("Usage: /rename <new title>"),
|
||||
};
|
||||
|
||||
if new_title.chars().count() > MAX_TITLE_LEN {
|
||||
return CommandResult::error(format!("Title too long (max {MAX_TITLE_LEN} characters)"));
|
||||
}
|
||||
|
||||
let session_id = match &app.current_session_id {
|
||||
Some(id) => id.clone(),
|
||||
None => {
|
||||
return CommandResult::error(
|
||||
"No active session. Send a message first to start a session.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let manager = match SessionManager::default_location() {
|
||||
Ok(m) => m,
|
||||
Err(e) => return CommandResult::error(format!("Could not open sessions directory: {e}")),
|
||||
};
|
||||
|
||||
rename_with_manager(new_title, &session_id, &manager, app)
|
||||
}
|
||||
|
||||
fn rename_with_manager(
|
||||
new_title: &str,
|
||||
session_id: &str,
|
||||
manager: &SessionManager,
|
||||
app: &App,
|
||||
) -> CommandResult {
|
||||
let mut session = match manager.load_session(session_id) {
|
||||
Ok(s) => s,
|
||||
Err(e) => return CommandResult::error(format!("Could not load session: {e}")),
|
||||
};
|
||||
|
||||
// Sync with current App state to avoid overwriting unsaved messages.
|
||||
session = update_session(
|
||||
session,
|
||||
&app.api_messages,
|
||||
u64::from(app.session.total_tokens),
|
||||
app.system_prompt.as_ref(),
|
||||
);
|
||||
session.metadata.title = new_title.to_string();
|
||||
|
||||
match manager.save_session(&session) {
|
||||
Ok(_) => CommandResult::message(format!("Session renamed to \"{new_title}\"")),
|
||||
Err(e) => CommandResult::error(format!("Could not save session: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::session_manager::{SessionManager, create_saved_session_with_mode};
|
||||
use crate::tui::app::{App, TuiOptions};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_app(tmpdir: &TempDir) -> App {
|
||||
App::new(
|
||||
TuiOptions {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmpdir.path().to_path_buf(),
|
||||
config_path: None,
|
||||
config_profile: None,
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
use_bracketed_paste: true,
|
||||
max_subagents: 1,
|
||||
skills_dir: tmpdir.path().join("skills"),
|
||||
memory_path: tmpdir.path().join("memory.md"),
|
||||
notes_path: tmpdir.path().join("notes.txt"),
|
||||
mcp_config_path: tmpdir.path().join("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
initial_input: None,
|
||||
},
|
||||
&Config::default(),
|
||||
)
|
||||
}
|
||||
|
||||
fn make_session_manager(tmpdir: &TempDir) -> SessionManager {
|
||||
SessionManager::new(tmpdir.path().join("sessions")).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_without_arg_returns_error() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut app = make_app(&tmp);
|
||||
let r = rename(&mut app, None);
|
||||
assert!(r.is_error);
|
||||
assert!(r.message.unwrap().contains("Usage:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_with_empty_arg_returns_error() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut app = make_app(&tmp);
|
||||
let r = rename(&mut app, Some(" "));
|
||||
assert!(r.is_error);
|
||||
assert!(r.message.unwrap().contains("Usage:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_without_active_session_returns_error() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut app = make_app(&tmp);
|
||||
app.current_session_id = None;
|
||||
let r = rename(&mut app, Some("My Session"));
|
||||
assert!(r.is_error);
|
||||
assert!(r.message.unwrap().contains("No active session"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_title_too_long_returns_error() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut app = make_app(&tmp);
|
||||
let long_title = "a".repeat(MAX_TITLE_LEN + 1);
|
||||
let r = rename(&mut app, Some(&long_title));
|
||||
assert!(r.is_error);
|
||||
assert!(r.message.unwrap().contains("too long"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_persists_new_title() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let manager = make_session_manager(&tmp);
|
||||
let app = make_app(&tmp);
|
||||
|
||||
let session =
|
||||
create_saved_session_with_mode(&[], "deepseek-v4-pro", tmp.path(), 0, None, None);
|
||||
let session_id = session.metadata.id.clone();
|
||||
manager.save_session(&session).unwrap();
|
||||
|
||||
let result = rename_with_manager("Brand New Title", &session_id, &manager, &app);
|
||||
assert!(!result.is_error);
|
||||
assert!(result.message.unwrap().contains("Brand New Title"));
|
||||
|
||||
let reloaded = manager.load_session(&session_id).unwrap();
|
||||
assert_eq!(reloaded.metadata.title, "Brand New Title");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_title_at_max_length_succeeds() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let manager = make_session_manager(&tmp);
|
||||
let app = make_app(&tmp);
|
||||
|
||||
let session =
|
||||
create_saved_session_with_mode(&[], "deepseek-v4-pro", tmp.path(), 0, None, None);
|
||||
let session_id = session.metadata.id.clone();
|
||||
manager.save_session(&session).unwrap();
|
||||
|
||||
let max_title = "中".repeat(MAX_TITLE_LEN);
|
||||
let result = rename_with_manager(&max_title, &session_id, &manager, &app);
|
||||
assert!(!result.is_error);
|
||||
|
||||
let reloaded = manager.load_session(&session_id).unwrap();
|
||||
assert_eq!(reloaded.metadata.title, max_title);
|
||||
}
|
||||
}
|
||||
@@ -247,6 +247,7 @@ pub enum MessageId {
|
||||
CmdProviderDescription,
|
||||
CmdQueueDescription,
|
||||
CmdRecallDescription,
|
||||
CmdRenameDescription,
|
||||
CmdRestoreDescription,
|
||||
CmdRetryDescription,
|
||||
CmdReviewDescription,
|
||||
@@ -435,6 +436,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
|
||||
MessageId::CmdProviderDescription,
|
||||
MessageId::CmdQueueDescription,
|
||||
MessageId::CmdRecallDescription,
|
||||
MessageId::CmdRenameDescription,
|
||||
MessageId::CmdRestoreDescription,
|
||||
MessageId::CmdRetryDescription,
|
||||
MessageId::CmdReviewDescription,
|
||||
@@ -758,6 +760,7 @@ fn english(id: MessageId) -> &'static str {
|
||||
}
|
||||
MessageId::CmdQueueDescription => "View or edit queued messages",
|
||||
MessageId::CmdRecallDescription => "Search prior cycle archives (BM25 over message text)",
|
||||
MessageId::CmdRenameDescription => "Rename the current session",
|
||||
MessageId::CmdRestoreDescription => {
|
||||
"Roll back the workspace to a prior pre/post-turn snapshot. With no arg, lists recent snapshots."
|
||||
}
|
||||
@@ -1039,6 +1042,7 @@ fn japanese(id: MessageId) -> Option<&'static str> {
|
||||
MessageId::CmdRecallDescription => {
|
||||
"過去のサイクルアーカイブを検索(メッセージ本文への BM25 検索)"
|
||||
}
|
||||
MessageId::CmdRenameDescription => "現在のセッションの名前を変更",
|
||||
MessageId::CmdRestoreDescription => {
|
||||
"ワークスペースを以前のターン前/後スナップショットへロールバック。引数なしで最近のスナップショットを一覧表示。"
|
||||
}
|
||||
@@ -1298,6 +1302,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> {
|
||||
MessageId::CmdProviderDescription => "切换或查看当前 LLM 后端(deepseek | nvidia-nim)",
|
||||
MessageId::CmdQueueDescription => "查看或编辑已排队的消息",
|
||||
MessageId::CmdRecallDescription => "搜索此前的循环归档(基于消息文本的 BM25 检索)",
|
||||
MessageId::CmdRenameDescription => "重命名当前会话",
|
||||
MessageId::CmdRestoreDescription => {
|
||||
"将工作区回滚到此前的轮次前/后快照。不带参数时列出最近的快照。"
|
||||
}
|
||||
@@ -1559,6 +1564,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> {
|
||||
MessageId::CmdRecallDescription => {
|
||||
"Buscar arquivos de ciclos anteriores (BM25 sobre o texto das mensagens)"
|
||||
}
|
||||
MessageId::CmdRenameDescription => "Renomear a sessão atual",
|
||||
MessageId::CmdRestoreDescription => {
|
||||
"Reverter o workspace a um snapshot pré/pós-turno anterior. Sem argumento, lista os snapshots recentes."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user