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:
kitty
2026-05-06 17:13:23 +08:00
committed by GitHub
parent b86baa38ed
commit 719594636c
3 changed files with 197 additions and 0 deletions
+8
View File
@@ -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),
+183
View File
@@ -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);
}
}
+6
View File
@@ -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."
}