mirror of
https://github.com/carlrobertoh/ProxyAI.git
synced 2026-05-16 19:44:36 +00:00
feat: redesign write and edit tool calls with diff previews
This commit is contained in:
parent
d25e5a7c4b
commit
6ae3fff7cf
17 changed files with 1503 additions and 1687 deletions
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue