use crate::commands::traits::{CommandInfo, RegisterCommand};
use crate::localization::MessageId;
use crate::session_manager::{SessionManager, update_session};
use crate::tui::app::App;
use super::CommandResult;
const MAX_TITLE_LEN: usize = 100;
pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
name: "title",
aliases: &["tabtitle", "window-title"],
usage: "/title [new title|off]",
description_id: MessageId::CmdTitleDescription,
};
pub(in crate::commands) struct TitleCmd;
impl RegisterCommand for TitleCmd {
fn info() -> &'static CommandInfo {
&COMMAND_INFO
}
fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
set_window_title(app, arg)
}
}
pub fn set_window_title(app: &mut App, arg: Option<&str>) -> CommandResult {
let arg = arg.map(str::trim).filter(|s| !s.is_empty());
let Some(arg) = arg else {
let current = app.window_title_prefix().unwrap_or("unset");
let source = if app.window_title.is_some() {
" (session)"
} else if app.title_default.is_some() {
" (config default)"
} else {
""
};
return CommandResult::message(format!("Window title: [{current}]{source}"));
};
if arg == "off" || arg == "clear" || arg == "none" {
let manager = match resolve_manager() {
Ok(manager) => manager,
Err(error) => return error,
};
return set_window_title_with_manager(app, None, &manager);
}
if arg.chars().count() > MAX_TITLE_LEN {
return CommandResult::error(format!("Title too long (max {MAX_TITLE_LEN} characters)"));
}
let sanitized = crate::session_manager::sanitize_session_title(arg);
let sanitized = sanitized.trim();
if sanitized.is_empty() {
return CommandResult::error(
"Title cannot be empty; use /title off to clear a session title",
);
}
let manager = match resolve_manager() {
Ok(manager) => manager,
Err(error) => return error,
};
set_window_title_with_manager(app, Some(sanitized.to_string()), &manager)
}
#[allow(clippy::result_large_err)]
fn resolve_manager() -> Result<SessionManager, CommandResult> {
SessionManager::default_location()
.map_err(|e| CommandResult::error(format!("Could not open sessions directory: {e}")))
}
pub(crate) fn set_window_title_with_manager(
app: &mut App,
title: Option<String>,
manager: &SessionManager,
) -> CommandResult {
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 mut session = match manager.load_session(&session_id) {
Ok(s) => s,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
match super::rename::live_session_before_first_snapshot(manager, &session_id, app) {
Some(s) => s,
None => {
return CommandResult::error(format!("Could not load session: {err}"));
}
}
}
Err(e) => return CommandResult::error(format!("Could not load session: {e}")),
};
let title = title.map(|t| crate::session_manager::sanitize_session_title(&t));
let title = title.map(|t| t.trim().to_string());
if let Some(ref t) = title
&& t.is_empty()
{
return CommandResult::error(
"Title cannot be empty; use /title off to clear a session title",
);
}
session = update_session(
session,
&app.api_messages,
u64::from(app.session.total_tokens),
app.system_prompt.as_ref(),
);
session.work_state = match app.work_state_snapshot() {
Ok(state) => state,
Err(err) => {
return CommandResult::error(format!(
"Could not snapshot Work state before setting title: {err}"
));
}
};
session.context_references = app.session_context_references.clone();
session.artifacts = app.session_artifacts.clone();
session.last_auto_route = app.auto_route_for_persistence();
session.metadata.model = app.model_selection_for_persistence();
session
.metadata
.set_model_provider_route(app.api_provider.as_str(), app.provider_id_for_persistence());
session.metadata.workspace.clone_from(&app.workspace);
session.metadata.mode = Some(app.mode.as_setting().to_string());
app.sync_cost_to_metadata(&mut session.metadata);
session.window_title = title.clone();
match manager.save_session(&session) {
Ok(_) => {
app.window_title = title.clone();
app.needs_redraw = true;
if let Err(err) = app.publish_pending_work_state() {
return CommandResult::error(format!(
"Window title saved, but Work views were not published: {err}"
));
}
match title {
Some(title) => CommandResult::message(format!(
"Window title set to \"{title}\" — the terminal tab now reads [\"{title}\"] …"
)),
None => CommandResult::message(
"Window title cleared (the config default still applies if set)",
),
}
}
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 {
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"),
..crate::test_support::test_tui_options(tmpdir.path())
},
&Config::default(),
)
}
fn make_session_manager(tmpdir: &TempDir) -> SessionManager {
SessionManager::new(tmpdir.path().join("sessions")).unwrap()
}
#[test]
fn no_active_session_reports_usage_error() {
let tmp = TempDir::new().unwrap();
let mut app = make_app(&tmp);
app.current_session_id = None;
let result = set_window_title(&mut app, Some("task-7"));
assert!(result.is_error);
assert!(result.message.unwrap().contains("No active session"));
}
#[test]
fn set_title_persists_on_the_saved_session() {
let tmp = TempDir::new().unwrap();
let manager = make_session_manager(&tmp);
let mut app = make_app(&tmp);
let mut session =
create_saved_session_with_mode(&[], "deepseek-v4-pro", tmp.path(), 0, None, None);
session.metadata.id = "title-test".to_string();
session.metadata.title = "Original Name".to_string();
manager.save_session(&session).unwrap();
app.current_session_id = Some("title-test".to_string());
let result =
set_window_title_with_manager(&mut app, Some("parallel-task".to_string()), &manager);
assert!(!result.is_error, "unexpected error: {:?}", result.message);
assert_eq!(app.window_title.as_deref(), Some("parallel-task"));
assert_eq!(app.session_title, None);
let reloaded = manager.load_session("title-test").unwrap();
assert_eq!(reloaded.window_title.as_deref(), Some("parallel-task"));
assert_eq!(reloaded.metadata.title, "Original Name");
}
#[test]
fn clear_title_removes_the_session_level_title() {
let tmp = TempDir::new().unwrap();
let manager = make_session_manager(&tmp);
let mut app = make_app(&tmp);
let mut session =
create_saved_session_with_mode(&[], "deepseek-v4-pro", tmp.path(), 0, None, None);
session.metadata.id = "title-clear".to_string();
session.window_title = Some("stale".to_string());
manager.save_session(&session).unwrap();
app.current_session_id = Some("title-clear".to_string());
let result = set_window_title_with_manager(&mut app, None, &manager);
assert!(!result.is_error, "unexpected error: {:?}", result.message);
assert_eq!(app.window_title, None);
assert_eq!(
manager.load_session("title-clear").unwrap().window_title,
None
);
}
#[test]
fn empty_argument_reports_the_effective_title() {
let tmp = TempDir::new().unwrap();
let mut app = make_app(&tmp);
app.title_default = Some("workspace-x".to_string());
let result = set_window_title(&mut app, None);
assert!(!result.is_error);
assert!(result.message.unwrap().contains("[workspace-x]"));
app.window_title = Some("session-specific".to_string());
let result = set_window_title(&mut app, None);
assert!(!result.is_error);
assert!(result.message.unwrap().contains("[session-specific]"));
}
#[test]
fn oversized_title_is_rejected() {
let tmp = TempDir::new().unwrap();
let mut app = make_app(&tmp);
app.current_session_id = Some("any".to_string());
let long = "x".repeat(MAX_TITLE_LEN + 1);
let result = set_window_title(&mut app, Some(&long));
assert!(result.is_error);
assert!(result.message.unwrap().contains("Title too long"));
}
#[test]
fn set_title_strips_terminal_controls_before_persisting() {
let tmp = TempDir::new().unwrap();
let manager = make_session_manager(&tmp);
let mut 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();
app.current_session_id = Some(session_id.clone());
let result = set_window_title_with_manager(
&mut app,
Some("Ev\u{1b}]0;PWNED\u{7}il\u{202e} Beta".to_string()),
&manager,
);
assert!(!result.is_error, "{result:?}");
let reloaded = manager.load_session(&session_id).unwrap();
assert_eq!(reloaded.window_title.as_deref(), Some("Ev]0;PWNEDil Beta"));
assert_eq!(app.window_title.as_deref(), Some("Ev]0;PWNEDil Beta"));
let result = set_window_title_with_manager(
&mut app,
Some("\u{1b}\u{7}\u{200b}".to_string()),
&manager,
);
assert!(result.is_error);
}
#[test]
fn set_title_mid_first_turn_recovers_from_checkpoint_when_snapshot_missing() {
let tmp = TempDir::new().unwrap();
let manager = make_session_manager(&tmp);
let mut app = make_app(&tmp);
let checkpoint =
create_saved_session_with_mode(&[], "deepseek-v4-pro", tmp.path(), 0, None, None);
let session_id = checkpoint.metadata.id.clone();
manager.save_checkpoint(&checkpoint).unwrap();
app.current_session_id = Some(session_id.clone());
app.api_messages = vec![user_message("first turn still streaming")];
let result =
set_window_title_with_manager(&mut app, Some("Midturn Title".to_string()), &manager);
assert!(!result.is_error, "{result:?}");
assert_eq!(app.window_title.as_deref(), Some("Midturn Title"));
let persisted = manager.load_session(&session_id).unwrap();
assert_eq!(persisted.window_title.as_deref(), Some("Midturn Title"));
assert_eq!(persisted.messages.len(), 1);
assert_ne!(persisted.metadata.title, "Midturn Title");
}
#[test]
fn set_title_mid_first_turn_builds_from_app_state_when_nothing_persisted() {
let tmp = TempDir::new().unwrap();
let manager = make_session_manager(&tmp);
let mut app = make_app(&tmp);
let session_id = "live-before-first-checkpoint";
app.current_session_id = Some(session_id.to_string());
app.api_messages = vec![user_message("turn one, nothing persisted yet")];
let result =
set_window_title_with_manager(&mut app, Some("Earliest Title".to_string()), &manager);
assert!(!result.is_error, "{result:?}");
assert_eq!(app.window_title.as_deref(), Some("Earliest Title"));
let persisted = manager.load_session(session_id).unwrap();
assert_eq!(persisted.window_title.as_deref(), Some("Earliest Title"));
assert_eq!(persisted.messages.len(), 1);
}
fn user_message(text: &str) -> crate::models::Message {
crate::models::Message {
role: "user".to_string(),
content: vec![crate::models::ContentBlock::Text {
text: text.to_string(),
cache_control: None,
}],
}
}
}