diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 69224052..a08e49a0 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -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 ", + 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), diff --git a/crates/tui/src/commands/rename.rs b/crates/tui/src/commands/rename.rs new file mode 100644 index 00000000..705ec02f --- /dev/null +++ b/crates/tui/src/commands/rename.rs @@ -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 ` +/// +/// The new title is persisted immediately to `~/.deepseek/sessions/.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 "), + }; + + 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); + } +} diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index f4a448c0..b6992720 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -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." }