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(), 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 diff --git a/src/commands/checkpoint_agent/agent_presets.rs b/src/commands/checkpoint_agent/agent_presets.rs index ea9ddace2..b7f26ed17 100644 --- a/src/commands/checkpoint_agent/agent_presets.rs +++ b/src/commands/checkpoint_agent/agent_presets.rs @@ -4,6 +4,8 @@ use crate::{ working_log::{AgentId, CheckpointKind}, }, error::GitAiError, + git::repo_storage::RepoStorage, + utils::normalize_to_posix, }; use chrono::{TimeZone, Utc}; use rusqlite::{Connection, OpenFlags}; @@ -661,9 +663,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 +695,104 @@ 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 = match Self::get_most_recent_file_content(&repo_working_dir, &file_path) + .map(|(content, _blob_sha)| content) + { + 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) + .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(); + 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 +864,237 @@ 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 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() { + 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())); + } + } + } + } + } + + // 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 + } + + /// 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()))?; + + if edits_array.is_empty() { + return Ok(old_content.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()) + .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("") + .to_string(); + + let start_offset = line_col_to_offset(start_line, start_col)?; + let end_offset = line_col_to_offset(end_line, end_col)?; + + if start_offset > end_offset { + return Err(GitAiError::PresetError(format!( + "edit start offset {} is after end offset {}", + start_offset, end_offset + ))); + } + + 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(result) + } + /// 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..41f48474f 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 --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!( @@ -794,6 +796,8 @@ fn install_cursor_hooks(binary_path: &Path, dry_run: bool) -> Result Result Result 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'" + ); +} + +#[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: 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()], + "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(); + + // 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(); + + // 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_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; + + 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(); + + // afterTabFileEdit (unsaved) + 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(); + + // 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(); + + // 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(); + + // afterTabFileEdit (unsaved) + 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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 + let mut file = repo.filename("greeting.ts"); + file.assert_lines_and_blame(lines![ + "function greet(name: string) {".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(), + ]); +} + +#[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 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(); + + // 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 + 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 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(); + + // 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(); + + // 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(), + ]); +}