diff --git a/crates/cc-compact/src/context_analysis.rs b/crates/cc-compact/src/context_analysis.rs new file mode 100644 index 00000000..6b5aa24a --- /dev/null +++ b/crates/cc-compact/src/context_analysis.rs @@ -0,0 +1,253 @@ +//! Context window analysis — what actually reaches the API after the +//! pre-send pipeline. + +use serde::Serialize; + +use cc_types::message::Message; +use cc_utils::tokens; + +use super::auto_compact; +use super::microcompact; +use super::snip; + +const DEFAULT_SNIP_MAX_TURNS: usize = 200; + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct ContextCategory { + pub label: String, + pub tokens: u64, + pub percent: f32, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct ContextAnalysis { + pub model: String, + pub context_window: u64, + pub total_used: u64, + pub total_percent: f32, + pub compacted: bool, + pub messages_in: usize, + pub messages_out: usize, + pub categories: Vec, +} + +#[derive(Debug, Default)] +pub struct ContextAnalysisInput<'a> { + pub messages: &'a [Message], + pub system_prompt: Option<&'a str>, + pub skills_manifest: Option<&'a str>, + pub cached_files_chars: u64, + pub tools_schema: Option<&'a str>, + pub hook_results: Option<&'a str>, + pub model: &'a str, +} + +pub fn analyze_context_usage(input: ContextAnalysisInput<'_>) -> ContextAnalysis { + let context_window = auto_compact::get_context_window_size(input.model); + let messages_in = input.messages.len(); + let mut compacted = false; + let snipped = snip::snip_compact_if_needed(input.messages.to_vec(), DEFAULT_SNIP_MAX_TURNS); + if snipped.tokens_freed > 0 { compacted = true; } + let micro = microcompact::microcompact_messages(snipped.messages); + if micro.tokens_freed > 0 { compacted = true; } + let effective = micro.messages; + let messages_out = effective.len(); + + let messages_tokens = tokens::estimate_messages_tokens(&effective); + let system_tokens = input.system_prompt.map(tokens::estimate_tokens).unwrap_or(0); + let skills_tokens = input.skills_manifest.map(tokens::estimate_tokens).unwrap_or(0); + let tools_tokens = input.tools_schema.map(tokens::estimate_tokens).unwrap_or(0); + let hooks_tokens = input.hook_results.map(tokens::estimate_tokens).unwrap_or(0); + let files_tokens = if input.cached_files_chars == 0 { + 0 + } else { + ((input.cached_files_chars as f64) / 4.0).ceil() as u64 + }; + + let total_used = messages_tokens + .saturating_add(system_tokens) + .saturating_add(skills_tokens) + .saturating_add(tools_tokens) + .saturating_add(hooks_tokens) + .saturating_add(files_tokens); + let capped_used = total_used.min(context_window); + let free = context_window.saturating_sub(capped_used); + + let mut rows: Vec = vec![ + row("messages", messages_tokens, context_window), + row("system prompt", system_tokens, context_window), + row("skills", skills_tokens, context_window), + row("files cached", files_tokens, context_window), + row("tools schema", tools_tokens, context_window), + row("hook results", hooks_tokens, context_window), + ]; + rows.sort_by(|a, b| b.tokens.cmp(&a.tokens)); + rows.push(row("free", free, context_window)); + + let total_percent = percent_of(capped_used, context_window); + + ContextAnalysis { + model: input.model.to_string(), + context_window, + total_used, + total_percent, + compacted, + messages_in, + messages_out, + categories: rows, + } +} + +fn row(label: &str, tokens: u64, window: u64) -> ContextCategory { + ContextCategory { + label: label.to_string(), + tokens, + percent: percent_of(tokens, window), + } +} + +fn percent_of(n: u64, total: u64) -> f32 { + if total == 0 { return 0.0; } + ((n as f64 / total as f64) * 100.0) as f32 +} + +#[cfg(test)] +mod tests { + use super::*; + use cc_types::message::{AssistantMessage, ContentBlock, MessageContent, UserMessage}; + use uuid::Uuid; + + fn make_user(text: &str) -> Message { + Message::User(UserMessage { + uuid: Uuid::new_v4(), + timestamp: 0, + role: "user".into(), + content: MessageContent::Text(text.into()), + is_meta: false, + tool_use_result: None, + source_tool_assistant_uuid: None, + }) + } + + fn make_assistant(text: &str) -> Message { + Message::Assistant(AssistantMessage { + uuid: Uuid::new_v4(), + timestamp: 0, + role: "assistant".into(), + content: vec![ContentBlock::Text { text: text.into() }], + usage: None, + stop_reason: Some("end_turn".into()), + is_api_error_message: false, + api_error: None, + cost_usd: 0.0, + }) + } + + #[test] + fn test_empty_inputs_yield_zero_used() { + let report = analyze_context_usage(ContextAnalysisInput { + messages: &[], + model: "claude-sonnet-4-20250514", + ..Default::default() + }); + assert_eq!(report.total_used, 0); + assert_eq!(report.total_percent, 0.0); + assert_eq!(report.context_window, 200_000); + assert_eq!(report.categories.len(), 7); + let free = report.categories.last().unwrap(); + assert_eq!(free.label, "free"); + assert_eq!(free.tokens, 200_000); + } + + #[test] + fn test_non_empty_categories_sum_and_percentages() { + let messages = vec![make_user("hello world"), make_assistant("hi!")]; + let report = analyze_context_usage(ContextAnalysisInput { + messages: &messages, + system_prompt: Some("You are a helpful assistant."), + skills_manifest: Some("skill a\nskill b"), + cached_files_chars: 400, + tools_schema: Some("bash, edit, grep"), + hook_results: None, + model: "claude-sonnet-4-20250514", + }); + let labels: Vec<&str> = report.categories.iter().map(|c| c.label.as_str()).collect(); + for expected in ["messages", "system prompt", "skills", "files cached", "tools schema", "hook results", "free"] { + assert!(labels.contains(&expected)); + } + let files = report.categories.iter().find(|c| c.label == "files cached").unwrap(); + assert_eq!(files.tokens, 100); + let non_free: u64 = report.categories.iter().filter(|c| c.label != "free").map(|c| c.tokens).sum(); + assert_eq!(non_free, report.total_used); + let free_tokens = report.categories.iter().find(|c| c.label == "free").unwrap().tokens; + assert!(report.total_used.min(report.context_window) + free_tokens <= report.context_window); + } + + #[test] + fn test_percentages_never_exceed_100() { + let huge_text = "a".repeat(1_000_000); + let messages = vec![make_user(&huge_text)]; + let report = analyze_context_usage(ContextAnalysisInput { + messages: &messages, + model: "claude-sonnet-4-20250514", + ..Default::default() + }); + assert!(report.total_used > report.context_window); + assert!(report.total_percent <= 100.0); + let free = report.categories.iter().find(|c| c.label == "free").unwrap(); + assert_eq!(free.tokens, 0); + } + + #[test] + fn test_categories_sorted_desc_with_free_last() { + let messages = vec![make_user("hello world"), make_assistant("hi!")]; + let report = analyze_context_usage(ContextAnalysisInput { + messages: &messages, + system_prompt: Some(&"a".repeat(4_000)), + tools_schema: Some("x"), + model: "claude-sonnet-4-20250514", + ..Default::default() + }); + assert_eq!(report.categories.last().unwrap().label, "free"); + let non_free: Vec<&ContextCategory> = report.categories.iter().filter(|c| c.label != "free").collect(); + for pair in non_free.windows(2) { + assert!(pair[0].tokens >= pair[1].tokens); + } + } + + #[test] + fn test_transform_is_applied_for_large_conversations() { + let mut msgs = Vec::new(); + for i in 0..250 { + msgs.push(make_user(&format!("user turn {}", i))); + msgs.push(make_assistant(&format!("assistant turn {}", i))); + } + let report = analyze_context_usage(ContextAnalysisInput { + messages: &msgs, + model: "claude-sonnet-4-20250514", + ..Default::default() + }); + assert!(report.compacted); + assert!(report.messages_out < report.messages_in); + } + + #[test] + fn test_serialize_json_shape() { + let report = analyze_context_usage(ContextAnalysisInput { + messages: &[], + model: "claude-sonnet-4-20250514", + ..Default::default() + }); + let json = serde_json::to_value(&report).unwrap(); + for k in ["model", "context_window", "total_used", "total_percent", "compacted", "messages_in", "messages_out"] { + assert!(json.get(k).is_some()); + } + let cats = json.get("categories").unwrap().as_array().unwrap(); + assert!(!cats.is_empty()); + for c in cats { + assert!(c.get("label").is_some()); + assert!(c.get("tokens").is_some()); + assert!(c.get("percent").is_some()); + } + } +} diff --git a/crates/cc-compact/src/lib.rs b/crates/cc-compact/src/lib.rs index b43c51de..a2fa552b 100644 --- a/crates/cc-compact/src/lib.rs +++ b/crates/cc-compact/src/lib.rs @@ -7,6 +7,7 @@ pub mod auto_compact; pub mod compaction; +pub mod context_analysis; pub mod messages; pub mod microcompact; pub mod pipeline; diff --git a/crates/cc-config/src/paths.rs b/crates/cc-config/src/paths.rs index 495bc6a2..df2308a0 100644 --- a/crates/cc-config/src/paths.rs +++ b/crates/cc-config/src/paths.rs @@ -93,6 +93,15 @@ pub fn memory_dir_global() -> PathBuf { data_root().join("memory") } +/// `{data_root}/auto_memory/` — auto-captured memories (issue #45). +/// +/// Separated from the primary `memory/` directory so users can inspect +/// or purge auto-captured notes independently of curated entries. +/// Creation is deferred to `memdir::ensure_memory_dir` on first write. +pub fn auto_memory_dir() -> PathBuf { + data_root().join("auto_memory") +} + pub fn session_insights_dir() -> PathBuf { data_root().join("session-insights") } @@ -144,6 +153,38 @@ pub fn project_cc_rust_dir(cwd: &Path) -> PathBuf { cwd.join(".cc-rust") } +// ----- Plan file (issue #46) ----------------------------------------------- + +/// `{cwd}/.cc-rust/plan.md` — project-scoped plan file. +pub fn plan_file_path_project(cwd: &Path) -> PathBuf { + cwd.join(".cc-rust").join("plan.md") +} + +/// `{data_root}/plan.md` — fallback global plan file used outside a project. +pub fn plan_file_path_global() -> PathBuf { + data_root().join("plan.md") +} + +/// Resolve the plan file the current session should read/write. +/// +/// Priority: +/// 1. If `{cwd}/.cc-rust/plan.md` already exists → use it (idempotent). +/// 2. Else if `{cwd}/.cc-rust/` or `{cwd}/CLAUDE.md` is present → project path. +/// 3. Else → global `{data_root}/plan.md`. +pub fn current_plan_file_path(cwd: &Path) -> PathBuf { + let project = plan_file_path_project(cwd); + if project.exists() { + return project; + } + let has_project_marker = + cwd.join(".cc-rust").is_dir() || cwd.join("CLAUDE.md").is_file(); + if has_project_marker { + project + } else { + plan_file_path_global() + } +} + #[cfg(test)] mod tests { use super::*; @@ -245,6 +286,7 @@ mod tests { assert_eq!(audits_dir(), base.join("audits")); assert_eq!(transcripts_dir(), base.join("transcripts")); assert_eq!(memory_dir_global(), base.join("memory")); + assert_eq!(auto_memory_dir(), base.join("auto_memory")); assert_eq!(session_insights_dir(), base.join("session-insights")); assert_eq!(plugins_dir(), base.join("plugins")); assert_eq!(skills_dir_global(), base.join("skills")); @@ -287,4 +329,62 @@ mod tests { PathBuf::from("/foo/bar/.cc-rust") ); } + + // Plan file path helpers (issue #46) ---------------------------------- + + #[test] + #[serial] + fn plan_file_path_project_is_cwd_relative() { + let _g = EnvGuard::set("CC_RUST_HOME", "/tmp/ignored"); + assert_eq!( + plan_file_path_project(Path::new("/foo/bar")), + PathBuf::from("/foo/bar/.cc-rust/plan.md") + ); + } + + #[test] + #[serial] + fn plan_file_path_global_is_under_data_root() { + let _g = EnvGuard::set("CC_RUST_HOME", "/tmp/cc-plan-global"); + assert_eq!( + plan_file_path_global(), + PathBuf::from("/tmp/cc-plan-global/plan.md") + ); + } + + #[test] + #[serial] + fn current_plan_prefers_project_when_markers_present() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join(".cc-rust")).unwrap(); + let _g = EnvGuard::set("CC_RUST_HOME", "/tmp/should-not-be-used"); + assert_eq!( + current_plan_file_path(tmp.path()), + tmp.path().join(".cc-rust").join("plan.md") + ); + } + + #[test] + #[serial] + fn current_plan_falls_back_to_global_without_markers() { + let tmp = tempfile::tempdir().unwrap(); + let home = tempfile::tempdir().unwrap(); + let _g = EnvGuard::set("CC_RUST_HOME", home.path().to_str().unwrap()); + assert_eq!( + current_plan_file_path(tmp.path()), + home.path().join("plan.md") + ); + } + + #[test] + #[serial] + fn current_plan_is_idempotent_once_plan_exists() { + let tmp = tempfile::tempdir().unwrap(); + let plan = tmp.path().join(".cc-rust").join("plan.md"); + std::fs::create_dir_all(plan.parent().unwrap()).unwrap(); + std::fs::write(&plan, "# Plan\n").unwrap(); + // Even without other markers, an existing plan.md sticks. + let _g = EnvGuard::set("CC_RUST_HOME", "/tmp/unused"); + assert_eq!(current_plan_file_path(tmp.path()), plan); + } } diff --git a/crates/cc-config/src/runtime_settings.rs b/crates/cc-config/src/runtime_settings.rs index b0904c34..0a828f14 100644 --- a/crates/cc-config/src/runtime_settings.rs +++ b/crates/cc-config/src/runtime_settings.rs @@ -54,6 +54,12 @@ pub struct SettingsJson { pub teammate_mode: Option, pub claude_in_chrome_default_enabled: Option, + // -- Memory (issue #45) -------------------------------------------- + /// Whether auto-memory capture + injection is enabled for this session. + /// Persisted via `settings.json::autoMemoryEnabled`; toggled by + /// `/memory auto on|off`. Default is `None` (off). + pub auto_memory_enabled: Option, + // -- Per-key source (provenance) ----------------------------------- /// 来源映射: key -> 哪个 layer 提供了该值。由启动路径 + `/config set` /// 在写入对应键时一并更新。`/config show` 读取此 map 显示来源信息。 diff --git a/crates/cc-config/src/settings.rs b/crates/cc-config/src/settings.rs index dad1b342..8a3ad777 100644 --- a/crates/cc-config/src/settings.rs +++ b/crates/cc-config/src/settings.rs @@ -430,6 +430,13 @@ pub struct RawSettings { #[serde(rename = "claudeInChromeDefaultEnabled")] pub claude_in_chrome_default_enabled: Option, + // -- Memory (issue #45) -------------------------------------------- + /// Auto-memory toggle: when `true`, memories captured during a session + /// are surfaced by `/memory` and injected into the prompt via + /// `build_memory_context_with`. Default is `None` (off). The capture + /// hook itself is not yet wired up — only the state is persisted. + pub auto_memory_enabled: Option, + // -- Prompts -------------------------------------------------------- pub system_prompt: Option, @@ -508,6 +515,7 @@ impl RawSettings { claude_in_chrome_default_enabled, "claudeInChromeDefaultEnabled" ); + merge_opt!(auto_memory_enabled, "autoMemoryEnabled"); merge_opt!(system_prompt, "systemPrompt"); merge_opt!(api_key, "apiKey"); @@ -699,6 +707,8 @@ pub struct EffectiveSettings { pub fast_mode: Option, pub fast_mode_per_session_opt_in: Option, pub teammate_mode: Option, + /// Auto-memory toggle (issue #45). `None` means "inherit default" (off). + pub auto_memory_enabled: Option, } impl EffectiveSettings { @@ -740,6 +750,7 @@ impl EffectiveSettings { fast_mode: raw.fast_mode, fast_mode_per_session_opt_in: raw.fast_mode_per_session_opt_in, teammate_mode: raw.teammate_mode, + auto_memory_enabled: raw.auto_memory_enabled, } } } @@ -1280,6 +1291,7 @@ pub fn settings_schema() -> Value { "fastModePerSessionOptIn": { "type": "boolean" }, "teammateMode": { "type": "boolean" }, "claudeInChromeDefaultEnabled": { "type": "boolean" }, + "autoMemoryEnabled": { "type": "boolean" }, "systemPrompt": { "type": "string" }, "apiKey": { "type": "string" } } diff --git a/crates/cc-session/src/fork.rs b/crates/cc-session/src/fork.rs new file mode 100644 index 00000000..57abaf2b --- /dev/null +++ b/crates/cc-session/src/fork.rs @@ -0,0 +1,379 @@ +//! Conversation forking — transcript-level branch. +//! +//! A conversation "fork" (issue #36) produces a copy of the current +//! conversation as a new session, preserving the original message UUIDs and +//! content while rewriting the envelope `session_id` so the fork is +//! self-consistent. The new transcript begins with a `session_header` record +//! carrying the fork provenance: +//! +//! ```json +//! { "msg_type": "session_header", +//! "session_id": "", +//! "forked_from": "", +//! "forked_at_uuid": "", +//! "title": "..." } +//! ``` +//! +//! The fork lives alongside the parent — we do not modify the parent's +//! transcript, session file, or title. The caller is expected to display a +//! resume hint (`/resume `); runtime "attach to new session" +//! behavior requires engine-state surgery that happens outside this crate. + +use anyhow::{Context, Result}; +use chrono::Utc; +use tracing::{debug, info}; + +use crate::storage; +use crate::transcript::{ + self, copy_transcript_entries, write_session_header, SessionHeader, +}; +use cc_types::message::Message; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/// Outcome of a successful transcript fork. +#[derive(Debug, Clone)] +pub struct ForkOutcome { + /// The freshly allocated session ID for the new fork. + pub new_session_id: String, + /// The source session that was forked from. + pub parent_session_id: String, + /// UUID of the last message copied into the fork (the fork point), if + /// one could be determined. `None` for a fork of an empty conversation. + pub forked_at_uuid: Option, + /// Number of message entries copied from the parent transcript into the + /// fork's transcript. + pub copied_entry_count: usize, + /// Title assigned to the fork. + pub title: String, +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Fork the parent session into a freshly allocated session ID. +/// +/// The parent session is untouched. On success: +/// - A new transcript file is written with a `session_header` carrying the +/// fork provenance, followed by rewritten copies of the parent's messages +/// (through `cursor_uuid`, inclusive) with the `session_id` envelope +/// rewritten to the new ID and original message UUIDs preserved. +/// - A session file is persisted for the fork with the same `cwd` as the +/// parent and a recognizable custom title. +/// +/// ## Parameters +/// - `parent_session_id`: the source session to copy from +/// - `new_session_id`: the freshly minted session ID for the fork +/// - `messages`: the current in-memory message list (used to persist a +/// session file alongside the transcript, and to derive a fork point when +/// `cursor_uuid` is `None`) +/// - `cwd`: working directory for the fork's session file +/// - `cursor_uuid`: copy messages up to and including this UUID. When `None`, +/// defaults to the last message's UUID in `messages` (or copies everything +/// from the parent transcript if `messages` is empty). +/// +/// Returns a [`ForkOutcome`] with details that the caller can surface to the +/// user (IDs, title, resume hint). +pub fn fork_session( + parent_session_id: &str, + new_session_id: &str, + messages: &[Message], + cwd: &str, + cursor_uuid: Option<&str>, +) -> Result { + if parent_session_id == new_session_id { + anyhow::bail!("fork_session: parent and new session IDs must differ"); + } + + // Derive the fork point. If the caller passes an explicit UUID we honor + // it; otherwise fall back to the last in-memory message, then to "copy + // everything" if the buffer is empty. + let derived_cursor = cursor_uuid + .map(|s| s.to_string()) + .or_else(|| messages.last().map(|m| m.uuid().to_string())); + + // Build a recognizable title. Prefer the parent's current title + // (custom or auto-derived); fall back to the short new ID when neither + // is available. + let parent_title = storage::load_session_info(parent_session_id) + .map(|info| info.title) + .unwrap_or_default(); + let title = derive_fork_title(&parent_title, new_session_id); + + // Write the session_header line first so the transcript file exists + // before we try to copy entries into it. + let now_ms = Utc::now().timestamp_millis(); + let header = SessionHeader { + timestamp: now_ms, + session_id: new_session_id.to_string(), + msg_type: "session_header".to_string(), + forked_from: Some(parent_session_id.to_string()), + forked_at_uuid: derived_cursor.clone(), + title: Some(title.clone()), + }; + write_session_header(&header).context("Failed to write session_header for fork")?; + + // Copy the parent transcript up through the fork point. + let copied = copy_transcript_entries( + parent_session_id, + new_session_id, + derived_cursor.as_deref(), + ) + .context("Failed to copy parent transcript entries into fork")?; + + // Persist a session file so /resume and /session list can find the + // fork. We truncate messages to the cursor (inclusive) so the saved + // buffer matches the transcript's copied range. + let saved_messages: Vec = match derived_cursor.as_deref() { + Some(cursor) => messages_up_to_cursor(messages, cursor), + None => Vec::new(), + }; + + storage::save_session(new_session_id, &saved_messages, cwd) + .context("Failed to save forked session file")?; + + // Pin the fork's title so it's distinguishable in /session list. + storage::set_session_title(new_session_id, Some(&title)) + .context("Failed to set forked session title")?; + + // Make durable before returning so a crash right after the fork + // doesn't leave a half-written transcript header. + let _ = transcript::flush_transcript(new_session_id); + + info!( + parent = parent_session_id, + child = new_session_id, + copied_entries = copied, + "session forked" + ); + debug!(forked_at_uuid = ?derived_cursor, "fork cursor recorded"); + + Ok(ForkOutcome { + new_session_id: new_session_id.to_string(), + parent_session_id: parent_session_id.to_string(), + forked_at_uuid: derived_cursor, + copied_entry_count: copied, + title, + }) +} + +/// Build a recognizable fork title. Uses the parent title and the first 8 +/// characters of the new session ID as a short fingerprint. Falls back to the +/// short ID alone when no parent title is available. +fn derive_fork_title(parent_title: &str, new_session_id: &str) -> String { + let short: String = new_session_id.chars().take(8).collect(); + let parent = parent_title.trim(); + if parent.is_empty() { + format!("Forked session ({})", short) + } else { + format!("{} (fork @ {})", parent, short) + } +} + +/// Copy messages from `messages` up to and including the first entry whose +/// UUID equals `cursor`. If no such entry is found, copies everything (this +/// mirrors the transcript-copy behavior, which also falls through to "copy +/// all" when the cursor can't be located). +fn messages_up_to_cursor(messages: &[Message], cursor: &str) -> Vec { + let mut out = Vec::with_capacity(messages.len()); + for m in messages { + out.push(m.clone()); + if m.uuid().to_string() == cursor { + return out; + } + } + out +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use cc_types::message::{MessageContent, UserMessage}; + use std::path::Path; + use tempfile::tempdir; + use uuid::Uuid; + + struct HomeGuard { + previous: Option, + } + + impl HomeGuard { + fn set(path: &Path) -> Self { + let previous = std::env::var("CC_RUST_HOME").ok(); + std::env::set_var("CC_RUST_HOME", path); + Self { previous } + } + } + + impl Drop for HomeGuard { + fn drop(&mut self) { + match &self.previous { + Some(v) => std::env::set_var("CC_RUST_HOME", v), + None => std::env::remove_var("CC_RUST_HOME"), + } + } + } + + fn user(text: &str, uuid: Uuid) -> Message { + Message::User(UserMessage { + uuid, + timestamp: 0, + role: "user".into(), + content: MessageContent::Text(text.into()), + is_meta: false, + tool_use_result: None, + source_tool_assistant_uuid: None, + }) + } + + fn seed_parent_transcript_and_session( + session_id: &str, + uuids: &[Uuid], + cwd: &str, + ) -> Vec { + // Write matching transcript entries on disk so the copy has something + // to read from. The transcript code expects NDJSON envelopes with + // `session_id` + `msg_type` + `uuid` + `payload`. + let dir = transcript::get_transcript_dir(); + std::fs::create_dir_all(&dir).unwrap(); + let path = transcript::get_transcript_file(session_id); + let mut buf = String::new(); + for (i, uuid) in uuids.iter().enumerate() { + let entry = serde_json::json!({ + "timestamp": 1_700_000_000_000_i64 + i as i64, + "session_id": session_id, + "msg_type": if i % 2 == 0 { "user" } else { "assistant" }, + "uuid": uuid.to_string(), + "payload": { "text": format!("msg {}", i) } + }); + buf.push_str(&serde_json::to_string(&entry).unwrap()); + buf.push('\n'); + } + std::fs::write(&path, buf).unwrap(); + + // Also persist a session file so /session list can find the parent. + let messages: Vec = uuids + .iter() + .enumerate() + .map(|(i, u)| user(&format!("msg {}", i), *u)) + .collect(); + storage::save_session(session_id, &messages, cwd).unwrap(); + messages + } + + #[test] + #[serial_test::serial] + fn test_fork_session_copies_entries_and_writes_header() { + let temp = tempdir().unwrap(); + let _g = HomeGuard::set(temp.path()); + + let uuids: Vec = (0..4).map(|_| Uuid::new_v4()).collect(); + let messages = seed_parent_transcript_and_session("parent-abc", &uuids, "/proj"); + + let new_id = "child-xyz-12345678"; + let outcome = fork_session( + "parent-abc", + new_id, + &messages, + "/proj", + Some(&uuids[2].to_string()), + ) + .unwrap(); + + assert_eq!(outcome.new_session_id, new_id); + assert_eq!(outcome.parent_session_id, "parent-abc"); + assert_eq!(outcome.forked_at_uuid.as_deref(), Some(uuids[2].to_string().as_str())); + assert_eq!(outcome.copied_entry_count, 3); + + // Verify transcript layout: header + 3 copied entries. + let content = + std::fs::read_to_string(transcript::get_transcript_file(new_id)).unwrap(); + let lines: Vec<&str> = content.lines().filter(|l| !l.is_empty()).collect(); + assert_eq!(lines.len(), 4); + + // First line is the header with correct forked_from / forked_at_uuid. + let header: SessionHeader = serde_json::from_str(lines[0]).unwrap(); + assert_eq!(header.msg_type, "session_header"); + assert_eq!(header.session_id, new_id); + assert_eq!(header.forked_from.as_deref(), Some("parent-abc")); + assert_eq!(header.forked_at_uuid, Some(uuids[2].to_string())); + assert!(header.title.as_deref().unwrap_or("").contains("fork @")); + + // All copied entries must bear the new session_id. + for line in &lines[1..] { + let v: serde_json::Value = serde_json::from_str(line).unwrap(); + assert_eq!(v.get("session_id").and_then(|v| v.as_str()), Some(new_id)); + } + + // A session file was persisted for the fork so /resume can find it. + let info = storage::load_session_info(new_id).unwrap(); + assert!(info.custom_title.is_some()); + assert_eq!(info.message_count, 3); + } + + #[test] + #[serial_test::serial] + fn test_fork_session_defaults_cursor_to_last_message() { + let temp = tempdir().unwrap(); + let _g = HomeGuard::set(temp.path()); + + let uuids: Vec = (0..2).map(|_| Uuid::new_v4()).collect(); + let messages = seed_parent_transcript_and_session("parent-def", &uuids, "/proj"); + + let outcome = fork_session("parent-def", "child-def", &messages, "/proj", None).unwrap(); + assert_eq!(outcome.forked_at_uuid, Some(uuids[1].to_string())); + assert_eq!(outcome.copied_entry_count, 2); + } + + #[test] + #[serial_test::serial] + fn test_fork_session_rejects_self_fork() { + let temp = tempdir().unwrap(); + let _g = HomeGuard::set(temp.path()); + + let err = fork_session("same", "same", &[], "/proj", None).unwrap_err(); + assert!(err.to_string().contains("must differ")); + } + + #[test] + fn test_derive_fork_title_with_parent() { + let title = derive_fork_title(" Debug auth flow ", "abcd1234-etc"); + assert_eq!(title, "Debug auth flow (fork @ abcd1234)"); + } + + #[test] + fn test_derive_fork_title_without_parent() { + let title = derive_fork_title("", "abcd1234-etc"); + assert_eq!(title, "Forked session (abcd1234)"); + } + + #[test] + fn test_messages_up_to_cursor_inclusive() { + let u0 = Uuid::new_v4(); + let u1 = Uuid::new_v4(); + let u2 = Uuid::new_v4(); + let msgs = vec![ + user("a", u0), + user("b", u1), + user("c", u2), + ]; + let kept = messages_up_to_cursor(&msgs, &u1.to_string()); + assert_eq!(kept.len(), 2); + assert_eq!(kept[1].uuid().to_string(), u1.to_string()); + } + + #[test] + fn test_messages_up_to_cursor_missing_keeps_all() { + let u0 = Uuid::new_v4(); + let msgs = vec![user("a", u0)]; + let kept = messages_up_to_cursor(&msgs, "no-such-uuid"); + assert_eq!(kept.len(), 1); + } +} diff --git a/crates/cc-session/src/lib.rs b/crates/cc-session/src/lib.rs index 94c2a916..4448c865 100644 --- a/crates/cc-session/src/lib.rs +++ b/crates/cc-session/src/lib.rs @@ -9,6 +9,7 @@ pub mod audit_export; pub mod export; +pub mod fork; pub mod memdir; pub mod migrations; pub mod resume; diff --git a/crates/cc-session/src/memdir.rs b/crates/cc-session/src/memdir.rs index 90fd9b6d..88239c05 100644 --- a/crates/cc-session/src/memdir.rs +++ b/crates/cc-session/src/memdir.rs @@ -38,10 +38,31 @@ pub struct MemoryEntry { /// Scope of memory storage. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MemoryScope { - /// Global memories: `~/.cc-rust/memory/` + /// Global memories: `{data_root}/memory/` Global, /// Project-local memories: `.cc-rust/memory/` relative to cwd Project, + /// Team-shared memories: `{data_root}/projects/{sanitized_cwd}/memory/team/`. + /// Gated by `FEATURE_TEAMMEM`; the directory itself is readable/writable + /// even when the feature is off so legacy data is never stranded. + Team, + /// Auto-captured memories: `{data_root}/auto_memory/`. + /// Gated at the context-injection layer by the `auto_memory_enabled` + /// toggle; the directory is always readable so prior captures can be + /// inspected and purged. + Auto, +} + +impl MemoryScope { + /// Short label used in selector output and JSON representations. + pub fn as_str(self) -> &'static str { + match self { + MemoryScope::Global => "global", + MemoryScope::Project => "project", + MemoryScope::Team => "team", + MemoryScope::Auto => "auto", + } + } } // --------------------------------------------------------------------------- @@ -53,6 +74,8 @@ pub fn memory_dir(scope: MemoryScope, cwd: &Path) -> Result { match scope { MemoryScope::Global => Ok(cc_config::paths::memory_dir_global()), MemoryScope::Project => Ok(cwd.join(".cc-rust").join("memory")), + MemoryScope::Team => Ok(cc_config::paths::team_memory_dir(cwd)), + MemoryScope::Auto => Ok(cc_config::paths::auto_memory_dir()), } } @@ -208,7 +231,21 @@ pub fn search_memories(query: &str, scope: MemoryScope, cwd: &Path) -> Result Result { + build_memory_context_with(cwd, false) +} + +/// See [`build_memory_context`]. Extra `include_auto` flag lets the root +/// crate wire in the per-session `auto_memory_enabled` toggle without +/// dragging settings types into this crate. +/// +/// Scopes included: +/// - `Project` and `Global` are always considered. +/// - `Team` is included when `FEATURE_TEAMMEM` is enabled. +/// - `Auto` is included when `include_auto` is true. +pub fn build_memory_context_with(cwd: &Path, include_auto: bool) -> Result { let mut sections = Vec::new(); // Collect project memories @@ -233,6 +270,34 @@ pub fn build_memory_context(cwd: &Path) -> Result { } } + // Team memories — gated on FEATURE_TEAMMEM at the context-injection + // layer. The dir is readable regardless so the selector can still show + // legacy entries even when the feature is off. + if cc_config::features::enabled(cc_config::features::Feature::TeamMemory) { + if let Ok(team_mems) = list_memories(MemoryScope::Team, cwd) { + if !team_mems.is_empty() { + let mut s = String::from("## Team Memories\n"); + for mem in &team_mems { + s.push_str(&format!("- **{}**: {}\n", mem.key, mem.value)); + } + sections.push(s); + } + } + } + + // Auto memories — injected only when the caller opts in via toggle. + if include_auto { + if let Ok(auto_mems) = list_memories(MemoryScope::Auto, cwd) { + if !auto_mems.is_empty() { + let mut s = String::from("## Auto Memories\n"); + for mem in &auto_mems { + s.push_str(&format!("- **{}**: {}\n", mem.key, mem.value)); + } + sections.push(s); + } + } + } + if sections.is_empty() { Ok(String::new()) } else { @@ -392,4 +457,74 @@ mod tests { cleanup(&cwd); } + + /// Every `MemoryScope` variant resolves to a concrete path. + /// Uses a `CC_RUST_HOME` override so tests don't touch real + /// `~/.cc-rust/`. + #[test] + #[serial_test::serial] + fn test_memory_dir_resolves_all_scopes() { + let root = make_temp_dir(); + let previous = std::env::var("CC_RUST_HOME").ok(); + std::env::set_var("CC_RUST_HOME", &root); + + let cwd = root.join("my_project"); + std::fs::create_dir_all(&cwd).unwrap(); + + let global = memory_dir(MemoryScope::Global, &cwd).unwrap(); + assert_eq!(global, root.join("memory")); + + let project = memory_dir(MemoryScope::Project, &cwd).unwrap(); + assert_eq!(project, cwd.join(".cc-rust").join("memory")); + + let team = memory_dir(MemoryScope::Team, &cwd).unwrap(); + let s = team.to_string_lossy().replace('\\', "/"); + assert!( + s.ends_with("/memory/team"), + "unexpected team path: {}", + team.display() + ); + + let auto = memory_dir(MemoryScope::Auto, &cwd).unwrap(); + assert_eq!(auto, root.join("auto_memory")); + + match previous { + Some(v) => std::env::set_var("CC_RUST_HOME", v), + None => std::env::remove_var("CC_RUST_HOME"), + } + cleanup(&root); + } + + /// Auto scope round-trip write/list/delete under a sandboxed + /// `CC_RUST_HOME` so the real auto_memory/ is untouched. + #[test] + #[serial_test::serial] + fn test_auto_scope_roundtrip() { + let root = make_temp_dir(); + let previous = std::env::var("CC_RUST_HOME").ok(); + std::env::set_var("CC_RUST_HOME", &root); + + let cwd = root.join("scratch"); + std::fs::create_dir_all(&cwd).unwrap(); + + write_memory("auto-key", "captured note", "auto", MemoryScope::Auto, &cwd).unwrap(); + let all = list_memories(MemoryScope::Auto, &cwd).unwrap(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].key, "auto-key"); + assert!(delete_memory("auto-key", MemoryScope::Auto, &cwd).unwrap()); + + match previous { + Some(v) => std::env::set_var("CC_RUST_HOME", v), + None => std::env::remove_var("CC_RUST_HOME"), + } + cleanup(&root); + } + + #[test] + fn test_scope_as_str_labels() { + assert_eq!(MemoryScope::Global.as_str(), "global"); + assert_eq!(MemoryScope::Project.as_str(), "project"); + assert_eq!(MemoryScope::Team.as_str(), "team"); + assert_eq!(MemoryScope::Auto.as_str(), "auto"); + } } diff --git a/crates/cc-session/src/transcript.rs b/crates/cc-session/src/transcript.rs index 73554253..abdc58c5 100644 --- a/crates/cc-session/src/transcript.rs +++ b/crates/cc-session/src/transcript.rs @@ -10,7 +10,7 @@ use std::path::PathBuf; use anyhow::{Context, Result}; use chrono::Utc; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use cc_types::message::Message; @@ -18,6 +18,32 @@ use cc_types::message::Message; // Types // --------------------------------------------------------------------------- + +/// Session-level metadata written as the first line of a transcript file. +/// +/// Unlike regular message entries, `session_header` entries carry metadata +/// about the session as a whole — e.g. fork provenance. A transcript may have +/// at most one `session_header` entry, written at creation time. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionHeader { + /// Unix timestamp (milliseconds) when the header was written. + pub timestamp: i64, + /// Session this header describes. + pub session_id: String, + /// Always `"session_header"` — mirrors the `msg_type` tag on regular + /// entries so readers can dispatch on a single field. + pub msg_type: String, + /// Parent session this one was forked from, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub forked_from: Option, + /// UUID of the last message copied from the parent (the fork point). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub forked_at_uuid: Option, + /// Optional display title captured at fork time. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, +} + /// A single transcript entry written as one line of NDJSON. #[derive(Debug, Serialize)] struct TranscriptEntry { @@ -39,15 +65,135 @@ struct TranscriptEntry { /// Return the directory for transcript files. Resolves through /// [`cc_config::paths::transcripts_dir`]. -fn get_transcript_dir() -> PathBuf { +pub fn get_transcript_dir() -> PathBuf { cc_config::paths::transcripts_dir() } /// Return the transcript file path for a specific session. -fn get_transcript_file(session_id: &str) -> PathBuf { +pub fn get_transcript_file(session_id: &str) -> PathBuf { get_transcript_dir().join(format!("{}.ndjson", session_id)) } +// --------------------------------------------------------------------------- +// Fork helpers +// --------------------------------------------------------------------------- + +/// Write the `session_header` record as the first (or only) line of a new +/// transcript file for `session_id`. +pub fn write_session_header(header: &SessionHeader) -> Result<()> { + let dir = get_transcript_dir(); + std::fs::create_dir_all(&dir) + .with_context(|| format!("Failed to create transcript directory {}", dir.display()))?; + + let path = get_transcript_file(&header.session_id); + if path.exists() { + anyhow::bail!( + "Transcript for session {} already exists; refusing to overwrite header", + header.session_id + ); + } + + let line = serde_json::to_string(header).context("Failed to serialize session header")?; + let mut file = std::fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&path) + .with_context(|| format!("Failed to create transcript {}", path.display()))?; + writeln!(file, "{}", line) + .with_context(|| format!("Failed to write header to transcript {}", path.display()))?; + Ok(()) +} + +/// Load the session_header (first line) from a transcript file if present. +pub fn read_session_header(session_id: &str) -> Result> { + let path = get_transcript_file(session_id); + if !path.exists() { + return Ok(None); + } + let content = std::fs::read_to_string(&path) + .with_context(|| format!("Failed to read transcript {}", path.display()))?; + let Some(first_line) = content.lines().next() else { + return Ok(None); + }; + let value: serde_json::Value = match serde_json::from_str(first_line) { + Ok(v) => v, + Err(_) => return Ok(None), + }; + if value.get("msg_type").and_then(|v| v.as_str()) != Some("session_header") { + return Ok(None); + } + let header: SessionHeader = serde_json::from_value(value) + .with_context(|| format!("Failed to parse session header in {}", path.display()))?; + Ok(Some(header)) +} + +/// Copy transcript entries from `source_session_id` into the transcript file +/// for `target_session_id`, rewriting the `session_id` field on each envelope +/// to point at the target. Stops after writing the entry matching +/// `stop_at_uuid` (inclusive) if provided. Source session_header entries are +/// skipped. +pub fn copy_transcript_entries( + source_session_id: &str, + target_session_id: &str, + stop_at_uuid: Option<&str>, +) -> Result { + let source_path = get_transcript_file(source_session_id); + if !source_path.exists() { + return Ok(0); + } + + let content = std::fs::read_to_string(&source_path) + .with_context(|| format!("Failed to read source transcript {}", source_path.display()))?; + + let target_path = get_transcript_file(target_session_id); + let mut target_file = std::fs::OpenOptions::new() + .append(true) + .open(&target_path) + .with_context(|| { + format!( + "Target transcript {} does not exist — call write_session_header first", + target_path.display() + ) + })?; + + let mut copied = 0usize; + for line in content.lines() { + if line.trim().is_empty() { + continue; + } + let mut value: serde_json::Value = match serde_json::from_str(line) { + Ok(v) => v, + Err(_) => continue, + }; + + if value.get("msg_type").and_then(|v| v.as_str()) == Some("session_header") { + continue; + } + + if let Some(obj) = value.as_object_mut() { + obj.insert( + "session_id".to_string(), + serde_json::Value::String(target_session_id.to_string()), + ); + } + + let rewritten = + serde_json::to_string(&value).context("Failed to re-serialize transcript entry")?; + writeln!(target_file, "{}", rewritten).with_context(|| { + format!("Failed to append to target transcript {}", target_path.display()) + })?; + copied += 1; + + if let Some(stop) = stop_at_uuid { + if value.get("uuid").and_then(|v| v.as_str()) == Some(stop) { + break; + } + } + } + + Ok(copied) +} + // --------------------------------------------------------------------------- // Recording // --------------------------------------------------------------------------- @@ -178,4 +324,142 @@ mod tests { let path = get_transcript_file("test-session"); assert!(path.to_string_lossy().ends_with(".ndjson")); } + + use std::path::Path; + use tempfile::tempdir; + + struct HomeGuard { + previous: Option, + } + + impl HomeGuard { + fn set(path: &Path) -> Self { + let previous = std::env::var("CC_RUST_HOME").ok(); + std::env::set_var("CC_RUST_HOME", path); + Self { previous } + } + } + + impl Drop for HomeGuard { + fn drop(&mut self) { + match &self.previous { + Some(v) => std::env::set_var("CC_RUST_HOME", v), + None => std::env::remove_var("CC_RUST_HOME"), + } + } + } + + fn seed_parent_transcript(session_id: &str, uuids: &[&str]) { + let dir = get_transcript_dir(); + std::fs::create_dir_all(&dir).unwrap(); + let path = get_transcript_file(session_id); + let mut buf = String::new(); + for (i, uuid) in uuids.iter().enumerate() { + let msg_type = if i % 2 == 0 { "user" } else { "assistant" }; + let entry = serde_json::json!({ + "timestamp": 1_700_000_000_000_i64 + i as i64, + "session_id": session_id, + "msg_type": msg_type, + "uuid": uuid, + "payload": { "text": format!("msg {}", i) } + }); + buf.push_str(&serde_json::to_string(&entry).unwrap()); + buf.push('\n'); + } + std::fs::write(&path, buf).unwrap(); + } + + #[test] + #[serial_test::serial] + fn test_write_and_read_session_header() { + let temp = tempdir().unwrap(); + let _g = HomeGuard::set(temp.path()); + + let header = SessionHeader { + timestamp: 42, + session_id: "child".into(), + msg_type: "session_header".into(), + forked_from: Some("parent".into()), + forked_at_uuid: Some("00000000-0000-0000-0000-000000000003".into()), + title: Some("Debug auth (fork @ 00000000)".into()), + }; + + write_session_header(&header).unwrap(); + + let loaded = read_session_header("child").unwrap().unwrap(); + assert_eq!(loaded.session_id, "child"); + assert_eq!(loaded.forked_from.as_deref(), Some("parent")); + assert_eq!( + loaded.forked_at_uuid.as_deref(), + Some("00000000-0000-0000-0000-000000000003") + ); + assert!(write_session_header(&header).is_err()); + } + + #[test] + #[serial_test::serial] + fn test_copy_transcript_entries_stops_at_uuid_and_rewrites_session_id() { + let temp = tempdir().unwrap(); + let _g = HomeGuard::set(temp.path()); + + let uuids = [ + "11111111-1111-1111-1111-111111111111", + "22222222-2222-2222-2222-222222222222", + "33333333-3333-3333-3333-333333333333", + "44444444-4444-4444-4444-444444444444", + ]; + seed_parent_transcript("parent", &uuids); + + write_session_header(&SessionHeader { + timestamp: 1, + session_id: "child".into(), + msg_type: "session_header".into(), + forked_from: Some("parent".into()), + forked_at_uuid: Some(uuids[2].into()), + title: None, + }) + .unwrap(); + + let copied = copy_transcript_entries("parent", "child", Some(uuids[2])).unwrap(); + assert_eq!(copied, 3); + + let child_content = + std::fs::read_to_string(get_transcript_file("child")).unwrap(); + let lines: Vec<&str> = child_content.lines().filter(|l| !l.is_empty()).collect(); + assert_eq!(lines.len(), 4); + + for line in &lines[1..] { + let v: serde_json::Value = serde_json::from_str(line).unwrap(); + assert_eq!(v.get("session_id").and_then(|v| v.as_str()), Some("child")); + } + } + + #[test] + #[serial_test::serial] + fn test_copy_transcript_entries_skips_source_header() { + let temp = tempdir().unwrap(); + let _g = HomeGuard::set(temp.path()); + + let parent_path = get_transcript_file("parent2"); + std::fs::create_dir_all(get_transcript_dir()).unwrap(); + let header = serde_json::json!({ + "timestamp": 0, "session_id": "parent2", "msg_type": "session_header", + "forked_from": null, "forked_at_uuid": null, + }); + let msg = serde_json::json!({ + "timestamp": 1, "session_id": "parent2", "msg_type": "user", + "uuid": "55555555-5555-5555-5555-555555555555", + "payload": { "text": "hello" } + }); + std::fs::write(&parent_path, format!("{}\n{}\n", header, msg)).unwrap(); + + write_session_header(&SessionHeader { + timestamp: 1, session_id: "child2".into(), msg_type: "session_header".into(), + forked_from: Some("parent2".into()), forked_at_uuid: None, title: None, + }) + .unwrap(); + + let copied = copy_transcript_entries("parent2", "child2", None).unwrap(); + assert_eq!(copied, 1); + } } diff --git a/crates/claude-code-rs/src/commands/branch.rs b/crates/claude-code-rs/src/commands/branch.rs index 79c7093d..151f391b 100644 --- a/crates/claude-code-rs/src/commands/branch.rs +++ b/crates/claude-code-rs/src/commands/branch.rs @@ -1,93 +1,93 @@ -//! `/branch` command — show or manage git branches. +//! `/branch` command — fork the current conversation (transcript-level). //! -//! Without arguments: lists all local branches with current branch marked. -//! With arguments: creates or switches to a branch. +//! Issue #36: align `/branch` with the TypeScript reference behavior. This is +//! **not** a git-checkout wrapper — that's `/gbranch`. Instead, `/branch` +//! produces a copy of the current transcript under a freshly allocated +//! session ID, preserving message UUIDs/content while rewriting the envelope +//! `session_id`, and writes a `session_header` record carrying +//! `forked_from` + `forked_at_uuid` metadata. +//! +//! Usage: +//! - `/branch` — fork the current conversation at the latest message +//! +//! The command prints a resume hint. Automatic switch-into-fork requires +//! runtime state surgery (reloading the engine's session pointer) which is +//! out of scope for this change — see the TODO at the call site. +//! +//! To use the git-branch wrapper, run `/gbranch` or `/gitbranch`. use anyhow::Result; use async_trait::async_trait; use super::{CommandContext, CommandHandler, CommandResult}; -use crate::utils::git; +use crate::bootstrap::SessionId; +use crate::session::fork as session_fork; pub struct BranchHandler; #[async_trait] impl CommandHandler for BranchHandler { async fn execute(&self, args: &str, ctx: &mut CommandContext) -> Result { - if !git::is_git_repo(&ctx.cwd) { - return Ok(CommandResult::Output( - "Error: not in a git repository.".to_string(), - )); - } - + // Currently /branch takes no arguments. Accept-and-ignore any extra + // input so users who habitually append context don't see a cryptic + // error, but warn them about `/gbranch` for the git case. let args = args.trim(); - - if args.is_empty() { - // List branches - let branches = git::list_branches(&ctx.cwd)?; - - if branches.is_empty() { - return Ok(CommandResult::Output("No branches found.".to_string())); - } - - let mut lines = Vec::new(); - for b in &branches { - let marker = if b.is_head { "* " } else { " " }; - lines.push(format!("{}{}", marker, b.name)); - } - - Ok(CommandResult::Output(lines.join("\n"))) - } else { - // Switch or create branch via git - let output = std::process::Command::new("git") - .args(["checkout", args]) - .current_dir(&ctx.cwd) - .output(); - - match output { - Ok(out) if out.status.success() => { - let msg = String::from_utf8_lossy(&out.stdout); - let err = String::from_utf8_lossy(&out.stderr); - // git checkout prints to stderr - let display = if !err.trim().is_empty() { - err.trim().to_string() - } else { - msg.trim().to_string() - }; - Ok(CommandResult::Output(format!( - "Switched to branch '{}'.\n{}", - args, display - ))) - } - Ok(_out) => { - // Branch doesn't exist — try creating it - let create = std::process::Command::new("git") - .args(["checkout", "-b", args]) - .current_dir(&ctx.cwd) - .output(); - - match create { - Ok(c) if c.status.success() => Ok(CommandResult::Output(format!( - "Created and switched to new branch '{}'.", - args - ))), - Ok(c) => { - let stderr = String::from_utf8_lossy(&c.stderr); - Ok(CommandResult::Output(format!( - "Failed to switch/create branch '{}':\n{}", - args, - stderr.trim() - ))) - } - Err(e) => Ok(CommandResult::Output(format!("Failed to run git: {}", e))), - } - } - Err(e) => Ok(CommandResult::Output(format!("Failed to run git: {}", e))), - } + if !args.is_empty() { + return Ok(CommandResult::Output(format!( + "/branch takes no arguments and forks the current conversation.\n\ + Did you mean `/gbranch {}` (git branch wrapper)?", + args + ))); } + + let parent_session_id = ctx.session_id.as_str().to_string(); + let new_session_id = SessionId::new(); + let cwd = ctx.cwd.to_string_lossy().to_string(); + + let outcome = session_fork::fork_session( + &parent_session_id, + new_session_id.as_str(), + &ctx.messages, + &cwd, + None, + )?; + + // TODO: automatically switch the engine's session pointer so the user + // lands in the fork without needing /resume. Doing this correctly + // requires coordinating with QueryEngine state (abort in-flight work, + // swap SessionId, reset transcript flush targets) — deferred to a + // follow-up change. For now, print a resume hint. + + let short = short_id(outcome.new_session_id.as_str()); + let lines = vec![ + format!("Forked session -> {}.", outcome.new_session_id), + format!(" parent: {}", outcome.parent_session_id), + format!( + " fork point: {}", + outcome + .forked_at_uuid + .as_deref() + .unwrap_or("(none — empty conversation)") + ), + format!( + " copied: {} transcript entries", + outcome.copied_entry_count + ), + format!(" title: {}", outcome.title), + String::new(), + format!("Resume with `/resume {}`.", short), + ]; + + Ok(CommandResult::Output(lines.join("\n"))) } } +/// Truncate a UUID-like session ID to its first 8 characters, which is enough +/// to disambiguate forks in the user-facing resume hint. +fn short_id(id: &str) -> String { + id.chars().take(8).collect() +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -95,49 +95,127 @@ impl CommandHandler for BranchHandler { #[cfg(test)] mod tests { use super::*; - use crate::bootstrap::SessionId; + use crate::session::{storage, transcript}; use crate::types::app_state::AppState; - use std::path::PathBuf; + use crate::types::message::{Message, MessageContent, UserMessage}; + use std::path::{Path, PathBuf}; + use uuid::Uuid; - fn test_ctx(cwd: PathBuf) -> CommandContext { - CommandContext { - messages: Vec::new(), - cwd, - app_state: AppState::default(), - session_id: SessionId::from_string("test-session"), + struct HomeGuard { + previous: Option, + } + + impl HomeGuard { + fn set(path: &Path) -> Self { + let previous = std::env::var("CC_RUST_HOME").ok(); + std::env::set_var("CC_RUST_HOME", path); + Self { previous } } } - #[tokio::test] - async fn test_branch_not_in_git_repo_list() { - let handler = BranchHandler; - let mut ctx = test_ctx(PathBuf::from("/nonexistent/fake/path")); - let result = handler.execute("", &mut ctx).await.unwrap(); - match result { - CommandResult::Output(text) => { - assert!( - text.contains("not in a git repository"), - "expected error message, got: {}", - text - ); + impl Drop for HomeGuard { + fn drop(&mut self) { + match &self.previous { + Some(v) => std::env::set_var("CC_RUST_HOME", v), + None => std::env::remove_var("CC_RUST_HOME"), } - _ => panic!("Expected Output"), } } + fn user_msg(text: &str) -> Message { + Message::User(UserMessage { + uuid: Uuid::new_v4(), + timestamp: 0, + role: "user".into(), + content: MessageContent::Text(text.into()), + is_meta: false, + tool_use_result: None, + source_tool_assistant_uuid: None, + }) + } + + fn seed_parent(session_id: &str, messages: &[Message], cwd: &str) { + // Write a matching transcript on disk. + let dir = transcript::get_transcript_dir(); + std::fs::create_dir_all(&dir).unwrap(); + let path = transcript::get_transcript_file(session_id); + let mut buf = String::new(); + for (i, m) in messages.iter().enumerate() { + let entry = serde_json::json!({ + "timestamp": 1_700_000_000_000_i64 + i as i64, + "session_id": session_id, + "msg_type": "user", + "uuid": m.uuid().to_string(), + "payload": { "text": format!("msg {}", i) } + }); + buf.push_str(&serde_json::to_string(&entry).unwrap()); + buf.push('\n'); + } + std::fs::write(&path, buf).unwrap(); + // And a session file so load_session_info works during title derivation. + storage::save_session(session_id, messages, cwd).unwrap(); + } + + #[tokio::test] + #[serial_test::serial] + async fn test_branch_forks_current_conversation() { + let temp = tempfile::tempdir().unwrap(); + let _g = HomeGuard::set(temp.path()); + + let messages = vec![user_msg("first prompt"), user_msg("second prompt")]; + let parent_id = "parent-branch-test"; + seed_parent(parent_id, &messages, "/proj"); + + let mut ctx = CommandContext { + messages: messages.clone(), + cwd: PathBuf::from("/proj"), + app_state: AppState::default(), + session_id: SessionId::from_string(parent_id), + }; + + let result = BranchHandler.execute("", &mut ctx).await.unwrap(); + let text = match result { + CommandResult::Output(t) => t, + _ => panic!("expected Output"), + }; + + assert!(text.starts_with("Forked session"), "got: {}", text); + assert!(text.contains("Resume with `/resume")); + assert!(text.contains(parent_id), "parent id missing: {}", text); + + // The parent transcript must still be intact and unchanged. + let parent_transcript = + std::fs::read_to_string(transcript::get_transcript_file(parent_id)).unwrap(); + let parent_lines: Vec<&str> = + parent_transcript.lines().filter(|l| !l.is_empty()).collect(); + assert_eq!(parent_lines.len(), 2); + } + #[tokio::test] - async fn test_branch_not_in_git_repo_with_name() { - let handler = BranchHandler; - let mut ctx = test_ctx(PathBuf::from("/nonexistent/fake/path")); - let result = handler - .execute("feature/my-branch", &mut ctx) - .await - .unwrap(); + #[serial_test::serial] + async fn test_branch_rejects_arguments_and_suggests_gbranch() { + let temp = tempfile::tempdir().unwrap(); + let _g = HomeGuard::set(temp.path()); + + let mut ctx = CommandContext { + messages: Vec::new(), + cwd: PathBuf::from("/proj"), + app_state: AppState::default(), + session_id: SessionId::from_string("noop"), + }; + + let result = BranchHandler.execute("feature/foo", &mut ctx).await.unwrap(); match result { CommandResult::Output(text) => { - assert!(text.contains("not in a git repository")); + assert!(text.contains("/gbranch"), "got: {}", text); + assert!(text.contains("feature/foo")); } - _ => panic!("Expected Output"), + _ => panic!("expected Output"), } } + + #[test] + fn test_short_id_truncates_to_eight_chars() { + assert_eq!(short_id("abcdef12-3456-7890-abcd-ef1234567890"), "abcdef12"); + } } diff --git a/crates/claude-code-rs/src/commands/config_cmd.rs b/crates/claude-code-rs/src/commands/config_cmd.rs index c3f21f31..19a206e8 100644 --- a/crates/claude-code-rs/src/commands/config_cmd.rs +++ b/crates/claude-code-rs/src/commands/config_cmd.rs @@ -256,6 +256,12 @@ fn handle_show(parts: &[&str], ctx: &CommandContext) -> Result { "claudeInChromeDefaultEnabled", &mut lines, ); + row( + "autoMemoryEnabled", + opt_str(state.settings.auto_memory_enabled.map(|b| b.to_string())), + "autoMemoryEnabled", + &mut lines, + ); lines.push(String::new()); lines.push("File locations:".into()); @@ -390,7 +396,8 @@ fn handle_set(parts: &[&str], ctx: &mut CommandContext) -> Result Available keys: model, backend, theme, verbose, permissionMode,\n \ outputStyle, language, voiceEnabled, editorMode, viewMode,\n \ terminalProgressBarEnabled, effortLevel, fastMode,\n \ - fastModePerSessionOptIn, teammateMode, claudeInChromeDefaultEnabled\n\n{}", + fastModePerSessionOptIn, teammateMode, claudeInChromeDefaultEnabled,\n \ + autoMemoryEnabled\n\n{}", usage_text() ))); } @@ -501,6 +508,10 @@ fn apply_set_in_memory(key: &str, value: &str, ctx: &mut CommandContext) -> Resu s.claude_in_chrome_default_enabled = parsed_bool_opt(); Ok(format!("Claude-in-Chrome default: {}", parsed_bool())) } + "autoMemoryEnabled" | "auto_memory_enabled" => { + s.auto_memory_enabled = parsed_bool_opt(); + Ok(format!("Auto-memory enabled: {}", parsed_bool())) + } _ => anyhow::bail!( "Unknown config key: '{}'. Run `/config show` to see available keys.", key @@ -579,6 +590,7 @@ fn apply_set_to_raw(raw: &mut RawSettings, key: &str, value: &str) { "claudeInChromeDefaultEnabled" | "claude_in_chrome_default_enabled" => { raw.claude_in_chrome_default_enabled = bool_opt(); } + "autoMemoryEnabled" | "auto_memory_enabled" => raw.auto_memory_enabled = bool_opt(), // Unknown keys get stuffed in `extra` so users can experiment with // future fields without losing data. _ => { diff --git a/crates/claude-code-rs/src/commands/context.rs b/crates/claude-code-rs/src/commands/context.rs index b1f487bc..4dc83e81 100644 --- a/crates/claude-code-rs/src/commands/context.rs +++ b/crates/claude-code-rs/src/commands/context.rs @@ -1,65 +1,29 @@ -//! /context command -- display context usage information. +//! `/context` — show the effective, post-compact API view of the conversation. //! -//! Shows an overview of the conversation context: message count, estimated -//! token usage, and model information. In the TypeScript version this calls -//! `analyzeContextUsage()` with full system prompt analysis. The Rust CLI -//! provides a simplified local estimate since full token counting requires -//! an API connection. +//! Delegates to [`cc_compact::context_analysis::analyze_context_usage`] +//! which runs the same snip + microcompact transforms as the real send +//! pipeline, categorises the result into tracked buckets (messages, +//! system prompt, skills, file cache, tool schemas, hook results, +//! free budget) and renders it either as a TUI token grid or as JSON. +//! +//! Subcommands (matching the pattern established by `/doctor`): +//! +//! ```text +//! /context — rendered TUI output (token grid + percentages) +//! /context json — machine-readable JSON (headless / scripting) +//! /context raw — alias for `json` +//! ``` use anyhow::Result; use async_trait::async_trait; +use cc_compact::context_analysis::{ + analyze_context_usage, ContextAnalysis, ContextAnalysisInput, +}; use super::{CommandContext, CommandHandler, CommandResult}; -use crate::types::message::Message; -/// Handler for the `/context` slash command. pub struct ContextHandler; -/// Rough estimate of tokens in a message for display purposes. -/// -/// Uses a simple heuristic of ~4 characters per token. The real -/// implementation requires a tokenizer (tiktoken / API-based counting). -fn estimate_message_tokens(msg: &Message) -> u64 { - let text_len = match msg { - Message::User(u) => match &u.content { - crate::types::message::MessageContent::Text(t) => t.len(), - crate::types::message::MessageContent::Blocks(blocks) => { - blocks.iter().map(|b| estimate_block_chars(b)).sum() - } - }, - Message::Assistant(a) => a.content.iter().map(|b| estimate_block_chars(b)).sum(), - Message::System(s) => s.content.len(), - Message::Progress(p) => p.data.to_string().len(), - Message::Attachment(_a) => { - // Rough estimate for attachment metadata. - 50 - } - }; - - // ~4 chars per token is a common rough estimate. - (text_len as u64 / 4).max(1) -} - -/// Estimate character count for a content block. -fn estimate_block_chars(block: &crate::types::message::ContentBlock) -> usize { - match block { - crate::types::message::ContentBlock::Text { text } => text.len(), - crate::types::message::ContentBlock::ToolUse { name, input, .. } => { - name.len() + input.to_string().len() - } - crate::types::message::ContentBlock::ToolResult { content, .. } => match content { - crate::types::message::ToolResultContent::Text(t) => t.len(), - crate::types::message::ToolResultContent::Blocks(blocks) => { - blocks.iter().map(|b| estimate_block_chars(b)).sum() - } - }, - crate::types::message::ContentBlock::Thinking { thinking, .. } => thinking.len(), - crate::types::message::ContentBlock::RedactedThinking { data } => data.len(), - crate::types::message::ContentBlock::Image { .. } => 1000, // Images use many tokens. - } -} - -/// Format a token count for display. fn format_tokens(n: u64) -> String { if n >= 1_000_000 { format!("{:.1}M", n as f64 / 1_000_000.0) @@ -70,56 +34,93 @@ fn format_tokens(n: u64) -> String { } } +fn render_bar(percent: f32, width: usize) -> String { + let pct = percent.clamp(0.0, 100.0); + let filled = ((pct / 100.0) * width as f32).round() as usize; + let filled = filled.min(width); + let empty = width - filled; + let mut bar = String::with_capacity(width); + for _ in 0..filled { bar.push('\u{2588}'); } + for _ in 0..empty { bar.push('\u{2591}'); } + bar +} + +fn render_tui(report: &ContextAnalysis) -> String { + let mut lines: Vec = Vec::new(); + lines.push("## Context Usage".into()); + lines.push(String::new()); + lines.push(format!("**Model:** {}", report.model)); + lines.push(format!("**Window:** {} tokens", format_tokens(report.context_window))); + lines.push(format!( + "**Used:** {} / {} ({:.1}%){}", + format_tokens(report.total_used), + format_tokens(report.context_window), + report.total_percent, + if report.compacted { " [compacted]" } else { "" }, + )); + lines.push(format!( + "**Messages:** {} in -> {} after pre-send pipeline", + report.messages_in, report.messages_out, + )); + lines.push(String::new()); + lines.push("### Breakdown".into()); + lines.push(String::new()); + let bar_width = 24; + let label_width = report.categories.iter().map(|c| c.label.len()).max().unwrap_or(0); + for cat in &report.categories { + lines.push(format!( + " {:7} {:>5.1}%", + cat.label, + render_bar(cat.percent, bar_width), + format_tokens(cat.tokens), + cat.percent, + lw = label_width, + )); + } + lines.push(String::new()); + lines.push( + "Note: token counts are estimated with the standard ~4-chars/token \ + heuristic. Snip + microcompact are simulated; the async \ + tool-result-budget pass is skipped." + .into(), + ); + lines.join("\n") +} + #[async_trait] impl CommandHandler for ContextHandler { - async fn execute(&self, _args: &str, ctx: &mut CommandContext) -> Result { - let messages = &ctx.messages; - let model = &ctx.app_state.main_loop_model; - - if messages.is_empty() { - return Ok(CommandResult::Output( - "Context is empty -- no messages in the conversation.".into(), - )); - } - - let mut user_msgs = 0u64; - let mut assistant_msgs = 0u64; - let mut system_msgs = 0u64; - let mut total_tokens: u64 = 0; - - for msg in messages { - match msg { - Message::User(_) => user_msgs += 1, - Message::Assistant(_) => assistant_msgs += 1, - Message::System(_) => system_msgs += 1, - _ => {} + async fn execute(&self, args: &str, ctx: &mut CommandContext) -> Result { + let mode = args.trim().to_ascii_lowercase(); + let hook_results_str = if ctx.app_state.hooks.is_empty() { + None + } else { + serde_json::to_string(&ctx.app_state.hooks).ok() + }; + let input = ContextAnalysisInput { + messages: &ctx.messages, + system_prompt: None, + skills_manifest: None, + cached_files_chars: 0, + tools_schema: None, + hook_results: hook_results_str.as_deref(), + model: &ctx.app_state.main_loop_model, + }; + let report = analyze_context_usage(input); + match mode.as_str() { + "json" | "raw" => { + let json = serde_json::to_string_pretty(&report) + .unwrap_or_else(|e| format!("(serialisation error: {})", e)); + Ok(CommandResult::Output(json)) } - total_tokens += estimate_message_tokens(msg); + "" | "tui" | "full" => Ok(CommandResult::Output(render_tui(&report))), + other => Ok(CommandResult::Output(format!( + "Unknown /context subcommand '{}'.\n\n\ + Usage:\n \ + /context - rendered TUI output (token grid + percentages)\n \ + /context json - machine-readable JSON (headless / scripting)\n", + other + ))), } - - let mut lines = Vec::new(); - lines.push("## Context Usage".into()); - lines.push(String::new()); - lines.push(format!("**Model:** {}", model)); - lines.push(format!( - "**Tokens:** ~{} (estimated, local heuristic)", - format_tokens(total_tokens) - )); - lines.push(String::new()); - lines.push("### Message breakdown".into()); - lines.push(String::new()); - lines.push(format!(" User messages: {}", user_msgs)); - lines.push(format!(" Assistant messages: {}", assistant_msgs)); - lines.push(format!(" System messages: {}", system_msgs)); - lines.push(format!(" Total messages: {}", messages.len())); - lines.push(String::new()); - lines.push( - "Note: Accurate token counts require an API connection. \ - Counts shown here are rough estimates." - .into(), - ); - - Ok(CommandResult::Output(lines.join("\n"))) } } @@ -154,20 +155,22 @@ mod tests { } #[tokio::test] - async fn test_context_empty() { + async fn test_context_empty_renders_tui() { let handler = ContextHandler; let mut ctx = test_ctx(); let result = handler.execute("", &mut ctx).await.unwrap(); match result { CommandResult::Output(text) => { - assert!(text.contains("empty")); + assert!(text.contains("Context Usage")); + assert!(text.contains("Breakdown")); + assert!(text.contains("free")); } _ => panic!("Expected Output result"), } } #[tokio::test] - async fn test_context_with_messages() { + async fn test_context_with_messages_tui() { let handler = ContextHandler; let mut ctx = test_ctx(); ctx.messages = vec![ @@ -178,8 +181,57 @@ mod tests { match result { CommandResult::Output(text) => { assert!(text.contains("Context Usage")); - assert!(text.contains("User messages:")); - assert!(text.contains("2")); + assert!(text.contains("messages")); + assert!(text.contains(&ctx.app_state.main_loop_model)); + } + _ => panic!("Expected Output result"), + } + } + + #[tokio::test] + async fn test_context_json_output_parses() { + let handler = ContextHandler; + let mut ctx = test_ctx(); + ctx.messages = vec![make_user_msg("hello")]; + let result = handler.execute("json", &mut ctx).await.unwrap(); + let text = match result { + CommandResult::Output(text) => text, + _ => panic!("Expected Output result"), + }; + let parsed: serde_json::Value = + serde_json::from_str(&text).expect("/context json must emit valid JSON"); + assert!(parsed.get("model").is_some()); + assert!(parsed.get("context_window").is_some()); + let total_used = parsed.get("total_used").unwrap().as_u64().unwrap(); + assert!(total_used > 0); + let total_pct = parsed.get("total_percent").unwrap().as_f64().unwrap(); + assert!(total_pct >= 0.0 && total_pct <= 100.0); + let cats = parsed.get("categories").unwrap().as_array().unwrap(); + assert!(!cats.is_empty()); + } + + #[tokio::test] + async fn test_context_json_alias_raw() { + let handler = ContextHandler; + let mut ctx = test_ctx(); + let result = handler.execute("raw", &mut ctx).await.unwrap(); + match result { + CommandResult::Output(text) => { + let _parsed: serde_json::Value = serde_json::from_str(&text).unwrap(); + } + _ => panic!("Expected Output result"), + } + } + + #[tokio::test] + async fn test_context_unknown_subcommand() { + let handler = ContextHandler; + let mut ctx = test_ctx(); + let result = handler.execute("wobble", &mut ctx).await.unwrap(); + match result { + CommandResult::Output(text) => { + assert!(text.contains("Unknown /context subcommand")); + assert!(text.contains("wobble")); } _ => panic!("Expected Output result"), } @@ -191,4 +243,17 @@ mod tests { assert_eq!(format_tokens(1500), "1.5K"); assert_eq!(format_tokens(1_500_000), "1.5M"); } + + #[test] + fn test_render_bar_bounds() { + let zero = render_bar(0.0, 10); + assert_eq!(zero.chars().filter(|c| *c == '\u{2588}').count(), 0); + let full = render_bar(100.0, 10); + assert_eq!(full.chars().filter(|c| *c == '\u{2588}').count(), 10); + let half = render_bar(50.0, 10); + let filled = half.chars().filter(|c| *c == '\u{2588}').count(); + assert!(filled == 5, "expected 5 filled chars at 50%, got {}", filled); + let over = render_bar(999.0, 10); + assert_eq!(over.chars().filter(|c| *c == '\u{2588}').count(), 10); + } } diff --git a/crates/claude-code-rs/src/commands/gbranch.rs b/crates/claude-code-rs/src/commands/gbranch.rs new file mode 100644 index 00000000..814c3023 --- /dev/null +++ b/crates/claude-code-rs/src/commands/gbranch.rs @@ -0,0 +1,148 @@ +//! `/gbranch` command — show or manage **git** branches. +//! +//! Without arguments: lists all local branches with current branch marked. +//! With arguments: creates or switches to a branch via `git checkout`. +//! +//! History: this handler used to live at `/branch`. Issue #36 repurposed +//! `/branch` for conversation forking (transcript-level branching), so the +//! git-wrapper behavior was renamed to `/gbranch` (alias: `gitbranch`). The +//! previous `br` alias now routes to the new `/branch` command. + +use anyhow::Result; +use async_trait::async_trait; + +use super::{CommandContext, CommandHandler, CommandResult}; +use crate::utils::git; + +pub struct GitBranchHandler; + +#[async_trait] +impl CommandHandler for GitBranchHandler { + async fn execute(&self, args: &str, ctx: &mut CommandContext) -> Result { + if !git::is_git_repo(&ctx.cwd) { + return Ok(CommandResult::Output( + "Error: not in a git repository.".to_string(), + )); + } + + let args = args.trim(); + + if args.is_empty() { + // List branches + let branches = git::list_branches(&ctx.cwd)?; + + if branches.is_empty() { + return Ok(CommandResult::Output("No branches found.".to_string())); + } + + let mut lines = Vec::new(); + for b in &branches { + let marker = if b.is_head { "* " } else { " " }; + lines.push(format!("{}{}", marker, b.name)); + } + + Ok(CommandResult::Output(lines.join("\n"))) + } else { + // Switch or create branch via git + let output = std::process::Command::new("git") + .args(["checkout", args]) + .current_dir(&ctx.cwd) + .output(); + + match output { + Ok(out) if out.status.success() => { + let msg = String::from_utf8_lossy(&out.stdout); + let err = String::from_utf8_lossy(&out.stderr); + // git checkout prints to stderr + let display = if !err.trim().is_empty() { + err.trim().to_string() + } else { + msg.trim().to_string() + }; + Ok(CommandResult::Output(format!( + "Switched to branch '{}'.\n{}", + args, display + ))) + } + Ok(_out) => { + // Branch doesn't exist — try creating it + let create = std::process::Command::new("git") + .args(["checkout", "-b", args]) + .current_dir(&ctx.cwd) + .output(); + + match create { + Ok(c) if c.status.success() => Ok(CommandResult::Output(format!( + "Created and switched to new branch '{}'.", + args + ))), + Ok(c) => { + let stderr = String::from_utf8_lossy(&c.stderr); + Ok(CommandResult::Output(format!( + "Failed to switch/create branch '{}':\n{}", + args, + stderr.trim() + ))) + } + Err(e) => Ok(CommandResult::Output(format!("Failed to run git: {}", e))), + } + } + Err(e) => Ok(CommandResult::Output(format!("Failed to run git: {}", e))), + } + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::bootstrap::SessionId; + use crate::types::app_state::AppState; + use std::path::PathBuf; + + fn test_ctx(cwd: PathBuf) -> CommandContext { + CommandContext { + messages: Vec::new(), + cwd, + app_state: AppState::default(), + session_id: SessionId::from_string("test-session"), + } + } + + #[tokio::test] + async fn test_gbranch_not_in_git_repo_list() { + let handler = GitBranchHandler; + let mut ctx = test_ctx(PathBuf::from("/nonexistent/fake/path")); + let result = handler.execute("", &mut ctx).await.unwrap(); + match result { + CommandResult::Output(text) => { + assert!( + text.contains("not in a git repository"), + "expected error message, got: {}", + text + ); + } + _ => panic!("Expected Output"), + } + } + + #[tokio::test] + async fn test_gbranch_not_in_git_repo_with_name() { + let handler = GitBranchHandler; + let mut ctx = test_ctx(PathBuf::from("/nonexistent/fake/path")); + let result = handler + .execute("feature/my-branch", &mut ctx) + .await + .unwrap(); + match result { + CommandResult::Output(text) => { + assert!(text.contains("not in a git repository")); + } + _ => panic!("Expected Output"), + } + } +} diff --git a/crates/claude-code-rs/src/commands/memory.rs b/crates/claude-code-rs/src/commands/memory.rs index f701422e..dbf484ff 100644 --- a/crates/claude-code-rs/src/commands/memory.rs +++ b/crates/claude-code-rs/src/commands/memory.rs @@ -1,22 +1,39 @@ -//! `/memory` command — view and manage CLAUDE.md project instructions + memdir entries. +//! `/memory` command — default entry point is a memory selector that +//! surfaces auto-memory state and exposes every scope (global, project, +//! team, auto) together with the nearest CLAUDE.md files. //! -//! Subcommands: -//! show — Display CLAUDE.md content (default) +//! Subcommands (issue #45): +//! (no args) — Print the selector (grouped listing of every scope + +//! auto-memory header + directory shortcuts) +//! show — Display CLAUDE.md content //! path — Show CLAUDE.md file locations //! edit — Create/locate CLAUDE.md for editing -//! list — List all memdir entries (project + global) +//! list — List memdir entries across every scope //! get — Read a memdir entry -//! set [--global] [--category=] — Write a memdir entry -//! rm [--global] — Delete a memdir entry -//! search — Search memdir entries +//! set [--global|--team|--auto] [--category=] +//! rm [--global|--team|--auto] +//! search +//! auto on|off|status — Toggle auto-memory capture/injection +//! open — Print/ensure-and-open a dir +//! +//! # TODO +//! - The selector is currently a formatted listing; a real interactive TUI +//! picker is a future improvement that belongs in the ink-terminal +//! frontend, not here. +//! - The `auto` toggle only persists `auto_memory_enabled`; the actual +//! capture hook is a separate change. use anyhow::Result; use async_trait::async_trait; use std::fs; +use std::path::{Path, PathBuf}; use super::{CommandContext, CommandHandler, CommandResult}; use crate::config::claude_md; -use crate::session::memdir::{self, MemoryScope}; +use crate::config::features::{self, Feature}; +use crate::config::paths as cfg_paths; +use crate::config::settings; +use crate::session::memdir::{self, MemoryEntry, MemoryScope}; pub struct MemoryHandler; @@ -24,13 +41,15 @@ pub struct MemoryHandler; impl CommandHandler for MemoryHandler { async fn execute(&self, args: &str, ctx: &mut CommandContext) -> Result { let parts: Vec<&str> = args.trim().splitn(3, char::is_whitespace).collect(); - let subcommand = parts.first().copied().unwrap_or("show"); + let subcommand = parts.first().copied().unwrap_or(""); match subcommand { - "" | "show" => show_memory(&ctx.cwd), + // Default entry point — selector view (issue #45). + "" => selector(ctx), + "show" => show_memory(&ctx.cwd), "path" => show_paths(&ctx.cwd), "edit" => edit_memory(&ctx.cwd), - "list" | "ls" => list_entries(&ctx.cwd), + "list" | "ls" => list_entries(ctx), "get" => { let key = parts.get(1).unwrap_or(&""); if key.is_empty() { @@ -45,7 +64,7 @@ impl CommandHandler for MemoryHandler { let rest = parts.get(2).copied().unwrap_or(""); if key.is_empty() || rest.is_empty() { return Ok(CommandResult::Output( - "Usage: /memory set [--global] [--category=]" + "Usage: /memory set [--global|--team|--auto] [--category=]" .to_string(), )); } @@ -56,14 +75,10 @@ impl CommandHandler for MemoryHandler { let flag = parts.get(2).copied().unwrap_or(""); if key.is_empty() { return Ok(CommandResult::Output( - "Usage: /memory rm [--global]".to_string(), + "Usage: /memory rm [--global|--team|--auto]".to_string(), )); } - let scope = if flag.contains("--global") { - MemoryScope::Global - } else { - MemoryScope::Project - }; + let scope = parse_scope_flag(flag); rm_entry(key, scope, &ctx.cwd) } "search" | "find" => { @@ -75,19 +90,31 @@ impl CommandHandler for MemoryHandler { } search_entries(query, &ctx.cwd) } + "auto" => { + let action = parts.get(1).copied().unwrap_or("status"); + auto_toggle(action, ctx) + } + "open" => { + let which = parts.get(1).copied().unwrap_or(""); + open_dir(which, &ctx.cwd) + } _ => Ok(CommandResult::Output( - "Usage: /memory [show|path|edit|list|get|set|rm|search]\n\n\ + "Usage: /memory [show|path|edit|list|get|set|rm|search|auto|open]\n\n\ + (no args) — Interactive memory selector (default)\n\n\ CLAUDE.md:\n\ - \x20 show — Display current CLAUDE.md content (default)\n\ + \x20 show — Display current CLAUDE.md content\n\ \x20 path — Show CLAUDE.md file locations\n\ \x20 edit — Create/locate CLAUDE.md for editing\n\n\ Memory entries:\n\ - \x20 list — List all memory entries\n\ - \x20 get — Read a memory entry\n\ - \x20 set [--global] [--category=]\n\ - \x20 — Write/update a memory entry\n\ - \x20 rm [--global] — Delete a memory entry\n\ - \x20 search — Search memory entries" + \x20 list — List entries across all scopes\n\ + \x20 get — Read an entry (searches all scopes)\n\ + \x20 set [--global|--team|--auto] [--category=]\n\ + \x20 rm [--global|--team|--auto]\n\ + \x20 search — Substring match across entries\n\n\ + Auto-memory (issue #45):\n\ + \x20 auto on|off|status — Toggle auto-capture\n\ + \x20 open \n\ + \x20 — Print/open a scope directory" .to_string(), )), } @@ -95,10 +122,26 @@ impl CommandHandler for MemoryHandler { } // --------------------------------------------------------------------------- -// CLAUDE.md subcommands (unchanged) +// Scope-flag parsing // --------------------------------------------------------------------------- -fn show_memory(cwd: &std::path::Path) -> Result { +fn parse_scope_flag(flag: &str) -> MemoryScope { + if flag.contains("--global") { + MemoryScope::Global + } else if flag.contains("--team") { + MemoryScope::Team + } else if flag.contains("--auto") { + MemoryScope::Auto + } else { + MemoryScope::Project + } +} + +// --------------------------------------------------------------------------- +// CLAUDE.md subcommands +// --------------------------------------------------------------------------- + +fn show_memory(cwd: &Path) -> Result { let context = claude_md::build_claude_md_context(cwd)?; if context.is_empty() { @@ -115,7 +158,7 @@ fn show_memory(cwd: &std::path::Path) -> Result { } } -fn show_paths(cwd: &std::path::Path) -> Result { +fn show_paths(cwd: &Path) -> Result { let files = claude_md::find_claude_md_files(cwd); if files.is_empty() { @@ -132,7 +175,7 @@ fn show_paths(cwd: &std::path::Path) -> Result { } } -fn edit_memory(cwd: &std::path::Path) -> Result { +fn edit_memory(cwd: &Path) -> Result { let claude_md_path = cwd.join("CLAUDE.md"); if !claude_md_path.exists() { let template = "# CLAUDE.md\n\n\ @@ -147,15 +190,129 @@ fn edit_memory(cwd: &std::path::Path) -> Result { ))) } +// --------------------------------------------------------------------------- +// Selector (default entry point — issue #45) +// --------------------------------------------------------------------------- + +/// Build the default selector listing: auto-memory header + grouped entries +/// from every enabled scope + directory shortcuts. +/// +/// Output format: +/// Memory selector +/// Auto-memory: OFF (enable via /memory auto on) +/// +/// [1] [global] my_key — 2026-04-20 +/// [2] [project] auth_notes — 2026-04-18 +/// ... +/// [a] open auto-memory dir — +/// [t] open team-memory dir — +/// [g] open global memory dir — +/// [p] open project memory dir— +/// +/// The true interactive TUI picker belongs in the ink-terminal frontend; +/// see the module-level TODO. +fn selector(ctx: &CommandContext) -> Result { + let cwd = &ctx.cwd; + let auto_on = ctx.app_state.settings.auto_memory_enabled.unwrap_or(false); + let team_gate = features::enabled(Feature::TeamMemory); + + let mut lines = Vec::new(); + lines.push("**Memory selector**".to_string()); + lines.push(format!( + "Auto-memory: {} (toggle via /memory auto on|off)", + if auto_on { "ON" } else { "OFF" } + )); + lines.push(String::new()); + + // CLAUDE.md files at the top — unnumbered because they're content-only. + let md_files = claude_md::find_claude_md_files(cwd); + if !md_files.is_empty() { + lines.push(format!("CLAUDE.md files ({}):", md_files.len())); + for p in &md_files { + lines.push(format!(" {}", p.display())); + } + lines.push(String::new()); + } + + // Enumerate memory entries, grouped by scope. + let mut idx: usize = 0; + let mut any_entries = false; + + let mut emit_group = |label: &str, entries: &[MemoryEntry], lines: &mut Vec| { + if entries.is_empty() { + return; + } + any_entries = true; + for e in entries { + idx += 1; + let date = e + .updated_at + .split('T') + .next() + .unwrap_or(&e.updated_at) + .to_string(); + lines.push(format!(" [{}] [{}] {} — {}", idx, label, e.key, date)); + } + }; + + let global = memdir::list_memories(MemoryScope::Global, cwd).unwrap_or_default(); + let project = memdir::list_memories(MemoryScope::Project, cwd).unwrap_or_default(); + let team = memdir::list_memories(MemoryScope::Team, cwd).unwrap_or_default(); + let auto = memdir::list_memories(MemoryScope::Auto, cwd).unwrap_or_default(); + + emit_group("global", &global, &mut lines); + emit_group("project", &project, &mut lines); + if team_gate || !team.is_empty() { + // Show team entries even when the feature is gated off so legacy + // data is never stranded — only injection into prompts is gated. + emit_group("team", &team, &mut lines); + } + if auto_on || !auto.is_empty() { + // Same principle: show auto entries even when the toggle is off + // so users can inspect/purge past captures. + emit_group("auto", &auto, &mut lines); + } + + if !any_entries { + lines.push(" (no memory entries — use `/memory set ` to create one)".into()); + } + + lines.push(String::new()); + lines.push("Directory shortcuts:".into()); + lines.push(format!( + " [a] auto-memory dir — {}", + cfg_paths::auto_memory_dir().display() + )); + lines.push(format!( + " [t] team-memory dir — {}", + cfg_paths::team_memory_dir(cwd).display() + )); + lines.push(format!( + " [g] global memory dir — {}", + cfg_paths::memory_dir_global().display() + )); + lines.push(format!( + " [p] project memory dir — {}", + cwd.join(".cc-rust").join("memory").display() + )); + lines.push(String::new()); + lines.push("Open a directory with `/memory open `.".into()); + + Ok(CommandResult::Output(lines.join("\n"))) +} + // --------------------------------------------------------------------------- // Memdir subcommands // --------------------------------------------------------------------------- -fn list_entries(cwd: &std::path::Path) -> Result { +fn list_entries(ctx: &CommandContext) -> Result { + let cwd = &ctx.cwd; let project = memdir::list_memories(MemoryScope::Project, cwd).unwrap_or_default(); let global = memdir::list_memories(MemoryScope::Global, cwd).unwrap_or_default(); + let team = memdir::list_memories(MemoryScope::Team, cwd).unwrap_or_default(); + let auto = memdir::list_memories(MemoryScope::Auto, cwd).unwrap_or_default(); - if project.is_empty() && global.is_empty() { + if project.is_empty() && global.is_empty() && team.is_empty() && auto.is_empty() { return Ok(CommandResult::Output( "No memory entries found.\n\nUse `/memory set ` to create one." .to_string(), @@ -163,44 +320,48 @@ fn list_entries(cwd: &std::path::Path) -> Result { } let mut lines = Vec::new(); - - if !project.is_empty() { - lines.push(format!("**Project memories** ({})", project.len())); - for e in &project { - let cat = if e.category.is_empty() { - String::new() - } else { - format!(" [{}]", e.category) - }; - lines.push(format!(" {} — {}{}", e.key, truncate(&e.value, 60), cat)); - } + append_group(&mut lines, "Project memories", &project); + append_group(&mut lines, "Global memories", &global); + if features::enabled(Feature::TeamMemory) || !team.is_empty() { + append_group(&mut lines, "Team memories", &team); } - - if !global.is_empty() { - if !lines.is_empty() { - lines.push(String::new()); - } - lines.push(format!("**Global memories** ({})", global.len())); - for e in &global { - let cat = if e.category.is_empty() { - String::new() - } else { - format!(" [{}]", e.category) - }; - lines.push(format!(" {} — {}{}", e.key, truncate(&e.value, 60), cat)); - } + let auto_on = ctx.app_state.settings.auto_memory_enabled.unwrap_or(false); + if auto_on || !auto.is_empty() { + append_group(&mut lines, "Auto memories", &auto); } Ok(CommandResult::Output(lines.join("\n"))) } -fn get_entry(key: &str, cwd: &std::path::Path) -> Result { - // Try project first, then global - if let Ok(entry) = memdir::read_memory(key, MemoryScope::Project, cwd) { - return Ok(CommandResult::Output(format_entry(&entry, "project"))); +fn append_group(lines: &mut Vec, header: &str, entries: &[MemoryEntry]) { + if entries.is_empty() { + return; + } + if !lines.is_empty() { + lines.push(String::new()); } - if let Ok(entry) = memdir::read_memory(key, MemoryScope::Global, cwd) { - return Ok(CommandResult::Output(format_entry(&entry, "global"))); + lines.push(format!("**{}** ({})", header, entries.len())); + for e in entries { + let cat = if e.category.is_empty() { + String::new() + } else { + format!(" [{}]", e.category) + }; + lines.push(format!(" {} — {}{}", e.key, truncate(&e.value, 60), cat)); + } +} + +fn get_entry(key: &str, cwd: &Path) -> Result { + // Search project → global → team → auto. First hit wins. + for scope in [ + MemoryScope::Project, + MemoryScope::Global, + MemoryScope::Team, + MemoryScope::Auto, + ] { + if let Ok(entry) = memdir::read_memory(key, scope, cwd) { + return Ok(CommandResult::Output(format_entry(&entry, scope.as_str()))); + } } Ok(CommandResult::Output(format!( "Memory '{}' not found.", @@ -208,57 +369,74 @@ fn get_entry(key: &str, cwd: &std::path::Path) -> Result { ))) } -fn set_entry(key: &str, rest: &str, cwd: &std::path::Path) -> Result { +fn set_entry(key: &str, rest: &str, cwd: &Path) -> Result { // Parse flags from the value string let mut value_parts = Vec::new(); let mut scope = MemoryScope::Project; let mut category = String::new(); for token in rest.split_whitespace() { - if token == "--global" { - scope = MemoryScope::Global; - } else if let Some(cat) = token.strip_prefix("--category=") { - category = cat.to_string(); - } else { - value_parts.push(token); + match token { + "--global" => scope = MemoryScope::Global, + "--team" => scope = MemoryScope::Team, + "--auto" => scope = MemoryScope::Auto, + _ => { + if let Some(cat) = token.strip_prefix("--category=") { + category = cat.to_string(); + } else { + value_parts.push(token); + } + } } } let value = value_parts.join(" "); if value.is_empty() { return Ok(CommandResult::Output( - "Usage: /memory set [--global] [--category=]".to_string(), + "Usage: /memory set [--global|--team|--auto] [--category=]" + .to_string(), )); } - let scope_label = match scope { - MemoryScope::Project => "project", - MemoryScope::Global => "global", - }; - let entry = memdir::write_memory(key, &value, &category, scope, cwd)?; Ok(CommandResult::Output(format!( "Saved {} memory '{}': {}", - scope_label, entry.key, entry.value + scope.as_str(), + entry.key, + entry.value ))) } -fn rm_entry(key: &str, scope: MemoryScope, cwd: &std::path::Path) -> Result { +fn rm_entry(key: &str, scope: MemoryScope, cwd: &Path) -> Result { let deleted = memdir::delete_memory(key, scope, cwd)?; if deleted { - Ok(CommandResult::Output(format!("Deleted memory '{}'.", key))) - } else { Ok(CommandResult::Output(format!( - "Memory '{}' not found.", + "Deleted {} memory '{}'.", + scope.as_str(), key ))) + } else { + Ok(CommandResult::Output(format!( + "Memory '{}' not found in {} scope.", + key, + scope.as_str() + ))) } } -fn search_entries(query: &str, cwd: &std::path::Path) -> Result { - let mut results = memdir::search_memories(query, MemoryScope::Project, cwd).unwrap_or_default(); - let global = memdir::search_memories(query, MemoryScope::Global, cwd).unwrap_or_default(); - results.extend(global); +fn search_entries(query: &str, cwd: &Path) -> Result { + let mut results = Vec::new(); + for scope in [ + MemoryScope::Project, + MemoryScope::Global, + MemoryScope::Team, + MemoryScope::Auto, + ] { + let hits = memdir::search_memories(query, scope, cwd).unwrap_or_default(); + for entry in hits { + results.push((scope, entry)); + } + } if results.is_empty() { return Ok(CommandResult::Output(format!( @@ -272,17 +450,115 @@ fn search_entries(query: &str, cwd: &std::path::Path) -> Result { results.len(), query )]; - for e in &results { + for (scope, e) in &results { let cat = if e.category.is_empty() { String::new() } else { format!(" [{}]", e.category) }; - lines.push(format!(" {} — {}{}", e.key, truncate(&e.value, 60), cat)); + lines.push(format!( + " [{}] {} — {}{}", + scope.as_str(), + e.key, + truncate(&e.value, 60), + cat + )); } Ok(CommandResult::Output(lines.join("\n"))) } +// --------------------------------------------------------------------------- +// Auto-memory toggle (issue #45) +// --------------------------------------------------------------------------- + +fn auto_toggle(action: &str, ctx: &mut CommandContext) -> Result { + match action { + "on" | "off" => { + let on = action == "on"; + ctx.app_state.settings.auto_memory_enabled = Some(on); + + // Persist to the user-level settings.json so the toggle + // survives restarts. A write failure surfaces in the output + // but doesn't abort the session change. + let persist_msg = persist_auto_memory(on); + + Ok(CommandResult::Output(format!( + "Auto-memory: {}\n{}\n\nNote: the auto-capture hook is not yet wired — \ + this toggle persists the setting only.", + if on { "ON" } else { "OFF" }, + persist_msg + ))) + } + "status" | "" => { + let on = ctx.app_state.settings.auto_memory_enabled.unwrap_or(false); + Ok(CommandResult::Output(format!( + "Auto-memory: {} (setting key: autoMemoryEnabled)", + if on { "ON" } else { "OFF" } + ))) + } + _ => Ok(CommandResult::Output( + "Usage: /memory auto [on|off|status]".into(), + )), + } +} + +fn persist_auto_memory(on: bool) -> String { + let path = settings::user_settings_path(); + // Load-or-default so we don't clobber other fields. + let mut raw = settings::load_global_config().unwrap_or_default(); + raw.auto_memory_enabled = Some(on); + match settings::write_settings_file(&path, &raw) { + Ok(()) => format!("Persisted to {}", path.display()), + Err(e) => format!( + "Warning: could not persist setting to {}: {}", + path.display(), + e + ), + } +} + +// --------------------------------------------------------------------------- +// /memory open +// --------------------------------------------------------------------------- + +fn open_dir(which: &str, cwd: &Path) -> Result { + let (label, dir): (&str, PathBuf) = match which { + "auto" => ("auto-memory", cfg_paths::auto_memory_dir()), + "team" => ("team-memory", cfg_paths::team_memory_dir(cwd)), + "global" => ("global memory", cfg_paths::memory_dir_global()), + "project" => ("project memory", cwd.join(".cc-rust").join("memory")), + "" => { + return Ok(CommandResult::Output( + "Usage: /memory open ".into(), + )); + } + other => { + return Ok(CommandResult::Output(format!( + "Unknown scope: '{}'. Use auto|team|global|project.", + other + ))); + } + }; + + // Ensure the directory exists so the path resolves to something + // openable. We intentionally don't spawn an external editor — the UI + // layer (or the user) picks the right opener. + if let Err(e) = fs::create_dir_all(&dir) { + return Ok(CommandResult::Output(format!( + "Could not create {} dir {}: {}", + label, + dir.display(), + e + ))); + } + + Ok(CommandResult::Output(format!( + "{} directory:\n {}", + label, + dir.display() + ))) +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -341,17 +617,32 @@ mod tests { } } + /// Default entry point (no args) is the selector, not `show`. + /// This is the core UX change for issue #45. #[tokio::test] - async fn test_memory_empty_args_defaults_to_show() { + async fn test_memory_empty_args_is_selector() { let handler = MemoryHandler; let mut ctx = test_ctx(PathBuf::from("/nonexistent/fake/path")); - let result_empty = handler.execute("", &mut ctx).await.unwrap(); - let result_show = handler.execute("show", &mut ctx).await.unwrap(); - match (result_empty, result_show) { - (CommandResult::Output(a), CommandResult::Output(b)) => { - assert_eq!(a, b, "empty args should equal 'show'"); + let result = handler.execute("", &mut ctx).await.unwrap(); + match result { + CommandResult::Output(text) => { + assert!( + text.contains("Memory selector"), + "expected selector header, got: {}", + text + ); + assert!( + text.contains("Auto-memory:"), + "expected auto-memory header, got: {}", + text + ); + assert!( + text.contains("Directory shortcuts"), + "expected directory shortcuts block, got: {}", + text + ); } - _ => panic!("Expected Output for both"), + _ => panic!("Expected Output"), } } @@ -457,4 +748,115 @@ mod tests { let _ = std::fs::remove_dir_all(&tmp); } + + /// Auto-toggle updates the in-memory setting. We pin `CC_RUST_HOME` + /// to a tempdir so the persistence side-effect lands there instead of + /// the real `~/.cc-rust/settings.json`. + #[tokio::test] + async fn test_memory_auto_toggle_updates_state() { + let root = + std::env::temp_dir().join(format!("cc_rust_mem_auto_test_{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + + let previous = std::env::var("CC_RUST_HOME").ok(); + std::env::set_var("CC_RUST_HOME", &root); + + let handler = MemoryHandler; + let mut ctx = test_ctx(root.clone()); + assert_eq!(ctx.app_state.settings.auto_memory_enabled, None); + + // auto on + let result = handler.execute("auto on", &mut ctx).await.unwrap(); + match &result { + CommandResult::Output(text) => assert!(text.contains("ON")), + _ => panic!("Expected Output"), + } + assert_eq!(ctx.app_state.settings.auto_memory_enabled, Some(true)); + + // status + let result = handler.execute("auto status", &mut ctx).await.unwrap(); + match &result { + CommandResult::Output(text) => assert!(text.contains("ON")), + _ => panic!("Expected Output"), + } + + // auto off + let result = handler.execute("auto off", &mut ctx).await.unwrap(); + match &result { + CommandResult::Output(text) => assert!(text.contains("OFF")), + _ => panic!("Expected Output"), + } + assert_eq!(ctx.app_state.settings.auto_memory_enabled, Some(false)); + + match previous { + Some(v) => std::env::set_var("CC_RUST_HOME", v), + None => std::env::remove_var("CC_RUST_HOME"), + } + let _ = std::fs::remove_dir_all(&root); + } + + /// `/memory open` prints the path for each valid scope and rejects + /// unknown scopes. + #[tokio::test] + async fn test_memory_open_scope_paths() { + let tmp = + std::env::temp_dir().join(format!("cc_rust_mem_open_test_{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&tmp).unwrap(); + + let handler = MemoryHandler; + let mut ctx = test_ctx(tmp.clone()); + + for scope in ["auto", "team", "global", "project"] { + let result = handler + .execute(&format!("open {}", scope), &mut ctx) + .await + .unwrap(); + match &result { + CommandResult::Output(text) => { + assert!( + text.contains("directory:"), + "expected directory line for {}, got: {}", + scope, + text + ); + } + _ => panic!("Expected Output"), + } + } + + let result = handler.execute("open bogus", &mut ctx).await.unwrap(); + match &result { + CommandResult::Output(text) => assert!(text.contains("Unknown scope")), + _ => panic!("Expected Output"), + } + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[tokio::test] + async fn test_memory_selector_reflects_auto_state() { + let tmp = std::env::temp_dir() + .join(format!("cc_rust_mem_sel_test_{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&tmp).unwrap(); + + let handler = MemoryHandler; + let mut ctx = test_ctx(tmp.clone()); + + // Default (None → OFF) + let result = handler.execute("", &mut ctx).await.unwrap(); + match &result { + CommandResult::Output(text) => assert!(text.contains("Auto-memory: OFF")), + _ => panic!("Expected Output"), + } + + // Flip directly in app_state to sidestep persistence. + ctx.app_state.settings.auto_memory_enabled = Some(true); + let result = handler.execute("", &mut ctx).await.unwrap(); + match &result { + CommandResult::Output(text) => assert!(text.contains("Auto-memory: ON")), + _ => panic!("Expected Output"), + } + + let _ = std::fs::remove_dir_all(&tmp); + } } diff --git a/crates/claude-code-rs/src/commands/mod.rs b/crates/claude-code-rs/src/commands/mod.rs index e21e7493..eab6a8dd 100644 --- a/crates/claude-code-rs/src/commands/mod.rs +++ b/crates/claude-code-rs/src/commands/mod.rs @@ -24,6 +24,7 @@ pub mod version; // Git & workflow pub mod branch; pub mod commit; +pub mod gbranch; pub mod recap; pub mod review; pub mod security_review; @@ -37,6 +38,9 @@ pub mod model_add; pub mod memory; pub mod skills_cmd; +// Plan mode (issue #46) +pub mod plan; + // Session management pub mod copy; pub mod init; @@ -262,6 +266,12 @@ pub fn get_all_commands() -> Vec { description: "View or modify tool permission settings".into(), handler: Box::new(permissions_cmd::PermissionsHandler), }, + Command { + name: "plan".into(), + aliases: vec![], + description: "Enter plan mode and show/edit the plan file (issue #46)".into(), + handler: Box::new(plan::PlanHandler), + }, Command { name: "login".into(), aliases: vec![], @@ -289,9 +299,15 @@ pub fn get_all_commands() -> Vec { Command { name: "branch".into(), aliases: vec!["br".into()], - description: "Show or switch git branches".into(), + description: "Fork the current conversation (transcript-level branch)".into(), handler: Box::new(branch::BranchHandler), }, + Command { + name: "gbranch".into(), + aliases: vec!["gitbranch".into()], + description: "Show or switch git branches (moved from /branch, issue #36)".into(), + handler: Box::new(gbranch::GitBranchHandler), + }, Command { name: "effort".into(), aliases: vec![], @@ -577,6 +593,9 @@ mod tests { assert!(names.contains(&"review")); assert!(names.contains(&"security-review")); assert!(names.contains(&"recap")); + // Conversation fork (issue #36) and renamed git-branch wrapper. + assert!(names.contains(&"branch")); + assert!(names.contains(&"gbranch")); // Read-only browser family (issues #34, #39, #40, #54). assert!(names.contains(&"hooks")); assert!(names.contains(&"agents")); @@ -603,6 +622,7 @@ mod tests { assert!(find_command("ctx").is_some()); assert!(find_command("perms").is_some()); assert!(find_command("br").is_some()); + assert!(find_command("gitbranch").is_some()); assert!(find_command("mem").is_some()); } diff --git a/crates/claude-code-rs/src/commands/plan.rs b/crates/claude-code-rs/src/commands/plan.rs new file mode 100644 index 00000000..0725ae48 --- /dev/null +++ b/crates/claude-code-rs/src/commands/plan.rs @@ -0,0 +1,294 @@ +//! `/plan` command — plan-mode switch + plan file access (issue #46). +//! +//! Subcommands: +//! (no args) or `show`/`view` — Enter plan mode, then print the plan file. +//! `open` / `edit` — Open plan file in external editor. +//! `path` — Print the plan file path. +//! +//! The no-arg entrypoint mirrors the `pre_plan_mode` save/restore handshake +//! used by `tools::plan_mode::EnterPlanModeTool`, so a subsequent `ExitPlanMode` +//! restores whatever mode the user was in before. +//! +//! # TODO (IPC/daemon sync) +//! `ipc::ingress::*` currently syncs only `additional_working_directories` and +//! `team_context` back to the engine after a command. Mode transitions driven +//! by `/plan` (like `/permissions mode`) only take effect in the TUI runtime +//! via `sync_app_runtime_from_state`. Wiring the daemon ingress path is a +//! follow-up. + +use anyhow::Result; +use async_trait::async_trait; +use std::fs; +use std::path::{Path, PathBuf}; + +use super::{CommandContext, CommandHandler, CommandResult}; +use crate::config::paths as cfg_paths; +use crate::types::tool::PermissionMode; +use crate::ui::browser::{ensure_and_open, format_open_outcome}; + +/// Template seeded into a fresh plan file on first `open`/`edit`. +const PLAN_TEMPLATE: &str = "# Plan\n\n\n"; + +pub struct PlanHandler; + +#[async_trait] +impl CommandHandler for PlanHandler { + async fn execute(&self, args: &str, ctx: &mut CommandContext) -> Result { + let mut parts = args.trim().splitn(2, char::is_whitespace); + let sub = parts.next().unwrap_or("").trim(); + + match sub { + "" | "show" | "view" => enter_and_show(ctx), + "open" | "edit" => open_plan(&ctx.cwd), + "path" => Ok(CommandResult::Output(format!( + "Plan file: {}", + cfg_paths::current_plan_file_path(&ctx.cwd).display() + ))), + other => Ok(CommandResult::Output(format!( + "Unknown subcommand: `{}`\n\n\ + Usage:\n\ + \x20 /plan — Enter plan mode and show the plan file\n\ + \x20 /plan show|view — Show the plan file (same as no-arg)\n\ + \x20 /plan open|edit — Open plan file in $EDITOR\n\ + \x20 /plan path — Print the plan file path", + other + ))), + } + } +} + +// --------------------------------------------------------------------------- +// Subcommand implementations +// --------------------------------------------------------------------------- + +/// Enter plan mode (saving `pre_plan_mode`) and render the plan-file body. +/// +/// Mirrors `tools::plan_mode::EnterPlanModeTool::call` so `/plan` and the +/// tool-call path converge on the same state transition. Entering plan mode +/// twice is idempotent: the second call preserves the original `pre_plan_mode` +/// snapshot rather than overwriting it with `Plan`. +fn enter_and_show(ctx: &mut CommandContext) -> Result { + let perm = &mut ctx.app_state.tool_permission_context; + let was_in_plan = matches!(perm.mode, PermissionMode::Plan); + if !was_in_plan { + perm.pre_plan_mode = Some(perm.mode.clone()); + perm.mode = PermissionMode::Plan; + } + + let path = cfg_paths::current_plan_file_path(&ctx.cwd); + let body = read_plan_body(&path); + + let header = if was_in_plan { + format!("**Plan mode** (already active) — {}", path.display()) + } else { + format!("**Plan mode** (entered) — {}", path.display()) + }; + + Ok(CommandResult::Output(match body { + Some(content) if !content.trim().is_empty() => format!("{header}\n\n{content}"), + _ => format!( + "{header}\n\n(empty plan — use `/plan open` to draft one)" + ), + })) +} + +/// Ensure the plan file exists (seeded with `PLAN_TEMPLATE`) and hand off to +/// `$VISUAL` / `$EDITOR`. Prints a readable outcome. +fn open_plan(cwd: &Path) -> Result { + let path: PathBuf = cfg_paths::current_plan_file_path(cwd); + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + let outcome = ensure_and_open(&path, PLAN_TEMPLATE); + Ok(CommandResult::Output(format_open_outcome(&outcome, &path))) +} + +/// Return `Some(body)` if the plan file exists and is readable; `None` +/// otherwise. Read errors surface as `None` so the command degrades to the +/// empty-plan message instead of failing. +fn read_plan_body(path: &Path) -> Option { + if !path.is_file() { + return None; + } + fs::read_to_string(path).ok() +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::bootstrap::SessionId; + use crate::types::app_state::AppState; + use crate::types::tool::PermissionMode; + use serial_test::serial; + use std::env; + use tempfile::tempdir; + + struct EnvGuard { + key: &'static str, + previous: Option, + } + + impl EnvGuard { + fn set(key: &'static str, value: &str) -> Self { + let previous = env::var(key).ok(); + env::set_var(key, value); + Self { key, previous } + } + + fn unset(key: &'static str) -> Self { + let previous = env::var(key).ok(); + env::remove_var(key); + Self { key, previous } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + match &self.previous { + Some(v) => env::set_var(self.key, v), + None => env::remove_var(self.key), + } + } + } + + fn make_ctx(cwd: PathBuf, start_mode: PermissionMode) -> CommandContext { + let mut app_state = AppState::default(); + app_state.tool_permission_context.mode = start_mode; + app_state.tool_permission_context.pre_plan_mode = None; + CommandContext { + messages: Vec::new(), + cwd, + app_state, + session_id: SessionId::new(), + } + } + + #[tokio::test] + #[serial] + async fn bare_plan_enters_plan_mode_and_shows_placeholder() { + let tmp = tempdir().unwrap(); + let _g = EnvGuard::set("CC_RUST_HOME", tmp.path().to_str().unwrap()); + let _editor = EnvGuard::unset("VISUAL"); + let _editor2 = EnvGuard::unset("EDITOR"); + + let mut ctx = make_ctx(tmp.path().to_path_buf(), PermissionMode::Default); + let result = PlanHandler.execute("", &mut ctx).await.unwrap(); + + assert!(matches!( + ctx.app_state.tool_permission_context.mode, + PermissionMode::Plan + )); + assert_eq!( + ctx.app_state.tool_permission_context.pre_plan_mode, + Some(PermissionMode::Default) + ); + match result { + CommandResult::Output(s) => { + assert!(s.contains("Plan mode")); + assert!(s.contains("empty plan")); + } + _ => panic!("expected Output"), + } + } + + #[tokio::test] + #[serial] + async fn plan_is_idempotent_and_preserves_prior_mode() { + let tmp = tempdir().unwrap(); + let _g = EnvGuard::set("CC_RUST_HOME", tmp.path().to_str().unwrap()); + + let mut ctx = make_ctx(tmp.path().to_path_buf(), PermissionMode::AcceptEdits); + PlanHandler.execute("", &mut ctx).await.unwrap(); + // Second call: mode is already Plan — pre_plan_mode must keep the + // original AcceptEdits snapshot, not get overwritten by Plan. + PlanHandler.execute("", &mut ctx).await.unwrap(); + + assert_eq!( + ctx.app_state.tool_permission_context.pre_plan_mode, + Some(PermissionMode::AcceptEdits), + "second /plan invocation must not clobber pre_plan_mode" + ); + } + + #[tokio::test] + #[serial] + async fn show_existing_plan_renders_body() { + let tmp = tempdir().unwrap(); + let plan_dir = tmp.path().join(".cc-rust"); + fs::create_dir_all(&plan_dir).unwrap(); + let plan_path = plan_dir.join("plan.md"); + fs::write(&plan_path, "# My Plan\n\n1. Step one\n").unwrap(); + let _g = EnvGuard::set("CC_RUST_HOME", tmp.path().to_str().unwrap()); + + let mut ctx = make_ctx(tmp.path().to_path_buf(), PermissionMode::Default); + let result = PlanHandler.execute("show", &mut ctx).await.unwrap(); + match result { + CommandResult::Output(s) => { + assert!(s.contains("My Plan")); + assert!(s.contains("Step one")); + } + _ => panic!("expected Output"), + } + } + + #[tokio::test] + #[serial] + async fn unknown_subcommand_prints_usage() { + let tmp = tempdir().unwrap(); + let _g = EnvGuard::set("CC_RUST_HOME", tmp.path().to_str().unwrap()); + + let mut ctx = make_ctx(tmp.path().to_path_buf(), PermissionMode::Default); + let result = PlanHandler.execute("bogus", &mut ctx).await.unwrap(); + match result { + CommandResult::Output(s) => { + assert!(s.contains("Unknown subcommand")); + assert!(s.contains("bogus")); + assert!(s.contains("/plan open")); + } + _ => panic!("expected Output"), + } + } + + #[tokio::test] + #[serial] + async fn path_subcommand_prints_resolved_path() { + let tmp = tempdir().unwrap(); + let _g = EnvGuard::set("CC_RUST_HOME", tmp.path().to_str().unwrap()); + + let mut ctx = make_ctx(tmp.path().to_path_buf(), PermissionMode::Default); + let result = PlanHandler.execute("path", &mut ctx).await.unwrap(); + match result { + CommandResult::Output(s) => assert!(s.contains("Plan file:")), + _ => panic!("expected Output"), + } + } + + #[tokio::test] + #[serial] + async fn open_without_editor_creates_template_and_reports() { + let tmp = tempdir().unwrap(); + let _g = EnvGuard::set("CC_RUST_HOME", tmp.path().to_str().unwrap()); + // Force "no editor" path so the test is hermetic. + let _v = EnvGuard::unset("VISUAL"); + let _e = EnvGuard::unset("EDITOR"); + + let mut ctx = make_ctx(tmp.path().to_path_buf(), PermissionMode::Default); + let result = PlanHandler.execute("open", &mut ctx).await.unwrap(); + + // The plan file must exist now, seeded with the template header. + let body = fs::read_to_string( + cfg_paths::current_plan_file_path(tmp.path()) + ).unwrap(); + assert!(body.starts_with("# Plan")); + + // Output should describe the outcome (either "editor" or "no editor"). + match result { + CommandResult::Output(s) => assert!(!s.is_empty()), + _ => panic!("expected Output"), + } + } +} diff --git a/crates/claude-code-rs/src/main.rs b/crates/claude-code-rs/src/main.rs index 93fbc5a1..531ccd07 100644 --- a/crates/claude-code-rs/src/main.rs +++ b/crates/claude-code-rs/src/main.rs @@ -531,6 +531,7 @@ async fn run_full_init(cli: Cli) -> anyhow::Result { fast_mode_per_session_opt_in: merged_config.fast_mode_per_session_opt_in, teammate_mode: merged_config.teammate_mode, claude_in_chrome_default_enabled: merged_config.claude_in_chrome_default_enabled, + auto_memory_enabled: merged_config.auto_memory_enabled, sources, }, verbose: cli.verbose, diff --git a/crates/claude-code-rs/tests/e2e_context_cmd.rs b/crates/claude-code-rs/tests/e2e_context_cmd.rs new file mode 100644 index 00000000..e2e682e0 --- /dev/null +++ b/crates/claude-code-rs/tests/e2e_context_cmd.rs @@ -0,0 +1,145 @@ +//! E2E tests for the `/context` slash command (issue #38). +//! +//! `claude-code-rs` is a binary-only crate (no `lib.rs`), so these tests +//! use the public API of the `cc-compact` crate and source-file checks +//! to assert the wiring of the new `analyze_context_usage` service. + +use std::fs; +use std::path::Path; + +use cc_compact::context_analysis::{ + analyze_context_usage, ContextAnalysis, ContextAnalysisInput, +}; +use cc_types::message::{ + AssistantMessage, ContentBlock, Message, MessageContent, UserMessage, +}; +use uuid::Uuid; + +fn user(text: &str) -> Message { + Message::User(UserMessage { + uuid: Uuid::new_v4(), + timestamp: 0, + role: "user".into(), + content: MessageContent::Text(text.into()), + is_meta: false, + tool_use_result: None, + source_tool_assistant_uuid: None, + }) +} + +fn assistant(text: &str) -> Message { + Message::Assistant(AssistantMessage { + uuid: Uuid::new_v4(), + timestamp: 0, + role: "assistant".into(), + content: vec![ContentBlock::Text { text: text.into() }], + usage: None, + stop_reason: Some("end_turn".into()), + is_api_error_message: false, + api_error: None, + cost_usd: 0.0, + }) +} + +#[test] +fn context_analysis_is_reachable_across_crate_boundary() { + let _fn: fn(ContextAnalysisInput<'_>) -> ContextAnalysis = analyze_context_usage; +} + +#[test] +fn report_includes_all_seven_canonical_categories() { + let report = analyze_context_usage(ContextAnalysisInput { + messages: &[], + model: "claude-sonnet-4-20250514", + ..Default::default() + }); + let labels: Vec<&str> = report.categories.iter().map(|c| c.label.as_str()).collect(); + for expected in ["messages", "system prompt", "skills", "files cached", "tools schema", "hook results", "free"] { + assert!(labels.contains(&expected)); + } +} + +#[test] +fn total_used_plus_free_never_exceeds_window() { + let messages = vec![ + user("hello world"), + assistant("hi"), + user(&"x".repeat(4_000)), + ]; + let report = analyze_context_usage(ContextAnalysisInput { + messages: &messages, + system_prompt: Some(&"sys".repeat(500)), + skills_manifest: Some("skill-a"), + tools_schema: Some(&"tools".repeat(200)), + hook_results: Some("{}"), + cached_files_chars: 2_000, + model: "claude-sonnet-4-20250514", + }); + let free = report.categories.iter().find(|c| c.label == "free").unwrap().tokens; + let capped = report.total_used.min(report.context_window); + assert!(capped + free <= report.context_window); + assert!(report.total_percent <= 100.0); +} + +#[test] +fn categories_sorted_descending_with_free_pinned_last() { + let report = analyze_context_usage(ContextAnalysisInput { + messages: &[user("x")], + system_prompt: Some(&"abc".repeat(1_000)), + tools_schema: Some("t"), + model: "claude-sonnet-4-20250514", + ..Default::default() + }); + assert_eq!(report.categories.last().unwrap().label, "free"); + let non_free: Vec<_> = report.categories.iter().filter(|c| c.label != "free").collect(); + for pair in non_free.windows(2) { + assert!(pair[0].tokens >= pair[1].tokens); + } +} + +#[test] +fn json_shape_is_stable_for_headless_callers() { + let report = analyze_context_usage(ContextAnalysisInput { + messages: &[user("hi")], + model: "claude-sonnet-4-20250514", + ..Default::default() + }); + let json = serde_json::to_value(&report).expect("serialise analysis"); + for key in ["model", "context_window", "total_used", "total_percent", "compacted", "messages_in", "messages_out", "categories"] { + assert!(json.get(key).is_some()); + } + for cat in json.get("categories").unwrap().as_array().unwrap() { + assert!(cat.get("label").is_some()); + assert!(cat.get("tokens").is_some()); + assert!(cat.get("percent").is_some()); + } +} + +fn command_file() -> String { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("src").join("commands").join("context.rs"); + fs::read_to_string(&path).expect("read commands/context.rs") +} + +#[test] +fn context_handler_delegates_to_service() { + let text = command_file(); + assert!(text.contains("analyze_context_usage")); + assert!(text.contains("ContextAnalysisInput")); +} + +#[test] +fn context_handler_offers_json_subcommand() { + let text = command_file(); + assert!(text.contains("\"json\"")); + assert!(text.contains("\"raw\"")); +} + +#[test] +fn context_command_is_still_registered() { + let mod_rs = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("src").join("commands").join("mod.rs"); + let text = fs::read_to_string(&mod_rs).expect("read commands/mod.rs"); + assert!(text.contains("name: \"context\"")); + assert!(text.contains("context::ContextHandler")); +} diff --git a/crates/claude-code-rs/tests/e2e_memory_scopes.rs b/crates/claude-code-rs/tests/e2e_memory_scopes.rs new file mode 100644 index 00000000..913dce07 --- /dev/null +++ b/crates/claude-code-rs/tests/e2e_memory_scopes.rs @@ -0,0 +1,151 @@ +//! E2E tests for the expanded memory scope system (issue #45). +//! +//! Exercises `cc_session::memdir` + `cc_config::paths` + the `/memory` +//! command surface together: +//! - All four scopes (Global, Project, Team, Auto) resolve to +//! well-defined paths under a sandboxed `CC_RUST_HOME`. +//! - Writes/reads round-trip correctly across every scope. +//! - The layered settings loader persists `autoMemoryEnabled`. +//! +//! These tests are hermetic: each sets `CC_RUST_HOME` to a tempdir so +//! they never touch `~/.cc-rust/`. +//! +//! Run with: `cargo test --test e2e_memory_scopes` + +use cc_config::paths; +use cc_config::settings::{self, RawSettings}; +use cc_session::memdir::{ + delete_memory, list_memories, memory_dir, read_memory, write_memory, MemoryScope, +}; +use serial_test::serial; +use tempfile::TempDir; + +struct CcRustHomeGuard { + previous: Option, +} + +impl CcRustHomeGuard { + fn set(path: &std::path::Path) -> Self { + let previous = std::env::var("CC_RUST_HOME").ok(); + std::env::set_var("CC_RUST_HOME", path); + Self { previous } + } +} + +impl Drop for CcRustHomeGuard { + fn drop(&mut self) { + match &self.previous { + Some(v) => std::env::set_var("CC_RUST_HOME", v), + None => std::env::remove_var("CC_RUST_HOME"), + } + } +} + +#[test] +#[serial] +fn memory_dir_resolves_every_scope_under_data_root() { + let root = TempDir::new().expect("tmp root"); + let _g = CcRustHomeGuard::set(root.path()); + + let cwd = root.path().join("my_project"); + std::fs::create_dir_all(&cwd).unwrap(); + + assert_eq!( + memory_dir(MemoryScope::Global, &cwd).unwrap(), + root.path().join("memory") + ); + assert_eq!( + memory_dir(MemoryScope::Project, &cwd).unwrap(), + cwd.join(".cc-rust").join("memory") + ); + + // The team dir is rooted under data_root/projects//memory/team + let team = memory_dir(MemoryScope::Team, &cwd).unwrap(); + let s = team.to_string_lossy().replace('\\', "/"); + assert!( + s.starts_with(&root.path().to_string_lossy().replace('\\', "/")), + "team path {} should be under data_root {}", + team.display(), + root.path().display() + ); + assert!( + s.ends_with("/memory/team"), + "team path {} should end with /memory/team", + team.display() + ); + + // Auto scope lives at data_root/auto_memory — matches the helper too. + assert_eq!( + memory_dir(MemoryScope::Auto, &cwd).unwrap(), + paths::auto_memory_dir() + ); + assert_eq!( + memory_dir(MemoryScope::Auto, &cwd).unwrap(), + root.path().join("auto_memory") + ); +} + +#[test] +#[serial] +fn every_scope_supports_write_list_delete() { + let root = TempDir::new().expect("tmp root"); + let _g = CcRustHomeGuard::set(root.path()); + + let cwd = root.path().join("ws"); + std::fs::create_dir_all(&cwd).unwrap(); + + for scope in [ + MemoryScope::Global, + MemoryScope::Project, + MemoryScope::Team, + MemoryScope::Auto, + ] { + let key = format!("e2e-{}", scope.as_str()); + let value = format!("value for {}", scope.as_str()); + + let written = write_memory(&key, &value, "e2e", scope, &cwd).unwrap(); + assert_eq!(written.key, key); + assert_eq!(written.value, value); + + let read = read_memory(&key, scope, &cwd).unwrap(); + assert_eq!(read.key, key); + + let all = list_memories(scope, &cwd).unwrap(); + assert!(all.iter().any(|e| e.key == key)); + + assert!(delete_memory(&key, scope, &cwd).unwrap()); + assert!(!delete_memory(&key, scope, &cwd).unwrap()); // idempotent + } +} + +#[test] +#[serial] +fn auto_memory_enabled_roundtrips_through_settings_json() { + let root = TempDir::new().expect("tmp root"); + let _g = CcRustHomeGuard::set(root.path()); + + let path = settings::user_settings_path(); + let mut raw = RawSettings::default(); + raw.auto_memory_enabled = Some(true); + settings::write_settings_file(&path, &raw).expect("write settings"); + + let loaded = settings::load_effective(&std::path::PathBuf::from(root.path())) + .expect("load effective"); + assert_eq!( + loaded.effective.auto_memory_enabled, + Some(true), + "autoMemoryEnabled should survive a round-trip through settings.json" + ); +} + +#[test] +#[serial] +fn auto_memory_dir_helper_matches_data_root_layout() { + let root = TempDir::new().expect("tmp root"); + let _g = CcRustHomeGuard::set(root.path()); + + assert_eq!(paths::auto_memory_dir(), root.path().join("auto_memory")); + // Distinct from the curated global memory dir so purge-all semantics + // don't clobber hand-written entries. + assert_ne!(paths::auto_memory_dir(), paths::memory_dir_global()); +} diff --git a/crates/claude-code-rs/tests/e2e_plan_cmd.rs b/crates/claude-code-rs/tests/e2e_plan_cmd.rs new file mode 100644 index 00000000..3819c9c1 --- /dev/null +++ b/crates/claude-code-rs/tests/e2e_plan_cmd.rs @@ -0,0 +1,105 @@ +//! e2e tests for `/plan` slash command (issue #46). +//! +//! `claude-code-rs` is a binary crate with no `lib.rs`, so full handler +//! exercising lives in the bin-internal unit tests. Here we verify the +//! externally-observable contract: registry wiring, source-level module +//! layout, and cross-crate path-helper surface. + +use std::fs; +use std::path::PathBuf; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|p| p.parent()) + .expect("workspace root") + .to_path_buf() +} + +fn read_source(rel: &str) -> String { + let path = repo_root().join(rel); + fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("failed to read {}: {}", path.display(), e)) +} + +#[test] +fn plan_command_module_is_declared() { + let src = read_source("crates/claude-code-rs/src/commands/mod.rs"); + assert!( + src.contains("pub mod plan;"), + "commands::plan module must be declared in commands/mod.rs" + ); +} + +#[test] +fn plan_command_is_registered_in_get_all_commands() { + let src = read_source("crates/claude-code-rs/src/commands/mod.rs"); + assert!( + src.contains("name: \"plan\".into(),"), + "/plan command must have a registry entry" + ); + assert!( + src.contains("plan::PlanHandler"), + "/plan entry must wire PlanHandler" + ); +} + +#[test] +fn plan_handler_covers_expected_subcommands() { + let src = read_source("crates/claude-code-rs/src/commands/plan.rs"); + for token in [ + "\"show\"", + "\"view\"", + "\"open\"", + "\"edit\"", + "\"path\"", + ] { + assert!( + src.contains(token), + "/plan handler must dispatch on subcommand {}", + token + ); + } +} + +#[test] +fn plan_handler_uses_pre_plan_mode_handshake() { + let src = read_source("crates/claude-code-rs/src/commands/plan.rs"); + assert!( + src.contains("pre_plan_mode"), + "/plan handler must save pre_plan_mode so ExitPlanMode can restore" + ); + assert!( + src.contains("PermissionMode::Plan"), + "/plan handler must set mode to PermissionMode::Plan" + ); +} + +#[test] +fn plan_path_helpers_are_publicly_exposed() { + let src = read_source("crates/cc-config/src/paths.rs"); + for fn_name in [ + "pub fn plan_file_path_project", + "pub fn plan_file_path_global", + "pub fn current_plan_file_path", + ] { + assert!( + src.contains(fn_name), + "cc-config::paths must expose `{}`", + fn_name + ); + } +} + +#[test] +fn plan_handler_delegates_to_external_editor_util() { + let src = read_source("crates/claude-code-rs/src/commands/plan.rs"); + assert!( + src.contains("ensure_and_open"), + "/plan open must delegate to the shared ensure_and_open editor util" + ); + assert!( + src.contains("format_open_outcome"), + "/plan open must format the OpenOutcome for the user" + ); +} diff --git a/docs/schemas/settings.schema.json b/docs/schemas/settings.schema.json index df39533b..78cfbe1c 100644 --- a/docs/schemas/settings.schema.json +++ b/docs/schemas/settings.schema.json @@ -114,6 +114,7 @@ "fastModePerSessionOptIn": { "type": "boolean" }, "teammateMode": { "type": "boolean" }, "claudeInChromeDefaultEnabled": { "type": "boolean" }, + "autoMemoryEnabled": { "type": "boolean" }, "systemPrompt": { "type": "string" }, "apiKey": { "type": "string" } }