diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/SubagentTools.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/SubagentTools.kt deleted file mode 100644 index 20578b3b..00000000 --- a/src/main/kotlin/ee/carlrobert/codegpt/agent/SubagentTools.kt +++ /dev/null @@ -1,48 +0,0 @@ -package ee.carlrobert.codegpt.agent - -enum class SubagentTool(val id: String, val displayName: String, val isWrite: Boolean) { - READ("read", "Read", false), - TODO_WRITE("todowrite", "TodoWrite", false), - INTELLIJ_SEARCH("intellijsearch", "IntelliJSearch", false), - DIAGNOSTICS("diagnostics", "Diagnostics", false), - WEB_SEARCH("websearch", "WebSearch", false), - WEB_FETCH("webfetch", "WebFetch", false), - MCP("MCP", "MCP", false), - RESOLVE_LIBRARY_ID("resolvelibraryid", "ResolveLibraryId", false), - GET_LIBRARY_DOCS("getlibrarydocs", "GetLibraryDocs", false), - LOAD_SKILL("loadskill", "LoadSkill", false), - BASH_OUTPUT("bashoutput", "BashOutput", false), - KILL_SHELL("killshell", "KillShell", false), - GET_RUN_CONFIGURATIONS("getrunconfigurations", "GetRunConfigurations", false), - GET_RUN_CONFIGURATION_DETAILS("getrunconfigdetails", "GetRunConfigurationDetails", false), - EXECUTE_RUN_CONFIGURATION("executerunconfig", "ExecuteRunConfiguration", true), - GET_RUN_OUTPUT("getrunoutput", "GetRunOutput", false), - DEBUG_SESSION_CONTROL("debugsessioncontrol", "DebugSessionControl", true), - EDIT("edit", "Edit", true), - WRITE("write", "Write", true), - BASH("bash", "Bash", true), - EXIT("exit", "Exit", false); - - companion object { - val readOnly: List = entries.filterNot { it.isWrite } - val write: List = entries.filter { it.isWrite } - - fun parse(values: Collection): Set { - return values.mapNotNull { fromString(it) }.toSet() - } - - fun toStoredValues(tools: Collection): List { - val selected = tools.toSet() - return entries.filter { it in selected }.map { it.id } - } - - fun fromString(value: String): SubagentTool? { - val key = normalize(value) - return entries.firstOrNull { normalize(it.id) == key || normalize(it.displayName) == key } - } - - private fun normalize(value: String): String { - return value.lowercase().filter { it.isLetterOrDigit() } - } - } -} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/AgentEventHandler.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/AgentEventHandler.kt index c9e98134..2f31d0da 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/AgentEventHandler.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/AgentEventHandler.kt @@ -19,11 +19,7 @@ import ee.carlrobert.codegpt.agent.* import ee.carlrobert.codegpt.agent.history.CheckpointRef import ee.carlrobert.codegpt.agent.rollback.RollbackService import ee.carlrobert.codegpt.agent.tools.* -import ee.carlrobert.codegpt.agent.tools.ide.BreakpointTool -import ee.carlrobert.codegpt.agent.tools.ide.ExecuteRunConfigurationTool -import ee.carlrobert.codegpt.agent.tools.ide.GetBreakpointsTool -import ee.carlrobert.codegpt.agent.tools.ide.GetDebugSessionsTool -import ee.carlrobert.codegpt.agent.tools.ide.GetRunOutputTool +import ee.carlrobert.codegpt.agent.tools.ide.* import ee.carlrobert.codegpt.completions.ToolApprovalMode import ee.carlrobert.codegpt.settings.agents.SubagentRuntimeResolver import ee.carlrobert.codegpt.settings.service.ServiceType @@ -31,6 +27,8 @@ import ee.carlrobert.codegpt.settings.service.codegpt.CodeGPTApiException import ee.carlrobert.codegpt.toolwindow.agent.ui.* import ee.carlrobert.codegpt.toolwindow.agent.ui.approval.* import ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor.Badge +import ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor.FileChangeSnapshot +import ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor.ToolCallDiffPreview import ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor.ToolKind import ee.carlrobert.codegpt.toolwindow.agent.ui.renderer.* import ee.carlrobert.codegpt.toolwindow.chat.ui.ChatMessageResponseBody @@ -67,6 +65,7 @@ class AgentEventHandler( } private val mainToolCards = ConcurrentHashMap() + private val fileChangeSnapshots = ConcurrentHashMap() private val pendingToolOutput = ConcurrentHashMap>() private val scheduledToolOutputFlushes = Collections.newSetFromMap(ConcurrentHashMap()) @@ -90,9 +89,15 @@ class AgentEventHandler( @Volatile private var lastWriteArgs: WriteTool.Args? = null + @Volatile + private var lastWriteSnapshot: FileChangeSnapshot? = null + @Volatile private var lastEditArgs: EditTool.Args? = null + @Volatile + private var lastEditSnapshot: FileChangeSnapshot? = null + @Volatile private var currentRollbackRunId: String? = null @@ -133,11 +138,14 @@ class AgentEventHandler( fun resetForNewSubmission() { mainToolCards.clear() + fileChangeSnapshots.clear() pendingToolOutput.clear() scheduledToolOutputFlushes.clear() currentResponseBody = null lastWriteArgs = null + lastWriteSnapshot = null lastEditArgs = null + lastEditSnapshot = null currentApproval = null approvalQueue.clear() currentQuestion = null @@ -417,74 +425,78 @@ class AgentEventHandler( } override fun onToolStarting(id: String, toolName: String, args: Any?) { - val uiArgs = ToolSpecs.coerceArgsForUi(toolName, args) - if (toolName == "Tool" && uiArgs == null) { + if (toolName == "Tool" && args == null) { logger.debug("Deferring placeholder tool card session=$sessionId toolId=$id toolName=$toolName") return } - when (uiArgs) { + when (args) { is TodoWriteTool.Args -> { runInEdt { val inProgressTask = - uiArgs.todos.find { it.status == TodoWriteTool.TodoStatus.IN_PROGRESS } + args.todos.find { it.status == TodoWriteTool.TodoStatus.IN_PROGRESS } if (inProgressTask != null) { showLoading(inProgressTask.activeForm) } - todoListPanel.updateTodos(uiArgs.todos) + todoListPanel.updateTodos(args.todos) todoListPanel.isVisible = true - showMainToolCard(id, toolName, uiArgs) + showMainToolCard(id, toolName, args) } } is TaskTool.Args -> { runInEdt { val host = ensureRunViewForSubagent(id) - host.addEntry(createTaskEntry(id, null, uiArgs)) + host.addEntry(createTaskEntry(id, null, args)) host.refresh() requestUiRefresh() } } else -> { - when (uiArgs) { - is EditTool.Args -> { - trackEditOperation(uiArgs) + val fileChangeSnapshot = when (args) { + is EditTool.Args -> captureEditSnapshot(args).also { + fileChangeSnapshots[keyFor(id)] = it + lastEditSnapshot = it + trackEditOperation(args) } - is WriteTool.Args -> { - trackWriteOperation(uiArgs) + is WriteTool.Args -> captureWriteSnapshot(args).also { + fileChangeSnapshots[keyFor(id)] = it + lastWriteSnapshot = it + trackWriteOperation(args) } + + else -> null } - runInEdt { - showMainToolCard(id, toolName, uiArgs) + showMainToolCard(id, toolName, args, fileChangeSnapshot) } } } } override fun onToolCompleted(id: String?, toolName: String, result: Any?) { - val uiResult = ToolSpecs.coerceResultForUi(toolName, result) runInEdt { - if (id != null && (toolName == "Task" || uiResult is TaskTool.Result)) { + if (id != null && (toolName == "Task" || result is TaskTool.Result)) { val holder = runViewHolder ?: subagentViewHolders.values.firstOrNull { viewHolder -> viewHolder.getItems().any { entry -> entry.id == id } } - holder?.completeEntry(id, uiResult) + holder?.completeEntry(id, result) holder?.refresh() } else if (id != null && mainToolCards.containsKey(keyFor(id))) { - val success = uiResult !is ToolError && uiResult != null - mainToolCards[keyFor(id)]?.complete(success, uiResult) + val success = result !is ToolError && result != null + mainToolCards[keyFor(id)]?.complete(success, result) + fileChangeSnapshots.remove(keyFor(id)) mainToolCards[keyFor(id)]?.getDescriptor()?.let { descriptor -> if (descriptor.kind == ToolKind.OTHER || descriptor.titleMain.isBlank()) { logger.warn( - "Completed generic tool card session=$sessionId toolId=$id toolName=$toolName resultType=${uiResult?.javaClass?.name ?: "null"} title=${descriptor.titleMain}" + "Completed generic tool card session=$sessionId toolId=$id toolName=$toolName resultType=${result?.javaClass?.name ?: "null"} title=${descriptor.titleMain}" ) } } - val bgId = (uiResult as? BashTool.Result)?.bashId + val bgId = (result as? BashTool.Result)?.bashId if (bgId == null) { mainToolCards.remove(keyFor(id)) } else { @@ -543,12 +555,16 @@ class AgentEventHandler( is WriteTool.Args -> { lastWriteArgs = args - RunEntry.WriteEntry(cid, parentId, args, null) + val snapshot = captureWriteSnapshot(args) + lastWriteSnapshot = snapshot + RunEntry.WriteEntry(cid, parentId, args, null, snapshot) } is EditTool.Args -> { lastEditArgs = args - RunEntry.EditEntry(cid, parentId, args, null) + val snapshot = captureEditSnapshot(args) + lastEditSnapshot = snapshot + RunEntry.EditEntry(cid, parentId, args, null, snapshot) } is TaskTool.Args -> createTaskEntry(cid, parentId, args) @@ -611,11 +627,17 @@ class AgentEventHandler( } } - private fun showMainToolCard(id: String, toolName: String, uiArgs: Any?) { + private fun showMainToolCard( + id: String, + toolName: String, + uiArgs: Any?, + fileChangeSnapshot: FileChangeSnapshot? = null + ) { val key = keyFor(id) val existingCard = mainToolCards[key] if (existingCard == null) { - val card = ToolCallCard(project, toolName, uiArgs) + val card = + ToolCallCard(project, toolName, uiArgs, fileChangeSnapshot = fileChangeSnapshot) mainToolCards[key] = card currentResponseBody?.addToolStatusPanel(card) requestUiRefresh() @@ -838,14 +860,19 @@ class AgentEventHandler( val payload = request.payload ?: return val (path, before, after) = when (payload) { is EditPayload -> { - val currentContent = getFileContentWithFallback(payload.filePath) - val proposed = payload.proposedContent ?: applyStringReplacement( - currentContent, - payload.oldString, - payload.newString, - payload.replaceAll - ) - Triple(payload.filePath, currentContent, proposed) + val snapshot = lastEditSnapshot + if (snapshot != null) { + Triple(payload.filePath, snapshot.beforeText, snapshot.afterText) + } else { + val currentContent = getFileContentWithFallback(payload.filePath) + val proposed = payload.proposedContent ?: applyStringReplacement( + currentContent, + payload.oldString, + payload.newString, + payload.replaceAll + ) + Triple(payload.filePath, currentContent, proposed) + } } else -> return @@ -868,14 +895,15 @@ class AgentEventHandler( val nonDiffBadges = descriptor.secondaryBadges.filterNot { isDiffBadge(it) } descriptor.copy( secondaryBadges = nonDiffBadges + diffBadges, - summary = null + summary = null, + diffPreview = ToolCallDiffPreview(path, FileChangeSnapshot(before, after)) ) } } private fun isDiffBadge(badge: Badge): Boolean { val text = badge.text - return text.startsWith("[+") || text.startsWith("[-") || text.startsWith("[~") + return text.startsWith("+") || text.startsWith("-") || text.startsWith("~") } private fun maybeShowNextQuestion() { @@ -993,6 +1021,7 @@ class AgentEventHandler( serviceScope.dispose() agentApprovalManager.dispose() mainToolCards.clear() + fileChangeSnapshots.clear() synchronized(toolOutputLock) { pendingToolOutput.clear() scheduledToolOutputFlushes.clear() @@ -1089,4 +1118,26 @@ class AgentEventHandler( rollbackService.trackWrite(sessionId, normalizedPath) } } + + private fun captureEditSnapshot(args: EditTool.Args): FileChangeSnapshot { + val normalizedPath = args.filePath.replace("\\", "/") + val currentContent = getFileContentWithFallback(normalizedPath) + val proposedContent = applyStringReplacement( + currentContent, + args.oldString, + args.newString, + args.replaceAll + ) + return FileChangeSnapshot(currentContent, proposedContent) + } + + private fun captureWriteSnapshot(args: WriteTool.Args): FileChangeSnapshot { + val normalizedPath = args.filePath.replace("\\", "/") + val beforeContent = getFileContentWithFallback(normalizedPath) + return FileChangeSnapshot( + beforeText = beforeContent, + afterText = args.content, + isNewFile = beforeContent.isEmpty() && !java.io.File(normalizedPath).exists() + ) + } } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/AgentSessionTimelineHistoricalRollbackSupport.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/AgentSessionTimelineHistoricalRollbackSupport.kt index 30253336..757865b3 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/AgentSessionTimelineHistoricalRollbackSupport.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/AgentSessionTimelineHistoricalRollbackSupport.kt @@ -1,27 +1,18 @@ package ee.carlrobert.codegpt.toolwindow.agent import ai.koog.agents.snapshot.feature.AgentCheckpointData -import ai.koog.prompt.message.Message as PromptMessage import com.intellij.openapi.application.runInEdt -import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.application.runReadActionBlocking import com.intellij.openapi.application.runWriteAction import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.LocalFileSystem import ee.carlrobert.codegpt.agent.ToolName -import ee.carlrobert.codegpt.agent.ToolSpecs import ee.carlrobert.codegpt.agent.history.AgentCheckpointHistoryService -import ee.carlrobert.codegpt.agent.tools.EditTool -import ee.carlrobert.codegpt.agent.tools.ReadTool -import ee.carlrobert.codegpt.agent.tools.WriteTool import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.booleanOrNull -import kotlinx.serialization.json.jsonObject -import java.io.File import java.nio.file.Paths -import java.util.ArrayDeque +import java.util.* +import ai.koog.prompt.message.Message as PromptMessage internal class AgentSessionTimelineHistoricalRollbackSupport( private val project: Project, @@ -68,24 +59,45 @@ internal class AgentSessionTimelineHistoricalRollbackSupport( val tool = HistoricalRollbackCompatibility.resolveSupportedTool(call.tool) ?: return@forEachIndexed - if (!HistoricalRollbackCompatibility.isSuccessfulResult(tool, message.content, replayJson)) { + if (!HistoricalRollbackCompatibility.isSuccessfulResult( + tool, + message.content, + replayJson + ) + ) { return@forEachIndexed } when (tool) { ToolName.READ -> { - val args = decodeReadArgs(call.tool, call.content) ?: return@forEachIndexed - val filePath = normalizeToolFilePath(args.filePath) + val args = HistoricalRollbackCompatibility.decodeReadArgs( + replayJson = replayJson, + rawToolName = call.tool, + rawArgs = call.content + ) ?: return@forEachIndexed + val filePath = HistoricalRollbackCompatibility.normalizeToolFilePath( + project.basePath, + args.filePath + ) ?: return@forEachIndexed val content = - decodeReadToolResultContent(message.content) ?: return@forEachIndexed + HistoricalRollbackCompatibility.decodeReadToolResultContent( + message.content + ) ?: return@forEachIndexed latestKnownContentByFile[filePath] = content return@forEachIndexed } ToolName.EDIT -> { - val args = decodeEditArgs(call.tool, call.content) ?: return@forEachIndexed - val filePath = normalizeToolFilePath(args.filePath) + val args = HistoricalRollbackCompatibility.decodeEditArgs( + replayJson = replayJson, + rawToolName = call.tool, + rawArgs = call.content + ) ?: return@forEachIndexed + val filePath = HistoricalRollbackCompatibility.normalizeToolFilePath( + project.basePath, + args.filePath + ) ?: return@forEachIndexed val oldString = args.oldString val newString = args.newString @@ -115,8 +127,15 @@ internal class AgentSessionTimelineHistoricalRollbackSupport( } ToolName.WRITE -> { - val args = decodeWriteArgs(call.tool, call.content) ?: return@forEachIndexed - val filePath = normalizeToolFilePath(args.filePath) + val args = HistoricalRollbackCompatibility.decodeWriteArgs( + replayJson = replayJson, + rawToolName = call.tool, + rawArgs = call.content + ) ?: return@forEachIndexed + val filePath = HistoricalRollbackCompatibility.normalizeToolFilePath( + project.basePath, + args.filePath + ) ?: return@forEachIndexed val newContent = args.content val previousContent = latestKnownContentByFile[filePath] @@ -200,95 +219,10 @@ internal class AgentSessionTimelineHistoricalRollbackSupport( return errors } - private fun parseToolArgs(rawArgs: String): Map? { - return runCatching { replayJson.parseToJsonElement(rawArgs).jsonObject }.getOrNull() - } - - private fun decodeReadArgs(rawToolName: String, rawArgs: String): ReadTool.Args? { - val typed = ToolSpecs.decodeArgsOrNull(rawToolName, rawArgs) as? ReadTool.Args - if (typed != null) return typed - - val args = parseToolArgs(rawArgs) ?: return null - val filePath = stringValue(args["file_path"]) - ?: stringValue(args["path"]) - ?: stringValue(args["pathInProject"]) - ?: return null - return ReadTool.Args(filePath = filePath) - } - - private fun decodeEditArgs(rawToolName: String, rawArgs: String): EditTool.Args? { - val typed = ToolSpecs.decodeArgsOrNull(rawToolName, rawArgs) as? EditTool.Args - if (typed != null) return typed - - val args = parseToolArgs(rawArgs) ?: return null - val filePath = stringValue(args["file_path"]) ?: return null - val oldString = stringValue(args["old_string"]) ?: return null - val newString = stringValue(args["new_string"]) ?: return null - val shortDescription = stringValue(args["short_description"]) ?: "Recovered historical edit" - val replaceAll = booleanValue(args["replace_all"]) ?: false - - return EditTool.Args( - filePath = filePath, - oldString = oldString, - newString = newString, - shortDescription = shortDescription, - replaceAll = replaceAll - ) - } - - private fun decodeWriteArgs(rawToolName: String, rawArgs: String): WriteTool.Args? { - val typed = ToolSpecs.decodeArgsOrNull(rawToolName, rawArgs) as? WriteTool.Args - if (typed != null) return typed - - val args = parseToolArgs(rawArgs) ?: return null - val filePath = stringValue(args["file_path"]) ?: return null - val content = stringValue(args["content"]) ?: return null - return WriteTool.Args(filePath = filePath, content = content) - } - - private fun booleanValue(element: JsonElement?): Boolean? { - val primitive = element as? JsonPrimitive ?: return null - return if (primitive.isString) primitive.content.toBooleanStrictOrNull() else primitive.booleanOrNull - } - - private fun stringValue(element: JsonElement?): String? { - if (element == null) return null - val primitive = element as? JsonPrimitive - return if (primitive != null && primitive.isString) primitive.content else element.toString() - } - - private fun normalizeToolFilePath(rawPath: String?): String? { - val trimmed = rawPath?.trim()?.takeIf { it.isNotEmpty() } ?: return null - val normalized = trimmed.replace("\\", "/") - val file = File(normalized) - if (file.isAbsolute) { - return file.toPath().normalize().toString().replace("\\", "/") - } - - val basePath = project.basePath ?: return file.absolutePath.replace("\\", "/") - return Paths.get(basePath).resolve(normalized).normalize().toString().replace("\\", "/") - } - - private fun decodeReadToolResultContent(content: String): String? { - if (content.isBlank()) return "" - - val numberedLines = content.lineSequence().mapNotNull { line -> - val tabIndex = line.indexOf('\t') - if (tabIndex <= 0) return@mapNotNull null - val prefix = line.substring(0, tabIndex) - if (!prefix.all { it.isDigit() }) return@mapNotNull null - line.substring(tabIndex + 1) - }.toList() - if (numberedLines.isNotEmpty()) return numberedLines.joinToString(separator = "\n") - - if (content.startsWith("Error reading file", ignoreCase = true)) return null - return content - } - private fun readFileText(path: String): String? { val virtualFile = LocalFileSystem.getInstance().refreshAndFindFileByPath(path) ?: return null - val documentText = runReadAction { + val documentText = runReadActionBlocking { FileDocumentManager.getInstance().getDocument(virtualFile)?.text } if (documentText != null) return documentText @@ -298,7 +232,8 @@ internal class AgentSessionTimelineHistoricalRollbackSupport( private fun writeFileText(path: String, content: String): Boolean { val virtualFile = LocalFileSystem.getInstance().refreshAndFindFileByPath(path) ?: return false - val document = runReadAction { FileDocumentManager.getInstance().getDocument(virtualFile) } + val document = + runReadActionBlocking { FileDocumentManager.getInstance().getDocument(virtualFile) } return runCatching { runInEdt { runWriteAction { diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/AgentToolWindowTabPanel.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/AgentToolWindowTabPanel.kt index 9813d5c0..df963df1 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/AgentToolWindowTabPanel.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/AgentToolWindowTabPanel.kt @@ -23,16 +23,17 @@ import ee.carlrobert.codegpt.agent.history.AgentCheckpointHistoryService import ee.carlrobert.codegpt.agent.history.AgentCheckpointTurnSequencer import ee.carlrobert.codegpt.agent.history.CheckpointRef import ee.carlrobert.codegpt.agent.rollback.RollbackService +import ee.carlrobert.codegpt.completions.ToolApprovalMode import ee.carlrobert.codegpt.conversations.Conversation import ee.carlrobert.codegpt.conversations.message.Message import ee.carlrobert.codegpt.conversations.message.QueuedMessage import ee.carlrobert.codegpt.mcp.McpTagStatusUpdater import ee.carlrobert.codegpt.psistructure.PsiStructureProvider -import ee.carlrobert.codegpt.completions.ToolApprovalMode import ee.carlrobert.codegpt.settings.models.ModelSettings import ee.carlrobert.codegpt.settings.service.FeatureType import ee.carlrobert.codegpt.toolwindow.ToolWindowInitialState import ee.carlrobert.codegpt.toolwindow.agent.ui.* +import ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor.FileChangeSnapshot import ee.carlrobert.codegpt.toolwindow.chat.MessageBuilder import ee.carlrobert.codegpt.toolwindow.chat.editor.actions.CopyAction import ee.carlrobert.codegpt.toolwindow.chat.structure.data.PsiStructureRepository @@ -69,6 +70,13 @@ class AgentToolWindowTabPanel( private const val RECOVERED_CONVERSATION_RENDER_BATCH_SIZE = 6 } + private data class RecoveredPendingToolCall( + val toolName: String, + val args: Any, + val rawArgs: String, + val card: ToolCallCard + ) + private val scrollablePanel = ChatToolWindowScrollablePanel() private val tagManager = TagManager() private val dispatchers = CoroutineDispatchers() @@ -521,6 +529,7 @@ class AgentToolWindowTabPanel( } val messages = conversation.messages.toList() + val fileChangeReconstructor = HistoricalFileChangeReconstructor(project, agentJson) var nextIndex = 0 while (nextIndex < messages.size) { if (!isActive || project.isDisposed) return@withContext @@ -552,13 +561,17 @@ class AgentToolWindowTabPanel( ) val renderedInOrder = if (canRenderInOrder) { - renderRecoveredTurnInOrder(responseBody, recoveredTurns[index].events) + renderRecoveredTurnInOrder( + responseBody, + recoveredTurns[index].events, + fileChangeReconstructor + ) } else { false } if (!renderedInOrder) { - addRecoveredToolCards(responseBody, message) + addRecoveredToolCards(responseBody, message, fileChangeReconstructor) responseBody.withResponse(message.response.orEmpty().stripThinkingBlocks()) } @@ -595,14 +608,15 @@ class AgentToolWindowTabPanel( private fun renderRecoveredTurnInOrder( responseBody: ChatMessageResponseBody, - events: List + events: List, + fileChangeReconstructor: HistoricalFileChangeReconstructor ): Boolean { if (events.isEmpty()) { return false } - val pendingById = mutableMapOf() - val pendingWithoutId = ArrayDeque() + val pendingById = mutableMapOf() + val pendingWithoutId = ArrayDeque() var rendered = false events.forEach { event -> @@ -627,13 +641,15 @@ class AgentToolWindowTabPanel( val toolName = event.tool.ifBlank { "Tool" } val rawArgs = event.content val args = parseRecoveredToolArgs(toolName, rawArgs) - val card = createRecoveredToolCard(toolName, args, rawArgs) + val snapshot = fileChangeReconstructor.createSnapshot(toolName, args, rawArgs) + val card = createRecoveredToolCard(toolName, args, rawArgs, snapshot) responseBody.addToolStatusPanel(card) + val pendingCall = RecoveredPendingToolCall(toolName, args, rawArgs, card) val callId = event.id?.takeIf { it.isNotBlank() } if (callId != null) { - pendingById[callId] = card + pendingById[callId] = pendingCall } else { - pendingWithoutId.addLast(card) + pendingWithoutId.addLast(pendingCall) } rendered = true } @@ -642,17 +658,25 @@ class AgentToolWindowTabPanel( val toolName = event.tool.ifBlank { "Tool" } val rawResult = event.content val parsedResult = parseRecoveredToolResult(toolName, rawResult) - val success = inferRecoveredToolSuccess(parsedResult, rawResult) - val card = event.id + val success = inferRecoveredToolSuccess(toolName, parsedResult, rawResult) + val pendingCall = event.id ?.takeIf { it.isNotBlank() } ?.let { pendingById.remove(it) } ?: pendingWithoutId.pollFirst() ?: run { - val orphan = createRecoveredToolCard(toolName, "", "") - responseBody.addToolStatusPanel(orphan) - orphan + val orphanCard = createRecoveredToolCard(toolName, "", "", null) + responseBody.addToolStatusPanel(orphanCard) + RecoveredPendingToolCall(toolName, "", "", orphanCard) } - card.complete(success, parsedResult ?: rawResult) + pendingCall.card.complete(success, parsedResult ?: rawResult) + if (success) { + fileChangeReconstructor.applySuccessfulResult( + pendingCall.toolName, + pendingCall.args, + pendingCall.rawArgs, + rawResult + ) + } rendered = true } @@ -662,7 +686,11 @@ class AgentToolWindowTabPanel( return rendered } - private fun addRecoveredToolCards(responseBody: ChatMessageResponseBody, message: Message) { + private fun addRecoveredToolCards( + responseBody: ChatMessageResponseBody, + message: Message, + fileChangeReconstructor: HistoricalFileChangeReconstructor + ) { val toolCalls = message.toolCalls ?: return val toolCallResults = message.toolCallResults ?: emptyMap() @@ -670,23 +698,28 @@ class AgentToolWindowTabPanel( val toolName = toolCall.function.name ?: return@forEach val rawArgs = toolCall.function.arguments.orEmpty() val args = parseRecoveredToolArgs(toolName, rawArgs) - val card = createRecoveredToolCard(toolName, args, rawArgs) + val snapshot = fileChangeReconstructor.createSnapshot(toolName, args, rawArgs) + val card = createRecoveredToolCard(toolName, args, rawArgs, snapshot) responseBody.addToolStatusPanel(card) val rawResult = toolCallResults[toolCall.id] ?: return@forEach val parsedResult = parseRecoveredToolResult(toolName, rawResult) - val success = inferRecoveredToolSuccess(parsedResult, rawResult) + val success = inferRecoveredToolSuccess(toolName, parsedResult, rawResult) card.complete(success, parsedResult ?: rawResult) + if (success) { + fileChangeReconstructor.applySuccessfulResult(toolName, args, rawArgs, rawResult) + } } } private fun createRecoveredToolCard( toolName: String, args: Any, - rawArgs: String + rawArgs: String, + fileChangeSnapshot: FileChangeSnapshot? ): ToolCallCard { return try { - ToolCallCard(project, toolName, args) + ToolCallCard(project, toolName, args, fileChangeSnapshot = fileChangeSnapshot) } catch (_: Exception) { val fallbackName = "Recovered $toolName" val fallbackArgs = rawArgs.ifBlank { "(no arguments)" } @@ -714,7 +747,19 @@ class AgentToolWindowTabPanel( ) ?: payload } - private fun inferRecoveredToolSuccess(parsedResult: Any?, rawResult: String): Boolean { + private fun inferRecoveredToolSuccess( + toolName: String, + parsedResult: Any?, + rawResult: String + ): Boolean { + val supportedTool = HistoricalRollbackCompatibility.resolveSupportedTool(toolName) + if (supportedTool != null) { + return HistoricalRollbackCompatibility.isSuccessfulResult( + supportedTool, + rawResult, + agentJson + ) + } if (parsedResult != null) { return parsedResult::class.simpleName != "Error" } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/HistoricalFileChangeReconstructor.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/HistoricalFileChangeReconstructor.kt new file mode 100644 index 00000000..8985126f --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/HistoricalFileChangeReconstructor.kt @@ -0,0 +1,124 @@ +package ee.carlrobert.codegpt.toolwindow.agent + +import com.intellij.openapi.project.Project +import ee.carlrobert.codegpt.agent.ToolName +import ee.carlrobert.codegpt.agent.tools.EditTool +import ee.carlrobert.codegpt.agent.tools.WriteTool +import ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor.FileChangeSnapshot +import ee.carlrobert.codegpt.toolwindow.agent.ui.renderer.applyStringReplacement +import kotlinx.serialization.json.Json + +internal class HistoricalFileChangeReconstructor( + private val project: Project, + private val replayJson: Json +) { + + private val latestKnownContentByFile = mutableMapOf() + + fun createSnapshot(rawToolName: String, args: Any, rawArgs: String): FileChangeSnapshot? { + return when (HistoricalRollbackCompatibility.resolveSupportedTool(rawToolName)) { + ToolName.EDIT -> decodeEditArgs(rawToolName, args, rawArgs)?.let(::createEditSnapshot) + ToolName.WRITE -> decodeWriteArgs( + rawToolName, + args, + rawArgs + )?.let(::createWriteSnapshot) + + else -> null + } + } + + fun applySuccessfulResult(rawToolName: String, args: Any, rawArgs: String, rawResult: String) { + when (HistoricalRollbackCompatibility.resolveSupportedTool(rawToolName)) { + ToolName.READ -> { + val readArgs = HistoricalRollbackCompatibility.decodeReadArgs( + replayJson = replayJson, + rawToolName = rawToolName, + args = args, + rawArgs = rawArgs + ) ?: return + val filePath = HistoricalRollbackCompatibility.normalizeToolFilePath( + project.basePath, + readArgs.filePath + ) ?: return + val content = HistoricalRollbackCompatibility.decodeReadToolResultContent(rawResult) + ?: return + latestKnownContentByFile[filePath] = content + } + + ToolName.EDIT -> { + val editArgs = HistoricalRollbackCompatibility.decodeEditArgs( + replayJson = replayJson, + rawToolName = rawToolName, + args = args, + rawArgs = rawArgs + ) ?: return + val filePath = HistoricalRollbackCompatibility.normalizeToolFilePath( + project.basePath, + editArgs.filePath + ) ?: return + val known = latestKnownContentByFile[filePath] ?: return + if (!known.contains(editArgs.oldString)) return + latestKnownContentByFile[filePath] = applyStringReplacement( + known, + editArgs.oldString, + editArgs.newString, + editArgs.replaceAll + ) + } + + ToolName.WRITE -> { + val writeArgs = HistoricalRollbackCompatibility.decodeWriteArgs( + replayJson = replayJson, + rawToolName = rawToolName, + args = args, + rawArgs = rawArgs + ) ?: return + val filePath = HistoricalRollbackCompatibility.normalizeToolFilePath( + project.basePath, + writeArgs.filePath + ) ?: return + latestKnownContentByFile[filePath] = writeArgs.content + } + + else -> Unit + } + } + + private fun createEditSnapshot(args: EditTool.Args): FileChangeSnapshot? { + val filePath = + HistoricalRollbackCompatibility.normalizeToolFilePath(project.basePath, args.filePath) + ?: return null + val before = latestKnownContentByFile[filePath] ?: return null + if (!before.contains(args.oldString)) return null + val after = applyStringReplacement(before, args.oldString, args.newString, args.replaceAll) + return FileChangeSnapshot(before, after) + } + + private fun createWriteSnapshot(args: WriteTool.Args): FileChangeSnapshot? { + val filePath = + HistoricalRollbackCompatibility.normalizeToolFilePath(project.basePath, args.filePath) + ?: return null + val before = latestKnownContentByFile[filePath] ?: return null + if (before == args.content) return null + return FileChangeSnapshot(before, args.content) + } + + private fun decodeEditArgs(rawToolName: String, args: Any, rawArgs: String): EditTool.Args? { + return HistoricalRollbackCompatibility.decodeEditArgs( + replayJson = replayJson, + rawToolName = rawToolName, + args = args, + rawArgs = rawArgs + ) + } + + private fun decodeWriteArgs(rawToolName: String, args: Any, rawArgs: String): WriteTool.Args? { + return HistoricalRollbackCompatibility.decodeWriteArgs( + replayJson = replayJson, + rawToolName = rawToolName, + args = args, + rawArgs = rawArgs + ) + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/HistoricalRollbackCompatibility.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/HistoricalRollbackCompatibility.kt index 0cb115b2..e061ab5b 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/HistoricalRollbackCompatibility.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/HistoricalRollbackCompatibility.kt @@ -5,7 +5,9 @@ import ee.carlrobert.codegpt.agent.ToolSpecs import ee.carlrobert.codegpt.agent.tools.EditTool import ee.carlrobert.codegpt.agent.tools.ReadTool import ee.carlrobert.codegpt.agent.tools.WriteTool -import kotlinx.serialization.json.Json +import kotlinx.serialization.json.* +import java.io.File +import java.nio.file.Paths internal object HistoricalRollbackCompatibility { @@ -15,7 +17,7 @@ internal object HistoricalRollbackCompatibility { val normalized = rawToolName.trim() if (normalized.isEmpty()) return null - val resolved = ToolSpecs.find(normalized)?.name ?: return null + val resolved = ToolSpecs.find(normalized) ?: return null return resolved.takeIf { it in supportedTools } } @@ -39,17 +41,128 @@ internal object HistoricalRollbackCompatibility { ToolName.READ -> !normalized.startsWith(READ_ERROR_PREFIX, ignoreCase = true) ToolName.EDIT -> normalized.isNotBlank() && - !normalized.startsWith(EDIT_ERROR_PREFIX, ignoreCase = true) && - (normalized.contains(EDIT_SUCCESS_MARKER, ignoreCase = true) || - normalized.contains(LEGACY_EDIT_SUCCESS_MARKER, ignoreCase = true)) + !normalized.startsWith(EDIT_ERROR_PREFIX, ignoreCase = true) && + (normalized.contains(EDIT_SUCCESS_MARKER, ignoreCase = true) || + normalized.contains(LEGACY_EDIT_SUCCESS_MARKER, ignoreCase = true)) + ToolName.WRITE -> normalized.isNotBlank() && - normalized.contains(WRITE_SUCCESS_MARKER, ignoreCase = true) && - !normalized.startsWith(WRITE_ERROR_PREFIX, ignoreCase = true) + normalized.contains(WRITE_SUCCESS_MARKER, ignoreCase = true) && + !normalized.startsWith(WRITE_ERROR_PREFIX, ignoreCase = true) + else -> false } } + fun decodeReadArgs( + replayJson: Json, + rawToolName: String, + args: Any? = null, + rawArgs: String + ): ReadTool.Args? { + val typedArgs = args as? ReadTool.Args + if (typedArgs != null) return typedArgs + + val decodedArgs = ToolSpecs.decodeArgsOrNull(rawToolName, rawArgs) as? ReadTool.Args + if (decodedArgs != null) return decodedArgs + + val jsonArgs = parseToolArgs(replayJson, rawArgs) ?: return null + val filePath = stringValue(jsonArgs["file_path"]) + ?: stringValue(jsonArgs["path"]) + ?: stringValue(jsonArgs["pathInProject"]) + ?: return null + return ReadTool.Args(filePath = filePath) + } + + fun decodeEditArgs( + replayJson: Json, + rawToolName: String, + args: Any? = null, + rawArgs: String + ): EditTool.Args? { + val typedArgs = args as? EditTool.Args + if (typedArgs != null) return typedArgs + + val decodedArgs = ToolSpecs.decodeArgsOrNull(rawToolName, rawArgs) as? EditTool.Args + if (decodedArgs != null) return decodedArgs + + val jsonArgs = parseToolArgs(replayJson, rawArgs) ?: return null + val filePath = stringValue(jsonArgs["file_path"]) ?: return null + val oldString = stringValue(jsonArgs["old_string"]) ?: return null + val newString = stringValue(jsonArgs["new_string"]) ?: return null + val shortDescription = + stringValue(jsonArgs["short_description"]) ?: "Recovered historical edit" + val replaceAll = booleanValue(jsonArgs["replace_all"]) ?: false + return EditTool.Args( + filePath = filePath, + oldString = oldString, + newString = newString, + shortDescription = shortDescription, + replaceAll = replaceAll + ) + } + + fun decodeWriteArgs( + replayJson: Json, + rawToolName: String, + args: Any? = null, + rawArgs: String + ): WriteTool.Args? { + val typedArgs = args as? WriteTool.Args + if (typedArgs != null) return typedArgs + + val decodedArgs = ToolSpecs.decodeArgsOrNull(rawToolName, rawArgs) as? WriteTool.Args + if (decodedArgs != null) return decodedArgs + + val jsonArgs = parseToolArgs(replayJson, rawArgs) ?: return null + val filePath = stringValue(jsonArgs["file_path"]) ?: return null + val content = stringValue(jsonArgs["content"]) ?: return null + return WriteTool.Args(filePath = filePath, content = content) + } + + fun normalizeToolFilePath(projectBasePath: String?, rawPath: String?): String? { + val trimmed = rawPath?.trim()?.takeIf { it.isNotEmpty() } ?: return null + val normalized = trimmed.replace("\\", "/") + val file = File(normalized) + if (file.isAbsolute) { + return file.toPath().normalize().toString().replace("\\", "/") + } + + val basePath = projectBasePath ?: return file.absolutePath.replace("\\", "/") + return Paths.get(basePath).resolve(normalized).normalize().toString().replace("\\", "/") + } + + fun decodeReadToolResultContent(content: String): String? { + if (content.isBlank()) return "" + + val numberedLines = content.lineSequence().mapNotNull { line -> + val tabIndex = line.indexOf('\t') + if (tabIndex <= 0) return@mapNotNull null + val prefix = line.substring(0, tabIndex) + if (!prefix.all { it.isDigit() }) return@mapNotNull null + line.substring(tabIndex + 1) + }.toList() + if (numberedLines.isNotEmpty()) return numberedLines.joinToString(separator = "\n") + + if (content.startsWith(READ_ERROR_PREFIX, ignoreCase = true)) return null + return content + } + + private fun parseToolArgs(replayJson: Json, rawArgs: String): Map? { + return runCatching { replayJson.parseToJsonElement(rawArgs).jsonObject }.getOrNull() + } + + private fun booleanValue(element: JsonElement?): Boolean? { + val primitive = element as? JsonPrimitive ?: return null + return if (primitive.isString) primitive.content.toBooleanStrictOrNull() else primitive.booleanOrNull + } + + private fun stringValue(element: JsonElement?): String? { + if (element == null) return null + val primitive = element as? JsonPrimitive + return if (primitive != null && primitive.isString) primitive.content else element.toString() + } + private const val READ_ERROR_PREFIX = "Error reading file" private const val EDIT_ERROR_PREFIX = "Error editing file" private const val WRITE_ERROR_PREFIX = "Error writing file" diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/AgentRunDslPanel.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/AgentRunDslPanel.kt index 352213b2..6f99fa3b 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/AgentRunDslPanel.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/AgentRunDslPanel.kt @@ -103,7 +103,8 @@ class AgentRunDslPanel( args = value.args ?: "", result = value.result, overrideKind = value.kind, - summary = summary + summary = summary, + fileChangeSnapshot = value.fileChangeSnapshot ) val view = ToolCallView(descriptor) viewByEntryId[value.id] = view diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/AgentRunViewModel.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/AgentRunViewModel.kt index ddb0285e..4ed37ba6 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/AgentRunViewModel.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/AgentRunViewModel.kt @@ -7,6 +7,7 @@ import ee.carlrobert.codegpt.agent.tools.ide.ExecuteRunConfigurationTool import ee.carlrobert.codegpt.agent.tools.ide.GetBreakpointsTool import ee.carlrobert.codegpt.agent.tools.ide.GetDebugSessionsTool import ee.carlrobert.codegpt.agent.tools.ide.GetRunOutputTool +import ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor.FileChangeSnapshot import ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor.ToolKind sealed class RunEntry { @@ -16,6 +17,7 @@ sealed class RunEntry { abstract val toolName: String abstract val args: Any? abstract val result: Any? + open val fileChangeSnapshot: FileChangeSnapshot? = null abstract fun withAnyResult(result: Any?): RunEntry data class ReadEntry( @@ -83,6 +85,7 @@ sealed class RunEntry { override val parentId: String? = null, override val args: WriteTool.Args? = null, override val result: WriteTool.Result? = null, + override val fileChangeSnapshot: FileChangeSnapshot? = null, ) : RunEntry() { override val kind: ToolKind = ToolKind.WRITE override val toolName: String = "Write" @@ -95,6 +98,7 @@ sealed class RunEntry { override val parentId: String? = null, override val args: EditTool.Args? = null, override val result: EditTool.Result? = null, + override val fileChangeSnapshot: FileChangeSnapshot? = null, ) : RunEntry() { override val kind: ToolKind = ToolKind.EDIT override val toolName: String = "Edit" @@ -169,7 +173,7 @@ sealed class RunEntry { override val args: BreakpointTool.Args? = null, override val result: BreakpointTool.Result? = null, ) : RunEntry() { - override val kind: ToolKind = ToolKind.OTHER + override val kind: ToolKind = ToolKind.IDE_BREAKPOINT override val toolName: String = "Breakpoint" override fun withAnyResult(result: Any?): RunEntry = copy(result = result as? BreakpointTool.Result) @@ -181,7 +185,7 @@ sealed class RunEntry { override val args: GetBreakpointsTool.Args? = null, override val result: GetBreakpointsTool.Result? = null, ) : RunEntry() { - override val kind: ToolKind = ToolKind.OTHER + override val kind: ToolKind = ToolKind.IDE_BREAKPOINTS override val toolName: String = "GetBreakpoints" override fun withAnyResult(result: Any?): RunEntry = copy(result = result as? GetBreakpointsTool.Result) @@ -193,7 +197,7 @@ sealed class RunEntry { override val args: GetDebugSessionsTool.Args? = null, override val result: GetDebugSessionsTool.Result? = null, ) : RunEntry() { - override val kind: ToolKind = ToolKind.OTHER + override val kind: ToolKind = ToolKind.IDE_DEBUG_SESSIONS override val toolName: String = "GetDebugSessions" override fun withAnyResult(result: Any?): RunEntry = copy(result = result as? GetDebugSessionsTool.Result) @@ -205,7 +209,7 @@ sealed class RunEntry { override val args: DebugSessionControlTool.Args? = null, override val result: DebugSessionControlTool.Result? = null, ) : RunEntry() { - override val kind: ToolKind = ToolKind.IDE_DEBUGGER + override val kind: ToolKind = ToolKind.IDE_DEBUG_SESSION_CONTROL override val toolName: String = "DebugSessionControl" override fun withAnyResult(result: Any?): RunEntry = copy(result = result as? DebugSessionControlTool.Result) @@ -217,7 +221,7 @@ sealed class RunEntry { override val args: GetRunOutputTool.Args? = null, override val result: GetRunOutputTool.Result? = null, ) : RunEntry() { - override val kind: ToolKind = ToolKind.OTHER + override val kind: ToolKind = ToolKind.IDE_RUN_OUTPUT override val toolName: String = "GetRunOutput" override fun withAnyResult(result: Any?): RunEntry = copy(result = result as? GetRunOutputTool.Result) @@ -229,7 +233,7 @@ sealed class RunEntry { override val args: ExecuteRunConfigurationTool.Args? = null, override val result: ExecuteRunConfigurationTool.Result? = null, ) : RunEntry() { - override val kind: ToolKind = ToolKind.IDE_RUN_CONFIGURATION + override val kind: ToolKind = ToolKind.IDE_EXECUTE_RUN_CONFIGURATION override val toolName: String = "ExecuteRunConfiguration" override fun withAnyResult(result: Any?): RunEntry = copy(result = result as? ExecuteRunConfigurationTool.Result) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/ToolCallCard.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/ToolCallCard.kt index 234bf517..9b434c9c 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/ToolCallCard.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/ToolCallCard.kt @@ -3,6 +3,7 @@ package ee.carlrobert.codegpt.toolwindow.agent.ui import com.intellij.openapi.project.Project import com.intellij.ui.components.JBPanel import com.intellij.util.ui.JBUI +import ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor.FileChangeSnapshot import ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor.ToolCallDescriptor import ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor.ToolCallDescriptorFactory import ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor.ToolCallView @@ -13,7 +14,8 @@ class ToolCallCard( private val project: Project, private var toolName: String, private var args: Any?, - private val overrideKind: ToolKind? = null + private val overrideKind: ToolKind? = null, + private val fileChangeSnapshot: FileChangeSnapshot? = null ) : JBPanel() { private val view: ToolCallView @@ -28,7 +30,8 @@ class ToolCallCard( toolName = toolName, args = args ?: Unit, result = null, - overrideKind = overrideKind + overrideKind = overrideKind, + fileChangeSnapshot = fileChangeSnapshot ) view = ToolCallView(descriptor) add(view, BorderLayout.CENTER) @@ -40,7 +43,8 @@ class ToolCallCard( toolName = toolName, args = args ?: Unit, result = result, - overrideKind = overrideKind + overrideKind = overrideKind, + fileChangeSnapshot = fileChangeSnapshot ) view.refreshDescriptor(updated) view.complete(success, result) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/components/DiffPreviewAccordionPanel.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/components/DiffPreviewAccordionPanel.kt new file mode 100644 index 00000000..65218718 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/components/DiffPreviewAccordionPanel.kt @@ -0,0 +1,265 @@ +package ee.carlrobert.codegpt.toolwindow.agent.ui.components + +import com.intellij.icons.AllIcons +import com.intellij.ui.Gray +import com.intellij.ui.JBColor +import com.intellij.ui.components.ActionLink +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBPanel +import com.intellij.util.ui.JBUI +import java.awt.* +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.* + +data class DiffAccordionFileLink( + val text: String, + val tooltip: String? = null, + val enabled: Boolean = true, + val action: () -> Unit +) + +data class DiffAccordionBadge( + val text: String, + val color: JBColor, + val tooltip: String? = null +) + +data class DiffAccordionAction( + val label: String, + val action: (Component) -> Unit +) + +data class DiffAccordionModel( + val icon: Icon?, + val prefixText: String? = null, + val titleText: String? = null, + val subtitleText: String? = null, + val fileLink: DiffAccordionFileLink? = null, + val tooltip: String? = null, + val badges: List, + val bodyFactory: () -> JComponent, + val actions: List = emptyList() +) + +class DiffPreviewAccordionPanel( + private val model: DiffAccordionModel, + private var expanded: Boolean, + private val onExpandedChange: (Boolean) -> Unit +) : JBPanel() { + + private var bodyComponent: JComponent? = null + + init { + layout = BorderLayout() + isOpaque = false + rebuild() + } + + private fun rebuild() { + removeAll() + + val container = JPanel().apply { + isOpaque = false + layout = BoxLayout(this, BoxLayout.Y_AXIS) + alignmentX = LEFT_ALIGNMENT + add(createHeader().apply { + alignmentX = LEFT_ALIGNMENT + }) + if (expanded) { + add(createBody().apply { + alignmentX = LEFT_ALIGNMENT + }) + } + } + + add(container, BorderLayout.CENTER) + revalidate() + repaint() + } + + private fun createHeader(): JComponent { + val header = JPanel(BorderLayout(8, 0)).apply { + isOpaque = false + border = JBUI.Borders.empty(2, 0) + toolTipText = model.tooltip + } + + val left = JPanel().apply { + isOpaque = false + layout = BoxLayout(this, BoxLayout.Y_AXIS) + alignmentX = LEFT_ALIGNMENT + } + + val titleRow = JPanel().apply { + isOpaque = false + layout = BoxLayout(this, BoxLayout.X_AXIS) + alignmentX = LEFT_ALIGNMENT + } + + titleRow.add(JBLabel(model.icon).apply { + toolTipText = model.tooltip + border = JBUI.Borders.emptyRight(4) + }) + model.prefixText + ?.takeIf { it.isNotBlank() } + ?.let { prefix -> + titleRow.add(JBLabel(prefix).withFont(JBUI.Fonts.label()).apply { + foreground = JBUI.CurrentTheme.Label.foreground() + toolTipText = model.tooltip + }) + titleRow.add(JBLabel(" ").withFont(JBUI.Fonts.label())) + } + model.titleText + ?.takeIf { it.isNotBlank() } + ?.let { title -> + titleRow.add(JBLabel(title).withFont(JBUI.Fonts.label()).apply { + foreground = JBUI.CurrentTheme.Label.foreground() + toolTipText = model.tooltip + }) + if (model.fileLink != null) { + titleRow.add(JBLabel(" ").withFont(JBUI.Fonts.label())) + } + } + model.fileLink?.let { fileLink -> + titleRow.add(ActionLink(fileLink.text) { fileLink.action() }.apply { + toolTipText = fileLink.tooltip + isEnabled = fileLink.enabled + setExternalLinkIcon() + }) + } + + model.badges.forEach { badge -> + titleRow.add( + JBLabel(badge.text).withFont(JBUI.Fonts.label()).apply { + foreground = badge.color + toolTipText = badge.tooltip + border = JBUI.Borders.emptyLeft(10) + } + ) + } + + left.add(titleRow) + model.subtitleText + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { subtitle -> + left.add(JBLabel(subtitle).withFont(JBUI.Fonts.smallFont()).apply { + foreground = Gray.x88 + toolTipText = model.tooltip + border = JBUI.Borders.emptyLeft(if (model.icon != null) 20 else 0) + alignmentX = LEFT_ALIGNMENT + }) + } + + val chevron = + JBLabel(if (expanded) AllIcons.General.ArrowUp else AllIcons.General.ArrowDown).apply { + foreground = Gray.x88 + } + + header.add(left, BorderLayout.CENTER) + header.add(chevron, BorderLayout.EAST) + installToggleHandler(header) + return header + } + + private fun createBody(): JComponent { + return JPanel().apply { + isOpaque = false + layout = BoxLayout(this, BoxLayout.Y_AXIS) + alignmentX = LEFT_ALIGNMENT + border = JBUI.Borders.compound( + JBUI.Borders.customLine(SEPARATOR_COLOR, 1, 0, 0, 0), + JBUI.Borders.emptyTop(6) + ) + + add(createBodyContent()) + if (model.actions.isNotEmpty()) { + add(createFooterActions()) + } + add(createCollapseIndicator()) + } + } + + private fun createBodyContent(): JComponent { + return JPanel(BorderLayout()).apply { + isOpaque = false + border = JBUI.Borders.empty(0, 0, 2, 0) + add( + bodyComponent ?: model.bodyFactory().also { bodyComponent = it }, + BorderLayout.CENTER + ) + } + } + + private fun createFooterActions(): JComponent { + return JPanel(BorderLayout()).apply { + isOpaque = false + border = JBUI.Borders.empty(2, 0, 0, 0) + + val actionsPanel = JPanel().apply { + isOpaque = false + layout = BoxLayout(this, BoxLayout.X_AXIS) + } + + model.actions.forEachIndexed { index, action -> + if (index > 0) { + actionsPanel.add( + JBLabel(" · ").withFont(JBUI.Fonts.smallFont()).apply { + foreground = Gray.x88 + } + ) + } + actionsPanel.add(ActionLink(action.label) { action.action(this) }.apply { + font = JBUI.Fonts.smallFont() + }) + } + + add(actionsPanel, BorderLayout.EAST) + } + } + + private fun createCollapseIndicator(): JComponent { + return JPanel(BorderLayout()).apply { + isOpaque = false + border = JBUI.Borders.empty(4, 0, 2, 0) + + val indicator = JBLabel(AllIcons.General.ArrowDown).apply { + foreground = Gray.x88 + horizontalAlignment = SwingConstants.CENTER + } + + add(indicator, BorderLayout.CENTER) + installToggleHandler(this) + } + } + + private fun installToggleHandler(component: Component) { + if (component is ActionLink) { + return + } + + component.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + component.addMouseListener(object : MouseAdapter() { + override fun mouseClicked(event: MouseEvent) { + if (SwingUtilities.isLeftMouseButton(event)) { + toggle() + } + } + }) + + if (component is Container) { + component.components.forEach(::installToggleHandler) + } + } + + private fun toggle() { + expanded = !expanded + onExpandedChange(expanded) + rebuild() + } + + companion object { + private val SEPARATOR_COLOR = JBColor(Color(0xD9DDE3), Color(0x4B4F52)) + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/components/UnifiedDiffPreviewPanel.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/components/UnifiedDiffPreviewPanel.kt new file mode 100644 index 00000000..756d58a5 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/components/UnifiedDiffPreviewPanel.kt @@ -0,0 +1,93 @@ +package ee.carlrobert.codegpt.toolwindow.agent.ui.components + +import com.intellij.diff.DiffContentFactory +import com.intellij.diff.DiffContext +import com.intellij.diff.requests.SimpleDiffRequest +import com.intellij.diff.tools.fragmented.UnifiedDiffViewer +import com.intellij.openapi.fileTypes.FileTypeManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Key +import com.intellij.openapi.util.UserDataHolderBase +import com.intellij.openapi.util.text.StringUtil +import com.intellij.testFramework.LightVirtualFile +import com.intellij.ui.components.JBPanel +import com.intellij.util.ui.JBUI +import ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor.ToolCallDiffPreview +import java.awt.BorderLayout +import java.awt.Dimension + +class UnifiedDiffPreviewPanel( + project: Project?, + preview: ToolCallDiffPreview +) : JBPanel() { + + init { + layout = BorderLayout() + isOpaque = false + border = JBUI.Borders.empty(0, 0, 0, 0) + + val viewer = createViewer(project, preview) + val editor = viewer.editor + editor.apply { + settings.apply { + additionalColumnsCount = 0 + additionalLinesCount = 0 + isAdditionalPageAtBottom = false + isFoldingOutlineShown = false + isCaretRowShown = false + isBlinkCaret = false + isDndEnabled = false + isIndentGuidesShown = false + isUseSoftWraps = false + } + scrollPane.border = JBUI.Borders.empty() + scrollPane.viewportBorder = null + contentComponent.border = JBUI.Borders.emptyLeft(4) + setBorder(JBUI.Borders.empty()) + component.preferredSize = Dimension(0, JBUI.scale(180)) + } + + add(editor.component, BorderLayout.CENTER) + } + + private fun createViewer(project: Project?, preview: ToolCallDiffPreview): UnifiedDiffViewer { + val fileName = preview.filePath.substringAfterLast('/').substringAfterLast('\\') + val fileType = FileTypeManager.getInstance().getFileTypeByFileName(fileName) + val beforeFile = LightVirtualFile( + fileName, + fileType, + StringUtil.convertLineSeparators(preview.snapshot.beforeText) + ) + val afterFile = LightVirtualFile( + fileName, + fileType, + StringUtil.convertLineSeparators(preview.snapshot.afterText) + ) + + val contentFactory = DiffContentFactory.getInstance() + val request = SimpleDiffRequest( + null, + contentFactory.create(project, beforeFile), + contentFactory.create(project, afterFile), + null, + null + ) + + return UnifiedDiffViewer(PreviewDiffContext(project), request).apply { + rediff(true) + } + } + + private class PreviewDiffContext(private val project: Project?) : DiffContext() { + private val data = UserDataHolderBase() + + override fun getProject(): Project? = project + override fun isFocusedInWindow(): Boolean = false + override fun isWindowFocused(): Boolean = false + override fun requestFocusInWindow() {} + override fun getUserData(key: Key): T? = data.getUserData(key) + override fun putUserData(key: Key, value: T?) { + data.putUserData(key, value) + } + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/descriptor/IdeToolDescriptors.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/descriptor/IdeToolDescriptors.kt index 08711093..82d646ef 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/descriptor/IdeToolDescriptors.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/descriptor/IdeToolDescriptors.kt @@ -2,6 +2,7 @@ package ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor import com.intellij.icons.AllIcons import com.intellij.ui.JBColor +import com.intellij.util.IconUtil import ee.carlrobert.codegpt.agent.tools.ide.* /** @@ -58,7 +59,7 @@ object IdeToolDescriptors { } return ToolCallDescriptor( - kind = ToolKind.IDE_RUN_CONFIGURATION, + kind = ToolKind.IDE_RUN_CONFIGURATIONS, icon = icon, titlePrefix = titlePrefix, titleMain = "Configurations", @@ -119,7 +120,7 @@ object IdeToolDescriptors { } return ToolCallDescriptor( - kind = ToolKind.IDE_RUN_CONFIGURATION, + kind = ToolKind.IDE_EXECUTE_RUN_CONFIGURATION, icon = AllIcons.Toolwindows.ToolWindowRun, titlePrefix = "Run", titleMain = configurationName, @@ -209,8 +210,8 @@ object IdeToolDescriptors { } return ToolCallDescriptor( - kind = ToolKind.OTHER, - icon = AllIcons.Debugger.Db_set_breakpoint, + kind = ToolKind.IDE_BREAKPOINT, + icon = IconUtil.scale(AllIcons.Debugger.Db_set_breakpoint, null, 0.75f), titlePrefix = "", titleMain = "$actionLabel breakpoint", tooltip = if (filePath.isNotEmpty()) "$actionLabel breakpoint at ${filePath}:${line}" else "$actionLabel breakpoint", @@ -304,7 +305,7 @@ object IdeToolDescriptors { .replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } return ToolCallDescriptor( - kind = ToolKind.IDE_DEBUGGER, + kind = ToolKind.IDE_DEBUG_SESSION_CONTROL, icon = AllIcons.Toolwindows.ToolWindowDebugger, titlePrefix = "", titleMain = actionName, @@ -384,9 +385,6 @@ object IdeToolDescriptors { appendLine() result.breakpoints.forEach { bp -> append("- ${bp.filePath}:L${bp.line}") - if (!bp.condition.isNullOrBlank()) { - append(" if ${bp.condition}") - } if (!bp.enabled) { append(" (disabled)") } @@ -411,8 +409,8 @@ object IdeToolDescriptors { } return ToolCallDescriptor( - kind = ToolKind.OTHER, - icon = AllIcons.Debugger.Db_set_breakpoint, + kind = ToolKind.IDE_BREAKPOINTS, + icon = IconUtil.scale(AllIcons.Debugger.Db_set_breakpoint, null, 0.75f), titlePrefix = "", titleMain = "Breakpoints", tooltip = "List breakpoints in project", @@ -503,7 +501,7 @@ object IdeToolDescriptors { } return ToolCallDescriptor( - kind = ToolKind.IDE_DEBUGGER, + kind = ToolKind.IDE_DEBUG_SESSIONS, icon = AllIcons.Toolwindows.ToolWindowDebugger, titlePrefix = "", titleMain = "Debug sessions", @@ -573,7 +571,7 @@ object IdeToolDescriptors { } return ToolCallDescriptor( - kind = ToolKind.OTHER, + kind = ToolKind.IDE_RUN_OUTPUT, icon = AllIcons.RunConfigurations.TestState.Run, titlePrefix = "Output", titleMain = name, diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/descriptor/ToolCallDescriptor.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/descriptor/ToolCallDescriptor.kt index 9843d7e2..d88d7961 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/descriptor/ToolCallDescriptor.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/descriptor/ToolCallDescriptor.kt @@ -6,8 +6,30 @@ import java.awt.Component import javax.swing.Icon enum class ToolKind { - READ, WRITE, EDIT, BASH, BASH_OUTPUT, KILL_SHELL, SEARCH, WEB, TASK, TODO_WRITE, MCP, LIBRARY_RESOLVE, LIBRARY_DOCS, SKILL, ASK_QUESTION, EXIT, DIAGNOSTICS, - IDE_RUN_CONFIGURATION, IDE_REFACTORING, IDE_DEBUGGER, IDE_SYMBOL_INFO, + READ, + WRITE, + EDIT, + BASH, + BASH_OUTPUT, + KILL_SHELL, + SEARCH, + WEB, + TASK, + TODO_WRITE, + MCP, + LIBRARY_RESOLVE, + LIBRARY_DOCS, + SKILL, + ASK_QUESTION, + EXIT, + DIAGNOSTICS, + IDE_RUN_CONFIGURATIONS, + IDE_EXECUTE_RUN_CONFIGURATION, + IDE_BREAKPOINT, + IDE_BREAKPOINTS, + IDE_DEBUG_SESSIONS, + IDE_RUN_OUTPUT, + IDE_DEBUG_SESSION_CONTROL, OTHER } @@ -33,11 +55,28 @@ data class ToolAction( val action: (Component) -> Unit ) +data class FileChangeSnapshot( + val beforeText: String, + val afterText: String, + val isNewFile: Boolean = false +) + +data class ToolCallDiffPreview( + val filePath: String, + val snapshot: FileChangeSnapshot +) + +enum class ToolCallSecondaryLayout { + SINGLE_ROW, + STACKED +} + data class ToolCallDescriptor( val kind: ToolKind, val icon: Icon?, val titlePrefix: String, val titleMain: String, + val subtitleText: String? = null, val tooltip: String?, val secondaryBadges: List = emptyList(), val fileLink: FileLink? = null, @@ -49,4 +88,6 @@ data class ToolCallDescriptor( val prefixColor: JBColor? = null, val summary: String? = null, val detailText: String? = null, + val secondaryLayout: ToolCallSecondaryLayout = ToolCallSecondaryLayout.SINGLE_ROW, + val diffPreview: ToolCallDiffPreview? = null, ) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/descriptor/ToolCallDescriptorFactory.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/descriptor/ToolCallDescriptorFactory.kt index 9bcac6c6..f883eaab 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/descriptor/ToolCallDescriptorFactory.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/descriptor/ToolCallDescriptorFactory.kt @@ -18,6 +18,7 @@ import ee.carlrobert.codegpt.toolwindow.agent.ui.approval.DiffViewAction import ee.carlrobert.codegpt.toolwindow.agent.ui.renderer.ChangeColors import ee.carlrobert.codegpt.toolwindow.agent.ui.renderer.DiffBadgeText import ee.carlrobert.codegpt.toolwindow.agent.ui.renderer.diffBadgeText +import ee.carlrobert.codegpt.toolwindow.agent.ui.renderer.lineDiffStats import ee.carlrobert.codegpt.ui.UIUtil import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject @@ -43,11 +44,12 @@ object ToolCallDescriptorFactory { args: Any, result: Any? = null, overrideKind: ToolKind? = null, - summary: String? = null + summary: String? = null, + fileChangeSnapshot: FileChangeSnapshot? = null ): ToolCallDescriptor { - val name = ToolName.entries.find { it.id == toolName || it.aliases.contains(toolName) } + val name = ToolName.fromString(toolName) val kind = overrideKind ?: detectToolKind(name, args, result) - if (kind == ToolKind.OTHER && !isHandledOtherTool(args)) { + if (kind == ToolKind.OTHER) { logger.warn("Unrecognized tool descriptor toolName=$toolName}") } @@ -55,8 +57,21 @@ object ToolCallDescriptorFactory { return when (kind) { ToolKind.SEARCH -> createSearchDescriptor(args, result, projectId) ToolKind.READ -> createReadDescriptor(args, result, projectId) - ToolKind.WRITE -> createWriteDescriptor(project, args, result, projectId) - ToolKind.EDIT -> createEditDescriptor(project, args, result, projectId) + ToolKind.WRITE -> createWriteDescriptor( + project, + args, + result, + projectId, + fileChangeSnapshot + ) + + ToolKind.EDIT -> createEditDescriptor( + project, + args, + result, + projectId, + fileChangeSnapshot + ) ToolKind.BASH, ToolKind.BASH_OUTPUT, @@ -73,20 +88,29 @@ object ToolCallDescriptorFactory { ToolKind.ASK_QUESTION -> createAskDescriptor(args, result, projectId) ToolKind.EXIT -> createExitDescriptor(args, result, projectId) ToolKind.DIAGNOSTICS -> createDiagnosticsDescriptor(args, result, projectId) - ToolKind.IDE_RUN_CONFIGURATION -> when (args) { - is GetRunConfigurationsTool.Args -> IdeToolDescriptors.createRunConfigurationDescriptor(args, result, projectId) - is ExecuteRunConfigurationTool.Args -> IdeToolDescriptors.createExecuteRunConfigurationDescriptor(args, result, projectId) - else -> IdeToolDescriptors.createRunConfigurationDescriptor(args, result, projectId) - } - ToolKind.IDE_REFACTORING -> IdeToolDescriptors.createRefactoringDescriptor(args, result, projectId) - ToolKind.IDE_DEBUGGER -> IdeToolDescriptors.createDebugSessionControlDescriptor(args, result, projectId) - ToolKind.IDE_SYMBOL_INFO -> IdeToolDescriptors.createSymbolInfoDescriptor(args, result, projectId) + ToolKind.IDE_RUN_CONFIGURATIONS -> + IdeToolDescriptors.createRunConfigurationDescriptor(args, result, projectId) + + ToolKind.IDE_EXECUTE_RUN_CONFIGURATION -> + IdeToolDescriptors.createExecuteRunConfigurationDescriptor(args, result, projectId) + + ToolKind.IDE_BREAKPOINT -> + IdeToolDescriptors.createBreakpointDescriptor(args, result, projectId) + + ToolKind.IDE_BREAKPOINTS -> + IdeToolDescriptors.createGetBreakpointsDescriptor(args, result, projectId) + + ToolKind.IDE_DEBUG_SESSIONS -> + IdeToolDescriptors.createDebugSessionsDescriptor(args, result, projectId) + + ToolKind.IDE_RUN_OUTPUT -> + IdeToolDescriptors.createRunOutputDescriptor(args, result, projectId) + + ToolKind.IDE_DEBUG_SESSION_CONTROL -> + IdeToolDescriptors.createDebugSessionControlDescriptor(args, result, projectId) + ToolKind.OTHER -> run { return@run when (args) { - is BreakpointTool.Args -> IdeToolDescriptors.createBreakpointDescriptor(args, result, projectId) - is GetBreakpointsTool.Args -> IdeToolDescriptors.createGetBreakpointsDescriptor(args, result, projectId) - is GetDebugSessionsTool.Args -> IdeToolDescriptors.createDebugSessionsDescriptor(args, result, projectId) - is GetRunOutputTool.Args -> IdeToolDescriptors.createRunOutputDescriptor(args, result, projectId) else -> createOtherDescriptor(toolName, args, result, projectId) } } @@ -114,24 +138,23 @@ object ToolCallDescriptorFactory { toolName == ToolName.ASK_USER_QUESTION || args is AskUserQuestionTool.Args -> ToolKind.ASK_QUESTION toolName == ToolName.EXIT -> ToolKind.EXIT toolName == ToolName.DIAGNOSTICS || args is DiagnosticsTool.Args -> ToolKind.DIAGNOSTICS - args is GetRunConfigurationsTool.Args -> ToolKind.IDE_RUN_CONFIGURATION - args is ExecuteRunConfigurationTool.Args -> ToolKind.IDE_RUN_CONFIGURATION - args is DebugSessionControlTool.Args -> ToolKind.IDE_DEBUGGER - args is GetRunOutputTool.Args -> ToolKind.OTHER - args is GetBreakpointsTool.Args -> ToolKind.OTHER - args is BreakpointTool.Args -> ToolKind.OTHER - args is GetDebugSessionsTool.Args -> ToolKind.OTHER + toolName == ToolName.GET_RUN_CONFIGURATIONS || + args is GetRunConfigurationsTool.Args -> ToolKind.IDE_RUN_CONFIGURATIONS + + toolName == ToolName.EXECUTE_RUN_CONFIGURATION || + args is ExecuteRunConfigurationTool.Args -> ToolKind.IDE_EXECUTE_RUN_CONFIGURATION + + toolName == ToolName.BREAKPOINT || args is BreakpointTool.Args -> ToolKind.IDE_BREAKPOINT + toolName == ToolName.GET_BREAKPOINTS || args is GetBreakpointsTool.Args -> ToolKind.IDE_BREAKPOINTS + toolName == ToolName.GET_DEBUG_SESSIONS || args is GetDebugSessionsTool.Args -> ToolKind.IDE_DEBUG_SESSIONS + toolName == ToolName.GET_RUN_OUTPUT || args is GetRunOutputTool.Args -> ToolKind.IDE_RUN_OUTPUT + toolName == ToolName.DEBUG_SESSION_CONTROL || + args is DebugSessionControlTool.Args -> ToolKind.IDE_DEBUG_SESSION_CONTROL + else -> ToolKind.OTHER } } - private fun isHandledOtherTool(args: Any): Boolean { - return args is BreakpointTool.Args || - args is GetBreakpointsTool.Args || - args is GetDebugSessionsTool.Args || - args is GetRunOutputTool.Args - } - private fun createMcpDescriptor( toolName: String, args: Any, @@ -201,7 +224,12 @@ object ToolCallDescriptorFactory { args = args, result = result, projectId = projectId, - actions = actions + actions = actions, + detailText = if (result is LoadSkillTool.Result.Success) { + "Loaded into context" + } else { + null + } ) } @@ -306,19 +334,20 @@ object ToolCallDescriptorFactory { ): ToolCallDescriptor { val readArgs = args as? ReadTool.Args val fileName = extractBaseName(readArgs?.filePath ?: "") - val actions = when (result) { - is ReadTool.Result.Success -> listOf( - ToolAction("${result.lineCount} lines", AllIcons.Actions.Show) { - showTextDialog(result.content, "File Content: $fileName") - } + val lineBadge = when (result) { + is ReadTool.Result.Success -> Badge( + "${result.lineCount} lines", + JBColor.BLUE, + action = { showTextDialog(result.content, "File Content: $fileName") } ) - else -> emptyList() + + else -> null } return ToolCallDescriptor( kind = ToolKind.READ, icon = AllIcons.FileTypes.Text, - titlePrefix = "Read", + titlePrefix = "Read:", titleMain = "", tooltip = "Read file: ${readArgs?.filePath ?: ""}", fileLink = FileLink( @@ -326,15 +355,11 @@ object ToolCallDescriptorFactory { displayName = fileName, enabled = true ), - actions = actions, + secondaryBadges = listOfNotNull(lineBadge), args = args, result = result, projectId = projectId, - detailText = when (result) { - is ReadTool.Result.Success -> "Loaded file content" - is ReadTool.Result.Error -> "Read failed" - else -> null - } + detailText = null ) } @@ -342,23 +367,44 @@ object ToolCallDescriptorFactory { project: Project, args: Any, result: Any?, - projectId: String? + projectId: String?, + fileChangeSnapshot: FileChangeSnapshot? ): ToolCallDescriptor { val writeArgs = args as? WriteTool.Args val fileName = extractBaseName(writeArgs?.filePath ?: "") val badges = mutableListOf() val actions = mutableListOf() + var diffPreview: ToolCallDiffPreview? = null if (result is WriteTool.Result && writeArgs != null) { when (result) { is WriteTool.Result.Success -> { badges.add(Badge("written", JBColor.GREEN)) - actions.add( - ToolAction("Changes", AllIcons.Actions.Diff) { - DiffViewAction.showDiff(writeArgs.filePath, project) - } - ) + if (fileChangeSnapshot != null) { + val (inserted, deleted, changed) = lineDiffStats( + fileChangeSnapshot.beforeText, + fileChangeSnapshot.afterText + ) + badges.addAll(getDiffBadges(diffBadgeText(inserted, deleted, changed))) + diffPreview = ToolCallDiffPreview(writeArgs.filePath, fileChangeSnapshot) + actions.add( + ToolAction("View Diff", AllIcons.Actions.Diff) { + DiffViewAction.showDiff( + fileChangeSnapshot.beforeText, + fileChangeSnapshot.afterText, + "Changes in $fileName", + project + ) + } + ) + } else { + actions.add( + ToolAction("Changes", AllIcons.Actions.Diff) { + DiffViewAction.showDiff(writeArgs.filePath, project) + } + ) + } } @@ -371,7 +417,7 @@ object ToolCallDescriptorFactory { return ToolCallDescriptor( kind = ToolKind.WRITE, icon = AllIcons.FileTypes.Text, - titlePrefix = "Write", + titlePrefix = "Write:", titleMain = "", tooltip = "Write file: ${writeArgs?.filePath ?: ""}", secondaryBadges = badges, @@ -388,7 +434,8 @@ object ToolCallDescriptorFactory { is WriteTool.Result.Success -> "${writeArgs?.content?.lines()?.size ?: 0} lines written" is WriteTool.Result.Error -> "Write failed" else -> null - } + }, + diffPreview = diffPreview ) } @@ -396,55 +443,74 @@ object ToolCallDescriptorFactory { project: Project, args: Any, result: Any?, - projectId: String? + projectId: String?, + fileChangeSnapshot: FileChangeSnapshot? ): ToolCallDescriptor { val editArgs = args as? EditTool.Args ?: throw IllegalArgumentException("Invalid args") val displayName = extractBaseName(editArgs.filePath) val badges = mutableListOf() val actions = mutableListOf() + var diffPreview: ToolCallDiffPreview? = null when (result) { is EditTool.Result.Success -> { - val oldLines = editArgs.oldString.split('\n').size - val newLines = editArgs.newString.split('\n').size - val changedPer = minOf(oldLines, newLines) - val addedPer = (newLines - oldLines).coerceAtLeast(0) - val deletedPer = (oldLines - newLines).coerceAtLeast(0) - val changed = changedPer * result.replacementsMade - val inserted = addedPer * result.replacementsMade - val deleted = deletedPer * result.replacementsMade - + val (inserted, deleted, changed) = if (fileChangeSnapshot != null) { + lineDiffStats(fileChangeSnapshot.beforeText, fileChangeSnapshot.afterText) + } else { + val oldLines = editArgs.oldString.split('\n').size + val newLines = editArgs.newString.split('\n').size + val changedPer = minOf(oldLines, newLines) + val addedPer = (newLines - oldLines).coerceAtLeast(0) + val deletedPer = (oldLines - newLines).coerceAtLeast(0) + Triple( + addedPer * result.replacementsMade, + deletedPer * result.replacementsMade, + changedPer * result.replacementsMade + ) + } val texts = diffBadgeText(inserted, deleted, changed) badges.addAll(getDiffBadges(texts)) actions.add( - ToolAction("${result.replacementsMade} changes", AllIcons.Actions.Diff) { _ -> - try { - val path = Path.of(editArgs.filePath) - val after = Files.readString(path) - val before = buildString { - append(after) - }.let { cur -> - if (editArgs.replaceAll) { - cur.replace(editArgs.newString, editArgs.oldString) - } else { - replaceFirstNOccurrences( - cur, - editArgs.newString, - editArgs.oldString, - result.replacementsMade - ) - } - } + ToolAction("View Diff", AllIcons.Actions.Diff) { _ -> + if (fileChangeSnapshot != null) { DiffViewAction.showDiff( - before, - after, + fileChangeSnapshot.beforeText, + fileChangeSnapshot.afterText, "Changes in ${extractBaseName(editArgs.filePath)}", project ) - } catch (_: Exception) { - DiffViewAction.showDiff(editArgs.filePath, project) + } else { + try { + val path = Path.of(editArgs.filePath) + val after = Files.readString(path) + val before = buildString { + append(after) + }.let { cur -> + if (editArgs.replaceAll) { + cur.replace(editArgs.newString, editArgs.oldString) + } else { + replaceFirstNOccurrences( + cur, + editArgs.newString, + editArgs.oldString, + result.replacementsMade + ) + } + } + DiffViewAction.showDiff( + before, + after, + "Changes in ${extractBaseName(editArgs.filePath)}", + project + ) + } catch (_: Exception) { + DiffViewAction.showDiff(editArgs.filePath, project) + } } } ) + if (fileChangeSnapshot != null) { + diffPreview = ToolCallDiffPreview(editArgs.filePath, fileChangeSnapshot) + } } is EditTool.Result.Error -> { @@ -461,8 +527,9 @@ object ToolCallDescriptorFactory { return ToolCallDescriptor( kind = ToolKind.EDIT, icon = AllIcons.Actions.Edit, - titlePrefix = "Edit", + titlePrefix = "Edit:", titleMain = "", + subtitleText = editArgs.shortDescription.trim().takeIf { it.isNotEmpty() }, tooltip = "Edit file: ${editArgs.filePath}", secondaryBadges = badges, fileLink = FileLink( @@ -480,18 +547,28 @@ object ToolCallDescriptorFactory { is EditTool.Result.Success -> "${result.replacementsMade} replacement${if (result.replacementsMade == 1) "" else "s"} applied" is EditTool.Result.Error -> "Edit failed" else -> null - } + }, + diffPreview = diffPreview ) } private fun getDiffBadges(texts: DiffBadgeText): List { - return listOf( - Badge(texts.inserted, ChangeColors.inserted), - Badge(texts.deleted, ChangeColors.deleted), - Badge(texts.changed, ChangeColors.modified) + return listOfNotNull( + texts.inserted.takeIf(::hasNonZeroDiffCount)?.let { Badge(it, ChangeColors.inserted) }, + texts.deleted.takeIf(::hasNonZeroDiffCount)?.let { Badge(it, ChangeColors.deleted) }, + texts.changed.takeIf(::hasNonZeroDiffCount)?.let { Badge(it, ChangeColors.modified) } ) } + private fun hasNonZeroDiffCount(text: String): Boolean { + val count = text + .drop(1) + .trim() + .takeWhile { it.isDigit() } + .toIntOrNull() + return count != null && count > 0 + } + private fun replaceFirstNOccurrences( input: String, target: String, @@ -543,7 +620,8 @@ object ToolCallDescriptorFactory { supportsStreaming = true, args = args, result = result, - projectId = projectId + projectId = projectId, + secondaryLayout = ToolCallSecondaryLayout.STACKED ) } @@ -587,20 +665,11 @@ object ToolCallDescriptorFactory { } else { "Search: \"$pattern\" in $scopeOrPath" }, - actions = if (result is IntelliJSearchTool.Result) { - listOf( - ToolAction("${result.totalMatches} results", AllIcons.Actions.Show) { - showTextDialog(result.output, "Search Results") - } - ) - } else { - emptyList() - }, args = args, result = result, projectId = projectId, detailText = (result as? IntelliJSearchTool.Result)?.let { - if (it.totalMatches == 0) "No matches found" else "Search completed" + if (it.totalMatches == 0) "No matches found" else "${it.totalMatches} result${if (it.totalMatches == 1) "" else "s"}" } ) } @@ -632,7 +701,9 @@ object ToolCallDescriptorFactory { projectId = projectId, detailText = when (result) { is WebSearchTool.Result -> "${result.results.size} result${if (result.results.size == 1) "" else "s"}" - is WebFetchTool.Result -> result.statusCode?.let { "HTTP $it" } ?: result.finalUrl ?: result.url + is WebFetchTool.Result -> result.statusCode?.let { "HTTP $it" } ?: result.finalUrl + ?: result.url + else -> null } ) @@ -765,7 +836,8 @@ object ToolCallDescriptorFactory { projectId = projectId, prefixColor = prefixColor, summary = taskSummary, - actions = actions + actions = actions, + secondaryLayout = ToolCallSecondaryLayout.STACKED ) } @@ -1027,7 +1099,10 @@ object ToolCallDescriptorFactory { else -> { if (result.output.isNotBlank()) { actions.add( - ToolAction("${result.diagnosticCount} $filterLabel", AllIcons.Actions.Show) { + ToolAction( + "${result.diagnosticCount} $filterLabel", + AllIcons.Actions.Show + ) { showTextDialog(result.output, "Diagnostics: $fileName") } ) @@ -1053,11 +1128,9 @@ object ToolCallDescriptorFactory { result = result, projectId = projectId, detailText = when (result) { - is DiagnosticsTool.Result -> if (result.error != null) { - result.error - } else { - if (result.output.isNotBlank()) "Diagnostics loaded" else "${result.diagnosticCount} $filterLabel" - } + is DiagnosticsTool.Result -> result.error + ?: if (result.output.isNotBlank()) "Diagnostics loaded" else "${result.diagnosticCount} $filterLabel" + else -> null } ) @@ -1132,7 +1205,8 @@ object ToolCallDescriptorFactory { Badge( "${result.results.size} results", JBColor.BLUE - )) + ) + ) if (argsObj != null && !argsObj.allowedDomains.isNullOrEmpty()) { badges.add(Badge("${argsObj.allowedDomains.size} domains", JBColor.GRAY)) } @@ -1160,6 +1234,7 @@ object ToolCallDescriptorFactory { showWebResultsDialog(result) } ) + is WebFetchTool.Result -> if (result.error == null) { listOf( ToolAction("Content", AllIcons.Actions.Show) { @@ -1169,6 +1244,7 @@ object ToolCallDescriptorFactory { } else { emptyList() } + else -> emptyList() } } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/descriptor/ToolCallDescriptorFactory.kt.bak b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/descriptor/ToolCallDescriptorFactory.kt.bak deleted file mode 100644 index f3ffa6cd..00000000 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/descriptor/ToolCallDescriptorFactory.kt.bak +++ /dev/null @@ -1,1205 +0,0 @@ -package ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor - -import com.intellij.icons.AllIcons -import com.intellij.openapi.diagnostic.thisLogger -import com.intellij.openapi.project.Project -import com.intellij.ui.JBColor -import com.intellij.util.ui.JBUI -import com.intellij.util.ui.components.BorderLayoutPanel -import ee.carlrobert.codegpt.Icons -import ee.carlrobert.codegpt.agent.ToolName -import ee.carlrobert.codegpt.agent.external.events.AcpBashPreviewArgs -import ee.carlrobert.codegpt.agent.external.events.AcpSearchPreviewArgs -import ee.carlrobert.codegpt.agent.tools.* -import ee.carlrobert.codegpt.diagnostics.DiagnosticsFilter -import ee.carlrobert.codegpt.toolwindow.agent.ui.AgentUiConfig -import ee.carlrobert.codegpt.toolwindow.agent.ui.approval.DiffViewAction -import ee.carlrobert.codegpt.toolwindow.agent.ui.renderer.ChangeColors -import ee.carlrobert.codegpt.toolwindow.agent.ui.renderer.DiffBadgeText -import ee.carlrobert.codegpt.toolwindow.agent.ui.renderer.diffBadgeText -import ee.carlrobert.codegpt.ui.UIUtil -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.contentOrNull -import java.awt.BorderLayout -import java.awt.Color -import java.awt.FlowLayout -import java.awt.Toolkit -import java.awt.datatransfer.StringSelection -import java.nio.file.Files -import java.nio.file.Path -import javax.swing.* -import kotlin.math.absoluteValue - -object ToolCallDescriptorFactory { - - private val logger = thisLogger() - - fun create( - project: Project, - toolName: String, - args: Any, - result: Any? = null, - overrideKind: ToolKind? = null, - summary: String? = null - ): ToolCallDescriptor { - val name = ToolName.entries.find { it.id == toolName || it.aliases.contains(toolName) } - val kind = overrideKind ?: detectToolKind(name, args, result) - if (kind == ToolKind.OTHER) { - logger.warn("Unrecognized tool descriptor toolName=$toolName}") - } - - val projectId = project.locationHash - return when (kind) { - ToolKind.SEARCH -> createSearchDescriptor(args, result, projectId) - ToolKind.READ -> createReadDescriptor(args, result, projectId) - ToolKind.WRITE -> createWriteDescriptor(project, args, result, projectId) - ToolKind.EDIT -> createEditDescriptor(project, args, result, projectId) - - ToolKind.BASH, - ToolKind.BASH_OUTPUT, - ToolKind.KILL_SHELL -> createBashDescriptor(args, result, projectId) - - ToolKind.WEB -> createWebDescriptor(args, result, projectId) - ToolKind.TASK -> createTaskDescriptor(args, result, projectId, summary) - ToolKind.TODO_WRITE -> createTodoWriteDescriptor(args, result, projectId) - ToolKind.MCP -> createMcpDescriptor(toolName, args, result, projectId) - - ToolKind.LIBRARY_RESOLVE -> createLibraryResolveDescriptor(args, result, projectId) - ToolKind.LIBRARY_DOCS -> createLibraryDocsDescriptor(args, result, projectId) - ToolKind.SKILL -> createSkillDescriptor(args, result, projectId) - ToolKind.ASK_QUESTION -> createAskDescriptor(args, result, projectId) - ToolKind.EXIT -> createExitDescriptor(args, result, projectId) - ToolKind.DIAGNOSTICS -> createDiagnosticsDescriptor(args, result, projectId) - ToolKind.OTHER -> createOtherDescriptor(toolName, args, result, projectId) - } - } - - private fun detectToolKind(toolName: ToolName?, args: Any, result: Any?): ToolKind { - return when { - toolName == ToolName.INTELLIJ_SEARCH || args is IntelliJSearchTool.Args || args is AcpSearchPreviewArgs -> ToolKind.SEARCH - toolName == ToolName.READ || args is ReadTool.Args -> ToolKind.READ - toolName == ToolName.WRITE || args is WriteTool.Args -> ToolKind.WRITE - toolName == ToolName.EDIT || args is EditTool.Args -> ToolKind.EDIT - toolName == ToolName.BASH || args is BashTool.Args || args is AcpBashPreviewArgs -> ToolKind.BASH - toolName == ToolName.BASH_OUTPUT || args is BashOutputTool.Args -> ToolKind.BASH_OUTPUT - toolName == ToolName.KILL_SHELL || args is KillShellTool.Args -> ToolKind.KILL_SHELL - toolName == ToolName.WEB_SEARCH || args is WebSearchTool.Args || result is WebSearchTool.Result -> ToolKind.WEB - toolName == ToolName.WEB_FETCH || args is WebFetchTool.Args || result is WebFetchTool.Result -> ToolKind.WEB - looksLikeWebPayload(args) -> ToolKind.WEB - toolName == ToolName.TASK || args is TaskTool.Args -> ToolKind.TASK - toolName == ToolName.TODO_WRITE || args is TodoWriteTool.Args -> ToolKind.TODO_WRITE - toolName == ToolName.MCP || args is McpTool.Args || result is McpTool.Result -> ToolKind.MCP - toolName == ToolName.RESOLVE_LIBRARY_ID || args is ResolveLibraryIdTool.Args -> ToolKind.LIBRARY_RESOLVE - toolName == ToolName.GET_LIBRARY_DOCS || args is GetLibraryDocsTool.Args -> ToolKind.LIBRARY_DOCS - toolName == ToolName.LOAD_SKILL || args is LoadSkillTool.Args -> ToolKind.SKILL - toolName == ToolName.ASK_USER_QUESTION || args is AskUserQuestionTool.Args -> ToolKind.ASK_QUESTION - toolName == ToolName.EXIT -> ToolKind.EXIT - toolName == ToolName.DIAGNOSTICS || args is DiagnosticsTool.Args -> ToolKind.DIAGNOSTICS - else -> ToolKind.OTHER - } - } - - private fun createMcpDescriptor( - toolName: String, - args: Any, - result: Any?, - projectId: String? - ): ToolCallDescriptor { - val mcpArgs = args as? McpTool.Args - val mcpResult = result as? McpTool.Result - val resolvedToolName = mcpResult?.toolName ?: mcpArgs?.toolName ?: toolName - val server = - mcpResult?.serverName ?: mcpResult?.serverId ?: mcpArgs?.serverName ?: mcpArgs?.serverId - val summary = mcpArgs?.arguments - ?.entries - ?.take(2) - ?.joinToString(" · ") { (key, value) -> - val valueText = value.toString().trim('"') - "$key=${truncateQuery(valueText)}" - } - - val actions = if (mcpResult != null) { - listOf( - ToolAction("View Content", AllIcons.Actions.Show) { - showTextDialog(mcpResult.output, "MCP Tool Output: ${mcpResult.toolName}") - } - ) - } else { - emptyList() - } - val serverBadge = server?.takeIf { it.isNotBlank() }?.let { Badge("@ $it") } - - return ToolCallDescriptor( - kind = ToolKind.MCP, - icon = Icons.MCP, - titlePrefix = "MCP:", - titleMain = resolvedToolName, - tooltip = "MCP tool call: $resolvedToolName", - args = args, - result = result, - projectId = projectId, - secondaryBadges = listOfNotNull(serverBadge), - actions = actions, - summary = summary - ) - } - - private fun createSkillDescriptor( - args: Any, - result: Any?, - projectId: String? - ): ToolCallDescriptor { - val skillName = (args as? LoadSkillTool.Args)?.skillName.orEmpty() - val actions = when (result) { - is LoadSkillTool.Result.Success -> listOf( - ToolAction("View Content", AllIcons.Actions.Show) { - showTextDialog(result.loadedContent, "Skill Content: ${result.name}") - } - ) - - else -> emptyList() - } - return ToolCallDescriptor( - kind = ToolKind.SKILL, - icon = AllIcons.Nodes.Template, - titlePrefix = "Skill:", - titleMain = skillName, - tooltip = "Load reusable project skill", - args = args, - result = result, - projectId = projectId, - actions = actions - ) - } - - private fun createAskDescriptor( - args: Any, - result: Any?, - projectId: String? - ): ToolCallDescriptor { - return ToolCallDescriptor( - kind = ToolKind.ASK_QUESTION, - icon = AllIcons.General.ContextHelp, - titlePrefix = "Clarify Requirements", - titleMain = "", - tooltip = "Ask the user clarifying questions", - args = args, - result = result, - projectId = projectId - ) - } - - private fun createExitDescriptor( - args: Any, - result: Any?, - projectId: String? - ): ToolCallDescriptor { - return ToolCallDescriptor( - kind = ToolKind.EXIT, - icon = AllIcons.Actions.Exit, - titlePrefix = "Exit", - titleMain = "", - tooltip = "Agent task completed", - args = args, - result = result, - projectId = projectId - ) - } - - private fun createScrollPaneWithBorder(textArea: JTextArea): JScrollPane { - return JScrollPane(textArea).apply { - preferredSize = JBUI.size(700, 400) - border = JBUI.Borders.customLine( - JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground() - ) - } - } - - private fun createFooterButtonPanel(vararg buttons: JButton): JPanel { - return JPanel(FlowLayout(FlowLayout.RIGHT)).apply { - isOpaque = false - for (button in buttons) { - add(button) - } - } - } - - private fun createDialogFooterPanel(dialog: JDialog): JPanel { - return createFooterButtonPanel( - JButton("Close").apply { addActionListener { dialog.dispose() } } - ) - } - - private fun createDialogFooterPanelWithCopy(dialog: JDialog, content: String): JPanel { - return createFooterButtonPanel( - JButton("Copy").apply { - addActionListener { - val selection = StringSelection(content) - Toolkit.getDefaultToolkit().systemClipboard.setContents( - selection, - null - ) - } - }, - JButton("Close").apply { addActionListener { dialog.dispose() } } - ) - } - - private fun showDialog(dialog: JDialog, scrollPane: JScrollPane, footerPanel: JPanel) { - dialog.contentPane = BorderLayoutPanel().apply { - add(scrollPane, BorderLayout.CENTER) - add(footerPanel, BorderLayout.SOUTH) - } - dialog.pack() - dialog.setLocationRelativeTo(null) - dialog.isVisible = true - } - - private fun showTextDialog(content: String, title: String) { - val dialog = JDialog().apply { - this.title = title - isModal = true - } - val textArea = UIUtil.createReadOnlyTextArea(content) - val scrollPane = createScrollPaneWithBorder(textArea) - val footer = createDialogFooterPanelWithCopy(dialog, content) - showDialog(dialog, scrollPane, footer) - } - - private fun createReadDescriptor( - args: Any, - result: Any?, - projectId: String? - ): ToolCallDescriptor { - val readArgs = args as? ReadTool.Args - val fileName = extractBaseName(readArgs?.filePath ?: "") - - val lineBadge = when (result) { - is ReadTool.Result.Success -> { - Badge( - "[${result.lineCount} lines]", - action = { showTextDialog(result.content, "File Content: $fileName") }) - } - - else -> null - } - - return ToolCallDescriptor( - kind = ToolKind.READ, - icon = AllIcons.FileTypes.Text, - titlePrefix = "Read:", - titleMain = fileName, - tooltip = "Read file: ${readArgs?.filePath ?: ""}", - fileLink = FileLink( - path = readArgs?.filePath ?: "", - displayName = fileName, - enabled = true - ), - secondaryBadges = listOfNotNull(lineBadge), - args = args, - result = result, - projectId = projectId - ) - } - - private fun createWriteDescriptor( - project: Project, - args: Any, - result: Any?, - projectId: String? - ): ToolCallDescriptor { - val writeArgs = args as? WriteTool.Args - val fileName = extractBaseName(writeArgs?.filePath ?: "") - - val badges = mutableListOf() - val actions = mutableListOf() - - if (result is WriteTool.Result && writeArgs != null) { - when (result) { - is WriteTool.Result.Success -> { - badges.add(Badge("[${writeArgs.content.lines().size} lines]", JBColor.GREEN)) - actions.add( - ToolAction("View Changes", AllIcons.Actions.Diff) { - DiffViewAction.showDiff(writeArgs.filePath, project) - } - ) - } - - - is WriteTool.Result.Error -> { - badges.add(Badge("Error", JBColor.RED)) - } - } - } - - return ToolCallDescriptor( - kind = ToolKind.WRITE, - icon = AllIcons.FileTypes.Text, - titlePrefix = "Write:", - titleMain = fileName, - tooltip = "Write file: ${writeArgs?.filePath ?: ""}", - secondaryBadges = badges, - fileLink = FileLink( - path = writeArgs?.filePath ?: "", - displayName = fileName, - enabled = result != null - ), - actions = actions, - args = args, - result = result, - projectId = projectId - ) - } - - private fun createEditDescriptor( - project: Project, - args: Any, - result: Any?, - projectId: String? - ): ToolCallDescriptor { - val editArgs = args as? EditTool.Args ?: throw IllegalArgumentException("Invalid args") - val displayName = extractBaseName(editArgs.filePath) - val badges = mutableListOf() - val actions = mutableListOf() - when (result) { - is EditTool.Result.Success -> { - val oldLines = editArgs.oldString.split('\n').size - val newLines = editArgs.newString.split('\n').size - val changedPer = minOf(oldLines, newLines) - val addedPer = (newLines - oldLines).coerceAtLeast(0) - val deletedPer = (oldLines - newLines).coerceAtLeast(0) - val changed = changedPer * result.replacementsMade - val inserted = addedPer * result.replacementsMade - val deleted = deletedPer * result.replacementsMade - - val texts = diffBadgeText(inserted, deleted, changed) - badges.addAll(getDiffBadges(texts)) - actions.add( - ToolAction("View Changes", AllIcons.Actions.Diff) { _ -> - try { - val path = Path.of(editArgs.filePath) - val after = Files.readString(path) - val before = buildString { - append(after) - }.let { cur -> - if (editArgs.replaceAll) { - cur.replace(editArgs.newString, editArgs.oldString) - } else { - replaceFirstNOccurrences( - cur, - editArgs.newString, - editArgs.oldString, - result.replacementsMade - ) - } - } - DiffViewAction.showDiff( - before, - after, - "Changes in ${extractBaseName(editArgs.filePath)}", - project - ) - } catch (_: Exception) { - DiffViewAction.showDiff(editArgs.filePath, project) - } - } - ) - } - - is EditTool.Result.Error -> { - badges.add(Badge("Error", JBColor.RED)) - } - } - - val editLocations = when (result) { - is EditTool.Result.Success -> result.editLocations - else -> emptyList() - } - - val firstLocation = editLocations.firstOrNull() - return ToolCallDescriptor( - kind = ToolKind.EDIT, - icon = AllIcons.Actions.Edit, - titlePrefix = "Edit:", - titleMain = displayName, - tooltip = "Edit file: ${editArgs.filePath}", - secondaryBadges = badges, - fileLink = FileLink( - path = editArgs.filePath, - displayName = displayName, - enabled = editArgs.filePath.isNotBlank(), - line = firstLocation?.line, - column = firstLocation?.column - ), - actions = actions, - args = args, - result = result, - projectId = projectId, - ) - } - - private fun getDiffBadges(texts: DiffBadgeText): List { - return listOf( - Badge(texts.inserted, ChangeColors.inserted), - Badge(texts.deleted, ChangeColors.deleted), - Badge(texts.changed, ChangeColors.modified) - ) - } - - private fun replaceFirstNOccurrences( - input: String, - target: String, - replacement: String, - n: Int - ): String { - if (n <= 0 || target.isEmpty()) return input - var remaining = n - var idx: Int - val sb = StringBuilder() - var cursor = 0 - while (remaining > 0) { - idx = input.indexOf(target, cursor) - if (idx < 0) break - sb.append(input, cursor, idx) - sb.append(replacement) - cursor = idx + target.length - remaining-- - } - sb.append(input.substring(cursor)) - return sb.toString() - } - - private fun createBashDescriptor( - args: Any, - result: Any?, - projectId: String? - ): ToolCallDescriptor { - val command = when (args) { - is BashTool.Args -> args.command - is AcpBashPreviewArgs -> args.command ?: args.title - is BashOutputTool.Args -> args.bashId - is KillShellTool.Args -> "kill_shell" - else -> null - } - val isGenericBashPreview = args is AcpBashPreviewArgs && - args.command == null && - args.title.equals("Run shell command", ignoreCase = true) - val titleMain = if (command == null || isGenericBashPreview) - "Pending command" else truncateCommand(command) - val tooltip = if (isGenericBashPreview) "Command pending approval" else "Command: $command" - - return ToolCallDescriptor( - kind = ToolKind.BASH, - icon = AllIcons.Nodes.Console, - titlePrefix = "Bash:", - titleMain = titleMain, - tooltip = tooltip, - supportsStreaming = true, - args = args, - result = result, - projectId = projectId - ) - } - - private fun buildSearchBadges(result: Any?): List { - return if (result is IntelliJSearchTool.Result) { - listOf( - Badge( - "[${result.totalMatches} matches]", - JBColor.BLUE, - action = { showTextDialog(result.output, "Search Results") } - )) - } else { - emptyList() - } - } - - private fun createSearchDescriptor( - args: Any, - result: Any?, - projectId: String? - ): ToolCallDescriptor { - val searchArgs = args as? IntelliJSearchTool.Args - val searchPreviewArgs = args as? AcpSearchPreviewArgs - val pattern = searchArgs?.pattern ?: searchPreviewArgs?.pattern.orEmpty() - val scopeOrPath = searchArgs?.path?.substringAfterLast('/') - ?: searchPreviewArgs?.path?.substringAfterLast('/') - ?: (searchArgs?.scope ?: "") - val titleMain = if (pattern.isBlank()) { - scopeOrPath.ifBlank { - searchPreviewArgs?.title?.takeIf { - it.isNotBlank() && !it.equals("search", ignoreCase = true) - } ?: "Pending query" - } - } else { - buildSearchDisplay(truncatePattern(pattern), scopeOrPath) - } - - return ToolCallDescriptor( - kind = ToolKind.SEARCH, - icon = AllIcons.Actions.Search, - titlePrefix = "Search:", - titleMain = titleMain, - tooltip = if (pattern.isBlank()) { - searchPreviewArgs?.path?.let { "Search in $it" } - ?: searchPreviewArgs?.title?.takeIf { - it.isNotBlank() && !it.equals( - "search", - ignoreCase = true - ) - } - ?: "Search query pending" - } else if (scopeOrPath.isBlank()) { - "Search: \"$pattern\"" - } else { - "Search: \"$pattern\" in $scopeOrPath" - }, - secondaryBadges = buildSearchBadges(result), - actions = emptyList(), - args = args, - result = result, - projectId = projectId - ) - } - - private fun createWebDescriptor( - args: Any, - result: Any?, - projectId: String? - ): ToolCallDescriptor { - val isFetch = isWebFetchArgs(args) || result is WebFetchTool.Result - val query = extractWebQuery(args, result) - - val titlePrefix = if (isFetch) "Fetch:" else "Web:" - - val tooltip = if (isFetch) "Fetch: $query" else "Web search: $query" - - val truncatedQuery = truncateQuery(query) - - return ToolCallDescriptor( - kind = ToolKind.WEB, - icon = AllIcons.General.Web, - titlePrefix = titlePrefix, - titleMain = truncatedQuery, - tooltip = tooltip, - secondaryBadges = buildWebBadges(args, result), - args = args, - result = result, - projectId = projectId - ) - } - - private fun extractWebQuery(args: Any, result: Any?): String { - return when (args) { - is WebSearchTool.Args -> args.query - is WebFetchTool.Args -> args.url - is JsonObject -> jsonObjectString( - args, - "url", - "uri", - "href", - "link", - "query", - "q" - ) ?: extractFirstUrl(args.toString()) ?: "Unknown" - - is Map<*, *> -> mapString( - args, - "url", - "uri", - "href", - "link", - "query", - "q" - ) ?: extractFirstUrl(args.toString()) ?: "Unknown" - - is String -> extractFirstUrl(args) ?: args.takeIf { it.isNotBlank() } ?: "Unknown" - else -> (result as? WebFetchTool.Result)?.url ?: "Unknown" - } - } - - private fun isWebFetchArgs(args: Any): Boolean { - return when (args) { - is WebFetchTool.Args -> true - is JsonObject -> jsonObjectString(args, "url", "uri", "href", "link") != null - is Map<*, *> -> mapString(args, "url", "uri", "href", "link") != null - is String -> extractFirstUrl(args) != null - else -> false - } - } - - private fun looksLikeWebPayload(args: Any): Boolean { - return when (args) { - is JsonObject -> jsonObjectString( - args, - "url", - "uri", - "href", - "link", - "query", - "q" - ) != null - - is Map<*, *> -> mapString(args, "url", "uri", "href", "link", "query", "q") != null - is String -> extractFirstUrl(args) != null - else -> false - } - } - - private fun jsonObjectString(obj: JsonObject, vararg keys: String): String? { - return keys.firstNotNullOfOrNull { key -> - (obj[key] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() } - } ?: (obj["action"] as? JsonObject)?.let { action -> - keys.firstNotNullOfOrNull { key -> - (action[key] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() } - } - } - } - - private fun mapString(map: Map<*, *>, vararg keys: String): String? { - return keys.firstNotNullOfOrNull { key -> - (map[key] as? String)?.takeIf { it.isNotBlank() } - } ?: (map["action"] as? Map<*, *>)?.let { action -> - keys.firstNotNullOfOrNull { key -> - (action[key] as? String)?.takeIf { it.isNotBlank() } - } - } - } - - private fun createTaskDescriptor( - args: Any, - result: Any?, - projectId: String?, - summary: String? = null - ): ToolCallDescriptor { - val description = when (args) { - is TaskTool.Args -> args.description - else -> "Unknown" - } - - val titlePrefix: String - val prefixColor: JBColor? - - if (args is TaskTool.Args) { - val subagentType = args.subagentType - titlePrefix = "[$subagentType]" - prefixColor = getSubagentColor(subagentType) - } else { - titlePrefix = "Task:" - prefixColor = null - } - - val taskSummary = when { - summary != null -> summary - result is TaskTool.Result -> formatTaskSummary(result) - else -> null - } - - val actions = when (result) { - is TaskTool.Result -> listOf( - ToolAction("View Content", AllIcons.Actions.Show) { - showTextDialog(result.output, "Subagent Output: ${result.description}") - } - ) - - else -> emptyList() - } - - return ToolCallDescriptor( - kind = ToolKind.TASK, - icon = AllIcons.Actions.Execute, - titlePrefix = titlePrefix, - titleMain = description, - tooltip = "Task: $description", - args = args, - result = result, - projectId = projectId, - prefixColor = prefixColor, - summary = taskSummary, - actions = actions - ) - } - - private fun createTodoWriteDescriptor( - args: Any, - result: Any?, - projectId: String? - ): ToolCallDescriptor { - val description = when (args) { - is TodoWriteTool.Args -> todoWriteLabel(args) - is JsonObject -> extractTodoWriteLabel(args) ?: "Updated task list" - else -> "Updated task list" - } - val summary = when (args) { - is TodoWriteTool.Args -> todoWriteSummary(args) - is JsonObject -> extractTodoWriteSummary(args) - else -> null - } - - return ToolCallDescriptor( - kind = ToolKind.TODO_WRITE, - icon = AllIcons.Actions.Checked, - titlePrefix = "Tasks:", - titleMain = description, - tooltip = "Update the session task list", - args = args, - result = result, - projectId = projectId, - summary = summary - ) - } - - private fun formatTaskSummary(result: TaskTool.Result): String? { - val parts = mutableListOf() - if (result.totalTokens > 0) { - parts.add("${formatTokens(result.totalTokens)} tokens") - } - return if (parts.isNotEmpty()) parts.joinToString(" · ") else null - } - - private fun formatTokens(tokens: Long): String { - return if (tokens >= 1000) { - "${tokens / 1000}K" - } else { - tokens.toString() - } - } - - private fun getSubagentColor(subagentType: String): JBColor { - val hue = subagentType.hashCode().absoluteValue % 360 - val hueNormalized = hue.toFloat() / 360f - - val lightRgb = hslToRgb(hueNormalized, 0.75f, 0.45f) - val lightColor = Color(lightRgb[0], lightRgb[1], lightRgb[2]) - - val darkRgb = hslToRgb(hueNormalized, 0.70f, 0.70f) - val darkColor = Color(darkRgb[0], darkRgb[1], darkRgb[2]) - - return JBColor(lightColor, darkColor) - } - - private fun hslToRgb(h: Float, s: Float, l: Float): IntArray { - val r: Float - val g: Float - val b: Float - - if (s == 0f) { - r = l - g = l - b = l - } else { - val q = if (l < 0.5f) l * (1 + s) else l + s - l * s - val p = 2 * l - q - r = hueToRgb(p, q, h + 1f / 3f) - g = hueToRgb(p, q, h) - b = hueToRgb(p, q, h - 1f / 3f) - } - - return intArrayOf((r * 255).toInt(), (g * 255).toInt(), (b * 255).toInt()) - } - - private fun hueToRgb(p: Float, q: Float, t: Float): Float { - var t = t - if (t < 0f) t += 1f - if (t > 1f) t -= 1f - if (t < 1f / 6f) return p + (q - p) * 6 * t - if (t < 1f / 2f) return q - if (t < 2f / 3f) return p + (q - p) * (2f / 3f - t) * 6f - return p - } - - private fun todoWriteLabel(args: TodoWriteTool.Args): String { - val inProgress = - args.todos.firstOrNull { it.status == TodoWriteTool.TodoStatus.IN_PROGRESS } - if (inProgress != null && inProgress.activeForm.isNotBlank()) { - return inProgress.activeForm - } - val completed = args.todos.firstOrNull { it.status == TodoWriteTool.TodoStatus.COMPLETED } - if (completed != null && completed.content.isNotBlank()) { - return "Marked task done: ${completed.content}" - } - if (args.title.isNotBlank()) return "Updated task list: ${args.title}" - return "Updated task list" - } - - private fun extractTodoWriteLabel(args: JsonObject): String? { - val todos = args["todos"] as? JsonArray ?: return null - val inProgress = - todos.firstOrNull { it.stringValue("status")?.equals("in_progress", true) == true } - val inProgressLabel = inProgress?.stringValue("activeForm")?.trim().orEmpty() - if (inProgressLabel.isNotBlank()) return inProgressLabel - val title = args.stringValue("title")?.trim().orEmpty() - if (title.isNotBlank()) return "Updated task list: $title" - return "Updated task list" - } - - private fun todoWriteSummary(args: TodoWriteTool.Args): String { - val pending = args.todos.count { it.status == TodoWriteTool.TodoStatus.PENDING } - val inProgress = args.todos.count { it.status == TodoWriteTool.TodoStatus.IN_PROGRESS } - val completed = args.todos.count { it.status == TodoWriteTool.TodoStatus.COMPLETED } - return buildTodoWriteSummary(pending, inProgress, completed) - } - - private fun extractTodoWriteSummary(args: JsonObject): String? { - val todos = args["todos"] as? JsonArray ?: return null - val pending = todos.count { - (it as? JsonObject)?.stringValue("status")?.equals("pending", true) == true - } - val inProgress = todos.count { - (it as? JsonObject)?.stringValue("status")?.equals("in_progress", true) == true - } - val completed = todos.count { - (it as? JsonObject)?.stringValue("status")?.equals("completed", true) == true - } - return buildTodoWriteSummary(pending, inProgress, completed) - } - - private fun buildTodoWriteSummary(pending: Int, inProgress: Int, completed: Int): String { - return listOf( - "$pending pending", - "$inProgress active", - "$completed done" - ).joinToString(" · ") - } - - private fun JsonObject.stringValue(key: String): String? = - (this[key] as? JsonPrimitive)?.contentOrNull - - private fun JsonArray.firstOrNull(predicate: (JsonObject) -> Boolean): JsonObject? { - for (element in this) { - val obj = element as? JsonObject ?: continue - if (predicate(obj)) return obj - } - return null - } - - private fun createLibraryResolveDescriptor( - args: Any, - result: Any?, - projectId: String? - ): ToolCallDescriptor { - val libraryName = when { - args is ResolveLibraryIdTool.Args -> args.libraryName - else -> "Unknown" - } - - val badges = mutableListOf() - - if (result is ResolveLibraryIdTool.Result.Success) { - badges.add( - Badge( - "[${result.libraries.size} found]", - JBColor.BLUE, - action = { showLibrariesDialog(result) } - ) - ) - } - - return ToolCallDescriptor( - kind = ToolKind.LIBRARY_RESOLVE, - icon = AllIcons.General.Web, - titlePrefix = "Library:", - titleMain = libraryName, - tooltip = "Resolve library: $libraryName", - secondaryBadges = badges, - args = args, - result = result, - projectId = projectId - ) - } - - private fun createLibraryDocsDescriptor( - args: Any, - result: Any?, - projectId: String? - ): ToolCallDescriptor { - val libraryId = when { - args is GetLibraryDocsTool.Args -> args.context7CompatibleLibraryID - else -> "Unknown" - } - - return ToolCallDescriptor( - kind = ToolKind.LIBRARY_DOCS, - icon = AllIcons.General.Web, - titlePrefix = "Docs:", - titleMain = libraryId, - tooltip = "Get library docs: $libraryId", - secondaryBadges = buildDocsBadges(result), - args = args, - result = result, - projectId = projectId - ) - } - - private fun createDiagnosticsDescriptor( - args: Any, - result: Any?, - projectId: String? - ): ToolCallDescriptor { - val diagnosticsArgs = args as? DiagnosticsTool.Args - val filePath = diagnosticsArgs?.filePath ?: "" - val fileName = extractBaseName(filePath) - val filterLabel = when (diagnosticsArgs?.filter) { - DiagnosticsFilter.ALL -> "all" - else -> "errors" - } - - val badges = mutableListOf() - val actions = mutableListOf() - - if (result is DiagnosticsTool.Result) { - when { - result.error != null -> badges.add(Badge("Error", JBColor.RED)) - else -> { - badges.add(Badge("[${result.diagnosticCount} $filterLabel]", JBColor.BLUE)) - if (result.output.isNotBlank()) { - actions.add( - ToolAction("View Diagnostics", AllIcons.Actions.Show) { - showTextDialog(result.output, "Diagnostics: $fileName") - } - ) - } - } - } - } - - return ToolCallDescriptor( - kind = ToolKind.DIAGNOSTICS, - icon = AllIcons.General.InspectionsOK, - titlePrefix = "Diagnostics:", - titleMain = fileName, - tooltip = "Diagnostics: $filePath ($filterLabel)", - fileLink = FileLink( - path = filePath, - displayName = fileName, - enabled = filePath.isNotBlank() - ), - secondaryBadges = badges, - actions = actions, - args = args, - result = result, - projectId = projectId - ) - } - - private fun createOtherDescriptor( - toolName: String, - args: Any, - result: Any?, - projectId: String? - ): ToolCallDescriptor { - return ToolCallDescriptor( - kind = ToolKind.OTHER, - icon = AllIcons.Actions.Execute, - titlePrefix = "", - titleMain = toolName, - tooltip = toolName, - args = args, - result = result, - projectId = projectId - ) - } - - private fun extractBaseName(filePath: String): String { - return filePath.substringAfterLast('/') - } - - private fun truncatePattern(pattern: String): String { - return if (pattern.length > AgentUiConfig.GREP_PATTERN_MAX) { - pattern.take(AgentUiConfig.GREP_PATTERN_MAX) + "..." - } else { - pattern - } - } - - private fun truncateQuery(query: String): String { - return if (query.length > AgentUiConfig.WEB_QUERY_MAX) { - query.take(AgentUiConfig.WEB_QUERY_MAX) + "..." - } else { - query - } - } - - private fun truncateCommand(command: String): String { - return if (command.length > AgentUiConfig.BASH_CMD_MAX) { - command.take(AgentUiConfig.BASH_CMD_MAX) + "..." - } else { - command - } - } - - private fun buildSearchDisplay(pattern: String, scope: String?): String { - return if (scope.isNullOrBlank()) { - "\"$pattern\"" - } else { - "\"$pattern\" in $scope" - } - } - - private fun buildDocsBadges(result: Any?): List { - return if (result is GetLibraryDocsTool.Result.Success) { - listOf( - Badge( - "[View Results]", - JBColor.BLUE, - action = { - showTextDialog( - result.documentation, - "Documentation: ${result.libraryId}" - ) - } - )) - } else { - emptyList() - } - } - - private fun buildWebBadges(args: Any, result: Any?): List { - return when (result) { - is WebSearchTool.Result -> { - val argsObj = args as? WebSearchTool.Args - val badges = mutableListOf( - Badge( - "[${result.results.size} results]", - JBColor.BLUE, - action = { showWebResultsDialog(result) } - )) - if (argsObj != null && !argsObj.allowedDomains.isNullOrEmpty()) { - badges.add(Badge("[${argsObj.allowedDomains.size} domains]", JBColor.GRAY)) - } - badges - } - - is WebFetchTool.Result -> { - val badges = mutableListOf() - if (result.error != null) { - badges.add(Badge("Error", JBColor.RED)) - } else { - badges.add( - Badge( - "[View Content]", - JBColor.BLUE, - action = { showWebFetchResultDialog(result) } - ) - ) - result.statusCode?.let { badges.add(Badge("[$it]", JBColor.GRAY)) } - } - badges - } - - else -> emptyList() - } - } - - private fun showWebResultsDialog(result: WebSearchTool.Result) { - val dialog = JDialog().apply { - title = "Web Search Results" - isModal = true - } - - val content = buildString { - if (result.results.isEmpty()) { - appendLine("No search results found.") - } else { - result.results.forEachIndexed { index, searchResult -> - appendLine("${index + 1}. ${searchResult.title}") - appendLine(" URL: ${searchResult.url}") - appendLine(" ${searchResult.content}") - appendLine() - } - } - } - - val textArea = UIUtil.createReadOnlyTextArea(content) - val scrollPane = createScrollPaneWithBorder(textArea) - val footerPanel = createDialogFooterPanel(dialog) - showDialog(dialog, scrollPane, footerPanel) - } - - private fun showWebFetchResultDialog(result: WebFetchTool.Result) { - val dialog = JDialog().apply { - title = "Web Fetch Result" - isModal = true - } - - val content = buildString { - appendLine("Source URL: ${result.url}") - result.finalUrl?.let { appendLine("Final URL: $it") } - result.title?.let { appendLine("Title: $it") } - result.statusCode?.let { appendLine("Status: $it") } - result.contentType?.let { appendLine("Content-Type: $it") } - result.usedSelector?.let { appendLine("Selector: $it") } - appendLine() - if (result.error != null) { - appendLine("Error: ${result.error}") - } else { - append(result.markdown) - } - } - - val textArea = UIUtil.createReadOnlyTextArea(content) - val scrollPane = createScrollPaneWithBorder(textArea) - val footerPanel = createDialogFooterPanelWithCopy(dialog, content) - showDialog(dialog, scrollPane, footerPanel) - } - - private fun showLibrariesDialog(result: ResolveLibraryIdTool.Result.Success) { - val content = buildString { - if (result.libraries.isEmpty()) { - appendLine("No libraries found for '${result.libraryName}'.") - appendLine() - appendLine("Please try with different search terms or check the library name spelling.") - } else { - appendLine("Available Libraries:") - appendLine() - result.libraries.forEachIndexed { index, library -> - appendLine("${index + 1}. ${library.name}") - appendLine(" Library ID: ${library.id}") - if (library.description.isNotBlank()) { - appendLine(" Description: ${library.description}") - } - appendLine(" Code Snippets: ${library.codeSnippets}") - appendLine(" Source Reputation: ${library.sourceReputation}") - appendLine(" Benchmark Score: ${library.benchmarkScore}") - if (!library.versions.isNullOrEmpty()) { - appendLine(" Available Versions: ${library.versions.joinToString(", ")}") - } - appendLine() - } - - val topLibrary = result.libraries.maxByOrNull { - (it.benchmarkScore * 0.4 + it.codeSnippets * 0.3 + when (it.sourceReputation.lowercase()) { - "high" -> 30 - "medium" -> 20 - "low" -> 10 - else -> 0 - } * 0.3).toInt() - } - if (topLibrary != null) { - appendLine("Recommended Selection:") - appendLine() - appendLine("Library ID: ${topLibrary.id}") - appendLine("Name: ${topLibrary.name}") - appendLine("Reasoning: Highest combined score of benchmark (${topLibrary.benchmarkScore}), code snippets (${topLibrary.codeSnippets}), and source reputation (${topLibrary.sourceReputation})") - } - } - } - - val textArea = UIUtil.createReadOnlyTextArea(content) - val scrollPane = createScrollPaneWithBorder(textArea) - val dialog = JDialog().apply { - title = "Library Search Results" - isModal = true - } - val footerPanel = createDialogFooterPanel(dialog) - showDialog(dialog, scrollPane, footerPanel) - } - - private fun extractFirstUrl(text: String): String? { - return URL_REGEX.find(text)?.value - } - - private val URL_REGEX = Regex("""https?://[^\s"'<>]+""") -} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/descriptor/ToolCallView.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/descriptor/ToolCallView.kt index c1b90bfb..e7bd44ed 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/descriptor/ToolCallView.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/descriptor/ToolCallView.kt @@ -1,11 +1,13 @@ package ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor +import com.intellij.icons.AllIcons import com.intellij.ide.actions.OpenFileAction import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.fileEditor.OpenFileDescriptor import com.intellij.openapi.project.Project import com.intellij.openapi.project.ProjectManager import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VirtualFile import com.intellij.ui.Gray import com.intellij.ui.components.ActionLink import com.intellij.ui.components.JBLabel @@ -13,22 +15,23 @@ import com.intellij.ui.components.JBPanel import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI import com.intellij.util.ui.components.BorderLayoutPanel -import ee.carlrobert.codegpt.agent.tools.IntelliJSearchTool import ee.carlrobert.codegpt.agent.tools.AskUserQuestionTool -import ee.carlrobert.codegpt.agent.tools.LoadSkillTool -import java.awt.BorderLayout -import java.awt.FlowLayout -import java.awt.Font -import java.awt.Toolkit +import ee.carlrobert.codegpt.agent.tools.IntelliJSearchTool +import ee.carlrobert.codegpt.toolwindow.agent.ui.components.* +import java.awt.* import java.awt.datatransfer.StringSelection -import javax.swing.SwingUtilities +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.nio.file.Path import javax.swing.* class ToolCallView( private var descriptor: ToolCallDescriptor ) : JBPanel() { - private var headerPanel = ToolCallHeaderPanel(descriptor) + private var diffPreviewExpanded = false + private var searchResultsExpanded = false + private var headerPanel = createHeaderPanel() private val streamingPanel = ToolCallStreamingPanel() init { @@ -45,15 +48,10 @@ class ToolCallView( headerPanel.updateCompletionStatus(result) when (result) { is AskUserQuestionTool.Result.Success -> { - val compactLines = result.answers.entries.map { (k, v) -> "$k: $v" } - streamingPanel.showCompactInfo(compactLines) - } - is LoadSkillTool.Result.Success -> { - val compactLines = listOf( - "Skill '${result.name}' loaded into context" - ) - streamingPanel.showCompactInfo(compactLines) + val compactLine = result.answers.entries.joinToString(" · ") { (k, v) -> "$k: $v" } + streamingPanel.showCompactInfo(listOf(compactLine)) } + else -> streamingPanel.onCompletion() } } @@ -69,15 +67,35 @@ class ToolCallView( fun refreshDescriptor(newDescriptor: ToolCallDescriptor) { this.descriptor = newDescriptor remove(headerPanel) - headerPanel = ToolCallHeaderPanel(descriptor) + headerPanel = createHeaderPanel() add(headerPanel, BorderLayout.NORTH) revalidate() repaint() } + + private fun createHeaderPanel(): ToolCallHeaderPanel { + return ToolCallHeaderPanel( + descriptor = descriptor, + isDiffPreviewExpanded = diffPreviewExpanded, + isSearchResultsExpanded = searchResultsExpanded, + onDiffPreviewExpandedChange = { + diffPreviewExpanded = it + refreshDescriptor(descriptor) + }, + onSearchResultsExpandedChange = { + searchResultsExpanded = it + refreshDescriptor(descriptor) + } + ) + } } private class ToolCallHeaderPanel( - private val descriptor: ToolCallDescriptor + private val descriptor: ToolCallDescriptor, + private val isDiffPreviewExpanded: Boolean, + private val isSearchResultsExpanded: Boolean, + private val onDiffPreviewExpandedChange: (Boolean) -> Unit, + private val onSearchResultsExpandedChange: (Boolean) -> Unit ) : JBPanel() { private val leftRow = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { isOpaque = false } @@ -91,11 +109,60 @@ private class ToolCallHeaderPanel( layout = BoxLayout(this, BoxLayout.Y_AXIS) isOpaque = false - buildHeaderContent() - contentPanel.add(leftRow) - buildDetailLine()?.let { contentPanel.add(it) } - buildActionsLine()?.let { contentPanel.add(it) } - add(contentPanel) + buildDiffAccordion()?.let(::add) ?: run { + buildHeaderContent() + buildHeaderToggle()?.let { leftRow.add(it) } + installSearchHeaderToggle(leftRow) + contentPanel.add(leftRow) + buildSecondaryLine()?.let { contentPanel.add(it) } + add(contentPanel) + } + } + + private fun buildDiffAccordion(): JComponent? { + val preview = descriptor.diffPreview ?: return null + val fileLink = descriptor.fileLink ?: return null + if (descriptor.kind != ToolKind.WRITE && descriptor.kind != ToolKind.EDIT) { + return null + } + + return DiffPreviewAccordionPanel( + model = DiffAccordionModel( + icon = descriptor.icon, + prefixText = descriptor.titlePrefix.takeIf { it.isNotBlank() }, + titleText = descriptor.titleMain.takeIf { it.isNotBlank() }, + subtitleText = descriptor.subtitleText, + fileLink = createAccordionFileLink(fileLink), + tooltip = fileLink.path, + badges = descriptor.secondaryBadges + .filter { it.action == null && isDiffBadge(it.text.trim()) } + .map { badge -> + DiffAccordionBadge( + text = badge.text.trim(), + color = badge.color, + tooltip = badge.tooltip + ) + }, + bodyFactory = { UnifiedDiffPreviewPanel(getProject(), preview) }, + actions = emptyList() + ), + expanded = isDiffPreviewExpanded, + onExpandedChange = onDiffPreviewExpandedChange + ) + } + + private fun createAccordionFileLink(fileLink: FileLink): DiffAccordionFileLink { + return DiffAccordionFileLink( + text = fileLink.displayName, + tooltip = if (fileLink.line != null) { + "${fileLink.path}:${fileLink.line}" + } else { + fileLink.path + }, + enabled = fileLink.enabled + ) { + openFileLink(fileLink) + } } private fun buildHeaderContent() { @@ -107,6 +174,9 @@ private class ToolCallHeaderPanel( descriptor.prefixColor?.let { color -> foreground = color } + if (descriptor.titlePrefix.isEmpty()) { + border = JBUI.Borders.emptyRight(4) + } } leftRow.add(prefixLabel) @@ -130,23 +200,7 @@ private class ToolCallHeaderPanel( leftRow.add(mutedLabel(" ")) } val link = ActionLink(fileLink.displayName) { - val project = getProject() - if (project != null) { - val vf = LocalFileSystem.getInstance().findFileByPath(fileLink.path) - if (vf != null) { - if (fileLink.line != null) { - val descriptor = OpenFileDescriptor( - project, - vf, - fileLink.line - 1, - fileLink.column ?: 0 - ) - FileEditorManager.getInstance(project).openTextEditor(descriptor, true) - } else { - OpenFileAction.openFile(vf, project) - } - } - } + openFileLink(fileLink) }.apply { toolTipText = if (fileLink.line != null) { "${fileLink.path}:${fileLink.line}" @@ -171,8 +225,8 @@ private class ToolCallHeaderPanel( val lineSuffix = fileLink.line?.let { ":L$it" }.orEmpty() val pathWithLine = "${fileLink.path}$lineSuffix" return normalizedSummary != fileLink.path && - normalizedSummary != pathWithLine && - normalizedSummary != linkDisplay + normalizedSummary != pathWithLine && + normalizedSummary != linkDisplay } private fun addRegularContent() { @@ -185,24 +239,69 @@ private class ToolCallHeaderPanel( addSearchParametersIfAny() } + private fun buildHeaderToggle(): JComponent? { + val result = descriptor.result as? IntelliJSearchTool.Result ?: return null + if (descriptor.kind != ToolKind.SEARCH || result.matches.size <= 1) { + return null + } + + val expanded = isSearchResultsExpanded + return ActionLink("") { + onSearchResultsExpandedChange(!expanded) + }.apply { + icon = if (expanded) AllIcons.General.ArrowUp else AllIcons.General.ArrowDown + border = JBUI.Borders.emptyLeft(10) + toolTipText = if (expanded) "Collapse results" else "Expand results" + } + } + + private fun installSearchHeaderToggle(component: Component) { + val result = descriptor.result as? IntelliJSearchTool.Result ?: return + if (descriptor.kind != ToolKind.SEARCH || result.matches.size <= 1 || component is ActionLink) { + return + } + + component.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + component.addMouseListener(object : MouseAdapter() { + override fun mouseClicked(event: MouseEvent) { + if (SwingUtilities.isLeftMouseButton(event)) { + onSearchResultsExpandedChange(!isSearchResultsExpanded) + } + } + }) + + if (component is Container) { + component.components.forEach(::installSearchHeaderToggle) + } + } + private fun addSecondaryBadges() { var prevWasDiff = false descriptor.secondaryBadges.forEach { badge -> - if (badge.action != null) { - return@forEach - } val isDiff = isDiffBadge(badge.text) val leftGap = if (isDiff && prevWasDiff) 0 else 4 - leftRow.add(JBLabel(badge.text).withFont(JBFont.small()).apply { - foreground = badge.color - if (badge.tooltip != null) { - toolTipText = badge.tooltip - } - border = JBUI.Borders.compound( - JBUI.Borders.emptyLeft(leftGap), - JBUI.Borders.empty(1, 6) - ) - }) + if (badge.action != null) { + leftRow.add(ActionLink("[${badge.text}]") { + badge.action.invoke() + }.apply { + font = JBUI.Fonts.smallFont() + if (badge.tooltip != null) { + toolTipText = badge.tooltip + } + border = JBUI.Borders.emptyLeft(leftGap) + }) + } else { + leftRow.add(JBLabel(badge.text).withFont(JBFont.small()).apply { + foreground = badge.color + if (badge.tooltip != null) { + toolTipText = badge.tooltip + } + border = JBUI.Borders.compound( + JBUI.Borders.emptyLeft(leftGap), + JBUI.Borders.empty(1, 6) + ) + }) + } prevWasDiff = isDiff } } @@ -266,27 +365,7 @@ private class ToolCallHeaderPanel( repaint() } - private fun buildActionsLine(): JComponent? { - val inlineActions = buildInlineActions() - if (inlineActions.isEmpty()) { - return null - } - - return JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { - isOpaque = false - border = JBUI.Borders.empty(2, 20, 0, 0) - inlineActions.forEachIndexed { index, action -> - if (index > 0) { - add(mutedLabel(" · ")) - } - add(ActionLink("[${action.label}]") { action.action() }.apply { - font = JBUI.Fonts.smallFont() - }) - } - } - } - - private fun buildDetailLine(): JComponent? { + private fun buildSecondaryLine(): JComponent? { val detailText = descriptor.detailText?.trim().takeUnless { it.isNullOrEmpty() } ?: descriptor.summary ?.trim() @@ -294,14 +373,148 @@ private class ToolCallHeaderPanel( ?.takeIf { summary -> descriptor.fileLink?.let { shouldShowFileLinkSummary(summary, it) } ?: true } - if (detailText == null) { + val inlineActions = buildInlineActions() + val searchResult = descriptor.result as? IntelliJSearchTool.Result + + if (descriptor.kind == ToolKind.SEARCH && searchResult?.matches?.isNotEmpty() == true) { + return buildSearchResultsSection(searchResult, inlineActions) + } + + if (detailText == null && inlineActions.isEmpty()) { + return null + } + + return if (descriptor.secondaryLayout == ToolCallSecondaryLayout.STACKED) { + JPanel().apply { + isOpaque = false + layout = BoxLayout(this, BoxLayout.Y_AXIS) + + detailText?.let { + add(buildSummaryRow(it, inlineActions = emptyList())) + } + if (inlineActions.isNotEmpty()) { + add(buildSummaryRow(null, inlineActions)) + } + } + } else { + buildSummaryRow(detailText, inlineActions) + } + } + + private fun buildSearchResultsSection( + result: IntelliJSearchTool.Result, + inlineActions: List + ): JComponent { + return JPanel().apply { + isOpaque = false + layout = BoxLayout(this, BoxLayout.Y_AXIS) + + add(buildSearchSummaryRow(result)) + if (isSearchResultsExpanded && result.matches.size > 1) { + add(buildExpandedSearchResults(result)) + } + buildSearchFooter(result)?.let(::add) + if (inlineActions.isNotEmpty()) { + add(buildSummaryRow(null, inlineActions)) + } + } + } + + private fun buildSearchSummaryRow(result: IntelliJSearchTool.Result): JComponent { + val firstMatch = result.matches.first() + + return JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { + isOpaque = false + border = JBUI.Borders.empty(2, 20, 0, 0) + + add(createSearchMatchLink(firstMatch)) + } + } + + private fun buildExpandedSearchResults(result: IntelliJSearchTool.Result): JComponent { + val visibleMatches = result.matches.drop(1).take(MAX_VISIBLE_SEARCH_RESULTS) + val hiddenCount = result.matches.size - 1 - visibleMatches.size + + return JPanel().apply { + isOpaque = false + layout = BoxLayout(this, BoxLayout.Y_AXIS) + + visibleMatches.forEach { match -> + add(buildSearchMatchRow(match)) + } + + if (hiddenCount > 0) { + add(JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { + isOpaque = false + border = JBUI.Borders.empty(2, 20, 0, 0) + add(ActionLink("Show all ${result.matches.size} results") { + showSearchResultsDialog(formatSearchResultsDialogContent(result)) + }.apply { + font = JBUI.Fonts.smallFont() + foreground = Gray.x88 + }) + }) + } + } + } + + private fun buildSearchFooter(result: IntelliJSearchTool.Result): JComponent? { + val remaining = result.matches.size - 1 + if (remaining <= 0) { return null } + return JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { + isOpaque = false + border = JBUI.Borders.empty(4, 20, 0, 0) + add(ActionLink(if (isSearchResultsExpanded) "Collapse" else "+$remaining more") { + onSearchResultsExpandedChange(!isSearchResultsExpanded) + }.apply { + font = JBUI.Fonts.smallFont() + }) + } + } + + private fun buildSearchMatchRow(match: IntelliJSearchTool.SearchMatch): JComponent { return JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { isOpaque = false border = JBUI.Borders.empty(2, 20, 0, 0) - add(mutedLabel(detailText)) + + add(createSearchMatchLink(match)) + } + } + + private fun createSearchMatchLink(match: IntelliJSearchTool.SearchMatch): ActionLink { + return ActionLink(formatSearchLocation(match)) { + openSearchMatch(match) + }.apply { + font = JBUI.Fonts.smallFont() + foreground = Gray.x88 + toolTipText = formatSearchTooltip(match) + setExternalLinkIcon() + } + } + + private fun buildSummaryRow( + detailText: String?, + inlineActions: List + ): JComponent { + return JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { + isOpaque = false + border = JBUI.Borders.empty(2, 20, 0, 0) + + if (detailText != null) { + add(mutedLabel(detailText)) + } + + inlineActions.forEachIndexed { index, action -> + if (detailText != null || index > 0) { + add(mutedLabel(" · ")) + } + add(ActionLink("[${action.label}]") { action.action() }.apply { + font = JBUI.Fonts.smallFont() + }) + } } } @@ -315,12 +528,8 @@ private class ToolCallHeaderPanel( val descriptorActions = descriptor.actions.map { action -> InlineAction(action.name) { action.action(this@ToolCallHeaderPanel) } } - val badgeActions = descriptor.secondaryBadges - .filter { it.action != null } - .map { badge -> - InlineAction(badge.text) { badge.action?.invoke() } - } - return descriptorActions + badgeActions + + return descriptorActions } private data class InlineAction( @@ -364,6 +573,126 @@ private class ToolCallHeaderPanel( ProjectManager.getInstance().openProjects.find { it.locationHash == projectId } } ?: ProjectManager.getInstance().openProjects.firstOrNull() } + + private fun openFileLink(fileLink: FileLink) { + val project = getProject() ?: return + fileLink.action?.invoke(project) ?: openVirtualFile( + project, + resolveToolCallVirtualFile(project, fileLink.path) ?: return, + fileLink.line, + fileLink.column + ) + } + + private fun openSearchMatch(match: IntelliJSearchTool.SearchMatch) { + val project = getProject() ?: return + val virtualFile = resolveToolCallVirtualFile(project, match.file) ?: return + openVirtualFile(project, virtualFile, match.line, match.column) + } + + private fun formatSearchLocation(match: IntelliJSearchTool.SearchMatch): String { + return toProjectRelativePath(match.file) + } + + private fun formatSearchTooltip(match: IntelliJSearchTool.SearchMatch): String { + val columnSuffix = match.column?.let { ":$it" }.orEmpty() + return if (match.line != null) { + "${match.file}:${match.line}$columnSuffix" + } else { + match.file + } + } + + private fun formatSearchResultsDialogContent(result: IntelliJSearchTool.Result): String { + return buildString { + appendLine("Pattern: ${result.pattern}") + appendLine("Scope: ${result.scope}") + appendLine("Total matches: ${result.totalMatches}") + appendLine() + result.matches.forEachIndexed { index, match -> + appendLine("${index + 1}. ${formatSearchLocation(match)}") + match.context + ?.condenseWhitespace() + ?.takeIf { it.isNotBlank() } + ?.take(MAX_SEARCH_SUMMARY_LENGTH) + ?.let { appendLine(" $it") } + appendLine() + } + }.trimEnd() + } + + private fun toProjectRelativePath(path: String): String { + val projectBasePath = getProject()?.basePath + val normalizedPath = path.replace("\\", "/") + val normalizedBase = projectBasePath?.replace("\\", "/")?.trimEnd('/') + return if (!normalizedBase.isNullOrBlank() && normalizedPath.startsWith("$normalizedBase/")) { + normalizedPath.removePrefix("$normalizedBase/") + } else { + normalizedPath + } + } + + companion object { + private const val MAX_VISIBLE_SEARCH_RESULTS = 7 + private const val MAX_SEARCH_SUMMARY_LENGTH = 140 + } +} + +internal fun resolveToolCallVirtualFile(project: Project, rawPath: String): VirtualFile? { + val fs = LocalFileSystem.getInstance() + val normalizedRawPath = rawPath.replace("\\", "/") + + fs.findFileByPath(normalizedRawPath)?.let { return it } + + val resolvedPath = resolveToolCallFileSystemPath(project.basePath, rawPath) + if (resolvedPath != normalizedRawPath) { + fs.findFileByPath(resolvedPath)?.let { return it } + } + + return null +} + +internal fun resolveToolCallFileSystemPath(projectBasePath: String?, rawPath: String): String { + val normalizedRawPath = rawPath.replace("\\", "/") + if (normalizedRawPath.isBlank()) { + return normalizedRawPath + } + + return try { + val path = Path.of(rawPath) + when { + path.isAbsolute -> path.normalize().toString().replace("\\", "/") + !projectBasePath.isNullOrBlank() -> + Path.of(projectBasePath, rawPath).normalize().toString().replace("\\", "/") + + else -> normalizedRawPath + } + } catch (_: Exception) { + normalizedRawPath + } +} + +private fun openVirtualFile( + project: Project, + virtualFile: VirtualFile, + line: Int?, + column: Int? +) { + if (line != null) { + val descriptor = OpenFileDescriptor( + project, + virtualFile, + line - 1, + column ?: 0 + ) + FileEditorManager.getInstance(project).openTextEditor(descriptor, true) + } else { + OpenFileAction.openFile(virtualFile, project) + } +} + +private fun String.condenseWhitespace(): String { + return replace("\r", " ").replace("\n", " ").replace(Regex("\\s+"), " ").trim() } /** diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/renderer/SimpleChangeUtils.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/renderer/SimpleChangeUtils.kt index 01b60e70..8f58c701 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/renderer/SimpleChangeUtils.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/renderer/SimpleChangeUtils.kt @@ -1,5 +1,8 @@ package ee.carlrobert.codegpt.toolwindow.agent.ui.renderer +import com.intellij.diff.comparison.ComparisonManager +import com.intellij.diff.comparison.ComparisonPolicy +import com.intellij.openapi.progress.EmptyProgressIndicator import com.intellij.ui.JBColor import java.awt.Color import java.awt.Graphics2D @@ -20,7 +23,12 @@ data class DiffBadgeText( val summary: String ) -fun diffBadgeText(inserted: Int, deleted: Int, changed: Int, spaced: Boolean = true): DiffBadgeText { +fun diffBadgeText( + inserted: Int, + deleted: Int, + changed: Int, + spaced: Boolean = true +): DiffBadgeText { val sep = if (spaced) " " else "" return DiffBadgeText( inserted = "+$inserted$sep", @@ -41,52 +49,34 @@ fun applyStringReplacement( replaceAll: Boolean ): String { if (oldString.isEmpty()) return original - return if (replaceAll) original.replace(oldString, newString) else original.replaceFirst(oldString, newString) + return if (replaceAll) original.replace(oldString, newString) else original.replaceFirst( + oldString, + newString + ) } fun lineDiffStats(before: String, after: String): Triple { if (before == after) return Triple(0, 0, 0) - val a = before.split('\n') - val b = after.split('\n') - val lcs = longestCommonSubsequenceLength(a, b) - val deletions = (a.size - lcs).coerceAtLeast(0) - val insertions = (b.size - lcs).coerceAtLeast(0) - val changed = 0 - return Triple(insertions, deletions, changed) -} - -private fun longestCommonSubsequenceLength(a: List, b: List): Int { - val n = a.size - val m = b.size - if (n == 0 || m == 0) return 0 - - val smaller = if (n < m) a else b - val larger = if (n < m) b else a - - val smallSize = smaller.size - val largeSize = larger.size - - var prev = IntArray(smallSize + 1) - var curr = IntArray(smallSize + 1) - - for (i in 1..largeSize) { - val largerLine = larger[i - 1] - val temp = prev - prev = curr - curr = temp - - for (j in 1..smallSize) { - curr[j] = if (largerLine == smaller[j - 1]) { - prev[j - 1] + 1 - } else { - maxOf(prev[j], curr[j - 1]) - } - } + return compareLineFragments(before, after).fold(Triple(0, 0, 0)) { (ins, del, mod), fragment -> + val deletedLines = fragment.endLine1 - fragment.startLine1 + val insertedLines = fragment.endLine2 - fragment.startLine2 + val modifiedLines = minOf(deletedLines, insertedLines) + Triple( + ins + (insertedLines - modifiedLines), + del + (deletedLines - modifiedLines), + mod + modifiedLines + ) } - - return curr[smallSize] } +private fun compareLineFragments(before: String, after: String) = ComparisonManager.getInstance() + .compareLines( + before.replace("\r\n", "\n"), + after.replace("\r\n", "\n"), + ComparisonPolicy.DEFAULT, + EmptyProgressIndicator() + ) + fun drawCenteredText(g2: Graphics2D, text: String, width: Int, height: Int) { val metrics = g2.fontMetrics val x = (width - metrics.stringWidth(text)) / 2