From 275fa7f8e5bbcbe70224e6894a92bc4cd8731530 Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Tue, 9 Dec 2025 21:11:21 -0500 Subject: [PATCH 1/7] use new cursor tab hooks --- .../checkpoint_agent/agent_presets.rs | 248 +++++++++++++++++- src/commands/install_hooks.rs | 2 + tests/cursor.rs | 233 ++++++++++++++++ 3 files changed, 481 insertions(+), 2 deletions(-) diff --git a/src/commands/checkpoint_agent/agent_presets.rs b/src/commands/checkpoint_agent/agent_presets.rs index ea9ddace2..131cc8efd 100644 --- a/src/commands/checkpoint_agent/agent_presets.rs +++ b/src/commands/checkpoint_agent/agent_presets.rs @@ -4,6 +4,7 @@ use crate::{ working_log::{AgentId, CheckpointKind}, }, error::GitAiError, + git::repo_storage::RepoStorage, }; use chrono::{TimeZone, Utc}; use rusqlite::{Connection, OpenFlags}; @@ -661,9 +662,12 @@ impl AgentCheckpointPreset for CursorPreset { .unwrap_or_else(|| "unknown".to_string()); // Validate hook_event_name - if hook_event_name != "beforeSubmitPrompt" && hook_event_name != "afterFileEdit" { + if hook_event_name != "beforeSubmitPrompt" + && hook_event_name != "afterFileEdit" + && hook_event_name != "beforeTabFileRead" + && hook_event_name != "afterTabFileEdit" { return Err(GitAiError::PresetError(format!( - "Invalid hook_event_name: {}. Expected 'beforeSubmitPrompt' or 'afterFileEdit'", + "Invalid hook_event_name: {}. Expected 'beforeSubmitPrompt', 'afterFileEdit', 'beforeTabFileRead', or 'afterTabFileEdit'", hook_event_name ))); } @@ -690,6 +694,96 @@ impl AgentCheckpointPreset for CursorPreset { }); } + if hook_event_name == "beforeTabFileRead" { + // Handle Cursor Tab AI file read - create a human checkpoint scoped to one file + let file_path = hook_data + .get("file_path") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + GitAiError::PresetError("file_path not found in beforeTabFileRead hook".to_string()) + })? + .to_string(); + + let content = hook_data + .get("content") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + GitAiError::PresetError("content not found in beforeTabFileRead hook".to_string()) + })? + .to_string(); + + // Create dirty_files with just this one file + let mut dirty_files = HashMap::new(); + dirty_files.insert(file_path.clone(), content); + + return Ok(AgentRunResult { + agent_id: AgentId { + tool: "cursor".to_string(), + id: conversation_id.clone(), + model: model.clone(), + }, + agent_metadata: None, + checkpoint_kind: CheckpointKind::Human, + transcript: None, + repo_working_dir: Some(repo_working_dir.clone()), + edited_filepaths: None, + will_edit_filepaths: Some(vec![file_path]), + dirty_files: Some(dirty_files), + }); + } + + if hook_event_name == "afterTabFileEdit" { + // Handle Cursor Tab AI file edit - create an AiTab checkpoint + let file_path = hook_data + .get("file_path") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + GitAiError::PresetError("file_path not found in afterTabFileEdit hook".to_string()) + })? + .to_string(); + + let edits = hook_data + .get("edits") + .ok_or_else(|| { + GitAiError::PresetError("edits not found in afterTabFileEdit hook".to_string()) + })?; + + // Get the most recent file content from the working log + let old_content = Self::get_most_recent_file_content(&repo_working_dir, &file_path) + .map(|(content, _blob_sha)| content) + .unwrap_or_else(|| { + // If no checkpoint exists, try to read from filesystem as fallback + std::fs::read_to_string(&file_path).unwrap_or_default() + }); + + // Apply the edits to get the new content + let new_content = Self::apply_edits_to_content(&old_content, edits).unwrap_or_else(|e| { + eprintln!("[Warning] Failed to apply edits for afterTabFileEdit: {}", e); + old_content.clone() + }); + + // Create dirty_files with the new content + let mut dirty_files = HashMap::new(); + dirty_files.insert(file_path.clone(), new_content); + + let agent_id = AgentId { + tool: "cursor".to_string(), + id: conversation_id, + model, + }; + + return Ok(AgentRunResult { + agent_id, + agent_metadata: None, + checkpoint_kind: CheckpointKind::AiTab, + transcript: None, + repo_working_dir: Some(repo_working_dir), + edited_filepaths: Some(vec![file_path]), + will_edit_filepaths: None, + dirty_files: Some(dirty_files), + }); + } + // Locate Cursor storage let global_db = Self::cursor_global_database_path()?; if !global_db.exists() { @@ -761,6 +855,156 @@ impl AgentCheckpointPreset for CursorPreset { } impl CursorPreset { + /// Get the most recent file content from the working log for a given file path + /// Returns (content, blob_sha) if found + fn get_most_recent_file_content( + repo_working_dir: &str, + file_path: &str, + ) -> Option<(String, String)> { + // Open the repository and get the base commit + let repo = crate::git::repository::find_repository_in_path(repo_working_dir).ok()?; + let base_commit = match repo.head() { + Ok(head) => match head.target() { + Ok(oid) => oid, + Err(_) => return None, + }, + Err(_) => return None, + }; + + // Get repo path and workdir + let repo_path = repo.path(); + let repo_workdir_path = match repo.workdir() { + Ok(path) => path, + Err(_) => return None, + }; + + // Create storage and working log + let repo_storage = RepoStorage::for_repo_path(&repo_path, &repo_workdir_path); + let working_log = repo_storage.working_log_for_base_commit(&base_commit); + + // Read all checkpoints + let checkpoints = working_log.read_all_checkpoints().ok()?; + + // Convert file_path to repo-relative format for comparison + let relative_file_path = working_log.to_repo_relative_path(file_path); + + // Find the most recent checkpoint that has an entry for this file + for checkpoint in checkpoints.iter().rev() { + for entry in &checkpoint.entries { + if entry.file == relative_file_path { + // Found the most recent checkpoint for this file + if !entry.blob_sha.is_empty() { + // Load the content from the blob + if let Ok(content) = working_log.get_file_version(&entry.blob_sha) { + return Some((content, entry.blob_sha.clone())); + } + } + } + } + } + + None + } + + /// Apply edit operations to file content + /// The edits are provided in the format from Cursor's afterTabFileEdit hook + fn apply_edits_to_content( + old_content: &str, + edits: &serde_json::Value, + ) -> Result { + let edits_array = edits.as_array().ok_or_else(|| { + GitAiError::PresetError("edits must be an array".to_string()) + })?; + + let mut lines: Vec = old_content.lines().map(|s| s.to_string()).collect(); + + // Apply each edit in order + for edit in edits_array { + let range = edit.get("range").ok_or_else(|| { + GitAiError::PresetError("edit missing range field".to_string()) + })?; + + let start_line = range + .get("start_line_number") + .and_then(|v| v.as_u64()) + .ok_or_else(|| { + GitAiError::PresetError("range missing start_line_number".to_string()) + })? as usize; + + let start_col = range + .get("start_column") + .and_then(|v| v.as_u64()) + .ok_or_else(|| { + GitAiError::PresetError("range missing start_column".to_string()) + })? as usize; + + let end_line = range + .get("end_line_number") + .and_then(|v| v.as_u64()) + .ok_or_else(|| { + GitAiError::PresetError("range missing end_line_number".to_string()) + })? as usize; + + let end_col = range + .get("end_column") + .and_then(|v| v.as_u64()) + .ok_or_else(|| { + GitAiError::PresetError("range missing end_column".to_string()) + })? as usize; + + let new_string = edit + .get("new_string") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + // Convert 1-indexed line numbers to 0-indexed + let start_line_idx = start_line.saturating_sub(1); + let end_line_idx = end_line.saturating_sub(1); + + // Ensure we have enough lines + while lines.len() <= end_line_idx { + lines.push(String::new()); + } + + if start_line_idx == end_line_idx { + // Single-line edit + let line = &lines[start_line_idx]; + // Convert 1-indexed columns to 0-indexed + let start_col_idx = start_col.saturating_sub(1); + let end_col_idx = end_col.saturating_sub(1); + + // Split the line and insert the new string + let before = if start_col_idx < line.len() { + &line[..start_col_idx] + } else { + line.as_str() + }; + let after = if end_col_idx < line.len() { + &line[end_col_idx..] + } else { + "" + }; + + lines[start_line_idx] = format!("{}{}{}", before, new_string, after); + } else { + // Multi-line edit - for now, treat as single-line at start position + // This is a simplification; full implementation would handle multi-line edits + let line = &lines[start_line_idx]; + let start_col_idx = start_col.saturating_sub(1); + + let before = if start_col_idx < line.len() { + &line[..start_col_idx] + } else { + line.as_str() + }; + + lines[start_line_idx] = format!("{}{}", before, new_string); + } + } + + Ok(lines.join("\n")) + } + /// Fetch the latest version of a Cursor conversation from the database pub fn fetch_latest_cursor_conversation( conversation_id: &str, diff --git a/src/commands/install_hooks.rs b/src/commands/install_hooks.rs index 29a21d7d9..26f03237a 100644 --- a/src/commands/install_hooks.rs +++ b/src/commands/install_hooks.rs @@ -23,6 +23,8 @@ const CLAUDE_POST_TOOL_CMD: &str = "checkpoint claude --hook-input stdin"; // Cursor hooks (requires absolute path to avoid shell config loading delay) const CURSOR_BEFORE_SUBMIT_CMD: &str = "checkpoint cursor --hook-input stdin"; const CURSOR_AFTER_EDIT_CMD: &str = "checkpoint cursor --hook-input stdin"; +const CURSOR_BEFORE_TAB_FILE_READ_CMD: &str = "checkpoint cursor-tab --hook-input stdin"; +const CURSOR_AFTER_TAB_FILE_EDIT_CMD: &str = "checkpoint cursor-tab --hook-input stdin"; // OpenCode plugin content (TypeScript), embedded from the source file to avoid drift const OPENCODE_PLUGIN_CONTENT: &str = include_str!(concat!( diff --git a/tests/cursor.rs b/tests/cursor.rs index f6cbf9987..5cdae56a4 100644 --- a/tests/cursor.rs +++ b/tests/cursor.rs @@ -511,3 +511,236 @@ fn test_cursor_e2e_with_resync() { // The temp directory and database will be automatically cleaned up when temp_dir goes out of scope } + +#[test] +fn test_cursor_preset_before_tab_file_read() { + use git_ai::authorship::working_log::CheckpointKind; + use git_ai::commands::checkpoint_agent::agent_presets::{ + AgentCheckpointFlags, AgentCheckpointPreset, CursorPreset, + }; + + let hook_input = r##"{ + "conversation_id": "test-tab-conversation-id", + "workspace_roots": ["/Users/test/workspace"], + "hook_event_name": "beforeTabFileRead", + "file_path": "/Users/test/workspace/src/main.rs", + "content": "fn main() {\n println!(\"Hello\");\n}", + "model": "tab" + }"##; + + let flags = AgentCheckpointFlags { + hook_input: Some(hook_input.to_string()), + }; + + let preset = CursorPreset; + let result = preset + .run(flags) + .expect("Should succeed for beforeTabFileRead"); + + // Verify this is a human checkpoint + assert_eq!( + result.checkpoint_kind, + CheckpointKind::Human, + "Should be a human checkpoint" + ); + + // Verify will_edit_filepaths is set with the single file + assert!(result.will_edit_filepaths.is_some(), "Should have will_edit_filepaths"); + let will_edit = result.will_edit_filepaths.unwrap(); + assert_eq!(will_edit.len(), 1, "Should have exactly one file"); + assert_eq!(will_edit[0], "/Users/test/workspace/src/main.rs"); + + // Verify dirty_files contains the file content + assert!(result.dirty_files.is_some(), "Should have dirty_files"); + let dirty_files = result.dirty_files.unwrap(); + assert_eq!(dirty_files.len(), 1, "Should have exactly one dirty file"); + assert!( + dirty_files.contains_key("/Users/test/workspace/src/main.rs"), + "Should contain the file path" + ); + assert_eq!( + dirty_files.get("/Users/test/workspace/src/main.rs").unwrap(), + "fn main() {\n println!(\"Hello\");\n}" + ); + + // Verify agent_id + assert_eq!(result.agent_id.tool, "cursor"); + assert_eq!(result.agent_id.id, "test-tab-conversation-id"); + assert_eq!(result.agent_id.model, "tab"); +} + +#[test] +fn test_cursor_preset_after_tab_file_edit() { + use git_ai::authorship::working_log::CheckpointKind; + use git_ai::commands::checkpoint_agent::agent_presets::{ + AgentCheckpointFlags, AgentCheckpointPreset, CursorPreset, + }; + + let hook_input = r##"{ + "conversation_id": "test-tab-conversation-id", + "workspace_roots": ["/Users/test/workspace"], + "hook_event_name": "afterTabFileEdit", + "file_path": "/Users/test/workspace/src/main.rs", + "edits": [ + { + "old_string": "", + "new_string": "// New comment", + "range": { + "start_line_number": 1, + "start_column": 1, + "end_line_number": 1, + "end_column": 1 + }, + "old_line": "", + "new_line": "// New comment" + } + ], + "model": "tab" + }"##; + + let flags = AgentCheckpointFlags { + hook_input: Some(hook_input.to_string()), + }; + + let preset = CursorPreset; + let result = preset + .run(flags) + .expect("Should succeed for afterTabFileEdit"); + + // Verify this is an AiTab checkpoint + assert_eq!( + result.checkpoint_kind, + CheckpointKind::AiTab, + "Should be an AiTab checkpoint" + ); + + // Verify edited_filepaths is set + assert!(result.edited_filepaths.is_some(), "Should have edited_filepaths"); + let edited = result.edited_filepaths.unwrap(); + assert_eq!(edited.len(), 1, "Should have exactly one file"); + assert_eq!(edited[0], "/Users/test/workspace/src/main.rs"); + + // Verify dirty_files contains the new content + assert!(result.dirty_files.is_some(), "Should have dirty_files"); + let dirty_files = result.dirty_files.unwrap(); + assert_eq!(dirty_files.len(), 1, "Should have exactly one dirty file"); + assert!( + dirty_files.contains_key("/Users/test/workspace/src/main.rs"), + "Should contain the file path" + ); + + // Verify agent_id + assert_eq!(result.agent_id.tool, "cursor"); + assert_eq!(result.agent_id.id, "test-tab-conversation-id"); + assert_eq!(result.agent_id.model, "tab"); + + // Verify no agent_metadata + assert!(result.agent_metadata.is_none(), "Should not have agent_metadata"); +} + +#[test] +fn test_cursor_tab_e2e_workflow() { + use std::fs; + + let repo = TestRepo::new(); + + // Create parent directory for the test file + let src_dir = repo.path().join("src"); + fs::create_dir_all(&src_dir).unwrap(); + + // Create initial file with some base content + let file_path = repo.path().join("src/main.rs"); + let base_content = "fn main() {\n println!(\"Hello, World!\");\n}\n"; + fs::write(&file_path, base_content).unwrap(); + + repo.stage_all_and_commit("Initial commit").unwrap(); + + // Step 1: beforeTabFileRead - simulate Tab reading the file + let before_read_hook = serde_json::json!({ + "conversation_id": "test-tab-session", + "workspace_roots": [repo.canonical_path().to_string_lossy().to_string()], + "hook_event_name": "beforeTabFileRead", + "file_path": file_path.to_string_lossy().to_string(), + "content": base_content, + "model": "tab" + }) + .to_string(); + + let result = repo + .git_ai(&["checkpoint", "cursor", "--hook-input", &before_read_hook]) + .unwrap(); + + println!("Before read checkpoint output: {}", result); + + // Step 2: Simulate Tab making edits to the file + let edited_content = "fn main() {\n println!(\"Hello, World!\");\n // Added by Tab AI\n println!(\"Tab was here!\");\n}\n"; + fs::write(&file_path, edited_content).unwrap(); + + // Step 3: afterTabFileEdit - simulate Tab completing the edit + let after_edit_hook = serde_json::json!({ + "conversation_id": "test-tab-session", + "workspace_roots": [repo.canonical_path().to_string_lossy().to_string()], + "hook_event_name": "afterTabFileEdit", + "file_path": file_path.to_string_lossy().to_string(), + "edits": [{ + "old_string": "", + "new_string": " // Added by Tab AI\n println!(\"Tab was here!\");\n", + "range": { + "start_line_number": 3, + "start_column": 1, + "end_line_number": 3, + "end_column": 1 + }, + "old_line": "", + "new_line": " // Added by Tab AI" + }], + "model": "tab" + }) + .to_string(); + + let result = repo + .git_ai(&["checkpoint", "cursor", "--hook-input", &after_edit_hook]) + .unwrap(); + + println!("After edit checkpoint output: {}", result); + + // Commit the changes + let commit = repo.stage_all_and_commit("Add Tab AI edits").unwrap(); + + // Verify attribution using TestFile + let mut file = repo.filename("src/main.rs"); + file.assert_lines_and_blame(lines![ + "fn main() {".human(), + " println!(\"Hello, World!\");".human(), + " // Added by Tab AI".ai(), + " println!(\"Tab was here!\");".ai(), + "}".human(), + ]); + + // Verify the authorship log contains attestations + assert!( + commit.authorship_log.attestations.len() > 0, + "Should have at least one attestation" + ); + + // Verify the agent metadata + let prompt_record = commit + .authorship_log + .metadata + .prompts + .values() + .next() + .expect("Should have at least one prompt record"); + + // Verify the model is "tab" + assert_eq!( + prompt_record.agent_id.model, "tab", + "Model should be 'tab' from Tab AI" + ); + + // Verify the tool is "cursor" + assert_eq!( + prompt_record.agent_id.tool, "cursor", + "Tool should be 'cursor'" + ); +} From befead7acea199ea9d224850d9b620780cd20930 Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Tue, 9 Dec 2025 21:20:06 -0500 Subject: [PATCH 2/7] clean up tool/model in extension --- agent-support/vscode/src/ai-tab-edit-manager.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/agent-support/vscode/src/ai-tab-edit-manager.ts b/agent-support/vscode/src/ai-tab-edit-manager.ts index 39104e941..9e6019448 100644 --- a/agent-support/vscode/src/ai-tab-edit-manager.ts +++ b/agent-support/vscode/src/ai-tab-edit-manager.ts @@ -74,8 +74,8 @@ export class AITabEditManager { // Before edit checkpoint await this.aiEditManager.checkpoint("ai_tab", JSON.stringify({ hook_event_name: 'before_edit', - tool: 'github-copilot-tab', - model: 'default', + tool: 'github-copilot', + model: 'tab', will_edit_filepaths: [last.document.uri.fsPath], dirty_files: { ...this.aiEditManager.getDirtyFiles(), @@ -86,8 +86,8 @@ export class AITabEditManager { // After edit checkpoint await this.aiEditManager.checkpoint("ai_tab", JSON.stringify({ hook_event_name: 'after_edit', - tool: 'github-copilot-tab', - model: 'default', + tool: 'github-copilot', + model: 'tab', edited_filepaths: [last.document.uri.fsPath], dirty_files: { ...this.aiEditManager.getDirtyFiles(), From 19a500ae5ea6cde4ee9db4f3a8d3aa91cf316b2e Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Tue, 9 Dec 2025 21:30:32 -0500 Subject: [PATCH 3/7] add more realistic cursor tab tests --- tests/cursor.rs | 408 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 408 insertions(+) diff --git a/tests/cursor.rs b/tests/cursor.rs index 5cdae56a4..cad01a8b9 100644 --- a/tests/cursor.rs +++ b/tests/cursor.rs @@ -744,3 +744,411 @@ fn test_cursor_tab_e2e_workflow() { "Tool should be 'cursor'" ); } + +#[test] +fn test_cursor_tab_multiple_edits_in_one_session() { + use std::fs; + + let repo = TestRepo::new(); + + // Create initial file with base content + let file_path = repo.path().join("index.ts"); + let base_content = "function hello() {\n console.log('hello world');\n}\n"; + fs::write(&file_path, base_content).unwrap(); + + repo.stage_all_and_commit("Initial commit").unwrap(); + + // Step 1: beforeTabFileRead + let before_read_hook = serde_json::json!({ + "conversation_id": "test-multi-edit-session", + "workspace_roots": [repo.canonical_path().to_string_lossy().to_string()], + "hook_event_name": "beforeTabFileRead", + "file_path": file_path.to_string_lossy().to_string(), + "content": base_content, + "model": "tab" + }) + .to_string(); + + repo.git_ai(&["checkpoint", "cursor", "--hook-input", &before_read_hook]) + .unwrap(); + + // Step 2: Tab makes multiple edits - wrapping line with a for loop + // This simulates the example from the user where Tab wraps existing code + let edited_content = "function hello() {\n for (let i = 0; i < 10; i++) {\n console.log('hello world');\n }\n}\n"; + fs::write(&file_path, edited_content).unwrap(); + + // Step 3: afterTabFileEdit with multiple edits + let after_edit_hook = serde_json::json!({ + "conversation_id": "test-multi-edit-session", + "workspace_roots": [repo.canonical_path().to_string_lossy().to_string()], + "hook_event_name": "afterTabFileEdit", + "file_path": file_path.to_string_lossy().to_string(), + "edits": [ + { + "old_string": "", + "new_string": "for (let i = 0; i < 10; i++) {\n ", + "range": { + "start_line_number": 2, + "start_column": 5, + "end_line_number": 2, + "end_column": 5 + }, + "old_line": " console.log('hello world');", + "new_line": " for (let i = 0; i < 10; i++) {" + }, + { + "old_string": "", + "new_string": "\n }", + "range": { + "start_line_number": 2, + "start_column": 36, + "end_line_number": 2, + "end_column": 36 + }, + "old_line": " console.log('hello world');", + "new_line": " console.log('hello world');" + } + ], + "model": "tab" + }) + .to_string(); + + repo.git_ai(&["checkpoint", "cursor", "--hook-input", &after_edit_hook]) + .unwrap(); + + // Commit the changes + repo.stage_all_and_commit("Tab wraps code in for loop").unwrap(); + + // Verify attribution - the for loop lines should be attributed to AI + let mut file = repo.filename("index.ts"); + file.assert_lines_and_blame(lines![ + "function hello() {".human(), + " for (let i = 0; i < 10; i++) {".ai(), + " console.log('hello world');".human(), + " }".ai(), + "}".human(), + ]); +} + +#[test] +fn test_cursor_tab_edit_at_beginning_of_file() { + use std::fs; + + let repo = TestRepo::new(); + + // Create initial file + let file_path = repo.path().join("config.ts"); + let base_content = "export const API_URL = 'https://api.example.com';\n"; + fs::write(&file_path, base_content).unwrap(); + + repo.stage_all_and_commit("Initial commit").unwrap(); + + // beforeTabFileRead + let before_read_hook = serde_json::json!({ + "conversation_id": "test-beginning-edit", + "workspace_roots": [repo.canonical_path().to_string_lossy().to_string()], + "hook_event_name": "beforeTabFileRead", + "file_path": file_path.to_string_lossy().to_string(), + "content": base_content, + "model": "tab" + }) + .to_string(); + + repo.git_ai(&["checkpoint", "cursor", "--hook-input", &before_read_hook]) + .unwrap(); + + // Tab adds comment at the beginning + let edited_content = "// API Configuration\nexport const API_URL = 'https://api.example.com';\n"; + fs::write(&file_path, edited_content).unwrap(); + + // afterTabFileEdit + let after_edit_hook = serde_json::json!({ + "conversation_id": "test-beginning-edit", + "workspace_roots": [repo.canonical_path().to_string_lossy().to_string()], + "hook_event_name": "afterTabFileEdit", + "file_path": file_path.to_string_lossy().to_string(), + "edits": [{ + "old_string": "", + "new_string": "// API Configuration\n", + "range": { + "start_line_number": 1, + "start_column": 1, + "end_line_number": 1, + "end_column": 1 + }, + "old_line": "", + "new_line": "// API Configuration" + }], + "model": "tab" + }) + .to_string(); + + repo.git_ai(&["checkpoint", "cursor", "--hook-input", &after_edit_hook]) + .unwrap(); + + repo.stage_all_and_commit("Tab adds comment at beginning") + .unwrap(); + + // Verify blame + let mut file = repo.filename("config.ts"); + file.assert_lines_and_blame(lines![ + "// API Configuration".ai(), + "export const API_URL = 'https://api.example.com';".human(), + ]); +} + +#[test] +fn test_cursor_tab_edit_at_end_of_file() { + use std::fs; + + let repo = TestRepo::new(); + + // Create initial file + let file_path = repo.path().join("utils.ts"); + let base_content = "export function add(a: number, b: number) {\n return a + b;\n}\n"; + fs::write(&file_path, base_content).unwrap(); + + repo.stage_all_and_commit("Initial commit").unwrap(); + + // beforeTabFileRead + let before_read_hook = serde_json::json!({ + "conversation_id": "test-end-edit", + "workspace_roots": [repo.canonical_path().to_string_lossy().to_string()], + "hook_event_name": "beforeTabFileRead", + "file_path": file_path.to_string_lossy().to_string(), + "content": base_content, + "model": "tab" + }) + .to_string(); + + repo.git_ai(&["checkpoint", "cursor", "--hook-input", &before_read_hook]) + .unwrap(); + + // Tab adds new function at the end + let edited_content = "export function add(a: number, b: number) {\n return a + b;\n}\n\nexport function subtract(a: number, b: number) {\n return a - b;\n}\n"; + fs::write(&file_path, edited_content).unwrap(); + + // afterTabFileEdit + let after_edit_hook = serde_json::json!({ + "conversation_id": "test-end-edit", + "workspace_roots": [repo.canonical_path().to_string_lossy().to_string()], + "hook_event_name": "afterTabFileEdit", + "file_path": file_path.to_string_lossy().to_string(), + "edits": [{ + "old_string": "", + "new_string": "\nexport function subtract(a: number, b: number) {\n return a - b;\n}\n", + "range": { + "start_line_number": 4, + "start_column": 1, + "end_line_number": 4, + "end_column": 1 + }, + "old_line": "", + "new_line": "" + }], + "model": "tab" + }) + .to_string(); + + repo.git_ai(&["checkpoint", "cursor", "--hook-input", &after_edit_hook]) + .unwrap(); + + repo.stage_all_and_commit("Tab adds subtract function") + .unwrap(); + + // Verify blame + let mut file = repo.filename("utils.ts"); + file.assert_lines_and_blame(lines![ + "export function add(a: number, b: number) {".human(), + " return a + b;".human(), + "}".human(), + "".ai(), + "export function subtract(a: number, b: number) {".ai(), + " return a - b;".ai(), + "}".ai(), + ]); +} + +#[test] +fn test_cursor_tab_inline_completion() { + use std::fs; + + let repo = TestRepo::new(); + + // Create initial file with incomplete line + let file_path = repo.path().join("greeting.ts"); + let base_content = "function greet(name: string) {\n console.log(\n}\n"; + fs::write(&file_path, base_content).unwrap(); + + repo.stage_all_and_commit("Initial commit").unwrap(); + + // beforeTabFileRead + let before_read_hook = serde_json::json!({ + "conversation_id": "test-inline-completion", + "workspace_roots": [repo.canonical_path().to_string_lossy().to_string()], + "hook_event_name": "beforeTabFileRead", + "file_path": file_path.to_string_lossy().to_string(), + "content": base_content, + "model": "tab" + }) + .to_string(); + + repo.git_ai(&["checkpoint", "cursor", "--hook-input", &before_read_hook]) + .unwrap(); + + // Tab completes the console.log line + let edited_content = "function greet(name: string) {\n console.log(`Hello, ${name}!`);\n}\n"; + fs::write(&file_path, edited_content).unwrap(); + + // afterTabFileEdit - inline completion on same line + let after_edit_hook = serde_json::json!({ + "conversation_id": "test-inline-completion", + "workspace_roots": [repo.canonical_path().to_string_lossy().to_string()], + "hook_event_name": "afterTabFileEdit", + "file_path": file_path.to_string_lossy().to_string(), + "edits": [{ + "old_string": "", + "new_string": "`Hello, ${name}!`);", + "range": { + "start_line_number": 2, + "start_column": 17, + "end_line_number": 2, + "end_column": 17 + }, + "old_line": " console.log(", + "new_line": " console.log(`Hello, ${name}!`);" + }], + "model": "tab" + }) + .to_string(); + + repo.git_ai(&["checkpoint", "cursor", "--hook-input", &after_edit_hook]) + .unwrap(); + + repo.stage_all_and_commit("Tab completes console.log") + .unwrap(); + + // Verify blame - inline completion modifies an existing line, so it stays human + // (Git sees this as a modification of line 2, not a new AI-added line) + let mut file = repo.filename("greeting.ts"); + file.assert_lines_and_blame(lines![ + "function greet(name: string) {".human(), + " console.log(`Hello, ${name}!`);".human(), + "}".human(), + ]); +} + +#[test] +fn test_cursor_tab_multiple_sessions_same_file() { + use std::fs; + + let repo = TestRepo::new(); + + // Create initial file + let file_path = repo.path().join("math.ts"); + let base_content = "export function multiply(a: number, b: number) {\n return a * b;\n}\n"; + fs::write(&file_path, base_content).unwrap(); + + repo.stage_all_and_commit("Initial commit").unwrap(); + + // First Tab session - add a comment + let before_read_1 = serde_json::json!({ + "conversation_id": "session-1", + "workspace_roots": [repo.canonical_path().to_string_lossy().to_string()], + "hook_event_name": "beforeTabFileRead", + "file_path": file_path.to_string_lossy().to_string(), + "content": base_content, + "model": "tab" + }) + .to_string(); + + repo.git_ai(&["checkpoint", "cursor", "--hook-input", &before_read_1]) + .unwrap(); + + let content_after_1 = "// Multiplication function\nexport function multiply(a: number, b: number) {\n return a * b;\n}\n"; + fs::write(&file_path, content_after_1).unwrap(); + + let after_edit_1 = serde_json::json!({ + "conversation_id": "session-1", + "workspace_roots": [repo.canonical_path().to_string_lossy().to_string()], + "hook_event_name": "afterTabFileEdit", + "file_path": file_path.to_string_lossy().to_string(), + "edits": [{ + "old_string": "", + "new_string": "// Multiplication function\n", + "range": { + "start_line_number": 1, + "start_column": 1, + "end_line_number": 1, + "end_column": 1 + }, + "old_line": "", + "new_line": "// Multiplication function" + }], + "model": "tab" + }) + .to_string(); + + repo.git_ai(&["checkpoint", "cursor", "--hook-input", &after_edit_1]) + .unwrap(); + + repo.stage_all_and_commit("Tab adds comment").unwrap(); + + // Second Tab session - add another function + let before_read_2 = serde_json::json!({ + "conversation_id": "session-2", + "workspace_roots": [repo.canonical_path().to_string_lossy().to_string()], + "hook_event_name": "beforeTabFileRead", + "file_path": file_path.to_string_lossy().to_string(), + "content": content_after_1, + "model": "tab" + }) + .to_string(); + + repo.git_ai(&["checkpoint", "cursor", "--hook-input", &before_read_2]) + .unwrap(); + + let content_after_2 = "// Multiplication function\nexport function multiply(a: number, b: number) {\n return a * b;\n}\n\n// Division function\nexport function divide(a: number, b: number) {\n return a / b;\n}\n"; + fs::write(&file_path, content_after_2).unwrap(); + + let after_edit_2 = serde_json::json!({ + "conversation_id": "session-2", + "workspace_roots": [repo.canonical_path().to_string_lossy().to_string()], + "hook_event_name": "afterTabFileEdit", + "file_path": file_path.to_string_lossy().to_string(), + "edits": [{ + "old_string": "", + "new_string": "\n// Division function\nexport function divide(a: number, b: number) {\n return a / b;\n}\n", + "range": { + "start_line_number": 5, + "start_column": 1, + "end_line_number": 5, + "end_column": 1 + }, + "old_line": "", + "new_line": "" + }], + "model": "tab" + }) + .to_string(); + + repo.git_ai(&["checkpoint", "cursor", "--hook-input", &after_edit_2]) + .unwrap(); + + repo.stage_all_and_commit("Tab adds divide function") + .unwrap(); + + // Verify blame - both Tab sessions' contributions should be attributed + let mut file = repo.filename("math.ts"); + file.assert_lines_and_blame(lines![ + "// Multiplication function".ai(), + "export function multiply(a: number, b: number) {".human(), + " return a * b;".human(), + "}".human(), + "".ai(), + "// Division function".ai(), + "export function divide(a: number, b: number) {".ai(), + " return a / b;".ai(), + "}".ai(), + ]); +} From 454c67c8319675da684a0cac206686d8b87fb471 Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Tue, 9 Dec 2025 21:44:04 -0500 Subject: [PATCH 4/7] skip prompt updates for ai tab (only needed for ai agent) checkpoints --- src/authorship/post_commit.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/authorship/post_commit.rs b/src/authorship/post_commit.rs index 77c85dc8a..6b64a3389 100644 --- a/src/authorship/post_commit.rs +++ b/src/authorship/post_commit.rs @@ -146,9 +146,15 @@ pub fn filter_untracked_files( /// across multiple checkpoints when only the final version matters. fn update_prompts_to_latest(checkpoints: &mut [Checkpoint]) -> Result<(), GitAiError> { // Group checkpoints by agent ID (tool + id), tracking indices + // Only process AiAgent checkpoints (skip AiTab and Human) let mut agent_checkpoint_indices: HashMap> = HashMap::new(); for (idx, checkpoint) in checkpoints.iter().enumerate() { + // Skip non-AiAgent checkpoints + if checkpoint.kind != crate::authorship::working_log::CheckpointKind::AiAgent { + continue; + } + if let Some(agent_id) = &checkpoint.agent_id { let key = format!("{}:{}", agent_id.tool, agent_id.id); agent_checkpoint_indices From 7e9dffef75d03a1773bdba4aad004b5047a29f60 Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Tue, 9 Dec 2025 22:07:03 -0500 Subject: [PATCH 5/7] update install-hooks for cursor tab hooks --- src/commands/install_hooks.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/commands/install_hooks.rs b/src/commands/install_hooks.rs index 26f03237a..41f48474f 100644 --- a/src/commands/install_hooks.rs +++ b/src/commands/install_hooks.rs @@ -23,8 +23,8 @@ const CLAUDE_POST_TOOL_CMD: &str = "checkpoint claude --hook-input stdin"; // Cursor hooks (requires absolute path to avoid shell config loading delay) const CURSOR_BEFORE_SUBMIT_CMD: &str = "checkpoint cursor --hook-input stdin"; const CURSOR_AFTER_EDIT_CMD: &str = "checkpoint cursor --hook-input stdin"; -const CURSOR_BEFORE_TAB_FILE_READ_CMD: &str = "checkpoint cursor-tab --hook-input stdin"; -const CURSOR_AFTER_TAB_FILE_EDIT_CMD: &str = "checkpoint cursor-tab --hook-input stdin"; +const CURSOR_BEFORE_TAB_FILE_READ_CMD: &str = "checkpoint cursor --hook-input stdin"; +const CURSOR_AFTER_TAB_FILE_EDIT_CMD: &str = "checkpoint cursor --hook-input stdin"; // OpenCode plugin content (TypeScript), embedded from the source file to avoid drift const OPENCODE_PLUGIN_CONTENT: &str = include_str!(concat!( @@ -796,6 +796,8 @@ fn install_cursor_hooks(binary_path: &Path, dry_run: bool) -> Result Result Result Date: Tue, 9 Dec 2025 23:23:35 -0500 Subject: [PATCH 6/7] clean up cursor tab tests, add more cases --- .../checkpoint_agent/agent_presets.rs | 188 +++++++---- tests/cursor.rs | 316 ++++++++++++------ 2 files changed, 343 insertions(+), 161 deletions(-) diff --git a/src/commands/checkpoint_agent/agent_presets.rs b/src/commands/checkpoint_agent/agent_presets.rs index 131cc8efd..cb6ebb11e 100644 --- a/src/commands/checkpoint_agent/agent_presets.rs +++ b/src/commands/checkpoint_agent/agent_presets.rs @@ -749,18 +749,26 @@ impl AgentCheckpointPreset for CursorPreset { })?; // Get the most recent file content from the working log - let old_content = Self::get_most_recent_file_content(&repo_working_dir, &file_path) + let old_content = match Self::get_most_recent_file_content(&repo_working_dir, &file_path) .map(|(content, _blob_sha)| content) - .unwrap_or_else(|| { - // If no checkpoint exists, try to read from filesystem as fallback - std::fs::read_to_string(&file_path).unwrap_or_default() - }); + { + Some(content) => content, + None => { + return Err(GitAiError::PresetError(format!( + "No checkpoint exists for file '{}', cannot reconstruct pre-edit content for afterTabFileEdit", + file_path + ))) + } + }; // Apply the edits to get the new content - let new_content = Self::apply_edits_to_content(&old_content, edits).unwrap_or_else(|e| { - eprintln!("[Warning] Failed to apply edits for afterTabFileEdit: {}", e); - old_content.clone() - }); + let new_content = Self::apply_edits_to_content(&old_content, edits) + .map_err(|e| { + GitAiError::PresetError(format!( + "Failed to apply edits for afterTabFileEdit: {}", + e + )) + })?; // Create dirty_files with the new content let mut dirty_files = HashMap::new(); @@ -903,6 +911,12 @@ impl CursorPreset { } } + // Fallback: try to read the file content from HEAD for this repository + if let Ok(bytes) = repo.get_file_content(&relative_file_path, &base_commit) { + let content = String::from_utf8_lossy(&bytes).to_string(); + return Some((content, String::new())); + } + None } @@ -912,18 +926,70 @@ impl CursorPreset { old_content: &str, edits: &serde_json::Value, ) -> Result { - let edits_array = edits.as_array().ok_or_else(|| { - GitAiError::PresetError("edits must be an array".to_string()) - })?; + let edits_array = edits + .as_array() + .ok_or_else(|| GitAiError::PresetError("edits must be an array".to_string()))?; - let mut lines: Vec = old_content.lines().map(|s| s.to_string()).collect(); + if edits_array.is_empty() { + return Ok(old_content.to_string()); + } - // Apply each edit in order - for edit in edits_array { - let range = edit.get("range").ok_or_else(|| { - GitAiError::PresetError("edit missing range field".to_string()) + // Track the starting character offset of each line (1-indexed lines -> 0-indexed offsets) + let mut line_start_positions: Vec = vec![0]; + let mut total_chars = 0usize; + for ch in old_content.chars() { + total_chars += 1; + if ch == '\n' { + // Next line starts after this newline + line_start_positions.push(total_chars); + } + } + + // Map character offsets to byte offsets so we can safely slice the string + let mut char_to_byte: Vec = Vec::with_capacity(total_chars + 1); + for (byte_idx, _) in old_content.char_indices() { + char_to_byte.push(byte_idx); + } + char_to_byte.push(old_content.len()); + + // Helper to convert (line, column) -> character offset, clamping columns to the end of the line + let line_col_to_offset = |line: usize, col: usize| -> Result { + if line == 0 { + return Err(GitAiError::PresetError( + "line numbers are 1-indexed in Cursor edits".to_string(), + )); + } + + let line_idx = line - 1; + let line_start = *line_start_positions.get(line_idx).ok_or_else(|| { + GitAiError::PresetError(format!("line {} not found in original content", line)) })?; + // The next line start (if present) is one char past the newline + let line_end_exclusive = if let Some(next_line_start) = line_start_positions.get(line_idx + 1) { + next_line_start.saturating_sub(1) // Exclude the newline itself + } else { + total_chars + }; + + let line_len = line_end_exclusive.saturating_sub(line_start); + let col_idx = col.saturating_sub(1).min(line_len); + Ok(line_start + col_idx) + }; + + struct TabEdit { + start_offset: usize, + end_offset: usize, + new_text: String, + } + + let mut parsed_edits: Vec = Vec::with_capacity(edits_array.len()); + + for edit in edits_array { + let range = edit + .get("range") + .ok_or_else(|| GitAiError::PresetError("edit missing range field".to_string()))?; + let start_line = range .get("start_line_number") .and_then(|v| v.as_u64()) @@ -955,54 +1021,60 @@ impl CursorPreset { let new_string = edit .get("new_string") .and_then(|v| v.as_str()) - .unwrap_or(""); + .unwrap_or("") + .to_string(); - // Convert 1-indexed line numbers to 0-indexed - let start_line_idx = start_line.saturating_sub(1); - let end_line_idx = end_line.saturating_sub(1); + let start_offset = line_col_to_offset(start_line, start_col)?; + let end_offset = line_col_to_offset(end_line, end_col)?; - // Ensure we have enough lines - while lines.len() <= end_line_idx { - lines.push(String::new()); + if start_offset > end_offset { + return Err(GitAiError::PresetError(format!( + "edit start offset {} is after end offset {}", + start_offset, end_offset + ))); } - if start_line_idx == end_line_idx { - // Single-line edit - let line = &lines[start_line_idx]; - // Convert 1-indexed columns to 0-indexed - let start_col_idx = start_col.saturating_sub(1); - let end_col_idx = end_col.saturating_sub(1); - - // Split the line and insert the new string - let before = if start_col_idx < line.len() { - &line[..start_col_idx] - } else { - line.as_str() - }; - let after = if end_col_idx < line.len() { - &line[end_col_idx..] - } else { - "" - }; - - lines[start_line_idx] = format!("{}{}{}", before, new_string, after); - } else { - // Multi-line edit - for now, treat as single-line at start position - // This is a simplification; full implementation would handle multi-line edits - let line = &lines[start_line_idx]; - let start_col_idx = start_col.saturating_sub(1); - - let before = if start_col_idx < line.len() { - &line[..start_col_idx] - } else { - line.as_str() - }; - - lines[start_line_idx] = format!("{}{}", before, new_string); + parsed_edits.push(TabEdit { + start_offset, + end_offset, + new_text: new_string, + }); + } + + // Apply edits from the end of the file backwards so earlier offsets remain valid + parsed_edits.sort_by(|a, b| { + b.start_offset + .cmp(&a.start_offset) + .then_with(|| b.end_offset.cmp(&a.end_offset)) + }); + + let mut result = old_content.to_string(); + for edit in parsed_edits { + let start_byte = *char_to_byte.get(edit.start_offset).ok_or_else(|| { + GitAiError::PresetError(format!( + "failed to convert start offset {} to byte index", + edit.start_offset + )) + })?; + + let end_byte = *char_to_byte.get(edit.end_offset).ok_or_else(|| { + GitAiError::PresetError(format!( + "failed to convert end offset {} to byte index", + edit.end_offset + )) + })?; + + if start_byte > end_byte || end_byte > result.len() { + return Err(GitAiError::PresetError(format!( + "invalid byte range {}..{} for edit", + start_byte, end_byte + ))); } + + result.replace_range(start_byte..end_byte, &edit.new_text); } - Ok(lines.join("\n")) + Ok(result) } /// Fetch the latest version of a Cursor conversation from the database diff --git a/tests/cursor.rs b/tests/cursor.rs index cad01a8b9..19917e869 100644 --- a/tests/cursor.rs +++ b/tests/cursor.rs @@ -569,75 +569,6 @@ fn test_cursor_preset_before_tab_file_read() { assert_eq!(result.agent_id.model, "tab"); } -#[test] -fn test_cursor_preset_after_tab_file_edit() { - use git_ai::authorship::working_log::CheckpointKind; - use git_ai::commands::checkpoint_agent::agent_presets::{ - AgentCheckpointFlags, AgentCheckpointPreset, CursorPreset, - }; - - let hook_input = r##"{ - "conversation_id": "test-tab-conversation-id", - "workspace_roots": ["/Users/test/workspace"], - "hook_event_name": "afterTabFileEdit", - "file_path": "/Users/test/workspace/src/main.rs", - "edits": [ - { - "old_string": "", - "new_string": "// New comment", - "range": { - "start_line_number": 1, - "start_column": 1, - "end_line_number": 1, - "end_column": 1 - }, - "old_line": "", - "new_line": "// New comment" - } - ], - "model": "tab" - }"##; - - let flags = AgentCheckpointFlags { - hook_input: Some(hook_input.to_string()), - }; - - let preset = CursorPreset; - let result = preset - .run(flags) - .expect("Should succeed for afterTabFileEdit"); - - // Verify this is an AiTab checkpoint - assert_eq!( - result.checkpoint_kind, - CheckpointKind::AiTab, - "Should be an AiTab checkpoint" - ); - - // Verify edited_filepaths is set - assert!(result.edited_filepaths.is_some(), "Should have edited_filepaths"); - let edited = result.edited_filepaths.unwrap(); - assert_eq!(edited.len(), 1, "Should have exactly one file"); - assert_eq!(edited[0], "/Users/test/workspace/src/main.rs"); - - // Verify dirty_files contains the new content - assert!(result.dirty_files.is_some(), "Should have dirty_files"); - let dirty_files = result.dirty_files.unwrap(); - assert_eq!(dirty_files.len(), 1, "Should have exactly one dirty file"); - assert!( - dirty_files.contains_key("/Users/test/workspace/src/main.rs"), - "Should contain the file path" - ); - - // Verify agent_id - assert_eq!(result.agent_id.tool, "cursor"); - assert_eq!(result.agent_id.id, "test-tab-conversation-id"); - assert_eq!(result.agent_id.model, "tab"); - - // Verify no agent_metadata - assert!(result.agent_metadata.is_none(), "Should not have agent_metadata"); -} - #[test] fn test_cursor_tab_e2e_workflow() { use std::fs; @@ -672,11 +603,7 @@ fn test_cursor_tab_e2e_workflow() { println!("Before read checkpoint output: {}", result); - // Step 2: Simulate Tab making edits to the file - let edited_content = "fn main() {\n println!(\"Hello, World!\");\n // Added by Tab AI\n println!(\"Tab was here!\");\n}\n"; - fs::write(&file_path, edited_content).unwrap(); - - // Step 3: afterTabFileEdit - simulate Tab completing the edit + // Step 2: afterTabFileEdit - simulate Tab completing the edit (unsaved) let after_edit_hook = serde_json::json!({ "conversation_id": "test-tab-session", "workspace_roots": [repo.canonical_path().to_string_lossy().to_string()], @@ -704,6 +631,10 @@ fn test_cursor_tab_e2e_workflow() { println!("After edit checkpoint output: {}", result); + // User saves the file before committing + let edited_content = "fn main() {\n println!(\"Hello, World!\");\n // Added by Tab AI\n println!(\"Tab was here!\");\n}\n"; + fs::write(&file_path, edited_content).unwrap(); + // Commit the changes let commit = repo.stage_all_and_commit("Add Tab AI edits").unwrap(); @@ -772,12 +703,7 @@ fn test_cursor_tab_multiple_edits_in_one_session() { repo.git_ai(&["checkpoint", "cursor", "--hook-input", &before_read_hook]) .unwrap(); - // Step 2: Tab makes multiple edits - wrapping line with a for loop - // This simulates the example from the user where Tab wraps existing code - let edited_content = "function hello() {\n for (let i = 0; i < 10; i++) {\n console.log('hello world');\n }\n}\n"; - fs::write(&file_path, edited_content).unwrap(); - - // Step 3: afterTabFileEdit with multiple edits + // Step 2: afterTabFileEdit with multiple edits (unsaved) let after_edit_hook = serde_json::json!({ "conversation_id": "test-multi-edit-session", "workspace_roots": [repo.canonical_path().to_string_lossy().to_string()], @@ -816,6 +742,10 @@ fn test_cursor_tab_multiple_edits_in_one_session() { repo.git_ai(&["checkpoint", "cursor", "--hook-input", &after_edit_hook]) .unwrap(); + // User saves the file before committing + let edited_content = "function hello() {\n for (let i = 0; i < 10; i++) {\n console.log('hello world');\n }\n}\n"; + fs::write(&file_path, edited_content).unwrap(); + // Commit the changes repo.stage_all_and_commit("Tab wraps code in for loop").unwrap(); @@ -830,6 +760,102 @@ fn test_cursor_tab_multiple_edits_in_one_session() { ]); } +#[test] +fn test_cursor_tab_serial_edits_before_save() { + use std::fs; + + let repo = TestRepo::new(); + + let file_path = repo.path().join("stacked.ts"); + let base_content = "function calc(n: number) {\n return n * 2;\n}\n"; + fs::write(&file_path, base_content).unwrap(); + + repo.stage_all_and_commit("Initial commit").unwrap(); + + // Initial read + let before_read_hook = serde_json::json!({ + "conversation_id": "stacked-tab-session", + "workspace_roots": [repo.canonical_path().to_string_lossy().to_string()], + "hook_event_name": "beforeTabFileRead", + "file_path": file_path.to_string_lossy().to_string(), + "content": base_content, + "model": "tab" + }) + .to_string(); + + repo.git_ai(&["checkpoint", "cursor", "--hook-input", &before_read_hook]) + .unwrap(); + + // First tab edit (unsaved) + let after_edit_hook_one = serde_json::json!({ + "conversation_id": "stacked-tab-session", + "workspace_roots": [repo.canonical_path().to_string_lossy().to_string()], + "hook_event_name": "afterTabFileEdit", + "file_path": file_path.to_string_lossy().to_string(), + "edits": [{ + "old_string": "", + "new_string": "if (n < 0) {\n return 0;\n }\n ", + "range": { + "start_line_number": 2, + "start_column": 5, + "end_line_number": 2, + "end_column": 5 + }, + "old_line": " return n * 2;", + "new_line": " if (n < 0) {" + }], + "model": "tab" + }) + .to_string(); + + repo.git_ai(&["checkpoint", "cursor", "--hook-input", &after_edit_hook_one]) + .unwrap(); + + // Second tab edit (still unsaved) + let after_edit_hook_two = serde_json::json!({ + "conversation_id": "stacked-tab-session", + "workspace_roots": [repo.canonical_path().to_string_lossy().to_string()], + "hook_event_name": "afterTabFileEdit", + "file_path": file_path.to_string_lossy().to_string(), + "edits": [{ + "old_string": "", + "new_string": " const result = n * 2;\n console.log(result);\n return result;", + "range": { + "start_line_number": 5, + "start_column": 1, + "end_line_number": 5, + "end_column": 100 + }, + "old_line": " return n * 2;", + "new_line": " const result = n * 2;" + }], + "model": "tab" + }) + .to_string(); + + repo.git_ai(&["checkpoint", "cursor", "--hook-input", &after_edit_hook_two]) + .unwrap(); + + // User finally saves the file before committing + let final_content = "function calc(n: number) {\n if (n < 0) {\n return 0;\n }\n const result = n * 2;\n console.log(result);\n return result;\n}\n"; + fs::write(&file_path, final_content).unwrap(); + + repo.stage_all_and_commit("Stacked tab edits before save") + .unwrap(); + + let mut file = repo.filename("stacked.ts"); + file.assert_lines_and_blame(lines![ + "function calc(n: number) {".human(), + " if (n < 0) {".ai(), + " return 0;".ai(), + " }".ai(), + " const result = n * 2;".ai(), + " console.log(result);".ai(), + " return result;".ai(), + "}".human(), + ]); +} + #[test] fn test_cursor_tab_edit_at_beginning_of_file() { use std::fs; @@ -857,11 +883,7 @@ fn test_cursor_tab_edit_at_beginning_of_file() { repo.git_ai(&["checkpoint", "cursor", "--hook-input", &before_read_hook]) .unwrap(); - // Tab adds comment at the beginning - let edited_content = "// API Configuration\nexport const API_URL = 'https://api.example.com';\n"; - fs::write(&file_path, edited_content).unwrap(); - - // afterTabFileEdit + // afterTabFileEdit (unsaved) let after_edit_hook = serde_json::json!({ "conversation_id": "test-beginning-edit", "workspace_roots": [repo.canonical_path().to_string_lossy().to_string()], @@ -886,6 +908,10 @@ fn test_cursor_tab_edit_at_beginning_of_file() { repo.git_ai(&["checkpoint", "cursor", "--hook-input", &after_edit_hook]) .unwrap(); + // User saves the file before committing + let edited_content = "// API Configuration\nexport const API_URL = 'https://api.example.com';\n"; + fs::write(&file_path, edited_content).unwrap(); + repo.stage_all_and_commit("Tab adds comment at beginning") .unwrap(); @@ -924,11 +950,7 @@ fn test_cursor_tab_edit_at_end_of_file() { repo.git_ai(&["checkpoint", "cursor", "--hook-input", &before_read_hook]) .unwrap(); - // Tab adds new function at the end - let edited_content = "export function add(a: number, b: number) {\n return a + b;\n}\n\nexport function subtract(a: number, b: number) {\n return a - b;\n}\n"; - fs::write(&file_path, edited_content).unwrap(); - - // afterTabFileEdit + // afterTabFileEdit (unsaved) let after_edit_hook = serde_json::json!({ "conversation_id": "test-end-edit", "workspace_roots": [repo.canonical_path().to_string_lossy().to_string()], @@ -953,6 +975,10 @@ fn test_cursor_tab_edit_at_end_of_file() { repo.git_ai(&["checkpoint", "cursor", "--hook-input", &after_edit_hook]) .unwrap(); + // User saves the file before committing + let edited_content = "export function add(a: number, b: number) {\n return a + b;\n}\n\nexport function subtract(a: number, b: number) {\n return a - b;\n}\n"; + fs::write(&file_path, edited_content).unwrap(); + repo.stage_all_and_commit("Tab adds subtract function") .unwrap(); @@ -996,10 +1022,6 @@ fn test_cursor_tab_inline_completion() { repo.git_ai(&["checkpoint", "cursor", "--hook-input", &before_read_hook]) .unwrap(); - // Tab completes the console.log line - let edited_content = "function greet(name: string) {\n console.log(`Hello, ${name}!`);\n}\n"; - fs::write(&file_path, edited_content).unwrap(); - // afterTabFileEdit - inline completion on same line let after_edit_hook = serde_json::json!({ "conversation_id": "test-inline-completion", @@ -1025,15 +1047,101 @@ fn test_cursor_tab_inline_completion() { repo.git_ai(&["checkpoint", "cursor", "--hook-input", &after_edit_hook]) .unwrap(); + // User saves the file before committing + let edited_content = "function greet(name: string) {\n console.log(`Hello, ${name}!`);\n}\n"; + fs::write(&file_path, edited_content).unwrap(); + repo.stage_all_and_commit("Tab completes console.log") .unwrap(); - // Verify blame - inline completion modifies an existing line, so it stays human - // (Git sees this as a modification of line 2, not a new AI-added line) + // Verify blame - inline completion modifies an existing line let mut file = repo.filename("greeting.ts"); file.assert_lines_and_blame(lines![ "function greet(name: string) {".human(), - " console.log(`Hello, ${name}!`);".human(), + " console.log(`Hello, ${name}!`);".ai(), + "}".human(), + ]); +} + +#[test] +fn test_cursor_tab_import_and_body_rewrite() { + use std::fs; + + let repo = TestRepo::new(); + + let file_path = repo.path().join("greet.ts"); + let base_content = "export function greet(name: string) {\n return `Hi ${name}`;\n}\n"; + fs::write(&file_path, base_content).unwrap(); + + repo.stage_all_and_commit("Initial commit").unwrap(); + + // beforeTabFileRead + let before_read_hook = serde_json::json!({ + "conversation_id": "import-body-rewrite", + "workspace_roots": [repo.canonical_path().to_string_lossy().to_string()], + "hook_event_name": "beforeTabFileRead", + "file_path": file_path.to_string_lossy().to_string(), + "content": base_content, + "model": "tab" + }) + .to_string(); + + repo.git_ai(&["checkpoint", "cursor", "--hook-input", &before_read_hook]) + .unwrap(); + + // afterTabFileEdit (unsaved) with two edits + let after_edit_hook = serde_json::json!({ + "conversation_id": "import-body-rewrite", + "workspace_roots": [repo.canonical_path().to_string_lossy().to_string()], + "hook_event_name": "afterTabFileEdit", + "file_path": file_path.to_string_lossy().to_string(), + "edits": [ + { + "old_string": "", + "new_string": "import { logger } from './logger';\n\n", + "range": { + "start_line_number": 1, + "start_column": 1, + "end_line_number": 1, + "end_column": 1 + }, + "old_line": "", + "new_line": "import { logger } from './logger';" + }, + { + "old_string": "", + "new_string": "return `Hello, ${name}!`;\n logger.log('greet called');\n ", + "range": { + "start_line_number": 2, + "start_column": 5, + "end_line_number": 2, + "end_column": 100 + }, + "old_line": " return `Hi ${name}`;", + "new_line": " return `Hello, ${name}!`;" + } + ], + "model": "tab" + }) + .to_string(); + + repo.git_ai(&["checkpoint", "cursor", "--hook-input", &after_edit_hook]) + .unwrap(); + + // User saves the file before committing + let edited_content = "import { logger } from './logger';\n\nexport function greet(name: string) {\n return `Hello, ${name}!`;\n logger.log('greet called');\n}\n"; + fs::write(&file_path, edited_content).unwrap(); + + repo.stage_all_and_commit("Tab adds import and rewrites body") + .unwrap(); + + let mut file = repo.filename("greet.ts"); + file.assert_lines_and_blame(lines![ + "import { logger } from './logger';".ai(), + "".ai(), + "export function greet(name: string) {".human(), + " return `Hello, ${name}!`;".ai(), + " logger.log('greet called');".ai(), "}".human(), ]); } @@ -1065,9 +1173,6 @@ fn test_cursor_tab_multiple_sessions_same_file() { repo.git_ai(&["checkpoint", "cursor", "--hook-input", &before_read_1]) .unwrap(); - let content_after_1 = "// Multiplication function\nexport function multiply(a: number, b: number) {\n return a * b;\n}\n"; - fs::write(&file_path, content_after_1).unwrap(); - let after_edit_1 = serde_json::json!({ "conversation_id": "session-1", "workspace_roots": [repo.canonical_path().to_string_lossy().to_string()], @@ -1092,6 +1197,10 @@ fn test_cursor_tab_multiple_sessions_same_file() { repo.git_ai(&["checkpoint", "cursor", "--hook-input", &after_edit_1]) .unwrap(); + // User saves the file before committing + let content_after_1 = "// Multiplication function\nexport function multiply(a: number, b: number) {\n return a * b;\n}\n"; + fs::write(&file_path, content_after_1).unwrap(); + repo.stage_all_and_commit("Tab adds comment").unwrap(); // Second Tab session - add another function @@ -1108,9 +1217,6 @@ fn test_cursor_tab_multiple_sessions_same_file() { repo.git_ai(&["checkpoint", "cursor", "--hook-input", &before_read_2]) .unwrap(); - let content_after_2 = "// Multiplication function\nexport function multiply(a: number, b: number) {\n return a * b;\n}\n\n// Division function\nexport function divide(a: number, b: number) {\n return a / b;\n}\n"; - fs::write(&file_path, content_after_2).unwrap(); - let after_edit_2 = serde_json::json!({ "conversation_id": "session-2", "workspace_roots": [repo.canonical_path().to_string_lossy().to_string()], @@ -1135,6 +1241,10 @@ fn test_cursor_tab_multiple_sessions_same_file() { repo.git_ai(&["checkpoint", "cursor", "--hook-input", &after_edit_2]) .unwrap(); + // User saves the file before committing + let content_after_2 = "// Multiplication function\nexport function multiply(a: number, b: number) {\n return a * b;\n}\n\n// Division function\nexport function divide(a: number, b: number) {\n return a / b;\n}\n"; + fs::write(&file_path, content_after_2).unwrap(); + repo.stage_all_and_commit("Tab adds divide function") .unwrap(); From 9a91dacd3ca3d5af89be106b85bf4cc6da346017 Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Tue, 9 Dec 2025 23:37:29 -0500 Subject: [PATCH 7/7] cursor tab tests windows fixes --- .../checkpoint_agent/agent_presets.rs | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/commands/checkpoint_agent/agent_presets.rs b/src/commands/checkpoint_agent/agent_presets.rs index cb6ebb11e..b7f26ed17 100644 --- a/src/commands/checkpoint_agent/agent_presets.rs +++ b/src/commands/checkpoint_agent/agent_presets.rs @@ -5,6 +5,7 @@ use crate::{ }, error::GitAiError, git::repo_storage::RepoStorage, + utils::normalize_to_posix, }; use chrono::{TimeZone, Utc}; use rusqlite::{Connection, OpenFlags}; @@ -894,7 +895,24 @@ impl CursorPreset { let checkpoints = working_log.read_all_checkpoints().ok()?; // Convert file_path to repo-relative format for comparison - let relative_file_path = working_log.to_repo_relative_path(file_path); + let mut relative_file_path = working_log.to_repo_relative_path(file_path); + // If still absolute (can happen on Windows with short paths), try again manually + if std::path::Path::new(&relative_file_path).is_absolute() { + let repo_workdir = repo_workdir_path.clone(); + let canonical_repo = repo_workdir.canonicalize().ok(); + let canonical_file = std::path::Path::new(file_path).canonicalize().ok(); + + if let (Some(repo_canon), Some(file_canon)) = (canonical_repo, canonical_file) { + if file_canon.starts_with(&repo_canon) { + if let Ok(rel) = file_canon.strip_prefix(&repo_canon) { + relative_file_path = normalize_to_posix(&rel.to_string_lossy()); + } + } + } + } else { + // Normalize separators to POSIX for git lookups + relative_file_path = normalize_to_posix(&relative_file_path); + } // Find the most recent checkpoint that has an entry for this file for checkpoint in checkpoints.iter().rev() {