From a995046c5f40469ef80b0df3b379e9752309f3ba Mon Sep 17 00:00:00 2001 From: mabulgu Date: Fri, 17 Apr 2026 16:13:29 +0300 Subject: [PATCH 1/5] Add project-based grouped view, session archiving, move, and visual improvements Grouped view: - Collapsible tree view grouping sessions by project (Tab to toggle, --flat flag for ungrouped mode). Projects default to collapsed state. - Keyboard navigation: Left/Right to collapse/expand projects, Enter on project to toggle, End key respects grouped row count. Session archiving: - Press 'a' to archive a session with confirmation prompt (y/Enter to confirm, Esc/any other key to cancel). Archived sessions are moved to a projects-archive/ directory. Move to project: - Press 'm' to open a project picker overlay, select target with arrow keys and Enter. The session JSONL file is moved to the chosen project directory and all cwd fields are rewritten to match the target project. Visual improvements: - Dash prefix on non-selected sessions for better visual separation. - Execute resume command directly via interactive shell on session select. Internal: - Use on-disk directory name as project_path instead of JSONL cwd field, so file location is always the source of truth for project grouping. Tests: - Comprehensive tests for grouping, tree rows, input handling, archive and move file operations (67 tests total). --- Cargo.lock | 20 ++ Cargo.toml | 3 + src/discovery.rs | 97 ++++++-- src/lib.rs | 1 + src/main.rs | 8 +- src/search.rs | 4 +- src/session.rs | 15 +- src/titles.rs | 32 +++ src/tui/input.rs | 260 ++++++++++++++++++++- src/tui/mod.rs | 505 ++++++++++++++++++++++++++++++++++++++-- src/tui/view.rs | 360 ++++++++++++++++++++++++++-- tests/grouping_test.rs | 162 +++++++++++++ tests/tui_input_test.rs | 418 +++++++++++++++++++++++++++++++++ 13 files changed, 1822 insertions(+), 63 deletions(-) create mode 100644 src/titles.rs create mode 100644 tests/grouping_test.rs create mode 100644 tests/tui_input_test.rs diff --git a/Cargo.lock b/Cargo.lock index 764202e..1ff5774 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -235,6 +235,7 @@ dependencies = [ "serde_json", "syntect", "syntect-tui", + "tempfile", "termbg", ] @@ -669,6 +670,12 @@ dependencies = [ "regex", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "fax" version = "0.2.6" @@ -2153,6 +2160,19 @@ dependencies = [ "syntect", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.1", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "termbg" version = "0.6.2" diff --git a/Cargo.toml b/Cargo.toml index 848c347..b5a111e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,9 @@ syntect = "5.3" syntect-tui = "3.0" termbg = "0.6" +[dev-dependencies] +tempfile = "3" + [profile.release] lto = true strip = true diff --git a/src/discovery.rs b/src/discovery.rs index 2905072..6a06cb8 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -60,22 +60,60 @@ pub fn discover_sessions(claude_home: &Path) -> Vec { sessions } +/// Decode a Claude project directory name to a real filesystem path. +/// +/// Claude encodes paths by replacing '/' with '-', e.g.: +/// `-Users-abulgu-github-repos-mabulgu-cluster-baremetal-operator` +/// becomes: +/// `/Users/abulgu/github-repos/mabulgu/cluster-baremetal-operator` +/// +/// Uses filesystem probing to handle dashes in actual directory names. +fn decode_encoded_dir(encoded: &str) -> String { + let trimmed = encoded.trim_start_matches('-'); + let parts: Vec<&str> = trimmed.split('-').collect(); + let mut path = PathBuf::from("/"); + let mut i = 0; + + while i < parts.len() { + let mut found = false; + for j in (i + 1..=parts.len()).rev() { + let candidate = parts[i..j].join("-"); + let full = path.join(&candidate); + if full.is_dir() { + path = full; + i = j; + found = true; + break; + } + } + if !found { + path = path.join(parts[i]); + i += 1; + } + } + + path.to_string_lossy().to_string() +} + /// Parse a single JSONL session file and extract the first user message. fn parse_session_file(path: &Path) -> Option { let session_id = path.file_stem()?.to_str()?.to_string(); + let encoded_dir = path.parent()?.file_name()?.to_str()?.to_string(); + let file = fs::File::open(path).ok()?; let reader = BufReader::new(file); - // Track metadata from the first user entry (for cwd, branch, timestamp) - // but keep scanning for a non-meta message to display let mut cwd = String::new(); let mut git_branch: Option = None; let mut timestamp: DateTime = Utc::now(); let mut first_message = String::new(); let mut found_metadata = false; + let mut found_message = false; + let mut custom_title: Option = None; + let mut line_count: usize = 0; - for line in reader.lines().take(50) { + for line in reader.lines() { let line = match line { Ok(l) => l, Err(_) => continue, @@ -83,6 +121,27 @@ fn parse_session_file(path: &Path) -> Option { if line.trim().is_empty() { continue; } + + if line.contains("\"custom-title\"") { + if let Ok(val) = serde_json::from_str::(&line) { + if val.get("type").and_then(|v| v.as_str()) == Some("custom-title") { + custom_title = val + .get("customTitle") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + } + } + continue; + } + + if found_message { + continue; + } + line_count += 1; + if line_count > 50 { + continue; + } + let entry: SessionFileEntry = match serde_json::from_str(&line) { Ok(e) => e, Err(_) => continue, @@ -92,7 +151,6 @@ fn parse_session_file(path: &Path) -> Option { continue; } - // Grab metadata from the first user entry if !found_metadata { cwd = entry.cwd.clone().unwrap_or_default(); git_branch = entry.git_branch.clone(); @@ -104,7 +162,6 @@ fn parse_session_file(path: &Path) -> Option { found_metadata = true; } - // Extract and clean message text let raw_text = entry.message.map(|m| m.content.text()).unwrap_or_default(); if is_meta_message(&raw_text) { continue; @@ -118,30 +175,43 @@ fn parse_session_file(path: &Path) -> Option { .chars() .take(200) .collect(); - break; + found_message = true; } if !found_metadata { return None; } - let project_name = Path::new(&cwd) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown") - .to_string(); + let project_name = { + let decoded = decode_encoded_dir(&encoded_dir); + let decoded_path = Path::new(&decoded); + if decoded_path.exists() { + decoded_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string() + } else { + Path::new(&cwd) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string() + } + }; let project_exists = Path::new(&cwd).exists(); Some(Session { id: session_id, - project_path: cwd.clone(), + project_path: encoded_dir, project_name, git_branch, timestamp, first_message, cwd, project_exists, + custom_title, }) } @@ -152,10 +222,9 @@ fn parse_session_file(path: &Path) -> Option { /// the same role are merged into a single message with paragraphs separated by /// blank lines. pub fn load_conversation(claude_home: &Path, session: &Session) -> Vec { - let encoded_dir = session.project_path.replace('/', "-"); let file_path = claude_home .join("projects") - .join(&encoded_dir) + .join(&session.project_path) .join(format!("{}.jsonl", session.id)); let file = match fs::File::open(&file_path) { diff --git a/src/lib.rs b/src/lib.rs index d7d6983..04ef8f2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,4 +4,5 @@ pub mod filter; pub mod search; pub mod session; pub mod theme; +pub mod titles; pub mod tui; diff --git a/src/main.rs b/src/main.rs index 42037e8..abce01e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod filter; mod search; mod session; mod theme; +mod titles; mod tui; use clap::Parser; @@ -33,6 +34,10 @@ struct Cli { /// Force dark color theme #[arg(long = "dark", conflicts_with = "light")] dark: bool, + + /// Start in flat list view (default: grouped by project) + #[arg(long = "flat")] + flat: bool, } /// Parse a human-friendly duration string into a chrono::Duration. @@ -95,7 +100,8 @@ fn main() { }; // Interactive TUI - if let Err(e) = tui::run(sessions, theme) { + let grouped_view = !cli.flat; + if let Err(e) = tui::run(sessions, theme, grouped_view) { eprintln!("TUI error: {e}"); std::process::exit(1); } diff --git a/src/search.rs b/src/search.rs index 5cb6621..8439bb2 100644 --- a/src/search.rs +++ b/src/search.rs @@ -21,9 +21,8 @@ pub fn build_session_index(claude_home: &Path, sessions: &[Session]) -> HashMap< let mut index = HashMap::with_capacity(sessions.len()); for session in sessions { - let encoded_dir = session.project_path.replace('/', "-"); let file_path = projects_dir - .join(&encoded_dir) + .join(&session.project_path) .join(format!("{}.jsonl", session.id)); index.insert(file_path, session.clone()); } @@ -318,5 +317,6 @@ fn search_file_with_metadata(path: &Path, re: &Regex) -> Option { first_message, cwd, project_exists, + custom_title: None, }) } diff --git a/src/session.rs b/src/session.rs index fef4fa6..bf7d06c 100644 --- a/src/session.rs +++ b/src/session.rs @@ -15,6 +15,7 @@ pub struct Session { pub first_message: String, pub cwd: String, pub project_exists: bool, + pub custom_title: Option, } impl Session { @@ -22,9 +23,19 @@ impl Session { /// /// The path is single-quoted to handle spaces and special characters. pub fn resume_command(&self) -> String { - // Single-quote the path, escaping any embedded single quotes let escaped_cwd = self.cwd.replace('\'', "'\\''"); - format!("cd '{}' && claude -r {}", escaped_cwd, self.id) + match &self.custom_title { + Some(title) => { + let escaped_title = title.replace('\'', "'\\''"); + format!("cd '{}' && claude -r {} -n '{}'", escaped_cwd, self.id, escaped_title) + } + None => format!("cd '{}' && claude -r {}", escaped_cwd, self.id), + } + } + + pub fn fork_command(&self) -> String { + let escaped_cwd = self.cwd.replace('\'', "'\\''"); + format!("cd '{}' && claude -r {} --fork-session", escaped_cwd, self.id) } } diff --git a/src/titles.rs b/src/titles.rs new file mode 100644 index 0000000..590bb98 --- /dev/null +++ b/src/titles.rs @@ -0,0 +1,32 @@ +use std::fs::OpenOptions; +use std::io::Write; +use std::path::Path; + +use crate::discovery::get_claude_home; + +/// Append a custom-title entry to a session's JSONL file. +pub fn save_custom_title(project_path: &str, session_id: &str, title: &str) -> Result<(), String> { + let claude_home = get_claude_home(); + let file_path = claude_home + .join("projects") + .join(project_path) + .join(format!("{session_id}.jsonl")); + + append_custom_title(&file_path, session_id, title) +} + +fn append_custom_title(path: &Path, session_id: &str, title: &str) -> Result<(), String> { + let entry = serde_json::json!({ + "type": "custom-title", + "customTitle": title, + "sessionId": session_id, + }); + + let mut file = OpenOptions::new() + .append(true) + .open(path) + .map_err(|e| format!("failed to open session file: {e}"))?; + + writeln!(file, "{}", entry) + .map_err(|e| format!("failed to write title: {e}")) +} diff --git a/src/tui/input.rs b/src/tui/input.rs index 8e8a394..8d2a37c 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -3,7 +3,7 @@ use std::time::Instant; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use super::{Action, App, ContentSearchState, Mode}; +use super::{Action, App, ContentSearchState, Mode, TreeRow}; /// Handle a key event and return the resulting action. pub fn handle_input(app: &mut App, key: KeyEvent) -> Action { @@ -16,31 +16,76 @@ pub fn handle_input(app: &mut App, key: KeyEvent) -> Action { Mode::Browsing => handle_browse(app, key), Mode::Conversation => handle_conversation(app, key), Mode::ConversationSearch => handle_conversation_search(app, key), + Mode::ConfirmArchive => handle_confirm_archive(app, key), + Mode::MoveSelectProject => handle_move_select(app, key), + Mode::TitleEdit => handle_title_edit(app, key), } } fn handle_browse(app: &mut App, key: KeyEvent) -> Action { match key.code { + KeyCode::Tab | KeyCode::BackTab => { + app.toggle_view(); + Action::Continue + } KeyCode::Esc => { if !app.filter_query.is_empty() || app.filter_active { - // First Escape: clear filter and deactivate app.cancel_content_search(); app.filter_query.clear(); app.filter_active = false; app.apply_filter(); Action::Continue } else { - // Second Escape (filter already empty): quit Action::Quit } } KeyCode::Enter => { - if app.selected < app.display_entries.len() { + if app.grouped_view { + match app.selected_tree_row().cloned() { + Some(TreeRow::Project(gi)) => { + app.toggle_project(gi); + Action::Continue + } + Some(TreeRow::Session { display_idx, .. }) => { + Action::EnterConversation(display_idx) + } + None => Action::Continue, + } + } else if app.selected < app.display_entries.len() { Action::EnterConversation(app.selected) } else { Action::Continue } } + KeyCode::Right => { + if app.grouped_view { + if let Some(TreeRow::Project(gi)) = app.selected_tree_row().cloned() { + if !app.project_groups[gi].expanded { + app.toggle_project(gi); + } + } + } + Action::Continue + } + KeyCode::Left => { + if app.grouped_view { + match app.selected_tree_row().cloned() { + Some(TreeRow::Project(gi)) => { + if app.project_groups[gi].expanded { + app.toggle_project(gi); + } + } + Some(TreeRow::Session { project_idx, .. }) => { + // Jump to parent project header + if let Some(pos) = app.tree_rows.iter().position(|r| *r == TreeRow::Project(project_idx)) { + app.selected = pos; + } + } + None => {} + } + } + Action::Continue + } KeyCode::Down => { app.move_down(); Action::Continue @@ -50,14 +95,12 @@ fn handle_browse(app: &mut App, key: KeyEvent) -> Action { Action::Continue } KeyCode::PageDown => { - // Jump down by a page for _ in 0..20 { app.move_down(); } Action::Continue } KeyCode::PageUp => { - // Jump up by a page for _ in 0..20 { app.move_up(); } @@ -69,8 +112,9 @@ fn handle_browse(app: &mut App, key: KeyEvent) -> Action { Action::Continue } KeyCode::End => { - if !app.display_entries.is_empty() { - app.selected = app.display_entries.len() - 1; + let count = app.visible_row_count(); + if count > 0 { + app.selected = count - 1; } Action::Continue } @@ -92,11 +136,72 @@ fn handle_browse(app: &mut App, key: KeyEvent) -> Action { Action::Continue } KeyCode::Char(c) => { - // First '/' activates filter mode visually without adding to query if c == '/' && app.filter_query.is_empty() && !app.filter_active { app.filter_active = true; return Action::Continue; } + if c == 'a' && !app.filter_active { + let display_idx = if app.grouped_view { + match app.selected_tree_row().cloned() { + Some(TreeRow::Session { display_idx, .. }) => Some(display_idx), + _ => None, + } + } else if app.selected < app.display_entries.len() { + Some(app.selected) + } else { + None + }; + if let Some(idx) = display_idx { + app.archive_confirm = Some(idx); + app.mode = Mode::ConfirmArchive; + return Action::Continue; + } + } + if c == 'n' && !app.filter_active && app.grouped_view { + let cwd = match app.selected_tree_row().cloned() { + Some(TreeRow::Project(gi)) => Some(app.project_groups[gi].cwd.clone()), + Some(TreeRow::Session { project_idx, .. }) => Some(app.project_groups[project_idx].cwd.clone()), + None => None, + }; + if let Some(cwd) = cwd.filter(|c| !c.is_empty()) { + app.start_new_session_title(cwd); + return Action::Continue; + } + } + if c == 't' && !app.filter_active { + let display_idx = if app.grouped_view { + match app.selected_tree_row().cloned() { + Some(TreeRow::Session { display_idx, .. }) => Some(display_idx), + _ => None, + } + } else if app.selected < app.display_entries.len() { + Some(app.selected) + } else { + None + }; + if let Some(idx) = display_idx { + let entry = &app.display_entries[idx]; + let session_id = app.display_session(entry).id.clone(); + app.start_title_edit(session_id, Mode::Browsing); + return Action::Continue; + } + } + if c == 'm' && !app.filter_active { + let display_idx = if app.grouped_view { + match app.selected_tree_row().cloned() { + Some(TreeRow::Session { display_idx, .. }) => Some(display_idx), + _ => None, + } + } else if app.selected < app.display_entries.len() { + Some(app.selected) + } else { + None + }; + if let Some(idx) = display_idx { + app.start_move(idx); + return Action::Continue; + } + } app.filter_active = true; app.filter_query.push(c); app.cancel_flag.store(true, Ordering::Relaxed); @@ -191,6 +296,21 @@ fn handle_conversation(app: &mut App, key: KeyEvent) -> Action { jump_to_prev_match(app); Action::Continue } + KeyCode::Char('t') => { + if let Some(conv) = &app.conversation { + let session_id = conv.session.id.clone(); + app.start_title_edit(session_id, Mode::Conversation); + } + Action::Continue + } + KeyCode::Char('f') => { + if let Some(conv) = &app.conversation { + let cmd = conv.session.fork_command(); + Action::ForkSession(cmd) + } else { + Action::Continue + } + } KeyCode::Char('/') => { if let Some(conv) = &mut app.conversation { conv.search_active = true; @@ -306,6 +426,128 @@ fn handle_conversation_search(app: &mut App, key: KeyEvent) -> Action { } } +fn handle_move_select(app: &mut App, key: KeyEvent) -> Action { + match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + app.move_state = None; + app.mode = Mode::Browsing; + Action::Continue + } + KeyCode::Enter => { + if let Some(state) = app.move_state.take() { + let (target, _, cwd) = &state.projects[state.selected]; + let target = target.clone(); + let cwd = cwd.clone(); + app.mode = Mode::Browsing; + return Action::MoveSession { + display_idx: state.display_idx, + target_project: target, + target_cwd: cwd, + }; + } + app.mode = Mode::Browsing; + Action::Continue + } + KeyCode::Down | KeyCode::Char('j') => { + if let Some(state) = &mut app.move_state { + if state.selected + 1 < state.projects.len() { + state.selected += 1; + } + } + Action::Continue + } + KeyCode::Up | KeyCode::Char('k') => { + if let Some(state) = &mut app.move_state { + state.selected = state.selected.saturating_sub(1); + } + Action::Continue + } + KeyCode::Home => { + if let Some(state) = &mut app.move_state { + state.selected = 0; + } + Action::Continue + } + KeyCode::End => { + if let Some(state) = &mut app.move_state { + if !state.projects.is_empty() { + state.selected = state.projects.len() - 1; + } + } + Action::Continue + } + _ => Action::Continue, + } +} + +fn handle_title_edit(app: &mut App, key: KeyEvent) -> Action { + match key.code { + KeyCode::Esc => { + app.cancel_title_edit(); + Action::Continue + } + KeyCode::Enter => { + match app.finish_title_edit() { + Ok(Some(action)) => action, + Ok(None) => Action::Continue, + Err(msg) => { + app.set_status(msg); + Action::Continue + } + } + } + KeyCode::Backspace => { + if let Some(state) = &mut app.title_edit { + if state.cursor > 0 { + state.query.remove(state.cursor - 1); + state.cursor -= 1; + } + } + Action::Continue + } + KeyCode::Left => { + if let Some(state) = &mut app.title_edit { + state.cursor = state.cursor.saturating_sub(1); + } + Action::Continue + } + KeyCode::Right => { + if let Some(state) = &mut app.title_edit { + if state.cursor < state.query.len() { + state.cursor += 1; + } + } + Action::Continue + } + KeyCode::Char(c) => { + if let Some(state) = &mut app.title_edit { + state.query.insert(state.cursor, c); + state.cursor += 1; + } + Action::Continue + } + _ => Action::Continue, + } +} + +fn handle_confirm_archive(app: &mut App, key: KeyEvent) -> Action { + match key.code { + KeyCode::Enter | KeyCode::Char('y') => { + let idx = app.archive_confirm.take(); + app.mode = Mode::Browsing; + if let Some(display_idx) = idx { + return Action::ArchiveSession(display_idx); + } + Action::Continue + } + _ => { + app.archive_confirm = None; + app.mode = Mode::Browsing; + Action::Continue + } + } +} + fn jump_to_next_match(app: &mut App) { if let Some(conv) = &mut app.conversation { if conv.match_positions.is_empty() { diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 09219d3..7d6e602 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -18,7 +18,6 @@ use crossterm::terminal::{ }; use ratatui::prelude::*; -use crate::clipboard; use crate::discovery::{get_claude_home, load_conversation}; use crate::filter::filter_sessions; use crate::search; @@ -33,6 +32,9 @@ pub enum Mode { Browsing, Conversation, ConversationSearch, + ConfirmArchive, + MoveSelectProject, + TitleEdit, } /// Phase of the background content search. @@ -74,7 +76,125 @@ pub enum Action { Quit, EnterConversation(usize), CopyCommand(String), + NewSession(String), + ForkSession(String), BackToList, + ArchiveSession(usize), + MoveSession { display_idx: usize, target_project: String, target_cwd: String }, +} + +/// A group of sessions belonging to the same project directory. +#[derive(Debug, Clone)] +pub struct ProjectGroup { + pub name: String, + pub path: String, + pub cwd: String, + pub session_indices: Vec, + pub expanded: bool, +} + +/// A single row in the tree view: either a project header or a session under it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TreeRow { + Project(usize), + Session { project_idx: usize, display_idx: usize }, +} + +/// Build project groups from a flat list of sessions. +/// Groups are sorted by the latest session timestamp (newest group first). +/// Sessions within each group are ordered by their position in `display_entries`. +pub fn group_by_project(sessions: &[Session], display_entries: &[DisplayEntry]) -> Vec { + group_by_project_with_content(sessions, display_entries, &[]) +} + +/// Build project groups, including content-only search results. +pub fn group_by_project_with_content(sessions: &[Session], display_entries: &[DisplayEntry], content_results: &[Session]) -> Vec { + let mut group_map: std::collections::HashMap> = std::collections::HashMap::new(); + + for (di, entry) in display_entries.iter().enumerate() { + let session = match &entry.source { + DisplaySource::Sessions(idx) => &sessions[*idx], + DisplaySource::Content(idx) => &content_results[*idx], + }; + group_map + .entry(session.project_path.clone()) + .or_default() + .push(di); + } + + let mut groups: Vec = group_map + .into_iter() + .map(|(path, indices)| { + let first_session = indices.first() + .and_then(|&di| { + match &display_entries.get(di)?.source { + DisplaySource::Sessions(idx) => sessions.get(*idx), + DisplaySource::Content(idx) => content_results.get(*idx), + } + }); + let name = first_session + .map(|s| s.project_name.clone()) + .unwrap_or_else(|| "unknown".to_string()); + let cwd = first_session + .map(|s| s.cwd.clone()) + .unwrap_or_default(); + ProjectGroup { + name, + path, + cwd, + session_indices: indices, + expanded: false, + } + }) + .collect(); + + // Sort groups by latest activity (newest first) + groups.sort_by(|a, b| { + let ts_a = a.session_indices.first() + .map(|&i| display_entries[i].timestamp) + .unwrap_or_else(|| DateTime::::MIN_UTC); + let ts_b = b.session_indices.first() + .map(|&i| display_entries[i].timestamp) + .unwrap_or_else(|| DateTime::::MIN_UTC); + ts_b.cmp(&ts_a) + }); + + groups +} + +/// Build the flat list of tree rows from project groups, respecting expanded state. +pub fn build_tree_rows(groups: &[ProjectGroup]) -> Vec { + let mut rows = Vec::new(); + for (gi, group) in groups.iter().enumerate() { + rows.push(TreeRow::Project(gi)); + if group.expanded { + for &di in &group.session_indices { + rows.push(TreeRow::Session { project_idx: gi, display_idx: di }); + } + } + } + rows +} + +/// State for the project picker when moving a session. +pub struct MoveState { + pub display_idx: usize, + pub projects: Vec<(String, String, String)>, // (encoded_path, display_name, cwd) + pub selected: usize, +} + +/// What the title edit is for. +pub enum TitleEditContext { + Rename { session_id: String }, + NewSession { cwd: String }, +} + +/// State for title editing. +pub struct TitleEditState { + pub context: TitleEditContext, + pub query: String, + pub cursor: usize, + pub return_mode: Mode, } /// State for the conversation viewer. @@ -128,10 +248,22 @@ pub struct App { pub theme: Theme, /// Syntax highlighter for code blocks. pub syntax_highlighter: syntax::SyntaxHighlighter, + /// Project groups for tree view. + pub project_groups: Vec, + /// Flattened tree rows for the grouped view. + pub tree_rows: Vec, + /// Whether the grouped (tree) view is active. + pub grouped_view: bool, + /// Pending archive confirmation: display_idx of session to archive. + pub archive_confirm: Option, + /// State for the move-to-project picker. + pub move_state: Option, + /// Title being edited. + pub title_edit: Option, } impl App { - pub fn new(sessions: Vec, session_index: HashMap, theme: Theme) -> Self { + pub fn new(sessions: Vec, session_index: HashMap, theme: Theme, grouped_view: bool) -> Self { let filtered_indices: Vec = (0..sessions.len()).collect(); let display_entries: Vec = filtered_indices .iter() @@ -141,6 +273,8 @@ impl App { timestamp: sessions[idx].timestamp, }) .collect(); + let project_groups = group_by_project(&sessions, &display_entries); + let tree_rows = build_tree_rows(&project_groups); Self { sessions, filtered_indices, @@ -161,6 +295,12 @@ impl App { session_index: Arc::new(session_index), theme, syntax_highlighter: syntax::SyntaxHighlighter::new(), + project_groups, + tree_rows, + grouped_view, + archive_confirm: None, + move_state: None, + title_edit: None, } } @@ -213,6 +353,55 @@ impl App { entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); self.display_entries = entries; + self.rebuild_tree_rows(); + } + + /// Rebuild project groups and tree rows from current display entries. + pub fn rebuild_tree_rows(&mut self) { + let old_expanded: HashMap = self.project_groups + .iter() + .map(|g| (g.path.clone(), g.expanded)) + .collect(); + self.project_groups = group_by_project_with_content( + &self.sessions, + &self.display_entries, + &self.content_results, + ); + for group in &mut self.project_groups { + if let Some(&was_expanded) = old_expanded.get(&group.path) { + group.expanded = was_expanded; + } + } + self.tree_rows = build_tree_rows(&self.project_groups); + } + + /// Toggle a project group's expanded/collapsed state. + pub fn toggle_project(&mut self, group_idx: usize) { + if group_idx < self.project_groups.len() { + self.project_groups[group_idx].expanded = !self.project_groups[group_idx].expanded; + self.tree_rows = build_tree_rows(&self.project_groups); + } + } + + /// Toggle between flat list and grouped tree view. + pub fn toggle_view(&mut self) { + self.grouped_view = !self.grouped_view; + self.selected = 0; + self.scroll_offset = 0; + } + + /// Get the currently selected tree row, if in grouped view. + pub fn selected_tree_row(&self) -> Option<&TreeRow> { + self.tree_rows.get(self.selected) + } + + /// Total number of visible rows in the current view mode. + pub fn visible_row_count(&self) -> usize { + if self.grouped_view { + self.tree_rows.len() + } else { + self.display_entries.len() + } } /// Get the session referenced by a display entry. @@ -234,8 +423,9 @@ impl App { /// Move the selection cursor down, clamped to bounds. pub fn move_down(&mut self) { - if !self.display_entries.is_empty() { - self.selected = (self.selected + 1).min(self.display_entries.len() - 1); + let count = self.visible_row_count(); + if count > 0 { + self.selected = (self.selected + 1).min(count - 1); } } @@ -296,6 +486,260 @@ impl App { self.mode = Mode::Browsing; } + /// Archive a session by moving its JSONL file to a projects-archive/ directory. + /// Returns Ok(session_name) on success. + pub fn archive_session(&mut self, display_idx: usize) -> Result { + if display_idx >= self.display_entries.len() { + return Err("invalid index".to_string()); + } + let entry = &self.display_entries[display_idx]; + let session = self.display_session(entry).clone(); + + let claude_home = get_claude_home(); + let src = claude_home + .join("projects") + .join(&session.project_path) + .join(format!("{}.jsonl", session.id)); + let archive_dir = claude_home + .join("projects-archive") + .join(&session.project_path); + + std::fs::create_dir_all(&archive_dir) + .map_err(|e| format!("failed to create archive dir: {e}"))?; + + let dst = archive_dir.join(format!("{}.jsonl", session.id)); + std::fs::rename(&src, &dst) + .map_err(|e| format!("failed to move session: {e}"))?; + + let label = session.first_message.chars().take(40).collect::(); + + match entry.source { + DisplaySource::Sessions(sidx) => { + self.sessions.remove(sidx); + } + DisplaySource::Content(cidx) => { + self.content_results.remove(cidx); + } + } + self.apply_filter(); + + Ok(label) + } + + /// Start the move-to-project flow for a given session. + pub fn start_move(&mut self, display_idx: usize) { + if display_idx >= self.display_entries.len() { + return; + } + let entry = &self.display_entries[display_idx]; + let current_path = self.display_session(entry).project_path.clone(); + + let mut seen = HashSet::new(); + let mut projects: Vec<(String, String, String)> = Vec::new(); + for s in &self.sessions { + if s.project_path == current_path { + continue; + } + if seen.insert(s.project_path.clone()) { + projects.push((s.project_path.clone(), s.project_name.clone(), s.cwd.clone())); + } + } + + projects.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase())); + + if projects.is_empty() { + self.set_status("No other projects to move to".to_string()); + return; + } + + self.move_state = Some(MoveState { + display_idx, + projects, + selected: 0, + }); + self.mode = Mode::MoveSelectProject; + } + + /// Move a session to a different project directory, updating cwd in the JSONL. + pub fn move_session(&mut self, display_idx: usize, target_encoded_dir: &str, target_cwd: &str) -> Result { + if display_idx >= self.display_entries.len() { + return Err("invalid index".to_string()); + } + let entry = &self.display_entries[display_idx]; + let session = self.display_session(entry).clone(); + + let claude_home = get_claude_home(); + let src = claude_home + .join("projects") + .join(&session.project_path) + .join(format!("{}.jsonl", session.id)); + + let dst_dir = claude_home.join("projects").join(target_encoded_dir); + std::fs::create_dir_all(&dst_dir) + .map_err(|e| format!("failed to create target dir: {e}"))?; + + let content = std::fs::read_to_string(&src) + .map_err(|e| format!("failed to read session file: {e}"))?; + let old_cwd = &session.cwd; + let updated: String = content + .lines() + .map(|line| { + if let Ok(mut val) = serde_json::from_str::(line) { + if let Some(obj) = val.as_object_mut() { + if obj.get("cwd").and_then(|v| v.as_str()) == Some(old_cwd) { + obj.insert("cwd".to_string(), serde_json::Value::String(target_cwd.to_string())); + } + } + serde_json::to_string(&val).unwrap_or_else(|_| line.to_string()) + } else { + line.to_string() + } + }) + .collect::>() + .join("\n"); + + let dst = dst_dir.join(format!("{}.jsonl", session.id)); + std::fs::write(&dst, &updated) + .map_err(|e| format!("failed to write moved session: {e}"))?; + let _ = std::fs::remove_file(&src); + + let label = session.first_message.chars().take(40).collect::(); + let target_name = std::path::Path::new(target_cwd) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(target_encoded_dir); + + match entry.source { + DisplaySource::Sessions(sidx) => { + self.sessions.remove(sidx); + } + DisplaySource::Content(cidx) => { + self.content_results.remove(cidx); + } + } + self.apply_filter(); + + Ok(format!("{} → {}", label, target_name)) + } + + /// Start editing a title for the given session (rename). + pub fn start_title_edit(&mut self, session_id: String, return_mode: Mode) { + let existing = self.find_session(&session_id) + .and_then(|s| s.custom_title.clone()) + .unwrap_or_default(); + let cursor = existing.len(); + self.title_edit = Some(TitleEditState { + context: TitleEditContext::Rename { session_id }, + query: existing, + cursor, + return_mode, + }); + self.mode = Mode::TitleEdit; + } + + /// Start title input for a new session. + pub fn start_new_session_title(&mut self, cwd: String) { + self.title_edit = Some(TitleEditState { + context: TitleEditContext::NewSession { cwd }, + query: String::new(), + cursor: 0, + return_mode: Mode::Browsing, + }); + self.mode = Mode::TitleEdit; + } + + /// Finish title editing. Returns an Action if the caller should execute it. + pub fn finish_title_edit(&mut self) -> Result, String> { + let state = self.title_edit.take().ok_or("no title edit in progress")?; + let title = state.query.trim().to_string(); + + match state.context { + TitleEditContext::Rename { session_id } => { + if !title.is_empty() { + let duplicate = self.sessions.iter() + .chain(self.content_results.iter()) + .any(|s| s.id != session_id && s.custom_title.as_deref() == Some(&title)); + if duplicate { + let cursor = title.len(); + self.title_edit = Some(TitleEditState { + context: TitleEditContext::Rename { session_id }, + query: title, + cursor, + return_mode: state.return_mode, + }); + self.mode = Mode::TitleEdit; + return Err("title already in use".to_string()); + } + } + + let project_path = self.find_session(&session_id) + .map(|s| s.project_path.clone()) + .ok_or("session not found")?; + + crate::titles::save_custom_title(&project_path, &session_id, &title)?; + self.update_session_title(&session_id, if title.is_empty() { None } else { Some(title) }); + self.mode = state.return_mode; + Ok(None) + } + TitleEditContext::NewSession { cwd } => { + let escaped_cwd = cwd.replace('\'', "'\\''"); + + if title.is_empty() { + self.mode = Mode::Browsing; + return Ok(Some(Action::NewSession(format!("cd '{}' && claude", escaped_cwd)))); + } + + let duplicate = self.sessions.iter() + .chain(self.content_results.iter()) + .any(|s| s.custom_title.as_deref() == Some(&title)); + if duplicate { + let cursor = title.len(); + self.title_edit = Some(TitleEditState { + context: TitleEditContext::NewSession { cwd }, + query: title, + cursor, + return_mode: state.return_mode, + }); + self.mode = Mode::TitleEdit; + return Err("title already in use".to_string()); + } + + let escaped_title = title.replace('\'', "'\\''"); + self.mode = Mode::Browsing; + Ok(Some(Action::NewSession(format!("cd '{}' && claude -n '{}'", escaped_cwd, escaped_title)))) + } + } + } + + /// Cancel title editing. + pub fn cancel_title_edit(&mut self) { + if let Some(state) = self.title_edit.take() { + self.mode = state.return_mode; + } + } + + /// Get the display label for a session: custom_title if set, otherwise first_message. + pub fn session_display_label(&self, session: &Session) -> String { + session.custom_title.clone().unwrap_or_else(|| session.first_message.clone()) + } + + /// Find a session by ID across sessions and content_results. + fn find_session(&self, session_id: &str) -> Option<&Session> { + self.sessions.iter() + .chain(self.content_results.iter()) + .find(|s| s.id == session_id) + } + + /// Update custom_title on a session in memory. + fn update_session_title(&mut self, session_id: &str, title: Option) { + for s in self.sessions.iter_mut().chain(self.content_results.iter_mut()) { + if s.id == session_id { + s.custom_title = title; + return; + } + } + } + /// Set a status message that disappears after a few seconds. #[allow(dead_code)] pub fn set_status(&mut self, msg: String) { @@ -391,7 +835,7 @@ impl App { } /// Run the interactive TUI session picker. -pub fn run(sessions: Vec, theme: Theme) -> Result<(), Box> { +pub fn run(sessions: Vec, theme: Theme, grouped_view: bool) -> Result<(), Box> { if sessions.is_empty() { eprintln!("No sessions found."); return Ok(()); @@ -412,7 +856,7 @@ pub fn run(sessions: Vec, theme: Theme) -> Result<(), Box = None; loop { @@ -439,13 +883,36 @@ pub fn run(sessions: Vec, theme: Theme) -> Result<(), Box { app.enter_conversation(idx); } - Action::CopyCommand(cmd) => match clipboard::copy_to_clipboard(&cmd) { - Ok(()) => break, - Err(_) => { - deferred_command = Some(cmd); - break; + Action::CopyCommand(cmd) | Action::NewSession(cmd) | Action::ForkSession(cmd) => { + deferred_command = Some(cmd); + break; + } + Action::ArchiveSession(display_idx) => { + match app.archive_session(display_idx) { + Ok(label) => { + app.set_status(format!("Archived: {}", label)); + if app.selected >= app.visible_row_count() && app.selected > 0 { + app.selected -= 1; + } + } + Err(e) => { + app.set_status(format!("Archive failed: {}", e)); + } + } + } + Action::MoveSession { display_idx, target_project, target_cwd } => { + match app.move_session(display_idx, &target_project, &target_cwd) { + Ok(label) => { + app.set_status(format!("Moved: {}", label)); + if app.selected >= app.visible_row_count() && app.selected > 0 { + app.selected -= 1; + } + } + Err(e) => { + app.set_status(format!("Move failed: {}", e)); + } } - }, + } Action::BackToList => { app.leave_conversation(); } @@ -460,7 +927,19 @@ pub fn run(sessions: Vec, theme: Theme) -> Result<(), Box std::process::exit(s.code().unwrap_or(1)), + Err(e) => { + eprintln!("Failed to exec: {e}"); + println!("{cmd}"); + } + } } Ok(()) diff --git a/src/tui/view.rs b/src/tui/view.rs index 171d84a..e9666a1 100644 --- a/src/tui/view.rs +++ b/src/tui/view.rs @@ -7,13 +7,16 @@ use ratatui::widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientatio use crate::session::{ConversationMessage, MessageRole}; use super::table; -use super::{App, ContentSearchState, Mode}; +use super::{App, ContentSearchState, Mode, TreeRow}; /// Render the full TUI frame. pub fn render(frame: &mut Frame, app: &mut App) { let area = frame.area(); - if app.mode == Mode::Conversation || app.mode == Mode::ConversationSearch { + let in_conversation = app.mode == Mode::Conversation + || app.mode == Mode::ConversationSearch + || (app.mode == Mode::TitleEdit && app.conversation.is_some()); + if in_conversation { render_conversation(frame, app, area); return; } @@ -25,13 +28,26 @@ pub fn render(frame: &mut Frame, app: &mut App) { render_session_list(frame, app, chunks[0]); render_status_bar(frame, app, chunks[1]); + + if app.mode == Mode::MoveSelectProject { + render_move_picker(frame, app, area); + } } /// Render the session list with single-line entries. fn render_session_list(frame: &mut Frame, app: &App, area: Rect) { - let width = area.width.saturating_sub(2) as usize; // account for left/right borders + if app.grouped_view { + render_grouped_session_list(frame, app, area); + } else { + render_flat_session_list(frame, app, area); + } +} + +/// Render the flat (ungrouped) session list. +fn render_flat_session_list(frame: &mut Frame, app: &App, area: Rect) { + let width = area.width.saturating_sub(2) as usize; let height = area.height as usize; - let visible_items = height.saturating_sub(2); // account for top/bottom borders + let visible_items = height.saturating_sub(2); let mut lines: Vec = Vec::new(); let terms = search_terms(app); @@ -56,8 +72,9 @@ fn render_session_list(frame: &mut Frame, app: &App, area: Rect) { (" ", 2) }; + let label = app.session_display_label(session); let max_msg_len = width.saturating_sub(cursor_len + right_len + 2); - let msg = truncate_str(&session.first_message, max_msg_len); + let msg = truncate_str(&label, max_msg_len); let msg_len = msg.chars().count(); let pad = width.saturating_sub(cursor_len + msg_len + right_len); let padding = " ".repeat(pad); @@ -69,7 +86,6 @@ fn render_session_list(frame: &mut Frame, app: &App, area: Rect) { }; let dim = Style::default().fg(app.theme.text_dim); - let cursor_style = Style::default().fg(app.theme.cursor_color); let mut spans = vec![Span::styled(cursor, cursor_style)]; @@ -101,7 +117,6 @@ fn render_session_list(frame: &mut Frame, app: &App, area: Rect) { let paragraph = Paragraph::new(text).block(block); frame.render_widget(paragraph, area); - // Render vertical scrollbar let total = app.display_entries.len(); let visible = area.height.saturating_sub(2) as usize; if total > visible { @@ -122,6 +137,206 @@ fn render_session_list(frame: &mut Frame, app: &App, area: Rect) { } } +/// Render the grouped (tree) session list with collapsible project headers. +fn render_grouped_session_list(frame: &mut Frame, app: &App, area: Rect) { + let width = area.width.saturating_sub(2) as usize; + let height = area.height as usize; + let visible_items = height.saturating_sub(2); + let mut lines: Vec = Vec::new(); + + let terms = search_terms(app); + let term_refs: Vec<&str> = terms.iter().map(|s| s.as_str()).collect(); + + let start = app.scroll_offset; + let end = (start + visible_items).min(app.tree_rows.len()); + + for i in start..end { + let row = &app.tree_rows[i]; + let is_selected = i == app.selected; + + match row { + TreeRow::Project(gi) => { + let group = &app.project_groups[*gi]; + let icon = if group.expanded { "\u{25BC}" } else { "\u{25B6}" }; + let count = group.session_indices.len(); + let label = format!("{} {} ({})", icon, group.name, count); + let label_len = label.chars().count(); + let pad = width.saturating_sub(label_len + 1); + let padding = " ".repeat(pad); + + let style = if is_selected { + Style::default().fg(app.theme.cursor_color).bold() + } else { + Style::default().fg(app.theme.heading).bold() + }; + + let line = Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(label, style), + Span::raw(padding), + ]); + + if is_selected { + lines.push(line.patch_style(Style::default().bg(app.theme.selected_bg))); + } else { + lines.push(line); + } + } + TreeRow::Session { display_idx, .. } => { + let entry = &app.display_entries[*display_idx]; + let session = app.display_session(entry); + + let delta = Utc::now().signed_duration_since(session.timestamp); + let time_ago = HumanTime::from(-delta).to_text_en(Accuracy::Rough, Tense::Past); + let right = format!(" {}", time_ago); + let right_len = right.len(); + + let indent = " "; + let indent_len = 4; + let (cursor, cursor_len) = if is_selected { + ("\u{27A4} ", 2) + } else { + ("- ", 2) + }; + + let label = app.session_display_label(session); + let max_msg_len = width.saturating_sub(indent_len + cursor_len + right_len + 2); + let msg = truncate_str(&label, max_msg_len); + let msg_len = msg.chars().count(); + let pad = width.saturating_sub(indent_len + cursor_len + msg_len + right_len); + let padding = " ".repeat(pad); + + let msg_style = if is_selected { + Style::default().fg(Color::White) + } else { + Style::default().fg(app.theme.text) + }; + + let dim = Style::default().fg(app.theme.text_dim); + let cursor_style = Style::default().fg(app.theme.cursor_color); + + let mut spans = vec![ + Span::raw(indent), + Span::styled(cursor, cursor_style), + ]; + spans.extend(highlight_terms(&msg, &term_refs, msg_style, &app.theme)); + spans.push(Span::raw(padding)); + spans.push(Span::styled(right, dim)); + + let line = Line::from(spans); + + if is_selected { + lines.push(line.patch_style(Style::default().bg(app.theme.selected_bg))); + } else { + lines.push(line); + } + } + } + } + + let session_count: usize = app.project_groups.iter() + .map(|g| g.session_indices.len()) + .sum(); + let text = Text::from(lines); + let border_style = Style::default().fg(app.theme.text_dim); + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title(format!( + " cc-session ({}/{}) \u{2500} {} projects ", + session_count, + app.sessions.len(), + app.project_groups.len(), + )) + .title_style(Style::default().fg(app.theme.cursor_color).bold()); + + let paragraph = Paragraph::new(text).block(block); + frame.render_widget(paragraph, area); + + let total = app.tree_rows.len(); + let visible = area.height.saturating_sub(2) as usize; + if total > visible { + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .thumb_style(Style::default().fg(app.theme.text_dim)) + .begin_symbol(None) + .end_symbol(None); + let mut scrollbar_state = + ScrollbarState::new(total.saturating_sub(visible)).position(app.scroll_offset); + frame.render_stateful_widget( + scrollbar, + area.inner(Margin { + vertical: 1, + horizontal: 0, + }), + &mut scrollbar_state, + ); + } +} + +/// Render the move-to-project picker as a centered overlay. +fn render_move_picker(frame: &mut Frame, app: &App, area: Rect) { + let state = match &app.move_state { + Some(s) => s, + None => return, + }; + + let max_w = 50u16.min(area.width.saturating_sub(4)); + let max_h = (state.projects.len() as u16 + 2).min(area.height.saturating_sub(4)).max(4); + let x = (area.width.saturating_sub(max_w)) / 2; + let y = (area.height.saturating_sub(max_h)) / 2; + let popup_area = Rect::new(x, y, max_w, max_h); + + let clear = ratatui::widgets::Clear; + frame.render_widget(clear, popup_area); + + let inner_w = max_w.saturating_sub(2) as usize; + let inner_h = max_h.saturating_sub(2) as usize; + + let visible_start = if state.selected >= inner_h { + state.selected - inner_h + 1 + } else { + 0 + }; + let visible_end = (visible_start + inner_h).min(state.projects.len()); + + let mut lines: Vec = Vec::new(); + for i in visible_start..visible_end { + let (_, ref name, _) = state.projects[i]; + let is_sel = i == state.selected; + let (prefix, style) = if is_sel { + ("\u{27A4} ", Style::default().fg(app.theme.cursor_color).bold()) + } else { + (" ", Style::default().fg(app.theme.text)) + }; + let label = if name.chars().count() + 2 > inner_w { + let truncated: String = name.chars().take(inner_w.saturating_sub(5)).collect(); + format!("{truncated}...") + } else { + name.clone() + }; + let pad = inner_w.saturating_sub(prefix.chars().count() + label.chars().count()); + let line = Line::from(vec![ + Span::styled(prefix, style), + Span::styled(label, style), + Span::raw(" ".repeat(pad)), + ]); + if is_sel { + lines.push(line.patch_style(Style::default().bg(app.theme.selected_bg))); + } else { + lines.push(line); + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(app.theme.cursor_color)) + .title(" Move to project ") + .title_style(Style::default().fg(app.theme.cursor_color).bold()); + + let paragraph = Paragraph::new(Text::from(lines)).block(block); + frame.render_widget(paragraph, popup_area); +} + const MAX_CONTENT_WIDTH: u16 = 120; /// Render the conversation viewer. @@ -134,29 +349,34 @@ fn render_conversation(frame: &mut Frame, app: &mut App, area: Rect) { let full_content_area = chunks[0]; let status_area = chunks[1]; - // Build title: show match position during search, nothing otherwise - let title_extra = if let Some(conv) = &app.conversation { + // Build title bar: session name + search match info + let session_title = app.conversation.as_ref() + .and_then(|conv| conv.session.custom_title.as_deref()) + .unwrap_or(""); + + let search_info = if let Some(conv) = &app.conversation { let has_search = conv.search_confirmed || !conv.initial_search_terms.is_empty(); if has_search && !conv.match_positions.is_empty() { - format!( - " ({}/{}) ", - conv.current_match + 1, - conv.match_positions.len() - ) + format!(" ({}/{})", conv.current_match + 1, conv.match_positions.len()) } else { - String::from(" ") + String::new() } } else { - String::from(" ") + String::new() + }; + + let title_text = if session_title.is_empty() { + format!(" cc-session{search_info} ") + } else { + format!(" {session_title}{search_info} ") }; - // Draw border around the full area with title let border_style = Style::default().fg(app.theme.text_dim); let title_style = Style::default().fg(app.theme.cursor_color).bold(); let block = Block::default() .borders(Borders::ALL) .border_style(border_style) - .title(format!(" cc-session{title_extra}")) + .title(title_text) .title_style(title_style); let inner_area = block.inner(full_content_area); frame.render_widget(block, full_content_area); @@ -273,7 +493,9 @@ fn render_conversation(frame: &mut Frame, app: &mut App, area: Rect) { /// Render the conversation viewer status bar. fn render_conversation_status(frame: &mut Frame, app: &App, area: Rect) { - let content = if let Some(conv) = &app.conversation { + let content = if app.mode == Mode::TitleEdit { + render_title_edit_bar(app) + } else if let Some(conv) = &app.conversation { let dim = Style::default().fg(app.theme.text_dim); let label_style = Style::default() .fg(app.theme.status_label_fg) @@ -351,7 +573,7 @@ fn render_conversation_status(frame: &mut Frame, app: &App, area: Rect) { ), Span::raw(" "), Span::styled( - "Space/b scroll g/G top/bottom / search Enter copy & exit Esc back", + "Space/b scroll / search t title f fork Enter resume Esc back", dim, ), ]) @@ -689,13 +911,88 @@ fn wrap_line(line: &str, width: usize) -> Vec { result } +/// Render the title edit input bar. +fn render_title_edit_bar(app: &App) -> Line<'static> { + let dim = Style::default().fg(app.theme.text_dim); + let label_style = Style::default() + .fg(app.theme.status_label_fg) + .bg(app.theme.status_label_bg) + .bold(); + + if let Some(state) = &app.title_edit { + let is_new = matches!(state.context, super::TitleEditContext::NewSession { .. }); + let label = if is_new { " New session: " } else { " Title: " }; + let hint = if is_new { + "Enter create Esc cancel (empty = no name)" + } else { + "Enter save Esc cancel (clear to remove title)" + }; + + let mut spans = vec![ + Span::styled(label.to_string(), label_style), + Span::styled(" ", Style::default()), + ]; + + let cursor_pos = state.cursor.min(state.query.len()); + let (before, rest) = state.query.split_at(cursor_pos); + if !before.is_empty() { + spans.push(Span::styled(before.to_string(), Style::default().fg(Color::White))); + } + if let Some(ch) = rest.chars().next() { + spans.push(Span::styled( + ch.to_string(), + Style::default().fg(Color::Black).bg(Color::White), + )); + let after = &rest[ch.len_utf8()..]; + if !after.is_empty() { + spans.push(Span::styled(after.to_string(), Style::default().fg(Color::White))); + } + } else { + spans.push(Span::styled( + " ".to_string(), + Style::default().fg(Color::Black).bg(Color::White), + )); + } + + spans.push(Span::raw(" ")); + spans.push(Span::styled(hint, dim)); + + Line::from(spans) + } else { + Line::from("") + } +} + /// Render the status/help bar at the bottom. fn render_status_bar(frame: &mut Frame, app: &App, area: Rect) { let dim = Style::default().fg(app.theme.text_dim); let content = match app.mode { Mode::Conversation | Mode::ConversationSearch => Line::from(""), - Mode::Browsing => { - if let Some((msg, _)) = &app.status_message { + Mode::TitleEdit => render_title_edit_bar(app), + Mode::MoveSelectProject => { + let key_style = Style::default().fg(Color::White).bold(); + Line::from(vec![ + Span::styled(" Select target project: ", Style::default().fg(app.theme.cursor_color).bold()), + Span::styled("↑↓", key_style), + Span::styled(" navigate ", dim), + Span::styled("Enter", key_style), + Span::styled(" confirm ", dim), + Span::styled("Esc", key_style), + Span::styled(" cancel", dim), + ]) + } + Mode::ConfirmArchive | Mode::Browsing => { + if app.archive_confirm.is_some() { + let warn_style = Style::default().fg(Color::Yellow).bold(); + let key_style = Style::default().fg(Color::White).bold(); + Line::from(vec![ + Span::styled(" Archive this session? ", warn_style), + Span::styled("y", key_style), + Span::styled(" yes ", dim), + Span::styled("any other key", key_style), + Span::styled(" cancel", dim), + ]) + } else if let Some((msg, _)) = &app.status_message { Line::from(vec![Span::styled( format!(" {msg}"), Style::default().fg(Color::Green).bold(), @@ -734,13 +1031,32 @@ fn render_status_bar(frame: &mut Frame, app: &App, area: Rect) { Span::styled("Esc clear Enter select", dim), ]) } else { + let view_hint = if app.grouped_view { + "Tab flat view" + } else { + "Tab grouped view" + }; Line::from(vec![ Span::styled(" Enter ", dim), Span::styled("detail", dim), Span::raw(" "), + Span::styled("t ", dim), + Span::styled("title", dim), + Span::raw(" "), + Span::styled("n ", dim), + Span::styled("new", dim), + Span::raw(" "), + Span::styled("a ", dim), + Span::styled("archive", dim), + Span::raw(" "), + Span::styled("m ", dim), + Span::styled("move", dim), + Span::raw(" "), Span::styled("Esc ", dim), Span::styled("quit", dim), Span::raw(" "), + Span::styled(view_hint, dim), + Span::raw(" "), Span::styled("(type to search)", dim), ]) } diff --git a/tests/grouping_test.rs b/tests/grouping_test.rs new file mode 100644 index 0000000..df06235 --- /dev/null +++ b/tests/grouping_test.rs @@ -0,0 +1,162 @@ +use std::path::PathBuf; + +use cc_session::discovery::discover_sessions; +use cc_session::filter::filter_sessions; +use cc_session::tui::{ + build_tree_rows, group_by_project, DisplayEntry, DisplaySource, MatchType, TreeRow, +}; + +fn fixture_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") +} + +fn make_display_entries(sessions: &[cc_session::session::Session]) -> Vec { + sessions + .iter() + .enumerate() + .map(|(idx, s)| DisplayEntry { + match_type: MatchType::Metadata, + source: DisplaySource::Sessions(idx), + timestamp: s.timestamp, + }) + .collect() +} + +#[test] +fn group_by_project_creates_correct_groups() { + let sessions = discover_sessions(&fixture_dir()); + let entries = make_display_entries(&sessions); + let groups = group_by_project(&sessions, &entries); + assert_eq!(groups.len(), 2, "should create 2 project groups"); + + let names: Vec<&str> = groups.iter().map(|g| g.name.as_str()).collect(); + assert!(names.contains(&"project-a")); + assert!(names.contains(&"project-b")); +} + +#[test] +fn group_by_project_correct_session_counts() { + let sessions = discover_sessions(&fixture_dir()); + let entries = make_display_entries(&sessions); + let groups = group_by_project(&sessions, &entries); + + let group_a = groups.iter().find(|g| g.name == "project-a").unwrap(); + let group_b = groups.iter().find(|g| g.name == "project-b").unwrap(); + + assert_eq!(group_a.session_indices.len(), 2, "project-a should have 2 sessions"); + assert_eq!(group_b.session_indices.len(), 1, "project-b should have 1 session"); +} + +#[test] +fn groups_sorted_by_latest_activity() { + let sessions = discover_sessions(&fixture_dir()); + let entries = make_display_entries(&sessions); + let groups = group_by_project(&sessions, &entries); + + // project-a has newer sessions (2025-02-20) than project-b (2025-02-18) + assert_eq!(groups[0].name, "project-a", "newest project should be first"); + assert_eq!(groups[1].name, "project-b", "older project should be second"); +} + +#[test] +fn groups_default_to_collapsed() { + let sessions = discover_sessions(&fixture_dir()); + let entries = make_display_entries(&sessions); + let groups = group_by_project(&sessions, &entries); + + for group in &groups { + assert!(!group.expanded, "groups should default to collapsed"); + } +} + +#[test] +fn build_tree_rows_default_collapsed() { + let sessions = discover_sessions(&fixture_dir()); + let entries = make_display_entries(&sessions); + let groups = group_by_project(&sessions, &entries); + let rows = build_tree_rows(&groups); + + // Default is collapsed: only 2 project headers + assert_eq!(rows.len(), 2, "default collapsed: only 2 project headers"); + assert!(matches!(rows[0], TreeRow::Project(0))); + assert!(matches!(rows[1], TreeRow::Project(1))); +} + +#[test] +fn build_tree_rows_all_expanded() { + let sessions = discover_sessions(&fixture_dir()); + let entries = make_display_entries(&sessions); + let mut groups = group_by_project(&sessions, &entries); + + groups[0].expanded = true; + groups[1].expanded = true; + let rows = build_tree_rows(&groups); + + // 2 project headers + 3 sessions = 5 rows + assert_eq!(rows.len(), 5, "all expanded: 2 headers + 3 sessions"); + + assert!(matches!(rows[0], TreeRow::Project(0))); + assert!(matches!(rows[1], TreeRow::Session { project_idx: 0, .. })); + assert!(matches!(rows[2], TreeRow::Session { project_idx: 0, .. })); + assert!(matches!(rows[3], TreeRow::Project(1))); + assert!(matches!(rows[4], TreeRow::Session { project_idx: 1, .. })); +} + +#[test] +fn build_tree_rows_one_expanded() { + let sessions = discover_sessions(&fixture_dir()); + let entries = make_display_entries(&sessions); + let mut groups = group_by_project(&sessions, &entries); + + // Expand only project-b (second group) + groups[1].expanded = true; + let rows = build_tree_rows(&groups); + + // 2 project headers + 1 session from project-b = 3 rows + assert_eq!(rows.len(), 3, "one expanded: 2 headers + 1 session"); + + assert!(matches!(rows[0], TreeRow::Project(0))); + assert!(matches!(rows[1], TreeRow::Project(1))); + assert!(matches!(rows[2], TreeRow::Session { project_idx: 1, .. })); +} + +#[test] +fn empty_sessions_no_groups() { + let groups = group_by_project(&[], &[]); + assert!(groups.is_empty()); + let rows = build_tree_rows(&groups); + assert!(rows.is_empty()); +} + +#[test] +fn filter_only_shows_matching_groups() { + let sessions = discover_sessions(&fixture_dir()); + let filtered = filter_sessions(&sessions, "project-b"); + + let entries: Vec = filtered + .iter() + .map(|&idx| DisplayEntry { + match_type: MatchType::Metadata, + source: DisplaySource::Sessions(idx), + timestamp: sessions[idx].timestamp, + }) + .collect(); + + let groups = group_by_project(&sessions, &entries); + assert_eq!(groups.len(), 1, "filter should leave only project-b"); + assert_eq!(groups[0].name, "project-b"); +} + +#[test] +fn tree_row_equality() { + assert_eq!(TreeRow::Project(0), TreeRow::Project(0)); + assert_ne!(TreeRow::Project(0), TreeRow::Project(1)); + assert_eq!( + TreeRow::Session { project_idx: 0, display_idx: 1 }, + TreeRow::Session { project_idx: 0, display_idx: 1 } + ); + assert_ne!( + TreeRow::Session { project_idx: 0, display_idx: 1 }, + TreeRow::Session { project_idx: 0, display_idx: 2 } + ); +} diff --git a/tests/tui_input_test.rs b/tests/tui_input_test.rs new file mode 100644 index 0000000..fcf5e31 --- /dev/null +++ b/tests/tui_input_test.rs @@ -0,0 +1,418 @@ +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use cc_session::discovery::discover_sessions; +use cc_session::theme::Theme; +use cc_session::tui::{input::handle_input, Action, App, Mode, TreeRow}; + +fn fixture_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") +} + +fn make_app(grouped: bool) -> App { + let sessions = discover_sessions(&fixture_dir()); + let session_index = HashMap::new(); + let theme = Theme::dark(); + App::new(sessions, session_index, theme, grouped) +} + +fn make_app_expanded() -> App { + let mut app = make_app(true); + for i in 0..app.project_groups.len() { + if !app.project_groups[i].expanded { + app.toggle_project(i); + } + } + app.selected = 0; + app +} + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +#[test] +fn tab_toggles_grouped_view() { + let mut app = make_app(true); + assert!(app.grouped_view); + + let action = handle_input(&mut app, key(KeyCode::Tab)); + assert!(matches!(action, Action::Continue)); + assert!(!app.grouped_view, "Tab should switch to flat view"); + + let action = handle_input(&mut app, key(KeyCode::Tab)); + assert!(matches!(action, Action::Continue)); + assert!(app.grouped_view, "Tab should switch back to grouped view"); +} + +#[test] +fn enter_on_project_toggles_expand() { + let mut app = make_app(true); + assert!(matches!(app.tree_rows[0], TreeRow::Project(0))); + assert!(!app.project_groups[0].expanded, "should start collapsed"); + + app.selected = 0; + let action = handle_input(&mut app, key(KeyCode::Enter)); + assert!(matches!(action, Action::Continue)); + assert!(app.project_groups[0].expanded, "Enter should expand project"); + + let action = handle_input(&mut app, key(KeyCode::Enter)); + assert!(matches!(action, Action::Continue)); + assert!(!app.project_groups[0].expanded, "Enter should collapse project"); +} + +#[test] +fn enter_on_session_enters_conversation() { + let mut app = make_app_expanded(); + app.selected = 1; + assert!(matches!(app.tree_rows[1], TreeRow::Session { .. })); + + let action = handle_input(&mut app, key(KeyCode::Enter)); + match action { + Action::EnterConversation(idx) => { + assert!(idx < app.display_entries.len()); + } + _ => panic!("Expected EnterConversation action on session row"), + } +} + +#[test] +fn left_arrow_collapses_project() { + let mut app = make_app_expanded(); + app.selected = 0; + assert!(app.project_groups[0].expanded); + + handle_input(&mut app, key(KeyCode::Left)); + assert!(!app.project_groups[0].expanded, "Left should collapse expanded project"); +} + +#[test] +fn left_arrow_on_collapsed_project_is_noop() { + let mut app = make_app(true); + app.selected = 0; + assert!(!app.project_groups[0].expanded); + + handle_input(&mut app, key(KeyCode::Left)); + assert!(!app.project_groups[0].expanded, "Left on collapsed project should be noop"); +} + +#[test] +fn right_arrow_expands_project() { + let mut app = make_app(true); + app.selected = 0; + assert!(!app.project_groups[0].expanded); + + handle_input(&mut app, key(KeyCode::Right)); + assert!(app.project_groups[0].expanded, "Right should expand collapsed project"); +} + +#[test] +fn right_arrow_on_expanded_project_is_noop() { + let mut app = make_app_expanded(); + app.selected = 0; + assert!(app.project_groups[0].expanded); + + handle_input(&mut app, key(KeyCode::Right)); + assert!(app.project_groups[0].expanded, "Right on expanded project should be noop"); +} + +#[test] +fn left_arrow_on_session_jumps_to_parent_project() { + let mut app = make_app_expanded(); + app.selected = 2; + assert!(matches!(app.tree_rows[2], TreeRow::Session { project_idx: 0, .. })); + + handle_input(&mut app, key(KeyCode::Left)); + assert_eq!(app.selected, 0, "Left on session should jump to parent project"); + assert!(matches!(app.tree_rows[0], TreeRow::Project(0))); +} + +#[test] +fn up_down_navigates_tree_rows() { + let mut app = make_app(true); + let total = app.tree_rows.len(); + assert!(total >= 2); + + app.selected = 0; + handle_input(&mut app, key(KeyCode::Down)); + assert_eq!(app.selected, 1); + + handle_input(&mut app, key(KeyCode::Up)); + assert_eq!(app.selected, 0); + + // Up at top stays at 0 + handle_input(&mut app, key(KeyCode::Up)); + assert_eq!(app.selected, 0); +} + +#[test] +fn end_key_goes_to_last_row_in_grouped_view() { + let mut app = make_app(true); + let total = app.tree_rows.len(); + + handle_input(&mut app, key(KeyCode::End)); + assert_eq!(app.selected, total - 1); +} + +#[test] +fn flat_view_enter_enters_conversation() { + let mut app = make_app(false); + app.selected = 0; + + let action = handle_input(&mut app, key(KeyCode::Enter)); + match action { + Action::EnterConversation(idx) => { + assert_eq!(idx, 0); + } + _ => panic!("Expected EnterConversation in flat view"), + } +} + +#[test] +fn visible_row_count_reflects_view_mode() { + let mut app = make_app(true); + let grouped_count = app.visible_row_count(); + assert_eq!(grouped_count, app.tree_rows.len()); + + app.toggle_view(); + let flat_count = app.visible_row_count(); + assert_eq!(flat_count, app.display_entries.len()); +} + +// ---- Archive tests ---- + +#[test] +fn a_key_sets_archive_confirm() { + let mut app = make_app(false); + app.selected = 0; + assert!(app.archive_confirm.is_none()); + + let action = handle_input(&mut app, key(KeyCode::Char('a'))); + assert!(matches!(action, Action::Continue)); + assert_eq!(app.archive_confirm, Some(0)); +} + +#[test] +fn a_key_in_grouped_view_on_session_sets_confirm() { + let mut app = make_app_expanded(); + app.selected = 1; + assert!(matches!(app.tree_rows[1], TreeRow::Session { .. })); + + let action = handle_input(&mut app, key(KeyCode::Char('a'))); + assert!(matches!(action, Action::Continue)); + assert!(app.archive_confirm.is_some()); +} + +#[test] +fn a_key_in_grouped_view_on_project_is_noop() { + let mut app = make_app(true); + app.selected = 0; + assert!(matches!(app.tree_rows[0], TreeRow::Project(_))); + + let action = handle_input(&mut app, key(KeyCode::Char('a'))); + assert!(matches!(action, Action::Continue)); + assert!(app.archive_confirm.is_none()); +} + +#[test] +fn archive_confirm_y_triggers_archive() { + let mut app = make_app(false); + app.archive_confirm = Some(0); + app.mode = Mode::ConfirmArchive; + + let action = handle_input(&mut app, key(KeyCode::Char('y'))); + assert!(matches!(action, Action::ArchiveSession(0))); + assert!(app.archive_confirm.is_none()); + assert!(matches!(app.mode, Mode::Browsing)); +} + +#[test] +fn archive_confirm_esc_cancels() { + let mut app = make_app(false); + app.archive_confirm = Some(0); + app.mode = Mode::ConfirmArchive; + + let action = handle_input(&mut app, key(KeyCode::Esc)); + assert!(matches!(action, Action::Continue)); + assert!(app.archive_confirm.is_none()); + assert!(matches!(app.mode, Mode::Browsing)); +} + +#[test] +fn archive_confirm_other_key_cancels() { + let mut app = make_app(false); + app.archive_confirm = Some(0); + app.mode = Mode::ConfirmArchive; + + let action = handle_input(&mut app, key(KeyCode::Char('n'))); + assert!(matches!(action, Action::Continue)); + assert!(app.archive_confirm.is_none()); + assert!(matches!(app.mode, Mode::Browsing)); +} + +#[test] +fn a_key_ignored_when_filter_active() { + let mut app = make_app(false); + app.filter_active = true; + app.selected = 0; + + let action = handle_input(&mut app, key(KeyCode::Char('a'))); + assert!(matches!(action, Action::Continue)); + assert!(app.archive_confirm.is_none(), "should not trigger archive when filtering"); +} + +#[test] +fn archive_session_moves_file() { + let tmp = tempfile::tempdir().unwrap(); + let project_dir = tmp.path().join("projects").join("-Users-test-myproject"); + fs::create_dir_all(&project_dir).unwrap(); + + let session_id = "abc123"; + let session_file = project_dir.join(format!("{session_id}.jsonl")); + fs::write(&session_file, r#"{"type":"user","cwd":"/Users/test/myproject","sessionId":"abc123","message":{"role":"user","content":"hello"},"uuid":"u1","timestamp":"2025-01-01T00:00:00.000Z"} +{"type":"assistant","cwd":"/Users/test/myproject","sessionId":"abc123","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]},"uuid":"u2","timestamp":"2025-01-01T00:01:00.000Z"} +"#).unwrap(); + + std::env::set_var("CLAUDE_HOME", tmp.path().to_str().unwrap()); + + let sessions = discover_sessions(tmp.path()); + assert!(!sessions.is_empty(), "should discover the test session"); + + let session_index = HashMap::new(); + let theme = Theme::dark(); + let mut app = App::new(sessions, session_index, theme, false); + assert!(!app.display_entries.is_empty()); + + let result = app.archive_session(0); + assert!(result.is_ok(), "archive should succeed: {:?}", result); + + assert!(!session_file.exists(), "original file should be moved"); + + let archive_file = tmp.path() + .join("projects-archive") + .join("-Users-test-myproject") + .join(format!("{session_id}.jsonl")); + assert!(archive_file.exists(), "file should exist in archive"); + + std::env::remove_var("CLAUDE_HOME"); +} + +#[test] +fn m_key_on_session_starts_move() { + let mut app = make_app(false); + app.selected = 0; + + let action = handle_input(&mut app, key(KeyCode::Char('m'))); + assert!(matches!(action, Action::Continue)); + assert!(matches!(app.mode, Mode::MoveSelectProject)); + assert!(app.move_state.is_some()); +} + +#[test] +fn m_key_ignored_when_filter_active() { + let mut app = make_app(false); + app.filter_active = true; + app.selected = 0; + + let action = handle_input(&mut app, key(KeyCode::Char('m'))); + assert!(matches!(action, Action::Continue)); + assert!(app.move_state.is_none(), "should not start move when filtering"); +} + +#[test] +fn m_key_in_grouped_view_on_project_is_noop() { + let mut app = make_app(true); + app.selected = 0; + assert!(matches!(app.tree_rows[0], TreeRow::Project(_))); + + let action = handle_input(&mut app, key(KeyCode::Char('m'))); + assert!(matches!(action, Action::Continue)); + assert!(app.move_state.is_none()); +} + +#[test] +fn move_picker_esc_cancels() { + let mut app = make_app(false); + app.selected = 0; + handle_input(&mut app, key(KeyCode::Char('m'))); + assert!(matches!(app.mode, Mode::MoveSelectProject)); + + let action = handle_input(&mut app, key(KeyCode::Esc)); + assert!(matches!(action, Action::Continue)); + assert!(app.move_state.is_none()); + assert!(matches!(app.mode, Mode::Browsing)); +} + +#[test] +fn move_picker_navigation() { + let mut app = make_app(false); + app.selected = 0; + handle_input(&mut app, key(KeyCode::Char('m'))); + + let project_count = app.move_state.as_ref().unwrap().projects.len(); + if project_count > 1 { + handle_input(&mut app, key(KeyCode::Down)); + assert_eq!(app.move_state.as_ref().unwrap().selected, 1); + + handle_input(&mut app, key(KeyCode::Up)); + assert_eq!(app.move_state.as_ref().unwrap().selected, 0); + } +} + +#[test] +fn move_picker_enter_triggers_move() { + let mut app = make_app(false); + app.selected = 0; + handle_input(&mut app, key(KeyCode::Char('m'))); + + if app.move_state.is_some() { + let action = handle_input(&mut app, key(KeyCode::Enter)); + assert!(matches!(action, Action::MoveSession { .. })); + assert!(matches!(app.mode, Mode::Browsing)); + } +} + +#[test] +fn move_session_moves_file() { + let tmp = tempfile::tempdir().unwrap(); + let src_dir = tmp.path().join("projects").join("-Users-test-src-project"); + let dst_dir_name = "-Users-test-dst-project"; + fs::create_dir_all(&src_dir).unwrap(); + fs::create_dir_all(tmp.path().join("projects").join(dst_dir_name)).unwrap(); + + let session_id = "move-test-123"; + let session_file = src_dir.join(format!("{session_id}.jsonl")); + fs::write(&session_file, r#"{"type":"user","cwd":"/Users/test/src-project","sessionId":"move-test-123","message":{"role":"user","content":"test move"},"uuid":"u1","timestamp":"2025-01-01T00:00:00.000Z"} +{"type":"assistant","cwd":"/Users/test/src-project","sessionId":"move-test-123","message":{"role":"assistant","content":[{"type":"text","text":"ok"}]},"uuid":"u2","timestamp":"2025-01-01T00:01:00.000Z"} +"#).unwrap(); + + std::env::set_var("CLAUDE_HOME", tmp.path().to_str().unwrap()); + + let sessions = discover_sessions(tmp.path()); + assert!(!sessions.is_empty(), "should discover the test session"); + + let session_index = HashMap::new(); + let theme = Theme::dark(); + let mut app = App::new(sessions, session_index, theme, false); + + let target_cwd = "/Users/test/dst-project"; + let result = app.move_session(0, dst_dir_name, target_cwd); + assert!(result.is_ok(), "move should succeed: {:?}", result); + + assert!(!session_file.exists(), "original file should be gone"); + + let moved_file = tmp.path() + .join("projects") + .join(dst_dir_name) + .join(format!("{session_id}.jsonl")); + assert!(moved_file.exists(), "file should exist in target project"); + + let content = fs::read_to_string(&moved_file).unwrap(); + assert!(content.contains(target_cwd), "cwd should be updated to target"); + assert!(!content.contains("/Users/test/src-project"), "old cwd should be replaced"); + + std::env::remove_var("CLAUDE_HOME"); +} From 4268bae3f28188f6ed3cc0dfd0dc85457707f7fa Mon Sep 17 00:00:00 2001 From: mabulgu Date: Fri, 22 May 2026 13:20:43 +0300 Subject: [PATCH 2/5] Fix CodeRabbit review issues: key handling, move safety, index validation - Prevent 'a' key from falling through to filter on non-session rows - Enable 'n' (new session) key in flat view mode - Preserve trailing newline in move_session file rewrite - Propagate remove_file errors instead of silently ignoring - Add defensive session ID validation before index-based removal --- src/tui/input.rs | 21 ++++++++++++++------- src/tui/mod.rs | 24 ++++++++++++++++++------ 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/src/tui/input.rs b/src/tui/input.rs index 8d2a37c..9acf471 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -154,19 +154,26 @@ fn handle_browse(app: &mut App, key: KeyEvent) -> Action { if let Some(idx) = display_idx { app.archive_confirm = Some(idx); app.mode = Mode::ConfirmArchive; - return Action::Continue; } + return Action::Continue; } - if c == 'n' && !app.filter_active && app.grouped_view { - let cwd = match app.selected_tree_row().cloned() { - Some(TreeRow::Project(gi)) => Some(app.project_groups[gi].cwd.clone()), - Some(TreeRow::Session { project_idx, .. }) => Some(app.project_groups[project_idx].cwd.clone()), - None => None, + if c == 'n' && !app.filter_active { + let cwd = if app.grouped_view { + match app.selected_tree_row().cloned() { + Some(TreeRow::Project(gi)) => Some(app.project_groups[gi].cwd.clone()), + Some(TreeRow::Session { project_idx, .. }) => Some(app.project_groups[project_idx].cwd.clone()), + None => None, + } + } else if app.selected < app.display_entries.len() { + let entry = &app.display_entries[app.selected]; + Some(app.display_session(entry).cwd.clone()) + } else { + None }; if let Some(cwd) = cwd.filter(|c| !c.is_empty()) { app.start_new_session_title(cwd); - return Action::Continue; } + return Action::Continue; } if c == 't' && !app.filter_active { let display_idx = if app.grouped_view { diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 7d6e602..4ec5bcb 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -515,10 +515,14 @@ impl App { match entry.source { DisplaySource::Sessions(sidx) => { - self.sessions.remove(sidx); + if sidx < self.sessions.len() && self.sessions[sidx].id == session.id { + self.sessions.remove(sidx); + } } DisplaySource::Content(cidx) => { - self.content_results.remove(cidx); + if cidx < self.content_results.len() && self.content_results[cidx].id == session.id { + self.content_results.remove(cidx); + } } } self.apply_filter(); @@ -581,7 +585,7 @@ impl App { let content = std::fs::read_to_string(&src) .map_err(|e| format!("failed to read session file: {e}"))?; let old_cwd = &session.cwd; - let updated: String = content + let mut updated: String = content .lines() .map(|line| { if let Ok(mut val) = serde_json::from_str::(line) { @@ -597,11 +601,15 @@ impl App { }) .collect::>() .join("\n"); + if !updated.is_empty() && !updated.ends_with('\n') { + updated.push('\n'); + } let dst = dst_dir.join(format!("{}.jsonl", session.id)); std::fs::write(&dst, &updated) .map_err(|e| format!("failed to write moved session: {e}"))?; - let _ = std::fs::remove_file(&src); + std::fs::remove_file(&src) + .map_err(|e| format!("failed to remove original session file: {e}"))?; let label = session.first_message.chars().take(40).collect::(); let target_name = std::path::Path::new(target_cwd) @@ -611,10 +619,14 @@ impl App { match entry.source { DisplaySource::Sessions(sidx) => { - self.sessions.remove(sidx); + if sidx < self.sessions.len() && self.sessions[sidx].id == session.id { + self.sessions.remove(sidx); + } } DisplaySource::Content(cidx) => { - self.content_results.remove(cidx); + if cidx < self.content_results.len() && self.content_results[cidx].id == session.id { + self.content_results.remove(cidx); + } } } self.apply_filter(); From fb42a43fbcc18b8683b9abbacc6c1c48172c6db6 Mon Sep 17 00:00:00 2001 From: mabulgu Date: Thu, 2 Jul 2026 18:19:20 +0300 Subject: [PATCH 3/5] Fix PR review issues: UTF-8 cursor safety, project_path, newline handling - Make cursor navigation UTF-8 safe in title edit and conversation search by tracking byte offsets via char_indices instead of +/- 1 - Fix search_file_with_metadata deriving project_path from filesystem path (encoded dir) instead of raw cwd, matching discover_sessions - Guard append_custom_title against missing trailing newline in JSONL - Skip -n flag for empty custom_title in resume_command - Use chars().count() for right-side label width in session list --- src/search.rs | 2 +- src/session.rs | 8 ++----- src/titles.rs | 9 ++++++++ src/tui/input.rs | 57 ++++++++++++++++++++++++++++++++++++------------ src/tui/view.rs | 11 ++++++---- 5 files changed, 62 insertions(+), 25 deletions(-) diff --git a/src/search.rs b/src/search.rs index 8439bb2..236bd7c 100644 --- a/src/search.rs +++ b/src/search.rs @@ -310,7 +310,7 @@ fn search_file_with_metadata(path: &Path, re: &Regex) -> Option { Some(Session { id: session_id, - project_path: cwd.clone(), + project_path: path.parent()?.file_name()?.to_str()?.to_string(), project_name, git_branch: entry.git_branch, timestamp, diff --git a/src/session.rs b/src/session.rs index bf7d06c..6f3e7d2 100644 --- a/src/session.rs +++ b/src/session.rs @@ -25,18 +25,14 @@ impl Session { pub fn resume_command(&self) -> String { let escaped_cwd = self.cwd.replace('\'', "'\\''"); match &self.custom_title { - Some(title) => { + Some(title) if !title.is_empty() => { let escaped_title = title.replace('\'', "'\\''"); format!("cd '{}' && claude -r {} -n '{}'", escaped_cwd, self.id, escaped_title) } - None => format!("cd '{}' && claude -r {}", escaped_cwd, self.id), + _ => format!("cd '{}' && claude -r {}", escaped_cwd, self.id), } } - pub fn fork_command(&self) -> String { - let escaped_cwd = self.cwd.replace('\'', "'\\''"); - format!("cd '{}' && claude -r {} --fork-session", escaped_cwd, self.id) - } } /// A single line from a session JSONL file. diff --git a/src/titles.rs b/src/titles.rs index 590bb98..6596773 100644 --- a/src/titles.rs +++ b/src/titles.rs @@ -22,11 +22,20 @@ fn append_custom_title(path: &Path, session_id: &str, title: &str) -> Result<(), "sessionId": session_id, }); + let needs_newline = std::fs::read(path) + .map(|bytes| !bytes.is_empty() && !bytes.ends_with(b"\n")) + .unwrap_or(false); + let mut file = OpenOptions::new() .append(true) .open(path) .map_err(|e| format!("failed to open session file: {e}"))?; + if needs_newline { + file.write_all(b"\n") + .map_err(|e| format!("failed to write newline: {e}"))?; + } + writeln!(file, "{}", entry) .map_err(|e| format!("failed to write title: {e}")) } diff --git a/src/tui/input.rs b/src/tui/input.rs index 9acf471..0d2be70 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -312,11 +312,11 @@ fn handle_conversation(app: &mut App, key: KeyEvent) -> Action { } KeyCode::Char('f') => { if let Some(conv) = &app.conversation { - let cmd = conv.session.fork_command(); - Action::ForkSession(cmd) - } else { - Action::Continue + let session_id = conv.session.id.clone(); + let cwd = conv.session.cwd.clone(); + app.start_fork_title(session_id, cwd); } + Action::Continue } KeyCode::Char('/') => { if let Some(conv) = &mut app.conversation { @@ -387,8 +387,13 @@ fn handle_conversation_search(app: &mut App, key: KeyEvent) -> Action { conv.search_cursor = 0; conv.search_replacing = false; } else if conv.search_cursor > 0 { - conv.search_query.remove(conv.search_cursor - 1); - conv.search_cursor -= 1; + let prev = conv.search_query[..conv.search_cursor] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); + conv.search_query.remove(prev); + conv.search_cursor = prev; } conv.rendered_width = 0; } @@ -400,7 +405,11 @@ fn handle_conversation_search(app: &mut App, key: KeyEvent) -> Action { conv.search_replacing = false; conv.search_cursor = 0; } else if conv.search_cursor > 0 { - conv.search_cursor -= 1; + conv.search_cursor = conv.search_query[..conv.search_cursor] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); } } Action::Continue @@ -411,7 +420,11 @@ fn handle_conversation_search(app: &mut App, key: KeyEvent) -> Action { conv.search_replacing = false; conv.search_cursor = conv.search_query.len(); } else if conv.search_cursor < conv.search_query.len() { - conv.search_cursor += 1; + conv.search_cursor = conv.search_query[conv.search_cursor..] + .char_indices() + .nth(1) + .map(|(i, _)| conv.search_cursor + i) + .unwrap_or(conv.search_query.len()); } } Action::Continue @@ -424,7 +437,7 @@ fn handle_conversation_search(app: &mut App, key: KeyEvent) -> Action { conv.search_replacing = false; } conv.search_query.insert(conv.search_cursor, c); - conv.search_cursor += 1; + conv.search_cursor += c.len_utf8(); conv.rendered_width = 0; } Action::Continue @@ -506,22 +519,38 @@ fn handle_title_edit(app: &mut App, key: KeyEvent) -> Action { KeyCode::Backspace => { if let Some(state) = &mut app.title_edit { if state.cursor > 0 { - state.query.remove(state.cursor - 1); - state.cursor -= 1; + let prev = state.query[..state.cursor] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); + state.query.remove(prev); + state.cursor = prev; } } Action::Continue } KeyCode::Left => { if let Some(state) = &mut app.title_edit { - state.cursor = state.cursor.saturating_sub(1); + if state.cursor > 0 { + state.cursor = state.query[..state.cursor] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); + } } Action::Continue } KeyCode::Right => { if let Some(state) = &mut app.title_edit { if state.cursor < state.query.len() { - state.cursor += 1; + let next = state.query[state.cursor..] + .char_indices() + .nth(1) + .map(|(i, _)| state.cursor + i) + .unwrap_or(state.query.len()); + state.cursor = next; } } Action::Continue @@ -529,7 +558,7 @@ fn handle_title_edit(app: &mut App, key: KeyEvent) -> Action { KeyCode::Char(c) => { if let Some(state) = &mut app.title_edit { state.query.insert(state.cursor, c); - state.cursor += 1; + state.cursor += c.len_utf8(); } Action::Continue } diff --git a/src/tui/view.rs b/src/tui/view.rs index e9666a1..8513a35 100644 --- a/src/tui/view.rs +++ b/src/tui/view.rs @@ -64,7 +64,7 @@ fn render_flat_session_list(frame: &mut Frame, app: &App, area: Rect) { let delta = Utc::now().signed_duration_since(session.timestamp); let time_ago = HumanTime::from(-delta).to_text_en(Accuracy::Rough, Tense::Past); let right = format!("{} {}", session.project_name, time_ago); - let right_len = right.len(); + let right_len = right.chars().count(); let (cursor, cursor_len) = if is_selected { ("\u{27A4} ", 2) @@ -189,7 +189,7 @@ fn render_grouped_session_list(frame: &mut Frame, app: &App, area: Rect) { let delta = Utc::now().signed_duration_since(session.timestamp); let time_ago = HumanTime::from(-delta).to_text_en(Accuracy::Rough, Tense::Past); let right = format!(" {}", time_ago); - let right_len = right.len(); + let right_len = right.chars().count(); let indent = " "; let indent_len = 4; @@ -921,8 +921,11 @@ fn render_title_edit_bar(app: &App) -> Line<'static> { if let Some(state) = &app.title_edit { let is_new = matches!(state.context, super::TitleEditContext::NewSession { .. }); - let label = if is_new { " New session: " } else { " Title: " }; - let hint = if is_new { + let is_fork = matches!(state.context, super::TitleEditContext::Fork { .. }); + let label = if is_fork { " Fork title: " } else if is_new { " New session: " } else { " Title: " }; + let hint = if is_fork { + "Enter fork Esc cancel (empty = no name)" + } else if is_new { "Enter create Esc cancel (empty = no name)" } else { "Enter save Esc cancel (clear to remove title)" From 8d6e5e2737acac1dde8f8821ddb9a73277561eaf Mon Sep 17 00:00:00 2001 From: mabulgu Date: Thu, 2 Jul 2026 18:19:38 +0300 Subject: [PATCH 4/5] Add title prompt before forking a session Show a title edit bar pre-filled with "{title} (fork)" when pressing f in conversation view, so forked sessions are distinguishable in the list. Empty title forks without -n flag, Esc cancels the fork entirely. --- src/tui/mod.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 4ec5bcb..36303e9 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -187,6 +187,7 @@ pub struct MoveState { pub enum TitleEditContext { Rename { session_id: String }, NewSession { cwd: String }, + Fork { session_id: String, cwd: String }, } /// State for title editing. @@ -660,6 +661,24 @@ impl App { self.mode = Mode::TitleEdit; } + /// Start title input for forking a session. + pub fn start_fork_title(&mut self, session_id: String, cwd: String) { + let existing = self.find_session(&session_id) + .and_then(|s| s.custom_title.clone()); + let prefill = match existing { + Some(title) => format!("{} (fork)", title), + None => String::new(), + }; + let cursor = prefill.len(); + self.title_edit = Some(TitleEditState { + context: TitleEditContext::Fork { session_id, cwd }, + query: prefill, + cursor, + return_mode: Mode::Conversation, + }); + self.mode = Mode::TitleEdit; + } + /// Finish title editing. Returns an Action if the caller should execute it. pub fn finish_title_edit(&mut self) -> Result, String> { let state = self.title_edit.take().ok_or("no title edit in progress")?; @@ -720,6 +739,20 @@ impl App { self.mode = Mode::Browsing; Ok(Some(Action::NewSession(format!("cd '{}' && claude -n '{}'", escaped_cwd, escaped_title)))) } + TitleEditContext::Fork { session_id, cwd } => { + let escaped_cwd = cwd.replace('\'', "'\\''"); + if title.is_empty() { + self.mode = state.return_mode; + return Ok(Some(Action::ForkSession( + format!("cd '{}' && claude -r {} --fork-session", escaped_cwd, session_id) + ))); + } + let escaped_title = title.replace('\'', "'\\''"); + self.mode = state.return_mode; + Ok(Some(Action::ForkSession( + format!("cd '{}' && claude -r {} --fork-session -n '{}'", escaped_cwd, session_id, escaped_title) + ))) + } } } From a4109bfaafc0b8393d8fa361a712478846fd0943 Mon Sep 17 00:00:00 2001 From: mabulgu Date: Thu, 2 Jul 2026 18:33:16 +0300 Subject: [PATCH 5/5] Address rhuss review: search titles, remove clipboard, auto-expand - Include custom_title in filter search haystack so named sessions are discoverable by their title - Remove dead clipboard module and arboard dependency - Auto-expand all project groups when a filter is active so matching sessions inside collapsed groups are always visible --- Cargo.toml | 1 - src/clipboard.rs | 17 ----------------- src/filter.rs | 5 +++-- src/lib.rs | 1 - src/main.rs | 1 - src/tui/mod.rs | 5 ++++- 6 files changed, 7 insertions(+), 23 deletions(-) delete mode 100644 src/clipboard.rs diff --git a/Cargo.toml b/Cargo.toml index b5a111e..9da5ea1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,6 @@ homepage = "https://github.com/cc-deck/cc-session" ratatui = "0.30" crossterm = "0.29" clap = { version = "4.5", features = ["derive"] } -arboard = { version = "3.6", features = ["wayland-data-control"] } rayon = "1.11" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/src/clipboard.rs b/src/clipboard.rs deleted file mode 100644 index b84f037..0000000 --- a/src/clipboard.rs +++ /dev/null @@ -1,17 +0,0 @@ -// Cross-platform clipboard with fallback - -use arboard::Clipboard; - -/// Copy the given text to the system clipboard. -pub fn copy_to_clipboard(text: &str) -> Result<(), String> { - let mut clipboard = Clipboard::new().map_err(|e| format!("failed to open clipboard: {e}"))?; - clipboard - .set_text(text.to_string()) - .map_err(|e| format!("failed to set clipboard text: {e}")) -} - -/// Check whether clipboard access is available on this system. -#[allow(dead_code)] -pub fn clipboard_available() -> bool { - Clipboard::new().is_ok() -} diff --git a/src/filter.rs b/src/filter.rs index 9fab19b..254b431 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -72,9 +72,10 @@ pub fn filter_sessions(sessions: &[Session], query: &str) -> Vec { .enumerate() .filter_map(|(idx, session)| { let branch = session.git_branch.as_deref().unwrap_or(""); + let title = session.custom_title.as_deref().unwrap_or(""); let haystack = format!( - "{} {} {}", - session.project_name, branch, session.first_message + "{} {} {} {}", + session.project_name, branch, session.first_message, title ) .to_lowercase(); diff --git a/src/lib.rs b/src/lib.rs index 04ef8f2..39df196 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,3 @@ -pub mod clipboard; pub mod discovery; pub mod filter; pub mod search; diff --git a/src/main.rs b/src/main.rs index abce01e..f867fe0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,3 @@ -mod clipboard; mod discovery; mod filter; mod search; diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 36303e9..6c9bf23 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -359,6 +359,7 @@ impl App { /// Rebuild project groups and tree rows from current display entries. pub fn rebuild_tree_rows(&mut self) { + let has_filter = !self.filter_query.is_empty(); let old_expanded: HashMap = self.project_groups .iter() .map(|g| (g.path.clone(), g.expanded)) @@ -369,7 +370,9 @@ impl App { &self.content_results, ); for group in &mut self.project_groups { - if let Some(&was_expanded) = old_expanded.get(&group.path) { + if has_filter { + group.expanded = true; + } else if let Some(&was_expanded) = old_expanded.get(&group.path) { group.expanded = was_expanded; } }