From f9ea02446ad65ee6ec3b5a3ca08388c29aba7570 Mon Sep 17 00:00:00 2001 From: Kc Balusu Date: Tue, 17 Mar 2026 20:00:51 -0400 Subject: [PATCH 1/3] Add git-ai blame visualization to JetBrains plugin (#660) Implements AI blame annotations in the IntelliJ plugin, matching the VS Code extension's functionality. Calls `git-ai blame --json --contents -` to get per-line AI authorship data and renders it via: - Gutter color stripes (4px colored icons per AI-authored line) - Inline after-text inlays showing model name (e.g. "Sonnet 3.5 via Cursor") - Status bar widget showing AI/human indicator for current line - Toggle action (Cmd+Shift+G) cycling Off/Line/All modes New files: BlameModels, BlameService, BlameEditorManager, BlameStatusBarWidgetFactory, BlameToggleAction, BlameStartupActivity, ModelNameParser (ported from VS Code extension). --- .../template/blame/BlameEditorManager.kt | 442 ++++++++++++++++++ .../plugins/template/blame/BlameModels.kt | 64 +++ .../plugins/template/blame/BlameService.kt | 171 +++++++ .../template/blame/BlameStartupActivity.kt | 13 + .../blame/BlameStatusBarWidgetFactory.kt | 86 ++++ .../template/blame/BlameToggleAction.kt | 26 ++ .../plugins/template/blame/ModelNameParser.kt | 98 ++++ .../plugins/template/services/GitAiService.kt | 3 + .../src/main/resources/META-INF/plugin.xml | 19 + 9 files changed, 922 insertions(+) create mode 100644 agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameEditorManager.kt create mode 100644 agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameModels.kt create mode 100644 agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameService.kt create mode 100644 agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameStartupActivity.kt create mode 100644 agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameStatusBarWidgetFactory.kt create mode 100644 agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameToggleAction.kt create mode 100644 agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/ModelNameParser.kt diff --git a/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameEditorManager.kt b/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameEditorManager.kt new file mode 100644 index 000000000..c767de5ca --- /dev/null +++ b/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameEditorManager.kt @@ -0,0 +1,442 @@ +package org.jetbrains.plugins.template.blame + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.EditorCustomElementRenderer +import com.intellij.openapi.editor.Inlay +import com.intellij.openapi.editor.colors.EditorFontType +import com.intellij.openapi.editor.event.CaretEvent +import com.intellij.openapi.editor.event.CaretListener +import com.intellij.openapi.editor.event.DocumentEvent +import com.intellij.openapi.editor.event.DocumentListener +import com.intellij.openapi.editor.markup.HighlighterLayer +import com.intellij.openapi.editor.markup.RangeHighlighter +import com.intellij.openapi.editor.markup.TextAttributes +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.FileEditorManagerListener +import com.intellij.openapi.fileEditor.TextEditor +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.vfs.VirtualFile +import java.awt.Color +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.Rectangle +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit + +/** + * Project-level service that manages blame decorations across editors. + * Renders gutter color stripes, inline after-text model annotations, and hover tooltips. + */ +@Service(Service.Level.PROJECT) +class BlameEditorManager(private val project: Project) : Disposable { + + private val logger = thisLogger() + + @Volatile + var blameMode: BlameMode = BlameMode.LINE + + // Per-editor state + private val editorStates = ConcurrentHashMap() + private val scheduler: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor { r -> + Thread(r, "git-ai-blame-scheduler").apply { isDaemon = true } + } + + // 40 distinct colors for prompt differentiation (same palette as VS Code extension) + private val promptColors = listOf( + Color(66, 133, 244), // Blue + Color(219, 68, 55), // Red + Color(244, 180, 0), // Yellow + Color(15, 157, 88), // Green + Color(171, 71, 188), // Purple + Color(255, 112, 67), // Deep Orange + Color(0, 172, 193), // Cyan + Color(124, 179, 66), // Light Green + Color(233, 30, 99), // Pink + Color(63, 81, 181), // Indigo + Color(255, 152, 0), // Orange + Color(0, 150, 136), // Teal + Color(156, 39, 176), // Deep Purple + Color(139, 195, 74), // Lime + Color(3, 169, 244), // Light Blue + Color(255, 87, 34), // Burnt Orange + Color(121, 85, 72), // Brown + Color(96, 125, 139), // Blue Grey + Color(244, 67, 54), // Bright Red + Color(33, 150, 243), // Material Blue + Color(76, 175, 80), // Material Green + Color(255, 193, 7), // Amber + Color(0, 188, 212), // Material Cyan + Color(103, 58, 183), // Material Purple + Color(205, 220, 57), // Yellow Green + Color(255, 138, 101), // Light Orange + Color(77, 208, 225), // Light Cyan + Color(186, 104, 200), // Light Purple + Color(174, 213, 129), // Soft Green + Color(100, 181, 246), // Soft Blue + Color(255, 183, 77), // Soft Orange + Color(128, 203, 196), // Soft Teal + Color(206, 147, 216), // Soft Purple + Color(220, 231, 117), // Soft Lime + Color(129, 212, 250), // Pastel Blue + Color(255, 171, 145), // Pastel Orange + Color(128, 222, 234), // Pastel Cyan + Color(179, 157, 219), // Pastel Purple + Color(165, 214, 167), // Pastel Green + Color(239, 154, 154) // Pastel Red + ) + + companion object { + private const val DEBOUNCE_MS = 300L + fun getInstance(project: Project): BlameEditorManager = + project.getService(BlameEditorManager::class.java) + } + + /** + * Initialize the manager: listen for editor events and decorate open editors. + */ + fun initialize() { + val connection = project.messageBus.connect(this) + + // Listen for file open/close events + connection.subscribe(FileEditorManagerListener.FILE_EDITOR_MANAGER, object : FileEditorManagerListener { + override fun fileOpened(source: FileEditorManager, file: VirtualFile) { + val editor = source.selectedTextEditor ?: return + attachToEditor(editor) + } + + override fun fileClosed(source: FileEditorManager, file: VirtualFile) { + // Clean up editors for this file + editorStates.keys.filter { editor -> + FileDocumentManager.getInstance().getFile(editor.document) == file + }.forEach { detachFromEditor(it) } + } + }) + + // Attach to already-open editors + FileEditorManager.getInstance(project).allEditors.filterIsInstance().forEach { textEditor -> + attachToEditor(textEditor.editor) + } + } + + private fun attachToEditor(editor: Editor) { + if (editorStates.containsKey(editor)) return + + val state = EditorBlameState(editor) + editorStates[editor] = state + + // Listen for caret changes (for LINE mode) + editor.caretModel.addCaretListener(object : CaretListener { + override fun caretPositionChanged(event: CaretEvent) { + if (blameMode == BlameMode.LINE) { + ApplicationManager.getApplication().invokeLater { + updateLineMode(editor, state) + } + } + } + }, state) + + // Listen for document changes (re-fetch blame after edits) + editor.document.addDocumentListener(object : DocumentListener { + override fun documentChanged(event: DocumentEvent) { + scheduleBlameRefresh(editor, state) + } + }, state) + + // Initial blame fetch + scheduleBlameRefresh(editor, state) + } + + private fun detachFromEditor(editor: Editor) { + val state = editorStates.remove(editor) ?: return + // Dispose on EDT to safely clear editor decorations. + // Disposer.dispose calls state.dispose() -> clearDecorations(). + ApplicationManager.getApplication().invokeLater { + Disposer.dispose(state) + } + } + + private fun scheduleBlameRefresh(editor: Editor, state: EditorBlameState) { + state.pendingRefresh?.cancel(false) + state.pendingRefresh = scheduler.schedule({ + fetchAndDecorate(editor, state) + }, DEBOUNCE_MS, TimeUnit.MILLISECONDS) + } + + private fun fetchAndDecorate(editor: Editor, state: EditorBlameState) { + if (blameMode == BlameMode.OFF) { + ApplicationManager.getApplication().invokeLater { + state.clearDecorations() + } + return + } + + // Read document content on the read thread + val (file, content) = ReadAction.compute, RuntimeException> { + val f = FileDocumentManager.getInstance().getFile(editor.document) + val c = editor.document.text + f to c + } + if (file == null) return + + val blameService = BlameService.getInstance(project) + val result = blameService.getBlame(file, content) + + ApplicationManager.getApplication().invokeLater { + if (editor.isDisposed) return@invokeLater + state.blameResult = result + state.clearDecorations() + if (result != null) { + when (blameMode) { + BlameMode.ALL -> decorateAllMode(editor, state, result) + BlameMode.LINE -> updateLineMode(editor, state) + BlameMode.OFF -> {} + } + } + } + } + + /** + * ALL mode: Show gutter stripes for all AI-authored lines, each prompt in a different color. + */ + private fun decorateAllMode(editor: Editor, state: EditorBlameState, result: BlameResult) { + val markupModel = editor.markupModel + + // Group lines by prompt hash for consistent coloring + val linesByPrompt = result.lineAuthors.entries.groupBy { it.value.promptHash } + + for ((promptHash, entries) in linesByPrompt) { + val color = colorForPromptHash(promptHash) + val promptRecord = entries.firstOrNull()?.value?.promptRecord + + for ((lineNum, _) in entries) { + val docLine = lineNum - 1 // blame uses 1-indexed lines + if (docLine < 0 || docLine >= editor.document.lineCount) continue + + // Gutter stripe + val highlighter = markupModel.addLineHighlighter( + docLine, + HighlighterLayer.SELECTION - 1, + null + ) + highlighter.gutterIconRenderer = BlameGutterIcon(color, promptHash) + state.highlighters.add(highlighter) + } + + // Add inline after-text for last line of each contiguous range + if (promptRecord != null) { + val sortedLines = entries.map { it.key }.sorted() + val lastLine = sortedLines.last() - 1 + if (lastLine >= 0 && lastLine < editor.document.lineCount) { + addInlineAnnotation(editor, state, lastLine, promptRecord, color) + } + } + } + } + + /** + * LINE mode: Highlight all lines belonging to the same prompt as the current cursor line. + */ + private fun updateLineMode(editor: Editor, state: EditorBlameState) { + state.clearDecorations() + val result = state.blameResult ?: return + + val currentLine = editor.caretModel.logicalPosition.line + 1 // 1-indexed + val info = result.lineAuthors[currentLine] + + if (info != null) { + // Highlight all lines from the same prompt + val color = colorForPromptHash(info.promptHash) + val samePromptLines = result.lineAuthors.filter { it.value.promptHash == info.promptHash } + + val markupModel = editor.markupModel + for ((lineNum, _) in samePromptLines) { + val docLine = lineNum - 1 + if (docLine < 0 || docLine >= editor.document.lineCount) continue + + val highlighter = markupModel.addLineHighlighter( + docLine, + HighlighterLayer.SELECTION - 1, + null + ) + highlighter.gutterIconRenderer = BlameGutterIcon(color, info.promptHash) + state.highlighters.add(highlighter) + } + + // Add inline annotation on current line + addInlineAnnotation(editor, state, currentLine - 1, info.promptRecord, color) + } + + // Notify status bar to update + BlameStatusBarWidgetFactory.update(project) + } + + private fun addInlineAnnotation( + editor: Editor, + state: EditorBlameState, + docLine: Int, + promptRecord: PromptRecord, + color: Color + ) { + val modelName = ModelNameParser.extractModelName(promptRecord.agentId?.model) ?: "AI" + val toolName = promptRecord.agentId?.tool?.replaceFirstChar { it.uppercase() } ?: "" + val displayText = if (toolName.isNotEmpty()) " $modelName via $toolName" else " $modelName" + + val renderer = InlineBlameRenderer(displayText, color, promptRecord) + val offset = editor.document.getLineEndOffset(docLine) + val inlay = editor.inlayModel.addAfterLineEndElement(offset, false, renderer) + if (inlay != null) { + state.inlays.add(inlay) + } + } + + /** + * Get the blame info for the current cursor line (used by status bar widget). + */ + fun getCurrentLineInfo(): Pair { + val editor = FileEditorManager.getInstance(project).selectedTextEditor ?: return null to blameMode + val state = editorStates[editor] ?: return null to blameMode + val result = state.blameResult ?: return null to blameMode + val currentLine = editor.caretModel.logicalPosition.line + 1 + return result.lineAuthors[currentLine] to blameMode + } + + /** + * Cycle blame mode: OFF -> LINE -> ALL -> OFF + */ + fun toggleBlameMode() { + blameMode = when (blameMode) { + BlameMode.OFF -> BlameMode.LINE + BlameMode.LINE -> BlameMode.ALL + BlameMode.ALL -> BlameMode.OFF + } + + // Refresh all editors + for ((editor, state) in editorStates) { + if (!editor.isDisposed) { + scheduleBlameRefresh(editor, state) + } + } + + BlameStatusBarWidgetFactory.update(project) + } + + private fun colorForPromptHash(hash: String): Color { + val index = (hash.hashCode() and Int.MAX_VALUE) % promptColors.size + return promptColors[index] + } + + override fun dispose() { + scheduler.shutdownNow() + for ((_, state) in editorStates) { + state.clearDecorations() + Disposer.dispose(state) + } + editorStates.clear() + } + + /** + * Tracks per-editor decoration state. + */ + private class EditorBlameState(val editor: Editor) : Disposable { + @Volatile + var pendingRefresh: ScheduledFuture<*>? = null + var blameResult: BlameResult? = null + val highlighters = mutableListOf() + val inlays = mutableListOf>() + + fun clearDecorations() { + for (h in highlighters) { + if (h.isValid) editor.markupModel.removeHighlighter(h) + } + highlighters.clear() + + for (inlay in inlays) { + Disposer.dispose(inlay) + } + inlays.clear() + } + + override fun dispose() { + pendingRefresh?.cancel(false) + clearDecorations() + } + } +} + +/** + * Renders a small colored stripe in the gutter, indicating AI authorship. + */ +private class BlameGutterIcon( + private val color: Color, + private val promptHash: String +) : com.intellij.openapi.editor.markup.GutterIconRenderer() { + + override fun getIcon(): javax.swing.Icon { + return object : javax.swing.Icon { + override fun paintIcon(c: java.awt.Component?, g: java.awt.Graphics, x: Int, y: Int) { + val g2 = g as? Graphics2D ?: return + g2.color = color + g2.fillRect(x, y, iconWidth, iconHeight) + } + + override fun getIconWidth(): Int = 4 + override fun getIconHeight(): Int = 16 + } + } + + override fun getAlignment(): Alignment = Alignment.LEFT + + override fun getTooltipText(): String = "AI-authored code" + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is BlameGutterIcon) return false + return promptHash == other.promptHash && color == other.color + } + + override fun hashCode(): Int = promptHash.hashCode() * 31 + color.hashCode() +} + +/** + * Renders inline text after the line end showing the AI model name. + */ +private class InlineBlameRenderer( + private val text: String, + private val color: Color, + private val promptRecord: PromptRecord +) : EditorCustomElementRenderer { + + override fun calcWidthInPixels(inlay: Inlay<*>): Int { + val editor = inlay.editor + val fontMetrics = editor.contentComponent.getFontMetrics( + editor.colorsScheme.getFont(EditorFontType.ITALIC) + ) + return fontMetrics.stringWidth(text) + 8 + } + + override fun paint(inlay: Inlay<*>, g: Graphics, targetRegion: Rectangle, textAttributes: TextAttributes) { + val g2 = g as? Graphics2D ?: return + val editor = inlay.editor + val font = editor.colorsScheme.getFont(EditorFontType.ITALIC) + g2.font = font + + // Use the prompt color with some transparency + g2.color = Color(color.red, color.green, color.blue, 180) + + val fontMetrics = g2.fontMetrics + val y = targetRegion.y + targetRegion.height - fontMetrics.descent + g2.drawString(text, targetRegion.x + 4, y) + } + + override fun toString(): String = "InlineBlame($text)" +} diff --git a/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameModels.kt b/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameModels.kt new file mode 100644 index 000000000..b59db45fa --- /dev/null +++ b/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameModels.kt @@ -0,0 +1,64 @@ +package org.jetbrains.plugins.template.blame + +import com.google.gson.annotations.SerializedName + +/** + * JSON output structure from `git-ai blame --json`. + */ +data class BlameJsonOutput( + val lines: Map = emptyMap(), + val prompts: Map = emptyMap(), + val metadata: BlameMetadata? = null +) + +data class BlameMetadata( + @SerializedName("is_logged_in") val isLoggedIn: Boolean = false, + @SerializedName("current_user") val currentUser: String? = null +) + +data class PromptRecord( + @SerializedName("agent_id") val agentId: AgentId? = null, + @SerializedName("human_author") val humanAuthor: String? = null, + val messages: List? = null, + @SerializedName("total_additions") val totalAdditions: Int? = null, + @SerializedName("total_deletions") val totalDeletions: Int? = null, + @SerializedName("accepted_lines") val acceptedLines: Int? = null, + @SerializedName("other_files") val otherFiles: List? = null, + val commits: List? = null, + @SerializedName("messages_url") val messagesUrl: String? = null +) + +data class AgentId( + val tool: String = "", + val id: String = "", + val model: String = "" +) + +data class PromptMessage( + val type: String = "", + val text: String? = null, + val timestamp: String? = null +) + +/** + * Per-line blame information after expanding line ranges. + */ +data class LineBlameInfo( + val promptHash: String, + val promptRecord: PromptRecord +) + +/** + * Complete blame result for a file. + */ +data class BlameResult( + val lineAuthors: Map, + val prompts: Map, + val metadata: BlameMetadata?, + val timestamp: Long, + val totalLines: Int +) + +enum class BlameMode { + OFF, LINE, ALL +} diff --git a/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameService.kt b/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameService.kt new file mode 100644 index 000000000..1384b27b9 --- /dev/null +++ b/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameService.kt @@ -0,0 +1,171 @@ +package org.jetbrains.plugins.template.blame + +import com.google.gson.Gson +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import org.jetbrains.plugins.template.services.GitAiService +import java.io.File +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit + +/** + * Project-level service that executes `git-ai blame --json` and caches results. + */ +@Service(Service.Level.PROJECT) +class BlameService(private val project: Project) { + + private val logger = thisLogger() + private val gson = Gson() + + private val cache = ConcurrentHashMap() + + companion object { + private const val TIMEOUT_MS = 30_000L + private const val MAX_CACHE_ENTRIES = 20 + + fun getInstance(project: Project): BlameService = + project.getService(BlameService::class.java) + } + + /** + * Get blame for a file. Returns cached result if available, otherwise runs the CLI. + */ + fun getBlame(file: VirtualFile, content: String): BlameResult? { + val filePath = file.path + val cacheKey = "$filePath:${content.hashCode()}" + + cache[cacheKey]?.let { return it } + + val result = runBlame(filePath, content) ?: return null + + // Evict oldest entries if cache is full + if (cache.size >= MAX_CACHE_ENTRIES) { + val oldest = cache.entries.minByOrNull { it.value.timestamp } + oldest?.let { cache.remove(it.key) } + } + cache[cacheKey] = result + return result + } + + /** + * Invalidate cache for a specific file. + */ + fun invalidate(filePath: String) { + cache.keys.removeAll { it.startsWith(filePath) } + } + + /** + * Clear entire cache. + */ + fun clearCache() { + cache.clear() + } + + private fun runBlame(filePath: String, content: String): BlameResult? { + val gitAiService = GitAiService.getInstance() + if (!gitAiService.checkAvailable()) { + logger.info("git-ai not available, skipping blame") + return null + } + + val gitAiPath = gitAiService.resolvedPath ?: return null + + // Find git repo root for this file + val repoRoot = findGitRoot(filePath) ?: return null + + return try { + val command = listOf(gitAiPath, "blame", "--json", "--contents", "-", filePath) + val process = ProcessBuilder(command) + .directory(File(repoRoot)) + .redirectErrorStream(false) + .start() + + // Write current file content to stdin + process.outputStream.bufferedWriter().use { writer -> + writer.write(content) + } + + // Read stdout/stderr concurrently to avoid pipe buffer deadlock. + // If blame output exceeds the OS pipe buffer (~64KB), the child + // blocks on write while the parent blocks on waitFor. + val stdoutFuture = CompletableFuture.supplyAsync { + process.inputStream.bufferedReader().readText().trim() + } + val stderrFuture = CompletableFuture.supplyAsync { + process.errorStream.bufferedReader().readText().trim() + } + + val completed = process.waitFor(TIMEOUT_MS, TimeUnit.MILLISECONDS) + if (!completed) { + process.destroyForcibly() + stdoutFuture.cancel(true) + stderrFuture.cancel(true) + logger.warn("git-ai blame timed out for $filePath") + return null + } + + val stdout = stdoutFuture.get(TIMEOUT_MS, TimeUnit.MILLISECONDS) + val stderr = stderrFuture.get(TIMEOUT_MS, TimeUnit.MILLISECONDS) + + if (process.exitValue() != 0) { + logger.info("git-ai blame failed for $filePath: $stderr") + return null + } + + if (stdout.isEmpty()) return null + + parseBlameOutput(stdout, content.lines().size) + } catch (e: Exception) { + logger.warn("Failed to run git-ai blame for $filePath: ${e.message}") + null + } + } + + private fun parseBlameOutput(json: String, totalLines: Int): BlameResult? { + return try { + val output = gson.fromJson(json, BlameJsonOutput::class.java) ?: return null + + // Expand line ranges ("11-114" -> individual lines 11..114) + val lineAuthors = mutableMapOf() + for ((rangeStr, promptHash) in output.lines) { + val prompt = output.prompts[promptHash] ?: continue + val info = LineBlameInfo(promptHash, prompt) + + if (rangeStr.contains("-")) { + val parts = rangeStr.split("-") + val start = parts[0].toIntOrNull() ?: continue + val end = parts[1].toIntOrNull() ?: continue + for (line in start..end) { + lineAuthors[line] = info + } + } else { + val line = rangeStr.toIntOrNull() ?: continue + lineAuthors[line] = info + } + } + + BlameResult( + lineAuthors = lineAuthors, + prompts = output.prompts, + metadata = output.metadata, + timestamp = System.currentTimeMillis(), + totalLines = totalLines + ) + } catch (e: Exception) { + logger.warn("Failed to parse blame JSON: ${e.message}") + null + } + } + + private fun findGitRoot(filePath: String): String? { + var dir = File(filePath).parentFile + while (dir != null) { + if (File(dir, ".git").exists()) return dir.absolutePath + dir = dir.parentFile + } + return null + } +} diff --git a/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameStartupActivity.kt b/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameStartupActivity.kt new file mode 100644 index 000000000..b48e0f102 --- /dev/null +++ b/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameStartupActivity.kt @@ -0,0 +1,13 @@ +package org.jetbrains.plugins.template.blame + +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.ProjectActivity + +/** + * Initializes the blame editor manager when a project is opened. + */ +class BlameStartupActivity : ProjectActivity { + override suspend fun execute(project: Project) { + BlameEditorManager.getInstance(project).initialize() + } +} diff --git a/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameStatusBarWidgetFactory.kt b/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameStatusBarWidgetFactory.kt new file mode 100644 index 000000000..c9987baf1 --- /dev/null +++ b/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameStatusBarWidgetFactory.kt @@ -0,0 +1,86 @@ +package org.jetbrains.plugins.template.blame + +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.StatusBar +import com.intellij.openapi.wm.StatusBarWidget +import com.intellij.openapi.wm.StatusBarWidgetFactory +import com.intellij.openapi.wm.WindowManager +import java.awt.Component +import java.awt.event.MouseEvent + +/** + * Status bar widget showing AI/human attribution for the current cursor line. + * Clicking toggles blame mode. + */ +class BlameStatusBarWidgetFactory : StatusBarWidgetFactory { + + companion object { + const val WIDGET_ID = "GitAiBlame" + + fun update(project: Project) { + val statusBar = WindowManager.getInstance().getStatusBar(project) ?: return + statusBar.updateWidget(WIDGET_ID) + } + } + + override fun getId(): String = WIDGET_ID + + override fun getDisplayName(): String = "Git AI Blame" + + override fun isAvailable(project: Project): Boolean = true + + override fun createWidget(project: Project): StatusBarWidget = BlameStatusBarWidget(project) +} + +private class BlameStatusBarWidget(private val project: Project) : StatusBarWidget, StatusBarWidget.TextPresentation { + + private var statusBar: StatusBar? = null + + override fun ID(): String = BlameStatusBarWidgetFactory.WIDGET_ID + + override fun install(statusBar: StatusBar) { + this.statusBar = statusBar + } + + override fun getPresentation(): StatusBarWidget.WidgetPresentation = this + + override fun getText(): String { + val manager = BlameEditorManager.getInstance(project) + val (info, mode) = manager.getCurrentLineInfo() + + if (mode == BlameMode.OFF) return "AI Blame: Off" + + return if (info != null) { + val modelName = ModelNameParser.extractModelName(info.promptRecord.agentId?.model) ?: "AI" + "\uD83E\uDD16 $modelName" // robot emoji + } else { + "\uD83E\uDDD1\u200D\uD83D\uDCBB Human" // person with laptop emoji + } + } + + override fun getTooltipText(): String { + val manager = BlameEditorManager.getInstance(project) + val (info, mode) = manager.getCurrentLineInfo() + + return when { + mode == BlameMode.OFF -> "Git AI Blame is off. Click to enable." + info != null -> { + val tool = info.promptRecord.agentId?.tool ?: "unknown" + val model = info.promptRecord.agentId?.model ?: "unknown" + val human = info.promptRecord.humanAuthor ?: "Unknown" + "AI-authored by $human using $model via $tool\nClick to change blame mode" + } + else -> "Human-authored line. Click to change blame mode." + } + } + + override fun getAlignment(): Float = Component.RIGHT_ALIGNMENT + + override fun getClickConsumer(): com.intellij.util.Consumer = com.intellij.util.Consumer { + BlameEditorManager.getInstance(project).toggleBlameMode() + } + + override fun dispose() { + statusBar = null + } +} diff --git a/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameToggleAction.kt b/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameToggleAction.kt new file mode 100644 index 000000000..a883ff522 --- /dev/null +++ b/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameToggleAction.kt @@ -0,0 +1,26 @@ +package org.jetbrains.plugins.template.blame + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent + +/** + * Action to toggle git-ai blame mode (Off -> Line -> All -> Off). + * Registered with keyboard shortcut Ctrl+Shift+A (Cmd+Shift+A on macOS). + */ +class BlameToggleAction : AnAction("Toggle Git AI Blame") { + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val manager = BlameEditorManager.getInstance(project) + manager.toggleBlameMode() + } + + override fun update(e: AnActionEvent) { + e.presentation.isEnabledAndVisible = e.project != null + val project = e.project + if (project != null) { + val mode = BlameEditorManager.getInstance(project).blameMode + e.presentation.text = "Toggle Git AI Blame (current: ${mode.name.lowercase()})" + } + } +} diff --git a/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/ModelNameParser.kt b/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/ModelNameParser.kt new file mode 100644 index 000000000..2d2460924 --- /dev/null +++ b/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/ModelNameParser.kt @@ -0,0 +1,98 @@ +package org.jetbrains.plugins.template.blame + +/** + * Parses raw model strings into human-readable display names. + * Ported from the VS Code extension's extractModelName logic. + */ +object ModelNameParser { + + fun extractModelName(modelString: String?): String? { + if (modelString.isNullOrBlank()) return null + + val trimmed = modelString.trim().lowercase() + + if (trimmed == "default" || trimmed == "auto") return "Cursor" + if (trimmed == "unknown") return null + + val parts = modelString.lowercase().split("-").filter { p -> + // Skip date suffixes (8+ digits) + if (p.matches(Regex("^\\d{8,}$"))) return@filter false + // Skip "thinking" variant + if (p == "thinking") return@filter false + true + } + + if (parts.isEmpty()) return modelString.trim() + + // GPT models: gpt-4o -> GPT 4o, gpt-4o-mini -> GPT 4o Mini + if (parts[0] == "gpt") { + val rest = parts.drop(1) + if (rest.isEmpty()) return "GPT" + val variant = rest.mapIndexed { i, p -> + if (i == 0) p else p.replaceFirstChar { it.uppercase() } + }.joinToString(" ") + return "GPT $variant" + } + + // Claude models: claude-3-5-sonnet -> Sonnet 3.5, claude-opus-4 -> Opus 4 + if (parts[0] == "claude") { + val rest = parts.drop(1) + val modelNames = listOf("opus", "sonnet", "haiku") + var modelName = "" + val versions = mutableListOf() + + for (p in rest) { + if (p in modelNames) { + modelName = p.replaceFirstChar { it.uppercase() } + } else if (p.matches(Regex("^[\\d.]+$"))) { + versions.add(p) + } + } + + if (modelName.isNotEmpty()) { + val versionStr = versions.joinToString(".") + return if (versionStr.isNotEmpty()) "$modelName $versionStr" else modelName + } + return "Claude" + } + + // Gemini models: gemini-1.5-flash -> Gemini Flash 1.5 + if (parts[0] == "gemini") { + val rest = parts.drop(1) + val variantNames = listOf("pro", "flash", "ultra", "nano") + var variantName = "" + var version = "" + + for (p in rest) { + if (p in variantNames) { + variantName = p.replaceFirstChar { it.uppercase() } + } else if (p.matches(Regex("^[\\d.]+$"))) { + version = p + } + } + + return when { + variantName.isNotEmpty() && version.isNotEmpty() -> "Gemini $variantName $version" + variantName.isNotEmpty() -> "Gemini $variantName" + version.isNotEmpty() -> "Gemini $version" + else -> "Gemini" + } + } + + // o1, o3, o4-mini models: o1 -> O1, o3-mini -> O3 Mini + if (parts[0].matches(Regex("^o\\d"))) { + return parts.joinToString(" ") { p -> + if (p.matches(Regex("^o\\d"))) p.uppercase() + else p.replaceFirstChar { it.uppercase() } + } + } + + // Codex models: codex-5.2 -> Codex 5.2 + if (parts[0] == "codex") { + val version = parts.find { it.matches(Regex("^[\\d.]+$")) } + return if (version != null) "Codex $version" else "Codex" + } + + return modelString.trim() + } +} diff --git a/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/services/GitAiService.kt b/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/services/GitAiService.kt index 8bf277b9b..0758be07f 100644 --- a/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/services/GitAiService.kt +++ b/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/services/GitAiService.kt @@ -33,6 +33,9 @@ class GitAiService { @Volatile private var resolvedGitAiPath: String? = null + /** Public accessor for the resolved binary path, used by BlameService. */ + val resolvedPath: String? get() = resolvedGitAiPath + // Track which locations were searched (for error reporting) private var lastSearchedPaths: List = emptyList() diff --git a/agent-support/intellij/src/main/resources/META-INF/plugin.xml b/agent-support/intellij/src/main/resources/META-INF/plugin.xml index 9c25a765e..7991a1bcf 100644 --- a/agent-support/intellij/src/main/resources/META-INF/plugin.xml +++ b/agent-support/intellij/src/main/resources/META-INF/plugin.xml @@ -12,6 +12,25 @@ serviceImplementation="org.jetbrains.plugins.template.services.DocumentChangeTrackerService"/> + + + + + + + + + + + + From e94960216184b763d045f1dd4edc65314af9c42b Mon Sep 17 00:00:00 2001 From: Kc Balusu Date: Thu, 19 Mar 2026 12:00:38 -0400 Subject: [PATCH 2/3] Fix pipe deadlock and wrong editor in blame plugin - BlameService: start stdout/stderr readers before stdin write and make stdin write async to prevent pipe buffer deadlock on files >64KB - BlameEditorManager: use getEditors(file) instead of selectedTextEditor in fileOpened callback to resolve correct editor in split-editor scenarios - Add consolidated pre-commit checks section to CLAUDE.md covering process I/O patterns, IntelliJ API misuses, Rust formatting, and lint --- Claude.md | 62 +++++++++++++++++++ .../template/blame/BlameEditorManager.kt | 5 +- .../plugins/template/blame/BlameService.kt | 19 +++--- 3 files changed, 76 insertions(+), 10 deletions(-) diff --git a/Claude.md b/Claude.md index 5fa812e62..423cc4278 100644 --- a/Claude.md +++ b/Claude.md @@ -1,5 +1,67 @@ - In plan mode, always use the /ask skill so you can read the code and the original prompts that generated it. Intent will help you write a better plan +## Pre-Commit Checks (MANDATORY) + +Run all applicable checks **before every commit**. Do not commit if any check fails. + +### Rust (`src/`) +- Run `cargo fmt -- --check` — CI enforces this with `-D warnings`. Run `cargo fmt` to auto-fix. + +### IntelliJ plugin (`agent-support/intellij/`) + +**Run the lint script** on the changed directories: +```bash +bash ~/.claude/projects/-Users-kcbalusu-Desktop-Project-git-ai/scripts/intellij-lint.sh +``` + +**Manually verify these patterns** (not all are caught by the lint script): + +#### Process I/O — Pipe deadlock prevention +When spawning external processes that use stdin/stdout/stderr: +1. **Never write stdin synchronously before starting stdout/stderr readers.** If content exceeds the OS pipe buffer (~64KB), the parent blocks on stdin write while the child blocks on stdout write — deadlock. Start all stream readers (via `CompletableFuture.supplyAsync`) **before** writing to stdin, and write stdin asynchronously too. +2. **Never read stdout/stderr after `waitFor()`.** Same deadlock from the other direction. +3. Correct flow: `start process → start stdout reader → start stderr reader → write stdin async → waitFor → collect results`. + +#### IntelliJ Platform API — Common misuses +1. **`fileOpened` callback: never use `selectedTextEditor`** — it returns the focused editor, not the editor for the `file` parameter. Use `source.getEditors(file).filterIsInstance()` instead. +2. **Shared state across editors:** debounce timers, alarms, or schedulers must be **per-editor**, not shared. A shared `cancelAllRequests()` or `cancel()` cancels pending work for all editors. +3. **`Disposer.dispose()` for editor state** must be called inside `invokeLater` to avoid threading races with EDT. +4. Verify correct API signatures — missing abstract method overrides, deprecated API usage, wrong method parameter types. + +### All code +- Check for compilation errors, API compatibility issues, thread safety, concurrency bugs +- Review for race conditions and shared state issues + +## PR Workflow (MANDATORY) + +### 1. Always create PRs in draft mode +Use `gh pr create --draft` for every PR. Never create a non-draft PR. + +### 2. Thorough code review before marking ready +After creating the draft PR, spin off a sub-agent to do an **extremely thorough review** of the entire changeset: +- Run all pre-commit checks above +- Fix any issues found, amend the commit, and push + +**Loop this review-fix cycle until zero issues are found.** + +### 3. Monitor CI after push (every 5 minutes) +After pushing to the draft PR, poll CI status every 5 minutes using: +```bash +GH_CONFIG_DIR=/tmp/gh-cfg gh run list --repo git-ai-project/git-ai --branch --limit 1 --json status,conclusion +``` +When a CI run completes: +- If **failed**: fetch the logs, identify the error, fix it, push, and continue monitoring + ```bash + GH_CONFIG_DIR=/tmp/gh-cfg gh run view --repo git-ai-project/git-ai --log-failed + ``` +- If **passed**: report success to the user. Do NOT mark the PR as ready — the user will do that. +- Distinguish between **our failures** (compilation, verification) and **pre-existing/unrelated failures** (E2E opencode fish_add_path, benchmark margin noise) + +**ALL review, fixing, and CI monitoring MUST happen while the PR is in draft mode.** + +### 4. GH CLI access +Use `GH_CONFIG_DIR=/tmp/gh-cfg` prefix for all `gh` commands to avoid sandbox config permission issues. The `GH_TOKEN` environment variable provides authentication. + ## Task Master AI Instructions **Import Task Master's development workflow commands and guidelines, treat as if import is in the main CLAUDE.md file.** @./.taskmaster/CLAUDE.md diff --git a/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameEditorManager.kt b/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameEditorManager.kt index c767de5ca..9b850d492 100644 --- a/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameEditorManager.kt +++ b/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameEditorManager.kt @@ -110,8 +110,9 @@ class BlameEditorManager(private val project: Project) : Disposable { // Listen for file open/close events connection.subscribe(FileEditorManagerListener.FILE_EDITOR_MANAGER, object : FileEditorManagerListener { override fun fileOpened(source: FileEditorManager, file: VirtualFile) { - val editor = source.selectedTextEditor ?: return - attachToEditor(editor) + source.getEditors(file).filterIsInstance().forEach { textEditor -> + attachToEditor(textEditor.editor) + } } override fun fileClosed(source: FileEditorManager, file: VirtualFile) { diff --git a/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameService.kt b/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameService.kt index 1384b27b9..0993e58e4 100644 --- a/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameService.kt +++ b/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameService.kt @@ -83,14 +83,9 @@ class BlameService(private val project: Project) { .redirectErrorStream(false) .start() - // Write current file content to stdin - process.outputStream.bufferedWriter().use { writer -> - writer.write(content) - } - - // Read stdout/stderr concurrently to avoid pipe buffer deadlock. - // If blame output exceeds the OS pipe buffer (~64KB), the child - // blocks on write while the parent blocks on waitFor. + // Start stdout/stderr readers BEFORE writing stdin to avoid pipe + // buffer deadlock. If content or output exceeds ~64KB, the child + // blocks on stdout write while the parent blocks on stdin write. val stdoutFuture = CompletableFuture.supplyAsync { process.inputStream.bufferedReader().readText().trim() } @@ -98,9 +93,17 @@ class BlameService(private val project: Project) { process.errorStream.bufferedReader().readText().trim() } + // Write stdin asynchronously so all three pipes are drained concurrently + val stdinFuture = CompletableFuture.runAsync { + process.outputStream.bufferedWriter().use { writer -> + writer.write(content) + } + } + val completed = process.waitFor(TIMEOUT_MS, TimeUnit.MILLISECONDS) if (!completed) { process.destroyForcibly() + stdinFuture.cancel(true) stdoutFuture.cancel(true) stderrFuture.cancel(true) logger.warn("git-ai blame timed out for $filePath") From abea2538b75a256f7941c5112130343e08f9e4eb Mon Sep 17 00:00:00 2001 From: Kc Balusu Date: Thu, 19 Mar 2026 12:19:39 -0400 Subject: [PATCH 3/3] Fix dispose() threading: wrap Disposer.dispose in invokeLater BlameEditorManager.dispose() was calling Disposer.dispose(state) and clearDecorations() directly without invokeLater, causing EDT threading violations when the service is disposed from a background thread during project closing. Now matches the pattern already used in detachFromEditor(). --- .../plugins/template/blame/BlameEditorManager.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameEditorManager.kt b/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameEditorManager.kt index 9b850d492..4a5a6238a 100644 --- a/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameEditorManager.kt +++ b/agent-support/intellij/src/main/kotlin/org/jetbrains/plugins/template/blame/BlameEditorManager.kt @@ -338,11 +338,14 @@ class BlameEditorManager(private val project: Project) : Disposable { override fun dispose() { scheduler.shutdownNow() - for ((_, state) in editorStates) { - state.clearDecorations() - Disposer.dispose(state) - } + val states = editorStates.values.toList() editorStates.clear() + // Dispose on EDT to safely clear editor decorations (markup/inlay access requires EDT). + ApplicationManager.getApplication().invokeLater { + for (state in states) { + Disposer.dispose(state) + } + } } /**