mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>() + "…";
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user