feat: redesign write and edit tool calls with diff previews
Some checks failed
Build / Build (push) Has been cancelled
Build / Verify Plugin (push) Has been cancelled

This commit is contained in:
Carl-Robert Linnupuu 2026-05-15 11:40:17 +01:00
parent d25e5a7c4b
commit 6ae3fff7cf
17 changed files with 1503 additions and 1687 deletions

View file

@ -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<SubagentTool> = entries.filterNot { it.isWrite }
val write: List<SubagentTool> = entries.filter { it.isWrite }
fun parse(values: Collection<String>): Set<SubagentTool> {
return values.mapNotNull { fromString(it) }.toSet()
}
fun toStoredValues(tools: Collection<SubagentTool>): List<String> {
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() }
}
}
}

View file

@ -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<String, ToolCallCard>()
private val fileChangeSnapshots = ConcurrentHashMap<String, FileChangeSnapshot>()
private val pendingToolOutput = ConcurrentHashMap<String, MutableList<ToolOutputLine>>()
private val scheduledToolOutputFlushes =
Collections.newSetFromMap(ConcurrentHashMap<String, Boolean>())
@ -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()
)
}
}

View file

@ -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<String, JsonElement>? {
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 {

View file

@ -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<AgentCheckpointTurnSequencer.TurnEvent>
events: List<AgentCheckpointTurnSequencer.TurnEvent>,
fileChangeReconstructor: HistoricalFileChangeReconstructor
): Boolean {
if (events.isEmpty()) {
return false
}
val pendingById = mutableMapOf<String, ToolCallCard>()
val pendingWithoutId = ArrayDeque<ToolCallCard>()
val pendingById = mutableMapOf<String, RecoveredPendingToolCall>()
val pendingWithoutId = ArrayDeque<RecoveredPendingToolCall>()
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"
}

View file

@ -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<String, String>()
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
)
}
}

View file

@ -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<String, JsonElement>? {
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"

View file

@ -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

View file

@ -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)

View file

@ -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<ToolCallCard>() {
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)

View file

@ -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<DiffAccordionBadge>,
val bodyFactory: () -> JComponent,
val actions: List<DiffAccordionAction> = emptyList()
)
class DiffPreviewAccordionPanel(
private val model: DiffAccordionModel,
private var expanded: Boolean,
private val onExpandedChange: (Boolean) -> Unit
) : JBPanel<DiffPreviewAccordionPanel>() {
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))
}
}

View file

@ -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<UnifiedDiffPreviewPanel>() {
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 <T> getUserData(key: Key<T>): T? = data.getUserData(key)
override fun <T> putUserData(key: Key<T>, value: T?) {
data.putUserData(key, value)
}
}
}

View file

@ -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,

View file

@ -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<Badge> = 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,
)

View file

@ -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<Badge>()
val actions = mutableListOf<ToolAction>()
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<Badge>()
val actions = mutableListOf<ToolAction>()
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<Badge> {
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()
}
}

View file

@ -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<ToolCallView>() {
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<ToolCallHeaderPanel>() {
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<InlineAction>
): 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<InlineAction>
): 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()
}
/**

View file

@ -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<Int, Int, Int> {
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<String>, b: List<String>): 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