feat(core/tui): add session nicknames

fixes #524
This commit is contained in:
James Peter
2026-01-24 00:23:48 +10:00
parent d3a315c963
commit 30d5e04533
9 changed files with 169 additions and 4 deletions
+66 -1
View File
@@ -69,6 +69,10 @@ pub struct SessionIndexEntry {
#[serde(skip_serializing_if = "Option::is_none")]
pub last_user_snippet: Option<String>,
/// Optional user-assigned nickname for the session
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nickname: Option<String>,
/// Device/machine where this session originated (for synced sessions)
#[serde(skip_serializing_if = "Option::is_none")]
pub sync_origin_device: Option<String>,
@@ -256,6 +260,16 @@ impl SessionCatalog {
self.save()
}
/// Update the nickname for a session entry.
pub fn set_nickname(&mut self, session_id: Uuid, nickname: Option<String>) -> io::Result<bool> {
let Some(entry) = self.entries.get_mut(&session_id) else {
return Ok(false);
};
entry.nickname = nickname;
self.save()?;
Ok(true)
}
/// Remove an entry's session_id from secondary indexes.
fn remove_from_indexes(&mut self, session_id: &Uuid, entry: &SessionIndexEntry) {
// Remove from cwd index
@@ -310,9 +324,12 @@ impl SessionCatalog {
}
// Upsert discovered entries.
for (session_id, entry) in discovered_entries {
for (session_id, mut entry) in discovered_entries {
if let Some(existing) = self.entries.get(&session_id).cloned() {
if should_replace(&existing, &entry) {
if entry.nickname.is_none() {
entry.nickname = existing.nickname.clone();
}
self.remove_from_indexes(&session_id, &existing);
self.index_entry(entry);
result.updated += 1;
@@ -535,6 +552,7 @@ async fn parse_rollout_file(
message_count,
user_message_count,
last_user_snippet,
nickname: None,
sync_origin_device: None,
sync_version: 0,
archived: false,
@@ -628,6 +646,7 @@ mod tests {
message_count: 5,
user_message_count: 2,
last_user_snippet: None,
nickname: None,
sync_origin_device: None,
sync_version: 0,
archived: false,
@@ -663,6 +682,7 @@ mod tests {
message_count: 5,
user_message_count: 1,
last_user_snippet: Some("test message".to_string()),
nickname: None,
sync_origin_device: None,
sync_version: 0,
archived: false,
@@ -680,6 +700,45 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn test_catalog_set_nickname() -> io::Result<()> {
let temp = TempDir::new()?;
let code_home = temp.path();
let session_id = Uuid::new_v4();
let entry = SessionIndexEntry {
session_id,
rollout_path: PathBuf::from("sessions/test-nickname.jsonl"),
snapshot_path: None,
created_at: "2025-01-01T10:00:00.000Z".to_string(),
last_event_at: "2025-01-01T10:05:00.000Z".to_string(),
cwd_real: PathBuf::from("/test"),
cwd_display: "/test".to_string(),
git_project_root: None,
git_branch: None,
model_provider: None,
session_source: SessionSource::Cli,
message_count: 5,
user_message_count: 1,
last_user_snippet: None,
nickname: None,
sync_origin_device: None,
sync_version: 0,
archived: false,
deleted: false,
};
let mut catalog = SessionCatalog::load(code_home)?;
catalog.upsert(entry.clone())?;
assert!(catalog.set_nickname(session_id, Some("Launch checklist".to_string()))?);
let loaded = SessionCatalog::load(code_home)?;
let retrieved = loaded.get(&session_id).expect("session entry");
assert_eq!(retrieved.nickname.as_deref(), Some("Launch checklist"));
Ok(())
}
#[tokio::test]
async fn test_catalog_indexes() -> io::Result<()> {
let temp = TempDir::new()?;
@@ -703,6 +762,7 @@ mod tests {
message_count: 5,
user_message_count: 1,
last_user_snippet: None,
nickname: None,
sync_origin_device: None,
sync_version: 0,
archived: false,
@@ -753,6 +813,7 @@ mod tests {
message_count: 5,
user_message_count: 2,
last_user_snippet: Some("first message".to_string()),
nickname: None,
sync_origin_device: None,
sync_version: 0,
archived: false,
@@ -802,6 +863,7 @@ mod tests {
message_count: 5,
user_message_count: 1,
last_user_snippet: None,
nickname: None,
sync_origin_device: None,
sync_version: 0,
archived: false,
@@ -845,6 +907,7 @@ mod tests {
message_count: 5,
user_message_count: 2,
last_user_snippet: None,
nickname: None,
sync_origin_device: None,
sync_version: 0,
archived: false,
@@ -905,6 +968,7 @@ mod tests {
message_count: 5,
user_message_count: 2,
last_user_snippet: None,
nickname: None,
sync_origin_device: None,
sync_version: 0,
archived: false,
@@ -982,6 +1046,7 @@ mod tests {
message_count: 5,
user_message_count: 2,
last_user_snippet: None,
nickname: None,
sync_origin_device: None,
sync_version: 0,
archived: false,
+14
View File
@@ -9,6 +9,7 @@ use code_protocol::protocol::SessionSource;
use once_cell::sync::OnceCell;
use tokio::task;
use tokio::sync::Mutex as AsyncMutex;
use uuid::Uuid;
use crate::rollout::catalog::{self as rollout_catalog, SessionIndexEntry};
@@ -126,6 +127,19 @@ impl SessionCatalog {
entry_to_rollout_path(&self.code_home, entry)
}
/// Set or clear a nickname for the given session.
pub async fn set_nickname(&self, session_id: Uuid, nickname: Option<String>) -> Result<bool> {
let mut catalog = self.load_inner().await?;
let updated = catalog
.set_nickname(session_id, nickname)
.context("failed to update session nickname")?;
if updated {
let mut guard = self.cache.lock().await;
*guard = Some(catalog);
}
Ok(updated)
}
async fn load_inner(&self) -> Result<rollout_catalog::SessionCatalog> {
{
let mut guard = self.cache.lock().await;
+65
View File
@@ -13,6 +13,7 @@ use code_cloud_tasks_client::{CloudTaskError, TaskId};
use code_core::config::add_project_allowed_command;
use code_core::config_types::Notifications;
use code_core::protocol::{Event, Op, SandboxPolicy};
use code_core::SessionCatalog;
use code_login::{AuthManager, AuthMode, ServerOptions};
use portable_pty::PtySize;
@@ -1081,6 +1082,65 @@ impl App<'_> {
widget.show_resume_picker();
}
}
SlashCommand::Rename => {
if let AppState::Chat { widget } = &mut self.app_state {
let trimmed = command_args.trim();
if trimmed.is_empty() {
widget.debug_notice(
"Usage: /rename <name> (or /rename - to clear)".to_string(),
);
} else if let Some(session_id) = widget.session_id() {
let nickname =
if trimmed == "-" || trimmed.eq_ignore_ascii_case("clear") {
None
} else {
Some(trimmed.to_string())
};
let code_home = self.config.code_home.clone();
let tx = self.app_event_tx.clone();
let nickname_label = nickname.clone();
if let Err(err) = std::thread::Builder::new()
.name("session-rename".to_string())
.spawn(move || {
let message = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => {
let catalog = SessionCatalog::new(code_home);
match rt.block_on(
catalog.set_nickname(session_id, nickname),
) {
Ok(true) => match nickname_label {
Some(name) => {
format!("Session renamed to \"{name}\".")
}
None => "Session nickname cleared.".to_string(),
},
Ok(false) => {
"Session not found in catalog.".to_string()
}
Err(err) => {
format!("Failed to rename session: {err}")
}
}
}
Err(err) => {
format!("Failed to start rename task: {err}")
}
};
tx.send(AppEvent::SessionRenameCompleted { message });
})
{
widget.debug_notice(format!(
"Failed to spawn rename task: {err}",
));
}
} else {
widget.debug_notice("Session not ready yet.".to_string());
}
}
}
SlashCommand::New => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.abort_active_turn_for_new_chat();
@@ -1345,6 +1405,11 @@ impl App<'_> {
widget.handle_resume_picker_load_failed(message);
}
}
AppEvent::SessionRenameCompleted { message } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.debug_notice(message);
}
}
AppEvent::ResumeFrom(path) => {
// Replace the current chat widget with a new one configured to resume
let mut cfg = self.config.clone();
+3
View File
@@ -263,6 +263,9 @@ pub(crate) enum AppEvent {
/// Resume picker failed to load
ResumePickerLoadFailed { message: String },
/// Session nickname update finished
SessionRenameCompleted { message: String },
/// Signal that agents are about to start (triggered when /plan, /solve, /code commands are entered)
PrepareAgents,
@@ -217,7 +217,7 @@ impl BottomPaneView<'_> for ResumeSelectionView {
Constraint::Min(10), // Last User Message
];
let header = Row::new(vec!["Modified", "Created", "User Msgs", "Branch", "Last User Message"]).height(1)
let header = Row::new(vec!["Modified", "Created", "User Msgs", "Branch", "Session"]).height(1)
.style(Style::default().fg(crate::colors::text_bright()));
let table = Table::new(rows_iter, widths)
+15 -2
View File
@@ -4162,9 +4162,22 @@ impl ChatWidget<'_> {
.map(|c| {
let modified = human_ago(&c.modified_ts.unwrap_or_default());
let created = human_ago(&c.created_ts.unwrap_or_default());
let user_msgs = format!("{}", c.user_message_count);
let user_message_count = c.user_message_count;
let user_msgs = format!("{user_message_count}");
let branch = c.branch.unwrap_or_else(|| "-".to_string());
let mut summary = c.snippet.unwrap_or_else(|| c.subtitle.unwrap_or_default());
let nickname = c
.nickname
.and_then(|name| {
let trimmed = name.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
});
let snippet = c.snippet.or(c.subtitle);
let mut summary = match (nickname, snippet) {
(Some(name), Some(snippet)) => format!("{name} - {snippet}"),
(Some(name), None) => name,
(None, Some(snippet)) => snippet,
(None, None) => String::new(),
};
const SNIPPET_MAX: usize = 64;
if summary.chars().count() > SNIPPET_MAX {
summary = summary.chars().take(SNIPPET_MAX).collect::<String>() + "…";
+2
View File
@@ -8,6 +8,7 @@ use tokio::runtime::{Builder, Handle};
#[derive(Clone, Debug)]
pub struct ResumeCandidate {
pub path: PathBuf,
pub nickname: Option<String>,
pub subtitle: Option<String>,
pub created_ts: Option<String>,
pub modified_ts: Option<String>,
@@ -100,6 +101,7 @@ fn entry_to_candidate(code_home: &Path, entry: SessionIndexEntry) -> ResumeCandi
ResumeCandidate {
path,
nickname: entry.nickname.clone(),
subtitle: entry.last_user_snippet.clone(),
created_ts: Some(entry.created_at.clone()),
modified_ts: Some(entry.last_event_at.clone()),
+2
View File
@@ -83,6 +83,7 @@ pub enum SlashCommand {
Validation,
Mcp,
Resume,
Rename,
Login,
// Prompt-expanding commands
Plan,
@@ -101,6 +102,7 @@ impl SlashCommand {
SlashCommand::Chrome => "connect to your Chrome browser",
SlashCommand::Browser => "open internal browser",
SlashCommand::Resume => "resume a past session for this folder",
SlashCommand::Rename => "rename the current session",
SlashCommand::Plan => "create a comprehensive plan (multiple agents)",
SlashCommand::Solve => "solve a challenging problem (multiple agents)",
SlashCommand::Code => "perform a coding task (multiple agents)",
+1
View File
@@ -17,6 +17,7 @@ Notes
- `/chrome`: connect to your Chrome browser.
- `/new`: start a new chat during a conversation.
- `/resume`: resume a past session for this folder.
- `/rename <name>`: rename the current session (shown in the resume list).
- `/quit`: exit Code.
- `/logout`: log out of Code.
- `/login`: manage Code sign-ins (select, add, or disconnect accounts).