feat: proxyai agent
Some checks are pending
Build / Build (push) Waiting to run
Build / Verify Plugin (push) Blocked by required conditions

This commit is contained in:
Carl-Robert Linnupuu 2025-12-15 12:30:47 +00:00
parent b2a2e902f5
commit 8d5c7e0076
130 changed files with 14038 additions and 364 deletions

View file

@ -37,6 +37,7 @@ plugins {
id("org.jetbrains.intellij.platform")
alias(libs.plugins.changelog)
alias(libs.plugins.protobuf)
alias(libs.plugins.kotlin.serialization)
}
group = properties("pluginGroup").get()
@ -72,26 +73,37 @@ dependencies {
implementation(project(":codegpt-telemetry"))
implementation(project(":codegpt-treesitter"))
implementation(platform(libs.okhttp.bom))
implementation(platform(libs.slf4j.bom))
implementation(platform(libs.jackson.bom))
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8")
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation(platform(libs.mcp.sdk.bom))
implementation(libs.jackson.datatype.jdk8)
implementation(libs.jackson.datatype.jsr310)
implementation(libs.jackson.module.kotlin)
implementation(libs.flexmark.all) {
// vulnerable transitive dependency
exclude(group = "org.jsoup", module = "jsoup")
}
implementation(kotlin("stdlib"))
implementation(kotlin("reflect"))
implementation(libs.kotlin.stdlib)
implementation(libs.kotlin.reflect)
implementation(libs.koog.agents) {
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core")
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-jdk8")
}
implementation(libs.jsoup)
implementation(libs.commons.text)
implementation(libs.jtokkit)
implementation(libs.grpc.protobuf)
implementation(libs.grpc.stub)
implementation(libs.grpc.netty.shaded)
implementation(libs.protobuf.runtime)
implementation(platform(libs.mcp.sdk.bom))
implementation(libs.mcp.sdk)
testImplementation("junit:junit:4.13.2")
testImplementation(libs.junit4)
testImplementation(kotlin("test"))
}

View file

@ -35,6 +35,7 @@ dependencies {
}
implementation(lib("llm.client"))
implementation(lib("kotlinx.serialization.json"))
constraints {
implementation(lib("okio")) {
because("llm-client 0.7.0 uses okio 3.2.0: https://avd.aquasec.com/nvd/cve-2023-3635")

View file

@ -13,8 +13,12 @@ jsoup = "1.21.2"
jtokkit = "1.1.0"
junit = "5.13.4"
kotlin = "2.2.10"
llm-client = "0.8.56"
kotlinx-serialization = "1.6.3"
llm-client = "0.8.58"
koog = "0.6.0"
okhttp = "4.12.0"
okio = "3.15.0"
slf4j = "2.0.17"
tree-sitter = "0.24.5"
grpc = "1.73.0"
protobuf = "3.25.1"
@ -28,14 +32,24 @@ flexmark-all = { module = "com.vladsch.flexmark:flexmark-all", version.ref = "fl
gradle-intellij-plugin = { module = "org.jetbrains.intellij.platform:intellij-platform-gradle-plugin", version.ref = "gradle-intellij-plugin-version" }
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
jackson-bom = { module = "com.fasterxml.jackson:jackson-bom", version.ref = "jackson" }
jackson-datatype-jdk8 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jdk8", version.ref = "jackson" }
jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson" }
jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" }
mcp-sdk = { module = "io.modelcontextprotocol.sdk:mcp", version.ref = "mcp" }
mcp-sdk-bom = { module = "io.modelcontextprotocol.sdk:mcp-bom", version.ref = "mcp" }
jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
jtokkit = { module = "com.knuddels:jtokkit", version.ref = "jtokkit" }
junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" }
junit4 = { module = "junit:junit", version = "4.13.2" }
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
koog-agents = { module = "ai.koog:koog-agents", version.ref = "koog" }
kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
llm-client = { module = "ee.carlrobert:llm-client", version.ref = "llm-client" }
okhttp-bom = { module = "com.squareup.okhttp3:okhttp-bom", version.ref = "okhttp" }
okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
slf4j-bom = { module = "org.slf4j:slf4j-bom", version.ref = "slf4j" }
tree-sitter = { module = "io.github.bonede:tree-sitter", version.ref = "tree-sitter" }
grpc-protobuf = { module = "io.grpc:grpc-protobuf", version.ref = "grpc" }
grpc-stub = { module = "io.grpc:grpc-stub", version.ref = "grpc" }
@ -46,4 +60,5 @@ protobuf-runtime = { module = "com.google.protobuf:protobuf-java", version.ref =
[plugins]
changelog = { id = "org.jetbrains.changelog", version.ref = "changelog" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
protobuf = { id = "com.google.protobuf", version.ref = "protobuf-plugin" }

View file

@ -128,8 +128,8 @@ public class ToolwindowChatCompletionRequestHandler {
}
} catch (Throwable ex) {
handleCallException(ex);
throw ex;
}
return null;
}
private void handleCallException(Throwable ex) {

View file

@ -1,6 +1,7 @@
package ee.carlrobert.codegpt.settings.service;
public enum FeatureType {
AGENT,
CHAT,
CODE_COMPLETION,
AUTO_APPLY,

View file

@ -14,6 +14,7 @@ import com.intellij.ui.JBColor;
import com.intellij.util.concurrency.AppExecutorUtil;
import com.intellij.util.ui.JBUI;
import ee.carlrobert.codegpt.CodeGPTKeys;
import ee.carlrobert.codegpt.CodeGPTBundle;
import ee.carlrobert.codegpt.EncodingManager;
import ee.carlrobert.codegpt.ReferencedFile;
import ee.carlrobert.codegpt.actions.ActionType;
@ -38,6 +39,7 @@ import ee.carlrobert.codegpt.toolwindow.chat.editor.actions.CopyAction;
import ee.carlrobert.codegpt.toolwindow.chat.structure.data.PsiStructureRepository;
import ee.carlrobert.codegpt.toolwindow.chat.structure.data.PsiStructureState;
import ee.carlrobert.codegpt.toolwindow.chat.ui.ChatMessageResponseBody;
import ee.carlrobert.codegpt.toolwindow.chat.editor.header.LoadingPanel;
import ee.carlrobert.codegpt.toolwindow.chat.ui.ChatToolWindowScrollablePanel;
import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.TotalTokensDetails;
import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.TotalTokensPanel;
@ -57,10 +59,10 @@ import ee.carlrobert.codegpt.ui.textarea.header.tag.TagDetails;
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager;
import ee.carlrobert.codegpt.util.EditorUtil;
import ee.carlrobert.codegpt.util.coroutines.CoroutineDispatchers;
import ee.carlrobert.llm.client.openai.completion.ErrorDetails;
import git4idea.GitCommit;
import java.awt.BorderLayout;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
@ -93,6 +95,8 @@ public class ChatToolWindowTabPanel implements Disposable {
private final TagManager tagManager;
private final JPanel mcpApprovalContainer;
private @Nullable ToolwindowChatCompletionRequestHandler requestHandler;
private LoadingPanel inputLoadingPanel;
private final JPanel queuedMessageContainer;
public ChatToolWindowTabPanel(@NotNull Project project, @NotNull Conversation conversation) {
this.project = project;
@ -130,6 +134,11 @@ public class ChatToolWindowTabPanel implements Disposable {
mcpApprovalContainer.setBorder(JBUI.Borders.empty());
mcpApprovalContainer.setOpaque(false);
queuedMessageContainer = new JPanel();
queuedMessageContainer.setLayout(new BoxLayout(queuedMessageContainer, BoxLayout.Y_AXIS));
queuedMessageContainer.setBorder(JBUI.Borders.empty());
queuedMessageContainer.setOpaque(false);
rootPanel = createRootPanel();
if (conversation.getMessages().isEmpty()) {
@ -414,7 +423,7 @@ public class ChatToolWindowTabPanel implements Disposable {
false,
message.isWebSearchIncluded(),
fileContextIncluded || message.getDocumentationDetails() != null,
true,
false,
this));
return panel;
}
@ -481,6 +490,7 @@ public class ChatToolWindowTabPanel implements Disposable {
}
userInputPanel.setSubmitEnabled(false);
userInputPanel.setStopEnabled(true);
userMessagePanel.disableActions(List.of("RELOAD", "DELETE"));
responseMessagePanel.disableActions(List.of("COPY"));
@ -493,6 +503,29 @@ public class ChatToolWindowTabPanel implements Disposable {
totalTokensPanel,
userInputPanel) {
@Override
public void handleRequestOpen() {
super.handleRequestOpen();
showInputLoading(CodeGPTBundle.get("toolwindow.chat.loading"));
}
@Override
public void handleCompleted(String fullMessage, ChatCompletionParameters callParameters) {
try {
super.handleCompleted(fullMessage, callParameters);
} finally {
hideInputLoading();
}
}
@Override
public void handleError(ErrorDetails error, Throwable ex) {
try {
super.handleError(error, ex);
} finally {
hideInputLoading();
}
}
@Override
public void handleTokensExceededPolicyAccepted() {
call(callParameters, responseMessagePanel, userMessagePanel);
}
@ -500,6 +533,7 @@ public class ChatToolWindowTabPanel implements Disposable {
this);
requestHandler.setResponseMessagePanel(responseMessagePanel);
showInputLoading(CodeGPTBundle.get("toolwindow.chat.loading"));
requestHandler.call(callParameters);
}
@ -520,7 +554,7 @@ public class ChatToolWindowTabPanel implements Disposable {
.filter(TagDetails::getSelected)
.collect(Collectors.toList());
var messageBuilder = new MessageBuilder(project, text).withInlays(appliedTags);
var messageBuilder = new MessageBuilder(project, text).withTags(appliedTags);
List<ReferencedFile> referencedFiles = getReferencedFiles(appliedTags);
if (!referencedFiles.isEmpty()) {
@ -547,27 +581,15 @@ public class ChatToolWindowTabPanel implements Disposable {
private Unit handleCancel() {
if (requestHandler != null) {
requestHandler.cancel();
ApplicationManager.getApplication().invokeLater(() -> {
mcpApprovalContainer.removeAll();
updateUserPromptPanel();
hideInputLoading();
userInputPanel.setSubmitEnabled(true);
CompletionProgressNotifier.update(project, false);
requestHandler = null;
});
}
ApplicationManager.getApplication().invokeLater(() -> {
mcpApprovalContainer.removeAll();
updateUserPromptPanel();
var lastComponent = toolWindowScrollablePanel.getLastComponent();
if (lastComponent != null) {
Arrays.stream(lastComponent.getComponents())
.filter(ResponseMessagePanel.class::isInstance)
.findFirst()
.ifPresent(panel -> {
ResponseMessagePanel responsePanel = (ResponseMessagePanel) panel;
var responseComponent = responsePanel.getResponseComponent();
if (responseComponent != null) {
responseComponent.stopLoading();
}
});
}
userInputPanel.setSubmitEnabled(true);
CompletionProgressNotifier.update(project, false);
});
requestHandler = null;
return Unit.INSTANCE;
}
@ -581,6 +603,16 @@ public class ChatToolWindowTabPanel implements Disposable {
topContainer.setLayout(new BoxLayout(topContainer, BoxLayout.Y_AXIS));
topContainer.setOpaque(false);
inputLoadingPanel = new LoadingPanel(CodeGPTBundle.get("toolwindow.chat.loading"), null, null);
inputLoadingPanel.setAlignmentX(JComponent.LEFT_ALIGNMENT);
inputLoadingPanel.setVisible(false);
topContainer.add(inputLoadingPanel);
if (queuedMessageContainer.getComponentCount() > 0) {
queuedMessageContainer.setAlignmentX(JComponent.LEFT_ALIGNMENT);
topContainer.add(queuedMessageContainer);
}
if (mcpApprovalContainer.getComponentCount() > 0) {
mcpApprovalContainer.setAlignmentX(JComponent.LEFT_ALIGNMENT);
topContainer.add(mcpApprovalContainer);
@ -596,6 +628,23 @@ public class ChatToolWindowTabPanel implements Disposable {
return panel;
}
private void showInputLoading(String text) {
if (inputLoadingPanel != null) {
inputLoadingPanel.setText(text);
inputLoadingPanel.setVisible(true);
inputLoadingPanel.revalidate();
inputLoadingPanel.repaint();
}
}
private void hideInputLoading() {
if (inputLoadingPanel != null) {
inputLoadingPanel.setVisible(false);
inputLoadingPanel.revalidate();
inputLoadingPanel.repaint();
}
}
private JComponent getLandingView() {
return new ChatToolWindowLandingPanel((action, locationOnScreen) -> {
var editor = EditorUtil.getSelectedEditor(project);

View file

@ -35,7 +35,7 @@ abstract class ToolWindowCompletionResponseEventListener implements
private final UserMessagePanel userMessagePanel;
private ChatMessageResponseBody responseContainer;
private final TotalTokensPanel totalTokensPanel;
private final UserInputPanel textArea;
private final UserInputPanel userInputPanel;
private final Timer updateTimer = new Timer(UPDATE_INTERVAL_MS, e -> processBufferedMessages());
private final ConcurrentLinkedQueue<String> messageBuffer = new ConcurrentLinkedQueue<>();
@ -47,13 +47,13 @@ abstract class ToolWindowCompletionResponseEventListener implements
UserMessagePanel userMessagePanel,
ResponseMessagePanel responsePanel,
TotalTokensPanel totalTokensPanel,
UserInputPanel textArea) {
UserInputPanel userInputPanel) {
this.encodingManager = EncodingManager.getInstance();
this.project = project;
this.userMessagePanel = userMessagePanel;
this.responsePanel = responsePanel;
this.totalTokensPanel = totalTokensPanel;
this.textArea = textArea;
this.userInputPanel = userInputPanel;
}
public abstract void handleTokensExceededPolicyAccepted();
@ -146,7 +146,7 @@ abstract class ToolWindowCompletionResponseEventListener implements
container.withResponse(fullMessage);
}
}
totalTokensPanel.updateUserPromptTokens(textArea.getText());
totalTokensPanel.updateUserPromptTokens(userInputPanel.getText());
totalTokensPanel.updateConversationTokens(callParameters.getConversation());
} finally {
stopStreaming(responseContainer);
@ -184,11 +184,11 @@ abstract class ToolWindowCompletionResponseEventListener implements
private void stopStreaming(ChatMessageResponseBody responseContainer) {
stopped = true;
textArea.setSubmitEnabled(true);
userInputPanel.setSubmitEnabled(true);
userInputPanel.setStopEnabled(false);
userMessagePanel.enableAllActions(true);
responsePanel.enableAllActions(true);
if (responseContainer != null) {
responseContainer.stopLoading();
responseContainer.hideCaret();
responseContainer.finishThinking();
}

View file

@ -21,11 +21,9 @@ import com.intellij.openapi.ui.VerticalFlowLayout;
import com.intellij.openapi.util.io.FileUtil;
import com.intellij.openapi.vfs.LocalFileSystem;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.ui.AnimatedIcon;
import com.intellij.ui.PopupHandler;
import com.intellij.ui.components.JBLabel;
import com.intellij.util.ui.JBUI;
import com.intellij.util.ui.components.BorderLayoutPanel;
import ee.carlrobert.codegpt.CodeGPTBundle;
import ee.carlrobert.codegpt.Icons;
import ee.carlrobert.codegpt.actions.ActionType;
@ -63,8 +61,8 @@ import java.awt.BorderLayout;
import java.util.Objects;
import java.util.stream.Stream;
import javax.swing.DefaultListModel;
import javax.swing.JComponent;
import javax.swing.JEditorPane;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JTextPane;
import javax.swing.event.HyperlinkListener;
@ -83,7 +81,6 @@ public class ChatMessageResponseBody extends JPanel {
private final DefaultListModel<WebSearchEventDetails> webpageListModel = new DefaultListModel<>();
private final WebpageList webpageList = new WebpageList(webpageListModel);
private final ResponseBodyProgressPanel progressPanel = new ResponseBodyProgressPanel();
private final JPanel loadingLabel = createLoadingPanel();
private final JPanel contentPanel =
new JPanel(new VerticalFlowLayout(VerticalFlowLayout.TOP, 0, 4, true, false));
@ -91,15 +88,6 @@ public class ChatMessageResponseBody extends JPanel {
private JEditorPane currentlyProcessedTextPane;
private JPanel webpageListPanel;
private JPanel createLoadingPanel() {
return new BorderLayoutPanel()
.addToLeft(new JBLabel(
CodeGPTBundle.get("toolwindow.chat.loading"),
new AnimatedIcon.Default(),
JLabel.LEFT))
.withBorder(JBUI.Borders.empty(4, 0));
}
public ChatMessageResponseBody(Project project, boolean compact, Disposable parentDisposable) {
this(project, false, compact, false, false, false, parentDisposable);
}
@ -122,10 +110,7 @@ public class ChatMessageResponseBody extends JPanel {
setOpaque(false);
contentPanel.setOpaque(false);
add(contentPanel, BorderLayout.NORTH);
loadingLabel.setVisible(withLoading);
add(loadingLabel, BorderLayout.SOUTH);
add(contentPanel, BorderLayout.CENTER);
if (ModelSelectionService.getInstance().getServiceForFeature(FeatureType.CHAT)
== ServiceType.PROXYAI) {
@ -153,15 +138,11 @@ public class ChatMessageResponseBody extends JPanel {
return this;
}
public void stopLoading() {
loadingLabel.setVisible(false);
}
public void addToolStatusPanel(JPanel panel) {
public void addToolStatusPanel(JComponent component) {
currentlyProcessedTextPane = null;
currentlyProcessedEditorPanel = null;
streamOutputParser.clear();
contentPanel.add(panel);
contentPanel.add(component);
}
public void updateMessage(String partialMessage) {
@ -199,6 +180,11 @@ public class ChatMessageResponseBody extends JPanel {
});
}
public void displayCreditsExhausted() {
String message = "You’ve used all your ProxyAI credits. <a href=\"https://tryproxy.io/dashboard/billing\">Get more credits</a> to continue.";
displayErrorMessage(message, UIUtil::handleHyperlinkClicked);
}
public void displayError(String message) {
displayErrorMessage(message, null);
}
@ -244,7 +230,6 @@ public class ChatMessageResponseBody extends JPanel {
public void clear() {
contentPanel.removeAll();
streamOutputParser.clear();
loadingLabel.setVisible(false);
// Reset for the next incoming message
prepareProcessingText(true);
@ -257,9 +242,6 @@ public class ChatMessageResponseBody extends JPanel {
private void displayErrorMessage(String message, HyperlinkListener hyperlinkListener) {
ApplicationManager.getApplication().invokeLater(() -> {
if (loadingLabel.isVisible()) {
loadingLabel.setVisible(false);
}
if (webpageListPanel != null) {
webpageListPanel.setVisible(false);
}

View file

@ -4,12 +4,12 @@ import static ee.carlrobert.codegpt.CodeGPTKeys.CODEGPT_USER_DETAILS;
import static ee.carlrobert.codegpt.settings.service.ServiceType.ANTHROPIC;
import static ee.carlrobert.codegpt.settings.service.ServiceType.CUSTOM_OPENAI;
import static ee.carlrobert.codegpt.settings.service.ServiceType.GOOGLE;
import static ee.carlrobert.codegpt.settings.service.ServiceType.INCEPTION;
import static ee.carlrobert.codegpt.settings.service.ServiceType.LLAMA_CPP;
import static ee.carlrobert.codegpt.settings.service.ServiceType.MISTRAL;
import static ee.carlrobert.codegpt.settings.service.ServiceType.OLLAMA;
import static ee.carlrobert.codegpt.settings.service.ServiceType.OPENAI;
import static ee.carlrobert.codegpt.settings.service.ServiceType.PROXYAI;
import static ee.carlrobert.codegpt.settings.service.ServiceType.INCEPTION;
import com.intellij.icons.AllIcons;
import com.intellij.openapi.actionSystem.ActionUpdateThread;
@ -150,8 +150,15 @@ public class ModelComboBoxAction extends ComboBoxAction {
}
private AnAction[] getCodeGPTModelActions(Project project, Presentation presentation) {
var userDetails = CODEGPT_USER_DETAILS.get(project);
var registry = ModelRegistry.getInstance();
if (featureType == FeatureType.AGENT) {
return registry.getAllModelsForFeature(featureType).stream()
.filter(model -> model.getProvider() == PROXYAI)
.map(model -> createCodeGPTModelAction(model, presentation))
.toArray(AnAction[]::new);
}
var userDetails = CODEGPT_USER_DETAILS.get(project);
return registry.getProxyAIChatModelsForPricingPlan(
userDetails == null ? null : userDetails.getPricingPlan()).stream()
.map(model -> createCodeGPTModelAction(model, presentation))
@ -175,31 +182,28 @@ public class ModelComboBoxAction extends ComboBoxAction {
if (availableProviders.contains(ANTHROPIC)) {
var anthropicGroup = DefaultActionGroup.createPopupGroup(() -> "Anthropic");
anthropicGroup.getTemplatePresentation().setIcon(Icons.Anthropic);
anthropicGroup.add(
createAnthropicModelAction(ModelRegistry.CLAUDE_OPUS_4_20250514, presentation));
anthropicGroup.add(
createAnthropicModelAction(ModelRegistry.CLAUDE_SONNET_4_20250514, presentation));
ModelRegistry.getInstance().getAgentModels(ANTHROPIC).forEach(item ->
anthropicGroup.add(createModelAction(
ANTHROPIC,
item.getModel().getId(),
Icons.Anthropic,
presentation,
() -> ApplicationManager.getApplication().getService(ModelSettings.class)
.setModel(featureType, item.getModel().getId(), ANTHROPIC))));
actionGroup.add(anthropicGroup);
}
if (availableProviders.contains(OPENAI)) {
var openaiGroup = DefaultActionGroup.createPopupGroup(() -> "OpenAI");
openaiGroup.getTemplatePresentation().setIcon(Icons.OpenAI);
List.of(
OpenAIChatCompletionModel.GPT_5,
OpenAIChatCompletionModel.GPT_5_MINI,
OpenAIChatCompletionModel.O_4_MINI,
OpenAIChatCompletionModel.O_3,
OpenAIChatCompletionModel.O_3_MINI,
OpenAIChatCompletionModel.O_1_PREVIEW,
OpenAIChatCompletionModel.O_1_MINI,
OpenAIChatCompletionModel.GPT_4_1,
OpenAIChatCompletionModel.GPT_4_1_MINI,
OpenAIChatCompletionModel.GPT_4_1_NANO,
OpenAIChatCompletionModel.GPT_4_O,
OpenAIChatCompletionModel.GPT_4_O_MINI,
OpenAIChatCompletionModel.GPT_4_0125_128k)
.forEach(model -> openaiGroup.add(createOpenAIModelAction(model, presentation)));
ModelRegistry.getInstance().getAgentModels(OPENAI).forEach(item ->
openaiGroup.add(createModelAction(
OPENAI,
item.getModel().getId(),
Icons.OpenAI,
presentation,
() -> ApplicationManager.getApplication().getService(ModelSettings.class)
.setModel(featureType, item.getModel().getId(), OPENAI))));
actionGroup.add(openaiGroup);
}
@ -220,26 +224,32 @@ public class ModelComboBoxAction extends ComboBoxAction {
if (availableProviders.contains(GOOGLE)) {
var googleGroup = DefaultActionGroup.createPopupGroup(() -> "Google");
googleGroup.getTemplatePresentation().setIcon(Icons.Google);
List.of(
GoogleModel.GEMINI_2_5_PRO_PREVIEW,
GoogleModel.GEMINI_2_5_FLASH_PREVIEW,
GoogleModel.GEMINI_2_5_PRO,
GoogleModel.GEMINI_2_0_PRO_EXP,
GoogleModel.GEMINI_2_0_FLASH_THINKING_EXP,
GoogleModel.GEMINI_2_0_FLASH,
GoogleModel.GEMINI_1_5_PRO)
.forEach(model -> googleGroup.add(createGoogleModelAction(model, presentation)));
ModelRegistry.getInstance().getAgentModels(GOOGLE).forEach(item ->
googleGroup.add(createModelAction(
GOOGLE,
item.getModel().getId(),
Icons.Google,
presentation,
() -> {
var application = ApplicationManager.getApplication();
application.getService(ModelSettings.class)
.setModel(featureType, item.getModel().getId(), GOOGLE);
})));
actionGroup.add(googleGroup);
}
if (availableProviders.contains(MISTRAL)) {
var mistralGroup = DefaultActionGroup.createPopupGroup(() -> "Mistral");
mistralGroup.getTemplatePresentation().setIcon(Icons.Mistral);
List.of(
ModelRegistry.DEVSTRAL_MEDIUM_2507,
ModelRegistry.MISTRAL_LARGE_2411,
ModelRegistry.CODESTRAL_LATEST)
.forEach(model -> mistralGroup.add(createMistralModelAction(model, presentation)));
ModelRegistry.getInstance().getAgentModels(MISTRAL).forEach(item ->
mistralGroup.add(createModelAction(
MISTRAL,
item.getModel().getId(),
Icons.Mistral,
presentation,
() -> ApplicationManager.getApplication().getService(ModelSettings.class)
.setModel(featureType, item.getModel().getId(), MISTRAL))));
actionGroup.add(mistralGroup);
}
@ -310,7 +320,7 @@ public class ModelComboBoxAction extends ComboBoxAction {
.filter(it -> modelCode != null && it.getModel().equals(modelCode))
.findFirst();
templatePresentation.setIcon(
proxyAIModel.map(ModelSelection::getIcon).orElse(Icons.CodeGPTModel));
proxyAIModel.map(ModelSelection::getIcon).orElse(Icons.DefaultSmall));
templatePresentation.setText(
proxyAIModel.map(ModelSelection::getDisplayName).orElse("Unknown"));
break;
@ -321,7 +331,10 @@ public class ModelComboBoxAction extends ComboBoxAction {
break;
case CUSTOM_OPENAI:
ModelRegistry.getInstance().getCustomOpenAIModels().stream()
.filter(it -> it.getModel().equals(modelCode))
.filter(it -> {
var id = it.getId();
return id != null && id.equals(modelCode);
})
.findFirst()
.ifPresent(selection -> {
templatePresentation.setIcon(Icons.OpenAI);
@ -354,7 +367,8 @@ public class ModelComboBoxAction extends ComboBoxAction {
break;
case INCEPTION:
templatePresentation.setIcon(Icons.Inception);
var inceptionModelName = ModelRegistry.getInstance().getModelDisplayName(INCEPTION, modelCode);
var inceptionModelName = ModelRegistry.getInstance()
.getModelDisplayName(INCEPTION, modelCode);
templatePresentation.setText(inceptionModelName);
break;
default:
@ -425,7 +439,7 @@ public class ModelComboBoxAction extends ComboBoxAction {
var selected = model.getDisplayName().equals(comboBoxPresentation.getText());
var locked = shouldLockModel(model);
return new CodeGPTModelsListPopupAction(model.getDisplayName(), model.getModel(),
model.getIcon() != null ? model.getIcon() : Icons.CodeGPTModel,
model.getIcon() != null ? model.getIcon() : Icons.DefaultSmall,
model.getPricingPlan() != null ? model.getPricingPlan() : PricingPlan.ANONYMOUS,
locked, selected, () -> {
var application = ApplicationManager.getApplication();
@ -438,6 +452,10 @@ public class ModelComboBoxAction extends ComboBoxAction {
}
private boolean shouldLockModel(ModelSelection model) {
if (featureType == FeatureType.AGENT) {
return false;
}
var userDetails = CODEGPT_USER_DETAILS.get(project);
if (userDetails != null) {
if (userDetails.getPricingPlan() == PricingPlan.INDIVIDUAL) {
@ -478,15 +496,20 @@ public class ModelComboBoxAction extends ComboBoxAction {
}
private AnAction createCustomOpenAIModelAction(
CustomServiceSettingsState model,
CustomServiceSettingsState state,
Presentation comboBoxPresentation) {
var model = state.getChatCompletionSettings().getBody().get("model");
var displayName =
state.getName()
+ ((model instanceof String && !((String) model).isEmpty()) ? " (" + model + ")" : "");
return createModelAction(
CUSTOM_OPENAI,
model.getName(),
displayName,
Icons.OpenAI,
comboBoxPresentation,
() -> ApplicationManager.getApplication().getService(ModelSettings.class)
.setModel(featureType, model.getId(), CUSTOM_OPENAI));
.setModel(featureType, state.getId(), CUSTOM_OPENAI));
}
private AnAction createGoogleModelAction(GoogleModel model, Presentation comboBoxPresentation) {

View file

@ -0,0 +1,35 @@
package ee.carlrobert.codegpt.agent
import ai.koog.agents.core.feature.handler.agent.AgentCompletedContext
import ai.koog.prompt.executor.clients.LLMClientException
import ee.carlrobert.codegpt.agent.tools.AskUserQuestionTool
import ee.carlrobert.codegpt.conversations.message.TokenUsage
import ee.carlrobert.codegpt.settings.service.ServiceType
import ee.carlrobert.codegpt.toolwindow.agent.AgentCreditsEvent
import ee.carlrobert.codegpt.toolwindow.agent.ui.approval.ToolApprovalRequest
interface AgentEvents {
fun onTextReceived(text: String) {}
fun onAgentCompleted(ctx: AgentCompletedContext) {}
fun onToolStarting(id: String, toolName: String, args: Any?) {}
fun onToolCompleted(id: String?, toolName: String, result: Any?) {}
fun onSubAgentToolStarting(parentId: String, toolName: String, args: Any?): String? = null
fun onSubAgentToolCompleted(
parentId: String,
childId: String?,
toolName: String,
result: Any?
) {
}
fun onQueuedMessagesResolved()
fun onTokenUsageAvailable(tokenUsage: Long) {}
fun onTokenUsageUpdated(tokenUsage: TokenUsage) {}
fun onCreditsAvailable(event: AgentCreditsEvent) {}
fun onClientException(provider: ServiceType, ex: LLMClientException) {}
suspend fun approveToolCall(request: ToolApprovalRequest): Boolean = false
suspend fun askUserQuestions(model: AskUserQuestionTool.AskUserQuestionsModel): Map<String, String> =
emptyMap()
}

View file

@ -0,0 +1,456 @@
package ee.carlrobert.codegpt.agent
import ai.koog.agents.core.agent.AIAgent
import ai.koog.agents.core.agent.GraphAIAgent
import ai.koog.agents.core.agent.config.AIAgentConfig
import ai.koog.agents.core.dsl.builder.forwardTo
import ai.koog.agents.core.dsl.builder.strategy
import ai.koog.agents.core.dsl.extension.*
import ai.koog.agents.core.feature.handler.tool.ToolCallCompletedContext
import ai.koog.agents.core.feature.handler.tool.ToolCallStartingContext
import ai.koog.agents.core.tools.ToolRegistry
import ai.koog.agents.ext.tool.ExitTool
import ai.koog.agents.ext.tool.shell.ShellCommandConfirmation
import ai.koog.agents.features.eventHandler.feature.handleEvents
import ai.koog.prompt.dsl.prompt
import ai.koog.prompt.executor.clients.LLMClient
import ai.koog.prompt.executor.clients.anthropic.AnthropicLLMClient
import ai.koog.prompt.executor.clients.google.GoogleLLMClient
import ai.koog.prompt.executor.clients.mistralai.MistralAILLMClient
import ai.koog.prompt.executor.clients.openai.OpenAILLMClient
import ai.koog.prompt.executor.clients.retry.RetryConfig
import ai.koog.prompt.executor.clients.retry.RetryingLLMClient
import ai.koog.prompt.executor.llms.SingleLLMPromptExecutor
import ai.koog.prompt.executor.model.PromptExecutor
import ai.koog.prompt.executor.ollama.client.OllamaClient
import ai.koog.prompt.message.Message
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import ee.carlrobert.codegpt.agent.clients.CustomOpenAILLMClient
import ee.carlrobert.codegpt.agent.clients.ProxyAILLMClient
import ee.carlrobert.codegpt.agent.credits.extractCreditsSnapshot
import ee.carlrobert.codegpt.agent.tools.*
import ee.carlrobert.codegpt.credentials.CredentialsStore.CredentialKey
import ee.carlrobert.codegpt.credentials.CredentialsStore.getCredential
import ee.carlrobert.codegpt.settings.ProxyAISettingsService
import ee.carlrobert.codegpt.settings.service.FeatureType
import ee.carlrobert.codegpt.settings.service.ModelSelectionService
import ee.carlrobert.codegpt.settings.service.ServiceType
import ee.carlrobert.codegpt.settings.service.custom.CustomServicesSettings
import ee.carlrobert.codegpt.toolwindow.agent.AgentCreditsEvent
import java.time.LocalDate
import kotlin.time.Duration.Companion.seconds
object AgentFactory {
fun createAgent(
agentType: AgentType,
provider: ServiceType,
project: Project,
sessionId: String,
approveToolCall: (suspend (name: String, details: String) -> Boolean)? = null,
onAgentToolCallStarting: ((eventContext: ToolCallStartingContext) -> Unit)? = null,
onAgentToolCallCompleted: ((eventContext: ToolCallCompletedContext) -> Unit)? = null,
extraBehavior: String? = null,
toolOverrides: Set<SubagentTool>? = null,
onCreditsAvailable: ((AgentCreditsEvent) -> Unit)? = null
): AIAgent<String, String> {
val installHandler = buildUsageAwareInstallHandler(
provider,
sessionId,
onAgentToolCallStarting,
onAgentToolCallCompleted,
onCreditsAvailable
)
return when (agentType) {
AgentType.GENERAL_PURPOSE -> createGeneralPurposeAgent(
provider,
project,
sessionId,
approveToolCall,
installHandler,
extraBehavior,
toolOverrides
)
AgentType.EXPLORE -> createExploreAgent(
provider,
project,
sessionId,
approveToolCall,
installHandler,
extraBehavior,
toolOverrides
)
}
}
fun createManualAgent(
provider: ServiceType,
project: Project,
sessionId: String,
title: String,
behavior: String,
toolNames: Set<String>,
approveToolCall: (suspend (name: String, details: String) -> Boolean)? = null,
onAgentToolCallStarting: ((eventContext: ToolCallStartingContext) -> Unit)? = null,
onAgentToolCallCompleted: ((eventContext: ToolCallCompletedContext) -> Unit)? = null,
onCreditsAvailable: ((AgentCreditsEvent) -> Unit)? = null,
): AIAgent<String, String> {
val installHandler = buildUsageAwareInstallHandler(
provider,
sessionId,
onAgentToolCallStarting,
onAgentToolCallCompleted,
onCreditsAvailable
)
val selected = SubagentTool.parse(toolNames)
val registry = createToolRegistry(
project,
sessionId,
approveToolCall,
selected,
approvalBashHandler(approveToolCall)
)
return AIAgent.Companion(
promptExecutor = createExecutor(provider),
strategy = singleRunWithParallelAbility(),
agentConfig = AIAgentConfig(
prompt = prompt("manual-agent") {
system(
"""
You are a user-defined subagent named "$title".
${getEnvironmentInfo(project)}${behaviorSection(behavior)}
""".trimIndent()
)
},
model = service<ModelSelectionService>().getAgentModel(),
maxAgentIterations = 100
),
toolRegistry = registry,
installFeatures = installHandler
)
}
fun createExecutor(provider: ServiceType): PromptExecutor {
return when (provider) {
ServiceType.OPENAI -> {
val apiKey = getCredential(CredentialKey.OpenaiApiKey) ?: ""
createRetryingExecutor(OpenAILLMClient(apiKey))
}
ServiceType.ANTHROPIC -> {
val apiKey = getCredential(CredentialKey.AnthropicApiKey) ?: ""
createRetryingExecutor(AnthropicLLMClient(apiKey))
}
ServiceType.GOOGLE -> {
val apiKey = getCredential(CredentialKey.GoogleApiKey) ?: ""
createRetryingExecutor(GoogleLLMClient(apiKey))
}
ServiceType.OLLAMA -> {
createRetryingExecutor(OllamaClient())
}
ServiceType.MISTRAL -> {
val apiKey = getCredential(CredentialKey.MistralApiKey) ?: ""
createRetryingExecutor(MistralAILLMClient(apiKey))
}
ServiceType.CUSTOM_OPENAI -> {
val state = service<CustomServicesSettings>()
.customServiceStateForFeatureType(FeatureType.AGENT)
val serviceId = state.id
?: throw IllegalArgumentException("No custom service configured")
val apiKey = getCredential(CredentialKey.CustomServiceApiKeyById(serviceId))
?: throw IllegalArgumentException("No API key found for custom service: $serviceId")
createRetryingExecutor(
CustomOpenAILLMClient.fromSettingsState(apiKey, state.chatCompletionSettings)
)
}
ServiceType.PROXYAI -> {
val apiKey = getCredential(CredentialKey.CodeGptApiKey) ?: ""
createRetryingExecutor(ProxyAILLMClient(apiKey))
}
else -> throw UnsupportedOperationException("Provider not supported: $provider")
}
}
private fun createRetryingExecutor(client: LLMClient): PromptExecutor {
val retryingClient = RetryingLLMClient(
client,
RetryConfig(
maxAttempts = 3,
initialDelay = 1.seconds,
maxDelay = 20.seconds,
backoffMultiplier = 2.0,
jitterFactor = 0.2
)
)
return SingleLLMPromptExecutor(retryingClient)
}
private fun createGeneralPurposeAgent(
provider: ServiceType,
project: Project,
sessionId: String,
approveToolCall: (suspend (name: String, details: String) -> Boolean)? = null,
installFeatures: GraphAIAgent.FeatureContext.() -> Unit = {},
extraBehavior: String? = null,
toolOverrides: Set<SubagentTool>? = null
): AIAgent<String, String> {
val selectedTools = toolOverrides ?: SubagentTool.entries.toSet()
return AIAgent.Companion(
promptExecutor = createExecutor(provider),
strategy = singleRunWithParallelAbility(),
agentConfig = AIAgentConfig(
prompt = prompt("general-agent") {
system(
"""
You are a general-purpose coding subagent operating inside a JetBrains IDE. You are invoked by a main agent to execute concrete steps. Work efficiently, call tools decisively, and keep messages concise and action-oriented.
${getEnvironmentInfo(project)}${behaviorSection(extraBehavior)}
# Tone and Style
- Keep responses short, task-focused, and free of fluff.
- Use GitHub-flavored Markdown for structure when helpful.
# Tool Usage Policy
- You may call multiple tools in a single turn. If calls are independent, run them in parallel. If calls depend on earlier results, run sequentially.
- Prefer specialized tools over bash: use Read for file content, Grep for search, Edit/Write for code changes. Use Bash only for true shell operations.
- Never use Bash to "echo" thoughts or communicate. All communication happens in your response text.
- Never guess parameters. If a required argument is unknown, first gather it via an appropriate tool.
- Respect approval gates: Edit/Write operations require confirmation hooks and may be denied.
- Use WebSearch for library/framework questions; use ResolveLibraryId/GetLibraryDocs for dependency docs.
- Use TodoWrite to outline and track subtasks when a request spans multiple steps.
# Tool Routing Rules
- If the user asks about how to use a library, best practices, API semantics, configuration, or conventions: prefer ResolveLibraryId followed by GetLibraryDocs to gather authoritative guidance before proposing changes.
- Use IntelliJSearch/Grep to locate symbols and examples before editing files.
# Collaboration as Subagent
- Assume a parent agent orchestrates overall strategy. Focus on execution quality and clear, minimal output.
- Do not create further planning layers; execute tasks and report concrete results or blockers.
# Good Practices
- Be precise and cite evidence (paths/lines) for findings and changes.
- Batch independent reads/greps/web queries in parallel for speed.
- Validate at boundaries (user input, external APIs); trust internal code guarantees.
""".trimIndent()
)
},
model = service<ModelSelectionService>().getAgentModel(),
maxAgentIterations = 100
),
toolRegistry = createToolRegistry(
project,
sessionId,
approveToolCall,
selectedTools,
approvalBashHandler(approveToolCall)
),
installFeatures = installFeatures
)
}
private fun createExploreAgent(
provider: ServiceType,
project: Project,
sessionId: String,
approveToolCall: (suspend (name: String, details: String) -> Boolean)? = null,
installFeatures: GraphAIAgent.FeatureContext.() -> Unit = {},
extraBehavior: String? = null,
toolOverrides: Set<SubagentTool>? = null
): AIAgent<String, String> {
val selectedTools = toolOverrides ?: (SubagentTool.readOnly + SubagentTool.BASH).toSet()
return AIAgent.Companion(
promptExecutor = createExecutor(provider),
strategy = singleRunWithParallelAbility(),
agentConfig = AIAgentConfig(
prompt = prompt("explore-agent") {
system(
"""
You are an Explore subagent for codebase understanding. You are invoked by a main agent to gather context and answer questions by reading and searching the project. You do NOT modify files.
${getEnvironmentInfo(project)}${behaviorSection(extraBehavior)}
# Tool Usage Policy (Read-only)
- Use Read to examine files; Grep to search patterns; WebSearch for external context; ResolveLibraryId/GetLibraryDocs for dependencies; TodoWrite to record findings.
- You may call multiple tools in parallel when independent (e.g., read multiple files, run several greps at once).
- Do not use Edit or Write. Avoid destructive Bash. Use Bash only for safe, read-only operations if strictly necessary.
- Never guess parameters; first gather precise paths/patterns.
# Tool Routing Rules
- For library usage and best practices: ResolveLibraryId then GetLibraryDocs to retrieve relevant docs prior to summarizing advice.
- Use IntelliJSearch/Grep for code navigation and symbol discovery.
# Exploration Workflow
- Initial scan: read key config/entry files in parallel; map structure.
- Deep dive: run targeted greps and reads in parallel for related components.
- Summarize: synthesize findings with concrete references; use TodoWrite to capture a brief outline of insights.
# Good Practices
- Prefer breadth-first sampling before deep dives.
- Keep results actionable, with clear file paths and line references.
- Stop when the question is fully answered or sufficient context is gathered; otherwise continue exploration.
""".trimIndent()
)
},
model = service<ModelSelectionService>().getAgentModel(),
maxAgentIterations = 100
),
toolRegistry = createToolRegistry(
project,
sessionId,
approveToolCall = approveToolCall,
selected = selectedTools,
bashConfirmationHandler = { ShellCommandConfirmation.Approved }
),
installFeatures = installFeatures
)
}
private fun buildUsageAwareInstallHandler(
provider: ServiceType,
sessionId: String,
onAgentToolCallStarting: ((eventContext: ToolCallStartingContext) -> Unit)?,
onAgentToolCallCompleted: ((eventContext: ToolCallCompletedContext) -> Unit)?,
onCreditsAvailable: ((AgentCreditsEvent) -> Unit)?
): GraphAIAgent.FeatureContext.() -> Unit = {
handleEvents {
onToolCallStarting { ctx -> onAgentToolCallStarting?.invoke(ctx) }
onToolCallCompleted { ctx -> onAgentToolCallCompleted?.invoke(ctx) }
onNodeExecutionCompleted { ctx ->
val responses = (ctx.output as? List<*>)?.filterIsInstance<Message.Response>()
?: return@onNodeExecutionCompleted
if (responses.isEmpty()) return@onNodeExecutionCompleted
if (provider == ServiceType.PROXYAI) {
extractCredits(responses, sessionId)?.let { onCreditsAvailable?.invoke(it) }
}
}
}
}
private fun extractCredits(
responses: List<Message.Response>,
sessionId: String
): AgentCreditsEvent? {
val credits = extractCreditsSnapshot(responses) ?: return null
return AgentCreditsEvent(
sessionId = sessionId,
remaining = credits.remaining,
monthlyRemaining = credits.monthlyRemaining,
consumed = credits.total
)
}
private fun singleRunWithParallelAbility() = strategy("subagent_single_run_sequential") {
val nodeCallLLM by nodeLLMRequestMultiple()
val nodeExecuteTool by nodeExecuteMultipleTools(parallelTools = true)
val nodeSendToolResult by nodeLLMSendMultipleToolResults()
edge(nodeStart forwardTo nodeCallLLM)
edge(nodeCallLLM forwardTo nodeExecuteTool onMultipleToolCalls { true })
edge(
nodeCallLLM forwardTo nodeFinish
onMultipleAssistantMessages { true }
transformed { it.joinToString("\n") { message -> message.content } }
)
edge(nodeExecuteTool forwardTo nodeSendToolResult)
edge(nodeSendToolResult forwardTo nodeExecuteTool onMultipleToolCalls { true })
edge(
nodeSendToolResult forwardTo nodeFinish
onMultipleAssistantMessages { true }
transformed { it.joinToString("\n") { message -> message.content } }
)
}
private fun getEnvironmentInfo(project: Project): String {
val osName = System.getProperty("os.name") ?: "Unknown"
val osVersion = System.getProperty("os.version") ?: "Unknown"
val currentDate = LocalDate.now()
return """
<environment>
Working Directory: ${project.basePath}
Platform: $osName
OS Version: $osVersion
Current Date: $currentDate
</environment>
""".trimIndent()
}
private fun createToolRegistry(
project: Project,
sessionId: String,
approveToolCall: (suspend (name: String, details: String) -> Boolean)?,
selected: Set<SubagentTool>,
bashConfirmationHandler: BashCommandConfirmationHandler
): ToolRegistry {
return ToolRegistry.Companion {
if (SubagentTool.READ in selected) tool(ReadTool(project))
if (SubagentTool.EDIT in selected) {
tool(
ConfirmingEditTool(EditTool(project)) { name, details ->
approveToolCall?.invoke(name, details) ?: true
}
)
}
if (SubagentTool.WRITE in selected) {
tool(
ConfirmingWriteTool(WriteTool(project)) { name, details ->
approveToolCall?.invoke(name, details) ?: true
}
)
}
if (SubagentTool.TODO_WRITE in selected) tool(TodoWriteTool(project, sessionId))
if (SubagentTool.INTELLIJ_SEARCH in selected) tool(IntelliJSearchTool(project))
if (SubagentTool.WEB_SEARCH in selected) tool(WebSearchTool())
if (SubagentTool.BASH_OUTPUT in selected) tool(BashOutputTool(sessionId))
if (SubagentTool.KILL_SHELL in selected) tool(KillShellTool())
if (SubagentTool.RESOLVE_LIBRARY_ID in selected) tool(ResolveLibraryIdTool())
if (SubagentTool.GET_LIBRARY_DOCS in selected) tool(GetLibraryDocsTool())
if (SubagentTool.BASH in selected) {
tool(
BashTool(
project.basePath ?: "",
confirmationHandler = bashConfirmationHandler,
sessionId = sessionId,
settingsService = project.service<ProxyAISettingsService>()
)
)
}
tool(ExitTool)
}
}
private fun approvalBashHandler(
approveToolCall: (suspend (name: String, details: String) -> Boolean)?
): BashCommandConfirmationHandler {
return BashCommandConfirmationHandler { args ->
try {
val ok = approveToolCall?.invoke("Bash", args.command) != false
if (ok) ShellCommandConfirmation.Approved
else ShellCommandConfirmation.Denied("User rejected the command")
} catch (_: Exception) {
ShellCommandConfirmation.Approved
}
}
}
private fun behaviorSection(behavior: String?): String {
val content = behavior?.trim().orEmpty()
return if (content.isEmpty()) "" else "\n# Subagent Behavior\n$content"
}
}

View file

@ -0,0 +1,12 @@
package ee.carlrobert.codegpt.agent
import com.intellij.util.messages.Topic
interface AgentToolOutputNotifier {
fun toolOutput(toolId: String, text: String, isError: Boolean)
companion object {
val AGENT_TOOL_OUTPUT_TOPIC =
Topic.create("agentToolOutput", AgentToolOutputNotifier::class.java)
}
}

View file

@ -0,0 +1,122 @@
package ee.carlrobert.codegpt.agent
import ai.koog.agents.core.agent.AIAgent
import ai.koog.agents.snapshot.providers.file.JVMFilePersistenceStorageProvider
import ai.koog.prompt.executor.clients.LLMClientException
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.project.Project
import ee.carlrobert.codegpt.conversations.message.TokenUsageTracker
import ee.carlrobert.codegpt.settings.service.FeatureType
import ee.carlrobert.codegpt.settings.service.ModelSelectionService
import ee.carlrobert.codegpt.toolwindow.agent.AgentSessionState
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import kotlin.coroutines.cancellation.CancellationException
import kotlin.io.path.Path
@Service(Service.Level.PROJECT)
class AgentService(private val project: Project) {
companion object {
private val logger = thisLogger()
}
private val sessionJobs = ConcurrentHashMap<String, Job>()
private val pendingMessages = ConcurrentHashMap<String, ArrayDeque<MessageWithContext>>()
private val sessionAgents = ConcurrentHashMap<String, AIAgent<MessageWithContext, String>>()
private val sessionTokenTrackers = ConcurrentHashMap<String, TokenUsageTracker>()
private val checkpointStorage =
JVMFilePersistenceStorageProvider(Path(project.basePath ?: "", ".proxyai"))
private val _queuedMessageProcessed = MutableSharedFlow<String>()
val queuedMessageProcessed = _queuedMessageProcessed.asSharedFlow()
fun addToQueue(message: MessageWithContext, sessionId: String) {
pendingMessages.getOrPut(sessionId) { ArrayDeque() }.add(message)
}
fun submitMessage(message: MessageWithContext, events: AgentEvents, sessionId: String) {
if (isSessionRunning(sessionId)) {
addToQueue(message, sessionId)
return
}
val provider = service<ModelSelectionService>().getServiceForFeature(FeatureType.AGENT)
val previousCheckpoint = runBlocking {
val prevAgentId =
project.service<AgentSessionState>().state.sessions
.firstOrNull { it.sessionId == sessionId }?.lastAgentId
if (prevAgentId == null) {
return@runBlocking null
}
runCatching {
val checkpoints = checkpointStorage.getCheckpoints(prevAgentId)
val latestCheckpoint = checkpoints
.filter { it.nodePath != "tombstone" }
.maxByOrNull { it.createdAt }
latestCheckpoint
}.onFailure { ex ->
logger.error(
"Agent checkpoints: failed to load for session=$sessionId " +
"agentId=$prevAgentId error=${ex.message}", ex
)
}.getOrNull()
}
val agent = ProxyAIAgent.create(
project,
checkpointStorage,
previousCheckpoint,
provider,
events,
sessionId,
pendingMessages
)
sessionAgents[sessionId] = agent
project.service<AgentSessionState>().updateSession(sessionId, lastAgentId = agent.id)
sessionJobs[sessionId] = CoroutineScope(Dispatchers.IO).launch {
try {
agent.run(message)
} catch (ex: LLMClientException) {
events.onClientException(provider, ex)
} catch (_: CancellationException) {
return@launch
} catch (ex: Exception) {
logger.error(ex)
} finally {
sessionJobs.remove(sessionId)
}
}
}
fun cancelCurrentRun(sessionId: String) {
sessionJobs[sessionId]?.cancel()
sessionJobs.remove(sessionId)
}
fun isSessionRunning(sessionId: String): Boolean {
return sessionJobs[sessionId]?.isActive == true
}
fun getPendingMessages(sessionId: String): List<MessageWithContext> {
return pendingMessages[sessionId]?.toList() ?: emptyList()
}
fun clearPendingMessages(sessionId: String) {
pendingMessages.remove(sessionId)
}
fun getAgentForSession(sessionId: String): AIAgent<MessageWithContext, String>? {
return sessionAgents[sessionId]
}
fun getTokenTrackerForSession(sessionId: String): TokenUsageTracker {
return sessionTokenTrackers.getOrPut(sessionId) { TokenUsageTracker() }
}
}

View file

@ -0,0 +1,73 @@
package ee.carlrobert.codegpt.agent
import ee.carlrobert.codegpt.settings.models.ModelRegistry
import ee.carlrobert.codegpt.settings.models.ModelSelection
import ee.carlrobert.codegpt.settings.service.ServiceType
import ee.carlrobert.codegpt.util.file.FileUtil
internal object AgentSystemPrompts {
private const val WORKING_DIRECTORY_TOKEN = "{{WORKING_DIRECTORY}}"
private const val OPENAI_KEY = "openai"
private const val GEMINI_KEY = "gemini"
private const val ANTHROPIC_KEY = "anthropic"
private val openAiPrompt: String by lazy { loadPrompt("/prompts/agent/openai.txt") }
private val geminiPrompt: String by lazy { loadPrompt("/prompts/agent/google.txt") }
private val anthropicPrompt: String by lazy { loadPrompt("/prompts/agent/anthropic.txt") }
fun createSystemPrompt(
provider: ServiceType,
modelSelection: ModelSelection?,
projectPath: String? = null
): String {
return when (provider) {
ServiceType.PROXYAI -> createProxyAiSystemPrompt(modelSelection, projectPath)
ServiceType.OPENAI -> createOpenAiSystemPrompt(projectPath)
ServiceType.GOOGLE -> createGeminiSystemPrompt(projectPath)
else -> createAnthropicSystemPrompt(projectPath)
}
}
private fun createProxyAiSystemPrompt(
modelSelection: ModelSelection?,
projectPath: String?
): String {
val modelId = modelSelection?.model
return if (modelId == ModelRegistry.PROXYAI_AUTO || modelId?.startsWith("claude") == true) {
createAnthropicSystemPrompt(projectPath)
} else {
createOpenAiSystemPrompt(projectPath)
}
}
private fun createOpenAiSystemPrompt(projectPath: String? = null): String {
return renderPrompt(OPENAI_KEY, projectPath)
}
private fun createGeminiSystemPrompt(projectPath: String? = null): String {
return renderPrompt(GEMINI_KEY, projectPath)
}
private fun createAnthropicSystemPrompt(projectPath: String? = null): String {
return renderPrompt(ANTHROPIC_KEY, projectPath)
}
private fun renderPrompt(key: String, projectPath: String?): String {
val workingDirectory = projectPath ?: "unknown"
return getPrompt(key).replace(WORKING_DIRECTORY_TOKEN, workingDirectory)
}
private fun getPrompt(key: String): String {
return when (key) {
OPENAI_KEY -> openAiPrompt
GEMINI_KEY -> geminiPrompt
ANTHROPIC_KEY -> anthropicPrompt
else -> error("Unknown system prompt key: $key")
}
}
private fun loadPrompt(path: String): String {
return FileUtil.getResourceContent(path).trimEnd()
}
}

View file

@ -0,0 +1,28 @@
package ee.carlrobert.codegpt.agent
/**
* Represents the different types of specialized agents that can be launched via the Task tool.
* Each agent type has a specific focus and optimized tool set for its intended use case.
*/
sealed class AgentType(val value: String, val description: String) {
object GENERAL_PURPOSE : AgentType(
value = "general-purpose",
description = "General-purpose agent for complex research, code search, and multi-step tasks"
)
object EXPLORE : AgentType(
value = "explore",
description = "Fast agent specialized for exploring codebases and finding files/patterns"
)
companion object {
fun fromString(value: String): AgentType = when (value.lowercase().trim()) {
"general-purpose" -> GENERAL_PURPOSE
"explore" -> EXPLORE
else -> throw IllegalArgumentException(
"Unknown agent type: '$value'. Valid types are: general-purpose, explore, plan."
)
}
}
}

View file

@ -0,0 +1,9 @@
package ee.carlrobert.codegpt.agent
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagDetails
import java.util.UUID
class MessageWithContext(val text: String, val tags: List<TagDetails>) {
val id: UUID = UUID.randomUUID()
}

View file

@ -0,0 +1,212 @@
package ee.carlrobert.codegpt.agent
import ai.koog.agents.snapshot.providers.file.JVMFilePersistenceStorageProvider
import ai.koog.prompt.dsl.prompt
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import ee.carlrobert.codegpt.settings.service.FeatureType
import ee.carlrobert.codegpt.settings.service.ModelSelectionService
import ee.carlrobert.codegpt.toolwindow.agent.AgentSessionState
import ee.carlrobert.codegpt.ui.textarea.TagProcessorFactory
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagDetails
import ee.carlrobert.codegpt.util.GitUtil
import ee.carlrobert.codegpt.util.ThinkingOutputParser
import ee.carlrobert.codegpt.util.file.FileUtil
import kotlinx.coroutines.runBlocking
import kotlin.io.path.Path
import ai.koog.prompt.message.Message as KoogMessage
import ai.koog.prompt.message.Message as PromptMessage
import ee.carlrobert.codegpt.conversations.message.Message as ChatMessage
data class PromptEnhancement(val prompt: String, val includeGitChanges: Boolean)
class PromptEnhancer(private val project: Project) {
fun enhancePrompt(
prompt: String,
tags: List<TagDetails> = emptyList(),
sessionId: String? = null
): PromptEnhancement {
return runBlocking { enhancePromptInternal(prompt, tags, sessionId) }
}
private suspend fun enhancePromptInternal(
prompt: String,
tags: List<TagDetails>,
sessionId: String?
): PromptEnhancement {
val trimmedPrompt = prompt.trim()
val gitDiff = GitUtil.getCurrentChanges(project)?.trim().orEmpty()
val tagsContext = buildTagsContext(tags)
val historyContext = buildHistoryContext(sessionId)
val modelService = service<ModelSelectionService>()
val provider = modelService.getServiceForFeature(FeatureType.AGENT)
val model = modelService.getAgentModel()
val executor = AgentFactory.createExecutor(provider)
val responseText = runCatching {
val p = prompt("prompt-enhancer") {
system(PromptEnhancerPrompts.DEFAULT_PROMPT)
user(buildUserPrompt(trimmedPrompt, gitDiff, tagsContext, historyContext))
}
val responses = executor.execute(p, model, emptyList())
responses.filterIsInstance<KoogMessage.Assistant>()
.joinToString("\n") { it.content.trim() }
}.getOrDefault("")
val enhanced = extractEnhancedPrompt(responseText.ifBlank { trimmedPrompt })
return PromptEnhancement(
prompt = enhanced,
includeGitChanges = gitDiff.isNotBlank()
)
}
private fun buildUserPrompt(
prompt: String,
gitDiff: String,
tagsContext: String,
historyContext: String
): String {
return buildString {
if (historyContext.isNotBlank()) {
append("Previous conversation history:\n")
append(historyContext.trim())
append("\n\n")
}
if (tagsContext.isNotBlank()) {
append("Selected tag context:\n")
append(tagsContext.trim())
append("\n\n")
}
append("Original prompt:\n")
append(prompt)
if (gitDiff.isNotBlank()) {
append("\n\nCurrent git changes:\n```diff\n")
append(gitDiff)
append("\n```")
}
}
}
private fun extractEnhancedPrompt(response: String): String {
val parser = ThinkingOutputParser()
val parsed = parser.processChunk(response)
val candidate = parsed.ifBlank { response }.trim()
return stripCodeFence(candidate)
}
private fun stripCodeFence(text: String): String {
val trimmed = text.trim()
if (!trimmed.startsWith("```")) {
return trimmed
}
val closingIndex = trimmed.lastIndexOf("```")
if (closingIndex <= 2) {
return trimmed
}
val inner = trimmed.substring(3, closingIndex).trimStart()
val newlineIndex = inner.indexOf('\n')
if (newlineIndex == -1) {
return inner.trim()
}
val header = inner.substring(0, newlineIndex)
val body = inner.substring(newlineIndex + 1)
return if (header.all { it.isLetterOrDigit() || it == '-' || it == '_' }) {
body.trim()
} else {
inner.trim()
}
}
private fun buildTagsContext(tags: List<TagDetails>): String {
if (tags.isEmpty()) return ""
val contextBuilder = StringBuilder()
val tempMessage = ChatMessage("")
tags.forEach { tagDetails ->
val processor = TagProcessorFactory.getProcessor(project, tagDetails)
processor.process(tempMessage, contextBuilder)
}
appendTagMetadata(contextBuilder, tempMessage)
return contextBuilder.toString().trim()
}
private fun appendTagMetadata(builder: StringBuilder, message: ChatMessage) {
val referencedFiles = message.referencedFilePaths.orEmpty()
if (referencedFiles.isNotEmpty()) {
builder.append("\nReferenced files:\n")
referencedFiles.forEach { path ->
builder.append("- ").append(path).append("\n")
}
}
message.documentationDetails?.let { details ->
builder.append("\nDocumentation:\n")
.append(details.name)
.append(" (")
.append(details.url)
.append(")\n")
}
message.personaName?.takeIf { it.isNotBlank() }?.let { persona ->
builder.append("\nPersona:\n").append(persona).append("\n")
}
message.imageFilePath?.takeIf { it.isNotBlank() }?.let { path ->
builder.append("\nImage:\n").append(path).append("\n")
}
}
private suspend fun buildHistoryContext(sessionId: String?): String {
if (sessionId.isNullOrBlank()) return ""
val sessionState = project.service<AgentSessionState>()
val agentId = sessionState.getLastAgentId(sessionId) ?: return ""
val storage = JVMFilePersistenceStorageProvider(Path(project.basePath ?: "", ".proxyai"))
val checkpoint = storage.getCheckpoints(agentId)
.filter { it.nodePath != "tombstone" }
.maxByOrNull { it.createdAt }
?: return ""
return formatHistory(checkpoint.messageHistory)
}
private fun formatHistory(messages: List<PromptMessage>): String {
val builder = StringBuilder()
messages.filterNot { it is PromptMessage.System }.forEach { msg ->
when (msg) {
is PromptMessage.User -> {
appendHistoryLine(builder, "User", msg.content)
}
is PromptMessage.Assistant -> {
appendHistoryLine(builder, "Assistant", msg.content)
}
is PromptMessage.Reasoning -> {
appendHistoryLine(builder, "Assistant", msg.content)
}
is PromptMessage.Tool.Call -> {
appendHistoryLine(builder, "Tool Call (${msg.tool})", msg.content)
}
is PromptMessage.Tool.Result -> {
appendHistoryLine(builder, "Tool Result (${msg.tool})", msg.content)
}
else -> {
appendHistoryLine(builder, "Message", msg.toString())
}
}
}
return builder.toString().trim()
}
private fun appendHistoryLine(builder: StringBuilder, label: String, content: String) {
val trimmed = content.trim()
if (trimmed.isBlank()) return
builder.append(label).append(": ").append(trimmed).append("\n\n")
}
}
private object PromptEnhancerPrompts {
private const val PROMPT_PATH = "/prompts/core/prompt-enhancer.txt"
val DEFAULT_PROMPT: String = FileUtil.getResourceContent(PROMPT_PATH)
}

View file

@ -0,0 +1,289 @@
package ee.carlrobert.codegpt.agent
import ai.koog.agents.core.agent.AIAgent
import ai.koog.agents.core.agent.config.AIAgentConfig
import ai.koog.agents.core.agent.context.RollbackStrategy
import ai.koog.agents.core.tools.ToolRegistry
import ai.koog.agents.ext.tool.ExitTool
import ai.koog.agents.ext.tool.shell.ShellCommandConfirmation
import ai.koog.agents.features.eventHandler.feature.handleEvents
import ai.koog.agents.features.tokenizer.feature.MessageTokenizer
import ai.koog.agents.features.tracing.feature.Tracing
import ai.koog.agents.features.tracing.writer.TraceFeatureMessageLogWriter
import ai.koog.agents.snapshot.feature.AgentCheckpointData
import ai.koog.agents.snapshot.feature.Persistence
import ai.koog.agents.snapshot.feature.persistence
import ai.koog.agents.snapshot.providers.file.JVMFilePersistenceStorageProvider
import ai.koog.prompt.dsl.prompt
import ai.koog.prompt.message.Message
import ai.koog.prompt.streaming.StreamFrame
import ai.koog.prompt.tokenizer.Tokenizer
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import ee.carlrobert.codegpt.EncodingManager
import ee.carlrobert.codegpt.agent.strategy.SingleRunStrategyProvider
import ee.carlrobert.codegpt.agent.tools.*
import ee.carlrobert.codegpt.settings.ProxyAISettingsService
import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings
import ee.carlrobert.codegpt.settings.service.FeatureType
import ee.carlrobert.codegpt.settings.service.ModelSelectionService
import ee.carlrobert.codegpt.settings.service.ServiceType
import ee.carlrobert.codegpt.toolwindow.agent.AgentSessionState
import ee.carlrobert.codegpt.toolwindow.agent.ui.approval.BashPayload
import ee.carlrobert.codegpt.toolwindow.agent.ui.approval.ToolApprovalRequest
import ee.carlrobert.codegpt.toolwindow.agent.ui.approval.ToolApprovalType
import io.github.oshai.kotlinlogging.KotlinLogging
import java.nio.file.Files
import java.nio.file.Paths
import java.util.*
import java.util.concurrent.ConcurrentHashMap
data class ToolError(val message: String)
object ProxyAIAgent {
private const val INSTRUCTION_FILE_NAME = "PROXYAI.md"
private val logger = KotlinLogging.logger { }
private fun searchForInstructions(projectPath: String?): String? {
if (projectPath == null) return null
val projectRoot = Paths.get(projectPath)
if (!Files.exists(projectRoot)) return null
val proxyAiFile = projectRoot.resolve(INSTRUCTION_FILE_NAME)
if (Files.exists(proxyAiFile) && Files.isRegularFile(proxyAiFile)) {
try {
val content = Files.readString(proxyAiFile).trim()
if (content.isNotEmpty()) {
return content
}
} catch (_: Exception) {
}
}
return null
}
fun create(
project: Project,
checkpointStorage: JVMFilePersistenceStorageProvider,
previousCheckpoint: AgentCheckpointData?,
provider: ServiceType,
events: AgentEvents,
sessionId: String,
pendingMessages: ConcurrentHashMap<String, ArrayDeque<MessageWithContext>>,
): AIAgent<MessageWithContext, String> {
val modelSelection =
service<ModelSelectionService>().getModelSelectionForFeature(FeatureType.AGENT)
val stream =
provider == ServiceType.ANTHROPIC || provider == ServiceType.OPENAI || provider == ServiceType.PROXYAI
val projectInstructions = searchForInstructions(project.basePath)
val executor = AgentFactory.createExecutor(provider)
val pendingMessageQueue = pendingMessages.getOrPut(sessionId) { ArrayDeque() }
val toolRegistry = createToolRegistry(project, events, sessionId)
val agent = AIAgent(
promptExecutor = executor,
strategy = SingleRunStrategyProvider().build(
project,
executor,
projectInstructions,
previousCheckpoint,
pendingMessageQueue,
events,
sessionId,
provider,
stream
),
agentConfig = AIAgentConfig(
prompt = prompt("proxyai-agent") {
system(
AgentSystemPrompts.createSystemPrompt(
provider,
modelSelection,
project.basePath
)
)
},
model = service<ModelSelectionService>().getAgentModel(),
maxAgentIterations = 100
),
toolRegistry = toolRegistry,
) {
if (ConfigurationSettings.getState().debugModeEnabled) {
install(Tracing) {
addMessageProcessor(TraceFeatureMessageLogWriter(logger))
}
}
install(Persistence) {
storage = checkpointStorage
rollbackStrategy = RollbackStrategy.Default
}
install(MessageTokenizer) {
tokenizer = object : Tokenizer {
override fun countTokens(text: String): Int =
EncodingManager.getInstance().countTokens(text)
}
enableCaching = false
}
handleEvents {
val toolCallToUiId: MutableMap<String, String> = HashMap()
val anonymousToolIds: ArrayDeque<String> = ArrayDeque()
onLLMStreamingFrameReceived { ctx ->
if (!stream) return@onLLMStreamingFrameReceived
val frame = ctx.streamFrame
if (frame is StreamFrame.Append) {
events.onTextReceived(frame.text)
}
}
onNodeExecutionCompleted { ctx ->
val checkpoint = ctx.context.persistence().createCheckpoint(
agentContext = ctx.context,
nodePath = ctx.context.executionInfo.path(),
lastInput = ctx.input,
lastInputType = ctx.inputType,
checkpointId = ctx.context.runId,
version = 0L
)
project.service<AgentSessionState>().updateSession(
sessionId = sessionId,
lastAgentId = ctx.context.agentId,
checkpointId = checkpoint?.checkpointId
)
if (stream) return@onNodeExecutionCompleted
(ctx.output as? List<*>)?.forEach { msg ->
(msg as? Message.Assistant)?.let {
events.onTextReceived(it.content)
}
(msg as? Message.Reasoning)?.let {
events.onTextReceived(it.content)
}
}
}
onToolCallStarting { ctx ->
val id = ctx.toolCallId ?: UUID.randomUUID().toString()
if (ctx.toolCallId == null) {
anonymousToolIds.addLast(id)
}
val decodedArgs =
ToolCallPayloadDecoder.decodeArgs(toolRegistry, ctx.toolName, ctx.toolArgs)
events.onToolStarting(id, ctx.toolName, decodedArgs)
ToolRunContext.set(sessionId, id)
}
onToolCallCompleted { ctx ->
val uiId = when {
ctx.toolCallId != null -> toolCallToUiId[ctx.toolCallId] ?: ctx.toolCallId
anonymousToolIds.isNotEmpty() -> anonymousToolIds.removeFirst()
else -> null
}
val decodedResult = ToolCallPayloadDecoder.decodeResult(
toolRegistry,
ctx.toolName,
ctx.toolResult
)
events.onToolCompleted(uiId, ctx.toolName, decodedResult)
}
onAgentCompleted { context ->
events.onAgentCompleted(context)
}
}
}
return agent
}
private fun createToolRegistry(
project: Project,
events: AgentEvents,
sessionId: String
): ToolRegistry {
return ToolRegistry {
tool(ReadTool(project))
tool(
ConfirmingEditTool(EditTool(project)) { name, details ->
try {
events.approveToolCall(
ToolApprovalRequest(
if (name.equals("Edit", true)
) ToolApprovalType.EDIT else ToolApprovalType.GENERIC,
"Allow $name?",
details
)
)
} catch (_: Exception) {
false
}
}
)
tool(
ConfirmingWriteTool(WriteTool(project)) { name, details ->
try {
val type = if (name.equals("Write", true))
ToolApprovalType.WRITE
else ToolApprovalType.GENERIC
events.approveToolCall(
ToolApprovalRequest(
type,
"Allow $name?",
details
)
)
} catch (_: Exception) {
false
}
}
)
tool(TodoWriteTool(project, sessionId))
tool(AskUserQuestionTool(events))
tool(ExitTool)
tool(IntelliJSearchTool(project))
tool(WebSearchTool())
tool(BashOutputTool(sessionId))
tool(KillShellTool())
tool(ResolveLibraryIdTool())
tool(GetLibraryDocsTool())
tool(
BashTool(
project.basePath ?: "",
confirmationHandler = { args ->
try {
val approved = events.approveToolCall(
ToolApprovalRequest(
ToolApprovalType.BASH,
"Run shell command?",
args.command,
BashPayload(args.command, args.description)
)
)
if (approved) ShellCommandConfirmation.Approved else ShellCommandConfirmation.Denied(
"User rejected the command"
)
} catch (_: Exception) {
ShellCommandConfirmation.Approved
}
},
sessionId = sessionId,
settingsService = project.service<ProxyAISettingsService>()
)
)
tool(
TaskTool(
project,
sessionId,
service<ModelSelectionService>().getServiceForFeature(FeatureType.AGENT),
events
)
)
}
}
}

View file

@ -0,0 +1,39 @@
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),
WEB_SEARCH("websearch", "WebSearch", false),
RESOLVE_LIBRARY_ID("resolvelibraryid", "ResolveLibraryId", false),
GET_LIBRARY_DOCS("getlibrarydocs", "GetLibraryDocs", false),
BASH_OUTPUT("bashoutput", "BashOutput", false),
KILL_SHELL("killshell", "KillShell", false),
EDIT("edit", "Edit", true),
WRITE("write", "Write", true),
BASH("bash", "Bash", true),
EXIT("exit", "Exit", false);
companion object {
val readOnly: List<SubagentTool> = entries.filterNot { it.isWrite }
val write: List<SubagentTool> = entries.filter { it.isWrite }
fun parse(values: Collection<String>): Set<SubagentTool> {
return values.mapNotNull { fromString(it) }.toSet()
}
fun toStoredValues(tools: Collection<SubagentTool>): List<String> {
val selected = tools.toSet()
return entries.filter { it in selected }.map { it.id }
}
fun fromString(value: String): SubagentTool? {
val key = normalize(value)
return entries.firstOrNull { normalize(it.id) == key || normalize(it.displayName) == key }
}
private fun normalize(value: String): String {
return value.lowercase().filter { it.isLetterOrDigit() }
}
}
}

View file

@ -0,0 +1,86 @@
package ee.carlrobert.codegpt.agent
import ai.koog.agents.core.tools.ToolRegistry
import ee.carlrobert.codegpt.agent.tools.*
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
object ToolCallPayloadDecoder {
private val fallbackJson = Json {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
decodeEnumsCaseInsensitive = true
}
fun decodeArgs(toolRegistry: ToolRegistry?, toolName: String, rawArgs: JsonObject): Any? {
val tool = toolRegistry?.getToolOrNull(toolName)
if (tool != null) {
return runCatching { tool.decodeArgs(rawArgs) }.getOrElse { rawArgs }
}
val serializer = argsSerializerFor(toolName) ?: return rawArgs
return decodeWith(serializer, rawArgs, rawArgs)
}
fun decodeResult(toolRegistry: ToolRegistry?, toolName: String, rawResult: JsonElement?): Any? {
if (rawResult == null) return null
val tool = toolRegistry?.getToolOrNull(toolName)
if (tool != null) {
return runCatching { tool.decodeResult(rawResult) }.getOrElse { rawResult }
}
val serializer = resultSerializerFor(toolName) ?: return rawResult
return decodeWith(serializer, rawResult, rawResult)
}
private fun argsSerializerFor(toolName: String): KSerializer<out Any>? {
return when (toolName) {
"Read" -> ReadTool.Args.serializer()
"Write" -> WriteTool.Args.serializer()
"Edit" -> EditTool.Args.serializer()
"Bash" -> BashTool.Args.serializer()
"BashOutput" -> BashOutputTool.Args.serializer()
"KillShell" -> KillShellTool.Args.serializer()
"IntelliJSearch" -> IntelliJSearchTool.Args.serializer()
"WebSearch" -> WebSearchTool.Args.serializer()
"ResolveLibraryId" -> ResolveLibraryIdTool.Args.serializer()
"GetLibraryDocs" -> GetLibraryDocsTool.Args.serializer()
"Task" -> TaskTool.Args.serializer()
"AskUserQuestion" -> AskUserQuestionTool.Args.serializer()
"TodoWrite", "TodoWriteTool" -> TodoWriteTool.Args.serializer()
"Exit" -> Unit.serializer()
else -> null
}
}
private fun resultSerializerFor(toolName: String): KSerializer<out Any>? {
return when (toolName) {
"Read" -> ReadTool.Result.serializer()
"Write" -> WriteTool.Result.serializer()
"Edit" -> EditTool.Result.serializer()
"Bash" -> BashTool.Result.serializer()
"BashOutput" -> BashOutputTool.Result.serializer()
"KillShell" -> KillShellTool.Result.serializer()
"IntelliJSearch" -> IntelliJSearchTool.Result.serializer()
"WebSearch" -> WebSearchTool.Result.serializer()
"ResolveLibraryId" -> ResolveLibraryIdTool.Result.serializer()
"GetLibraryDocs" -> GetLibraryDocsTool.Result.serializer()
"Task" -> TaskTool.Result.serializer()
"AskUserQuestion" -> AskUserQuestionTool.Result.serializer()
"TodoWrite", "TodoWriteTool" -> String.serializer()
"Exit" -> Unit.serializer()
else -> null
}
}
private fun <T> decodeWith(
serializer: KSerializer<T>,
raw: JsonElement,
fallback: Any?
): Any? {
return runCatching { fallbackJson.decodeFromJsonElement(serializer, raw) }.getOrElse { fallback }
}
}

View file

@ -0,0 +1,44 @@
package ee.carlrobert.codegpt.agent
import java.util.concurrent.ConcurrentHashMap
data class ToolContext(
val sessionId: String,
val toolId: String,
val checkpointId: String? = null,
val parentToolId: String? = null,
val timestamp: Long = System.currentTimeMillis()
)
object ToolRunContext {
private val sessionContexts = ConcurrentHashMap<String, ThreadLocal<ToolContext?>>()
fun set(sessionId: String, toolId: String, checkpointId: String? = null) {
val currentContext = get(sessionId)
val context = ToolContext(
sessionId = sessionId,
toolId = toolId,
checkpointId = checkpointId,
parentToolId = currentContext?.toolId,
timestamp = System.currentTimeMillis()
)
sessionContexts.computeIfAbsent(sessionId) { ThreadLocal() }.set(context)
}
fun get(sessionId: String): ToolContext? {
return sessionContexts[sessionId]?.get()
}
fun getToolId(sessionId: String): String? {
return sessionContexts[sessionId]?.get()?.toolId
}
fun clear(sessionId: String) {
sessionContexts[sessionId]?.remove()
}
fun cleanupSession(sessionId: String) {
sessionContexts.remove(sessionId)
}
}

View file

@ -0,0 +1,162 @@
package ee.carlrobert.codegpt.agent.clients
import ai.koog.prompt.executor.clients.openai.base.models.*
import ai.koog.prompt.executor.clients.serialization.AdditionalPropertiesFlatteningSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable
internal class CustomOpenAIChatCompletionRequest(
val messages: List<OpenAIMessage> = emptyList(),
val prompt: String? = null,
override val model: String? = null,
override val stream: Boolean? = null,
override val temperature: Double? = null,
val tools: List<OpenAITool>? = null,
val toolChoice: OpenAIToolChoice? = null,
override val topP: Double? = null,
override val topLogprobs: Int? = null,
val maxTokens: Int? = null,
val frequencyPenalty: Double? = null,
val presencePenalty: Double? = null,
val responseFormat: OpenAIResponseFormat? = null,
val stop: List<String>? = null,
val logprobs: Boolean? = null,
val seed: Int? = null,
val topK: Int? = null,
val repetitionPenalty: Double? = null,
val logitBias: Map<Int, Double>? = null,
val minP: Double? = null,
val topA: Double? = null,
val prediction: OpenAIStaticContent? = null,
val transforms: List<String>? = null,
val models: List<String>? = null,
val route: String? = null,
val user: String? = null,
val additionalProperties: Map<String, JsonElement>? = null,
) : OpenAIBaseLLMRequest
/**
* Chat completion choice
*
* @property finishReason The reason the model stopped generating tokens.
* This will be `stop` if the model hit a natural stop point or a provided stop sequence,
* `length` if the maximum number of tokens specified in the request was reached,
* `content_filter` if content was omitted due to a flag from our content filters,
* `tool_calls` if the model called a tool, or `function_call` (deprecated) if the model called a function.
* `error` if the model finishes the request with an error.
* @property nativeFinishReason The raw finish_reason string returned by the model
* @property message A chat completion message generated by the model.
* @property error An error response structure typically used for conveying error details to the clients.
*
* See (CompletionsResponse Format)[https://CustomOpenAI.ai/docs/api-reference/overview#completionsresponse-format]
*/
@Serializable
public class CustomOpenAIChoice(
public val finishReason: String? = null,
public val nativeFinishReason: String? = null,
public val message: OpenAIMessage,
public val error: ErrorResponse? = null
)
/**
* Chat completion choice
*
* @property finishReason The reason the model stopped generating tokens.
* This will be `stop` if the model hit a natural stop point or a provided stop sequence,
* `length` if the maximum number of tokens specified in the request was reached,
* `content_filter` if content was omitted due to a flag from our content filters,
* `tool_calls` if the model called a tool, or `function_call` (deprecated) if the model called a function.
* `error` if the model finishes the request with an error.
* @property nativeFinishReason The raw finish_reason string returned by the model
* @property delta A chat completion delta generated by streamed model responses.
* @property error An error response structure typically used for conveying error details to the clients.
*
* See (CompletionsResponse Format)[https://CustomOpenAI.ai/docs/api-reference/overview#completionsresponse-format]
*/
@Serializable
public class CustomOpenAIStreamChoice(
public val finishReason: String? = null,
public val nativeFinishReason: String? = null,
public val delta: CustomOpenAIStreamDelta,
public val error: ErrorResponse? = null
)
/**
* @property content The contents of the chunk message.
* @property role The role of the author of this message.
* @property toolCalls The tool calls requested by the model.
*/
@Serializable
public class CustomOpenAIStreamDelta(
public val content: String? = null,
public val role: String? = null,
public val toolCalls: List<CustomOpenAIToolCall>? = null
)
@Serializable
public class CustomOpenAIToolCall(
public val id: String? = "",
public val function: CustomOpenAIFunction
) {
/** The type of the tool. Currently, only `function` is supported. */
public val type: String = "function"
}
@Serializable
public class CustomOpenAIFunction(
public val name: String? = "",
public val arguments: String? = ""
)
/**
* Represents an error response structure typically used for conveying error details to the clients.
*
* @property code The numeric code representing the error.
* @property message A descriptive message providing details about the error.
* @property metadata Optional additional information about the error in the form of key-value pairs.
*/
@Serializable
public class ErrorResponse(
public val code: Int,
public val message: String,
public val metadata: Map<String, String>? = null,
)
/**
* CustomOpenAI Chat Completion Response
* See (CompletionsResponse Format)[https://CustomOpenAI.ai/docs/api-reference/overview#completionsresponse-format]
*/
@Serializable
public class CustomOpenAIChatCompletionResponse(
public val choices: List<CustomOpenAIChoice>,
override val created: Long,
override val id: String,
override val model: String,
public val systemFingerprint: String? = null,
@SerialName("object")
public val objectType: String = "chat.completion",
public val usage: OpenAIUsage? = null,
) : OpenAIBaseLLMResponse
/**
* CustomOpenAI Chat Completion Streaming Response
* See (CompletionsResponse Format)[https://CustomOpenAI.ai/docs/api-reference/overview#completionsresponse-format]
*/
@Serializable
public class CustomOpenAIChatCompletionStreamResponse(
public val choices: List<CustomOpenAIStreamChoice>,
override val created: Long,
override val id: String,
override val model: String,
public val systemFingerprint: String? = null,
@SerialName("object")
public val objectType: String = "chat.completion.chunk",
public val usage: OpenAIUsage? = null,
) : OpenAIBaseLLMStreamResponse
internal object CustomOpenAIChatCompletionRequestSerializer :
AdditionalPropertiesFlatteningSerializer<CustomOpenAIChatCompletionRequest>(
CustomOpenAIChatCompletionRequest.serializer()
)

View file

@ -0,0 +1,228 @@
package ee.carlrobert.codegpt.agent.clients
import ai.koog.prompt.dsl.ModerationResult
import ai.koog.prompt.dsl.Prompt
import ai.koog.prompt.executor.clients.ConnectionTimeoutConfig
import ai.koog.prompt.executor.clients.LLMClient
import ai.koog.prompt.executor.clients.LLMClientException
import ai.koog.prompt.executor.clients.openai.base.AbstractOpenAILLMClient
import ai.koog.prompt.executor.clients.openai.base.OpenAIBaseSettings
import ai.koog.prompt.executor.clients.openai.base.OpenAICompatibleToolDescriptorSchemaGenerator
import ai.koog.prompt.executor.clients.openai.base.models.*
import ai.koog.prompt.llm.LLMProvider
import ai.koog.prompt.llm.LLModel
import ai.koog.prompt.message.LLMChoice
import ai.koog.prompt.message.Message
import ai.koog.prompt.message.ResponseMetaInfo
import ai.koog.prompt.params.LLMParams
import ai.koog.prompt.streaming.StreamFrameFlowBuilder
import ee.carlrobert.codegpt.settings.service.custom.CustomServiceChatCompletionSettingsState
import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.client.*
import kotlinx.datetime.Clock
import java.net.URI
import kotlin.io.encoding.ExperimentalEncodingApi
/**
* Configuration settings for connecting to the CustomOpenAI API.
*
* @property baseUrl The base URL of the CustomOpenAI API. Default is "https://CustomOpenAI.ai/api/v1".
* @property timeoutConfig Configuration for connection timeouts including request, connection, and socket timeouts.
*/
public class CustomOpenAIClientSettings(
baseUrl: String,
chatCompletionsPath: String,
timeoutConfig: ConnectionTimeoutConfig = ConnectionTimeoutConfig(
requestTimeoutMillis = 120_000,
socketTimeoutMillis = 120_000
)
) : OpenAIBaseSettings(baseUrl, chatCompletionsPath, timeoutConfig)
/**
* Implementation of [LLMClient] for CustomOpenAI API.
* CustomOpenAI is an API that routes requests to multiple LLM providers.
*
* @param apiKey The API key for the CustomOpenAI API
* @param settings The base URL and timeouts for the CustomOpenAI API, defaults to "https://CustomOpenAI.ai" and 900s
* @param clock Clock instance used for tracking response metadata timestamps.
*/
public class CustomOpenAILLMClient(
apiKey: String,
private val settings: CustomOpenAIClientSettings,
private val state: CustomServiceChatCompletionSettingsState,
baseClient: HttpClient = HttpClient(),
clock: Clock = Clock.System
) : AbstractOpenAILLMClient<CustomOpenAIChatCompletionResponse, CustomOpenAIChatCompletionStreamResponse>(
apiKey,
settings,
baseClient,
clock,
staticLogger,
OpenAICompatibleToolDescriptorSchemaGenerator()
) {
public data object CustomOpenAI : LLMProvider("custom-openai", "Custom OpenAI")
companion object {
private val staticLogger = KotlinLogging.logger { }
init {
registerOpenAIJsonSchemaGenerators(CustomOpenAI)
}
fun fromSettingsState(
apiKey: String,
state: CustomServiceChatCompletionSettingsState
): CustomOpenAILLMClient {
val stateUrl = state.url ?: throw RuntimeException("Url not set")
val uri = URI.create(stateUrl)
val settings = CustomOpenAIClientSettings(
baseUrl = stateUrl.substring(0, stateUrl.lastIndexOf(uri.host) + (uri.host.length)),
chatCompletionsPath = uri.path,
)
return CustomOpenAILLMClient(apiKey, settings, state)
}
}
override fun llmProvider(): LLMProvider = CustomOpenAI
override fun serializeProviderChatRequest(
messages: List<OpenAIMessage>,
model: LLModel,
tools: List<OpenAITool>?,
toolChoice: OpenAIToolChoice?,
params: LLMParams,
stream: Boolean
): String {
val customParams = params.toCustomOpenAIParams(state)
val responseFormat = createResponseFormat(params.schema, model)
val request = CustomOpenAIChatCompletionRequest(
messages = messages,
model = model.id,
stream = stream,
temperature = customParams.temperature,
tools = tools,
toolChoice = customParams.toolChoice?.toOpenAIToolChoice(),
topP = customParams.topP,
topLogprobs = customParams.topLogprobs,
maxTokens = customParams.maxTokens,
frequencyPenalty = customParams.frequencyPenalty,
presencePenalty = customParams.presencePenalty,
responseFormat = responseFormat,
stop = customParams.stop,
logprobs = customParams.logprobs,
topK = customParams.topK,
repetitionPenalty = customParams.repetitionPenalty,
minP = customParams.minP,
topA = customParams.topA,
prediction = customParams.speculation?.let { OpenAIStaticContent(Content.Text(it)) },
transforms = customParams.transforms,
models = customParams.models,
route = customParams.route,
user = customParams.user,
additionalProperties = customParams.additionalProperties,
)
return json.encodeToString(CustomOpenAIChatCompletionRequestSerializer, request)
}
override fun processProviderChatResponse(response: CustomOpenAIChatCompletionResponse): List<LLMChoice> {
require(response.choices.isNotEmpty()) { "Empty choices in response" }
return response.choices.map {
it.message.toResponses(it.finishReason, createMetaInfo(response.usage))
}
}
override fun decodeStreamingResponse(data: String): CustomOpenAIChatCompletionStreamResponse =
json.decodeFromString(data)
override fun decodeResponse(data: String): CustomOpenAIChatCompletionResponse =
json.decodeFromString(data)
override suspend fun StreamFrameFlowBuilder.processStreamingChunk(chunk: CustomOpenAIChatCompletionStreamResponse) {
chunk.choices.firstOrNull()?.let { choice ->
choice.delta.content?.let { emitAppend(it) }
choice.delta.toolCalls?.forEachIndexed { index, openAIToolCall ->
val id = openAIToolCall.id
val name = openAIToolCall.function.name
val arguments = openAIToolCall.function.arguments
upsertToolCall(index, id, name, arguments)
}
choice.finishReason?.let { emitEnd(it, createMetaInfo(chunk.usage)) }
}
}
override suspend fun moderate(prompt: Prompt, model: LLModel): ModerationResult {
logger.warn { "Moderation is not supported by CustomOpenAI API" }
throw UnsupportedOperationException("Moderation is not supported by CustomOpenAI API.")
}
@OptIn(ExperimentalEncodingApi::class)
private fun OpenAIMessage.toResponses(
finishReason: String?,
metaInfo: ResponseMetaInfo
): List<Message.Response> {
return when {
this is OpenAIMessage.Assistant && !this.toolCalls.isNullOrEmpty() -> {
val result = mutableListOf<Message.Response>()
if (this.content != null) {
result.add(
Message.Assistant(
content = this.content!!.text(),
finishReason = finishReason,
metaInfo = metaInfo
)
)
}
this.toolCalls!!.forEach { toolCall ->
result.add(
Message.Tool.Call(
id = toolCall.id,
tool = toolCall.function.name,
/*
If the tool has no arguments, OpenRouter puts an empty string in the arguments instead of an empty object
But we always expect arguments to be a JSON object. Fixing this.
*/
content = toolCall.function.arguments
.takeIf { it.isNotEmpty() }
?: "{}",
metaInfo = metaInfo
)
)
}
return result
}
this is OpenAIMessage.Assistant && this.reasoningContent != null && this.content != null -> listOf(
Message.Reasoning(
content = this.reasoningContent!!,
metaInfo = metaInfo
),
Message.Assistant(
content = this.content!!.text(),
finishReason = finishReason,
metaInfo = metaInfo
)
)
this.content != null -> listOf(
Message.Assistant(
content = this.content!!.text(),
finishReason = finishReason,
metaInfo = metaInfo
)
)
else -> {
val exception = LLMClientException(
clientName,
"Unexpected response: no tool calls and no content"
)
logger.error(exception) { exception.message }
throw exception
}
}
}
}

View file

@ -0,0 +1,235 @@
package ee.carlrobert.codegpt.agent.clients
import ai.koog.prompt.params.LLMParams
import ai.koog.prompt.params.LLMParams.Schema
import ai.koog.prompt.params.LLMParams.ToolChoice
import ee.carlrobert.codegpt.settings.service.custom.CustomServiceChatCompletionSettingsState
import kotlinx.serialization.json.JsonElement
internal fun LLMParams.toCustomOpenAIParams(state: CustomServiceChatCompletionSettingsState): CustomOpenAIParams {
if (this is CustomOpenAIParams) return this
return CustomOpenAIParams(
temperature = state.body["temperature"] as? Double ?: temperature,
maxTokens = state.body["maxTokens"] as? Int ?: maxTokens,
numberOfChoices = state.body["numberOfChoices"] as? Int ?: numberOfChoices,
speculation = state.body["speculation"] as? String ?: speculation,
schema = state.body["schema"] as? Schema ?: schema,
toolChoice = state.body["toolChoice"] as? ToolChoice ?: toolChoice,
user = state.body["user"] as? String ?: user,
additionalProperties = state.body["additionalProperties"] as? Map<String, JsonElement>
?: additionalProperties,
)
}
/**
* CustomOpenAI chat-completions parameters layered on top of [LLMParams].
*
* @property temperature Sampling temperature in [0.0, 2.0]. Higher ⇒ more random;
* lower ⇒ more deterministic. Adjust this **or** [topP], not both.
* @property maxTokens Maximum number of tokens the model may generate for this response.
* @property numberOfChoices Number of completions to generate for the prompt (cost scales with N).
* @property speculation Provider-specific control for speculative decoding / draft acceleration.
* @property schema JSON Schema to constrain model output (validated when supported).
* @property toolChoice Controls if/which tool must be called (`none`/`auto`/`required`/specific).
* @property user stable end-user identifier
* @property includeThoughts Request inclusion of model “thoughts”/reasoning traces (model-dependent).
* @property thinkingBudget Soft cap on tokens spent on internal reasoning (reasoning models).
* @property additionalProperties Additional properties that can be used to store custom parameters.
* @property frequencyPenalty Number in [-2.0, 2.0]—penalizes frequent tokens to reduce repetition.
* @property presencePenalty Number in [-2.0, 2.0]—encourages introduction of new tokens/topics.
* @property logprobs Whether to include log-probabilities for output tokens.
* @property stop Stop sequences (0–4 items); generation halts before any of these.
* @property topLogprobs Number of top alternatives per position (0–20). Requires [logprobs] = true.
* @property topP Nucleus sampling in (0.0, 1.0]; use **instead of** [temperature].
* @property topK Number of top tokens to consider when generating output (min 1).
* @property repetitionPenalty Number in (0.0, 2.0] — penalizes token repetition.
* @property minP Minimum cumulative probability for token inclusion in sampling.
* @property topA Temperature scaling based on marginal probability gain.
* @property transforms List of context transforms.
* Defines how context is transformed when it exceeds the model's token limit.
* Default is ["middle-out"] which truncates from the middle of the prompt.
* Use empty list [] for no transformations.
* @property models List of allowed models for this request.
* @property route Request routing identifier.
* @property provider Model provider preferences.
*/
public open class CustomOpenAIParams(
temperature: Double? = null,
maxTokens: Int? = null,
numberOfChoices: Int? = null,
speculation: String? = null,
schema: Schema? = null,
toolChoice: ToolChoice? = null,
user: String? = null,
additionalProperties: Map<String, JsonElement>? = null,
public val frequencyPenalty: Double? = null,
public val presencePenalty: Double? = null,
public val logprobs: Boolean? = null,
public val stop: List<String>? = null,
public val topLogprobs: Int? = null,
public val topP: Double? = null,
public val topK: Int? = null,
public val repetitionPenalty: Double? = null,
public val minP: Double? = null,
public val topA: Double? = null,
public val transforms: List<String>? = null,
public val models: List<String>? = null,
public val route: String? = null,
) : LLMParams(
temperature, maxTokens, numberOfChoices,
speculation, schema, toolChoice,
user, additionalProperties
) {
init {
require(topP == null || topP in 0.0..1.0) {
"topP must be in (0.0, 1.0], but was $topP"
}
if (topLogprobs != null) {
require(logprobs == true) {
"`topLogprobs` requires `logprobs=true`."
}
require(topLogprobs in 0..20) {
"topLogprobs must be in [0, 20], but was $topLogprobs"
}
}
require(frequencyPenalty == null || frequencyPenalty in -2.0..2.0) {
"frequencyPenalty must be in [-2.0, 2.0], but was $frequencyPenalty"
}
require(presencePenalty == null || presencePenalty in -2.0..2.0) {
"presencePenalty must be in [-2.0, 2.0], but was $presencePenalty"
}
require(repetitionPenalty == null || repetitionPenalty in 0.0..2.0) {
"repetitionPenalty must be in (0.0, 2.0], but was $repetitionPenalty"
}
require(topK == null || topK >= 1) {
"topK must be in [1, Infinity], but was $topK"
}
require(minP == null || minP in 0.0..1.0) {
"minP must be in [0.0, 1.0], but was $minP"
}
require(topA == null || topA in 0.0..1.0) {
"topA must be in [0.0, 1.0], but was $topA"
}
// --- Stop sequences ---
if (stop != null) {
require(stop.isNotEmpty()) { "stop must not be empty when provided." }
require(stop.size <= 4) { "stop supports at most 4 sequences, but was ${stop.size}" }
require(stop.all { it.isNotBlank() }) { "stop sequences must not be blank." }
}
}
/**
* Creates a copy of this instance with the ability to modify any of its properties.
*/
public fun copy(
temperature: Double? = this.temperature,
maxTokens: Int? = this.maxTokens,
numberOfChoices: Int? = this.numberOfChoices,
speculation: String? = this.speculation,
schema: Schema? = this.schema,
toolChoice: ToolChoice? = this.toolChoice,
user: String? = this.user,
additionalProperties: Map<String, JsonElement>? = this.additionalProperties,
frequencyPenalty: Double? = this.frequencyPenalty,
presencePenalty: Double? = this.presencePenalty,
logprobs: Boolean? = this.logprobs,
stop: List<String>? = this.stop,
topLogprobs: Int? = this.topLogprobs,
topP: Double? = this.topP,
topK: Int? = this.topK,
repetitionPenalty: Double? = this.repetitionPenalty,
minP: Double? = this.minP,
topA: Double? = this.topA,
transforms: List<String>? = this.transforms,
models: List<String>? = this.models,
route: String? = this.route,
): CustomOpenAIParams = CustomOpenAIParams(
temperature = temperature,
maxTokens = maxTokens,
numberOfChoices = numberOfChoices,
speculation = speculation,
schema = schema,
toolChoice = toolChoice,
user = user,
additionalProperties = additionalProperties,
frequencyPenalty = frequencyPenalty,
presencePenalty = presencePenalty,
logprobs = logprobs,
stop = stop,
topLogprobs = topLogprobs,
topP = topP,
topK = topK,
repetitionPenalty = repetitionPenalty,
minP = minP,
topA = topA,
transforms = transforms,
models = models,
route = route,
)
override fun equals(other: Any?): Boolean = when {
this === other -> true
other !is CustomOpenAIParams -> false
else ->
temperature == other.temperature &&
maxTokens == other.maxTokens &&
numberOfChoices == other.numberOfChoices &&
speculation == other.speculation &&
schema == other.schema &&
toolChoice == other.toolChoice &&
user == other.user &&
additionalProperties == other.additionalProperties &&
frequencyPenalty == other.frequencyPenalty &&
presencePenalty == other.presencePenalty &&
logprobs == other.logprobs &&
stop == other.stop &&
topLogprobs == other.topLogprobs &&
topP == other.topP &&
topK == other.topK &&
repetitionPenalty == other.repetitionPenalty &&
minP == other.minP &&
topA == other.topA &&
transforms == other.transforms &&
models == other.models &&
route == other.route
}
override fun hashCode(): Int = listOf(
temperature, maxTokens, numberOfChoices,
speculation, schema, toolChoice,
user,
additionalProperties, frequencyPenalty, presencePenalty,
logprobs, stop, topLogprobs, topP,
topK, repetitionPenalty, minP,
topA, transforms, models, route,
).fold(0) { acc, element ->
31 * acc + (element?.hashCode() ?: 0)
}
override fun toString(): String = buildString {
append("CustomOpenAIParams(")
append("temperature=$temperature")
append(", maxTokens=$maxTokens")
append(", numberOfChoices=$numberOfChoices")
append(", speculation=$speculation")
append(", schema=$schema")
append(", toolChoice=$toolChoice")
append(", user=$user")
append(", additionalProperties=$additionalProperties")
append(", frequencyPenalty=$frequencyPenalty")
append(", presencePenalty=$presencePenalty")
append(", logprobs=$logprobs")
append(", stop=$stop")
append(", topLogprobs=$topLogprobs")
append(", topP=$topP")
append(", topK=$topK")
append(", repetitionPenalty=$repetitionPenalty")
append(", minP=$minP")
append(", topA=$topA")
append(", transforms=$transforms")
append(", models=$models")
append(", route=$route")
append(")")
}
}

View file

@ -0,0 +1,143 @@
package ee.carlrobert.codegpt.agent.clients
import ai.koog.prompt.executor.clients.openai.base.models.*
import ai.koog.prompt.executor.clients.serialization.AdditionalPropertiesFlatteningSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable
internal class ProxyAIChatCompletionRequest(
val messages: List<OpenAIMessage> = emptyList(),
val prompt: String? = null,
override val model: String? = null,
override val stream: Boolean? = null,
override val temperature: Double? = null,
val tools: List<OpenAITool>? = null,
val toolChoice: OpenAIToolChoice? = null,
override val topP: Double? = null,
override val topLogprobs: Int? = null,
val maxTokens: Int? = null,
val frequencyPenalty: Double? = null,
val presencePenalty: Double? = null,
val responseFormat: OpenAIResponseFormat? = null,
val stop: List<String>? = null,
val logprobs: Boolean? = null,
val seed: Int? = null,
val logitBias: Map<Int, Double>? = null,
val user: String? = null,
val additionalProperties: Map<String, JsonElement>? = null,
) : OpenAIBaseLLMRequest
/**
* Chat completion choice.
*/
@Serializable
public class ProxyAIChoice(
public val finishReason: String? = null,
public val nativeFinishReason: String? = null,
public val message: OpenAIMessage,
public val error: ProxyAIErrorResponse? = null
)
/**
* Chat completion streaming choice.
*/
@Serializable
public class ProxyAIStreamChoice(
public val finishReason: String? = null,
public val nativeFinishReason: String? = null,
public val delta: ProxyAIStreamDelta,
public val error: ProxyAIErrorResponse? = null
)
/**
* @property content The contents of the chunk message.
* @property role The role of the author of this message.
* @property toolCalls The tool calls requested by the model.
*/
@Serializable
public class ProxyAIStreamDelta(
public val content: String? = null,
public val role: String? = null,
public val toolCalls: List<ProxyAIToolCall>? = null
)
@Serializable
public class ProxyAIToolCall(
public val id: String? = "",
public val function: ProxyAIFunction
) {
/** The type of the tool. Currently, only `function` is supported. */
public val type: String = "function"
}
@Serializable
public class ProxyAIFunction(
public val name: String? = "",
public val arguments: String? = ""
)
/**
* Represents an error response structure typically used for conveying error details to the clients.
*/
@Serializable
public class ProxyAIErrorResponse(
public val code: Int,
public val message: String,
public val metadata: Map<String, String>? = null,
)
@Serializable
public class ProxyAICredits(
public val prompt: Long? = null,
public val completion: Long? = null,
public val total: Long? = null,
public val remaining: Long? = null,
)
@Serializable
public class ProxyAIUsage(
@SerialName("prompt_tokens")
public val promptTokens: Int? = null,
@SerialName("completion_tokens")
public val completionTokens: Int? = null,
@SerialName("total_tokens")
public val totalTokens: Int? = null,
public val credits: ProxyAICredits? = null,
)
/**
* ProxyAI Chat Completion Response.
*/
@Serializable
public class ProxyAIChatCompletionResponse(
public val choices: List<ProxyAIChoice> = emptyList(),
override val created: Long = 0L,
override val id: String = "",
override val model: String = "",
public val systemFingerprint: String? = null,
@SerialName("object")
public val objectType: String = "chat.completion",
public val usage: ProxyAIUsage? = null,
) : OpenAIBaseLLMResponse
/**
* ProxyAI Chat Completion Streaming Response.
*/
@Serializable
public class ProxyAIChatCompletionStreamResponse(
public val choices: List<ProxyAIStreamChoice> = emptyList(),
override val created: Long = 0L,
override val id: String = "",
override val model: String = "",
public val systemFingerprint: String? = null,
@SerialName("object")
public val objectType: String = "chat.completion.chunk",
public val usage: ProxyAIUsage? = null,
) : OpenAIBaseLLMStreamResponse
internal object ProxyAIChatCompletionRequestSerializer :
AdditionalPropertiesFlatteningSerializer<ProxyAIChatCompletionRequest>(
ProxyAIChatCompletionRequest.serializer()
)

View file

@ -0,0 +1,243 @@
package ee.carlrobert.codegpt.agent.clients
import ai.koog.prompt.dsl.ModerationResult
import ai.koog.prompt.dsl.Prompt
import ai.koog.prompt.executor.clients.ConnectionTimeoutConfig
import ai.koog.prompt.executor.clients.LLMClient
import ai.koog.prompt.executor.clients.LLMClientException
import ai.koog.prompt.executor.clients.openai.base.AbstractOpenAILLMClient
import ai.koog.prompt.executor.clients.openai.base.OpenAIBaseSettings
import ai.koog.prompt.executor.clients.openai.base.OpenAICompatibleToolDescriptorSchemaGenerator
import ai.koog.prompt.executor.clients.openai.base.models.*
import ai.koog.prompt.llm.LLMProvider
import ai.koog.prompt.llm.LLModel
import ai.koog.prompt.message.LLMChoice
import ai.koog.prompt.message.Message
import ai.koog.prompt.message.ResponseMetaInfo
import ai.koog.prompt.params.LLMParams
import ai.koog.prompt.streaming.StreamFrameFlowBuilder
import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.client.*
import kotlinx.datetime.Clock
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonObjectBuilder
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlin.io.encoding.ExperimentalEncodingApi
/**
* Configuration settings for connecting to the ProxyAI API.
*/
public class ProxyAIClientSettings(
baseUrl: String = DEFAULT_BASE_URL,
chatCompletionsPath: String = DEFAULT_CHAT_COMPLETIONS_PATH,
timeoutConfig: ConnectionTimeoutConfig = ConnectionTimeoutConfig(
requestTimeoutMillis = 120_000,
socketTimeoutMillis = 120_000
)
) : OpenAIBaseSettings(baseUrl, chatCompletionsPath, timeoutConfig) {
public companion object {
public const val DEFAULT_BASE_URL: String = "https://codegpt-api.carlrobert.ee"
public const val DEFAULT_CHAT_COMPLETIONS_PATH: String = "/v2/chat/completions"
}
}
/**
* Implementation of [LLMClient] for ProxyAI API.
*/
public class ProxyAILLMClient(
apiKey: String,
private val settings: ProxyAIClientSettings = ProxyAIClientSettings(),
baseClient: HttpClient = HttpClient(),
clock: Clock = Clock.System
) : AbstractOpenAILLMClient<ProxyAIChatCompletionResponse, ProxyAIChatCompletionStreamResponse>(
apiKey,
settings,
baseClient,
clock,
staticLogger,
OpenAICompatibleToolDescriptorSchemaGenerator()
) {
public data object ProxyAI : LLMProvider("proxyai", "ProxyAI")
companion object {
private val staticLogger = KotlinLogging.logger { }
init {
registerOpenAIJsonSchemaGenerators(ProxyAI)
}
}
override fun llmProvider(): LLMProvider = ProxyAI
override fun serializeProviderChatRequest(
messages: List<OpenAIMessage>,
model: LLModel,
tools: List<OpenAITool>?,
toolChoice: OpenAIToolChoice?,
params: LLMParams,
stream: Boolean
): String {
val proxyParams = params.toProxyAIParams()
val responseFormat = createResponseFormat(params.schema, model)
val request = ProxyAIChatCompletionRequest(
messages = messages,
model = model.id,
stream = stream,
temperature = proxyParams.temperature,
tools = tools,
toolChoice = proxyParams.toolChoice?.toOpenAIToolChoice(),
topP = proxyParams.topP,
topLogprobs = proxyParams.topLogprobs,
maxTokens = proxyParams.maxTokens,
frequencyPenalty = proxyParams.frequencyPenalty,
presencePenalty = proxyParams.presencePenalty,
responseFormat = responseFormat,
stop = proxyParams.stop,
logprobs = proxyParams.logprobs,
user = proxyParams.user,
additionalProperties = proxyParams.additionalProperties,
)
return json.encodeToString(ProxyAIChatCompletionRequestSerializer, request)
}
override fun processProviderChatResponse(response: ProxyAIChatCompletionResponse): List<LLMChoice> {
if (response.choices.isEmpty()) {
val errorMsg = "Empty choices in response. Response may contain an error."
logger.error { errorMsg }
throw LLMClientException(clientName, errorMsg)
}
// Check for errors in choices
response.choices.forEach { choice ->
choice.error?.let { error ->
val errorMsg = "ProxyAI API error (code: ${error.code}): ${error.message}"
logger.error { errorMsg }
throw LLMClientException(clientName, errorMsg)
}
}
return response.choices.map {
it.message.toResponses(it.finishReason, createProxyMetaInfo(response.usage))
}
}
override fun decodeStreamingResponse(data: String): ProxyAIChatCompletionStreamResponse =
json.decodeFromString(data)
override fun decodeResponse(data: String): ProxyAIChatCompletionResponse =
json.decodeFromString(data)
override suspend fun StreamFrameFlowBuilder.processStreamingChunk(chunk: ProxyAIChatCompletionStreamResponse) {
chunk.choices.firstOrNull()?.let { choice ->
choice.delta.content?.let { emitAppend(it) }
choice.delta.toolCalls?.forEachIndexed { index, toolCall ->
val id = toolCall.id
val name = toolCall.function.name
val arguments = toolCall.function.arguments
upsertToolCall(index, id, name, arguments)
}
choice.finishReason?.let { emitEnd(it, createProxyMetaInfo(chunk.usage)) }
}
}
override suspend fun moderate(prompt: Prompt, model: LLModel): ModerationResult {
logger.warn { "Moderation is not supported by ProxyAI API" }
throw UnsupportedOperationException("Moderation is not supported by ProxyAI API.")
}
@OptIn(ExperimentalEncodingApi::class)
private fun OpenAIMessage.toResponses(
finishReason: String?,
metaInfo: ResponseMetaInfo
): List<Message.Response> {
return when {
this is OpenAIMessage.Assistant && !this.toolCalls.isNullOrEmpty() -> {
val result = mutableListOf<Message.Response>()
if (this.content != null) {
result.add(
Message.Assistant(
content = this.content!!.text(),
finishReason = finishReason,
metaInfo = metaInfo
)
)
}
this.toolCalls!!.forEach { toolCall ->
result.add(
Message.Tool.Call(
id = toolCall.id,
tool = toolCall.function.name,
content = toolCall.function.arguments
.takeIf { it.isNotEmpty() }
?: "{}",
metaInfo = metaInfo
)
)
}
return result
}
this is OpenAIMessage.Assistant && this.reasoningContent != null && this.content != null -> listOf(
Message.Reasoning(
content = this.reasoningContent!!,
metaInfo = metaInfo
),
Message.Assistant(
content = this.content!!.text(),
finishReason = finishReason,
metaInfo = metaInfo
)
)
this.content != null -> listOf(
Message.Assistant(
content = this.content!!.text(),
finishReason = finishReason,
metaInfo = metaInfo
)
)
else -> {
val exception = LLMClientException(
clientName,
"Unexpected response: no tool calls and no content"
)
logger.error(exception) { exception.message }
throw exception
}
}
}
private fun createProxyMetaInfo(usage: ProxyAIUsage?): ResponseMetaInfo {
val metadata = usage?.credits?.toMetadata()
return ResponseMetaInfo.create(
clock = clock,
totalTokensCount = usage?.totalTokens,
inputTokensCount = usage?.promptTokens,
outputTokensCount = usage?.completionTokens,
metadata = metadata
)
}
private fun ProxyAICredits.toMetadata(): JsonObject? {
val creditsObject = buildJsonObject {
putIfNotNull("prompt", prompt)
putIfNotNull("completion", completion)
putIfNotNull("total", total)
putIfNotNull("remaining", remaining)
}
if (creditsObject.isEmpty()) return null
return buildJsonObject {
put("credits", creditsObject)
}
}
private fun JsonObjectBuilder.putIfNotNull(key: String, value: Long?) {
if (value != null) {
put(key, JsonPrimitive(value))
}
}
}

View file

@ -0,0 +1,151 @@
package ee.carlrobert.codegpt.agent.clients
import ai.koog.prompt.params.LLMParams
import ai.koog.prompt.params.LLMParams.Schema
import ai.koog.prompt.params.LLMParams.ToolChoice
import kotlinx.serialization.json.JsonElement
internal fun LLMParams.toProxyAIParams(): ProxyAIParams {
if (this is ProxyAIParams) return this
return ProxyAIParams(
temperature = temperature,
maxTokens = maxTokens,
numberOfChoices = numberOfChoices,
speculation = speculation,
schema = schema,
toolChoice = toolChoice,
user = user,
additionalProperties = additionalProperties,
)
}
/**
* ProxyAI chat-completions parameters layered on top of [LLMParams].
*/
public open class ProxyAIParams(
temperature: Double? = null,
maxTokens: Int? = null,
numberOfChoices: Int? = null,
speculation: String? = null,
schema: Schema? = null,
toolChoice: ToolChoice? = null,
user: String? = null,
additionalProperties: Map<String, JsonElement>? = null,
public val frequencyPenalty: Double? = null,
public val presencePenalty: Double? = null,
public val logprobs: Boolean? = null,
public val stop: List<String>? = null,
public val topLogprobs: Int? = null,
public val topP: Double? = null,
) : LLMParams(
temperature, maxTokens, numberOfChoices,
speculation, schema, toolChoice,
user, additionalProperties
) {
init {
require(topP == null || topP in 0.0..1.0) {
"topP must be in (0.0, 1.0], but was $topP"
}
if (topLogprobs != null) {
require(logprobs == true) {
"`topLogprobs` requires `logprobs=true`."
}
require(topLogprobs in 0..20) {
"topLogprobs must be in [0, 20], but was $topLogprobs"
}
}
require(frequencyPenalty == null || frequencyPenalty in -2.0..2.0) {
"frequencyPenalty must be in [-2.0, 2.0], but was $frequencyPenalty"
}
require(presencePenalty == null || presencePenalty in -2.0..2.0) {
"presencePenalty must be in [-2.0, 2.0], but was $presencePenalty"
}
if (stop != null) {
require(stop.isNotEmpty()) { "stop must not be empty when provided." }
require(stop.size <= 4) { "stop supports at most 4 sequences, but was ${stop.size}" }
require(stop.all { it.isNotBlank() }) { "stop sequences must not be blank." }
}
}
public fun copy(
temperature: Double? = this.temperature,
maxTokens: Int? = this.maxTokens,
numberOfChoices: Int? = this.numberOfChoices,
speculation: String? = this.speculation,
schema: Schema? = this.schema,
toolChoice: ToolChoice? = this.toolChoice,
user: String? = this.user,
additionalProperties: Map<String, JsonElement>? = this.additionalProperties,
frequencyPenalty: Double? = this.frequencyPenalty,
presencePenalty: Double? = this.presencePenalty,
logprobs: Boolean? = this.logprobs,
stop: List<String>? = this.stop,
topLogprobs: Int? = this.topLogprobs,
topP: Double? = this.topP,
): ProxyAIParams = ProxyAIParams(
temperature = temperature,
maxTokens = maxTokens,
numberOfChoices = numberOfChoices,
speculation = speculation,
schema = schema,
toolChoice = toolChoice,
user = user,
additionalProperties = additionalProperties,
frequencyPenalty = frequencyPenalty,
presencePenalty = presencePenalty,
logprobs = logprobs,
stop = stop,
topLogprobs = topLogprobs,
topP = topP,
)
override fun equals(other: Any?): Boolean = when {
this === other -> true
other !is ProxyAIParams -> false
else ->
temperature == other.temperature &&
maxTokens == other.maxTokens &&
numberOfChoices == other.numberOfChoices &&
speculation == other.speculation &&
schema == other.schema &&
toolChoice == other.toolChoice &&
user == other.user &&
additionalProperties == other.additionalProperties &&
frequencyPenalty == other.frequencyPenalty &&
presencePenalty == other.presencePenalty &&
logprobs == other.logprobs &&
stop == other.stop &&
topLogprobs == other.topLogprobs &&
topP == other.topP
}
override fun hashCode(): Int = listOf(
temperature, maxTokens, numberOfChoices,
speculation, schema, toolChoice,
user, additionalProperties,
frequencyPenalty, presencePenalty,
logprobs, stop, topLogprobs, topP,
).fold(0) { acc, element ->
31 * acc + (element?.hashCode() ?: 0)
}
override fun toString(): String = buildString {
append("ProxyAIParams(")
append("temperature=$temperature")
append(", maxTokens=$maxTokens")
append(", numberOfChoices=$numberOfChoices")
append(", speculation=$speculation")
append(", schema=$schema")
append(", toolChoice=$toolChoice")
append(", user=$user")
append(", additionalProperties=$additionalProperties")
append(", frequencyPenalty=$frequencyPenalty")
append(", presencePenalty=$presencePenalty")
append(", logprobs=$logprobs")
append(", stop=$stop")
append(", topLogprobs=$topLogprobs")
append(", topP=$topP")
append(")")
}
}

View file

@ -0,0 +1,27 @@
package ee.carlrobert.codegpt.agent.credits
import ai.koog.prompt.message.Message
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
data class CreditsSnapshot(
val remaining: Long,
val monthlyRemaining: Long,
val total: Long,
)
fun extractCreditsSnapshot(responses: List<Message.Response>): CreditsSnapshot? {
val creditsObject = responses.asReversed().firstNotNullOfOrNull { response ->
response.metaInfo.metadata?.get("credits")?.jsonObject
} ?: return null
val remaining = creditsObject.longOrNull("remaining") ?: return null
val monthlyRemaining = creditsObject.longOrNull("monthly_remaining") ?: remaining
val total = creditsObject.longOrNull("total") ?: 0
return CreditsSnapshot(remaining, monthlyRemaining, total)
}
private fun JsonObject.longOrNull(key: String): Long? {
return this[key]?.jsonPrimitive?.longOrNull
}

View file

@ -0,0 +1,301 @@
package ee.carlrobert.codegpt.agent.strategy
import ai.koog.agents.core.agent.config.AIAgentConfig
import ai.koog.agents.core.agent.entity.AIAgentGraphStrategy
import ai.koog.agents.core.agent.session.AIAgentLLMWriteSession
import ai.koog.agents.core.dsl.builder.AIAgentEdgeBuilderIntermediate
import ai.koog.agents.core.dsl.builder.EdgeTransformationDslMarker
import ai.koog.agents.core.dsl.builder.forwardTo
import ai.koog.agents.core.dsl.builder.strategy
import ai.koog.agents.core.dsl.extension.nodeExecuteMultipleTools
import ai.koog.agents.core.dsl.extension.onIsInstance
import ai.koog.agents.core.dsl.extension.onMultipleToolCalls
import ai.koog.agents.core.environment.ReceivedToolResult
import ai.koog.agents.core.environment.result
import ai.koog.agents.features.tokenizer.feature.tokenizer
import ai.koog.agents.snapshot.feature.AgentCheckpointData
import ai.koog.prompt.dsl.Prompt
import ai.koog.prompt.executor.model.PromptExecutor
import ai.koog.prompt.message.Message
import ai.koog.prompt.message.RequestMetaInfo
import ai.koog.prompt.streaming.StreamFrame
import ai.koog.prompt.streaming.toMessageResponses
import ai.koog.prompt.tokenizer.PromptTokenizer
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.LocalFileSystem
import ee.carlrobert.codegpt.ReferencedFile
import ee.carlrobert.codegpt.agent.AgentEvents
import ee.carlrobert.codegpt.agent.MessageWithContext
import ee.carlrobert.codegpt.agent.credits.extractCreditsSnapshot
import ee.carlrobert.codegpt.completions.CompletionRequestUtil
import ee.carlrobert.codegpt.settings.service.ServiceType
import ee.carlrobert.codegpt.toolwindow.agent.AgentCreditsEvent
import ee.carlrobert.codegpt.ui.textarea.TagProcessorFactory
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagDetails
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import java.util.*
import ee.carlrobert.codegpt.conversations.message.Message as ChatMessage
internal interface AgentRunStrategyProvider {
fun build(
project: Project,
executor: PromptExecutor,
projectInstructions: String?,
previousCheckpoint: AgentCheckpointData?,
pendingMessageQueue: ArrayDeque<MessageWithContext>,
events: AgentEvents,
sessionId: String,
provider: ServiceType,
stream: Boolean
): AIAgentGraphStrategy<MessageWithContext, String>
}
internal class SingleRunStrategyProvider : AgentRunStrategyProvider {
override fun build(
project: Project,
executor: PromptExecutor,
projectInstructions: String?,
previousCheckpoint: AgentCheckpointData?,
pendingMessageQueue: ArrayDeque<MessageWithContext>,
events: AgentEvents,
sessionId: String,
provider: ServiceType,
stream: Boolean
): AIAgentGraphStrategy<MessageWithContext, String> = strategy("single_run") {
val nodeCallLLM by node<MessageWithContext, List<Message.Response>> { message ->
llm.writeSession {
if (previousCheckpoint == null) {
projectInstructions?.let {
appendPrompt {
message(
Message.User(
it,
RequestMetaInfo(clock.now(), buildJsonObject {
put("cache_control", buildJsonObject {
put("type", JsonPrimitive("ephemeral"))
})
})
)
)
}
appendPrompt {
user(it)
}
}
} else {
val filteredMessageHistory = previousCheckpoint.messageHistory
.filter { it !is Message.System }
.toMutableList()
.apply {
if (lastOrNull() is Message.Tool.Call) {
removeLast()
}
}
for (previousMessage in filteredMessageHistory) {
if (previousMessage !is Message.System) {
appendPrompt { message(previousMessage) }
}
}
}
if (message.tags.isNotEmpty()) {
val context = buildTagContext(project, message.tags)
if (context.isNotBlank()) {
appendPrompt { user(context) }
}
}
appendPrompt { user(message.text) }
requestAndPublish(
executor,
config,
tokenizer(),
events,
sessionId,
provider,
stream
)
}
}
val nodeExecuteTool by nodeExecuteMultipleTools(parallelTools = true)
val nodeSendToolResult by node<List<ReceivedToolResult>, List<Message.Response>> { results ->
llm.writeSession {
appendPrompt {
results.forEach { tool { result(it) } }
}
val messages = prompt.messages
val toolCallMessages = messages.count { it is Message.Tool.Call }
val todoWriteToolUsed = messages.any { msg ->
msg is Message.Tool.Call && msg.tool == "TodoWrite"
}
if (toolCallMessages >= 2 && !todoWriteToolUsed) {
appendPrompt {
user("It seems that you haven't created a todo list yet. If the task on hand requires multiple steps then create a todo list to track your changes.")
}
}
if (pendingMessageQueue.isNotEmpty()) {
pendingMessageQueue.forEach {
appendPrompt { user(it.text) }
}
events.onQueuedMessagesResolved()
pendingMessageQueue.clear()
}
requestAndPublish(
executor,
config,
tokenizer(),
events,
sessionId,
provider,
stream
)
}
}
edge(nodeStart forwardTo nodeCallLLM)
edge(nodeCallLLM forwardTo nodeExecuteTool onMultipleToolCalls { true })
edge(nodeCallLLM forwardTo nodeFinish onSingleAssistantResponse { true })
edge(nodeExecuteTool forwardTo nodeSendToolResult)
edge(nodeSendToolResult forwardTo nodeFinish onSingleAssistantResponse { true })
edge(nodeSendToolResult forwardTo nodeExecuteTool onMultipleToolCalls { true })
}
}
private suspend fun AIAgentLLMWriteSession.requestResponses(
stream: Boolean,
executor: PromptExecutor,
config: AIAgentConfig,
appendResponse: (Message.Response) -> Unit
): List<Message.Response> {
val responses = if (stream) {
val streamFrames = mutableListOf<StreamFrame>()
val frames = requestLLMStreaming()
frames.collect { streamFrames.add(it) }
streamFrames.toMessageResponses()
} else {
val preparedPrompt = config.missingToolsConversionStrategy.convertPrompt(prompt, tools)
executor.execute(preparedPrompt, model, tools)
}
val appendableResponses = appendableResponses(responses)
appendableResponses.forEach(appendResponse)
return if (stream) responses else appendableResponses
}
private suspend fun AIAgentLLMWriteSession.requestAndPublish(
executor: PromptExecutor,
config: AIAgentConfig,
tokenizer: PromptTokenizer,
events: AgentEvents,
sessionId: String,
provider: ServiceType,
stream: Boolean
): List<Message.Response> {
val response = requestResponses(stream, executor, config) { appendPrompt { message(it) } }
publishUsageAndCredits(prompt, tokenizer, events, sessionId, provider, response)
return response
}
private fun appendableResponses(responses: List<Message.Response>): List<Message.Response> {
return responses
.sortedBy { if (it is Message.Assistant) 0 else 1 }
.filter { it !is Message.Reasoning }
}
private fun publishUsageAndCredits(
prompt: Prompt,
tokenizer: PromptTokenizer,
events: AgentEvents,
sessionId: String,
provider: ServiceType,
responses: List<Message.Response>,
) {
events.onTokenUsageAvailable(promptTokenCount(prompt, tokenizer).toLong())
publishCreditsIfAvailable(sessionId, provider, responses, events)
}
private fun promptTokenCount(prompt: Prompt, tokenizer: PromptTokenizer): Int {
return if (prompt.latestTokenUsage == 0) {
tokenizer.tokenCountFor(prompt)
} else {
prompt.latestTokenUsage
}
}
private fun buildTagContext(project: Project, tags: List<TagDetails>): String {
val contextBuilder = StringBuilder()
val tempMessage = ChatMessage("")
tags.forEach { tagDetails ->
val processor = TagProcessorFactory.getProcessor(project, tagDetails)
processor.process(tempMessage, contextBuilder)
}
val fileContext = buildReferencedFilesContext(tempMessage.referencedFilePaths.orEmpty())
val tagText = contextBuilder.toString().trim()
return buildString {
var hasContent = false
if (tagText.isNotBlank()) {
append(tagText)
hasContent = true
}
if (fileContext.isNotBlank()) {
if (hasContent) {
append("\n\n")
}
append(fileContext)
}
}.trim()
}
private fun buildReferencedFilesContext(referencedFilePaths: List<String>): String {
if (referencedFilePaths.isEmpty()) return ""
val vfs = LocalFileSystem.getInstance()
val referencedFiles = referencedFilePaths
.asSequence()
.mapNotNull { path -> vfs.findFileByPath(path) }
.distinctBy { it.path }
.map { ReferencedFile.from(it) }
.toList()
if (referencedFiles.isEmpty()) return ""
return CompletionRequestUtil.buildContextBlock(referencedFiles, null)
}
private fun publishCreditsIfAvailable(
sessionId: String,
provider: ServiceType,
responses: List<Message.Response>,
events: AgentEvents,
) {
if (provider != ServiceType.PROXYAI) return
val credits = extractCreditsSnapshot(responses) ?: return
val event = AgentCreditsEvent(
sessionId = sessionId,
remaining = credits.remaining,
monthlyRemaining = credits.monthlyRemaining,
consumed = credits.total
)
events.onCreditsAvailable(event)
}
@EdgeTransformationDslMarker
private infix fun <IncomingOutput, OutgoingInput> AIAgentEdgeBuilderIntermediate<IncomingOutput, List<Message.Response>, OutgoingInput>.onSingleAssistantResponse(
block: suspend (Message.Response) -> Boolean
): AIAgentEdgeBuilderIntermediate<IncomingOutput, String, OutgoingInput> {
return onIsInstance(List::class)
.transformed { response ->
response.filter { item -> item is Message.Response && item !is Message.Reasoning }
.filterIsInstance<Message.Response>()
}
.onCondition { it.size == 1 && it[0] is Message.Assistant }
.onCondition { messages -> block(messages[0]) }
.transformed { it[0].content }
}

View file

@ -0,0 +1,107 @@
package ee.carlrobert.codegpt.agent.tools
import ai.koog.agents.core.tools.Tool
import ai.koog.agents.core.tools.annotations.LLMDescription
import ee.carlrobert.codegpt.agent.AgentEvents
import kotlinx.serialization.Serializable
class AskUserQuestionTool(
private val events: AgentEvents
) : Tool<AskUserQuestionTool.Args, AskUserQuestionTool.Result>(
argsSerializer = Args.serializer(),
resultSerializer = Result.serializer(),
name = "AskUserQuestion",
description = """
Use this tool when you need to ask the user questions during execution. This allows you to:
1. Gather user preferences or requirements
2. Clarify ambiguous instructions
3. Get decisions on implementation choices as you work
4. Offer choices to the user about what direction to take.
Usage notes:
- Users will always be able to select "Other" to provide custom text input
- Use multiSelect: true to allow multiple answers to be selected for a question
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label
""".trimIndent()
) {
@Serializable
data class Option(
@property:LLMDescription("The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice.")
val label: String,
@property:LLMDescription("Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications.")
val description: String
)
@Serializable
data class Question(
@property:LLMDescription("The complete question to ask the user. Should be clear, specific, and end with a question mark. Example: \"Which library should we use for date formatting?\" If multiSelect is true, phrase it accordingly, e.g. \"Which features do you want to enable?\"")
val question: String,
@property:LLMDescription("Very short label displayed as a chip/tag (max 12 chars). Examples: \"Auth method\", \"Library\", \"Approach\".")
val header: String,
@property:LLMDescription("The available choices for this question. Must have 2-4 options. Each option should be a distinct, mutually exclusive choice (unless multiSelect is enabled). There should be no 'Other' option, that will be provided automatically.")
val options: List<Option>,
@property:LLMDescription("Set to true to allow the user to select multiple options instead of just one. Use when choices are not mutually exclusive.")
val multiSelect: Boolean
)
@Serializable
data class Args(
@property:LLMDescription("Questions to ask the user (1-4 questions)")
val questions: List<Question>,
@property:LLMDescription("User answers collected by the permission component")
val answers: Map<String, String>? = null
)
@Serializable
sealed class Result {
@Serializable
data class Success(
val answers: Map<String, String>
) : Result()
@Serializable
data class Error(
val message: String
) : Result()
}
override suspend fun execute(args: Args): Result {
if (args.questions.isEmpty()) {
return Result.Error("At least one question is required")
}
if (args.questions.size > 4) {
return Result.Error("Maximum of 4 questions allowed")
}
args.questions.forEach { q ->
if (q.options.size !in 2..4) return Result.Error("Question '${q.header}' must have 2-4 options")
}
val collected = try {
events.askUserQuestions(
AskUserQuestionsModel(
questions = args.questions,
prefilledAnswers = args.answers ?: emptyMap()
)
)
} catch (e: Exception) {
return Result.Error(e.message ?: "Question flow cancelled or failed")
}
return Result.Success(collected)
}
override fun encodeResultToString(result: Result): String = when (result) {
is Result.Success -> buildString {
append("User answered:\n")
result.answers.forEach { (k, v) -> append("- ").append(k).append(": ").append(v).append('\n') }
}.trimEnd()
is Result.Error -> "AskUserQuestion failed: ${result.message}"
}
@Serializable
data class AskUserQuestionsModel(
val questions: List<Question>,
val prefilledAnswers: Map<String, String> = emptyMap()
)
}

View file

@ -0,0 +1,100 @@
package ee.carlrobert.codegpt.agent.tools
import com.intellij.openapi.diagnostic.thisLogger
import kotlinx.coroutines.*
import java.util.concurrent.ConcurrentHashMap
object BackgroundProcessManager {
private val logger = thisLogger()
private val processes = ConcurrentHashMap<String, Process>()
private val outputBuffers = ConcurrentHashMap<String, ProcessOutput>()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
data class ProcessOutput(
val stdout: StringBuilder = StringBuilder(),
val stderr: StringBuilder = StringBuilder(),
var exitCode: Int? = null,
var isComplete: Boolean = false
)
fun registerProcess(id: String, process: Process) {
processes[id] = process
outputBuffers[id] = ProcessOutput()
scope.launch {
try {
collectProcessOutput(process, id)
} catch (e: Exception) {
logger.error("Could not register process with id: $id", e)
cleanup(id)
}
}
}
fun getProcess(id: String): Process? = processes[id]
fun getOutput(id: String): ProcessOutput? = outputBuffers[id]
fun terminateProcess(id: String): Boolean = try {
processes[id]?.let { process ->
if (process.isAlive) {
process.destroyForcibly()
}
}
cleanup(id)
true
} catch (e: Exception) {
logger.error("Could not terminate process with id: $id", e)
false
}
private fun cleanup(id: String) {
processes.remove(id)
scope.launch {
delay(30000)
outputBuffers.remove(id)
}
}
private suspend fun collectProcessOutput(process: Process, bashId: String) {
val output = outputBuffers[bashId] ?: return
withContext(Dispatchers.IO) {
val stdoutJob = launch {
try {
process.inputStream.bufferedReader().useLines { lines ->
lines.forEach { line ->
output.stdout.appendLine(line)
}
}
} catch (_: Exception) {
// Stream closed, ignore
}
}
val stderrJob = launch {
try {
process.errorStream.bufferedReader().useLines { lines ->
lines.forEach { line ->
output.stderr.appendLine(line)
}
}
} catch (_: Exception) {
// Stream closed, ignore
}
}
try {
process.waitFor()
output.exitCode = process.exitValue()
output.isComplete = true
} catch (_: Exception) {
output.isComplete = true
}
stdoutJob.join()
stderrJob.join()
}
}
}

View file

@ -0,0 +1,136 @@
package ee.carlrobert.codegpt.agent.tools
import ai.koog.agents.core.tools.Tool
import ai.koog.agents.core.tools.annotations.LLMDescription
import com.intellij.openapi.application.ApplicationManager
import ee.carlrobert.codegpt.agent.AgentToolOutputNotifier
import ee.carlrobert.codegpt.agent.ToolRunContext
import ee.carlrobert.codegpt.tokens.truncateToolResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
class BashOutputTool(private val sessionId: String = "global") :
Tool<BashOutputTool.Args, BashOutputTool.Result>(
argsSerializer = Args.serializer(),
resultSerializer = Result.serializer(),
name = "BashOutput",
description = """
- Retrieves output from a running or completed background bash shell
- Takes a shell_id parameter identifying the shell
- Always returns only new output since the last check
- Returns stdout and stderr output along with shell status
- Supports optional regex filtering to show only lines matching a pattern
- Use this tool when you need to monitor or check the output of a long-running shell
- Shell IDs can be found using the Bash tool with run_in_background=true
""".trimIndent()
) {
@Serializable
data class Args(
@property:LLMDescription(
"The ID of the background shell to retrieve output from"
)
@SerialName("bash_id")
val bashId: String,
@property:LLMDescription(
"Optional regular expression to filter the output lines. Only lines matching this regex will be included in the result."
)
val filter: String? = null
)
@Serializable
data class Result(
@SerialName("bash_id")
val bashId: String,
val stdout: String,
val stderr: String,
val status: String,
@SerialName("exit_code")
val exitCode: Int?
)
override suspend fun execute(args: Args): Result = withContext(Dispatchers.IO) {
val processOutput = BackgroundProcessManager.getOutput(args.bashId)
?: return@withContext Result(
bashId = args.bashId,
stdout = "",
stderr = "",
status = "not_found",
exitCode = null
)
val stdout = processOutput.stdout.toString()
val stderr = processOutput.stderr.toString()
val filteredStdout = getFilteredOutput(stdout, args.filter)
val filteredStderr = getFilteredOutput(stderr, args.filter)
val toolId = ToolRunContext.getToolId(sessionId)
if (toolId != null) {
val publisher = ApplicationManager.getApplication()
.messageBus
.syncPublisher(AgentToolOutputNotifier.AGENT_TOOL_OUTPUT_TOPIC)
if (filteredStdout.isNotEmpty()) {
filteredStdout.split('\n').forEach { line ->
if (line.isNotEmpty()) publisher.toolOutput(toolId, line, false)
}
}
if (filteredStderr.isNotEmpty()) {
filteredStderr.split('\n').forEach { line ->
if (line.isNotEmpty()) publisher.toolOutput(toolId, line, true)
}
}
}
val status = when {
processOutput.isComplete -> "completed"
BackgroundProcessManager.getProcess(args.bashId)?.isAlive == true -> "running"
else -> "terminated"
}
Result(
bashId = args.bashId,
stdout = filteredStdout.trimEnd(),
stderr = filteredStderr.trimEnd(),
status = status,
exitCode = processOutput.exitCode
)
}
override fun encodeResultToString(result: Result): String =
buildString {
appendLine("Shell ID: ${result.bashId}")
appendLine("Status: ${result.status}")
if (result.exitCode != null) {
appendLine("Exit code: ${result.exitCode}")
}
if (result.stdout.isNotEmpty()) {
appendLine()
appendLine("STDOUT:")
appendLine(result.stdout)
}
if (result.stderr.isNotEmpty()) {
appendLine()
appendLine("STDERR:")
appendLine(result.stderr)
}
if (result.stdout.isEmpty() && result.stderr.isEmpty() && result.status == "running") {
appendLine()
appendLine("(No new output since last check)")
}
}.trimEnd().truncateToolResult()
private fun getFilteredOutput(output: String, filter: String?): String {
return if (filter != null) {
val regex = Regex(filter)
output.lines().filter { it.matches(regex) }.joinToString("\n")
} else {
output
}
}
}

View file

@ -0,0 +1,502 @@
package ee.carlrobert.codegpt.agent.tools
import ai.koog.agents.core.tools.Tool
import ai.koog.agents.core.tools.annotations.LLMDescription
import ai.koog.agents.ext.tool.shell.ShellCommandConfirmation
import com.intellij.openapi.application.ApplicationManager
import ee.carlrobert.codegpt.agent.AgentToolOutputNotifier
import ee.carlrobert.codegpt.agent.ToolRunContext
import ee.carlrobert.codegpt.settings.ProxyAISettingsService
import ee.carlrobert.codegpt.tokens.truncateToolResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.io.IOException
import java.nio.file.Paths
import java.util.*
import kotlin.coroutines.cancellation.CancellationException
fun interface BashCommandConfirmationHandler {
suspend fun requestConfirmation(args: BashTool.Args): ShellCommandConfirmation
}
class BashTool(
private val workingDirectory: String,
private val confirmationHandler: BashCommandConfirmationHandler,
private val sessionId: String = "global",
private val settingsService: ProxyAISettingsService? = null
) : Tool<BashTool.Args, BashTool.Result>(
argsSerializer = Args.serializer(),
resultSerializer = Result.serializer(),
name = "Bash",
description = $$"""
Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.
<env>
Working directory: $$workingDirectory
</env>
Before executing the command, please follow these steps:
1. Directory Verification:
- If the command will create new directories or files, first use `ls` to verify the parent directory exists and is the correct location
- For example, before running "mkdir foo/bar", first use `ls foo` to check that "foo" exists and is the intended parent directory
2. Command Execution:
- Always quote file paths that contain spaces with double quotes (e.g., cd "path with spaces/file.txt")
- Examples of proper quoting:
- cd "/Users/name/My Documents" (correct)
- cd /Users/name/My Documents (incorrect - will fail)
- python "/path/with spaces/script.py" (correct)
- python /path/with spaces/script.py (incorrect - will fail)
- After ensuring proper quoting, execute the command.
- Capture the output of the command.
Usage notes:
- The command argument is required.
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes).
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- If the output exceeds 30000 characters, output will be truncated before being returned to you.
- You can use the `run_in_background` parameter to run the command in the background, which allows you to continue working while the command runs. You can monitor the output using the Bash tool as it becomes available. You do not need to use '&' at the end of the command when using this parameter.
- Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
- File search: Use Glob (NOT find or ls)
- Content search: Use Grep (NOT grep or rg)
- Read files: Use Read (NOT cat/head/tail)
- Edit files: Use Edit (NOT sed/awk)
- Write files: Use Write (NOT echo >/cat <<EOF)
- Communication: Output text directly (NOT echo/printf)
- When issuing multiple commands:
- If the commands are independent and can run in parallel, make multiple Bash tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two Bash tool calls in parallel.
- If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m "message" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead.
- Use ';' only when you need to run commands sequentially but don't care if earlier commands fail
- DO NOT use newlines to separate commands (newlines are ok in quoted strings)
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of `cd`. You may use `cd` if the User explicitly requests it.
<good-example>
pytest /foo/bar/tests
</good-example>
<bad-example>
cd /foo/bar && pytest tests
</bad-example>
# Committing changes with git
Only create commits when requested by the user. If unclear, ask first. When the user asks you to create a new git commit, follow these steps carefully:
Git Safety Protocol:
- NEVER update the git config
- NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them
- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it
- NEVER run force push to main/master, warn the user if they request it
- Avoid git commit --amend. ONLY use --amend when either (1) user explicitly requested amend OR (2) adding edits from pre-commit hook (additional instructions below)
- Before amending: ALWAYS check authorship (git log -1 --format='%an %ae')
- NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel, each using the Bash tool:
- Run a git status command to see all untracked files.
- Run a git diff command to see both staged and unstaged changes that will be committed.
- Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.
2. Analyze all staged changes (both previously staged and newly added) and draft a commit message:
- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.). Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.).
- Do not commit files that likely contain secrets (.env, credentials.json, etc). Warn the user if they specifically request to commit those files
- Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what"
- Ensure it accurately reflects the changes and their purpose
3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands:
- Add relevant untracked files to the staging area.
- Run git status after the commit completes to verify success.
Note: git status depends on the commit completing, so run it sequentially after the commit.
4. If the commit fails due to pre-commit hook changes, retry ONCE. If it succeeds but files were modified by the hook, verify it's safe to amend:
- Check HEAD commit: git log -1 --format='[%h] (%an <%ae>) %s'. VERIFY it matches your commit
- Check not pushed: git status shows "Your branch is ahead"
- If both true: amend your commit. Otherwise: create NEW commit (never amend other developers' commits)
Important notes:
- NEVER run additional commands to read or explore code, besides git bash commands
- NEVER use the TodoWrite or Task tools
- DO NOT push to the remote repository unless the user explicitly asks you to do so
- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.
- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit
- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:
<example>
git commit -m "$(cat <<'EOF'
Commit message here.
EOF
)"
</example>
# Creating pull requests
Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a Github URL use the gh command to get the information needed.
IMPORTANT: When the user asks you to create a pull request, follow these steps carefully:
1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. Run the following bash commands in parallel using the Bash tool, in order to understand the current state of the branch since it diverged from the main branch:
- Run a git status command to see all untracked files
- Run a git diff command to see both staged and unstaged changes that will be committed
- Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote
- Run a git log command and `git diff [base-branch]...HEAD` to understand the full commit history for the current branch (from the time it diverged from the base branch)
2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request summary
3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel:
- Create new branch if needed
- Push to remote with -u flag if needed
- Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.
<example>
gh pr create --title "the pr title" --body "$(cat <<'EOF'
## Summary
<1-3 bullet points>
## Test plan
[Bulleted markdown checklist of TODOs for testing the pull request...]
🤖 Generated with [ProxyAI](https://tryproxy.io/)
EOF
)"
</example>
Important:
- DO NOT use the TodoWrite or Task tools
- Return the PR URL when you're done, so the user can see it
# Other common operations
- View comments on a Github PR: gh api repos/foo/bar/pulls/123/comments
""".trimIndent()
) {
@Serializable
data class Args(
@property:LLMDescription(
"The command to execute"
)
val command: String,
@property:LLMDescription(
"Optional timeout in milliseconds (max 600000)"
)
val timeout: Int = 60_000,
@property:LLMDescription(
"Clear, concise description of what this command does in 5-10 words, in active voice. Examples:\n" +
"Input: ls\n" +
"Output: List files in current directory\n\n" +
"Input: git status\n" +
"Output: Show working tree status\n\n" +
"Input: npm install\n" +
"Output: Install package dependencies\n\n" +
"Input: mkdir foo\n" +
"Output: Create directory 'foo'"
)
val description: String? = null,
@property:LLMDescription(
"Set to true to run this command in the background. Use BashOutput to read the output later."
)
@SerialName("run_in_background")
val runInBackground: Boolean? = null,
)
@Serializable
data class Result(
val command: String,
val exitCode: Int?,
val output: String,
@SerialName("bash_id")
val bashId: String? = null
)
private fun isWhiteListed(args: Args): Boolean {
val permissions = settingsService?.getBashPermissions() ?: emptyList()
return permissions.any { pattern ->
when {
pattern.startsWith("Bash(") && pattern.endsWith(":*)") -> {
val commandPattern = pattern.removePrefix("Bash(").removeSuffix(":*)")
args.command.startsWith(commandPattern)
}
pattern.startsWith("Bash(") && pattern.endsWith(")") -> {
val commandPattern = pattern.removePrefix("Bash(").removeSuffix(")")
args.command == commandPattern
}
else -> false
}
}
}
override suspend fun execute(args: Args): Result {
if (shouldBlockByIgnore(args.command)) {
return Result(
args.command,
null,
"Command denied by policy: access to ignored files is blocked",
null
)
}
val isAskPattern = shouldAskForConfirmation(args.command)
val toolId = ToolRunContext.getToolId(sessionId)
?: throw IllegalArgumentException("Tool ID is missing")
val confirmation = when {
isWhiteListed(args) && !isAskPattern -> ShellCommandConfirmation.Approved
else -> confirmationHandler.requestConfirmation(args)
}
return when (confirmation) {
is ShellCommandConfirmation.Approved -> {
try {
if (args.runInBackground == true) {
val bashId = executeBackground(args.command)
Result(
args.command,
null,
"Background process started with ID: $bashId",
bashId
)
} else {
runForegroundWithStreaming(toolId, args)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result(args.command, null, "Failed to execute command: ${e.message}", null)
}
}
is ShellCommandConfirmation.Denied -> {
Result(
args.command,
null,
"Command execution denied with user response: ${confirmation.userResponse}",
null
)
}
}
}
private suspend fun runForegroundWithStreaming(toolId: String, args: Args): Result {
val publisher = ApplicationManager.getApplication().messageBus
.syncPublisher(AgentToolOutputNotifier.AGENT_TOOL_OUTPUT_TOPIC)
return withContext(Dispatchers.IO) {
val stdoutBuilder = StringBuilder()
val stderrBuilder = StringBuilder()
val shellCommand = buildShellCommand(args.command)
val process = ProcessBuilder(shellCommand)
.apply {
directory(java.io.File(workingDirectory))
redirectErrorStream(false)
}
.start()
try {
val job = currentCoroutineContext()[Job]
job?.invokeOnCompletion { cause ->
if (cause is CancellationException && process.isAlive) {
runCatching { process.destroyForcibly() }
}
}
val stdoutJob = launch {
try {
process.inputStream.bufferedReader().useLines { lines ->
lines.forEach { line ->
stdoutBuilder.appendLine(line)
publisher.toolOutput(toolId, line, false)
}
}
} catch (_: IOException) {
// Ignore IO exception if the stream is closed
}
}
val stderrJob = launch {
try {
process.errorStream.bufferedReader().useLines { lines ->
lines.forEach { line ->
stderrBuilder.appendLine(line)
publisher.toolOutput(toolId, line, true)
}
}
} catch (_: IOException) {
// Ignore IO exception if the stream is closed
}
}
val timeoutMs = args.timeout.coerceIn(1, 600_000)
val isCompleted = withTimeoutOrNull(timeoutMs.toLong()) {
withContext(Dispatchers.IO) {
process.waitFor()
}
} != null
if (!isCompleted) {
process.destroyForcibly()
}
stdoutJob.join()
stderrJob.join()
val combinedOutput = buildCombinedOutput(
stdoutBuilder.toString().trimEnd(),
stderrBuilder.toString().trimEnd(),
if (!isCompleted) "Command timed out after ${timeoutMs}ms" else null
)
Result(
args.command,
if (isCompleted) process.exitValue() else null,
combinedOutput,
null
)
} finally {
if (process.isAlive) {
process.destroyForcibly()
}
}
}
}
private fun buildCombinedOutput(
stdout: String,
stderr: String,
message: String? = null
): String {
return buildString {
if (stdout.isNotEmpty()) appendLine(stdout)
if (stderr.isNotEmpty()) appendLine(stderr)
message?.let { appendLine(it) }
}.trimEnd()
}
override fun encodeResultToString(result: Result): String = with(result) {
val raw = buildString {
appendLine("Command: $command")
if (output.isNotEmpty()) {
appendLine(output)
} else if (exitCode != null) {
appendLine("(no output)")
}
exitCode?.let {
appendLine("Exit code: $it")
}
}.trimEnd()
raw.truncateToolResult()
}
private fun executeBackground(command: String): String {
val bashId = UUID.randomUUID().toString()
val shellCommand = buildShellCommand(command)
val process = ProcessBuilder(shellCommand)
.apply {
directory(java.io.File(workingDirectory))
redirectErrorStream(false)
}
.start()
BackgroundProcessManager.registerProcess(bashId, process)
return bashId
}
private fun buildShellCommand(command: String): List<String> {
val isWindows = System.getProperty("os.name").lowercase().contains("windows")
return if (isWindows) {
listOf("cmd", "/c", command)
} else {
listOf("sh", "-c", command)
}
}
private fun shouldAskForConfirmation(command: String): Boolean {
val askPatterns = listOf(
"find * -delete*",
"find * -exec*",
"find * -fprint*",
"find * -fls*",
"find * -fprintf*",
"find * -ok*",
"sort --output=*",
"sort -o *",
"tree -o *"
)
return askPatterns.any { pattern ->
if (pattern.contains("*")) {
command.startsWith(pattern.removeSuffix("*"))
} else {
command == pattern
}
}
}
private fun shouldBlockByIgnore(command: String): Boolean {
val svc = settingsService ?: return false
val readers = setOf("cat", "grep", "rg", "sed", "awk", "head", "tail", "less", "more", "wc", "stat", "file")
val tokens = tokenize(command)
val paths = mutableListOf<String>()
var lastWasReader = false
var expectFileArg = false
tokens.forEach { t ->
if (t.startsWith("-")) {
if (t == "-f" || t.startsWith("--file=")) {
expectFileArg = true
val eq = t.indexOf('=')
if (eq > 0 && eq < t.length - 1) paths.add(t.substring(eq + 1))
}
return@forEach
}
if (expectFileArg) {
paths.add(t)
expectFileArg = false
return@forEach
}
val redirIdx = maxOf(t.lastIndexOf('>'), t.lastIndexOf('<'))
if (redirIdx >= 0 && redirIdx < t.length - 1) {
val target = t.substring(redirIdx + 1)
if (target.isNotBlank()) paths.add(target)
}
val tokenIsReader = readers.contains(t)
if (lastWasReader && !tokenIsReader) paths.add(t)
if (!tokenIsReader && looksLikePath(t)) paths.add(t)
lastWasReader = tokenIsReader
}
return paths.any { candidate -> svc.isPathIgnored(toAbsolute(candidate)) }
}
private fun tokenize(s: String): List<String> {
val out = ArrayList<String>()
val sb = StringBuilder()
var i = 0
var quote: Char? = null
while (i < s.length) {
val c = s[i]
if (quote == null) {
if (c == '\'' || c == '"') {
quote = c
} else if (c.isWhitespace()) {
if (sb.isNotEmpty()) { out.add(sb.toString()); sb.setLength(0) }
} else {
sb.append(c)
}
} else {
if (c == quote) quote = null else sb.append(c)
}
i += 1
}
if (sb.isNotEmpty()) out.add(sb.toString())
return out
}
private fun looksLikePath(token: String): Boolean =
token.startsWith("/") || token.startsWith("./") || token.startsWith("../") || token.contains('/')
private fun toAbsolute(token: String): String {
return try {
val p = Paths.get(token)
val abs = if (p.isAbsolute) p else Paths.get(workingDirectory).resolve(p)
abs.normalize().toString()
} catch (_: Exception) {
token
}
}
}

View file

@ -0,0 +1,54 @@
package ee.carlrobert.codegpt.agent.tools
import ai.koog.agents.core.tools.Tool
class ConfirmingWriteTool(
private val delegate: WriteTool,
private val approve: suspend (name: String, details: String) -> Boolean
) : Tool<WriteTool.Args, WriteTool.Result>(
argsSerializer = WriteTool.Args.serializer(),
resultSerializer = WriteTool.Result.serializer(),
name = delegate.name,
description = delegate.descriptor.description
) {
override suspend fun execute(args: WriteTool.Args): WriteTool.Result {
val details = buildString {
append("Path: ")
append(args.filePath)
append("\n")
append("Bytes: ")
append(args.content.toByteArray().size)
}
val ok = approve("Write", details)
if (!ok) {
return WriteTool.Result.Error(
filePath = args.filePath,
error = "User rejected write operation"
)
}
return delegate.execute(args)
}
}
class ConfirmingEditTool(
private val delegate: EditTool,
private val approve: suspend (name: String, details: String) -> Boolean
) : Tool<EditTool.Args, EditTool.Result>(
argsSerializer = EditTool.Args.serializer(),
resultSerializer = EditTool.Result.serializer(),
name = delegate.name,
description = delegate.descriptor.description
) {
override suspend fun execute(args: EditTool.Args): EditTool.Result {
val ok = approve("Edit", args.shortDescription)
if (!ok) {
return EditTool.Result.Error(
filePath = args.filePath,
error = "User rejected edit operation"
)
}
return delegate.execute(args)
}
}

View file

@ -0,0 +1,309 @@
package ee.carlrobert.codegpt.agent.tools
import ai.koog.agents.core.tools.Tool
import ai.koog.agents.core.tools.annotations.LLMDescription
import com.intellij.openapi.application.runReadAction
import com.intellij.openapi.command.WriteCommandAction
import com.intellij.openapi.editor.Document
import com.intellij.openapi.fileEditor.FileDocumentManager
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.TextRange
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.openapi.vfs.VirtualFileManager
import ee.carlrobert.codegpt.tokens.truncateToolResult
import ee.carlrobert.codegpt.settings.ProxyAISettingsService
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.io.File
class EditTool(private val project: Project) : Tool<EditTool.Args, EditTool.Result>(
argsSerializer = Args.serializer(),
resultSerializer = Result.serializer(),
name = "Edit",
description = """
Performs an exact string replacement in a file.
The tool searches for the exact old_string pattern and replaces it with new_string.
Matching is exact - whitespace, indentation, and all characters must match precisely.
Parameters:
- file_path: Absolute path to the file
- old_string: Exact string to find (case-sensitive, includes whitespace)
- new_string: Replacement string
- short_description: Short description of the requested edit.
- replace_all: If true, replaces ALL occurrences. If false, replaces only the first occurrence
Important notes:
- Always verify the old_string matches exactly before attempting edits
- Use replace_all=true for global replacements
- The file must exist and be writable
- Binary files are not supported
- old_string and new_string must be different
- Tool will fail if no replacements are made
""".trimIndent()
) {
private fun getLineAndColumn(document: Document, offset: Int): Pair<Int, Int> {
val line = document.getLineNumber(offset)
val lineStart = document.getLineStartOffset(line)
val column = offset - lineStart
return Pair(line + 1, column)
}
private fun getLineContext(document: Document, line: Int, contextLines: Int = 2): String {
val startLine = maxOf(0, line - contextLines)
val endLine = minOf(document.lineCount - 1, line + contextLines)
return buildString {
for (i in startLine..endLine) {
val lineStart = document.getLineStartOffset(i)
val lineEnd = document.getLineEndOffset(i)
appendLine(document.getText(TextRange(lineStart, lineEnd)))
}
}.trim()
}
@Serializable
data class Args(
@property:LLMDescription(
"The absolute path to the file to edit. Must be an absolute path, not a relative path."
)
@SerialName("file_path")
val filePath: String,
@property:LLMDescription(
"The exact string to search for in the file. Must match exactly including whitespace and indentation."
)
@SerialName("old_string")
val oldString: String,
@property:LLMDescription(
"The new string to replace the old string with."
)
@SerialName("new_string")
val newString: String,
@property:LLMDescription(
"Short description of the edit."
)
@SerialName("short_description")
val shortDescription: String,
@property:LLMDescription(
"Replace all occurrences of old_string with new_string. Default is false (only replace first occurrence)."
)
@SerialName("replace_all")
val replaceAll: Boolean = false
)
@Serializable
data class EditLocation(
val line: Int,
val column: Int,
val context: String
)
@Serializable
sealed class Result {
@Serializable
data class Success(
val filePath: String,
val replacementsMade: Int,
val message: String,
val oldStringPreview: String? = null,
val newStringPreview: String? = null,
val editLocations: List<EditLocation> = emptyList()
) : Result()
@Serializable
data class Error(
val filePath: String,
val error: String
) : Result()
}
override suspend fun execute(args: Args): Result {
return try {
val svc = project.getService(ProxyAISettingsService::class.java)
if (svc.isPathIgnored(args.filePath)) {
return Result.Error(
filePath = args.filePath,
error = ".proxyai ignore rules block editing this path"
)
}
val normalizedPath = args.filePath.replace("\\", "/")
val file = File(normalizedPath)
if (!file.exists()) {
return Result.Error(
filePath = args.filePath,
error = "File not found: ${args.filePath} (File does not exist on filesystem)"
)
}
if (!file.isFile) {
return Result.Error(
filePath = args.filePath,
error = "Path is not a file: ${args.filePath}"
)
}
if (!file.canWrite()) {
return Result.Error(
filePath = args.filePath,
error = "File is not writable: ${args.filePath}"
)
}
val virtualFile =
VirtualFileManager.getInstance().findFileByUrl("file://$normalizedPath")
?: VirtualFileManager.getInstance()
.refreshAndFindFileByUrl("file://$normalizedPath")
?: LocalFileSystem.getInstance().findFileByIoFile(file)
?: return Result.Error(
filePath = args.filePath,
error = "File not found in IntelliJ VFS: ${args.filePath}"
)
if (args.oldString.isEmpty()) {
return Result.Error(
filePath = args.filePath,
error = "old_string cannot be empty"
)
}
if (args.oldString == args.newString) {
return Result.Error(
filePath = args.filePath,
error = "old_string and new_string are identical. No replacement would be made."
)
}
val document =
runReadAction { FileDocumentManager.getInstance().getDocument(virtualFile) }
?: return Result.Error(
filePath = args.filePath,
error = "Cannot get document for file: ${args.filePath}"
)
val content = document.text
if (!content.contains(args.oldString)) {
return Result.Error(
filePath = args.filePath,
error = "String not found in file: '${args.oldString.take(50)}${if (args.oldString.length > 50) "..." else ""}'"
)
}
val editLocations = mutableListOf<EditLocation>()
if (args.replaceAll) {
var lastIndex = 0
while (true) {
val index = content.indexOf(args.oldString, lastIndex)
if (index == -1) break
val (line, column) = getLineAndColumn(document, index)
val context = getLineContext(document, line - 1)
editLocations.add(EditLocation(line, column, context))
lastIndex = index + args.oldString.length
}
} else {
val index = content.indexOf(args.oldString)
if (index != -1) {
val (line, column) = getLineAndColumn(document, index)
val context = getLineContext(document, line - 1)
editLocations.add(EditLocation(line, column, context))
}
}
val replacementsMade = WriteCommandAction.runWriteCommandAction<Int>(project) {
var count = 0
if (args.replaceAll) {
var lastIndex = 0
while (true) {
val index = content.indexOf(args.oldString, lastIndex)
if (index == -1) break
val range = TextRange(index, index + args.oldString.length)
document.replaceString(
range.startOffset,
range.endOffset,
args.newString
)
count++
lastIndex = index + args.newString.length
}
} else {
val index = content.indexOf(args.oldString)
if (index != -1) {
val range = TextRange(index, index + args.oldString.length)
document.replaceString(
range.startOffset,
range.endOffset,
args.newString
)
count = 1
}
}
FileDocumentManager.getInstance().saveDocument(document)
count
}
if (replacementsMade == 0) {
return Result.Error(
filePath = args.filePath,
error = "No replacements made. The old_string and new_string may be identical, or the replacement operation failed."
)
}
Result.Success(
filePath = args.filePath,
replacementsMade = replacementsMade,
message = "Successfully made $replacementsMade replacement${if (replacementsMade != 1) "s" else ""}",
oldStringPreview = args.oldString.trim().take(200),
newStringPreview = args.newString.trim().take(200),
editLocations = editLocations
)
} catch (e: Exception) {
Result.Error(
filePath = args.filePath,
error = "Failed to edit file: ${e.message}"
)
}
}
override fun encodeResultToString(result: Result): String = when (result) {
is Result.Success -> {
val raw = buildString {
appendLine("Successfully edited file '${result.filePath}'")
appendLine("Made ${result.replacementsMade} replacement${if (result.replacementsMade != 1) "s" else ""}")
if (result.replacementsMade > 0) {
appendLine()
appendLine("Changes made:")
appendLine("- File: ${result.filePath}")
appendLine("- Replacements: ${result.replacementsMade}")
if (result.oldStringPreview != null || result.newStringPreview != null) {
appendLine()
if (result.oldStringPreview != null) {
val oldPreview = if (result.oldStringPreview.length > 100) {
result.oldStringPreview.take(97) + "..."
} else result.oldStringPreview
appendLine("- Old text: \"$oldPreview\"")
}
if (result.newStringPreview != null) {
val newPreview = if (result.newStringPreview.length > 100) {
result.newStringPreview.take(97) + "..."
} else result.newStringPreview
appendLine("- New text: \"$newPreview\"")
}
}
}
}.trimEnd()
raw.truncateToolResult()
}
is Result.Error -> {
("Error editing file '${result.filePath}': ${result.error}").truncateToolResult()
}
}
}

View file

@ -0,0 +1,179 @@
package ee.carlrobert.codegpt.agent.tools
import ai.koog.agents.core.tools.Tool
import ai.koog.agents.core.tools.annotations.LLMDescription
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import org.jsoup.Jsoup
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import ee.carlrobert.codegpt.tokens.truncateToolResult
/**
* Tool for fetching library documentation from Context7.
*
* This tool retrieves documentation for a specific library using its
* Context7-compatible ID. It supports both code documentation and
* informational guides.
*/
class GetLibraryDocsTool : Tool<GetLibraryDocsTool.Args, GetLibraryDocsTool.Result>(
argsSerializer = Args.serializer(),
resultSerializer = Result.serializer(),
name = "GetLibraryDocs",
description = """
Use when the user asks how to use a library, best practices, APIs, configuration, or conventions. Resolve the library first with ResolveLibraryId unless the user provided a Context7-compatible ID. Use mode='code' for API/code, or mode='info' for guides.
""".trimIndent()
) {
@Serializable
data class Args(
@property:LLMDescription(
"Exact Context7-compatible library ID (e.g., '/mongodb/docs', '/vercel/next.js', '/supabase/supabase', '/vercel/next.js/v14.3.0-canary.87') retrieved from 'resolve-library-id' or directly from user query in the format '/org/project' or '/org/project/version'."
)
val context7CompatibleLibraryID: String,
@property:LLMDescription(
"Documentation mode: 'code' for API references and code examples (default), 'info' for conceptual guides, narrative information, and architectural questions."
)
val mode: String = "code",
@property:LLMDescription(
"Topic to focus documentation on (e.g., 'hooks', 'routing')."
)
val topic: String? = null,
@property:LLMDescription(
"Page number for pagination (start: 1, default: 1). If the context is not sufficient, try page=2, page=3, page=4, etc. with the same topic."
)
val page: Int = 1
)
@Serializable
sealed class Result {
@Serializable
data class Success(
val libraryId: String,
val documentation: String,
val docMode: String,
val page: Int
) : Result()
@Serializable
data class Error(
val error: String
) : Result()
}
override suspend fun execute(args: Args): Result = withContext(Dispatchers.IO) {
try {
if (!args.context7CompatibleLibraryID.startsWith("/")) {
return@withContext Result.Error(
"Invalid library ID format: '${args.context7CompatibleLibraryID}'. Library ID must start with '/' (e.g., '/vercel/next.js'). " +
"Use 'ResolveLibraryId' tool first to get the correct library ID format."
)
}
if (args.mode.lowercase() !in listOf("code", "info")) {
return@withContext Result.Error(
"Invalid documentation mode: '${args.mode}'. Must be either 'code' or 'info'."
)
}
if (args.page !in 1..10) {
return@withContext Result.Error(
"Invalid page number: ${args.page}. Page number must be between 1 and 10."
)
}
val libraryComponents = parseLibraryId(args.context7CompatibleLibraryID)
val baseUrl = "https://context7.com/api/v2/docs/${args.mode.lowercase()}"
val urlBuilder = StringBuilder("$baseUrl/${libraryComponents.username}/${libraryComponents.library}")
if (libraryComponents.tag != null) {
urlBuilder.append("/${libraryComponents.tag}")
}
urlBuilder.append("?type=txt")
if (args.topic != null) {
urlBuilder.append("&topic=${URLEncoder.encode(args.topic, StandardCharsets.UTF_8.toString())}")
}
if (args.page > 1) {
urlBuilder.append("&page=${args.page}")
}
val url = urlBuilder.toString()
val doc = Jsoup.connect(url)
.userAgent("Mozilla/5.0 (compatible; ProxyAI/1.0; +https://tryproxy.io)")
.timeout(10000)
.ignoreContentType(true)
.get()
val documentation = doc.body().text().take(10_000)
if (documentation.isEmpty()) {
return@withContext Result.Error(
"No documentation available for library '${args.context7CompatibleLibraryID}' in ${args.mode} mode."
)
}
if (documentation.contains("No content available") ||
documentation.contains("No context data available")) {
val suggestion = if (args.mode.lowercase() == "code") {
" Try mode='info' for guides and tutorials."
} else {
" Try mode='code' for API references and code examples."
}
return@withContext Result.Error(
"No ${args.mode} documentation available for this library.$suggestion"
)
}
return@withContext Result.Success(
libraryId = args.context7CompatibleLibraryID,
documentation = documentation,
docMode = args.mode,
page = args.page
)
} catch (e: Exception) {
val errorMessage = when {
e.message?.contains("404", ignoreCase = true) == true ->
"The library '${args.context7CompatibleLibraryID}' was not found. Please check the library ID or use 'ResolveLibraryId' to find the correct ID."
e.message?.contains("429", ignoreCase = true) == true ->
"Rate limited due to too many requests. Please try again later."
e.message?.contains("401", ignoreCase = true) == true ->
"Unauthorized access to Context7 service."
else ->
"Failed to fetch documentation: ${e.message}"
}
return@withContext Result.Error(errorMessage)
}
}
override fun encodeResultToString(result: Result): String = when (result) {
is Result.Success -> result.documentation.truncateToolResult()
is Result.Error -> ("Failed to retrieve library documentation: ${result.error}").truncateToolResult()
}
private fun parseLibraryId(libraryId: String): LibraryComponents {
val cleaned = libraryId.removePrefix("/")
val parts = cleaned.split("/")
if (parts.size < 2) {
throw IllegalArgumentException(
"Invalid library ID format: $libraryId. Expected format: /username/library or /username/library/tag"
)
}
return LibraryComponents(
username = parts[0],
library = parts[1],
tag = if (parts.size > 2) parts[2] else null
)
}
private data class LibraryComponents(
val username: String,
val library: String,
val tag: String?
)
}

View file

@ -0,0 +1,372 @@
package ee.carlrobert.codegpt.agent.tools
import ai.koog.agents.core.tools.Tool
import ai.koog.agents.core.tools.annotations.LLMDescription
import com.intellij.ide.util.gotoByName.ChooseByNameModel
import com.intellij.ide.util.gotoByName.GotoClassModel2
import com.intellij.ide.util.gotoByName.GotoFileModel
import com.intellij.ide.util.gotoByName.GotoSymbolModel2
import com.intellij.openapi.application.runReadAction
import com.intellij.openapi.editor.Document
import com.intellij.openapi.project.Project
import com.intellij.openapi.project.guessProjectDir
import com.intellij.openapi.roots.ProjectRootManager
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.psi.*
import com.intellij.psi.codeStyle.NameUtil
import com.intellij.psi.search.GlobalSearchScope
import com.intellij.psi.search.GlobalSearchScopesCore
import ee.carlrobert.codegpt.tokens.truncateToolResult
import ee.carlrobert.codegpt.settings.ProxyAISettingsService
import kotlinx.serialization.Serializable
import java.nio.file.Paths
import com.intellij.openapi.util.TextRange
/**
* Enhanced search tool using IntelliJ's native SearchService and FindModel.
*/
class IntelliJSearchTool(
private val project: Project
) : Tool<IntelliJSearchTool.Args, IntelliJSearchTool.Result>(
argsSerializer = Args.serializer(),
resultSerializer = Result.serializer(),
name = "IntelliJSearch",
description = """
Search Everywhere-style name search (files, classes, symbols).
Features:
- Uses the same models as Goto File/Class/Symbol
- Respects scope (project/all) and de-duplicates results
- Optimized with NameUtil's MinusculeMatcher like the IDE
Notes:
- This is a name-oriented search, not content search
- For content search, use the Grep tool instead
""".trimIndent()
) {
companion object {
const val MAX_CONTEXT_CHARS = 200
const val MAX_OUTPUT_LINES = 100
const val MAX_OUTPUT_CHARS = 6000
}
@Serializable
data class Args(
@property:LLMDescription(
"The text or pattern to search for"
)
val pattern: String,
@property:LLMDescription(
"Search scope: 'project', 'module', 'directory', 'file', or 'custom'"
)
val scope: String? = null,
@property:LLMDescription(
"File or directory to search in. If not specified, uses project scope."
)
val path: String? = null,
@property:LLMDescription(
"File type filter (e.g., 'java', 'kotlin', 'js', 'py')"
)
val fileType: String? = null,
@property:LLMDescription(
"Search context: 'ANY', 'IN_CODE', 'IN_COMMENTS', 'IN_STRING_LITERALS', 'EXCEPT_COMMENTS', 'EXCEPT_STRING_LITERALS'"
)
val context: String? = null,
@property:LLMDescription(
"Case sensitive search. Default: false"
)
val caseSensitive: Boolean? = null,
@property:LLMDescription(
"Regular expression search. Default: false"
)
val regex: Boolean? = null,
@property:LLMDescription(
"Whole word search. Default: false"
)
val wholeWords: Boolean? = null,
@property:LLMDescription(
"Output mode: 'content', 'files_with_matches', 'count'"
)
val outputMode: String? = null,
@property:LLMDescription(
"Maximum number of results to return"
)
val limit: Int? = null
)
@Serializable
data class Result(
val pattern: String,
val scope: String,
val totalMatches: Int,
val matches: List<SearchMatch>,
val output: String
)
@Serializable
data class SearchMatch(
val file: String,
val line: Int?,
val column: Int?,
val text: String,
val context: String?
)
override suspend fun execute(args: Args): Result {
try {
val searchScope = createSearchScope(args, project)
val effectiveLimit = (args.limit ?: 10).coerceAtLeast(1)
val matches =
runReadAction { searchEverywhere(args.pattern, searchScope, effectiveLimit) }
val output = formatOutput(matches, args)
return Result(
pattern = args.pattern,
scope = args.scope ?: "project",
totalMatches = matches.size,
matches = matches,
output = output
)
} catch (e: Exception) {
return Result(
pattern = args.pattern,
scope = args.scope ?: "project",
totalMatches = 0,
matches = emptyList(),
output = "Search failed: ${e.message}"
)
}
}
private fun createSearchScope(args: Args, project: Project): GlobalSearchScope {
return when (args.scope?.lowercase()) {
"project" -> GlobalSearchScope.projectScope(project)
"all", "everything", "anywhere" -> GlobalSearchScope.allScope(project)
"directory" -> {
val vf = args.path?.let { resolvePath(it) }
if (vf != null && vf.isDirectory) GlobalSearchScopesCore.directoriesScope(
project,
true,
vf
)
else GlobalSearchScope.projectScope(project)
}
"file" -> {
val vf = args.path?.let { resolvePath(it) }
if (vf != null && !vf.isDirectory) GlobalSearchScope.filesScope(project, listOf(vf))
else GlobalSearchScope.projectScope(project)
}
"module" -> {
val roots = ProjectRootManager.getInstance(project).contentRoots
if (roots.isNotEmpty()) {
GlobalSearchScopesCore.directoriesScope(project, true, *roots)
} else GlobalSearchScope.projectScope(project)
}
else -> GlobalSearchScope.projectScope(project)
}
}
private fun searchEverywhere(
pattern: String,
scope: GlobalSearchScope,
limit: Int?
): List<SearchMatch> {
val results = mutableListOf<SearchMatch>()
val max = (limit ?: 10).coerceAtLeast(1)
val tokens = pattern.split(Regex("[^A-Za-z0-9]+")).filter { it.isNotBlank() }
val tokenMatchers = if (tokens.isEmpty()) {
listOf(NameUtil.buildMatcher("*${pattern.trim()}*").build())
} else {
tokens.map { t -> NameUtil.buildMatcher("*$t*").build() }
}
fun addFromModel(model: ChooseByNameModel) {
val names: Array<String> = when (model) {
is GotoFileModel -> model.getNames(true)
is GotoClassModel2 -> model.getNames(true)
is GotoSymbolModel2 -> model.getNames(true)
else -> emptyArray()
}
val ranked = names.mapNotNull { name ->
val degree = tokenMatchers.maxOfOrNull { it.matchingDegree(name) } ?: Int.MIN_VALUE
if (degree != Int.MIN_VALUE) name to degree else null
}.sortedByDescending { it.second }
for ((name, _) in ranked) {
val elements = when (model) {
is GotoFileModel -> model.getElementsByName(name, true, name)
is GotoClassModel2 -> model.getElementsByName(name, true, name)
is GotoSymbolModel2 -> model.getElementsByName(name, true, name)
else -> emptyArray()
}.filterIsInstance<PsiElement>()
for (psi in elements) {
val target = psi.navigationElement ?: psi
val psiFile = target.containingFile
val vf = psiFile?.virtualFile
if (vf != null && !scope.contains(vf)) continue
val offset = when (target) {
is PsiNameIdentifierOwner -> target.nameIdentifier?.textOffset
?: target.textOffset
else -> target.textOffset
}.coerceAtLeast(0)
val document =
psiFile?.let { PsiDocumentManager.getInstance(project).getDocument(it) }
val (line, col) = computeLineCol(document, offset)
val context = if (psiFile != null) getContextText(psiFile, offset) else null
val displayFile = vf?.path ?: (psiFile?.name ?: psi.toString())
val svc = project.getService(ProxyAISettingsService::class.java)
if (vf?.path != null && svc.isPathIgnored(vf.path)) continue
results.add(
SearchMatch(
file = displayFile,
line = line,
column = col,
text = (target as? PsiNamedElement)?.name ?: name,
context = context
)
)
if (results.size >= max) return
}
}
}
addFromModel(GotoFileModel(project))
if (results.size >= max) return results.distinctBy { Triple(it.file, it.line, it.column) }
addFromModel(GotoClassModel2(project))
if (results.size >= max) return results.distinctBy { Triple(it.file, it.line, it.column) }
addFromModel(GotoSymbolModel2(project, project))
return results.distinctBy { Triple(it.file, it.line, it.column) }.take(max)
}
private fun computeLineCol(document: Document?, offset: Int): Pair<Int?, Int?> {
return if (document != null) {
val line = document.getLineNumber(offset)
val col = offset - document.getLineStartOffset(line)
Pair(line + 1, col)
} else {
Pair(null, null)
}
}
private fun resolvePath(relativeOrAbsolute: String): VirtualFile? {
val fs = LocalFileSystem.getInstance()
if (Paths.get(relativeOrAbsolute).isAbsolute) {
return fs.findFileByPath(relativeOrAbsolute)
}
val base = project.basePath
if (base != null) {
val abs = Paths.get(base).resolve(relativeOrAbsolute).normalize().toString()
fs.findFileByPath(abs)?.let { return it }
}
val projDir = guessProjectDir()
return projDir?.findFileByRelativePath(relativeOrAbsolute)
}
private fun guessProjectDir(): VirtualFile? = project.guessProjectDir()
private fun getContextText(psiFile: PsiFile?, offset: Int): String? {
if (psiFile == null) return null
val document = PsiDocumentManager.getInstance(project).getDocument(psiFile) ?: return null
val lineNumber = document.getLineNumber(offset)
val lineStart = document.getLineStartOffset(lineNumber)
val lineEnd = document.getLineEndOffset(lineNumber)
return document.getText(TextRange(lineStart, lineEnd)).trim()
}
private fun formatOutput(matches: List<SearchMatch>, args: Args): String {
val outputMode = args.outputMode?.lowercase() ?: "content"
return when (outputMode) {
"files_with_matches" -> {
val lines = matches.map { it.file }.distinct()
lines.take(MAX_OUTPUT_LINES).joinToString("\n").truncate(MAX_OUTPUT_CHARS)
}
"count" -> {
val fileCounts = matches.groupBy { it.file }.mapValues { it.value.size }
val lines = fileCounts.map { "${it.key}: ${it.value}" }
lines.take(MAX_OUTPUT_LINES).joinToString("\n").truncate(MAX_OUTPUT_CHARS)
}
"content" -> {
val lines = matches.map { match ->
val location = if (match.line != null) {
"${match.file}:${match.line}"
} else {
match.file
}
val context = match.context
?.let {
it.condenseWhitespace()
.take(MAX_CONTEXT_CHARS) + if (it.length > MAX_CONTEXT_CHARS) "..." else ""
}
?.let { " | $it" } ?: ""
"$location: ${match.text}$context"
}
lines.take(MAX_OUTPUT_LINES).joinToString("\n").truncate(MAX_OUTPUT_CHARS)
}
else -> matches.joinToString("\n") { "${it.file}: ${it.text}" }
}
}
override fun encodeResultToString(result: Result): String {
val raw = result.output
val lines = if (raw.isEmpty()) emptyList() else raw.lines()
val limitedLines = lines.take(MAX_OUTPUT_LINES)
val limitedOutput = limitedLines.joinToString("\n").truncate(MAX_OUTPUT_CHARS)
val truncatedInfo = buildString {
val linesTruncated = lines.size - limitedLines.size
if (linesTruncated > 0) append(" (showing first ${limitedLines.size} lines)")
val charsTruncated = raw.length - limitedOutput.length
if (charsTruncated > 0) append(" (truncated $charsTruncated chars)")
}
val s = buildString {
appendLine("Pattern: ${result.pattern}")
appendLine("Scope: ${result.scope}")
appendLine("Total matches: ${result.totalMatches}")
if (result.matches.isNotEmpty()) {
appendLine()
appendLine(limitedOutput)
if (truncatedInfo.isNotEmpty()) appendLine(truncatedInfo.trim())
} else {
appendLine("No matches found.")
}
}.trimEnd()
return s.truncateToolResult()
}
}
private fun String.truncate(max: Int): String =
if (length <= max) this else this.substring(0, max)
private fun String.condenseWhitespace(): String =
this.replace("\r", " ").replace("\n", " ").replace(Regex("\\s+"), " ").trim()

View file

@ -0,0 +1,91 @@
package ee.carlrobert.codegpt.agent.tools
import ai.koog.agents.core.tools.Tool
import ai.koog.agents.core.tools.annotations.LLMDescription
import ee.carlrobert.codegpt.tokens.truncateToolResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
class KillShellTool : Tool<KillShellTool.Args, KillShellTool.Result>(
argsSerializer = Args.serializer(),
resultSerializer = Result.serializer(),
name = "KillShell",
description = """
- Kills a running background bash shell by its ID
- Takes a shell_id parameter identifying the shell to kill
- Returns a success or failure status
- Use this tool when you need to terminate a long-running shell
- Shell IDs can be found using the Bash tool with run_in_background=true
""".trimIndent()
) {
@Serializable
data class Args(
@property:LLMDescription(
"The ID of the background shell to kill"
)
@SerialName("bash_id")
val bashId: String
)
@Serializable
data class Result(
@SerialName("bash_id")
val bashId: String,
val success: Boolean,
val message: String
)
override suspend fun execute(args: Args): Result = withContext(Dispatchers.IO) {
val process = BackgroundProcessManager.getProcess(args.bashId)
?: return@withContext Result(
bashId = args.bashId,
success = false,
message = "Shell not found. The shell may have already completed or the ID is invalid."
)
if (!process.isAlive) {
val exitCode = runCatching { process.exitValue() }.getOrNull()
return@withContext Result(
bashId = args.bashId,
success = false,
message = "Shell has already completed${exitCode?.let { " with exit code $it" } ?: ""}"
)
}
return@withContext try {
val terminated = BackgroundProcessManager.terminateProcess(args.bashId)
if (terminated) {
Result(
bashId = args.bashId,
success = true,
message = "Shell successfully terminated."
)
} else {
Result(
bashId = args.bashId,
success = false,
message = "Failed to terminate shell. The process may have already completed."
)
}
} catch (e: Exception) {
Result(
bashId = args.bashId,
success = false,
message = "Error terminating shell: ${e.message}"
)
}
}
override fun encodeResultToString(result: Result): String =
buildString {
appendLine("Shell ID: ${result.bashId}")
if (result.success) {
appendLine("✓ ${result.message}")
} else {
appendLine("✗ ${result.message}")
}
}.trimEnd().truncateToolResult()
}

View file

@ -0,0 +1,208 @@
package ee.carlrobert.codegpt.agent.tools
import ai.koog.agents.core.tools.Tool
import ai.koog.agents.core.tools.annotations.LLMDescription
import com.intellij.openapi.application.runReadAction
import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.fileEditor.FileDocumentManager
import com.intellij.openapi.fileTypes.FileTypeManager
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.VirtualFileManager
import ee.carlrobert.codegpt.settings.ProxyAISettingsService
import ee.carlrobert.codegpt.tokens.truncateToolResult
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.nio.charset.StandardCharsets
/**
* Reads a file from the local filesystem using IntelliJ's Document and VirtualFile APIs.
*/
class ReadTool(private val project: Project) : Tool<ReadTool.Args, ReadTool.Result>(
argsSerializer = Args.serializer(),
resultSerializer = Result.serializer(),
name = "Read",
description = """
Reads a file from the local filesystem. You can access any file directly by using this tool.
Assume this tool is able to read all files on the machine. If the user provides a path to a file, assume that path is valid.
By default, reads up to 2000 lines starting from the beginning of the file.
You can optionally specify a line offset and limit to read specific sections.
Any lines longer than 2000 characters will be truncated.
Results are returned using cat -n format, with line numbers starting at 1.
This tool can read images (eg PNG, JPG, etc), PDF files, and Jupyter notebooks.
- The file_path parameter must be an absolute path
- For large files, use offset and limit to read in chunks
- Images are returned visually for analysis
- PDFs are processed page by page
- Jupyter notebooks return all cells with outputs
""".trimIndent()
) {
companion object {
private val logger = thisLogger()
}
@Serializable
data class Args(
@property:LLMDescription(
"The absolute path to the file to read. Must be an absolute path, not a relative path."
)
@SerialName("file_path")
val filePath: String,
@property:LLMDescription(
"The line number to start reading from (1-indexed). Only provide if the file is too large to read at once."
)
val offset: Int? = null,
@property:LLMDescription(
"The number of lines to read. Only provide if the file is too large to read at once."
)
val limit: Int? = null
)
@Serializable
sealed class Result {
@Serializable
data class Success(
val filePath: String,
val content: String,
val lineCount: Int,
val truncated: Boolean,
val fileType: String?,
val startLine: Int? = null,
val endLine: Int? = null
) : Result()
@Serializable
data class Error(
val filePath: String,
val error: String
) : Result()
}
override suspend fun execute(args: Args): Result {
return try {
return runReadAction {
val svc = project.getService(ProxyAISettingsService::class.java)
if (svc.isPathIgnored(args.filePath)) {
return@runReadAction Result.Error(
filePath = args.filePath,
error = "Access to this path is blocked by .proxyai ignore rules"
)
}
val virtualFile =
VirtualFileManager.getInstance().findFileByUrl("file://${args.filePath}")
?: return@runReadAction Result.Error(
filePath = args.filePath,
error = "File not found: ${args.filePath}"
)
if (virtualFile.isDirectory) {
return@runReadAction Result.Error(
filePath = args.filePath,
error = "Path is a directory, not a file: ${args.filePath}"
)
}
val fileType = FileTypeManager.getInstance().getFileTypeByFile(virtualFile)
when {
fileType.isBinary -> {
Result.Error(
filePath = args.filePath,
error = "Binary files are not supported by ProxyAI yet."
)
}
else -> {
val content = try {
val document =
FileDocumentManager.getInstance().getDocument(virtualFile)
document?.text ?: String(
virtualFile.contentsToByteArray(),
StandardCharsets.UTF_8
)
} catch (e: Exception) {
logger.error("Error reading document from $virtualFile", e)
String(virtualFile.contentsToByteArray(), StandardCharsets.UTF_8)
}
val lines = content.lines()
val totalLines = lines.size
val (startIdx, endIdx, truncated) = when {
args.offset != null && args.limit != null -> {
val start = (args.offset - 1).coerceIn(0, totalLines)
val end = (start + args.limit).coerceIn(start, totalLines)
Triple(start, end, end < totalLines)
}
args.offset != null -> {
val start = (args.offset - 1).coerceIn(0, totalLines)
Triple(start, totalLines, false)
}
args.limit != null -> {
Triple(
0,
args.limit.coerceIn(0, totalLines),
totalLines > args.limit
)
}
else -> {
val limit = 2000
Triple(0, limit.coerceIn(0, totalLines), totalLines > limit)
}
}
val startLine = if (startIdx == 0) null else startIdx + 1
val endLine = if (endIdx == totalLines) null else endIdx
val selectedLines = lines.subList(startIdx, endIdx)
val numberedContent = buildString {
selectedLines.forEachIndexed { index, line ->
val lineNumber = startIdx + index + 1
appendLine("${lineNumber}\t${line.take(2000)}")
}
}
Result.Success(
filePath = args.filePath,
content = numberedContent.trimEnd(),
lineCount = selectedLines.size,
truncated = truncated,
fileType = fileType.name,
startLine = startLine,
endLine = endLine
)
}
}
}
} catch (e: Exception) {
Result.Error(
filePath = args.filePath,
error = "Failed to read file: ${e.message}"
)
}
}
override fun encodeResultToString(result: Result): String = when (result) {
is Result.Success -> {
val raw = buildString {
if (result.truncated) {
appendLine("Note: Content truncated. Use offset parameter to read more lines.")
appendLine()
}
append(result.content)
}.trimEnd()
raw.truncateToolResult()
}
is Result.Error -> {
("Error reading file '${result.filePath}': ${result.error}").truncateToolResult()
}
}
}

View file

@ -0,0 +1,216 @@
package ee.carlrobert.codegpt.agent.tools
import ai.koog.agents.core.tools.Tool
import ai.koog.agents.core.tools.annotations.LLMDescription
import ee.carlrobert.codegpt.tokens.truncateToolResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.jsoup.Jsoup
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
/**
* Tool for resolving library names to Context7-compatible library IDs.
*/
class ResolveLibraryIdTool : Tool<ResolveLibraryIdTool.Args, ResolveLibraryIdTool.Result>(
argsSerializer = Args.serializer(),
resultSerializer = Result.serializer(),
name = "ResolveLibraryId",
description = """
Use to resolve a library/package name into a Context7-compatible ID before fetching docs. Prefer this when the user asks about libraries, best practices, APIs, or configuration, unless they already provided a valid '/org/project[/version]' ID.
""".trimIndent()
) {
@Serializable
data class Args(
@property:LLMDescription("Library name to search for and retrieve a Context7-compatible library ID.")
@SerialName("library_name")
val libraryName: String
)
@Serializable
data class LibraryInfo(
val id: String,
val name: String,
val description: String,
val codeSnippets: Int,
val sourceReputation: String,
val benchmarkScore: Double,
val versions: List<String>?
)
@Serializable
sealed class Result {
@Serializable
data class Success(
val libraryName: String,
val libraries: List<LibraryInfo>
) : Result()
@Serializable
data class Error(
val error: String
) : Result()
}
override val json = Json {
ignoreUnknownKeys = true
isLenient = true
}
override suspend fun execute(args: Args): Result = withContext(Dispatchers.IO) {
try {
val encodedQuery =
URLEncoder.encode(args.libraryName, StandardCharsets.UTF_8.toString())
val url = "https://context7.com/api/v2/search?query=$encodedQuery"
val doc = Jsoup.connect(url)
.userAgent("Mozilla/5.0 (compatible; ProxyAI/1.0; +https://tryproxy.io)")
.timeout(10000)
.ignoreContentType(true)
.get()
val jsonResponse = doc.body().text()
if (jsonResponse.isEmpty()) {
return@withContext Result.Error("Empty response from Context7 API")
}
try {
val searchResponse = json.decodeFromString<Context7SearchResponse>(jsonResponse)
val libraries = searchResponse.results.map { result ->
LibraryInfo(
id = result.id,
name = result.title ?: result.id.split("/").lastOrNull() ?: "Unknown",
description = result.description ?: "No description available",
codeSnippets = result.totalSnippets ?: 0,
sourceReputation = when {
result.trustScore == null -> "Unknown"
result.trustScore > 8.0 -> "High"
result.trustScore > 5.0 -> "Medium"
else -> "Low"
},
benchmarkScore = result.benchmarkScore ?: 0.0,
versions = result.versions
)
}
if (libraries.isEmpty()) {
return@withContext Result.Success(
libraryName = args.libraryName,
libraries = emptyList()
)
}
return@withContext Result.Success(
libraryName = args.libraryName,
libraries = libraries
)
} catch (e: Exception) {
return@withContext Result.Error("Failed to parse Context7 response: ${e.message}")
}
} catch (e: Exception) {
val errorMessage = when {
e.message?.contains("404", ignoreCase = true) == true ->
"The Context7 service is currently unavailable. Please try again later."
e.message?.contains("429", ignoreCase = true) == true ->
"Rate limited due to too many requests. Please try again later."
e.message?.contains("401", ignoreCase = true) == true ->
"Unauthorized access to Context7 service."
else ->
"Failed to search for libraries: ${e.message}"
}
return@withContext Result.Error(errorMessage)
}
}
override fun encodeResultToString(result: Result): String = when (result) {
is Result.Success -> {
val s = if (result.libraries.isEmpty()) {
"No libraries found for '${result.libraryName}'. Please try with different search terms or check the library name spelling."
} else {
buildString {
appendLine("Available Libraries:")
appendLine()
appendLine("Each result includes:")
appendLine("- Library ID: Context7-compatible identifier (format: /org/project)")
appendLine("- Name: Library or package name")
appendLine("- Description: Short summary")
appendLine("- Code Snippets: Number of available code examples")
appendLine("- Source Reputation: Authority indicator (High, Medium, Low, or Unknown)")
appendLine("- Benchmark Score: Quality indicator (100 is the highest score)")
appendLine("- Versions: List of versions if available. Use one of those versions if the user provides a version in their query. The format of the version is /org/project/version.")
appendLine()
appendLine("For best results, select libraries based on name match, source reputation, snippet coverage, benchmark score, and relevance to your use case.")
appendLine()
appendLine("----------")
appendLine()
result.libraries.forEach { library ->
appendLine("**${library.name}**")
appendLine()
appendLine("Library ID: `${library.id}`")
if (library.description.isNotBlank()) {
appendLine("Description: ${library.description}")
}
appendLine("Code Snippets: ${library.codeSnippets}")
appendLine("Source Reputation: ${library.sourceReputation}")
appendLine("Benchmark Score: ${library.benchmarkScore}")
if (!library.versions.isNullOrEmpty()) {
appendLine("Available Versions: ${library.versions.joinToString(", ")}")
}
appendLine()
}
appendLine("**Recommended Selection:**")
val topLibrary = result.libraries.maxByOrNull {
(it.benchmarkScore * 0.4 + it.codeSnippets * 0.3 + when (it.sourceReputation.lowercase()) {
"high" -> 30
"medium" -> 20
"low" -> 10
else -> 0
} * 0.3).toInt()
}
if (topLibrary != null) {
appendLine("Based on the search results for '${result.libraryName}', the most relevant library is:")
appendLine()
appendLine("Library ID: `${topLibrary.id}`")
appendLine("Name: ${topLibrary.name}")
appendLine("Reasoning: Highest combined score of benchmark (${topLibrary.benchmarkScore}), code snippets (${topLibrary.codeSnippets}), and source reputation (${topLibrary.sourceReputation})")
}
}.trimEnd()
}
s.truncateToolResult()
}
is Result.Error -> {
("Failed to resolve library ID: ${result.error}").truncateToolResult()
}
}
@Serializable
private data class Context7SearchResponse(
val results: List<Context7LibraryResult> = emptyList()
)
@Serializable
private data class Context7LibraryResult(
val id: String,
val title: String? = null,
val description: String? = null,
val branch: String? = null,
val lastUpdateDate: String? = null,
val state: String? = null,
val totalTokens: Int? = null,
val totalSnippets: Int? = null,
val stars: Int? = null,
val trustScore: Double? = null,
val benchmarkScore: Double? = null,
val versions: List<String>? = null
)
}

View file

@ -0,0 +1,305 @@
package ee.carlrobert.codegpt.agent.tools
import ai.koog.agents.core.agent.GraphAIAgent
import ai.koog.agents.core.feature.handler.tool.ToolCallCompletedContext
import ai.koog.agents.core.feature.handler.tool.ToolCallStartingContext
import ai.koog.agents.core.tools.Tool
import ai.koog.agents.core.tools.ToolRegistry
import ai.koog.agents.core.tools.annotations.LLMDescription
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import ee.carlrobert.codegpt.EncodingManager
import ee.carlrobert.codegpt.agent.*
import ee.carlrobert.codegpt.conversations.message.TokenUsage
import ee.carlrobert.codegpt.settings.ProxyAISettingsService
import ee.carlrobert.codegpt.settings.ProxyAISubagent
import ee.carlrobert.codegpt.settings.agents.SubagentDefaults
import ee.carlrobert.codegpt.settings.service.ServiceType
import ee.carlrobert.codegpt.tokens.truncateToolResult
import ee.carlrobert.codegpt.toolwindow.agent.ui.approval.ToolApprovalRequest
import ee.carlrobert.codegpt.toolwindow.agent.ui.approval.ToolApprovalType
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* A tool for launching specialized agents to handle complex, multistep tasks autonomously.
*/
class TaskTool(
private val project: Project,
private val sessionId: String,
private val provider: ServiceType,
private val events: AgentEvents,
) : Tool<TaskTool.Args, TaskTool.Result>(
argsSerializer = Args.serializer(),
resultSerializer = Result.serializer(),
name = "Task",
description = buildTaskDescription(project)
) {
@Serializable
data class Args(
@property:LLMDescription(
"A short (3-5 word) description of the task. This serves as a label for the task."
)
val description: String,
@property:LLMDescription(
"The detailed task description for the agent to perform autonomously. This should contain " +
"clear instructions on what needs to be done, specific context and requirements, " +
"expected output or deliverables, and any constraints or preferences."
)
val prompt: String,
@property:LLMDescription(
"The type of specialized agent to use. Must match one of: " +
"\"general-purpose\" - For complex research, code search, and multi-step tasks, " +
"\"explore\" - Fast exploration of codebases, finding files and patterns, " +
"\"plan\" - Software architecture and implementation planning"
)
@SerialName("subagent_type")
val subagentType: String,
@property:LLMDescription(
"Optional model override for this specific task"
)
val model: String? = null,
@property:LLMDescription(
"Optional project path for file operations and context"
)
@SerialName("project_path")
val projectPath: String? = null
)
@Serializable
data class Result(
val agentType: String,
val description: String,
val prompt: String,
val output: String,
val executionTime: Long,
val totalTokens: Long = 0
)
internal data class InternalResult(
val agentType: String,
val description: String,
val prompt: String,
val output: String,
val executionTime: Long,
val totalTokens: Long = 0,
val tokenUsage: TokenUsage? = null
)
override suspend fun execute(args: Args): Result {
val startTime = System.currentTimeMillis()
val parentId = ToolRunContext.getToolId(sessionId)
?: throw IllegalStateException("No parent tool call found for session $sessionId")
try {
val toolCallBridge = SubagentToolCallBridge(events, parentId, sessionId)
val approvalHandler = approvalHandler(events)
val configuredSubagent = if (isBuiltInAgentType(args.subagentType)) {
null
} else {
findConfiguredSubagent(project, args.subagentType)
}
val agent = if (configuredSubagent != null) {
AgentFactory.createManualAgent(
provider,
project,
sessionId,
configuredSubagent.title,
configuredSubagent.behavior,
configuredSubagent.tools,
approveToolCall = approvalHandler,
onAgentToolCallStarting = toolCallBridge::onToolCallStarting,
onAgentToolCallCompleted = toolCallBridge::onToolCallCompleted,
onCreditsAvailable = events::onCreditsAvailable
)
} else {
val agentType = AgentType.fromString(args.subagentType)
val builtInConfig = lookupBuiltInConfig(project, agentType)
val extraBehavior = builtInConfig?.objective?.takeIf { it.isNotBlank() }
val toolOverrides = builtInConfig?.let { SubagentTool.parse(it.tools) }
AgentFactory.createAgent(
agentType,
provider,
project,
sessionId,
approveToolCall = approvalHandler,
onAgentToolCallStarting = toolCallBridge::onToolCallStarting,
onAgentToolCallCompleted = toolCallBridge::onToolCallCompleted,
extraBehavior = extraBehavior,
toolOverrides = toolOverrides,
onCreditsAvailable = events::onCreditsAvailable
)
}
toolCallBridge.setToolRegistry((agent as? GraphAIAgent<*, *>)?.toolRegistry)
val output = agent.run(args.prompt)
return args.createResult(output, startTime)
} catch (e: Exception) {
return Result(
agentType = args.subagentType,
description = args.description,
prompt = args.prompt,
output = "Error: ${e.message}",
executionTime = System.currentTimeMillis() - startTime,
)
}
}
private fun Args.createResult(output: String, startTime: Long): Result {
return Result(
agentType = this.subagentType,
description = this.description,
prompt = this.prompt,
output = output,
executionTime = System.currentTimeMillis() - startTime,
totalTokens = EncodingManager.getInstance().countTokens(output).toLong()
)
}
override fun encodeResultToString(result: Result): String {
val summary = buildString {
appendLine("Agent: ${result.agentType}")
appendLine("Description: ${result.description}")
appendLine("DurationMs: ${result.executionTime}")
if (result.totalTokens > 0) {
appendLine("TotalTokens: ${result.totalTokens}")
}
appendLine()
appendLine("Output:")
appendLine(result.output)
}.trimEnd()
return summary.truncateToolResult()
}
}
private fun buildTaskDescription(project: Project): String {
return buildString {
appendLine(
"""
Launch a new agent to handle complex, multi-step tasks autonomously.
Built-in agent types:
- general-purpose: General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks.
- explore: Fast agent specialized for exploring codebases.
- plan: Software architect agent for designing implementation plans.
Custom subagents:
- You may also pass the exact name of a configured subagent (as shown in ProxyAI Settings > Subagents) in the subagent_type field.
Usage notes:
- Use a single message with multiple tool calls to launch agents in parallel when helpful.
- Provide a detailed prompt and expected output.
""".trimIndent()
)
val customs = runCatching {
project.service<ProxyAISettingsService>().getSubagents()
.filterNot { SubagentDefaults.isBuiltInId(it.id) }
}.getOrNull()?.takeIf { it.isNotEmpty() }
if (customs != null) {
appendLine()
appendLine("Configured subagents available:")
customs.forEach { sa ->
val title = sa.title.trim()
if (title.isEmpty()) return@forEach
val desc = sa.objective.trim()
append("- ")
append(title)
if (desc.isNotBlank()) {
append(": ")
append(desc.take(140))
}
appendLine()
}
}
}.trimEnd()
}
private data class ConfiguredSubagent(
val title: String,
val behavior: String,
val tools: Set<String>
)
private fun isBuiltInAgentType(value: String): Boolean {
return when (value.lowercase().trim()) {
"general-purpose", "explore", "plan" -> true
else -> false
}
}
private fun findConfiguredSubagent(project: Project, name: String): ConfiguredSubagent? {
val subagent = runCatching {
project.service<ProxyAISettingsService>().getSubagents()
.firstOrNull {
!SubagentDefaults.isBuiltInId(it.id) && it.title.equals(
name,
ignoreCase = true
)
}
}.getOrNull() ?: return null
val title = subagent.title.takeIf { it.isNotBlank() } ?: name
return ConfiguredSubagent(title, subagent.objective, subagent.tools.toSet())
}
private fun lookupBuiltInConfig(project: Project, agentType: AgentType): ProxyAISubagent? {
val id = SubagentDefaults.builtInIdFor(agentType) ?: return null
return project.service<ProxyAISettingsService>().getSubagents().firstOrNull { it.id == id }
}
private fun approvalHandler(events: AgentEvents): suspend (String, String) -> Boolean =
{ name, details ->
events.approveToolCall(ToolApprovalRequest(approvalTypeFor(name), "Allow $name?", details))
}
private fun approvalTypeFor(name: String): ToolApprovalType {
return when {
name.equals("Write", true) -> ToolApprovalType.WRITE
name.equals("Edit", true) -> ToolApprovalType.EDIT
name.equals("Bash", true) -> ToolApprovalType.BASH
else -> ToolApprovalType.GENERIC
}
}
private class SubagentToolCallBridge(
private val events: AgentEvents,
private val parentId: String,
private val sessionId: String
) {
private val pendingChildIds = ArrayDeque<String>()
private var toolRegistry: ToolRegistry? = null
fun setToolRegistry(registry: ToolRegistry?) {
toolRegistry = registry
}
fun onToolCallStarting(eventContext: ToolCallStartingContext) {
val decodedArgs = ToolCallPayloadDecoder.decodeArgs(
toolRegistry,
eventContext.toolName,
eventContext.toolArgs
)
val childId = events.onSubAgentToolStarting(parentId, eventContext.toolName, decodedArgs)
if (childId != null) {
pendingChildIds.addLast(childId)
ToolRunContext.set(sessionId, childId)
}
}
fun onToolCallCompleted(eventContext: ToolCallCompletedContext) {
val childId = if (pendingChildIds.isEmpty()) null else pendingChildIds.removeFirst()
val decodedResult = ToolCallPayloadDecoder.decodeResult(
toolRegistry,
eventContext.toolName,
eventContext.toolResult
)
events.onSubAgentToolCompleted(
parentId,
childId,
eventContext.toolName,
decodedResult
)
}
}

View file

@ -0,0 +1,111 @@
package ee.carlrobert.codegpt.agent.tools
import ai.koog.agents.core.tools.SimpleTool
import ai.koog.agents.core.tools.annotations.LLMDescription
import com.intellij.openapi.project.Project
import ee.carlrobert.codegpt.agent.ToolRunContext
import ee.carlrobert.codegpt.toolwindow.agent.AgentTabTitleNotifier
import kotlinx.serialization.Serializable
class TodoWriteTool(
project: Project,
private val sessionId: String
) : SimpleTool<TodoWriteTool.Args>(
argsSerializer = Args.serializer(),
name = "TodoWrite",
description = """
Use this tool to create and manage a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user.
It also helps the user understand the progress of the task and overall progress of their requests.
## When to Use This Tool
Use this tool proactively in these scenarios:
1. Complex multi-step tasks - When a task requires 3 or more distinct steps or actions
2. Non-trivial and complex tasks - Tasks that require careful planning or multiple operations
3. User explicitly requests todo list - When the user directly asks you to use the todo list
4. User provides multiple tasks - When users provide a list of things to be done (numbered or comma-separated)
5. After receiving new instructions - Immediately capture user requirements as todos
6. When you start working on a task - Mark it as in_progress BEFORE beginning work. Ideally you should only have one todo as in_progress at a time
7. After completing a task - Mark it as completed and add any new follow-up tasks discovered during implementation
## When NOT to Use This Tool
Skip using this tool when:
1. There is only a single, straightforward task
2. The task is trivial and tracking it provides no organizational benefit
3. The task can be completed in less than 3 trivial steps
4. The task is purely conversational or informational
NOTE that you should not use this tool if there is only one trivial task to do. In this case you are better off just doing the task directly.
""".trimIndent()
) {
private val agentTabTitlePublisher =
project.messageBus.syncPublisher(AgentTabTitleNotifier.AGENT_TAB_TITLE_TOPIC)
@Serializable
data class Args(
@property:LLMDescription("Short task title (4 words max) in imperative form (e.g., 'Fix user authentication bug')")
val title: String,
@property:LLMDescription("The updated todo list")
val todos: List<TodoItem>
)
@Serializable
data class TodoItem(
@property:LLMDescription("The task description in imperative form (e.g., 'Run tests')")
val content: String,
@property:LLMDescription("Current status of the task: pending (not started), in_progress (currently working), or completed (finished)")
val status: TodoStatus,
@property:LLMDescription("The task description in present continuous form (e.g., 'Running tests')")
val activeForm: String
)
@Serializable
enum class TodoStatus {
PENDING,
IN_PROGRESS,
COMPLETED
}
override suspend fun execute(args: Args): String {
val title = args.title
if (title.isNotBlank()) {
val currentContext = ToolRunContext.get(sessionId)
val isMainAgentTodo = currentContext?.parentToolId == null
if (isMainAgentTodo) {
val truncatedTitle = if (title.length > 50) title.take(47) + "..." else title
agentTabTitlePublisher.updateTabTitle(sessionId, truncatedTitle)
}
}
val pendingCount = args.todos.count { it.status == TodoStatus.PENDING }
val inProgressCount = args.todos.count { it.status == TodoStatus.IN_PROGRESS }
val completedCount = args.todos.count { it.status == TodoStatus.COMPLETED }
return buildString {
appendLine("📋 Todo List Updated")
appendLine()
appendLine("Summary:")
appendLine(" ⏳ Pending: $pendingCount")
appendLine(" 🔄 In Progress: $inProgressCount")
appendLine(" ✅ Completed: $completedCount")
appendLine()
appendLine("Current Tasks:")
args.todos.forEachIndexed { index, todo ->
val statusIcon = when (todo.status) {
TodoStatus.PENDING -> "⏳"
TodoStatus.IN_PROGRESS -> "🔄"
TodoStatus.COMPLETED -> "✅"
}
val taskText = if (todo.status == TodoStatus.IN_PROGRESS) {
todo.activeForm
} else {
todo.content
}
appendLine("${index + 1}. $statusIcon $taskText")
}
}
}
}

View file

@ -0,0 +1,227 @@
package ee.carlrobert.codegpt.agent.tools
import ai.koog.agents.core.tools.Tool
import ai.koog.agents.core.tools.annotations.LLMDescription
import ee.carlrobert.codegpt.tokens.truncateToolResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.jsoup.Jsoup
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
class WebSearchTool(
private val userAgent: String = "Mozilla/5.0 (compatible; ProxyAI/1.0; +https://tryproxy.io)"
) : Tool<WebSearchTool.Args, WebSearchTool.Result>(
argsSerializer = Args.serializer(),
resultSerializer = Result.serializer(),
name = "WebSearch",
description = """
IMPORTANT - When NOT to use this tool:
- DO NOT use WebSearch for library, framework, or technical documentation queries
- For questions about libraries, frameworks, SDKs, or APIs (e.g., "How to use React hooks?", "Python pandas documentation", "Spring Boot setup"), you MUST use ResolveLibraryId and GetLibraryDocs tools instead
- WebSearch is for general web content, current events, and non-technical documentation queries only
- Allows Claude to search the web and use the results to inform responses
- Provides up-to-date information for current events and recent data
- Returns search result information formatted as search result blocks, including links as markdown hyperlinks
- Use this tool for accessing information beyond Claude's knowledge cutoff
- Searches are performed automatically within a single API call
CRITICAL REQUIREMENT - You MUST follow this:
- After answering the user's question, you MUST include a "Sources:" section at the end of your response
- In the Sources section, list all relevant URLs from the search results as markdown hyperlinks: [Title](URL)
- This is MANDATORY - never skip including sources in your response
- Example format:
[Your answer here]
Sources:
- [Source Title 1](https://example.com/1)
- [Source Title 2](https://example.com/2)
Usage notes:
- Domain filtering is supported to include or block specific websites
- Web search is only available in the US
IMPORTANT - Use the correct year in search queries:
- Today's date is 2025-12-04. You MUST use this year when searching for recent information, documentation, or current events.
- Example: If today is 2025-07-15 and the user asks for "latest React docs", search for "React documentation 2025", NOT "React documentation 2024"
""".trimIndent()
) {
@Serializable
data class Args(
@property:LLMDescription(
"The search query to use"
)
val query: String,
@property:LLMDescription(
"Only include search results from these domains"
)
@SerialName("allowed_domains")
val allowedDomains: List<String>? = null,
@property:LLMDescription(
"Never include search results from these domains"
)
@SerialName("blocked_domains")
val blockedDomains: List<String>? = null
)
@Serializable
data class SearchResult(
val title: String,
val url: String,
val content: String
)
@Serializable
data class Result(
val query: String,
val results: List<SearchResult>,
val sources: List<String>
)
override suspend fun execute(args: Args): Result = withContext(Dispatchers.IO) {
try {
val searxResults = searchWithSearxNG(args.query)
if (searxResults.isNotEmpty()) {
val filteredResults =
filterResults(searxResults, args.allowedDomains, args.blockedDomains)
return@withContext Result(
query = args.query,
results = filteredResults.take(10),
sources = filteredResults.take(10).map { "[${it.title}](${it.url})" }
)
}
} catch (e: Exception) {
// Fall back to DuckDuckGo
}
try {
val duckduckgoResults = searchWithDuckDuckGo(args.query)
val filteredResults =
filterResults(duckduckgoResults, args.allowedDomains, args.blockedDomains)
return@withContext Result(
query = args.query,
results = filteredResults.take(10),
sources = filteredResults.take(10).map { "[${it.title}](${it.url})" }
)
} catch (e: Exception) {
return@withContext Result(
query = args.query,
results = emptyList(),
sources = emptyList()
)
}
}
private suspend fun searchWithSearxNG(query: String): List<SearchResult> =
withContext(Dispatchers.IO) {
val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.toString())
val url = "https://searx.space/search?q=$encodedQuery&format=json"
val doc = Jsoup.connect(url)
.userAgent(userAgent)
.timeout(10000)
.ignoreContentType(true)
.get()
val jsonResponse = doc.body().text()
if (jsonResponse.isEmpty()) return@withContext emptyList()
try {
val searxResponse = json.decodeFromString<SearxResponse>(jsonResponse)
searxResponse.results.map { result ->
SearchResult(
title = result.title,
url = result.url,
content = result.content
)
}
} catch (e: Exception) {
emptyList()
}
}
private suspend fun searchWithDuckDuckGo(query: String): List<SearchResult> =
withContext(Dispatchers.IO) {
val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.toString())
val url = "https://duckduckgo.com/html/?q=$encodedQuery"
val doc = Jsoup.connect(url)
.userAgent(userAgent)
.timeout(10000)
.get()
val results = mutableListOf<SearchResult>()
doc.select("div.result").forEach { resultDiv ->
val titleLink = resultDiv.selectFirst("a.result__a")
val snippet = resultDiv.selectFirst("a.result__snippet")
if (titleLink != null) {
results.add(
SearchResult(
title = titleLink.text(),
url = titleLink.attr("href"),
content = snippet?.text() ?: ""
)
)
}
}
results
}
private fun filterResults(
results: List<SearchResult>,
allowedDomains: List<String>?,
blockedDomains: List<String>?
): List<SearchResult> {
return results.filter { result ->
val urlLower = result.url.lowercase()
blockedDomains?.any { domain ->
urlLower.contains(domain.lowercase())
}?.let { if (it) return@filter false }
allowedDomains?.let { allowed ->
allowed.any { domain ->
urlLower.contains(domain.lowercase())
}
} ?: true
}
}
override fun encodeResultToString(result: Result): String =
buildString {
if (result.results.isEmpty()) {
appendLine("No search results found for query: ${result.query}")
return@buildString
}
result.results.forEach { searchResult ->
appendLine()
appendLine("[${searchResult.title}](${searchResult.url})")
if (searchResult.content.isNotBlank()) {
appendLine()
appendLine(searchResult.content)
}
appendLine()
}
}.trimEnd().truncateToolResult()
@Serializable
private data class SearxResponse(
val results: List<SearxResult>
)
@Serializable
private data class SearxResult(
val title: String,
val url: String,
val content: String
)
}

View file

@ -0,0 +1,170 @@
package ee.carlrobert.codegpt.agent.tools
import ai.koog.agents.core.tools.Tool
import ai.koog.agents.core.tools.annotations.LLMDescription
import com.intellij.openapi.application.runInEdt
import com.intellij.openapi.application.runReadAction
import com.intellij.openapi.application.runWriteAction
import com.intellij.openapi.fileEditor.FileDocumentManager
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.text.StringUtil
import com.intellij.openapi.vfs.VirtualFileManager
import com.intellij.psi.PsiDocumentManager
import ee.carlrobert.codegpt.tokens.truncateToolResult
import ee.carlrobert.codegpt.settings.ProxyAISettingsService
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.io.File
import java.nio.charset.StandardCharsets
/**
* Writes content to files, creating new files or overwriting existing ones.
*
* Uses IntelliJ's Document API for proper IDE integration with undo/redo support.
* Handles both project files and external files with appropriate project context.
*/
class WriteTool(private val project: Project) : Tool<WriteTool.Args, WriteTool.Result>(
argsSerializer = Args.serializer(),
resultSerializer = Result.serializer(),
name = "Write",
description = """
Writes content to a file, creating it if it doesn't exist or overwriting if it does.
This tool creates parent directories if they don't exist and handles both
new file creation and existing file overwrites.
Parameters:
- file_path: Absolute path to the file to write (required)
- content: The content to write to the file (required, cannot be empty)
Important notes:
- Always use absolute paths
- Parent directories are created automatically
- Existing files will be overwritten completely
- Uses UTF-8 encoding by default
- Binary content should be handled differently
- Content field is required and cannot be empty
""".trimIndent()
) {
@Serializable
data class Args(
@property:LLMDescription(
"The absolute path to the file to write. Must be an absolute path, not a relative path."
)
@SerialName("file_path")
val filePath: String = "",
@property:LLMDescription(
"The content to write to the file. Must not be empty."
)
val content: String = ""
)
@Serializable
sealed class Result {
@Serializable
data class Success(
val filePath: String,
val bytesWritten: Int,
val isNewFile: Boolean,
val message: String
) : Result()
@Serializable
data class Error(
val filePath: String,
val error: String
) : Result()
}
override suspend fun execute(args: Args): Result {
val svc = project.getService(ProxyAISettingsService::class.java)
if (svc.isPathIgnored(args.filePath)) {
return Result.Error(
filePath = args.filePath,
error = ".proxyai ignore rules block writing to this path"
)
}
if (args.content.isBlank()) {
return Result.Error(
filePath = args.filePath,
error = "Content field is required and cannot be empty"
)
}
return try {
val file = File(args.filePath)
file.parentFile?.mkdirs()
val isNewFile = !file.exists()
if (file.exists() && !file.canWrite()) {
return Result.Error(
filePath = args.filePath,
error = "File is not writable: ${args.filePath}"
)
}
val virtualFile =
VirtualFileManager.getInstance().findFileByUrl("file://${args.filePath}")
?: if (isNewFile) {
VirtualFileManager.getInstance().findFileByUrl("file://${args.filePath}")
} else {
null
}
val bytesWritten = args.content.toByteArray(StandardCharsets.UTF_8).size
val projectToUse = project
if (virtualFile != null) {
val document =
runReadAction { FileDocumentManager.getInstance().getDocument(virtualFile) }
runInEdt {
if (document != null) {
runWriteAction {
PsiDocumentManager.getInstance(projectToUse).commitDocument(document)
document.setText(StringUtil.convertLineSeparators(args.content))
FileDocumentManager.getInstance().saveDocument(document)
}
} else {
runWriteAction {
virtualFile.setBinaryContent(args.content.toByteArray(StandardCharsets.UTF_8))
}
}
}
} else if (isNewFile) {
runInEdt {
runWriteAction {
file.writeText(
StringUtil.convertLineSeparators(args.content),
StandardCharsets.UTF_8
)
VirtualFileManager.getInstance().syncRefresh()
}
}
}
val action = if (isNewFile) "created" else "overwritten"
Result.Success(
filePath = args.filePath,
bytesWritten = bytesWritten,
isNewFile = isNewFile,
message = "File $action successfully. $bytesWritten bytes written."
)
} catch (e: Exception) {
Result.Error(
filePath = args.filePath,
error = "Failed to write file: ${e.message}"
)
}
}
override fun encodeResultToString(result: Result): String = when (result) {
is Result.Success -> ("File '${result.filePath}' ${result.message}").truncateToolResult()
is Result.Error -> ("Error writing file '${result.filePath}': ${result.error}").truncateToolResult()
}
}

View file

@ -65,7 +65,6 @@ class ClaudeRequestFactory : BaseRequestFactory() {
if (params.toolResults.isNullOrEmpty()) {
when {
params.imageDetails != null -> {
println("DEBUG [ClaudeRequestFactory]: Adding image message")
messages.add(
ClaudeCompletionDetailedMessage(
"user",
@ -142,7 +141,6 @@ class ClaudeRequestFactory : BaseRequestFactory() {
try {
if (isCurrentMessageWithToolResults) {
println(" - This is the current message with tool results, adding them now")
return mutableListOf()
}

View file

@ -0,0 +1,11 @@
package ee.carlrobert.codegpt.conversations.message
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import java.util.UUID
@JsonIgnoreProperties(ignoreUnknown = true)
data class QueuedMessage(
var prompt: String,
val id: UUID = UUID.randomUUID(),
var timestamp: Long = System.currentTimeMillis()
)

View file

@ -0,0 +1,12 @@
package ee.carlrobert.codegpt.conversations.message
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
data class TokenUsage(
val promptTokens: Long,
val completionTokens: Long,
val toolTokens: Long = 0,
val subAgentTokens: Long = 0,
val totalTokens: Long = promptTokens + completionTokens + toolTokens + subAgentTokens
)

View file

@ -0,0 +1,49 @@
package ee.carlrobert.codegpt.conversations.message
import java.util.concurrent.atomic.AtomicLong
class TokenUsageTracker {
private val promptTokens = AtomicLong(0)
private val completionTokens = AtomicLong(0)
private val toolTokens = AtomicLong(0)
private val subAgentTokens = AtomicLong(0)
fun addPromptTokens(tokens: Long) {
promptTokens.addAndGet(tokens)
}
fun addCompletionTokens(tokens: Long) {
completionTokens.addAndGet(tokens)
}
fun addToolTokens(tokens: Long) {
toolTokens.addAndGet(tokens)
}
fun addSubAgentTokens(tokens: Long) {
subAgentTokens.addAndGet(tokens)
}
fun getPromptTokens(): Long = promptTokens.get()
fun getTotalTokens(): Long =
promptTokens.get() + completionTokens.get() + toolTokens.get() + subAgentTokens.get()
fun updateTokenUsage(tokenUsage: TokenUsage?) {
tokenUsage?.let { usage ->
addPromptTokens(usage.promptTokens)
addCompletionTokens(usage.completionTokens)
addToolTokens(usage.toolTokens)
addSubAgentTokens(usage.subAgentTokens)
}
}
fun reset() {
promptTokens.set(0)
completionTokens.set(0)
toolTokens.set(0)
subAgentTokens.set(0)
}
fun isEmpty(): Boolean = getTotalTokens() == 0L
}

View file

@ -513,6 +513,7 @@ class InlineEditInlayRenderer(
change.isAccepted = true
removeChangeVisuals(change)
} catch (e: Exception) {
logger.debug("Error accepting change during bulk apply", e)
logger.error("Error accepting change during bulk apply", e)
}
}
@ -583,6 +584,7 @@ class InlineEditInlayRenderer(
try {
editor.markupModel.removeHighlighter(highlighter)
} catch (e: Exception) {
logger.debug("Error removing highlighter during disposal", e)
logger.error("Error removing highlighter during disposal", e)
}
}
@ -595,6 +597,7 @@ class InlineEditInlayRenderer(
change.additionInlay?.dispose()
change.buttonInlay?.dispose()
} catch (e: Exception) {
logger.debug("Error disposing change inlays during disposal", e)
logger.error("Error disposing change inlays during disposal", e)
}
}
@ -608,6 +611,7 @@ class InlineEditInlayRenderer(
ui.buttonInlay?.dispose()
ui.deletionHighlighter?.let { editor.markupModel.removeHighlighter(it) }
} catch (e: Exception) {
logger.debug("Error disposing hunk UI during disposal", e)
logger.error("Error disposing hunk UI during disposal", e)
}
}
@ -615,7 +619,13 @@ class InlineEditInlayRenderer(
}
private fun disposeTopPanel() {
try {
topPanelDisposable?.dispose()
} catch (e: Exception) {
logger.debug("Error disposing top panel", e)
}
topPanelDisposable?.dispose()
topPanelDisposable = null
}
}

View file

@ -282,6 +282,8 @@ class McpToolCallEventListener(
},
onReject = { toolCall, panel ->
cleanupAndComplete()
toolCallHandler.cancelAllPendingApprovals()
toolCallHandler.cancelAllExecutions()
},
onBatchApprove = { panels, _ ->
toolCallsWithServer.forEachIndexed { index, (mcpToolCall, openAIToolCall, serverName) ->
@ -308,6 +310,8 @@ class McpToolCallEventListener(
},
onBatchReject = { panels ->
cleanupAndComplete()
toolCallHandler.cancelAllPendingApprovals()
toolCallHandler.cancelAllExecutions()
}
)
@ -547,6 +551,7 @@ class McpToolCallEventListener(
toolName = toolCall.name,
serverName = serverName,
initialStatus = "Executing...",
args = toolCall.arguments,
)
activeStatusPanels[toolCall.id] = statusPanel
onToolCallUIUpdate(statusPanel)

View file

@ -123,6 +123,7 @@ class McpToolCallHandler {
toolName = toolCall.name,
serverName = actualServerName,
initialStatus = "Executing...",
args = toolCall.arguments,
)
statusPanels[toolCall.id] = statusPanel
onUIUpdate(statusPanel)

View file

@ -0,0 +1,281 @@
package ee.carlrobert.codegpt.settings
import com.intellij.openapi.components.Service
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.project.Project
import ee.carlrobert.codegpt.settings.agents.SubagentDefaults
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.json.encodeToStream
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.StandardOpenOption
import java.util.concurrent.atomic.AtomicReference
import kotlin.io.path.inputStream
import kotlin.io.path.notExists
@Service(Service.Level.PROJECT)
class ProxyAISettingsService(private val project: Project) {
private val json = Json { ignoreUnknownKeys = true }
private val logger = thisLogger()
private val settingsFile: Path by lazy {
Paths.get(project.basePath ?: "", ".proxyai", "settings.json")
}
private val store: ProxyAISettingsStore by lazy { ProxyAISettingsStore(settingsFile, json, logger) }
private val cache = AtomicReference<CachedSettings?>(null)
private val isWindows = System.getProperty("os.name")?.lowercase()?.contains("windows") == true
fun getSubagents(): List<ProxyAISubagent> {
val settings = snapshot().settings
return SubagentDefaults.ensureBuiltIns(settings.subagents)
}
fun saveSubagents(subagents: List<ProxyAISubagent>) {
val normalized = SubagentDefaults.ensureBuiltIns(subagents)
updateSettings { it.copy(subagents = normalized) }
}
fun getBashPermissions(): List<String> {
return snapshot().settings.permissions.allow
}
fun getIgnorePatterns(): List<String> {
return snapshot().settings.ignore
}
fun isPathIgnored(path: String): Boolean {
return isPathIgnored(path, project.basePath ?: "")
}
fun isPathIgnored(path: String, basePath: String): Boolean {
return snapshot().ignoreMatcher.matches(path, basePath)
}
private fun snapshot(): CachedSettings {
val cached = cache.get()
val lastModified = store.lastModified()
if (cached != null && cached.lastModified == lastModified) {
return cached
}
val settings = store.load() ?: ProxyAISettings.default()
val ignoreMatcher = IgnoreMatcher.from(settings.ignore, isWindows)
val snapshot = CachedSettings(settings, lastModified, ignoreMatcher)
cache.set(snapshot)
return snapshot
}
private fun updateSettings(transform: (ProxyAISettings) -> ProxyAISettings) {
val current = snapshot().settings
val updated = transform(current)
if (!store.save(updated)) return
val lastModified = store.lastModified()
val ignoreMatcher = IgnoreMatcher.from(updated.ignore, isWindows)
cache.set(CachedSettings(updated, lastModified, ignoreMatcher))
}
}
private data class CachedSettings(
val settings: ProxyAISettings,
val lastModified: Long,
val ignoreMatcher: IgnoreMatcher
)
private class ProxyAISettingsStore(
private val settingsFile: Path,
private val json: Json,
private val logger: Logger
) {
fun load(): ProxyAISettings? {
if (settingsFile.notExists()) return null
return try {
settingsFile.inputStream().use { inputStream ->
json.decodeFromStream<ProxyAISettings>(inputStream)
}
} catch (e: Exception) {
logger.warn("Failed to load ProxyAI settings from $settingsFile", e)
null
}
}
fun save(settings: ProxyAISettings): Boolean {
return try {
settingsFile.parent?.let { Files.createDirectories(it) }
Files.newOutputStream(
settingsFile,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE
).use { outputStream ->
json.encodeToStream(settings, outputStream)
}
true
} catch (e: Exception) {
logger.warn("Failed to write ProxyAI settings to $settingsFile", e)
false
}
}
fun lastModified(): Long {
return try {
if (settingsFile.notExists()) -1 else Files.getLastModifiedTime(settingsFile).toMillis()
} catch (e: Exception) {
logger.warn("Failed to read ProxyAI settings modified time from $settingsFile", e)
-1
}
}
}
private class IgnoreMatcher(
private val compiled: List<Pair<String, Regex>>,
private val isWindows: Boolean
) {
fun matches(path: String, basePath: String): Boolean {
val normalizedPath = normalizePath(path)
val rel = if (basePath.isNotBlank() && normalizedPath.startsWith(basePath)) {
normalizedPath.removePrefix(basePath).removePrefix("/")
} else normalizedPath
val pathToTest = if (isWindows) normalizedPath.lowercase() else normalizedPath
val relToTest = if (isWindows) rel.lowercase() else rel
return compiled.any { (raw, rx) ->
val normalized = if (isWindows) raw.lowercase() else raw
simpleMatch(relToTest, normalized) ||
simpleMatch(pathToTest, normalized) ||
rx.matches(relToTest) ||
rx.matches(pathToTest)
}
}
companion object {
fun from(patterns: List<String>, isWindows: Boolean): IgnoreMatcher {
val compiled = patterns.mapNotNull { pattern ->
val rx = globToRegex(pattern, ignoreCase = isWindows) ?: return@mapNotNull null
pattern to rx
}
return IgnoreMatcher(compiled, isWindows)
}
private fun globToRegex(glob: String, ignoreCase: Boolean = false): Regex? {
return try {
var g = glob.trim()
if (g.isEmpty()) return null
val dirSuffix = g.endsWith("/")
g = g.trimEnd('/')
val sb = StringBuilder()
sb.append('^')
var i = 0
while (i < g.length) {
when (val c = g[i]) {
'*' -> {
val nextStar = (i + 1 < g.length && g[i + 1] == '*')
if (nextStar) {
sb.append(".*")
i += 1
} else {
sb.append("[^/]*")
}
}
'?' -> sb.append(".")
'.', '(', ')', '+', '|', '^', '$', '@', '%' -> sb.append('\\').append(c)
'[' -> sb.append("[")
']' -> sb.append("]")
'{' -> sb.append('(')
'}' -> sb.append(')')
',' -> sb.append('|')
else -> sb.append(c)
}
i += 1
}
if (dirSuffix) sb.append("(/.*)?")
sb.append('$')
if (ignoreCase) Regex(sb.toString(), RegexOption.IGNORE_CASE) else Regex(sb.toString())
} catch (_: Exception) {
null
}
}
}
private fun normalizePath(path: String): String {
return try {
Paths.get(path).normalize().toString().replace('\\', '/')
} catch (_: Exception) {
path.replace('\\', '/')
}
}
private fun simpleMatch(path: String, pattern: String): Boolean {
val p = path.trimStart('/')
val pat = pattern.trim()
if (pat == p) return true
if (pat.endsWith("/") && p.startsWith(pat.trimEnd('/'))) return true
if (!pat.contains('/') && pat.startsWith('.')) {
val fileName = p.substringAfterLast('/')
if (fileName == pat) return true
}
return false
}
}
@Serializable
data class ProxyAISettings(
val ignore: List<String>,
val permissions: Permissions,
val subagents: List<ProxyAISubagent> = emptyList()
) {
companion object {
fun default(): ProxyAISettings {
return ProxyAISettings(
ignore = listOf(
".idea/",
"*.iml",
"build/",
"dist/",
"node_modules/",
".git/",
),
permissions = Permissions(
allow = listOf(
"Bash(rg:*)",
"Bash(grep:*)",
"Bash(find:*)",
"Bash(ls:*)",
"Bash(sed:*)",
"Bash(rm:*)",
"Bash(cat:*)",
"Bash(head:*)",
"Bash(tail:*)",
"Bash(diff:*)",
"Bash(du:*)",
"Bash(file:*)",
"Bash(sort:*)",
"Bash(stat:*)",
"Bash(tree:*)",
"Bash(uniq:*)",
"Bash(wc:*)",
"Bash(whereis:*)",
"Bash(which:*)",
"Bash(less:*)",
"Bash(more:*)",
)
),
subagents = SubagentDefaults.defaults()
)
}
}
@Serializable
data class Permissions(
val allow: List<String>
)
}
@Serializable
data class ProxyAISubagent(
val id: Int,
val title: String,
val objective: String,
val tools: List<String>
)

View file

@ -0,0 +1,47 @@
package ee.carlrobert.codegpt.settings.agents
import ee.carlrobert.codegpt.agent.AgentType
import ee.carlrobert.codegpt.agent.SubagentTool
import ee.carlrobert.codegpt.settings.ProxyAISubagent
object SubagentDefaults {
const val EXPLORER_ID = 1
const val GENERAL_ID = 2
fun defaults(): List<ProxyAISubagent> {
val explorerTools = SubagentTool.toStoredValues(SubagentTool.readOnly + SubagentTool.BASH)
val generalTools = SubagentTool.toStoredValues(SubagentTool.entries)
return listOf(
ProxyAISubagent(
id = EXPLORER_ID,
title = "Explorer",
objective = "Read-only subagent for codebase exploration and insight gathering.",
tools = explorerTools
),
ProxyAISubagent(
id = GENERAL_ID,
title = "General Task",
objective = "General-purpose subagent for executing concrete coding tasks.",
tools = generalTools
)
)
}
fun ensureBuiltIns(subagents: List<ProxyAISubagent>): List<ProxyAISubagent> {
val defaults = defaults()
val byId = subagents.associateBy { it.id }
val explorer = byId[EXPLORER_ID] ?: defaults.first { it.id == EXPLORER_ID }
val general = byId[GENERAL_ID] ?: defaults.first { it.id == GENERAL_ID }
val custom = subagents.filterNot { isBuiltInId(it.id) }
return listOf(explorer, general) + custom
}
fun isBuiltInId(id: Int): Boolean = id == EXPLORER_ID || id == GENERAL_ID
fun builtInIdFor(agentType: AgentType): Int? {
return when (agentType) {
AgentType.GENERAL_PURPOSE -> GENERAL_ID
AgentType.EXPLORE -> EXPLORER_ID
}
}
}

View file

@ -0,0 +1,74 @@
package ee.carlrobert.codegpt.settings.agents
import ai.koog.prompt.message.Message
import ai.koog.prompt.dsl.prompt
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import com.intellij.openapi.components.service
import ee.carlrobert.codegpt.agent.AgentFactory
import ee.carlrobert.codegpt.settings.service.FeatureType
import ee.carlrobert.codegpt.settings.service.ModelSelectionService
import ee.carlrobert.codegpt.settings.service.ServiceType
data class GeneratedSubagent(val title: String, val description: String)
object SubagentGenerator {
suspend fun generate(query: String): GeneratedSubagent {
return try {
val modelService = service<ModelSelectionService>()
val provider: ServiceType = modelService.getServiceForFeature(FeatureType.AGENT)
val model = modelService.getAgentModel()
val executor = AgentFactory.createExecutor(provider)
val p = prompt("subagent-generator") {
system(
"""
You generate concise subagent definitions for a JetBrains plugin.
Output only a single compact JSON object with keys: "title" and "description".
- title: 3-6 words, concise, sentence case
- description: 3-4 sentences, actionable behavior summary
No prose, no markdown, no code fences.
""".trimIndent()
)
user(query)
}
val responses = executor.execute(p, model, emptyList())
val text = responses.filterIsInstance<Message.Assistant>()
.joinToString("\n") { it.content.trim() }
parseJson(text) ?: fallback(query)
} catch (_: Exception) {
fallback(query)
}
}
private fun parseJson(text: String): GeneratedSubagent? {
return try {
val mapper = ObjectMapper().registerKotlinModule()
val trimmed = text.trim().trim('`')
val node = mapper.readTree(trimmed)
val title = node.get("title")?.asText()?.trim().orEmpty()
val description = node.get("description")?.asText()?.trim().orEmpty()
if (title.isNotBlank() && description.isNotBlank()) GeneratedSubagent(title, description) else null
} catch (_: Exception) {
null
}
}
private fun fallback(query: String): GeneratedSubagent {
val safe = query.trim()
val title = safe.split('.', '!', '?', '\u000A')
.firstOrNull()
?.take(60)
?.replace(Regex("\\s+"), " ")
?.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
?: "Generated Subagent"
val description = if (safe.isNotBlank()) safe else "Auto-generated subagent"
return GeneratedSubagent(title, description)
}
fun generateBlocking(query: String): GeneratedSubagent = kotlinx.coroutines.runBlocking {
generate(query)
}
}

View file

@ -0,0 +1,27 @@
package ee.carlrobert.codegpt.settings.agents
import com.intellij.openapi.options.Configurable
import com.intellij.openapi.project.Project
import ee.carlrobert.codegpt.settings.agents.form.SubagentsForm
import javax.swing.JComponent
class SubagentsConfigurable(private val project: Project) : Configurable {
private lateinit var form: SubagentsForm
override fun getDisplayName(): String = "ProxyAI: Subagents"
override fun createComponent(): JComponent {
form = SubagentsForm(project)
return form.createPanel()
}
override fun isModified(): Boolean = form.isModified()
override fun apply() {
form.applyChanges()
}
override fun reset() {
form.resetChanges()
}
}

View file

@ -0,0 +1,112 @@
package ee.carlrobert.codegpt.settings.agents.form
import com.intellij.ui.components.JBCheckBox
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBScrollPane
import com.intellij.ui.ScrollPaneFactory
import com.intellij.ui.IdeBorderFactory
import com.intellij.ui.components.JBTextArea
import com.intellij.ui.components.JBTextField
import com.intellij.util.ui.FormBuilder
import com.intellij.util.ui.JBUI
import ee.carlrobert.codegpt.agent.SubagentTool
import java.awt.Dimension
import java.awt.BorderLayout
import javax.swing.JComponent
import javax.swing.JPanel
data class SubagentDetails(
val id: Int,
var title: String,
var description: String,
var tools: MutableSet<SubagentTool>
)
class SubagentDetailsPanel(
private val readOnlyTools: List<SubagentTool>,
private val writeTools: List<SubagentTool>
) {
private val titleField = JBTextField()
private val descriptionArea = JBTextArea()
private val allTools = (readOnlyTools + writeTools).distinct()
private val toolBoxes = allTools.associateWith { JBCheckBox(it.displayName) }
private var current: SubagentDetails? = null
private var controlsEnabled = true
fun getPanel(): JPanel {
descriptionArea.lineWrap = true
descriptionArea.wrapStyleWord = true
descriptionArea.margin = JBUI.insets(6, 8)
val descriptionScroll = ScrollPaneFactory.createScrollPane(descriptionArea, true)
descriptionScroll.border = IdeBorderFactory.createRoundedBorder()
descriptionScroll.preferredSize = Dimension(0, JBUI.scale(188))
val titleLabel = JBLabel("Title")
val descriptionLabel = JBLabel("Description")
val descriptionHelp = JBLabel("Describe the subagent's goals and behavior concisely.")
descriptionHelp.foreground = JBUI.CurrentTheme.ContextHelp.FOREGROUND
descriptionHelp.font = JBUI.Fonts.smallFont()
val toolsPanel = javax.swing.JPanel()
toolsPanel.layout = javax.swing.BoxLayout(toolsPanel, javax.swing.BoxLayout.Y_AXIS)
allTools.forEach { tool -> toolsPanel.add(toolBoxes.getValue(tool)) }
val toolsScroll = JBScrollPane(toolsPanel)
toolsScroll.border = JBUI.Borders.empty(4, 4, 0, 4)
val headerForm = FormBuilder.createFormBuilder()
.addComponent(titleLabel)
.addComponent(wrapWithMargin(titleField, 4, 4, 4, 4), 1)
.addComponent(descriptionLabel)
.addComponent(wrapWithMargin(descriptionScroll, 4, 4, 8, 4), 1)
.addComponent(descriptionHelp)
.addComponent(JBLabel("Tools"))
.panel
toolsScroll.preferredSize = Dimension(0, 0)
val container = com.intellij.util.ui.components.BorderLayoutPanel(0, 0)
container.border = JBUI.Borders.empty(8)
container.addToTop(headerForm)
container.addToCenter(toolsScroll)
return container
}
private fun wrapWithMargin(component: JComponent, top: Int, left: Int, bottom: Int, right: Int): JPanel {
val panel = JPanel(BorderLayout())
panel.border = JBUI.Borders.empty(top, left, bottom, right)
panel.add(component, BorderLayout.CENTER)
return panel
}
fun updateData(details: SubagentDetails) {
current = details
titleField.text = details.title
descriptionArea.text = details.description
toolBoxes.values.forEach { it.isSelected = false }
details.tools.forEach { tool -> toolBoxes[tool]?.isSelected = true }
refreshState()
}
fun collect(): SubagentDetails? {
val d = current ?: return null
d.title = titleField.text.trim()
d.description = descriptionArea.text.trim()
d.tools = toolBoxes.filter { it.value.isSelected }.keys.toMutableSet()
return d
}
fun setControlsEnabled(enabled: Boolean) {
controlsEnabled = enabled
refreshState()
}
private fun refreshState() {
val editable = controlsEnabled
titleField.isEnabled = controlsEnabled
titleField.isEditable = editable
descriptionArea.isEnabled = controlsEnabled
descriptionArea.isEditable = editable
toolBoxes.values.forEach { it.isEnabled = editable }
}
}

View file

@ -0,0 +1,256 @@
package ee.carlrobert.codegpt.settings.agents.form
import com.intellij.ui.ToolbarDecorator
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.ui.DialogWrapper
import com.intellij.openapi.ui.ValidationInfo
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBTextField
import com.intellij.ui.dsl.builder.*
import com.intellij.icons.AllIcons
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.application.runInEdt
import com.intellij.openapi.components.service
import com.intellij.openapi.options.ConfigurationException
import com.intellij.openapi.progress.ProgressManager
import com.intellij.openapi.project.Project
import ee.carlrobert.codegpt.ui.OverlayUtil
import com.intellij.ui.components.JBList
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.components.BorderLayoutPanel
import ee.carlrobert.codegpt.agent.SubagentTool
import ee.carlrobert.codegpt.settings.ProxyAISubagent
import ee.carlrobert.codegpt.settings.ProxyAISettingsService
import ee.carlrobert.codegpt.settings.agents.SubagentDefaults
import java.awt.Dimension
import javax.swing.DefaultListModel
import javax.swing.JComponent
class SubagentsForm(private val project: Project) {
private val readOnlyTools = SubagentTool.readOnly
private val writeTools = SubagentTool.write
private val settingsService = project.service<ProxyAISettingsService>()
private val listModel = DefaultListModel<SubagentDetails>()
private val list = JBList(listModel)
private val detailsPanel = SubagentDetailsPanel(readOnlyTools, writeTools)
fun createPanel(): JComponent {
list.cellRenderer = SubagentListCellRenderer()
list.visibleRowCount = 10
list.addListSelectionListener { event ->
if (event.valueIsAdjusting) return@addListSelectionListener
detailsPanel.collect()
list.selectedValue?.let { detailsPanel.updateData(it) }
}
listModel.removeAllElements()
loadSubagents().forEach { listModel.addElement(it) }
if (listModel.size > 0) list.selectedIndex = 0
val decorated = ToolbarDecorator.createDecorator(list)
.setAddAction {
val d = SubagentDetails(nextId(), "New Subagent", "", mutableSetOf())
listModel.addElement(d)
list.selectedIndex = listModel.size - 1
detailsPanel.updateData(d)
}
.setRemoveAction {
val idx = list.selectedIndex
if (idx >= 0) listModel.remove(idx)
if (listModel.size > 0) list.selectedIndex = 0
}
.setRemoveActionUpdater {
val idx = list.selectedIndex
idx >= 0 && !isBuiltIn(listModel.getElementAt(idx))
}
.addExtraAction(object : AnAction("Generate", "Generate from natural language", AllIcons.Actions.IntentionBulb) {
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT
override fun actionPerformed(e: AnActionEvent) {
val input = showGeneratePromptDialog() ?: return
list.isEnabled = false
detailsPanel.setControlsEnabled(false)
var error: String? = null
var generated: ee.carlrobert.codegpt.settings.agents.GeneratedSubagent? = null
ProgressManager.getInstance().runProcessWithProgressSynchronously({
try {
generated = ee.carlrobert.codegpt.settings.agents.SubagentGenerator.generateBlocking(input)
} catch (t: Throwable) {
error = t.message ?: "Generation failed"
}
}, "Generating subagent...", true, project)
val tools = suggestTools(input)
runInEdt(ModalityState.any()) {
if (generated != null) {
val d = SubagentDetails(nextId(), generated!!.title, generated!!.description, tools)
listModel.addElement(d)
list.selectedIndex = listModel.size - 1
detailsPanel.updateData(d)
OverlayUtil.showNotification("Subagent generated.")
} else if (error != null) {
OverlayUtil.showNotification("Failed to generate subagent: $error", com.intellij.notification.NotificationType.ERROR)
}
list.isEnabled = true
detailsPanel.setControlsEnabled(true)
}
}
})
.disableUpDownActions()
.createPanel()
decorated.preferredSize = Dimension(JBUI.scale(260), 0)
val rightContainer = BorderLayoutPanel()
rightContainer.addToCenter(detailsPanel.getPanel())
return BorderLayoutPanel(8, 0)
.addToLeft(decorated)
.addToCenter(rightContainer)
}
fun isModified(): Boolean {
val stored = loadSubagents()
val form = currentFormStates()
if (stored.size != form.size) return true
return stored.zip(form).any { (a, b) -> a != b }
}
fun applyChanges() {
detailsPanel.collect()
val error = validateEntries()
if (error != null) throw ConfigurationException(error)
settingsService.saveSubagents(currentFormStates().map { it.toStored() })
}
fun resetChanges() {
listModel.removeAllElements()
loadSubagents().forEach { listModel.addElement(it) }
if (listModel.size > 0) {
list.selectedIndex = 0
detailsPanel.updateData(listModel.getElementAt(0))
}
}
private fun currentFormStates(): List<SubagentDetails> {
detailsPanel.collect()
return (0 until listModel.size).map { idx -> listModel.getElementAt(idx) }
}
private fun loadSubagents(): List<SubagentDetails> {
return settingsService.getSubagents().map { it.toDetails() }
}
private fun isBuiltIn(details: SubagentDetails): Boolean = SubagentDefaults.isBuiltInId(details.id)
private fun validateEntries(): String? {
val seen = mutableSetOf<String>()
for (i in 0 until listModel.size) {
val entry = listModel.getElementAt(i)
val title = entry.title.trim()
if (title.isEmpty()) return "Title is required."
if (entry.description.trim().isEmpty()) return "Description is required."
if (entry.tools.isEmpty()) return "Select at least one tool for '$title'."
val key = title.lowercase()
if (!seen.add(key)) return "Subagent titles must be unique."
}
return null
}
private fun nextId(): Int {
val maxId = (0 until listModel.size).maxOfOrNull { listModel.getElementAt(it).id } ?: 0
return maxId + 1
}
private fun showGeneratePromptDialog(): String? {
val dialog = GenerateSubagentDialog()
return if (dialog.showAndGet()) {
dialog.inputValue.takeIf { it.isNotBlank() }
} else null
}
}
private class GenerateSubagentDialog : DialogWrapper(true) {
private val inputField = JBTextField()
val inputValue: String
get() = inputField.text
init {
title = "Generate Subagent"
init()
}
override fun createCenterPanel(): JComponent {
return panel {
row {
label("Describe the subagent:")
}
row {
cell(inputField)
.columns(COLUMNS_LARGE)
.focused()
}
row {
comment("Example: Review Kotlin code for style and complexity")
}
}
}
override fun doValidate(): ValidationInfo? {
return if (inputField.text.trim().isEmpty()) {
ValidationInfo("Description cannot be empty", inputField)
} else null
}
}
private fun suggestTools(text: String): MutableSet<SubagentTool> {
val t = text.lowercase()
val selected = SubagentTool.readOnly.toMutableSet()
if (listOf("write", "edit", "fix", "implement", "change", "modify", "apply").any { it in t }) {
selected.addAll(SubagentTool.write)
}
if (listOf("review", "analyze", "audit", "inspect").any { it in t }) {
selected.removeAll(SubagentTool.write.toSet())
}
return selected
}
private fun ProxyAISubagent.toDetails(): SubagentDetails {
return SubagentDetails(
id = id,
title = title,
description = objective,
tools = SubagentTool.parse(tools).toMutableSet(),
)
}
private fun SubagentDetails.toStored(): ProxyAISubagent {
return ProxyAISubagent(
id = id,
title = title,
objective = description,
tools = SubagentTool.toStoredValues(tools)
)
}
private class SubagentListCellRenderer : javax.swing.ListCellRenderer<SubagentDetails> {
private val label = JBLabel()
override fun getListCellRendererComponent(
list: javax.swing.JList<out SubagentDetails>?,
value: SubagentDetails?,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean
): java.awt.Component {
label.text = value?.title ?: ""
label.background = if (isSelected) list?.selectionBackground else list?.background
label.foreground = if (isSelected) list?.selectionForeground else list?.foreground
label.isOpaque = true
label.border = JBUI.Borders.empty(4, 8)
return label
}
}

View file

@ -41,6 +41,7 @@ object LegacySettingsMigration {
return ModelSettingsState().apply {
val chatModel = getLegacyChatModelForService(selectedService)
setModelSelection(FeatureType.AGENT, "auto", selectedService)
setModelSelection(FeatureType.CHAT, chatModel, selectedService)
setModelSelection(FeatureType.AUTO_APPLY, chatModel, selectedService)
setModelSelection(FeatureType.COMMIT_MESSAGE, chatModel, selectedService)

View file

@ -32,7 +32,7 @@ object ModelIcons {
fun getIconForProvider(provider: ServiceType): Icon? {
return when (provider) {
ServiceType.PROXYAI -> Icons.CodeGPTModel
ServiceType.PROXYAI -> Icons.DefaultSmall
ServiceType.OPENAI -> Icons.OpenAI
ServiceType.ANTHROPIC -> Icons.Anthropic
ServiceType.GOOGLE -> Icons.Google

View file

@ -1,10 +1,28 @@
package ee.carlrobert.codegpt.settings.models
import ai.koog.prompt.executor.clients.anthropic.AnthropicModels.Haiku_4_5
import ai.koog.prompt.executor.clients.anthropic.AnthropicModels.Opus_4_5
import ai.koog.prompt.executor.clients.anthropic.AnthropicModels.Sonnet_4_5
import ai.koog.prompt.executor.clients.google.GoogleModels.Gemini2_5Flash
import ai.koog.prompt.executor.clients.google.GoogleModels.Gemini2_5Pro
import ai.koog.prompt.executor.clients.google.GoogleModels.Gemini3_Pro_Preview
import ai.koog.prompt.executor.clients.mistralai.MistralAIModels.Chat.DevstralMedium
import ai.koog.prompt.executor.clients.openai.OpenAIModels.Chat.GPT4_1
import ai.koog.prompt.executor.clients.openai.OpenAIModels.Chat.GPT4_1Mini
import ai.koog.prompt.executor.clients.openai.OpenAIModels.Chat.GPT5Mini
import ai.koog.prompt.executor.clients.openai.OpenAIModels.Chat.GPT5_1
import ai.koog.prompt.executor.clients.openai.OpenAIModels.Chat.GPT5_1Codex
import ai.koog.prompt.executor.clients.openai.OpenAIModels.Chat.GPT5_2
import ai.koog.prompt.llm.LLMCapability
import ai.koog.prompt.llm.LLMProvider
import ai.koog.prompt.llm.LLModel
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.thisLogger
import ee.carlrobert.codegpt.Icons
import ee.carlrobert.codegpt.agent.clients.CustomOpenAILLMClient
import ee.carlrobert.codegpt.agent.clients.ProxyAILLMClient
import ee.carlrobert.codegpt.completions.llama.LlamaModel
import ee.carlrobert.codegpt.settings.service.FeatureType
import ee.carlrobert.codegpt.settings.service.ServiceType
@ -20,7 +38,9 @@ data class ModelSelection(
val model: String,
val displayName: String,
val icon: Icon? = null,
val pricingPlan: PricingPlan? = null
val pricingPlan: PricingPlan? = null,
val llmModel: LLModel? = null,
val id: String? = "",
) {
val fullDisplayName: String = if (provider == ServiceType.LLAMA_CPP) {
displayName
@ -44,6 +64,7 @@ class ModelRegistry {
ServiceType.PROXYAI to ModelCapability(
ServiceType.PROXYAI,
setOf(
FeatureType.AGENT,
FeatureType.CHAT,
FeatureType.CODE_COMPLETION,
FeatureType.AUTO_APPLY,
@ -56,36 +77,59 @@ class ModelRegistry {
ServiceType.OPENAI to ModelCapability(
ServiceType.OPENAI,
setOf(
FeatureType.CHAT, FeatureType.CODE_COMPLETION, FeatureType.AUTO_APPLY,
FeatureType.COMMIT_MESSAGE, FeatureType.INLINE_EDIT, FeatureType.LOOKUP
FeatureType.AGENT,
FeatureType.CHAT,
FeatureType.CODE_COMPLETION,
FeatureType.AUTO_APPLY,
FeatureType.COMMIT_MESSAGE,
FeatureType.INLINE_EDIT,
FeatureType.LOOKUP
)
),
ServiceType.ANTHROPIC to ModelCapability(
ServiceType.ANTHROPIC,
setOf(
FeatureType.CHAT, FeatureType.AUTO_APPLY, FeatureType.COMMIT_MESSAGE,
FeatureType.INLINE_EDIT, FeatureType.LOOKUP
FeatureType.AGENT,
FeatureType.CHAT,
FeatureType.AUTO_APPLY,
FeatureType.COMMIT_MESSAGE,
FeatureType.INLINE_EDIT,
FeatureType.LOOKUP
)
),
ServiceType.GOOGLE to ModelCapability(
ServiceType.GOOGLE,
setOf(
FeatureType.CHAT, FeatureType.AUTO_APPLY, FeatureType.COMMIT_MESSAGE,
FeatureType.INLINE_EDIT, FeatureType.LOOKUP
FeatureType.AGENT,
FeatureType.CHAT,
FeatureType.AUTO_APPLY,
FeatureType.COMMIT_MESSAGE,
FeatureType.INLINE_EDIT,
FeatureType.LOOKUP
)
),
ServiceType.MISTRAL to ModelCapability(
ServiceType.MISTRAL,
setOf(
FeatureType.CHAT, FeatureType.CODE_COMPLETION, FeatureType.AUTO_APPLY,
FeatureType.COMMIT_MESSAGE, FeatureType.INLINE_EDIT, FeatureType.LOOKUP
FeatureType.AGENT,
FeatureType.CHAT,
FeatureType.CODE_COMPLETION,
FeatureType.AUTO_APPLY,
FeatureType.COMMIT_MESSAGE,
FeatureType.INLINE_EDIT,
FeatureType.LOOKUP
)
),
ServiceType.OLLAMA to ModelCapability(
ServiceType.OLLAMA,
setOf(
FeatureType.CHAT, FeatureType.CODE_COMPLETION, FeatureType.AUTO_APPLY,
FeatureType.COMMIT_MESSAGE, FeatureType.INLINE_EDIT, FeatureType.LOOKUP
FeatureType.AGENT,
FeatureType.CHAT,
FeatureType.CODE_COMPLETION,
FeatureType.AUTO_APPLY,
FeatureType.COMMIT_MESSAGE,
FeatureType.INLINE_EDIT,
FeatureType.LOOKUP
)
),
ServiceType.LLAMA_CPP to ModelCapability(
@ -98,8 +142,13 @@ class ModelRegistry {
ServiceType.CUSTOM_OPENAI to ModelCapability(
ServiceType.CUSTOM_OPENAI,
setOf(
FeatureType.CHAT, FeatureType.CODE_COMPLETION, FeatureType.AUTO_APPLY,
FeatureType.COMMIT_MESSAGE, FeatureType.INLINE_EDIT, FeatureType.LOOKUP
FeatureType.AGENT,
FeatureType.CHAT,
FeatureType.CODE_COMPLETION,
FeatureType.AUTO_APPLY,
FeatureType.COMMIT_MESSAGE,
FeatureType.INLINE_EDIT,
FeatureType.LOOKUP
)
),
ServiceType.INCEPTION to ModelCapability(
@ -114,6 +163,11 @@ class ModelRegistry {
private val pricingPlanBasedDefaults = mapOf(
PricingPlan.ANONYMOUS to mapOf(
FeatureType.AGENT to ModelSelection(
ServiceType.OPENAI,
PROXYAI_AUTO,
"Auto"
),
FeatureType.CHAT to ModelSelection(
ServiceType.PROXYAI,
GEMINI_FLASH_2_5,
@ -151,6 +205,11 @@ class ModelRegistry {
)
),
PricingPlan.FREE to mapOf(
FeatureType.AGENT to ModelSelection(
ServiceType.OPENAI,
PROXYAI_AUTO,
"Auto"
),
FeatureType.CHAT to ModelSelection(ServiceType.PROXYAI, QWEN3_CODER, "Qwen3 Coder"),
FeatureType.AUTO_APPLY to ModelSelection(
ServiceType.PROXYAI,
@ -180,6 +239,11 @@ class ModelRegistry {
)
),
PricingPlan.INDIVIDUAL to mapOf(
FeatureType.AGENT to ModelSelection(
ServiceType.OPENAI,
PROXYAI_AUTO,
"Auto"
),
FeatureType.CHAT to ModelSelection(
ServiceType.PROXYAI,
CLAUDE_4_5_SONNET_THINKING,
@ -190,7 +254,11 @@ class ModelRegistry {
MERCURY_CODER,
"Mercury Coder"
),
FeatureType.COMMIT_MESSAGE to ModelSelection(ServiceType.PROXYAI, GPT_5_CODEX, "GPT-5 Codex"),
FeatureType.COMMIT_MESSAGE to ModelSelection(
ServiceType.PROXYAI,
GPT_5_CODEX,
"GPT-5 Codex"
),
FeatureType.INLINE_EDIT to ModelSelection(
ServiceType.PROXYAI,
CLAUDE_4_5_SONNET,
@ -211,6 +279,11 @@ class ModelRegistry {
)
private val fallbackDefaults = mapOf(
FeatureType.AGENT to ModelSelection(
ServiceType.OPENAI,
PROXYAI_AUTO,
"Auto"
),
FeatureType.CHAT to ModelSelection(
ServiceType.PROXYAI,
GEMINI_FLASH_2_5,
@ -242,6 +315,7 @@ class ModelRegistry {
fun getAllModelsForFeature(featureType: FeatureType): List<ModelSelection> {
return when (featureType) {
FeatureType.AGENT -> getAllAgentModels()
FeatureType.CHAT, FeatureType.COMMIT_MESSAGE,
FeatureType.INLINE_EDIT, FeatureType.LOOKUP -> getAllChatModels()
@ -272,15 +346,22 @@ class ModelRegistry {
fun findModel(provider: ServiceType, modelCode: String): ModelSelection? {
return getAllModels()
.filter { it.provider == provider }
.find { it.model == modelCode }
.find { it.id == modelCode || it.model == modelCode }
}
fun getModelDisplayName(provider: ServiceType, modelCode: String): String {
return findModel(provider, modelCode)?.displayName ?: modelCode
fun getModelDisplayName(provider: ServiceType, modelCode: String?): String {
val code = modelCode?.takeIf { it.isNotBlank() }
return if (code != null) {
findModel(provider, code)?.displayName ?: code
} else {
// Fallback to the first known model for the provider, or provider name
getAllModels().firstOrNull { it.provider == provider }?.displayName ?: provider.name
}
}
private fun getAllModels(): List<ModelSelection> {
return buildList {
addAll(getAllAgentModels())
addAll(getAllApplyModels())
addAll(getAllChatModels())
addAll(getAllCodeModels())
@ -288,6 +369,205 @@ class ModelRegistry {
}.distinctBy { "${it.provider}:${it.model}" }
}
fun getAgentModels(serviceType: ServiceType?): List<LLMModelWrapper> {
return getAgentModels()[serviceType] ?: emptyList()
}
data class LLMModelWrapper(val model: LLModel, val id: String = "", val name: String = "")
private fun getProxyAIModelWrapper(id: String, name: String): LLMModelWrapper {
return LLMModelWrapper(
model = LLModel(
id = id,
provider = ProxyAILLMClient.ProxyAI,
capabilities = listOf(
LLMCapability.Temperature,
LLMCapability.Schema.JSON.Basic,
LLMCapability.Schema.JSON.Standard,
LLMCapability.Speculation,
LLMCapability.Tools,
LLMCapability.ToolChoice,
LLMCapability.Vision.Image,
LLMCapability.Document,
LLMCapability.Completion,
LLMCapability.MultipleChoices,
LLMCapability.OpenAIEndpoint.Completions,
),
contextLength = 200_000,
maxOutputTokens = 32_768,
),
name = name
)
}
fun getAgentModels(): Map<ServiceType, List<LLMModelWrapper>> {
return mapOf(
ServiceType.PROXYAI to listOf(
getProxyAIModelWrapper(PROXYAI_AUTO, "Auto"),
getProxyAIModelWrapper(GPT5_2.id, "GPT-5.2"),
getProxyAIModelWrapper(GPT5_1Codex.id, "GPT-5.1 Codex"),
getProxyAIModelWrapper(GPT5Mini.id, "GPT-5 Mini"),
getProxyAIModelWrapper(Opus_4_5.id, "Claude Opus 4.5"),
getProxyAIModelWrapper(Sonnet_4_5.id, "Claude Sonnet 4.5"),
getProxyAIModelWrapper(Haiku_4_5.id, "Claude Haiku 4.5"),
),
ServiceType.OPENAI to listOf(
LLMModelWrapper(GPT5_2, name = "GPT-5.2"),
LLMModelWrapper(GPT5_1, name = "GPT-5.1"),
LLMModelWrapper(GPT5_1Codex, name = "GPT-5.1 Codex"),
LLMModelWrapper(GPT5Mini, name = "GPT-5 Mini"),
LLMModelWrapper(GPT4_1, name = "GPT-4.1"),
LLMModelWrapper(GPT4_1Mini, name = "GPT-4.1 Mini")
),
ServiceType.ANTHROPIC to listOf(
LLMModelWrapper(Opus_4_5, name = "Claude Opus 4.5"),
LLMModelWrapper(Sonnet_4_5, name = "Claude Sonnet 4.5"),
LLMModelWrapper(Haiku_4_5, name = "Claude Haiku 4.5")
),
ServiceType.CUSTOM_OPENAI to getCustomOpenAIModels().map { model ->
LLMModelWrapper(
model = LLModel(
id = model.model,
provider = CustomOpenAILLMClient.CustomOpenAI,
capabilities = listOf(
LLMCapability.Temperature,
LLMCapability.Schema.JSON.Basic,
LLMCapability.Schema.JSON.Standard,
LLMCapability.Speculation,
LLMCapability.Tools,
LLMCapability.ToolChoice,
LLMCapability.Vision.Image,
LLMCapability.Document,
LLMCapability.Completion,
LLMCapability.MultipleChoices,
LLMCapability.OpenAIEndpoint.Completions,
),
contextLength = 200_000,
maxOutputTokens = 32_768,
),
id = model.id ?: "",
name = model.displayName
)
},
ServiceType.GOOGLE to listOf(
LLMModelWrapper(Gemini3_Pro_Preview, name = "Gemini 3 Pro Preview"),
LLMModelWrapper(Gemini2_5Pro, name = "Gemini 2.5 Pro"),
LLMModelWrapper(Gemini2_5Flash, name = "Gemini 2.5 Flash")
),
ServiceType.MISTRAL to listOf(
LLMModelWrapper(DevstralMedium, name = "Devstral Medium")
),
ServiceType.OLLAMA to getOllamaModels().map { model ->
LLMModelWrapper(
LLModel(
id = model.model,
provider = LLMProvider.Ollama,
capabilities = listOf(
LLMCapability.Temperature,
LLMCapability.Schema.JSON.Basic,
LLMCapability.Schema.JSON.Standard,
LLMCapability.Speculation,
LLMCapability.Tools,
LLMCapability.ToolChoice,
LLMCapability.Vision.Image,
LLMCapability.Document,
LLMCapability.Completion,
LLMCapability.MultipleChoices,
LLMCapability.OpenAIEndpoint.Completions,
),
contextLength = 200_000,
maxOutputTokens = 32_768,
),
name = model.displayName
)
}
)
}
private fun getAllAgentModels(): List<ModelSelection> {
return buildList {
addAll(getProxyAIAgentModels())
addAll(getAnthropicAgentModels())
addAll(getOpenAIAgentModels())
addAll(getCustomOpenAIModels())
addAll(getGoogleAgentModels())
addAll(getMistralAgentModels())
addAll(getOllamaModels())
}
}
fun getOpenAIAgentModels(): List<ModelSelection> {
return listOf(
ModelSelection(ServiceType.OPENAI, GPT5_2.id, "GPT-5.2", Icons.OpenAI),
ModelSelection(ServiceType.OPENAI, GPT5_1.id, "GPT-5.1", Icons.OpenAI),
ModelSelection(ServiceType.OPENAI, GPT5_1Codex.id, "GPT-5.1 Codex", Icons.OpenAI),
ModelSelection(ServiceType.OPENAI, GPT5Mini.id, "GPT-5 Mini", Icons.OpenAI),
)
}
private fun getProxyAIAgentModels(): List<ModelSelection> {
return listOf(
ModelSelection(ServiceType.PROXYAI, PROXYAI_AUTO, "Auto", Icons.DefaultSmall),
ModelSelection(ServiceType.PROXYAI, GPT5_2.id, "GPT-5.2", Icons.OpenAI),
ModelSelection(ServiceType.PROXYAI, GPT5_1Codex.id, "GPT-5.1 Codex", Icons.OpenAI),
ModelSelection(ServiceType.PROXYAI, GPT5Mini.id, "GPT-5 Mini", Icons.OpenAI),
ModelSelection(ServiceType.PROXYAI, Opus_4_5.id, "Claude Opus 4.5", Icons.Anthropic),
ModelSelection(
ServiceType.PROXYAI,
Sonnet_4_5.id,
"Claude Sonnet 4.5",
Icons.Anthropic
),
ModelSelection(
ServiceType.PROXYAI,
Haiku_4_5.id,
"Claude Haiku 4.5",
Icons.Anthropic
),
)
}
fun getAnthropicAgentModels(): List<ModelSelection> {
return listOf(
ModelSelection(ServiceType.ANTHROPIC, Opus_4_5.id, "Claude Opus 4.5", Icons.Anthropic),
ModelSelection(
ServiceType.ANTHROPIC,
Sonnet_4_5.id,
"Claude Sonnet 4.5",
Icons.Anthropic
),
ModelSelection(
ServiceType.ANTHROPIC,
Haiku_4_5.id,
"Claude Haiku 4.5",
Icons.Anthropic
),
)
}
fun getGoogleAgentModels(): List<ModelSelection> {
return listOf(
ModelSelection(ServiceType.GOOGLE, Gemini2_5Pro.id, "Gemini 2.5 Pro", Icons.Google),
ModelSelection(
ServiceType.GOOGLE,
Gemini2_5Flash.id,
"Gemini 2.5 Flash",
Icons.Google
),
)
}
fun getMistralAgentModels(): List<ModelSelection> {
return listOf(
ModelSelection(
ServiceType.MISTRAL,
DevstralMedium.id,
"Devstral Medium",
Icons.Mistral
),
)
}
private fun getAllChatModels(): List<ModelSelection> {
return buildList {
addAll(getProxyAIChatModels())
@ -575,16 +855,20 @@ class ModelRegistry {
fun getCustomOpenAIModels(): List<ModelSelection> {
return try {
val customServicesSettings = service<CustomServicesSettings>()
customServicesSettings.state.services.mapNotNull { service ->
service<CustomServicesSettings>().state.services.mapNotNull { service ->
val serviceId = service.id ?: return@mapNotNull null
val serviceName = service.name ?: ""
val modelName = service.chatCompletionSettings.body["model"] as? String
val displayName = if (!modelName.isNullOrEmpty()) {
if (modelName.length > 20) "$serviceName (...${modelName.takeLast(20)})" else "$serviceName ($modelName)"
val model = service.chatCompletionSettings.body["model"] as? String ?: ""
val displayName = if (model.isNotEmpty()) {
if (model.length > 20) "$serviceName (...${model.takeLast(20)})" else "$serviceName ($model)"
} else serviceName
ModelSelection(ServiceType.CUSTOM_OPENAI, serviceId, displayName)
ModelSelection(
id = serviceId,
provider = ServiceType.CUSTOM_OPENAI,
model = model,
displayName = displayName
)
}
} catch (e: Exception) {
logger.error("Failed to get Custom OpenAI models", e)
@ -609,6 +893,7 @@ class ModelRegistry {
companion object {
// ProxyAI Models
const val PROXYAI_AUTO = "auto"
const val GEMINI_PRO_2_5 = "gemini-pro-2.5"
const val GEMINI_FLASH_2_5 = "gemini-flash-2.5"
const val CLAUDE_4_SONNET = "claude-4-sonnet"
@ -640,6 +925,7 @@ class ModelRegistry {
const val GPT_5 = "gpt-5"
const val GPT_5_MINI = "gpt-5-mini"
const val GPT_5_CODEX = "gpt-5-codex"
const val GPT_5_1_CODEX = "gpt-5.1-codex"
// Anthropic Models
const val CLAUDE_OPUS_4_20250514 = "claude-opus-4-20250514"

View file

@ -21,6 +21,9 @@ class ModelSettings : SimplePersistentStateComponent<ModelSettingsState>(ModelSe
)
private val publisherMethods = mapOf(
FeatureType.AGENT to PublisherMethod { publisher, model, serviceType ->
publisher.agentModelChanged(model, serviceType)
},
FeatureType.CHAT to PublisherMethod { publisher, model, serviceType ->
publisher.chatModelChanged(model, serviceType)
},

View file

@ -42,6 +42,7 @@ class ModelSettingsForm(
titleKey = "settings.models.chat.section.title",
descriptionKey = "settings.models.chat.section.description",
features = listOf(
FeatureConfig(FeatureType.AGENT, "settings.models.agent.label"),
FeatureConfig(FeatureType.CHAT, "settings.models.chat.label"),
FeatureConfig(FeatureType.AUTO_APPLY, "settings.models.autoApply.label"),
FeatureConfig(
@ -73,6 +74,10 @@ class ModelSettingsForm(
messageBusConnection.subscribe(
ModelChangeNotifier.getTopic(),
object : ModelChangeNotifier {
override fun agentModelChanged(newModel: String, serviceType: ServiceType) {
modelChanged(FeatureType.AGENT, newModel, serviceType)
}
override fun chatModelChanged(newModel: String, serviceType: ServiceType) {
modelChanged(FeatureType.CHAT, newModel, serviceType)
}

View file

@ -4,6 +4,7 @@ import com.intellij.util.messages.Topic
interface ModelChangeNotifier {
fun agentModelChanged(newModel: String, serviceType: ServiceType)
fun chatModelChanged(newModel: String, serviceType: ServiceType)
fun codeModelChanged(newModel: String, serviceType: ServiceType)
fun autoApplyModelChanged(newModel: String, serviceType: ServiceType)
@ -25,6 +26,7 @@ interface ModelChangeNotifier {
abstract class ModelChangeNotifierAdapter : ModelChangeNotifier {
override fun agentModelChanged(newModel: String, serviceType: ServiceType) {}
override fun chatModelChanged(newModel: String, serviceType: ServiceType) {}
override fun codeModelChanged(newModel: String, serviceType: ServiceType) {}
override fun autoApplyModelChanged(newModel: String, serviceType: ServiceType) {}

View file

@ -1,5 +1,6 @@
package ee.carlrobert.codegpt.settings.service
import ai.koog.prompt.llm.LLModel
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
@ -12,6 +13,16 @@ import ee.carlrobert.llm.client.codegpt.PricingPlan
@Service
class ModelSelectionService {
fun getAgentModel(): LLModel {
val modelDetailsState = service<ModelSettings>().state.getModelSelection(FeatureType.AGENT)
val model = modelDetailsState?.model
val provider = modelDetailsState?.provider
val models = service<ModelRegistry>().getAgentModels()[provider]
return models?.find { it.id == model || it.model.id == model }?.model
?: throw IllegalArgumentException("Model '$model' not found for provider $provider")
}
fun getModelSelectionForFeature(
featureType: FeatureType,
pricingPlan: PricingPlan? = null
@ -19,14 +30,18 @@ class ModelSelectionService {
return try {
val modelSettings = service<ModelSettings>()
val modelDetailsState = modelSettings.state.getModelSelection(featureType)
if (modelDetailsState != null && modelDetailsState.model != null && modelDetailsState.provider != null) {
val foundModel = service<ModelRegistry>().findModel(modelDetailsState.provider!!, modelDetailsState.model!!)
val foundModel =
service<ModelRegistry>().findModel(
modelDetailsState.provider!!,
modelDetailsState.model!!
)
if (foundModel != null) {
return foundModel
}
}
service<ModelRegistry>().getDefaultModelForFeature(featureType, pricingPlan)
} catch (exception: Exception) {
logger.warn(
@ -64,7 +79,11 @@ class ModelSelectionService {
val settings = service<ModelSettings>()
FeatureType.entries.forEach { featureType ->
if (!registry.isFeatureSupportedByProvider(featureType, ServiceType.CUSTOM_OPENAI)) return@forEach
if (!registry.isFeatureSupportedByProvider(
featureType,
ServiceType.CUSTOM_OPENAI
)
) return@forEach
val current = settings.getModelSelection(featureType)
if (current?.provider != ServiceType.CUSTOM_OPENAI) return@forEach

View file

@ -121,14 +121,13 @@ class CustomServicesSettings :
val modelSelection = service<ModelSelectionService>()
val featureSelection = modelSelection.getModelSelectionForFeature(featureType)
if (featureSelection?.provider != ServiceType.CUSTOM_OPENAI)
if (featureSelection.provider != ServiceType.CUSTOM_OPENAI)
throw IllegalStateException(
"Current selected ServiceType (${featureSelection}) is not of type 'CUSTOM_OPENAI'. " +
"This function should not be called in this context!"
)
return this.state.services
.find { it.id == featureSelection.model }
return this.state.services.find { it.id == featureSelection.id }
?: throw IllegalStateException("Unable to find custom service with id '${featureSelection.model}'.")
}
}

View file

@ -2,7 +2,6 @@ package ee.carlrobert.codegpt.settings.service.custom.form
import com.intellij.icons.AllIcons
import com.intellij.icons.AllIcons.General
import com.intellij.ide.BrowserUtil
import com.intellij.ide.HelpTooltip
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
@ -14,11 +13,8 @@ import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
import com.intellij.openapi.ui.*
import com.intellij.openapi.ui.DialogWrapper.OK_EXIT_CODE
import com.intellij.ui.EnumComboBoxModel
import com.intellij.ui.JBColor
import com.intellij.ui.ToolbarDecorator
import com.intellij.ui.components.*
import com.intellij.ui.components.ActionLink
// removed: com.intellij.ui.dsl.builder.panel
import com.intellij.util.concurrency.AppExecutorUtil
import com.intellij.util.ui.FormBuilder
import com.intellij.util.ui.JBUI
@ -36,6 +32,7 @@ import ee.carlrobert.codegpt.settings.service.custom.template.CustomServiceTempl
import ee.carlrobert.codegpt.ui.OverlayUtil
import ee.carlrobert.codegpt.ui.UIUtil
import ee.carlrobert.codegpt.util.ApplicationUtil
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@ -44,7 +41,6 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import okhttp3.internal.toImmutableList
import java.awt.BorderLayout
import java.awt.Dimension
import java.awt.FlowLayout
@ -294,8 +290,11 @@ class CustomServiceForm(
.panel
private fun createMarketingPanel(): JPanel {
val marketingText = CodeGPTBundle.get("settingsConfigurable.service.custom.openai.marketing.text").trim()
val learnMoreText = CodeGPTBundle.get("settingsConfigurable.service.custom.openai.marketing.learnMore").trim()
val marketingText =
CodeGPTBundle.get("settingsConfigurable.service.custom.openai.marketing.text").trim()
val learnMoreText =
CodeGPTBundle.get("settingsConfigurable.service.custom.openai.marketing.learnMore")
.trim()
val html = """
<html>

View file

@ -0,0 +1,45 @@
package ee.carlrobert.codegpt.tokens
import com.intellij.openapi.components.service
import ee.carlrobert.codegpt.EncodingManager
/**
* Utilities to enforce token-based truncation on strings passed back to the model.
* Applies a head+tail gap strategy by default to preserve the beginning and the end.
*/
object ToolResultTruncationConfig {
const val MAX_TOKENS: Int = 16_000
const val DEFAULT_HEAD_RATIO: Double = 0.85 // keep more head than tail by default
const val GAP_MARKER: String = "\n...\n"
}
fun String.countTokens(): Int = service<EncodingManager>().countTokens(this)
fun String.truncateTokens(maxTokens: Int, fromStart: Boolean = true): String =
service<EncodingManager>().truncateText(this, maxTokens, fromStart)
fun String.truncateTokensWithGap(
maxTokens: Int = ToolResultTruncationConfig.MAX_TOKENS,
headRatio: Double = ToolResultTruncationConfig.DEFAULT_HEAD_RATIO,
gapMarker: String = ToolResultTruncationConfig.GAP_MARKER,
): String {
if (this.isEmpty()) return this
val total = this.countTokens()
if (total <= maxTokens) return this
val safeHeadRatio = headRatio.coerceIn(0.1, 0.9)
val headTokens = (maxTokens * safeHeadRatio).toInt().coerceAtLeast(1)
val tailTokens = (maxTokens - headTokens).coerceAtLeast(1)
val head = this.truncateTokens(headTokens, fromStart = true)
val tail = this.truncateTokens(tailTokens, fromStart = false)
return buildString(head.length + gapMarker.length + tail.length) {
append(head)
append(gapMarker)
append(tail)
}
}
fun String.truncateToolResult(maxTokens: Int = ToolResultTruncationConfig.MAX_TOKENS): String =
this.truncateTokensWithGap(maxTokens)

View file

@ -2,10 +2,12 @@ package ee.carlrobert.codegpt.toolwindow
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.wm.ToolWindow
import com.intellij.openapi.wm.ToolWindowFactory
import com.intellij.ui.content.ContentManagerEvent
import com.intellij.ui.content.ContentManagerListener
import ee.carlrobert.codegpt.toolwindow.agent.AgentToolWindowPanel
import ee.carlrobert.codegpt.toolwindow.chat.ChatToolWindowPanel
import ee.carlrobert.codegpt.toolwindow.history.ChatHistoryToolWindow
import javax.swing.JComponent
@ -16,9 +18,13 @@ class ProxyAIToolWindowFactory : ToolWindowFactory, DumbAware {
project: Project,
toolWindow: ToolWindow
) {
var chatToolWindowPanel = ChatToolWindowPanel(project, toolWindow.disposable)
var chatHistoryToolWindow = ChatHistoryToolWindow(project)
val agentToolWindowPanel = AgentToolWindowPanel(project).also {
Disposer.register(toolWindow.disposable, it)
}
val chatToolWindowPanel = ChatToolWindowPanel(project, toolWindow.disposable)
val chatHistoryToolWindow = ChatHistoryToolWindow(project)
addContent(toolWindow, agentToolWindowPanel, "Agent")
addContent(toolWindow, chatToolWindowPanel, "Chat")
addContent(toolWindow, chatHistoryToolWindow.getContent(), "Chat History")
@ -27,6 +33,10 @@ class ProxyAIToolWindowFactory : ToolWindowFactory, DumbAware {
if ("Chat History" == event.content.tabName && event.content.isSelected) {
chatHistoryToolWindow.refresh()
}
if (event.content.tabName == "Agent" && !event.content.isSelected) {
agentToolWindowPanel.getTabbedPane().onTabHidden()
}
}
})
}

View file

@ -0,0 +1,329 @@
package ee.carlrobert.codegpt.toolwindow.agent
import com.intellij.diff.DiffContentFactory
import com.intellij.diff.chains.SimpleDiffRequestChain
import com.intellij.diff.editor.ChainDiffVirtualFile
import com.intellij.diff.requests.SimpleDiffRequest
import com.intellij.diff.DiffManager
import com.intellij.diff.util.DiffUserDataKeys
import com.intellij.diff.util.DiffUserDataKeysEx
import com.intellij.icons.AllIcons
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.Presentation
import com.intellij.openapi.actionSystem.ex.CustomComponentAction
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.runInEdt
import com.intellij.openapi.editor.EditorFactory
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.fileTypes.FileTypes
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.popup.JBPopup
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.Key
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.ui.JBColor
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.components.BorderLayoutPanel
import ee.carlrobert.codegpt.CodeGPTBundle
import ee.carlrobert.codegpt.Icons
import ee.carlrobert.codegpt.agent.tools.EditTool
import ee.carlrobert.codegpt.agent.tools.WriteTool
import ee.carlrobert.codegpt.toolwindow.agent.ui.renderer.applyStringReplacement
import ee.carlrobert.codegpt.toolwindow.agent.ui.renderer.getFileContentWithFallback
import kotlinx.coroutines.CompletableDeferred
import java.awt.BorderLayout
import java.awt.FlowLayout
import java.awt.Insets
import java.awt.Point
import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import java.nio.file.Paths
import javax.swing.JButton
import javax.swing.JComponent
import javax.swing.JPanel
/**
* Manages the approval workflow for agent tool calls, particularly for Write and Edit operations.
* Handles diff display, approval dialogs, and popup management.
*/
class AgentApprovalManager(
private val project: Project
) {
private val approvalPopups = ConcurrentHashMap<String, JBPopup>()
companion object {
private val AGENT_DIFF_REQUEST_KEY: Key<String> = Key.create("agent.approval.diffRequest")
}
fun openWriteApprovalDiff(
args: WriteTool.Args,
decision: CompletableDeferred<Boolean>
) {
val vf = LocalFileSystem.getInstance().refreshAndFindFileByPath(args.filePath)
val factory = DiffContentFactory.getInstance()
val left = if (vf != null) factory.create(project, vf) else factory.create(project, "")
val rightDoc = EditorFactory.getInstance().createDocument(args.content)
.apply {
setReadOnly(true)
}
val right = if (vf != null) factory.create(project, rightDoc, vf)
else factory.create(project, rightDoc, FileTypes.PLAIN_TEXT)
val request = SimpleDiffRequest(
"Write File",
listOf(left, right),
listOf("Current", "Proposed")
)
attachApprovalActionsAndShowDiff(request, decision)
}
fun openEditApprovalDiff(args: EditTool.Args, decision: CompletableDeferred<Boolean>) {
val path = try {
Paths.get(args.filePath).normalize().toString()
} catch (_: Exception) {
args.filePath
}
val vf = LocalFileSystem.getInstance().refreshAndFindFileByPath(path)
val factory = DiffContentFactory.getInstance()
val current = getFileContentWithFallback(path)
val proposed =
applyStringReplacement(current, args.oldString, args.newString, args.replaceAll)
val left = if (vf != null) factory.create(project, vf) else factory.create(project, current)
val rightDoc = EditorFactory.getInstance().createDocument(proposed).apply {
setReadOnly(true)
}
val right = if (vf != null) factory.create(project, rightDoc, vf)
else factory.create(project, rightDoc, FileTypes.PLAIN_TEXT)
val request = SimpleDiffRequest(
"Edit File",
listOf(left, right),
listOf("Current", "Proposed")
)
attachApprovalActionsAndShowDiff(request, decision)
}
private fun attachApprovalActionsAndShowDiff(
request: SimpleDiffRequest,
decision: CompletableDeferred<Boolean>
) {
val diffId = UUID.randomUUID().toString()
val applyText = CodeGPTBundle.get("shared.apply")
val rejectText = CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.reject")
fun resolveApproval(approved: Boolean) {
if (!decision.isCompleted) decision.complete(approved)
ApplicationManager.getApplication().invokeLater {
closeDiffById(diffId)
}
}
val applyTop = createApplyAction(applyText) { resolveApproval(true) }
val rejectTop = createRejectAction(rejectText) { resolveApproval(false) }
request.putUserData(DiffUserDataKeys.CONTEXT_ACTIONS, listOf(applyTop, rejectTop))
val bottomPanel = createBottomPanel(applyText, rejectText) { resolveApproval(it) }
request.putUserData(DiffUserDataKeysEx.BOTTOM_PANEL, bottomPanel)
request.putUserData(AGENT_DIFF_REQUEST_KEY, diffId)
runInEdt { DiffManager.getInstance().showDiff(project, request) }
decision.invokeOnCompletion {
ApplicationManager.getApplication().invokeLater {
closeDiffById(diffId)
}
}
showDiffTabApprovalPopup(diffId, decision)
}
private fun createApplyAction(text: String, onApply: () -> Unit): AnAction =
object : AnAction(text, null, Icons.GreenCheckmark), CustomComponentAction {
override fun actionPerformed(e: AnActionEvent) = onApply()
override fun createCustomComponent(
presentation: Presentation,
place: String
): JComponent =
JButton(text).apply {
icon = Icons.GreenCheckmark
isFocusable = false
isContentAreaFilled = true
isOpaque = true
margin = Insets(JBUI.scale(4), JBUI.scale(10), JBUI.scale(4), JBUI.scale(10))
addActionListener { onApply() }
}
}
private fun createRejectAction(text: String, onReject: () -> Unit): AnAction =
object : AnAction(text, null, AllIcons.Actions.Close), CustomComponentAction {
override fun actionPerformed(e: AnActionEvent) = onReject()
override fun createCustomComponent(
presentation: Presentation,
place: String
): JComponent =
JButton(text).apply {
isFocusable = false
isContentAreaFilled = true
isOpaque = true
margin = Insets(JBUI.scale(4), JBUI.scale(10), JBUI.scale(4), JBUI.scale(10))
addActionListener { onReject() }
}
}
private fun createBottomPanel(
applyText: String,
rejectText: String,
onDecision: (Boolean) -> Unit
): JPanel =
JPanel(FlowLayout(FlowLayout.RIGHT, JBUI.scale(8), JBUI.scale(8))).apply {
isOpaque = true
border = JBUI.Borders.compound(
JBUI.Borders.customLine(JBColor.border(), 1, 0, 0, 0),
JBUI.Borders.empty(8)
)
val applyBtn = JButton(applyText).apply {
isFocusable = false
isContentAreaFilled = true
isOpaque = true
margin = Insets(JBUI.scale(6), JBUI.scale(12), JBUI.scale(6), JBUI.scale(12))
addActionListener { onDecision(true) }
}
val rejectBtn = JButton(rejectText).apply {
isFocusable = false
isContentAreaFilled = true
isOpaque = true
putClientProperty("JButton.buttonType", "roundRect")
margin = Insets(JBUI.scale(6), JBUI.scale(12), JBUI.scale(6), JBUI.scale(12))
addActionListener { onDecision(false) }
}
add(applyBtn)
add(rejectBtn)
}
private fun closeDiffById(diffId: String) {
val manager = FileEditorManager.getInstance(project)
val diffFile = manager.openFiles.firstOrNull {
it is ChainDiffVirtualFile && it.chain.requests
.filterIsInstance<SimpleDiffRequestChain.DiffRequestProducerWrapper>()
.any { wrapper ->
wrapper.request.getUserData(AGENT_DIFF_REQUEST_KEY) == diffId
}
}
if (diffFile != null) manager.closeFile(diffFile)
approvalPopups.remove(diffId)?.let { if (!it.isDisposed) it.cancel() }
}
private fun findDiffFileById(diffId: String): VirtualFile? {
val manager = FileEditorManager.getInstance(project)
return manager.openFiles.firstOrNull {
it is ChainDiffVirtualFile && it.chain.requests
.filterIsInstance<SimpleDiffRequestChain.DiffRequestProducerWrapper>()
.any { wrapper ->
wrapper.request.getUserData(AGENT_DIFF_REQUEST_KEY) == diffId
}
}
}
private fun showDiffTabApprovalPopup(diffId: String, decision: CompletableDeferred<Boolean>) {
val applyText = CodeGPTBundle.get("shared.apply")
val rejectText = CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.reject")
val panel = BorderLayoutPanel().apply {
isOpaque = true
background = JBUI.CurrentTheme.CustomFrameDecorations.paneBackground()
border = JBUI.Borders.compound(
JBUI.Borders.customLine(JBColor.border(), 1),
JBUI.Borders.empty(8, 12)
)
val buttons = JPanel(FlowLayout(FlowLayout.RIGHT, JBUI.scale(8), 0)).apply {
isOpaque = false
val applyBtn = JButton(applyText).apply {
isContentAreaFilled = true
isOpaque = true
margin = Insets(JBUI.scale(6), JBUI.scale(12), JBUI.scale(6), JBUI.scale(12))
addActionListener {
decision.complete(true)
closeDiffById(diffId)
}
}
val rejectBtn = JButton(rejectText).apply {
isContentAreaFilled = true
isOpaque = true
margin = Insets(JBUI.scale(6), JBUI.scale(12), JBUI.scale(6), JBUI.scale(12))
addActionListener {
decision.complete(false)
closeDiffById(diffId)
}
}
add(applyBtn)
add(rejectBtn)
}
add(buttons, BorderLayout.CENTER)
}
runInEdt {
val diffFile = findDiffFileById(diffId) ?: return@runInEdt
val manager = FileEditorManager.getInstance(project)
val fileEditor = manager.getSelectedEditor(diffFile)
?: manager.getEditors(diffFile).firstOrNull()
val target = fileEditor?.component ?: return@runInEdt
val popup = JBPopupFactory.getInstance()
.createComponentPopupBuilder(panel, null)
.setRequestFocus(false)
.setFocusable(false)
.setMovable(false)
.setResizable(false)
.setShowBorder(false)
.setCancelOnClickOutside(false)
.setCancelOnOtherWindowOpen(true)
.createPopup()
approvalPopups[diffId] = popup
val margin = JBUI.scale(12)
val size = panel.preferredSize
val onScreen = target.locationOnScreen
val x = onScreen.x + target.width - size.width - margin
val y = onScreen.y + target.height - size.height - margin
popup.showInScreenCoordinates(target, Point(x, y))
val listener = object : ComponentAdapter() {
override fun componentResized(e: ComponentEvent?) = relocate()
override fun componentMoved(e: ComponentEvent?) = relocate()
private fun relocate() {
if (popup.isDisposed) return
val loc = target.locationOnScreen
val nx = loc.x + target.width - size.width - margin
val ny = loc.y + target.height - size.height - margin
popup.setLocation(Point(nx, ny))
}
}
target.addComponentListener(listener)
Disposer.register(popup) {
try {
target.removeComponentListener(listener)
} catch (_: Exception) {
}
}
}
}
}

View file

@ -0,0 +1,18 @@
package ee.carlrobert.codegpt.toolwindow.agent
import com.intellij.util.messages.Topic
data class AgentCreditsEvent(
val sessionId: String,
val remaining: Long,
val monthlyRemaining: Long,
val consumed: Long,
)
interface AgentCreditsListener {
fun onCreditsChanged(event: AgentCreditsEvent)
companion object {
val AGENT_CREDITS_TOPIC = Topic("Agent Credits Changes", AgentCreditsListener::class.java)
}
}

View file

@ -0,0 +1,681 @@
package ee.carlrobert.codegpt.toolwindow.agent
import ai.koog.agents.core.feature.handler.agent.AgentCompletedContext
import ai.koog.http.client.KoogHttpClientException
import ai.koog.prompt.executor.clients.LLMClientException
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.runInEdt
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.project.Project
import ee.carlrobert.codegpt.agent.*
import ee.carlrobert.codegpt.agent.tools.*
import ee.carlrobert.codegpt.conversations.message.TokenUsage
import ee.carlrobert.codegpt.settings.service.ServiceType
import ee.carlrobert.codegpt.toolwindow.agent.ui.*
import ee.carlrobert.codegpt.toolwindow.agent.ui.approval.*
import ee.carlrobert.codegpt.toolwindow.chat.ui.ChatMessageResponseBody
import ee.carlrobert.codegpt.toolwindow.chat.ui.ChatToolWindowScrollablePanel
import ee.carlrobert.codegpt.ui.textarea.UserInputPanel
import kotlinx.coroutines.*
import java.awt.Component
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import javax.swing.JPanel
class AgentEventHandler(
private val project: Project,
private val sessionId: String,
private val agentApprovalManager: AgentApprovalManager,
private val approvalContainer: JPanel,
private val scrollablePanel: ChatToolWindowScrollablePanel,
private val todoListPanel: TodoListPanel,
private val userInputPanel: UserInputPanel,
private val onShowLoading: (String) -> Unit,
private val onHideLoading: () -> Unit,
private val onQueuedMessagesResolved: (MessageWithContext) -> Unit = {}
) : AgentEvents, Disposable {
companion object {
val logger = thisLogger()
}
private val mainToolCards = ConcurrentHashMap<String, ToolCallCard>()
@Volatile
private var lastReportedPromptTokens: Long = 0
private fun keyFor(toolId: String): String = "$sessionId:$toolId"
@Volatile
private var currentResponseBody: ChatMessageResponseBody? = null
@Volatile
private var lastWriteArgs: WriteTool.Args? = null
@Volatile
private var lastEditArgs: EditTool.Args? = null
private val approvalQueue: ArrayDeque<ApprovalRequest> = ArrayDeque()
@Volatile
private var currentApproval: ApprovalRequest? = null
private data class QuestionRequest(
val model: AskUserQuestionTool.AskUserQuestionsModel,
val deferred: CompletableDeferred<Map<String, String>>
)
private val questionQueue: ArrayDeque<QuestionRequest> = ArrayDeque()
@Volatile
private var currentQuestion: QuestionRequest? = null
private var runViewHolder: RunViewHolder? = null
private val subagentViewHolders = ConcurrentHashMap<String, RunViewHolder>()
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
data class ApprovalRequest(
var model: ToolApprovalRequest,
val deferred: CompletableDeferred<Boolean>
)
fun resetForNewSubmission() {
mainToolCards.clear()
currentResponseBody = null
lastWriteArgs = null
lastEditArgs = null
currentApproval = null
approvalQueue.clear()
currentQuestion = null
questionQueue.clear()
approvalContainer.removeAll()
approvalContainer.isVisible = false
todoListPanel.clearTodos()
runViewHolder = null
subagentViewHolders.clear()
lastReportedPromptTokens = 0
}
fun setCurrentResponseBody(responseBody: ChatMessageResponseBody) {
currentResponseBody = responseBody
}
override fun onAgentCompleted(ctx: AgentCompletedContext) {
runCatching {
project.service<AgentToolWindowContentManager>().getTabbedPane()
.onAgentCompleted(sessionId)
}
val tabTitle = runCatching {
project.service<AgentToolWindowContentManager>()
.getTabbedPane()
.tryFindTabTitle(sessionId)
.orElse(null)
}.getOrNull()
val resolvedAgentId = project.service<AgentService>().getAgentForSession(sessionId)?.id
?: ctx.agentId
project.service<AgentSessionState>().updateSession(
sessionId,
lastAgentId = resolvedAgentId,
displayName = tabTitle
)
handleDone()
}
override fun onClientException(provider: ServiceType, ex: LLMClientException) {
val cause = ex.cause
if (cause is KoogHttpClientException) {
if (provider == ServiceType.PROXYAI) {
handleProxyAIException(cause)
}
}
}
private fun handleProxyAIException(ex: KoogHttpClientException) {
when (ex.statusCode) {
403 -> {
currentResponseBody?.displayMissingCredential()
handleDone()
}
429 -> {
currentResponseBody?.displayCreditsExhausted()
handleDone()
}
}
}
private fun handleDone() {
runInEdt {
currentResponseBody?.finishThinking()
onHideLoading()
userInputPanel.setStopEnabled(false)
scrollablePanel.update()
scrollablePanel.scrollToBottom()
todoListPanel.clearTodos()
runCatching {
project.service<AgentToolWindowContentManager>()
.setTabStatus(sessionId, AgentToolWindowTabbedPane.TabStatus.STOPPED)
}
}
}
override suspend fun approveToolCall(request: ToolApprovalRequest): Boolean {
val isWrite = request.type == ToolApprovalType.WRITE
val isEdit = request.type == ToolApprovalType.EDIT
if (isWrite || isEdit) {
val deferred = CompletableDeferred<Boolean>()
runInEdt {
approvalQueue.addLast(ApprovalRequest(request, deferred))
maybeShowNextApproval()
}
lastWriteArgs?.let {
if (isWrite) agentApprovalManager.openWriteApprovalDiff(it, deferred)
}
lastEditArgs?.let {
if (isEdit) agentApprovalManager.openEditApprovalDiff(it, deferred)
}
return deferred.await()
}
val decision = CompletableDeferred<Boolean>()
runInEdt {
approvalQueue.addLast(ApprovalRequest(request, decision))
maybeShowNextApproval()
}
return decision.await()
}
override suspend fun askUserQuestions(
model: AskUserQuestionTool.AskUserQuestionsModel
): Map<String, String> {
val deferred = CompletableDeferred<Map<String, String>>()
runInEdt {
questionQueue.addLast(QuestionRequest(model, deferred))
maybeShowNextQuestion()
}
return deferred.await()
}
override fun onTextReceived(text: String) {
runInEdt {
val cleanedText =
text.replace(Regex("<think>.*?</think>", RegexOption.DOT_MATCHES_ALL), "")
currentResponseBody?.updateMessage(cleanedText)
scrollablePanel.update()
scrollablePanel.scrollToBottom()
}
}
override fun onToolStarting(id: String, toolName: String, args: Any?) {
when (args) {
is TodoWriteTool.Args -> {
runInEdt {
val inProgressTask =
args.todos.find { it.status == TodoWriteTool.TodoStatus.IN_PROGRESS }
if (inProgressTask != null) {
onShowLoading(inProgressTask.activeForm)
}
todoListPanel.updateTodos(args.todos)
todoListPanel.isVisible = true
scrollablePanel.update()
scrollablePanel.scrollToBottom()
}
}
is TaskTool.Args -> {
runInEdt {
val host = ensureRunViewForSubagent(id)
host.addEntry(RunEntry.TaskEntry(id, null, args, null))
host.refresh()
scrollablePanel.update()
scrollablePanel.scrollToBottom()
}
}
else -> {
if (args is WriteTool.Args) {
lastWriteArgs = args
} else if (args is EditTool.Args) {
lastEditArgs = args
}
if (toolName != "Task") {
runInEdt {
val key = keyFor(id)
if (!mainToolCards.containsKey(key)) {
val card = ToolCallCard(project, toolName, args)
mainToolCards[key] = card
currentResponseBody?.addToolStatusPanel(card)
scrollablePanel.update()
scrollablePanel.scrollToBottom()
}
}
}
}
}
}
override fun onToolCompleted(id: String?, toolName: String, result: Any?) {
runInEdt {
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, result)
holder?.refresh()
} else if (id != null && mainToolCards.containsKey(keyFor(id))) {
val success = result !is ToolError && result != null
mainToolCards[keyFor(id)]?.complete(success, result)
val bgId = (result as? BashTool.Result)?.bashId
if (bgId == null) {
mainToolCards.remove(keyFor(id))
} else {
val publisher =
ApplicationManager.getApplication()
.messageBus
.syncPublisher(AgentToolOutputNotifier.AGENT_TOOL_OUTPUT_TOPIC)
serviceScope.launch {
try {
var outPos = 0
var errPos = 0
while (true) {
val po = BackgroundProcessManager.getOutput(bgId) ?: break
val stdout = po.stdout.toString()
val stderr = po.stderr.toString()
if (outPos < stdout.length) {
stdout.substring(outPos).split('\n').forEach { line ->
if (line.isNotEmpty()) publisher.toolOutput(id, line, false)
}
outPos = stdout.length
}
if (errPos < stderr.length) {
stderr.substring(errPos).split('\n').forEach { line ->
if (line.isNotEmpty()) publisher.toolOutput(id, line, true)
}
errPos = stderr.length
}
if (po.isComplete) break
delay(300)
}
} catch (_: Exception) {
} finally {
runInEdt {
mainToolCards.remove(keyFor(id))
}
}
}
}
}
scrollablePanel.update()
scrollablePanel.scrollToBottom()
}
}
override fun onSubAgentToolStarting(parentId: String, toolName: String, args: Any?): String {
val cid = UUID.randomUUID().toString()
if (args is TodoWriteTool.Args) {
val inProgressTask =
args.todos.find { it.status == TodoWriteTool.TodoStatus.IN_PROGRESS }
if (inProgressTask != null) {
onShowLoading(inProgressTask.activeForm)
}
return cid
}
runInEdt {
val host = ensureRunViewForSubagent(parentId)
val entry = when (args) {
is ReadTool.Args -> RunEntry.ReadEntry(cid, parentId, args, null)
is IntelliJSearchTool.Args -> RunEntry.IntelliJSearchEntry(
cid,
parentId,
args,
null
)
is BashTool.Args -> RunEntry.BashEntry(cid, parentId, args, null)
is BashOutputTool.Args -> RunEntry.BashEntry(cid, parentId, null, null)
is KillShellTool.Args -> RunEntry.OtherEntry(
cid,
parentId,
"Kill Bash (${args.bashId.take(6)})"
)
is WebSearchTool.Args -> RunEntry.WebEntry(cid, parentId, args, null)
is GetLibraryDocsTool.Args -> RunEntry.LibraryDocsEntry(cid, parentId, args, null)
is ResolveLibraryIdTool.Args -> RunEntry.LibraryResolveEntry(
cid,
parentId,
args,
null
)
is WriteTool.Args -> {
lastWriteArgs = args
RunEntry.WriteEntry(cid, parentId, args, null)
}
is EditTool.Args -> {
lastEditArgs = args
RunEntry.EditEntry(cid, parentId, args, null)
}
is TaskTool.Args -> RunEntry.TaskEntry(cid, parentId, args, null)
else -> RunEntry.OtherEntry(cid, parentId, toolName)
}
host.addEntry(entry)
host.refresh()
scrollablePanel.update()
scrollablePanel.scrollToBottom()
}
return cid
}
override fun onSubAgentToolCompleted(
parentId: String,
childId: String?,
toolName: String,
result: Any?
) {
when (result) {
is TaskTool.InternalResult -> {
result.tokenUsage?.let { usage -> onTokenUsageUpdated(usage) }
}
is TokenUsage -> {
onTokenUsageUpdated(result)
}
}
runInEdt {
if (childId != null) {
val holder = subagentViewHolders.values.find { viewHolder ->
viewHolder.getItems().any { entry -> entry.id == childId }
} ?: runViewHolder
holder?.completeEntry(childId, result)
holder?.refresh()
val bgId = (result as? BashTool.Result)?.bashId
if (bgId != null) {
val publisher =
ApplicationManager.getApplication()
.messageBus
.syncPublisher(AgentToolOutputNotifier.AGENT_TOOL_OUTPUT_TOPIC)
serviceScope.launch {
try {
var outPos = 0
var errPos = 0
while (true) {
val po = BackgroundProcessManager.getOutput(bgId) ?: break
val stdout = po.stdout.toString()
val stderr = po.stderr.toString()
if (outPos < stdout.length) {
stdout.substring(outPos).split('\n').forEach { line ->
if (line.isNotEmpty()) publisher.toolOutput(
childId,
line,
false
)
}
outPos = stdout.length
}
if (errPos < stderr.length) {
stderr.substring(errPos).split('\n').forEach { line ->
if (line.isNotEmpty()) publisher.toolOutput(
childId,
line,
true
)
}
errPos = stderr.length
}
if (po.isComplete) break
delay(300)
}
} catch (_: Exception) {
}
}
}
}
scrollablePanel.update()
scrollablePanel.scrollToBottom()
}
}
override fun onQueuedMessagesResolved() {
val pendingMessage =
project.service<AgentService>().getPendingMessages(sessionId).firstOrNull() ?: return
onQueuedMessagesResolved(pendingMessage)
}
override fun onTokenUsageAvailable(tokenUsage: Long) {
val delta = (tokenUsage - lastReportedPromptTokens).coerceAtLeast(0)
if (delta > 0) {
project.service<AgentService>()
.getTokenTrackerForSession(sessionId)
.addPromptTokens(delta)
}
lastReportedPromptTokens = tokenUsage
val event = TokenUsageEvent(sessionId, tokenUsage)
project.messageBus.syncPublisher(TokenUsageListener.TOKEN_USAGE_TOPIC)
.onTokenUsageChanged(event)
}
override fun onTokenUsageUpdated(tokenUsage: TokenUsage) {
val tracker = project.service<AgentService>().getTokenTrackerForSession(sessionId)
tracker.updateTokenUsage(tokenUsage)
val event = TokenUsageEvent(sessionId, tracker.getPromptTokens())
project.messageBus.syncPublisher(TokenUsageListener.TOKEN_USAGE_TOPIC)
.onTokenUsageChanged(event)
}
override fun onCreditsAvailable(event: AgentCreditsEvent) {
project.messageBus.syncPublisher(AgentCreditsListener.AGENT_CREDITS_TOPIC)
.onCreditsChanged(event)
}
private fun maybeShowNextApproval() {
if (currentApproval != null) return
val next = approvalQueue.pollFirst() ?: run {
approvalContainer.removeAll()
approvalContainer.isVisible = false
approvalContainer.revalidate()
approvalContainer.repaint()
maybeShowNextQuestion()
return
}
val contentManager = project.service<AgentToolWindowContentManager>()
if (contentManager.isSessionAutoApproved(sessionId)) {
next.deferred.complete(true)
currentApproval = null
maybeShowNextApproval()
return
}
currentApproval = next
if (next.model.type == ToolApprovalType.WRITE && next.model.payload == null) {
lastWriteArgs?.let { wa ->
next.model = next.model.copy(payload = WritePayload(wa.filePath, wa.content))
}
} else if (next.model.type == ToolApprovalType.EDIT && next.model.payload == null) {
lastEditArgs?.let { ea ->
next.model = next.model.copy(
payload = EditPayload(
ea.filePath,
ea.oldString,
ea.newString,
ea.replaceAll
)
)
}
}
runCatching {
project.service<AgentToolWindowContentManager>()
.setTabStatus(sessionId, AgentToolWindowTabbedPane.TabStatus.APPROVAL)
}
val panel = DefaultApprovalPanelFactory.create(
project,
next.model,
onApprove = { auto ->
if (auto) {
project.service<AgentToolWindowContentManager>()
.markSessionAsAutoApproved(sessionId)
}
next.deferred.complete(true)
currentApproval = null
approvalContainer.removeAll()
approvalContainer.isVisible = false
approvalContainer.revalidate()
approvalContainer.repaint()
runCatching {
project.service<AgentToolWindowContentManager>()
.setTabStatus(sessionId, AgentToolWindowTabbedPane.TabStatus.RUNNING)
}
maybeShowNextApproval()
},
onReject = {
next.deferred.complete(false)
currentApproval = null
approvalContainer.removeAll()
approvalContainer.isVisible = false
approvalContainer.revalidate()
approvalContainer.repaint()
try {
project.service<AgentService>().cancelCurrentRun(sessionId)
} catch (_: Exception) {
}
handleDone()
}
)
approvalContainer.removeAll()
panel.alignmentX = Component.LEFT_ALIGNMENT
approvalContainer.add(panel)
approvalContainer.isVisible = true
approvalContainer.revalidate()
approvalContainer.repaint()
}
private fun maybeShowNextQuestion() {
if (currentApproval != null || currentQuestion != null) return
val next = questionQueue.pollFirst() ?: return
currentQuestion = next
runCatching {
project.service<AgentToolWindowContentManager>()
.setTabStatus(sessionId, AgentToolWindowTabbedPane.TabStatus.APPROVAL)
}
val panel = AskUserQuestionPanel(
model = next.model,
onSubmit = { answers ->
next.deferred.complete(answers)
currentQuestion = null
approvalContainer.removeAll()
approvalContainer.isVisible = false
approvalContainer.revalidate()
approvalContainer.repaint()
runCatching {
project.service<AgentToolWindowContentManager>()
.setTabStatus(sessionId, AgentToolWindowTabbedPane.TabStatus.RUNNING)
}
maybeShowNextApproval()
maybeShowNextQuestion()
},
onCancel = {
next.deferred.complete(emptyMap())
currentQuestion = null
approvalContainer.removeAll()
approvalContainer.isVisible = false
approvalContainer.revalidate()
approvalContainer.repaint()
runCatching {
project.service<AgentToolWindowContentManager>()
.setTabStatus(sessionId, AgentToolWindowTabbedPane.TabStatus.RUNNING)
}
maybeShowNextApproval()
maybeShowNextQuestion()
}
)
approvalContainer.removeAll()
panel.alignmentX = Component.LEFT_ALIGNMENT
approvalContainer.add(panel)
approvalContainer.isVisible = true
approvalContainer.revalidate()
approvalContainer.repaint()
}
private fun ensureRunViewForSubagent(taskId: String): RunViewHolder {
val existing = subagentViewHolders[taskId]
if (existing != null) return existing
val vm = AgentRunViewModel()
val view = AgentRunDslPanel(vm)
currentResponseBody?.addToolStatusPanel(view.component)
val viewHolder = RunViewHolder(vm, view)
subagentViewHolders[taskId] = viewHolder
return viewHolder
}
class RunViewHolder(
private val vm: AgentRunViewModel,
private val view: AgentRunDslPanel,
) {
fun addEntry(entry: RunEntry) {
vm.addEntry(entry)
view.refresh()
}
fun completeEntry(id: String, result: Any?) {
vm.completeEntry(id, result)
view.refresh()
}
fun refresh() = view.refresh()
fun getItems(): List<RunEntry> {
return vm.items
}
fun appendStreamingLine(id: String, text: String, isError: Boolean): Boolean {
return view.appendStreaming(id, text, isError)
}
}
fun handleToolOutput(toolId: String, text: String, isError: Boolean) {
runInEdt {
var handled = false
mainToolCards[toolId]?.let {
it.appendStreamingLine(text, isError)
handled = true
}
if (!handled) {
val rawId =
if (toolId.startsWith("$sessionId:")) toolId.substringAfter(":") else toolId
subagentViewHolders.values.firstOrNull { holder ->
holder.appendStreamingLine(rawId, text, isError)
}
}
}
}
override fun dispose() {
mainToolCards.clear()
approvalQueue.clear()
subagentViewHolders.clear()
}
}

View file

@ -0,0 +1,19 @@
package ee.carlrobert.codegpt.toolwindow.agent
import com.intellij.openapi.vfs.VirtualFile
import ee.carlrobert.codegpt.conversations.Conversation
import ee.carlrobert.codegpt.settings.service.ServiceType
/**
* Represents a single Agent session with its own conversation state and metadata.
* Each tab in the Agent tool window corresponds to one AgentSession.
*/
data class AgentSession(
val sessionId: String,
val conversation: Conversation,
var displayName: String = "",
var serviceType: ServiceType? = null,
val referencedFiles: List<VirtualFile> = emptyList(),
val createdAt: Long = System.currentTimeMillis(),
var lastActiveAt: Long = System.currentTimeMillis()
)

View file

@ -0,0 +1,85 @@
package ee.carlrobert.codegpt.toolwindow.agent
import com.intellij.openapi.components.*
import com.intellij.util.xmlb.XmlSerializerUtil
@State(
name = "ProxyAI_AgentSessionState",
storages = [Storage(StoragePathMacros.WORKSPACE_FILE)]
)
@Service(Service.Level.PROJECT)
class AgentSessionState : PersistentStateComponent<AgentSessionState.State> {
data class SessionEntry(
var sessionId: String = "",
var lastAgentId: String? = null,
var checkpointId: String? = null,
var displayName: String = "",
)
data class State(
var sessions: MutableList<SessionEntry> = mutableListOf(),
var lastActiveSessionId: String? = null
)
private var state = State()
override fun getState(): State = state
override fun loadState(state: State) {
XmlSerializerUtil.copyBean(state, this.state)
}
fun getSessionIds(): List<String> {
return state.sessions.mapNotNull { it.sessionId.takeIf { id -> id.isNotBlank() } }
}
fun getLastActiveSessionId(): String? = state.lastActiveSessionId
fun setLastActiveSessionId(sessionId: String) {
state.lastActiveSessionId = sessionId
}
fun getLastAgentId(sessionId: String): String? {
return state.sessions.firstOrNull { it.sessionId == sessionId }?.lastAgentId
}
fun getDisplayName(sessionId: String): String? {
return state.sessions.firstOrNull { it.sessionId == sessionId }?.displayName
}
fun ensureSession(sessionId: String): SessionEntry {
val existing = state.sessions.firstOrNull { it.sessionId == sessionId }
if (existing != null) return existing
val entry = SessionEntry(sessionId = sessionId)
state.sessions.add(entry)
return entry
}
fun updateSession(sessionId: String, lastAgentId: String? = null, checkpointId: String? = null, displayName: String? = null) {
val entry = ensureSession(sessionId)
if (lastAgentId != null) {
entry.lastAgentId = lastAgentId
}
if (displayName != null) {
entry.displayName = displayName
}
if (checkpointId != null) {
entry.checkpointId = checkpointId
}
}
fun removeSession(sessionId: String) {
state.sessions.removeIf { it.sessionId == sessionId }
if (state.lastActiveSessionId == sessionId) {
state.lastActiveSessionId = null
}
}
fun replaceSession(oldSessionId: String, newSessionId: String) {
removeSession(oldSessionId)
ensureSession(newSessionId)
state.lastActiveSessionId = newSessionId
}
}

View file

@ -0,0 +1,13 @@
package ee.carlrobert.codegpt.toolwindow.agent
import com.intellij.util.messages.Topic
interface AgentTabTitleNotifier {
fun updateTabTitle(sessionId: String, title: String)
companion object {
@JvmStatic
val AGENT_TAB_TITLE_TOPIC = Topic.create("agentTabTitle", AgentTabTitleNotifier::class.java)
}
}

View file

@ -0,0 +1,113 @@
package ee.carlrobert.codegpt.toolwindow.agent
import com.intellij.openapi.Disposable
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
@Service(Service.Level.PROJECT)
class AgentToolWindowContentManager(private val project: Project) : Disposable {
private val activeSessions = mutableMapOf<String, AgentSession>()
private val tabPanels = mutableMapOf<String, AgentToolWindowTabPanel>()
private val autoApprovedSessions = mutableSetOf<String>()
private val sessionState = project.service<AgentSessionState>()
private val tabbedPane = AgentToolWindowTabbedPane(project)
fun initializeTabbedPane(): AgentToolWindowTabbedPane {
project.messageBus.connect()
.subscribe(AgentTabTitleNotifier.AGENT_TAB_TITLE_TOPIC, object : AgentTabTitleNotifier {
override fun updateTabTitle(sessionId: String, title: String) {
renameTab(sessionId, title)
}
})
val storedSessions = sessionState.getSessionIds()
if (storedSessions.isEmpty()) {
createNewAgentTab()
} else {
storedSessions.forEach { sessionId ->
createNewAgentTab(sessionId = sessionId, select = false)
}
sessionState.getLastActiveSessionId()?.let { lastActive ->
tabbedPane.tryFindTabTitle(lastActive).ifPresent { title ->
val tabIndex = tabbedPane.indexOfTab(title)
if (tabIndex >= 0) {
tabbedPane.selectedIndex = tabIndex
}
}
}
}
return tabbedPane
}
fun createNewAgentTab(
sessionId: String? = null,
select: Boolean = true
): AgentToolWindowTabPanel {
val tabPanel = if (sessionId != null) {
AgentToolWindowTabPanel(project, sessionId)
} else {
AgentToolWindowTabPanel(project)
}
val resolvedSessionId = tabPanel.getSessionId()
val session = AgentSession(resolvedSessionId, tabPanel.getConversation())
val sessionEntry = sessionState.ensureSession(resolvedSessionId)
if (sessionEntry.displayName.isNotBlank()) {
tabPanel.getAgentSession().displayName = sessionEntry.displayName
session.displayName = sessionEntry.displayName
}
activeSessions[session.sessionId] = session
tabPanels[session.sessionId] = tabPanel
tabbedPane.addNewTab(tabPanel, select)
return tabPanel
}
fun getActiveTabPanel(): AgentToolWindowTabPanel? {
return tabbedPane.tryFindActiveTabPanel().orElse(null)
}
fun getTabbedPane(): AgentToolWindowTabbedPane = tabbedPane
fun resetCurrentlyActiveTab() {
tabbedPane.resetCurrentlyActiveTabPanel()
}
fun renameTab(sessionId: String, newName: String) {
tabbedPane.tryFindTabTitle(sessionId).ifPresent { title ->
val tabIndex = tabbedPane.indexOfTab(title)
if (tabIndex >= 0) {
tabbedPane.renameTab(tabIndex, newName)
}
}
}
fun setTabStatus(sessionId: String, status: AgentToolWindowTabbedPane.TabStatus) {
tabbedPane.updateStatusForSession(sessionId, status)
}
fun markSessionAsAutoApproved(sessionId: String) {
autoApprovedSessions.add(sessionId)
}
fun isSessionAutoApproved(sessionId: String): Boolean {
return autoApprovedSessions.contains(sessionId)
}
companion object {
fun getInstance(project: Project): AgentToolWindowContentManager {
return project.service()
}
}
override fun dispose() {
tabPanels.values.forEach { tabPanel ->
Disposer.dispose(tabPanel)
}
tabPanels.clear()
activeSessions.clear()
}
}

View file

@ -0,0 +1,72 @@
package ee.carlrobert.codegpt.toolwindow.agent
import com.intellij.icons.AllIcons
import com.intellij.openapi.Disposable
import com.intellij.openapi.actionSystem.*
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.SimpleToolWindowPanel
import com.intellij.openapi.util.Disposer
import com.intellij.util.ui.components.BorderLayoutPanel
import ee.carlrobert.codegpt.toolwindow.agent.ui.AgentCreditsToolbarLabel
import javax.swing.JComponent
class AgentToolWindowPanel(
private val project: Project
) : SimpleToolWindowPanel(true), Disposable {
private val contentManager = project.service<AgentToolWindowContentManager>()
private val tabbedPane = contentManager.initializeTabbedPane()
init {
toolbar = createToolbar()
setContent(tabbedPane)
}
private fun createToolbar(): JComponent {
val actionGroup = DefaultActionGroup().apply {
add(object : AnAction(
"New Agent",
"Create a new Agent session",
AllIcons.General.Add
) {
override fun actionPerformed(e: AnActionEvent) {
contentManager.createNewAgentTab()
}
override fun getActionUpdateThread(): ActionUpdateThread {
return ActionUpdateThread.EDT
}
})
add(object : AnAction(
"Reset Current Agent",
"Clear all messages and reset the current agent view",
AllIcons.General.Reset
) {
override fun actionPerformed(e: AnActionEvent) {
contentManager.resetCurrentlyActiveTab()
}
override fun getActionUpdateThread(): ActionUpdateThread {
return ActionUpdateThread.EDT
}
})
}
val toolbar = ActionManager.getInstance()
.createActionToolbar("AgentToolWindow", actionGroup, true)
toolbar.targetComponent = tabbedPane
val creditsLabel = AgentCreditsToolbarLabel(project)
Disposer.register(this, creditsLabel)
return BorderLayoutPanel()
.addToLeft(toolbar.component)
.addToRight(creditsLabel)
}
fun getTabbedPane(): AgentToolWindowTabbedPane = tabbedPane
override fun dispose() {
tabbedPane.dispose()
}
}

View file

@ -0,0 +1,403 @@
package ee.carlrobert.codegpt.toolwindow.agent
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.runInEdt
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.ui.AnimatedIcon
import com.intellij.ui.JBColor
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.components.BorderLayoutPanel
import ee.carlrobert.codegpt.CodeGPTBundle
import ee.carlrobert.codegpt.agent.AgentService
import ee.carlrobert.codegpt.agent.AgentToolOutputNotifier
import ee.carlrobert.codegpt.agent.MessageWithContext
import ee.carlrobert.codegpt.agent.ToolRunContext
import ee.carlrobert.codegpt.conversations.Conversation
import ee.carlrobert.codegpt.conversations.message.Message
import ee.carlrobert.codegpt.conversations.message.QueuedMessage
import ee.carlrobert.codegpt.psistructure.PsiStructureProvider
import ee.carlrobert.codegpt.settings.service.FeatureType
import ee.carlrobert.codegpt.settings.service.ModelSelectionService
import ee.carlrobert.codegpt.toolwindow.agent.ui.AgentToolWindowLandingPanel
import ee.carlrobert.codegpt.toolwindow.agent.ui.TodoListPanel
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
import ee.carlrobert.codegpt.toolwindow.chat.ui.ChatMessageResponseBody
import ee.carlrobert.codegpt.toolwindow.chat.ui.ChatToolWindowScrollablePanel
import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.TotalTokensPanel
import ee.carlrobert.codegpt.toolwindow.ui.ResponseMessagePanel
import ee.carlrobert.codegpt.toolwindow.ui.UserMessagePanel
import ee.carlrobert.codegpt.ui.UIUtil.createScrollPaneWithSmartScroller
import ee.carlrobert.codegpt.ui.components.TokenUsageCounterPanel
import ee.carlrobert.codegpt.ui.queue.QueuedMessagePanel
import ee.carlrobert.codegpt.ui.textarea.UserInputPanel
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager
import ee.carlrobert.codegpt.util.EditorUtil
import ee.carlrobert.codegpt.util.coroutines.CoroutineDispatchers
import kotlinx.coroutines.launch
import java.util.*
import javax.swing.Box
import javax.swing.BoxLayout
import javax.swing.JComponent
import javax.swing.JPanel
class AgentToolWindowTabPanel(
private val project: Project,
private val sessionId: String = UUID.randomUUID().toString()
) : BorderLayoutPanel(), Disposable {
private val scrollablePanel = ChatToolWindowScrollablePanel()
private val tagManager = TagManager()
private val dispatchers = CoroutineDispatchers()
private val conversation = Conversation()
private val agentSession = AgentSession(sessionId, conversation)
private val psiRepository = PsiStructureRepository(
this,
project,
tagManager,
PsiStructureProvider(),
dispatchers
)
private val approvalContainer = JPanel().apply {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
isOpaque = false
border = JBUI.Borders.empty(4, 0, 4, 0)
isVisible = false
}
private val queuedMessageContainer = JPanel().apply {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
isOpaque = false
border = JBUI.Borders.empty(4, 0, 4, 0)
isVisible = false
}
private val loadingLabel =
JBLabel(
CodeGPTBundle.get("toolwindow.chat.loading"),
AnimatedIcon.Default(),
JBLabel.LEFT
).apply {
isVisible = false
}
private val userInputPanel = UserInputPanel(
project,
TotalTokensPanel(
conversation,
EditorUtil.getSelectedEditorSelectedText(project),
this,
psiRepository
),
this,
FeatureType.AGENT,
tagManager,
onSubmit = { text -> handleSubmit(text) },
onStop = { handleCancel() },
withRemovableSelectedEditorTag = true,
agentTokenCounterPanel = TokenUsageCounterPanel(project, sessionId),
sessionIdProvider = { sessionId }
)
private val todoListPanel = TodoListPanel()
private val projectMessageBusConnection = project.messageBus.connect()
private val appMessageBusConnection = ApplicationManager.getApplication().messageBus.connect()
private val agentEventHandler = AgentEventHandler(
project = project,
sessionId = sessionId,
agentApprovalManager = AgentApprovalManager(project),
approvalContainer = approvalContainer,
scrollablePanel = scrollablePanel,
todoListPanel = todoListPanel,
userInputPanel = userInputPanel,
onShowLoading = { text ->
loadingLabel.text = text
loadingLabel.isVisible = true
revalidate()
repaint()
},
onHideLoading = {
loadingLabel.isVisible = false
revalidate()
repaint()
},
onQueuedMessagesResolved = { message ->
runInEdt {
clearQueuedMessagesAndCreateNewResponse(
message.text
)
}
}
)
init {
setupMessageBusSubscriptions()
setupUI()
if (conversation.messages.isEmpty()) {
displayLandingView()
}
userInputPanel.setStopEnabled(false)
Disposer.register(this, agentEventHandler)
}
private fun setupMessageBusSubscriptions() {
project.service<AgentService>().queuedMessageProcessed.let { flow ->
kotlinx.coroutines.CoroutineScope(dispatchers.io()).launch {
flow.collect { processedMessage ->
ApplicationManager.getApplication().invokeLater {
removeQueuedMessage(processedMessage)
}
}
}
}
appMessageBusConnection.subscribe(
AgentToolOutputNotifier.AGENT_TOOL_OUTPUT_TOPIC,
object : AgentToolOutputNotifier {
override fun toolOutput(toolId: String, text: String, isError: Boolean) {
val namespacedToolId = "${sessionId}:${toolId}"
agentEventHandler.handleToolOutput(namespacedToolId, text, isError)
}
}
)
}
private fun setupUI() {
addToCenter(createScrollPaneWithSmartScroller(scrollablePanel))
addToBottom(createUserPromptPanel())
}
private fun createUserPromptPanel(): JComponent {
val topContainer = JPanel().apply {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
isOpaque = false
}
todoListPanel.alignmentX = LEFT_ALIGNMENT
topContainer.add(todoListPanel)
queuedMessageContainer.alignmentX = LEFT_ALIGNMENT
topContainer.add(queuedMessageContainer)
approvalContainer.alignmentX = LEFT_ALIGNMENT
topContainer.add(approvalContainer)
val loadingContainer =
BorderLayoutPanel().withBorder(JBUI.Borders.empty(8)).addToCenter(loadingLabel)
return BorderLayoutPanel()
.addToTop(loadingContainer)
.addToCenter(
BorderLayoutPanel().withBorder(
JBUI.Borders.compound(
JBUI.Borders.customLine(JBColor.border(), 1, 0, 0, 0),
JBUI.Borders.empty(8)
)
)
.addToTop(topContainer)
.addToCenter(userInputPanel)
)
}
private fun handleSubmit(text: String) {
if (text.isBlank()) return
agentSession.serviceType = ModelSelectionService.getInstance().getServiceForFeature(FeatureType.AGENT)
val agentService = project.service<AgentService>()
if (agentService.isSessionRunning(sessionId)) {
addQueuedMessage(text)
userInputPanel.clearText()
userInputPanel.setSubmitEnabled(true)
userInputPanel.setStopEnabled(true)
agentService.submitMessage(
MessageWithContext(text, userInputPanel.getSelectedTags()),
agentEventHandler,
sessionId
)
return
}
runCatching {
project.service<AgentToolWindowContentManager>()
.setTabStatus(sessionId, AgentToolWindowTabbedPane.TabStatus.RUNNING)
}
val message = MessageWithContext(text, userInputPanel.getSelectedTags())
val messagePanel = scrollablePanel.addMessage(message.id)
val userPanel = UserMessagePanel(
project,
MessageBuilder(project, text).withTags(userInputPanel.getSelectedTags()).build(),
this
)
val responsePanel = ResponseMessagePanel()
val responseBody = ChatMessageResponseBody(
project,
false,
false,
false,
false,
true,
this
)
responsePanel.setResponseContent(responseBody)
userPanel.addCopyAction { CopyAction.copyToClipboard(text) }
messagePanel.add(userPanel)
messagePanel.add(responsePanel)
scrollablePanel.update()
agentEventHandler.resetForNewSubmission()
agentEventHandler.setCurrentResponseBody(responseBody)
loadingLabel.text = CodeGPTBundle.get("toolwindow.chat.loading")
loadingLabel.isVisible = true
clearQueuedMessages()
agentService.clearPendingMessages(sessionId)
userInputPanel.setStopEnabled(true)
agentService.submitMessage(message, agentEventHandler, sessionId)
}
private fun handleCancel() {
val agentService = project.service<AgentService>()
agentService.cancelCurrentRun(sessionId)
agentService.clearPendingMessages(sessionId)
approvalContainer.removeAll()
clearQueuedMessages()
approvalContainer.isVisible = false
loadingLabel.isVisible = false
userInputPanel.setStopEnabled(false)
runCatching {
project.service<AgentToolWindowContentManager>()
.setTabStatus(sessionId, AgentToolWindowTabbedPane.TabStatus.STOPPED)
}
}
private fun displayLandingView() {
scrollablePanel.displayLandingView(createLandingView())
}
private fun createLandingView(): AgentToolWindowLandingPanel {
return AgentToolWindowLandingPanel(project)
}
fun getSessionId(): String = sessionId
fun getAgentSession(): AgentSession = agentSession
fun getConversation(): Conversation = conversation
fun requestFocusForTextArea() {
userInputPanel.requestFocus()
}
fun addQueuedMessage(message: String) {
val queuedMessage = QueuedMessage(message)
val existingPanel = getQueuedMessagePanel()
if (existingPanel != null) {
val messages = existingPanel.getQueuedMessages().toMutableList()
messages.add(queuedMessage)
val updatedPanel = QueuedMessagePanel(messages)
val index = queuedMessageContainer.components.indexOf(existingPanel)
if (index >= 0) {
queuedMessageContainer.remove(existingPanel)
queuedMessageContainer.add(updatedPanel, index)
}
} else {
val queuedPanel = QueuedMessagePanel(listOf(queuedMessage))
if (queuedMessageContainer.componentCount > 0) {
queuedMessageContainer.add(Box.createVerticalStrut(4))
}
queuedPanel.alignmentX = LEFT_ALIGNMENT
queuedMessageContainer.add(queuedPanel)
}
queuedMessageContainer.isVisible = true
queuedMessageContainer.revalidate()
queuedMessageContainer.repaint()
}
private fun getQueuedMessagePanel(): QueuedMessagePanel? {
return queuedMessageContainer.components
.filterIsInstance<QueuedMessagePanel>()
.firstOrNull()
}
fun clearQueuedMessages() {
queuedMessageContainer.removeAll()
queuedMessageContainer.isVisible = false
queuedMessageContainer.revalidate()
queuedMessageContainer.repaint()
}
private fun clearQueuedMessagesAndCreateNewResponse(messageText: String) {
clearQueuedMessages()
val message = Message(messageText)
val messagePanel = scrollablePanel.addMessage(message.id)
val userPanel = UserMessagePanel(project, message, this)
userPanel.addCopyAction { CopyAction.copyToClipboard(message.prompt) }
messagePanel.add(userPanel)
val responseBody = ChatMessageResponseBody(project, false, false, false, false, true, this)
val responsePanel = ResponseMessagePanel()
responsePanel.setResponseContent(responseBody)
messagePanel.add(responsePanel)
agentEventHandler.resetForNewSubmission()
agentEventHandler.setCurrentResponseBody(responseBody)
scrollablePanel.update()
}
fun removeQueuedMessage(messageText: String) {
val panel = getQueuedMessagePanel() ?: return
val messages = panel.getQueuedMessages().toMutableList()
val messageToRemove = messages.find { it.prompt == messageText }
if (messageToRemove != null) {
messages.remove(messageToRemove)
if (messages.isEmpty()) {
queuedMessageContainer.remove(panel)
} else {
val updatedPanel = QueuedMessagePanel(messages)
val index = queuedMessageContainer.components.indexOf(panel)
if (index >= 0) {
queuedMessageContainer.remove(panel)
queuedMessageContainer.add(updatedPanel, index)
}
}
queuedMessageContainer.revalidate()
queuedMessageContainer.repaint()
if (queuedMessageContainer.componentCount == 0) {
queuedMessageContainer.isVisible = false
}
}
}
override fun dispose() {
ToolRunContext.cleanupSession(sessionId)
projectMessageBusConnection.disconnect()
appMessageBusConnection.disconnect()
}
}

View file

@ -0,0 +1,444 @@
package ee.carlrobert.codegpt.toolwindow.agent
import com.intellij.icons.AllIcons
import com.intellij.openapi.Disposable
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.InputValidator
import com.intellij.openapi.ui.JBMenuItem
import com.intellij.openapi.ui.Messages
import com.intellij.openapi.util.Disposer
import com.intellij.ui.JBColor
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBTabbedPane
import com.intellij.util.ui.JBUI
import ee.carlrobert.codegpt.agent.AgentService
import java.awt.*
import java.awt.event.ActionEvent
import java.awt.event.ActionListener
import java.util.*
import javax.swing.*
class AgentToolWindowTabbedPane(private val project: Project) : JBTabbedPane(), Disposable {
private val activeTabMapping = TreeMap<String, AgentToolWindowTabPanel>(
Comparator { o1, o2 ->
val nums1 = o1.replace("\\D".toRegex(), "")
val nums2 = o2.replace("\\D".toRegex(), "")
if (nums1.isNotEmpty() && nums2.isNotEmpty()) {
val n1 = nums1.toInt()
val n2 = nums2.toInt()
n1.compareTo(n2)
} else {
when {
nums1.isNotEmpty() -> -1
nums2.isNotEmpty() -> 1
else -> o1.compareTo(o2, ignoreCase = true)
}
}
}
)
private var isTabActive = true
init {
tabComponentInsets = null
setComponentPopupMenu(TabPopupMenu())
addChangeListener { refreshTabState() }
}
enum class TabStatus {
RUNNING,
STOPPED,
APPROVAL,
COMPLETED_UNSEEN
}
private data class TabState(
var status: TabStatus = TabStatus.STOPPED,
var unseen: Boolean = false
)
private val sessionStates = mutableMapOf<String, TabState>()
fun updateStatusForSession(sessionId: String, status: TabStatus) {
val state = sessionStates.getOrPut(sessionId) { TabState() }
state.status = status
applyIconForSession(sessionId)
}
fun onAgentCompleted(sessionId: String) {
val state = sessionStates.getOrPut(sessionId) { TabState() }
state.status = TabStatus.STOPPED
val selectedSessionId = tryFindActiveTabPanel()
.map { it.getSessionId() }
.orElse(null)
state.unseen = selectedSessionId != sessionId
applyIconForSession(sessionId)
}
fun onTabHidden() {
isTabActive = false
}
private fun statusIcon(status: TabStatus): Icon? = when (status) {
TabStatus.RUNNING -> CircleIcon(JBColor(0x43A047, 0x66BB6A), 8)
TabStatus.STOPPED -> null
TabStatus.APPROVAL -> CircleIcon(JBColor(0xFB8C00, 0xFFA726), 8)
TabStatus.COMPLETED_UNSEEN -> CircleIcon(JBColor(0x2196F3, 0x42A5F5), 8)
}
private fun computeIcon(state: TabState): Icon? {
return if (state.unseen) {
CircleIcon(JBColor(0x2196F3, 0x42A5F5), 8)
} else {
statusIcon(state.status)
}
}
private fun applyIconForSession(sessionId: String) {
tryFindTabTitle(sessionId).ifPresent { title ->
val index = indexOfTab(title)
if (index >= 0) {
val state = sessionStates[sessionId] ?: return@ifPresent
val icon = computeIcon(state)
val custom = getTabComponentAt(index)
if (custom is JPanel) {
val comps = custom.components
if (comps.isNotEmpty() && comps[0] is JBLabel) {
val label = comps[0] as JBLabel
label.icon = icon
label.iconTextGap = 6
label.revalidate()
label.repaint()
}
} else {
setIconAt(index, icon)
}
}
}
}
fun addNewTab(toolWindowPanel: AgentToolWindowTabPanel) {
addNewTab(toolWindowPanel, true)
}
fun addNewTab(toolWindowPanel: AgentToolWindowTabPanel, select: Boolean) {
val tabIndices = activeTabMapping.keys.toTypedArray()
var nextIndex = 0
for (title in tabIndices) {
if (title.matches("Agent \\d+".toRegex())) {
val numberPart = title.replace("\\D+".toRegex(), "")
val tabNum = numberPart.toInt()
if (tabNum - 1 == nextIndex) {
nextIndex++
} else {
break
}
}
}
val title = getTitle(toolWindowPanel, nextIndex)
val sessionId = toolWindowPanel.getSessionId()
toolWindowPanel.getAgentSession().displayName = title
project.service<AgentSessionState>().updateSession(sessionId, displayName = title)
super.insertTab(title, null, toolWindowPanel, null, nextIndex)
activeTabMapping[title] = toolWindowPanel
if (select) {
selectedIndex = nextIndex
}
sessionStates[sessionId] = TabState(status = TabStatus.STOPPED, unseen = false)
setTabComponentAt(nextIndex, createTabButtonPanel(title, nextIndex > 0, TabStatus.STOPPED))
toolWindowPanel.requestFocusForTextArea()
Disposer.register(this, toolWindowPanel)
}
private fun getTitle(toolWindowTabPanel: AgentToolWindowTabPanel, nextIndex: Int): String {
val session = toolWindowTabPanel.getAgentSession()
val customName = session.displayName
return if (customName.isNotBlank()) {
ensureUniqueName(customName, null)
} else {
"Agent ${nextIndex + 1}"
}
}
fun tryFindTabTitle(sessionId: String): Optional<String> {
return activeTabMapping.entries.stream()
.filter { entry -> entry.value.getSessionId() == sessionId }
.findFirst()
.map { it.key }
}
fun tryFindActiveTabPanel(): Optional<AgentToolWindowTabPanel> {
val selectedIndex = selectedIndex
if (selectedIndex == -1) {
return Optional.empty()
}
return Optional.ofNullable(activeTabMapping[getTitleAt(selectedIndex)])
}
fun clearAll() {
removeAll()
activeTabMapping.clear()
}
fun renameTab(tabIndex: Int, newName: String) {
if (tabIndex !in 0..<tabCount) {
return
}
val oldTitle = getTitleAt(tabIndex)
val panel = activeTabMapping[oldTitle] ?: return
val uniqueName = ensureUniqueName(newName, oldTitle)
setTitleAt(tabIndex, uniqueName)
val sessionId = panel.getSessionId()
val currentStatus = if (project.service<AgentService>().isSessionRunning(sessionId)) {
TabStatus.RUNNING
} else {
TabStatus.STOPPED
}
setTabComponentAt(tabIndex, createTabButtonPanel(uniqueName, tabIndex > 0, currentStatus))
activeTabMapping.remove(oldTitle)
activeTabMapping[uniqueName] = panel
panel.getAgentSession().displayName = uniqueName
project.service<AgentSessionState>().updateSession(sessionId, displayName = uniqueName)
applyIconForSession(sessionId)
}
private fun ensureUniqueName(desiredName: String, currentTitle: String?): String {
val baseName = desiredName.trim()
var uniqueName = baseName
var counter = 2
while (activeTabMapping.containsKey(uniqueName) && uniqueName != currentTitle) {
uniqueName = "$baseName ($counter)"
counter++
}
return uniqueName
}
private fun refreshTabState() {
val selectedIndex = selectedIndex
if (selectedIndex == -1) {
return
}
isTabActive = true
tryFindActiveTabPanel().ifPresent { selectedPanel ->
val selectedSessionId = selectedPanel.getSessionId()
val selectedState = sessionStates[selectedSessionId]
if (selectedState != null && selectedState.unseen) {
selectedState.unseen = false
applyIconForSession(selectedSessionId)
}
project.service<AgentSessionState>().setLastActiveSessionId(selectedSessionId)
}
for (i in 0 until tabCount) {
val title = getTitleAt(i)
val panel = activeTabMapping[title]
panel?.let {
val sessionId = it.getSessionId()
val state = sessionStates[sessionId] ?: TabState()
if (project.service<AgentService>().isSessionRunning(sessionId)) {
state.status = TabStatus.RUNNING
state.unseen = false
}
applyIconForSession(sessionId)
}
}
}
private fun renameAgentSession(tabIndex: Int) {
if (tabIndex <= 0) {
return
}
val currentTitle = getTitleAt(tabIndex)
val newName = Messages.showInputDialog(
"Enter new title:",
"Rename Agent Session",
Messages.getQuestionIcon(),
currentTitle,
AgentSessionNameValidator()
)
if (newName != null && newName != currentTitle) {
renameTab(tabIndex, newName)
}
}
private class AgentSessionNameValidator : InputValidator {
private val maxNameLength = 50
override fun checkInput(inputString: String?): Boolean {
if (inputString == null || inputString.trim().isEmpty()) {
return false
}
if (inputString.length > maxNameLength) {
return false
}
return !inputString.contains("\n") && !inputString.contains("\t")
}
override fun canClose(inputString: String?): Boolean {
return checkInput(inputString)
}
}
fun resetCurrentlyActiveTabPanel() {
tryFindActiveTabPanel().ifPresent { tabPanel ->
val oldSessionId = tabPanel.getSessionId()
Disposer.dispose(tabPanel)
activeTabMapping.remove(getTitleAt(selectedIndex))
removeTabAt(selectedIndex)
sessionStates.remove(oldSessionId)
val newTabPanel = AgentToolWindowTabPanel(project)
project.service<AgentSessionState>()
.replaceSession(oldSessionId, newTabPanel.getSessionId())
addNewTab(newTabPanel)
repaint()
revalidate()
}
}
private fun createTabButtonPanel(
title: String,
closeable: Boolean,
status: TabStatus = TabStatus.STOPPED
): JPanel {
val titleLabel = JBLabel(title).apply {
icon = statusIcon(status)
iconTextGap = 4
}
val panel = JBUI.Panels.simplePanel(4, 0)
.addToLeft(titleLabel)
if (closeable) {
val closeIcon = AllIcons.Actions.Close
val button = JButton(closeIcon).apply {
addActionListener(CloseActionListener(title))
preferredSize = Dimension(closeIcon.iconWidth, closeIcon.iconHeight)
border = BorderFactory.createEmptyBorder()
isContentAreaFilled = false
toolTipText = "Close Agent"
rolloverIcon = AllIcons.Actions.CloseHovered
}
panel.addToRight(button)
}
return panel.andTransparent()
}
private class CircleIcon(private val color: Color, private val diameter: Int = 8) :
Icon {
override fun getIconWidth(): Int = diameter
override fun getIconHeight(): Int = diameter
override fun paintIcon(c: Component?, g: Graphics?, x: Int, y: Int) {
val g2 = g as? Graphics2D ?: return
val oldAA = g2.getRenderingHint(RenderingHints.KEY_ANTIALIASING)
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g2.color = color
g2.fillOval(x, y, diameter, diameter)
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, oldAA)
}
}
private inner class CloseActionListener(private val title: String) : ActionListener {
override fun actionPerformed(evt: ActionEvent) {
val tabIndex = indexOfTab(title)
if (tabIndex >= 0) {
activeTabMapping[title]?.let { panel ->
sessionStates.remove(panel.getSessionId())
project.service<AgentSessionState>().removeSession(panel.getSessionId())
Disposer.dispose(panel)
}
removeTabAt(tabIndex)
activeTabMapping.remove(title)
}
}
}
private inner class TabPopupMenu : JPopupMenu() {
private var selectedPopupTabIndex = -1
init {
add(createPopupMenuItem("Rename Title") {
if (selectedPopupTabIndex > 0) {
renameAgentSession(selectedPopupTabIndex)
}
})
addSeparator()
add(createPopupMenuItem("Close") {
if (selectedPopupTabIndex > 0) {
val title = getTitleAt(selectedPopupTabIndex)
activeTabMapping[title]?.let { panel ->
sessionStates.remove(panel.getSessionId())
project.service<AgentSessionState>().removeSession(panel.getSessionId())
Disposer.dispose(panel)
}
removeTabAt(selectedPopupTabIndex)
activeTabMapping.remove(title)
}
})
add(createPopupMenuItem("Close Other Tabs") {
val selectedPopupTabTitle = getTitleAt(selectedPopupTabIndex)
val tabPanel = activeTabMapping[selectedPopupTabTitle]
val keepSessionId = tabPanel?.getSessionId()
sessionStates.keys
.filter { it != keepSessionId }
.forEach { sessionStates.remove(it) }
activeTabMapping.values
.map { it.getSessionId() }
.filter { it != keepSessionId }
.forEach { project.service<AgentSessionState>().removeSession(it) }
clearAll()
tabPanel?.let { addNewTab(it) }
})
}
override fun show(invoker: Component, x: Int, y: Int) {
selectedPopupTabIndex = this@AgentToolWindowTabbedPane.getUI()
.tabForCoordinate(this@AgentToolWindowTabbedPane, x, y)
if (selectedPopupTabIndex > 0) {
super.show(invoker, x, y)
}
}
private fun createPopupMenuItem(label: String, listener: ActionListener): JBMenuItem {
return JBMenuItem(label).apply {
addActionListener(listener)
}
}
}
override fun dispose() {
clearAll()
}
}

View file

@ -0,0 +1,16 @@
package ee.carlrobert.codegpt.toolwindow.agent
import com.intellij.util.messages.Topic
data class TokenUsageEvent(
val sessionId: String,
val totalTokens: Long,
)
interface TokenUsageListener {
fun onTokenUsageChanged(event: TokenUsageEvent)
companion object {
val TOKEN_USAGE_TOPIC = Topic("Token Usage Changes", TokenUsageListener::class.java)
}
}

View file

@ -0,0 +1,114 @@
package ee.carlrobert.codegpt.toolwindow.agent.ui
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.project.Project
import com.intellij.ui.components.JBLabel
import com.intellij.util.messages.MessageBusConnection
import com.intellij.util.ui.JBFont
import com.intellij.util.ui.JBUI
import ee.carlrobert.codegpt.CodeGPTKeys
import ee.carlrobert.codegpt.settings.service.*
import ee.carlrobert.codegpt.settings.service.codegpt.CodeGPTService
import ee.carlrobert.codegpt.settings.service.codegpt.CodeGPTUserDetailsNotifier
import ee.carlrobert.codegpt.toolwindow.agent.AgentCreditsEvent
import ee.carlrobert.codegpt.toolwindow.agent.AgentCreditsListener
import ee.carlrobert.llm.client.codegpt.CodeGPTUserDetails
import java.text.NumberFormat
import java.util.*
class AgentCreditsToolbarLabel(
private val project: Project
) : JBLabel(), Disposable {
private val numberFormat = NumberFormat.getNumberInstance(Locale.US).apply {
isGroupingUsed = true
}
private val messageBusConnection: MessageBusConnection = project.messageBus.connect(this)
private var currentCredits: AgentCreditsEvent? = null
private var currentUserDetails: CodeGPTUserDetails? = null
private var userDetailsRequested: Boolean = false
init {
isOpaque = false
font = JBFont.small()
border = JBUI.Borders.empty(0, 6, 0, 6)
currentUserDetails = project.getUserData(CodeGPTKeys.CODEGPT_USER_DETAILS)
subscribeToUpdates()
updateDisplay()
}
private fun subscribeToUpdates() {
messageBusConnection.subscribe(
CodeGPTUserDetailsNotifier.CODEGPT_USER_DETAILS_TOPIC,
object : CodeGPTUserDetailsNotifier {
override fun userDetailsObtained(userDetails: CodeGPTUserDetails?) {
currentUserDetails = userDetails
updateDisplay()
}
}
)
messageBusConnection.subscribe(
AgentCreditsListener.AGENT_CREDITS_TOPIC,
object : AgentCreditsListener {
override fun onCreditsChanged(event: AgentCreditsEvent) {
currentCredits = event
updateDisplay()
}
}
)
messageBusConnection.subscribe(
ModelChangeNotifier.getTopic(),
object : ModelChangeNotifierAdapter() {
override fun agentModelChanged(newModel: String, serviceType: ServiceType) {
updateDisplay()
}
}
)
}
private fun updateDisplay() {
ApplicationManager.getApplication().invokeLater {
val provider = ModelSelectionService.getInstance()
.getServiceForFeature(FeatureType.AGENT)
if (provider != ServiceType.PROXYAI) {
isVisible = false
return@invokeLater
}
isVisible = true
if (!userDetailsRequested && currentUserDetails == null) {
userDetailsRequested = true
project.getService(CodeGPTService::class.java).syncUserDetailsAsync()
}
val credits = currentCredits
val userDetails = currentUserDetails
val userTotal = userDetails?.creditsTotal
val userRemaining = if (userTotal != null && userDetails?.creditsUsed != null) {
(userTotal - userDetails.creditsUsed).coerceAtLeast(0)
} else null
val remaining = credits?.remaining ?: userRemaining
text = if (remaining != null) {
"Credits: ${numberFormat.format(remaining)} left"
} else {
"Credits: --"
}
toolTipText = buildString {
append("<html><body>")
append("<b>Credits</b><br>")
if (remaining != null) {
append("Remaining: ${numberFormat.format(remaining)}<br>")
}
if (userTotal != null) {
append("Total: ${numberFormat.format(userTotal)}<br>")
}
append("</body></html>")
}
}
}
override fun dispose() {
}
}

View file

@ -0,0 +1,124 @@
package ee.carlrobert.codegpt.toolwindow.agent.ui
import com.intellij.ui.components.ActionLink
import com.intellij.ui.components.JBScrollPane
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.components.BorderLayoutPanel
import ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor.ToolCallView
import ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor.ToolCallViewAdapter
import java.awt.BorderLayout
import javax.swing.BoxLayout
import javax.swing.JComponent
import javax.swing.JPanel
class AgentRunDslPanel(private val vm: AgentRunViewModel) {
private val rowsPanel = JPanel().apply {
isOpaque = false
layout = BoxLayout(this, BoxLayout.Y_AXIS)
}
private val scrollPane = JBScrollPane(rowsPanel).apply {
isOpaque = false
border = JBUI.Borders.empty()
viewport.isOpaque = false
horizontalScrollBarPolicy = JBScrollPane.HORIZONTAL_SCROLLBAR_NEVER
}
private val expandLink = ActionLink("") {
vm.expandAll = !vm.expandAll
refresh()
}.apply { isVisible = false }
private val root = BorderLayoutPanel().apply {
isOpaque = false
border = JBUI.Borders.empty()
addToCenter(scrollPane)
addToBottom(expandLink)
}
val component: JComponent get() = root
private val viewByEntryId = mutableMapOf<String, ToolCallView>()
fun refresh() {
viewByEntryId.clear()
val items = vm.items
val childrenByParent = LinkedHashMap<String, MutableList<RunEntry>>()
val tasksInOrder = mutableListOf<RunEntry.TaskEntry>()
val standalone = mutableListOf<RunEntry>()
items.forEach { e ->
when (e) {
is RunEntry.TaskEntry -> {
tasksInOrder.add(e)
childrenByParent.putIfAbsent(e.id, mutableListOf())
}
else -> {
val pid = e.parentId
if (pid != null) {
childrenByParent.getOrPut(pid) { mutableListOf() }.add(e)
} else {
standalone.add(e)
}
}
}
}
val finalList = mutableListOf<RunEntry>()
val taskIds = tasksInOrder.map { it.id }.toSet()
tasksInOrder.forEach { t ->
finalList.add(t)
childrenByParent[t.id]?.let { kids ->
if (vm.expandAll) {
finalList.addAll(kids)
} else {
val from =
if (kids.size > AgentUiConfig.TASK_CHILDREN_MAX) kids.size - AgentUiConfig.TASK_CHILDREN_MAX else 0
finalList.addAll(kids.subList(from, kids.size))
}
}
}
if (standalone.isNotEmpty()) {
finalList.addAll(standalone)
}
childrenByParent.forEach { (pid, kids) ->
if (!taskIds.contains(pid)) {
finalList.addAll(if (vm.expandAll) kids else kids.takeLast(AgentUiConfig.TASK_CHILDREN_MAX))
}
}
rowsPanel.removeAll()
finalList.forEach { value ->
val view = ToolCallViewAdapter.build(value)
viewByEntryId[value.id] = view
val leftIndent = if (value.parentId != null) 12 else 0
rowsPanel.add(BorderLayoutPanel().apply {
isOpaque = false
border = JBUI.Borders.empty(2, leftIndent, 2, 0)
add(view, BorderLayout.CENTER)
})
}
rowsPanel.revalidate()
rowsPanel.repaint()
val hasOverflow = tasksInOrder.any { task ->
(childrenByParent[task.id]?.size ?: 0) > AgentUiConfig.TASK_CHILDREN_MAX
} || childrenByParent.any { (pid, kids) ->
!taskIds.contains(pid) && kids.size > AgentUiConfig.TASK_CHILDREN_MAX
}
expandLink.isVisible = hasOverflow
expandLink.text = if (!vm.expandAll) "Show all" else "Collapse"
expandLink.border = JBUI.Borders.emptyLeft(12)
root.revalidate()
root.repaint()
}
fun appendStreaming(entryId: String, text: String, isError: Boolean): Boolean {
val view = viewByEntryId[entryId] ?: return false
view.appendStreamingLine(text, isError)
return true
}
fun complete(entryId: String, success: Boolean, result: Any?) {
viewByEntryId[entryId]?.complete(success, result)
}
}

View file

@ -0,0 +1,160 @@
package ee.carlrobert.codegpt.toolwindow.agent.ui
import ee.carlrobert.codegpt.agent.tools.*
import ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor.ToolKind
sealed class RunEntry {
abstract val id: String
abstract val parentId: String?
abstract val kind: ToolKind
abstract fun withAnyResult(result: Any?): RunEntry
data class ReadEntry(
override val id: String,
override val parentId: String? = null,
val args: ReadTool.Args? = null,
val result: ReadTool.Result? = null,
) : RunEntry() {
override val kind: ToolKind = ToolKind.READ
override fun withAnyResult(result: Any?): RunEntry =
copy(result = result as? ReadTool.Result)
}
data class IntelliJSearchEntry(
override val id: String,
override val parentId: String? = null,
val args: IntelliJSearchTool.Args? = null,
val result: IntelliJSearchTool.Result? = null,
) : RunEntry() {
override val kind: ToolKind = ToolKind.SEARCH
override fun withAnyResult(result: Any?): RunEntry =
copy(result = result as? IntelliJSearchTool.Result)
}
data class BashEntry(
override val id: String,
override val parentId: String? = null,
val args: BashTool.Args? = null,
val result: BashTool.Result? = null,
) : RunEntry() {
override val kind: ToolKind = ToolKind.BASH
override fun withAnyResult(result: Any?): RunEntry =
copy(result = result as? BashTool.Result)
}
data class WebEntry(
override val id: String,
override val parentId: String? = null,
val args: WebSearchTool.Args? = null,
val result: WebSearchTool.Result? = null,
) : RunEntry() {
override val kind: ToolKind = ToolKind.WEB
override fun withAnyResult(result: Any?): RunEntry =
copy(result = result as? WebSearchTool.Result)
}
data class WriteEntry(
override val id: String,
override val parentId: String? = null,
val args: WriteTool.Args? = null,
val result: WriteTool.Result? = null,
) : RunEntry() {
override val kind: ToolKind = ToolKind.WRITE
override fun withAnyResult(result: Any?): RunEntry =
copy(result = result as? WriteTool.Result)
}
data class EditEntry(
override val id: String,
override val parentId: String? = null,
val args: EditTool.Args? = null,
val result: EditTool.Result? = null,
) : RunEntry() {
override val kind: ToolKind = ToolKind.EDIT
override fun withAnyResult(result: Any?): RunEntry =
copy(result = result as? EditTool.Result)
}
data class TaskEntry(
override val id: String,
override val parentId: String? = null,
val args: TaskTool.Args? = null,
val result: TaskTool.Result? = null,
val summary: TaskSummary? = null,
) : RunEntry() {
override val kind: ToolKind = ToolKind.TASK
override fun withAnyResult(result: Any?): RunEntry =
copy(result = result as? TaskTool.Result)
}
data class LibraryResolveEntry(
override val id: String,
override val parentId: String? = null,
val args: ResolveLibraryIdTool.Args? = null,
val result: ResolveLibraryIdTool.Result? = null,
) : RunEntry() {
override val kind: ToolKind = ToolKind.LIBRARY_RESOLVE
override fun withAnyResult(result: Any?): RunEntry =
copy(result = result as? ResolveLibraryIdTool.Result)
}
data class LibraryDocsEntry(
override val id: String,
override val parentId: String? = null,
val args: GetLibraryDocsTool.Args? = null,
val result: GetLibraryDocsTool.Result? = null,
) : RunEntry() {
override val kind: ToolKind = ToolKind.LIBRARY_DOCS
override fun withAnyResult(result: Any?): RunEntry =
copy(result = result as? GetLibraryDocsTool.Result)
}
data class OtherEntry(
override val id: String,
override val parentId: String? = null,
val name: String,
) : RunEntry() {
override val kind: ToolKind = ToolKind.OTHER
override fun withAnyResult(result: Any?): RunEntry = this
}
}
class AgentRunViewModel {
var expandAll: Boolean = false
var items: List<RunEntry> = emptyList()
fun addEntry(entry: RunEntry) {
items = items + entry
}
fun completeEntry(id: String, result: Any?) {
val updated = items.map { existing ->
if (existing.id != id) existing else when (existing) {
is RunEntry.TaskEntry -> {
val children = items.filter { it.parentId == existing.id }
val calls = children.size
val totalTokens = when (val taskResult = result as? TaskTool.Result) {
null -> TokenCounter.countForEntries(children)
else -> if (taskResult.totalTokens > 0) taskResult.totalTokens else TokenCounter.countForEntries(
children
)
}.toLong()
existing.copy(
result = result as? TaskTool.Result,
summary = TaskSummary(calls, totalTokens)
)
}
else -> existing.withAnyResult(result)
}
}
items = updated
}
}
data class TaskSummary(
val toolCalls: Int,
val tokens: Long,
)

View file

@ -0,0 +1,289 @@
package ee.carlrobert.codegpt.toolwindow.agent.ui
import com.intellij.openapi.command.WriteCommandAction
import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.options.ShowSettingsUtil
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.openapi.vfs.VfsUtilCore
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.ui.SimpleTextAttributes
import com.intellij.ui.components.ActionLink
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.components.BorderLayoutPanel
import ee.carlrobert.codegpt.settings.GeneralSettings
import ee.carlrobert.codegpt.settings.agents.SubagentsConfigurable
import ee.carlrobert.codegpt.tokens.TokenComputationService
import ee.carlrobert.codegpt.toolwindow.ui.ResponseMessagePanel
import ee.carlrobert.codegpt.ui.UIUtil.createTextPane
import java.awt.BorderLayout
import java.awt.Color
import java.awt.Desktop
import java.net.URI
import java.nio.charset.StandardCharsets
import java.nio.file.Path
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import javax.swing.Box
import javax.swing.BoxLayout
import javax.swing.JPanel
class AgentToolWindowLandingPanel(private val project: Project) : ResponseMessagePanel() {
companion object {
private val logger = thisLogger()
}
private fun createLabel(text: String) = JBLabel(text)
private fun createLink(text: String, onClick: () -> Unit) = ActionLink(text) { onClick() }
private fun formatFileSize(bytes: Long): String = when {
bytes < 1024 -> "${bytes}B"
bytes < 1024 * 1024 -> "${bytes / 1024}KB"
else -> String.format("%.1fMB", bytes / (1024.0 * 1024.0))
}
init {
addContent(buildContent())
}
private fun buildContent(): JPanel {
return BorderLayoutPanel().apply {
border = JBUI.Borders.empty(0)
add(createTextPane(welcomeMessage(), false), BorderLayout.NORTH)
add(centerPanel(), BorderLayout.CENTER)
}
}
private fun centerPanel(): JPanel {
val panel = JPanel()
panel.layout = BoxLayout(panel, BoxLayout.Y_AXIS)
panel.add(actionsListPanel())
panel.add(Box.createVerticalStrut(12))
panel.add(projectInfoPanel())
return panel
}
private fun actionsListPanel(): JPanel {
val panel = JPanel()
panel.layout = BoxLayout(panel, BoxLayout.Y_AXIS)
panel.border = JBUI.Borders.emptyTop(8)
panel.alignmentX = LEFT_ALIGNMENT
panel.add(createLabel("Quick Actions"))
panel.add(Box.createVerticalStrut(6))
val itemsPanel = JPanel()
itemsPanel.layout = BoxLayout(itemsPanel, BoxLayout.Y_AXIS)
itemsPanel.border = JBUI.Borders.emptyLeft(16)
itemsPanel.alignmentX = LEFT_ALIGNMENT
itemsPanel.add(createLink("Manage Subagents") {
ShowSettingsUtil.getInstance()
.showSettingsDialog(project, SubagentsConfigurable::class.java)
})
itemsPanel.add(Box.createVerticalStrut(6))
itemsPanel.add(createLink("Documentation & Tips") {
try {
Desktop.getDesktop().browse(URI("https://docs.tryproxy.io/agent"))
} catch (e: Exception) {
logger.error(e)
}
})
panel.add(itemsPanel)
return panel
}
private fun projectInfoPanel(): JPanel {
val panel = JPanel()
panel.layout = BoxLayout(panel, BoxLayout.Y_AXIS)
panel.border = JBUI.Borders.empty()
panel.alignmentX = LEFT_ALIGNMENT
panel.add(createLabel("Project Setup"))
panel.add(Box.createVerticalStrut(6))
val container = JPanel()
container.layout = BoxLayout(container, BoxLayout.Y_AXIS)
container.border = JBUI.Borders.emptyLeft(16)
container.alignmentX = LEFT_ALIGNMENT
val vf = findProxyAiFile()
if (vf == null) {
container.add(createLabel("PROXYAI.md not found"))
container.add(Box.createVerticalStrut(6))
container.add(createLink("Initialize Project") { createProxyAiFile() })
} else {
val topRow = JPanel()
topRow.layout = BoxLayout(topRow, BoxLayout.X_AXIS)
topRow.alignmentX = LEFT_ALIGNMENT
topRow.add(createLink("PROXYAI.md") { openProxyAiFile() })
topRow.add(JBLabel(" • "))
val fileSize = vf.length
val fileSizeLabel = JBLabel(formatFileSize(fileSize))
fileSizeLabel.foreground = SimpleTextAttributes.GRAYED_ATTRIBUTES.fgColor
topRow.add(fileSizeLabel)
topRow.add(JBLabel(" • "))
val tokens = countFileTokens(vf)
topRow.add(JBLabel("Tokens: "))
val tokensLabel = JBLabel(formatTokens(tokens))
tokensLabel.foreground = healthColor(tokens)
topRow.add(tokensLabel)
val bottomRow = JPanel()
bottomRow.layout = BoxLayout(bottomRow, BoxLayout.X_AXIS)
bottomRow.alignmentX = LEFT_ALIGNMENT
val ts = vf.timeStamp
val now = Instant.now()
val fileTime = Instant.ofEpochMilli(ts)
val zonedNow = now.atZone(ZoneId.systemDefault())
val zonedFileTime = fileTime.atZone(ZoneId.systemDefault())
val timeLabel = when {
zonedFileTime.toLocalDate() == zonedNow.toLocalDate() -> {
val timeFormat =
DateTimeFormatter.ofPattern("h:mm a").withZone(ZoneId.systemDefault())
JBLabel("Modified today at ${timeFormat.format(fileTime)}")
}
zonedFileTime.toLocalDate() == zonedNow.minusDays(1).toLocalDate() -> {
val timeFormat =
DateTimeFormatter.ofPattern("h:mm a").withZone(ZoneId.systemDefault())
JBLabel("Modified yesterday at ${timeFormat.format(fileTime)}")
}
fileTime.isAfter(now.minusSeconds(7 * 24 * 60 * 60)) -> {
val dayFormat =
DateTimeFormatter.ofPattern("EEEE").withZone(ZoneId.systemDefault())
val timeFormat =
DateTimeFormatter.ofPattern("h:mm a").withZone(ZoneId.systemDefault())
JBLabel(
"Modified on ${dayFormat.format(fileTime)} at ${
timeFormat.format(
fileTime
)
}"
)
}
zonedFileTime.toLocalDate().year == zonedNow.toLocalDate().year -> {
val dateFormat =
DateTimeFormatter.ofPattern("MMM d").withZone(ZoneId.systemDefault())
val timeFormat =
DateTimeFormatter.ofPattern("h:mm a").withZone(ZoneId.systemDefault())
JBLabel(
"Modified on ${dateFormat.format(fileTime)} at ${
timeFormat.format(
fileTime
)
}"
)
}
else -> {
val dateFormat =
DateTimeFormatter.ofPattern("MMM d, yyyy").withZone(ZoneId.systemDefault())
val timeFormat =
DateTimeFormatter.ofPattern("h:mm a").withZone(ZoneId.systemDefault())
JBLabel(
"Modified on ${dateFormat.format(fileTime)} at ${
timeFormat.format(
fileTime
)
}"
)
}
}
timeLabel.foreground = SimpleTextAttributes.GRAYED_ATTRIBUTES.fgColor
bottomRow.add(timeLabel)
container.add(topRow)
container.add(Box.createVerticalStrut(2))
container.add(bottomRow)
}
panel.add(container)
return panel
}
private fun findProxyAiFile() =
project.basePath?.let {
LocalFileSystem.getInstance().findFileByPath(Path.of(it, "PROXYAI.md").toString())
}
private fun createProxyAiFile() {
val basePath = project.basePath ?: return
WriteCommandAction.runWriteCommandAction(project) {
val dir = LocalFileSystem.getInstance().refreshAndFindFileByPath(basePath)
?: return@runWriteCommandAction
val existing = dir.findChild("PROXYAI.md")
val vf = existing ?: dir.createChildData(this, "PROXYAI.md")
vf.setBinaryContent(defaultProxyAiContent().toByteArray(StandardCharsets.UTF_8))
}
refresh()
}
private fun openProxyAiFile() {
val vf = findProxyAiFile() ?: return
FileEditorManager.getInstance(project).openFile(vf, true)
}
private fun refresh() {
removeAll()
addContent(buildContent())
revalidate()
repaint()
}
private fun welcomeMessage(): String {
val name = GeneralSettings.getCurrentState().displayName
return """
<html>
<p style="margin-top: 4px; margin-bottom: 4px;">
Hi <strong>$name</strong>, I'm <strong>ProxyAI Agent</strong>! I can break down complex tasks, modify your codebase, navigate project files, execute terminal commands, and orchestrate multi-phase development workflows.
</p>
</html>
""".trimIndent()
}
private fun defaultProxyAiContent() = """
# ProxyAI Instructions
Describe goals, conventions, risky areas, and review rules here.
""".trimIndent()
private fun countFileTokens(vf: VirtualFile): Int {
val text = runCatching { VfsUtilCore.loadText(vf) }.getOrNull() ?: ""
return TokenComputationService.getInstance().countTextTokens(text)
}
private fun formatTokens(n: Int): String =
if (n < 1000) n.toString() else String.format("%.1fk", n / 1000.0)
private fun healthColor(tokens: Int): Color {
val green = Color(76, 175, 80)
val yellow = Color(255, 193, 7)
val red = Color(244, 67, 54)
if (tokens <= 4096) return green
if (tokens <= 8192) return lerpColor(green, yellow, (tokens - 4096f) / (8192f - 4096f))
if (tokens <= 16384) return lerpColor(yellow, red, (tokens - 8192f) / (16384f - 8192f))
return red
}
private fun lerpColor(a: Color, b: Color, tRaw: Float): Color {
val t = tRaw.coerceIn(0f, 1f)
val r = (a.red + (b.red - a.red) * t).toInt()
val g = (a.green + (b.green - a.green) * t).toInt()
val bl = (a.blue + (b.blue - a.blue) * t).toInt()
return Color(r, g, bl)
}
}

View file

@ -0,0 +1,9 @@
package ee.carlrobert.codegpt.toolwindow.agent.ui
object AgentUiConfig {
const val GREP_PATTERN_MAX = 40
const val WEB_QUERY_MAX = 60
const val BASH_CMD_MAX = 80
const val TASK_CHILDREN_MAX = 6
}

View file

@ -0,0 +1,93 @@
package ee.carlrobert.codegpt.toolwindow.agent.ui
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 ee.carlrobert.codegpt.Icons
import java.awt.BorderLayout
import java.awt.FlowLayout
import javax.swing.BorderFactory
import javax.swing.Box
class SimpleAgentApprovalPanel(
title: String,
details: String?,
private val onApprove: (autoApproveSession: Boolean) -> Unit,
private val onReject: () -> Unit,
) : JBPanel<SimpleAgentApprovalPanel>() {
init {
layout = BorderLayout()
isOpaque = false
border = BorderFactory.createCompoundBorder(
JBUI.Borders.empty(2, 0),
BorderFactory.createCompoundBorder(
BorderFactory.createLineBorder(
JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground(),
1
),
JBUI.Borders.empty(1)
)
)
val content = JBPanel<JBPanel<*>>().apply {
layout = BorderLayout()
isOpaque = false
border = JBUI.Borders.empty(8, 12)
}
val header = JBPanel<JBPanel<*>>().apply {
layout = FlowLayout(FlowLayout.LEFT, 0, 0)
isOpaque = false
}
header.add(JBLabel(Icons.MCP))
header.add(Box.createHorizontalStrut(8))
header.add(JBLabel(title).apply {
font = JBUI.Fonts.label().asBold()
})
content.add(header, BorderLayout.NORTH)
details?.takeIf { it.isNotBlank() }?.let { txt ->
val detailsPanel = JBPanel<JBPanel<*>>().apply {
layout = BorderLayout()
isOpaque = false
border = JBUI.Borders.empty(6, 0, 8, 0)
}
detailsPanel.add(JBLabel(txt).apply {
font = JBUI.Fonts.smallFont()
}, BorderLayout.CENTER)
content.add(detailsPanel, BorderLayout.CENTER)
}
val actions = JBPanel<JBPanel<*>>().apply {
layout = FlowLayout(FlowLayout.LEFT, 0, 0)
isOpaque = false
}
actions.add(ActionLink("Accept") { approve(false) })
actions.add(JBLabel("|").apply { foreground = JBUI.CurrentTheme.Label.disabledForeground() })
actions.add(ActionLink("Always accept for this session") { approve(true) })
actions.add(JBLabel(" | ").apply { foreground = JBUI.CurrentTheme.Label.disabledForeground() })
actions.add(ActionLink("Reject") { reject() })
content.add(actions, BorderLayout.SOUTH)
add(content, BorderLayout.CENTER)
}
private fun approve(auto: Boolean) {
onApprove(auto)
removeSelf()
}
private fun reject() {
onReject()
removeSelf()
}
private fun removeSelf() {
isVisible = false
parent?.remove(this)
parent?.revalidate()
parent?.repaint()
}
}

View file

@ -0,0 +1,208 @@
package ee.carlrobert.codegpt.toolwindow.agent.ui
import com.intellij.openapi.application.runInEdt
import com.intellij.ui.components.ActionLink
import com.intellij.ui.components.JBCheckBox
import com.intellij.ui.components.JBTextArea
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.components.BorderLayoutPanel
import ee.carlrobert.codegpt.agent.tools.TodoWriteTool
import java.awt.BorderLayout
import java.awt.GridBagConstraints
import java.awt.GridBagLayout
import javax.swing.*
class TodoListPanel : BorderLayoutPanel() {
private var currentTodos: List<TodoWriteTool.TodoItem> = emptyList()
private var expanded = false
private val expandLink = ActionLink("") {
expanded = !expanded
rebuild()
}.apply {
isVisible = false
font = JBUI.Fonts.smallFont()
}
private val headerPanel = BorderLayoutPanel()
.addToLeft(JLabel("Tasks").apply { font = JBUI.Fonts.label().asBold() })
.addToRight(expandLink)
.andTransparent()
.withBorder(JBUI.Borders.emptyBottom(8))
private val contentHolder = BorderLayoutPanel()
init {
isVisible = false
andTransparent()
withBorder(JBUI.Borders.empty(8, 0))
addToTop(headerPanel)
addToCenter(contentHolder)
}
fun updateTodos(todos: List<TodoWriteTool.TodoItem>) {
currentTodos = todos
runInEdt { rebuild() }
}
fun clearTodos() {
runInEdt {
currentTodos = emptyList()
contentHolder.removeAll()
isVisible = false
revalidate()
repaint()
}
}
private fun rebuild() {
val shown = if (getCurrent() != null) 1 + if (getNext() != null) 1 else 0 else 0
val remaining = (currentTodos.size - shown).coerceAtLeast(0)
expandLink.text = if (expanded) "Collapse" else "Show $remaining more"
expandLink.isVisible = expanded || currentTodos.size > 2
contentHolder.removeAll()
val content = if (expanded) buildExpandedContent() else buildCollapsedContent()
contentHolder.addToCenter(content)
contentHolder.revalidate()
contentHolder.repaint()
}
private fun buildCollapsedContent(): JPanel {
val current = getCurrent()
val container = JPanel()
container.isOpaque = false
container.layout = BoxLayout(container, BoxLayout.Y_AXIS)
val currentText = currentLine(current)
val currentComp = createWrappingText(currentText, bold = true, small = true, gray = false)
container.add(currentComp)
if (current != null) {
val currentIdx = currentTodos.indexOf(current)
val isLastItem = currentIdx == currentTodos.size - 1
if (isLastItem && currentIdx > 0) {
val previous = currentTodos[currentIdx - 1]
val previousLine = buildString {
append("Previous: ")
append(taskText(previous))
}
val previousComp =
createWrappingText(previousLine, bold = false, small = true, gray = true)
container.add(previousComp)
} else {
val next = getNext()
if (next != null) {
val nextLine = buildString {
append("Next: ")
append(taskText(next))
}
val nextComp =
createWrappingText(nextLine, bold = false, small = true, gray = true)
container.add(nextComp)
}
}
}
return BorderLayoutPanel().andTransparent().addToCenter(container)
}
private fun buildExpandedContent(): JPanel {
val listPanel = JPanel(GridBagLayout()).apply { isOpaque = false }
currentTodos.forEachIndexed { index, todo ->
val item = JPanel(BorderLayout(4, 0)).apply {
isOpaque = false
border = JBUI.Borders.empty(4, 8)
val cb = JBCheckBox().apply {
isSelected = todo.status == TodoWriteTool.TodoStatus.COMPLETED
isEnabled = false
isOpaque = false
margin = JBUI.emptyInsets()
}
val label = createWrappingText(
taskText(todo),
bold = todo.status == TodoWriteTool.TodoStatus.IN_PROGRESS,
small = false,
gray = todo.status == TodoWriteTool.TodoStatus.COMPLETED
)
add(cb, BorderLayout.WEST)
add(label, BorderLayout.CENTER)
}
val gbc = GridBagConstraints().apply {
gridx = 0
gridy = index
weightx = 1.0
fill = GridBagConstraints.HORIZONTAL
anchor = GridBagConstraints.NORTHWEST
insets = JBUI.insets(2, 0, 2, 0)
}
listPanel.add(item, gbc)
}
val scroll = JScrollPane(listPanel).apply {
isOpaque = false
viewport.isOpaque = false
border = JBUI.Borders.empty()
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED
}
return BorderLayoutPanel().apply {
isOpaque = false
add(scroll, BorderLayout.CENTER)
}
}
private fun getCurrent(): TodoWriteTool.TodoItem? {
return currentTodos.firstOrNull { it.status == TodoWriteTool.TodoStatus.IN_PROGRESS }
?: currentTodos.firstOrNull { it.status == TodoWriteTool.TodoStatus.PENDING }
?: currentTodos.firstOrNull()
}
private fun getNext(): TodoWriteTool.TodoItem? {
val current = getCurrent() ?: return null
val idx = currentTodos.indexOf(current)
return currentTodos.drop(idx + 1)
.firstOrNull { it.status != TodoWriteTool.TodoStatus.COMPLETED }
?: currentTodos.firstOrNull { it.status == TodoWriteTool.TodoStatus.PENDING && it != current }
}
private fun currentLine(current: TodoWriteTool.TodoItem?): String {
return current?.let {
val total = currentTodos.size
val pos = currentTodos.indexOf(it) + 1
buildString {
append(taskText(it))
if (total > 0) {
append(" (")
append(pos)
append('/')
append(total)
append(')')
}
}
} ?: "No tasks"
}
private fun taskText(todo: TodoWriteTool.TodoItem): String {
return if (todo.status == TodoWriteTool.TodoStatus.IN_PROGRESS) todo.activeForm else todo.content
}
private fun createWrappingText(
text: String,
bold: Boolean,
small: Boolean,
gray: Boolean
): JBTextArea {
return JBTextArea(text).apply {
isEditable = false
isOpaque = false
lineWrap = true
wrapStyleWord = true
border = JBUI.Borders.empty(0, 0, 2, 0)
font = when {
bold && !small -> JBUI.Fonts.label().asBold()
bold && small -> JBUI.Fonts.smallFont().asBold()
small -> JBUI.Fonts.smallFont()
else -> JBUI.Fonts.label()
}
foreground =
if (gray) JBUI.CurrentTheme.Label.disabledForeground() else JBUI.CurrentTheme.Label.foreground()
}
}
}

View file

@ -0,0 +1,58 @@
package ee.carlrobert.codegpt.toolwindow.agent.ui
import com.intellij.openapi.diagnostic.thisLogger
import ee.carlrobert.codegpt.EncodingManager
import ee.carlrobert.codegpt.agent.tools.EditTool
import ee.carlrobert.codegpt.agent.tools.ReadTool
import ee.carlrobert.codegpt.agent.tools.WriteTool
import kotlin.math.max
import kotlin.math.round
object TokenCounter {
private val logger = thisLogger()
fun countForEntries(entries: List<RunEntry>): Int = entries.sumOf { countForEntry(it) }
private fun countForEntry(entry: RunEntry): Int = when (entry) {
is RunEntry.ReadEntry -> when (val r = entry.result) {
is ReadTool.Result.Success -> count(r.content)
is ReadTool.Result.Error -> count(r.error)
else -> 0
}
is RunEntry.IntelliJSearchEntry -> entry.result?.let { count(it.output) } ?: 0
is RunEntry.BashEntry -> entry.result?.let { count(it.output) } ?: 0
is RunEntry.WebEntry -> entry.result?.let { res ->
val titles = res.results.joinToString("\n") { it.title + "\n" + it.content }
count(res.query) + count(titles)
} ?: 0
is RunEntry.WriteEntry -> when (val r = entry.result) {
is WriteTool.Result.Success -> count(r.message)
is WriteTool.Result.Error -> count(r.error)
else -> 0
}
is RunEntry.EditEntry -> when (val r = entry.result) {
is EditTool.Result.Success -> count(r.message)
is EditTool.Result.Error -> count(r.error)
else -> 0
}
else -> 0
}
private fun count(text: String?): Int {
if (text.isNullOrBlank()) return 0
return try {
EncodingManager.getInstance().countTokens(text)
} catch (e: Exception) {
logger.warn(
"Something went wrong when calculating tokens. Using fallback calculation logic.",
e
)
val approx = round(text.length / 4.0).toInt()
max(approx, 1)
}
}
}

View file

@ -0,0 +1,51 @@
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.ToolCallDescriptorFactory
import ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor.ToolCallView
import ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor.ToolKind
class ToolCallCard(
project: Project,
private val toolName: String,
private val args: Any?,
private val overrideKind: ToolKind? = null
) : JBPanel<ToolCallCard>() {
private val view: ToolCallView
private val projectId: String = project.locationHash
init {
layout = java.awt.BorderLayout()
isOpaque = false
border = JBUI.Borders.empty()
val descriptor = ToolCallDescriptorFactory.create(
toolName = toolName,
args = args ?: Unit,
result = null,
projectId = projectId,
overrideKind = overrideKind
)
view = ToolCallView(descriptor)
add(view, java.awt.BorderLayout.CENTER)
}
fun complete(success: Boolean, result: Any?) {
val updated = ToolCallDescriptorFactory.create(
toolName = toolName,
args = args ?: Unit,
result = result,
projectId = projectId,
overrideKind = overrideKind
)
view.refreshDescriptor(updated)
view.complete(success, result)
}
fun appendStreamingLine(text: String, isError: Boolean) {
view.appendStreamingLine(text, isError)
}
}

View file

@ -0,0 +1,33 @@
package ee.carlrobert.codegpt.toolwindow.agent.ui.approval
import com.intellij.openapi.project.Project
import ee.carlrobert.codegpt.toolwindow.agent.ui.SimpleAgentApprovalPanel
import javax.swing.JPanel
interface ApprovalPanelFactory {
fun create(
project: Project,
request: ToolApprovalRequest,
onApprove: (autoApproveSession: Boolean) -> Unit,
onReject: () -> Unit
): JPanel
}
object DefaultApprovalPanelFactory : ApprovalPanelFactory {
override fun create(
project: Project,
request: ToolApprovalRequest,
onApprove: (autoApproveSession: Boolean) -> Unit,
onReject: () -> Unit
): JPanel = when (request.type) {
ToolApprovalType.BASH -> BashApprovalPanel(project, request, onApprove, onReject)
ToolApprovalType.WRITE -> WriteApprovalPanel(project, request, onApprove, onReject)
ToolApprovalType.EDIT -> EditApprovalPanel(project, request, onApprove, onReject)
ToolApprovalType.GENERIC -> SimpleAgentApprovalPanel(
request.title,
request.details,
onApprove,
onReject
)
}
}

View file

@ -0,0 +1,186 @@
package ee.carlrobert.codegpt.toolwindow.agent.ui.approval
import com.intellij.ui.components.JBLabel
import com.intellij.ui.dsl.builder.Align
import com.intellij.ui.dsl.builder.TopGap
import com.intellij.ui.dsl.builder.panel
import com.intellij.util.ui.JBUI
import ee.carlrobert.codegpt.agent.tools.AskUserQuestionTool
import java.awt.BorderLayout
import javax.swing.BorderFactory
import javax.swing.JCheckBox
import javax.swing.JPanel
import javax.swing.JRadioButton
import javax.swing.JTextField
import javax.swing.border.EmptyBorder
class AskUserQuestionPanel(
private val model: AskUserQuestionTool.AskUserQuestionsModel,
private val onSubmit: (Map<String, String>) -> Unit,
private val onCancel: () -> Unit
) : JPanel(BorderLayout()) {
private var index = 0
private val answers = model.prefilledAnswers.toMutableMap()
init {
isOpaque = false
border = BorderFactory.createCompoundBorder(
JBUI.Borders.empty(2, 0),
BorderFactory.createCompoundBorder(
BorderFactory.createLineBorder(JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground(), 1),
JBUI.Borders.empty(8)
)
)
render()
}
private fun render() {
removeAll()
val q = model.questions[index]
val isFirst = index == 0
val isLast = index == model.questions.lastIndex
val otherField = JTextField()
val radios = mutableListOf<JRadioButton>()
val checks = mutableListOf<JCheckBox>()
val content = panel {
row {
val chip = JBLabel(q.header).apply { border = EmptyBorder(2, 6, 2, 6) }
cell(chip)
}
row {
comment(q.question)
}
if (q.multiSelect) {
q.options.forEach { opt ->
row {
val cb = JCheckBox(opt.label)
checks.add(cb)
cell(cb)
comment(opt.description)
}
}
row {
val cbOther = JCheckBox("Other")
checks.add(cbOther)
otherField.columns = 24
otherField.isEnabled = false
cbOther.addActionListener { otherField.isEnabled = cbOther.isSelected }
cell(cbOther)
cell(otherField).align(Align.FILL)
}
} else {
buttonsGroup {
q.options.forEach { opt ->
row {
val rb = radioButton(opt.label).component
radios.add(rb)
comment(opt.description)
}
}
row {
val rbOther = radioButton("Other").component
radios.add(rbOther)
otherField.columns = 24
otherField.isEnabled = false
rbOther.addActionListener { otherField.isEnabled = rbOther.isSelected }
cell(otherField).align(Align.FILL)
}
}
}
row {
if (!isFirst) link("Back") { saveAndBack(q, radios, checks, otherField) }
if (!isFirst) text("|").applyToComponent { foreground = JBUI.CurrentTheme.Label.disabledForeground() }
if (!isLast) link("Next") { saveAndNext(q, radios, checks, otherField) }
if (!isLast) text("|").applyToComponent { foreground = JBUI.CurrentTheme.Label.disabledForeground() }
if (isLast) link("Submit") { saveAndSubmit(q, radios, checks, otherField) }
if (isLast) text("|").applyToComponent { foreground = JBUI.CurrentTheme.Label.disabledForeground() }
link("Cancel") { cancel() }
}.topGap(TopGap.SMALL)
}
prefill(q, radios, checks, otherField)
if (!q.multiSelect && radios.isNotEmpty() && radios.none { it.isSelected }) {
radios.first().isSelected = true
}
add(content, BorderLayout.CENTER)
revalidate()
repaint()
}
private fun collect(q: AskUserQuestionTool.Question, radios: List<JRadioButton>, checks: List<JCheckBox>, other: JTextField): String {
return if (q.multiSelect) {
val selected = checks.filter { it.isSelected }.map { it.text }.toMutableList()
if (checks.isNotEmpty() && checks.last().isSelected) {
val t = other.text.trim()
if (t.isNotEmpty()) {
selected.removeIf { it == "Other" }
selected.add(t)
}
}
selected.joinToString(", ")
} else {
val rb = radios.firstOrNull { it.isSelected }
if (rb != null) {
if (rb.text == "Other") other.text.trim() else rb.text
} else ""
}
}
private fun prefill(q: AskUserQuestionTool.Question, radios: List<JRadioButton>, checks: List<JCheckBox>, other: JTextField) {
val ans = answers[q.header] ?: return
if (q.multiSelect) {
val parts = ans.split(',').map { it.trim() }.filter { it.isNotEmpty() }
q.options.forEachIndexed { idx, opt -> if (parts.contains(opt.label)) checks.getOrNull(idx)?.isSelected = true }
val extra = parts.filter { p -> q.options.none { it.label == p } }
if (extra.isNotEmpty()) {
checks.lastOrNull()?.isSelected = true
other.text = extra.joinToString(", ")
other.isEnabled = true
}
} else {
val idx = q.options.indexOfFirst { it.label == ans }
if (idx >= 0) {
radios.getOrNull(idx)?.isSelected = true
} else if (ans.isNotBlank()) {
radios.lastOrNull()?.isSelected = true
other.text = ans
other.isEnabled = true
}
}
}
private fun saveAndNext(q: AskUserQuestionTool.Question, radios: List<JRadioButton>, checks: List<JCheckBox>, other: JTextField) {
answers[q.header] = collect(q, radios, checks, other)
index += 1
render()
}
private fun saveAndBack(q: AskUserQuestionTool.Question, radios: List<JRadioButton>, checks: List<JCheckBox>, other: JTextField) {
answers[q.header] = collect(q, radios, checks, other)
index -= 1
render()
}
private fun saveAndSubmit(q: AskUserQuestionTool.Question, radios: List<JRadioButton>, checks: List<JCheckBox>, other: JTextField) {
answers[q.header] = collect(q, radios, checks, other)
onSubmit(answers)
removeSelf()
}
private fun cancel() {
onCancel()
removeSelf()
}
private fun removeSelf() {
isVisible = false
parent?.remove(this)
parent?.revalidate()
parent?.repaint()
}
}

View file

@ -0,0 +1,102 @@
package ee.carlrobert.codegpt.toolwindow.agent.ui.approval
import com.intellij.openapi.project.Project
import com.intellij.ui.dsl.builder.Align
import com.intellij.ui.dsl.builder.TopGap
import com.intellij.ui.dsl.builder.panel
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.components.BorderLayoutPanel
import ee.carlrobert.codegpt.toolwindow.chat.parser.Code
import java.awt.BorderLayout
import javax.swing.BorderFactory
import javax.swing.JComponent
import ee.carlrobert.codegpt.toolwindow.chat.editor.factory.EditorFactory as ChatEditorFactory
class BashApprovalPanel(
private val project: Project,
private val request: ToolApprovalRequest,
private val onApprove: (autoApproveSession: Boolean) -> Unit,
private val onReject: () -> Unit
) : BorderLayoutPanel() {
init {
isOpaque = false
border = BorderFactory.createCompoundBorder(
JBUI.Borders.empty(2, 0),
BorderFactory.createCompoundBorder(
BorderFactory.createLineBorder(
JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground(),
1
),
JBUI.Borders.empty(1)
)
)
val payload = request.payload as? BashPayload
val editorComponent = createEditorComponent(payload?.command ?: request.details)
add(
panel {
row {
label(request.title).bold().applyToComponent {
border = JBUI.Borders.emptyLeft(6)
}
}
row {
cell(editorComponent).align(Align.FILL).resizableColumn()
}.topGap(TopGap.SMALL)
payload?.description?.let {
row {
comment(it).applyToComponent {
border = JBUI.Borders.emptyLeft(6)
}
}
}
row {
link("Run") { approve(false) }.applyToComponent {
border = JBUI.Borders.emptyLeft(6)
}
text("|").applyToComponent {
foreground = JBUI.CurrentTheme.Label.disabledForeground()
}
link("Always run for this session") { approve(true) }
text("|").applyToComponent {
foreground = JBUI.CurrentTheme.Label.disabledForeground()
}
link("Reject") { reject() }
}
},
BorderLayout.CENTER
)
}
private fun createEditorComponent(command: String): JComponent {
val segment = Code(content = command, language = "bash", filePath = "temp.bash")
val editor = ChatEditorFactory.createEditor(project, segment)
ChatEditorFactory.configureEditor(editor, headerComponent = null)
return editor.component.apply {
border =
JBUI.Borders.customLine(JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground())
preferredSize = java.awt.Dimension(0, JBUI.scale(80))
}
}
private fun approve(auto: Boolean) {
onApprove(auto)
removeSelf()
}
private fun reject() {
onReject()
removeSelf()
}
private fun removeSelf() {
isVisible = false
parent?.remove(this)
parent?.revalidate()
parent?.repaint()
}
}

View file

@ -0,0 +1,53 @@
package ee.carlrobert.codegpt.toolwindow.agent.ui.approval
import com.intellij.diff.DiffContentFactory
import com.intellij.diff.DiffManager
import com.intellij.diff.requests.SimpleDiffRequest
import com.intellij.openapi.project.Project
import com.intellij.openapi.project.ProjectManager
import com.intellij.openapi.vfs.VirtualFileManager
import java.awt.Component
object DiffViewAction {
fun showDiff(filePath: String, component: Component) {
val project = ProjectManager.getInstance().openProjects.firstOrNull()
if (project != null) {
showDiff(filePath, project)
}
}
fun showDiff(filePath: String, project: Project) {
val virtualFile = VirtualFileManager.getInstance().findFileByUrl("file://$filePath")
if (virtualFile != null) {
val contentFactory = DiffContentFactory.getInstance()
val currentContent = contentFactory.create(project, virtualFile)
val emptyContent = contentFactory.createEmpty()
val diffRequest = SimpleDiffRequest(
"Changes in ${virtualFile.name}",
emptyContent,
currentContent,
"Before",
"After"
)
DiffManager.getInstance().showDiff(project, diffRequest)
}
}
fun showDiff(before: String, after: String, title: String, project: Project?) {
val proj = project ?: ProjectManager.getInstance().openProjects.firstOrNull() ?: return
val contentFactory = DiffContentFactory.getInstance()
val left = contentFactory.create(before)
val right = contentFactory.create(after)
val diffRequest = SimpleDiffRequest(
title,
left,
right,
"Before",
"After"
)
DiffManager.getInstance().showDiff(proj, diffRequest)
}
}

View file

@ -0,0 +1,183 @@
package ee.carlrobert.codegpt.toolwindow.agent.ui.approval
import com.intellij.diff.util.DiffUtil
import com.intellij.diff.util.Side
import com.intellij.ide.actions.OpenFileAction
import com.intellij.openapi.Disposable
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.text.StringUtil
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.ui.JBColor
import com.intellij.ui.components.ActionLink
import com.intellij.ui.components.JBLabel
import com.intellij.ui.dsl.builder.Align
import com.intellij.ui.dsl.builder.RightGap
import com.intellij.ui.dsl.builder.panel
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.components.BorderLayoutPanel
import ee.carlrobert.codegpt.toolwindow.agent.ui.renderer.ChangeColors
import ee.carlrobert.codegpt.toolwindow.agent.ui.renderer.applyStringReplacement
import ee.carlrobert.codegpt.toolwindow.agent.ui.renderer.getFileContentWithFallback
import ee.carlrobert.codegpt.toolwindow.agent.ui.renderer.lineDiffStats
import ee.carlrobert.codegpt.toolwindow.chat.editor.ResponseEditorPanel
import ee.carlrobert.codegpt.toolwindow.chat.parser.ReplaceWaiting
import java.awt.BorderLayout
import java.nio.file.Paths
import javax.swing.BorderFactory
import javax.swing.JComponent
import ee.carlrobert.codegpt.toolwindow.chat.editor.factory.EditorFactory as ChatEditorFactory
class EditApprovalPanel(
private val project: Project,
private val request: ToolApprovalRequest,
private val onApprove: (autoApproveSession: Boolean) -> Unit,
private val onReject: () -> Unit
) : BorderLayoutPanel(), Disposable {
private var diffCounts: Triple<Int, Int, Int>? = null // +ins, -del, ~changed
init {
isOpaque = false
border = BorderFactory.createCompoundBorder(
JBUI.Borders.empty(2, 0),
BorderFactory.createCompoundBorder(
BorderFactory.createLineBorder(
JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground(),
1
),
JBUI.Borders.empty(1)
)
)
val payload = request.payload as? EditPayload
val filePath = payload?.filePath ?: ""
val diffComponent = createInlineDiffComponent(payload)
add(
panel {
row { label(request.title).bold() }
row {
label(request.details).applyToComponent {
foreground = JBUI.CurrentTheme.Label.disabledForeground()
}
}
if (filePath.isNotBlank()) {
row("File:") {
val baseName = extractBaseName(filePath)
val link = ActionLink(baseName) {
LocalFileSystem.getInstance().findFileByPath(filePath)
?.let { OpenFileAction.openFile(it, project) }
}.apply {
toolTipText = filePath
setExternalLinkIcon()
}
cell(link).gap(RightGap.SMALL)
diffCounts?.let { (ins, del, changed) ->
if (ins + del + changed > 0) {
cell(colorLabel("+${ins}", ChangeColors.inserted).apply {
font = JBUI.Fonts.smallFont()
})
cell(colorLabel("-${del}", ChangeColors.deleted).apply {
font = JBUI.Fonts.smallFont()
})
cell(colorLabel("~${changed}", ChangeColors.modified).apply {
font = JBUI.Fonts.smallFont()
})
}
}
}
}
diffComponent?.let {
row {
cell(it).align(Align.FILL).resizableColumn()
}
}
row {
link("Accept") { approve(false) }
label("|").applyToComponent {
foreground = JBUI.CurrentTheme.Label.disabledForeground()
}
link("Always accept for this session") { approve(true) }
label("|").applyToComponent {
foreground = JBUI.CurrentTheme.Label.disabledForeground()
}
link("Reject") { reject() }
}
}.apply {
border = JBUI.Borders.empty(4, 8)
},
BorderLayout.CENTER
)
}
private fun createInlineDiffComponent(payload: EditPayload?): JComponent? {
if (payload == null) return null
val path = try {
Paths.get(payload.filePath).normalize().toString()
} catch (_: Exception) {
payload.filePath
}
val vfs = LocalFileSystem.getInstance().refreshAndFindFileByPath(path)
val current = getFileContentWithFallback(path)
val proposed = applyStringReplacement(
current,
payload.oldString,
payload.newString,
payload.replaceAll
)
val (insRaw, delRaw, changed) = lineDiffStats(current, proposed)
diffCounts = Triple(insRaw, delRaw, changed)
val language = vfs?.extension ?: "text"
val segment = ReplaceWaiting(current, proposed, language, path)
val editor = ChatEditorFactory.createEditor(project, segment)
ResponseEditorPanel.RESPONSE_EDITOR_DIFF_VIEWER_KEY.get(editor)?.let { viewer ->
val rightDoc = viewer.getDocument(Side.RIGHT)
if (DiffUtil.executeWriteCommand(rightDoc, project, "Update proposed") {
rightDoc.setText(StringUtil.convertLineSeparators(proposed))
viewer.scheduleRediff()
}) {
viewer.rediff(true)
}
}
ChatEditorFactory.configureEditor(editor, headerComponent = null)
val comp = editor.component
comp.border =
JBUI.Borders.customLine(JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground())
comp.preferredSize = java.awt.Dimension(0, JBUI.scale(140))
return comp
}
private fun approve(auto: Boolean) {
onApprove(auto)
removeSelf()
}
private fun reject() {
onReject()
removeSelf()
}
private fun removeSelf() {
isVisible = false
parent?.remove(this)
parent?.revalidate()
parent?.repaint()
}
override fun dispose() {}
private fun extractBaseName(path: String?): String {
if (path.isNullOrBlank()) return ""
val noSlash = path.substringAfterLast('/')
return noSlash.substringAfterLast('\\')
}
private fun colorLabel(text: String, color: JBColor): JBLabel =
JBLabel(text).apply { foreground = color }
}

View file

@ -0,0 +1,21 @@
package ee.carlrobert.codegpt.toolwindow.agent.ui.approval
sealed interface ToolApprovalPayload
data class BashPayload(
val command: String,
val description: String?
) : ToolApprovalPayload
data class WritePayload(
val filePath: String,
val content: String
) : ToolApprovalPayload
data class EditPayload(
val filePath: String,
val oldString: String,
val newString: String,
val replaceAll: Boolean
) : ToolApprovalPayload

View file

@ -0,0 +1,8 @@
package ee.carlrobert.codegpt.toolwindow.agent.ui.approval
data class ToolApprovalRequest(
val type: ToolApprovalType,
val title: String,
val details: String,
val payload: ToolApprovalPayload? = null
)

View file

@ -0,0 +1,9 @@
package ee.carlrobert.codegpt.toolwindow.agent.ui.approval
enum class ToolApprovalType {
BASH,
WRITE,
EDIT,
GENERIC
}

View file

@ -0,0 +1,117 @@
package ee.carlrobert.codegpt.toolwindow.agent.ui.approval
import com.intellij.ide.actions.OpenFileAction
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.ui.components.ActionLink
import com.intellij.ui.dsl.builder.Align
import com.intellij.ui.dsl.builder.RightGap
import com.intellij.ui.dsl.builder.TopGap
import com.intellij.ui.dsl.builder.panel
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.components.BorderLayoutPanel
import ee.carlrobert.codegpt.toolwindow.chat.parser.Code
import javax.swing.BorderFactory
import javax.swing.JComponent
import ee.carlrobert.codegpt.toolwindow.chat.editor.factory.EditorFactory as ChatEditorFactory
class WriteApprovalPanel(
private val project: Project,
private val request: ToolApprovalRequest,
private val onApprove: (autoApproveSession: Boolean) -> Unit,
private val onReject: () -> Unit
) : BorderLayoutPanel() {
init {
isOpaque = false
border = BorderFactory.createCompoundBorder(
JBUI.Borders.empty(2, 0),
BorderFactory.createCompoundBorder(
BorderFactory.createLineBorder(
JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground(),
1
),
JBUI.Borders.empty(1)
)
)
val payload = request.payload as? WritePayload
val filePath = payload?.filePath
val content = payload?.content
val editorComponent = createEditorComponent(filePath, content)
val metrics = content?.let { "${it.length} bytes • ${it.lines().size} lines" }
addToCenter(panel {
row { label(request.title).bold() }
if (!filePath.isNullOrBlank()) {
row("File:") {
val baseName = extractBaseName(filePath)
val link = ActionLink(baseName) {
LocalFileSystem.getInstance().findFileByPath(filePath)
?.let { OpenFileAction.openFile(it, project) }
}.apply {
toolTipText = filePath
setExternalLinkIcon()
}
cell(link).gap(RightGap.SMALL)
}
}
if (!metrics.isNullOrBlank()) row { comment(metrics) }
if (editorComponent != null) row {
cell(editorComponent).align(Align.FILL).resizableColumn()
}.topGap(
TopGap.SMALL
)
row {
link("Accept") { approve(false) }
label("|").applyToComponent {
foreground = JBUI.CurrentTheme.Label.disabledForeground()
}
link("Always accept for this session") { approve(true) }
label("|").applyToComponent {
foreground = JBUI.CurrentTheme.Label.disabledForeground()
}
link("Reject") { reject() }
}.topGap(TopGap.SMALL)
})
}
private fun createEditorComponent(filePath: String?, content: String?): JComponent? {
if (content == null) return null
val language = filePath?.substringAfterLast('.', missingDelimiterValue = "text") ?: "text"
val segment = Code(content = content, language = language, filePath = filePath)
val editor = ChatEditorFactory.createEditor(project, segment)
ChatEditorFactory.configureEditor(editor, headerComponent = null)
return editor.component.apply {
border =
JBUI.Borders.customLine(JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground())
preferredSize = java.awt.Dimension(0, JBUI.scale(140))
}
}
private fun approve(auto: Boolean) {
onApprove(auto)
removeSelf()
}
private fun reject() {
onReject()
removeSelf()
}
private fun removeSelf() {
isVisible = false
parent?.remove(this)
parent?.revalidate()
parent?.repaint()
}
private fun extractBaseName(path: String?): String {
if (path.isNullOrBlank()) return ""
val noSlash = path.substringAfterLast('/')
return noSlash.substringAfterLast('\\')
}
}

View file

@ -0,0 +1,45 @@
package ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor
import com.intellij.openapi.project.Project
import com.intellij.ui.JBColor
import java.awt.Component
import javax.swing.Icon
enum class ToolKind {
READ, WRITE, EDIT, BASH, SEARCH, WEB, TASK, LIBRARY_RESOLVE, LIBRARY_DOCS, ASK_QUESTION, EXIT, OTHER
}
data class Badge(
val text: String,
val color: JBColor = JBColor.GRAY,
val tooltip: String? = null
)
data class FileLink(
val path: String,
val displayName: String,
val enabled: Boolean = true,
val action: ((Project) -> Unit)? = null
)
data class ToolAction(
val name: String,
val icon: Icon,
val action: (Component) -> Unit
)
data class ToolCallDescriptor(
val kind: ToolKind,
val icon: Icon?,
val titlePrefix: String,
val titleMain: String,
val tooltip: String?,
val secondaryBadges: List<Badge> = emptyList(),
val fileLink: FileLink? = null,
val actions: List<ToolAction> = emptyList(),
val supportsStreaming: Boolean = false,
val args: Any,
val result: Any? = null,
val projectId: String? = null,
val prefixColor: JBColor? = null
)

View file

@ -0,0 +1,679 @@
package ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor
import com.intellij.icons.AllIcons
import com.intellij.ui.JBColor
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.components.BorderLayoutPanel
import ee.carlrobert.codegpt.agent.tools.*
import ee.carlrobert.codegpt.toolwindow.agent.ui.AgentUiConfig
import ee.carlrobert.codegpt.toolwindow.agent.ui.approval.DiffViewAction
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import java.awt.BorderLayout
import java.awt.Color
import java.awt.FlowLayout
import java.awt.Toolkit
import java.awt.datatransfer.StringSelection
import java.nio.file.Files
import java.nio.file.Path
import javax.swing.JButton
import javax.swing.JDialog
import javax.swing.JPanel
import javax.swing.JScrollPane
import javax.swing.JTextArea
import kotlin.math.absoluteValue
object ToolCallDescriptorFactory {
fun create(
toolName: String,
args: Any,
result: Any? = null,
projectId: String? = null,
overrideKind: ToolKind? = null
): ToolCallDescriptor {
val kind = overrideKind ?: detectToolKind(toolName, args)
return when (kind) {
ToolKind.SEARCH -> createSearchDescriptor(args, result, projectId)
ToolKind.READ -> createReadDescriptor(args, result, projectId)
ToolKind.WRITE -> createWriteDescriptor(args, result, projectId)
ToolKind.EDIT -> createEditDescriptor(args, result, projectId)
ToolKind.BASH -> createBashDescriptor(args, result, projectId)
ToolKind.WEB -> createWebDescriptor(args, result, projectId)
ToolKind.TASK -> createTaskDescriptor(args, result, projectId)
ToolKind.LIBRARY_RESOLVE -> createLibraryResolveDescriptor(args, result, projectId)
ToolKind.LIBRARY_DOCS -> createLibraryDocsDescriptor(args, result, projectId)
ToolKind.ASK_QUESTION -> createAskDescriptor(args, result, projectId)
ToolKind.EXIT -> createExitDescriptor(toolName, args, result, projectId)
ToolKind.OTHER -> createOtherDescriptor(toolName, args, result, projectId)
}
}
private fun detectToolKind(toolName: String, args: Any): ToolKind {
return when {
toolName == "IntelliJSearch" || args is IntelliJSearchTool.Args -> ToolKind.SEARCH
toolName == "Read" || args is ReadTool.Args -> ToolKind.READ
toolName == "Write" || args is WriteTool.Args -> ToolKind.WRITE
toolName == "Edit" || args is EditTool.Args -> ToolKind.EDIT
toolName == "Bash" || args is BashTool.Args -> ToolKind.BASH
toolName == "BashOutput" || args is BashOutputTool.Args -> ToolKind.BASH
toolName == "KillShell" || args is KillShellTool.Args -> ToolKind.BASH
toolName == "WebSearch" || args is WebSearchTool.Args -> ToolKind.WEB
toolName == "ResolveLibraryId" || args is ResolveLibraryIdTool.Args -> ToolKind.LIBRARY_RESOLVE
toolName == "GetLibraryDocs" || args is GetLibraryDocsTool.Args -> ToolKind.LIBRARY_DOCS
toolName == "Task" || args is TaskTool.Args -> ToolKind.TASK
toolName == "TodoWrite" || toolName == "TodoWriteTool" || args is TodoWriteTool.Args -> ToolKind.TASK
toolName == "AskUserQuestion" || args is AskUserQuestionTool.Args -> ToolKind.ASK_QUESTION
toolName == "Exit" -> ToolKind.EXIT
else -> ToolKind.OTHER
}
}
private fun createAskDescriptor(
args: Any,
result: Any?,
projectId: String?
): ToolCallDescriptor {
return ToolCallDescriptor(
kind = ToolKind.ASK_QUESTION,
icon = AllIcons.General.ContextHelp,
titlePrefix = "Clarify Requirements",
titleMain = "",
tooltip = "Ask the user clarifying questions",
supportsStreaming = false,
args = args,
result = result,
projectId = projectId
)
}
private fun createExitDescriptor(
toolName: String,
args: Any,
result: Any?,
projectId: String?
): ToolCallDescriptor {
return ToolCallDescriptor(
kind = ToolKind.EXIT,
icon = AllIcons.Actions.Exit,
titlePrefix = "Exit",
titleMain = "",
tooltip = "Agent task completed",
supportsStreaming = false,
args = args,
result = result,
projectId = projectId
)
}
private fun showTextDialog(content: String, title: String) {
val dialog = JDialog().apply {
this.title = title
isModal = true
}
val textArea = JTextArea(content).apply {
isEditable = false
lineWrap = true
wrapStyleWord = true
font = JBUI.Fonts.smallFont()
}
val scrollPane = JScrollPane(textArea).apply {
preferredSize = JBUI.size(700, 400)
border = JBUI.Borders.customLine(
JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground()
)
}
val footer = JPanel(FlowLayout(FlowLayout.RIGHT)).apply {
isOpaque = false
add(JButton("Copy").apply {
addActionListener {
val selection = StringSelection(content)
Toolkit.getDefaultToolkit().systemClipboard.setContents(
selection,
null
)
}
})
add(JButton("Close").apply { addActionListener { dialog.dispose() } })
}
dialog.contentPane = BorderLayoutPanel().apply {
add(scrollPane, BorderLayout.CENTER)
add(footer, BorderLayout.SOUTH)
}
dialog.pack()
dialog.setLocationRelativeTo(null)
dialog.isVisible = true
}
private fun createReadDescriptor(
args: Any,
result: Any?,
projectId: String?
): ToolCallDescriptor {
val readArgs = args as? ReadTool.Args
val fileName = extractBaseName(readArgs?.filePath ?: "")
val lineBadge = when (result) {
is ReadTool.Result.Success -> Badge("[${result.lineCount} lines]")
else -> null
}
return ToolCallDescriptor(
kind = ToolKind.READ,
icon = AllIcons.FileTypes.Text,
titlePrefix = "Read:",
titleMain = fileName,
tooltip = "Read file: ${readArgs?.filePath ?: ""}",
fileLink = FileLink(
path = readArgs?.filePath ?: "",
displayName = fileName,
enabled = true
),
secondaryBadges = listOfNotNull(lineBadge),
actions = emptyList(),
supportsStreaming = false,
args = args,
result = result,
projectId = projectId
)
}
private fun createWriteDescriptor(
args: Any,
result: Any?,
projectId: String?
): ToolCallDescriptor {
val writeArgs = args as? WriteTool.Args
val fileName = extractBaseName(writeArgs?.filePath ?: "")
val badges = mutableListOf<Badge>()
val actions = mutableListOf<ToolAction>()
if (result is WriteTool.Result && writeArgs != null) {
when (result) {
is WriteTool.Result.Success -> {
badges.add(Badge("${result.bytesWritten} bytes", JBColor.GREEN))
actions.add(
ToolAction("View Changes", AllIcons.Actions.Diff) { component ->
DiffViewAction.showDiff(writeArgs.filePath, component)
}
)
}
is WriteTool.Result.Error -> {
badges.add(Badge("Error", JBColor.RED))
}
}
}
return ToolCallDescriptor(
kind = ToolKind.WRITE,
icon = AllIcons.FileTypes.Text,
titlePrefix = "Write:",
titleMain = fileName,
tooltip = "Write file: ${writeArgs?.filePath ?: ""}",
secondaryBadges = badges,
fileLink = FileLink(
path = writeArgs?.filePath ?: "",
displayName = fileName,
enabled = result != null
),
actions = actions,
supportsStreaming = false,
args = args,
result = result,
projectId = projectId
)
}
private fun createEditDescriptor(
args: Any,
result: Any?,
projectId: String?
): ToolCallDescriptor {
val editArgs = args as? EditTool.Args
val fileName = extractBaseName(editArgs?.filePath ?: "")
val badges = mutableListOf<Badge>()
val actions = mutableListOf<ToolAction>()
if (result is EditTool.Result && editArgs != 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
if (changed > 0) badges.add(Badge("[~$changed]", JBColor.YELLOW))
if (deleted > 0) badges.add(Badge("[-$deleted]", JBColor.RED))
if (inserted > 0) badges.add(Badge("[+$inserted]", JBColor.GREEN))
actions.add(
ToolAction("View Changes", AllIcons.Actions.Diff) { component ->
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)}",
null
)
} catch (_: Exception) {
DiffViewAction.showDiff(editArgs.filePath, component)
}
}
)
}
is EditTool.Result.Error -> {
badges.add(Badge("Error", JBColor.RED))
}
}
}
return ToolCallDescriptor(
kind = ToolKind.EDIT,
icon = AllIcons.Actions.Edit,
titlePrefix = "Edit:",
titleMain = fileName,
tooltip = "Edit file: ${editArgs?.filePath ?: ""}",
secondaryBadges = badges,
fileLink = FileLink(
path = editArgs?.filePath ?: "",
displayName = fileName,
enabled = result != null
),
actions = actions,
supportsStreaming = false,
args = args,
result = result,
projectId = projectId
)
}
private fun replaceFirstNOccurrences(
input: String,
target: String,
replacement: String,
n: Int
): String {
if (n <= 0 || target.isEmpty()) return input
var remaining = n
var idx: Int
val sb = StringBuilder()
var cursor = 0
while (remaining > 0) {
idx = input.indexOf(target, cursor)
if (idx < 0) break
sb.append(input, cursor, idx)
sb.append(replacement)
cursor = idx + target.length
remaining--
}
sb.append(input.substring(cursor))
return sb.toString()
}
private fun createBashDescriptor(
args: Any,
result: Any?,
projectId: String?
): ToolCallDescriptor {
val command = when (args) {
is BashTool.Args -> args.command
is BashOutputTool.Args -> args.bashId
is KillShellTool.Args -> "kill_shell"
else -> "Unknown"
}
val truncatedCommand = truncateCommand(command)
return ToolCallDescriptor(
kind = ToolKind.BASH,
icon = AllIcons.Nodes.Console,
titlePrefix = "Bash:",
titleMain = truncatedCommand,
tooltip = "Command: $command",
supportsStreaming = true,
args = args,
result = result,
projectId = projectId
)
}
private fun createSearchDescriptor(
args: Any,
result: Any?,
projectId: String?
): ToolCallDescriptor {
val searchArgs = args as? IntelliJSearchTool.Args
val pattern = searchArgs?.pattern ?: ""
val scopeOrPath = searchArgs?.path?.substringAfterLast('/') ?: (searchArgs?.scope ?: "")
val titleMain = buildSearchDisplay(truncatePattern(pattern), scopeOrPath)
val badges = mutableListOf<Badge>()
val actions = mutableListOf<ToolAction>()
when (result) {
is IntelliJSearchTool.Result -> {
badges.add(Badge("${result.totalMatches} matches", JBColor.BLUE))
if (result.output.isNotBlank()) {
actions.add(
ToolAction("View Results", AllIcons.Actions.Find) { _ ->
showTextDialog(result.output, "Search Results")
}
)
}
}
is String -> {
actions.add(
ToolAction("View Results", AllIcons.Actions.Find) { _ ->
showTextDialog(result, "Search Results")
}
)
}
}
return ToolCallDescriptor(
kind = ToolKind.SEARCH,
icon = AllIcons.Actions.Search,
titlePrefix = "Search:",
titleMain = titleMain,
tooltip = buildTooltipString("Search", pattern, scopeOrPath.ifBlank { null }),
secondaryBadges = badges,
actions = actions,
supportsStreaming = false,
args = args,
result = result,
projectId = projectId
)
}
private fun createWebDescriptor(
args: Any,
result: Any?,
projectId: String?
): ToolCallDescriptor {
val query = when {
args is WebSearchTool.Args -> args.query
else -> "Unknown"
}
val truncatedQuery = truncateQuery(query)
return ToolCallDescriptor(
kind = ToolKind.WEB,
icon = AllIcons.General.Web,
titlePrefix = "Web:",
titleMain = truncatedQuery,
tooltip = "Web search: $query",
supportsStreaming = false,
args = args,
result = result,
projectId = projectId
)
}
private fun createTaskDescriptor(
args: Any,
result: Any?,
projectId: String?
): ToolCallDescriptor {
val description = when (args) {
is TaskTool.Args -> args.description
is TodoWriteTool.Args -> todoWriteLabel(args)
is JsonObject -> extractTodoWriteLabel(args) ?: "Unknown"
else -> "Unknown"
}
val titlePrefix: String
val prefixColor: JBColor?
if (args is TaskTool.Args) {
val subagentType = args.subagentType
titlePrefix = "[$subagentType]"
prefixColor = getSubagentColor(subagentType)
} else {
titlePrefix = "Task:"
prefixColor = null
}
return ToolCallDescriptor(
kind = ToolKind.TASK,
icon = AllIcons.Actions.RunAnything,
titlePrefix = titlePrefix,
titleMain = description,
tooltip = "Task: $description",
secondaryBadges = emptyList(),
supportsStreaming = false,
args = args,
result = result,
projectId = projectId,
prefixColor = prefixColor
)
}
private fun getSubagentColor(subagentType: String): JBColor {
val hue = subagentType.hashCode().absoluteValue % 360
val hueNormalized = hue.toFloat() / 360f
val lightRgb = hslToRgb(hueNormalized, 0.75f, 0.45f)
val lightColor = Color(lightRgb[0], lightRgb[1], lightRgb[2])
val darkRgb = hslToRgb(hueNormalized, 0.70f, 0.70f)
val darkColor = Color(darkRgb[0], darkRgb[1], darkRgb[2])
return JBColor(lightColor, darkColor)
}
private fun hslToRgb(h: Float, s: Float, l: Float): IntArray {
val r: Float
val g: Float
val b: Float
if (s == 0f) {
r = l
g = l
b = l
} else {
val q = if (l < 0.5f) l * (1 + s) else l + s - l * s
val p = 2 * l - q
r = hueToRgb(p, q, h + 1f / 3f)
g = hueToRgb(p, q, h)
b = hueToRgb(p, q, h - 1f / 3f)
}
return intArrayOf((r * 255).toInt(), (g * 255).toInt(), (b * 255).toInt())
}
private fun hueToRgb(p: Float, q: Float, t: Float): Float {
var t = t
if (t < 0f) t += 1f
if (t > 1f) t -= 1f
if (t < 1f / 6f) return p + (q - p) * 6 * t
if (t < 1f / 2f) return q
if (t < 2f / 3f) return p + (q - p) * (2f / 3f - t) * 6f
return p
}
private fun todoWriteLabel(args: TodoWriteTool.Args): String {
val inProgress =
args.todos.firstOrNull { it.status == TodoWriteTool.TodoStatus.IN_PROGRESS }
if (inProgress != null && inProgress.activeForm.isNotBlank()) {
return inProgress.activeForm
}
val completed = args.todos.firstOrNull { it.status == TodoWriteTool.TodoStatus.COMPLETED }
if (completed != null && completed.content.isNotBlank()) {
return "Marking task done: ${completed.content}"
}
if (args.title.isNotBlank()) return "Updating todo list: ${args.title}"
return "Updating todo list"
}
private fun extractTodoWriteLabel(args: JsonObject): String? {
val todos = args["todos"] as? JsonArray ?: return null
val inProgress =
todos.firstOrNull { it.stringValue("status")?.equals("in_progress", true) == true }
val inProgressLabel = inProgress?.stringValue("activeForm")?.trim().orEmpty()
if (inProgressLabel.isNotBlank()) return inProgressLabel
val completed =
todos.firstOrNull { it.stringValue("status")?.equals("completed", true) == true }
val completedLabel = completed?.stringValue("content")?.trim().orEmpty()
if (completedLabel.isNotBlank()) return "Marking task done: $completedLabel"
val title = args.stringValue("title")?.trim().orEmpty()
if (title.isNotBlank()) return "Updating todo list: $title"
return "Updating todo list"
}
private fun JsonObject.stringValue(key: String): String? =
(this[key] as? JsonPrimitive)?.contentOrNull
private fun JsonArray.firstOrNull(predicate: (JsonObject) -> Boolean): JsonObject? {
for (element in this) {
val obj = element as? JsonObject ?: continue
if (predicate(obj)) return obj
}
return null
}
private fun createLibraryResolveDescriptor(
args: Any,
result: Any?,
projectId: String?
): ToolCallDescriptor {
val libraryName = when {
args is ResolveLibraryIdTool.Args -> args.libraryName
else -> "Unknown"
}
val badges = mutableListOf<Badge>()
if (result is ResolveLibraryIdTool.Result.Success) {
badges.add(Badge("${result.libraries.size} found", JBColor.BLUE))
}
return ToolCallDescriptor(
kind = ToolKind.LIBRARY_RESOLVE,
icon = AllIcons.General.Web,
titlePrefix = "Library:",
titleMain = libraryName,
tooltip = "Resolve library: $libraryName",
secondaryBadges = badges,
supportsStreaming = false,
args = args,
result = result,
projectId = projectId
)
}
private fun createLibraryDocsDescriptor(
args: Any,
result: Any?,
projectId: String?
): ToolCallDescriptor {
val libraryId = when {
args is GetLibraryDocsTool.Args -> args.context7CompatibleLibraryID
else -> "Unknown"
}
return ToolCallDescriptor(
kind = ToolKind.LIBRARY_DOCS,
icon = AllIcons.General.Web,
titlePrefix = "Docs:",
titleMain = libraryId,
tooltip = "Get library docs: $libraryId",
supportsStreaming = false,
args = args,
result = result,
projectId = projectId
)
}
private fun createOtherDescriptor(
toolName: String,
args: Any,
result: Any?,
projectId: String?
): ToolCallDescriptor {
return ToolCallDescriptor(
kind = ToolKind.OTHER,
icon = AllIcons.Actions.Help,
titlePrefix = "Tool:",
titleMain = toolName,
tooltip = "Tool: $toolName",
supportsStreaming = false,
args = args,
result = result,
projectId = projectId
)
}
private fun extractBaseName(filePath: String): String {
return filePath.substringAfterLast('/')
}
private fun truncatePattern(pattern: String): String {
return if (pattern.length > AgentUiConfig.GREP_PATTERN_MAX) {
pattern.take(AgentUiConfig.GREP_PATTERN_MAX) + "..."
} else {
pattern
}
}
private fun truncateQuery(query: String): String {
return if (query.length > AgentUiConfig.WEB_QUERY_MAX) {
query.take(AgentUiConfig.WEB_QUERY_MAX) + "..."
} else {
query
}
}
private fun truncateCommand(command: String): String {
return if (command.length > AgentUiConfig.BASH_CMD_MAX) {
command.take(AgentUiConfig.BASH_CMD_MAX) + "..."
} else {
command
}
}
private fun buildSearchDisplay(pattern: String, scope: String?): String {
return if (scope.isNullOrBlank()) {
"\"$pattern\""
} else {
"\"$pattern\" in $scope"
}
}
private fun buildTooltipString(operation: String, pattern: String, scope: String?): String {
return if (scope.isNullOrBlank()) {
"$operation: \"$pattern\""
} else {
"$operation: \"$pattern\" in $scope"
}
}
}

View file

@ -0,0 +1,444 @@
package ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor
import com.intellij.ide.actions.OpenFileAction
import com.intellij.openapi.project.Project
import com.intellij.openapi.project.ProjectManager
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.ui.components.ActionLink
import com.intellij.ui.components.JBLabel
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 java.awt.BorderLayout
import java.awt.FlowLayout
import java.awt.Font
import java.awt.Toolkit
import java.awt.datatransfer.StringSelection
import javax.swing.*
class ToolCallView(
private var descriptor: ToolCallDescriptor
) : JBPanel<ToolCallView>() {
private var headerPanel = ToolCallHeaderPanel(descriptor)
private val streamingPanel = ToolCallStreamingPanel()
init {
layout = BorderLayout()
isOpaque = false
border = JBUI.Borders.empty()
add(headerPanel, BorderLayout.NORTH)
add(streamingPanel, BorderLayout.CENTER)
}
fun complete(success: Boolean, result: Any?) {
headerPanel.updateCompletionStatus(result)
when (result) {
is AskUserQuestionTool.Result.Success -> {
val compactLines = result.answers.entries.map { (k, v) -> "$k: $v" }
streamingPanel.showCompactInfo(compactLines)
}
else -> streamingPanel.onCompletion()
}
}
fun appendStreamingLine(text: String, isError: Boolean) {
if (descriptor.supportsStreaming) {
streamingPanel.appendLine(text, isError)
}
}
fun getDescriptor(): ToolCallDescriptor = descriptor
fun refreshDescriptor(newDescriptor: ToolCallDescriptor) {
this.descriptor = newDescriptor
remove(headerPanel)
headerPanel = ToolCallHeaderPanel(descriptor)
add(headerPanel, BorderLayout.NORTH)
revalidate()
repaint()
}
}
private class ToolCallHeaderPanel(
private val descriptor: ToolCallDescriptor
) : JBPanel<ToolCallHeaderPanel>() {
private val leftRow = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { isOpaque = false }
private var fileLink: ActionLink? = null
init {
layout = BorderLayout()
isOpaque = false
buildHeaderContent()
add(leftRow, BorderLayout.WEST)
}
private fun buildHeaderContent() {
val prefixLabel = JBLabel(
if (descriptor.titlePrefix.isNotEmpty()) "${descriptor.titlePrefix} " else "",
descriptor.icon,
SwingConstants.LEFT
).withFont(JBFont.label()).apply {
descriptor.prefixColor?.let { color ->
foreground = color
}
}
leftRow.add(prefixLabel)
when {
descriptor.fileLink != null -> addFileLink()
else -> addRegularContent()
}
addSecondaryBadges()
if (descriptor.actions.isNotEmpty()) {
addActionLinks()
}
}
private fun addFileLink() {
val fileLink = descriptor.fileLink!!
val link = ActionLink(fileLink.displayName) {
val project = getProject()
if (project != null) {
val vf = LocalFileSystem.getInstance().findFileByPath(fileLink.path)
if (vf != null) OpenFileAction.openFile(vf, project)
}
}.apply {
toolTipText = fileLink.path
setExternalLinkIcon()
isEnabled = fileLink.enabled
}
this.fileLink = link
leftRow.add(link)
}
private fun addRegularContent() {
val content = JBLabel(descriptor.titleMain)
if (!descriptor.tooltip.isNullOrBlank()) {
content.toolTipText = descriptor.tooltip
}
leftRow.add(content)
// Show IntelliJSearch parameter chips for compact rows for parity with main cards
addSearchParametersIfAny()
}
private fun addSecondaryBadges() {
descriptor.secondaryBadges.forEach { badge ->
val isMatchesBadge = badge.text.contains("matches", ignoreCase = true)
if (isMatchesBadge && descriptor.result != null) {
val link = ActionLink(badge.text) {
val content = when (val res = descriptor.result) {
is IntelliJSearchTool.Result -> res.output
is String -> res
else -> null
}
content?.let { showSearchResultsDialog(it) }
}.apply {
font = JBUI.Fonts.smallFont()
}
leftRow.add(link.apply { border = JBUI.Borders.emptyLeft(4) })
} else {
leftRow.add(JBLabel(badge.text).withFont(JBFont.small()).apply {
foreground = badge.color
if (badge.tooltip != null) {
toolTipText = badge.tooltip
}
border = JBUI.Borders.emptyLeft(4)
})
}
}
}
private fun showSearchResultsDialog(content: String) {
val dialog = JDialog().apply {
title = "Search Results"
isModal = true
}
val textArea = JTextArea(content).apply {
isEditable = false
lineWrap = true
wrapStyleWord = true
font = JBUI.Fonts.smallFont()
}
val scrollPane = JScrollPane(textArea).apply {
preferredSize = JBUI.size(700, 400)
border =
JBUI.Borders.customLine(JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground())
}
val footerPanel = JPanel(FlowLayout(FlowLayout.RIGHT)).apply {
isOpaque = false
add(JButton("Copy").apply {
addActionListener {
val selection = StringSelection(content)
Toolkit.getDefaultToolkit().systemClipboard.setContents(
selection,
null
)
}
})
add(JButton("Close").apply {
addActionListener { dialog.dispose() }
})
}
dialog.contentPane = BorderLayoutPanel().apply {
add(scrollPane, BorderLayout.CENTER)
add(footerPanel, BorderLayout.SOUTH)
}
dialog.pack()
dialog.setLocationRelativeTo(null)
dialog.isVisible = true
}
fun updateCompletionStatus(result: Any?) {
descriptor.fileLink?.let { fileLink ->
if (!fileLink.enabled && result != null) {
this.fileLink?.isEnabled = true
}
}
revalidate()
repaint()
}
private fun addActionLinks() {
descriptor.actions.forEach { action ->
val link = ActionLink("[${action.name}]") { action.action(this@ToolCallHeaderPanel) }.apply {
font = JBFont.small()
}
leftRow.add(JBLabel(" "))
leftRow.add(link)
}
}
private fun addSearchParametersIfAny() {
val args = descriptor.args
if (args is IntelliJSearchTool.Args) {
val params = buildList {
if (args.caseSensitive == true) add("case")
if (args.regex == true) add("regex")
if (args.wholeWords == true) add("words")
args.context?.let { c ->
if (c.isNotBlank() && !c.equals(
"ANY",
true
)
) add("context: $c")
}
args.fileType?.let { ft -> if (ft.isNotBlank()) add("type: $ft") }
args.outputMode?.let { om ->
if (om.isNotBlank() && !om.equals(
"content",
true
)
) add("out: $om")
}
args.limit?.let { lim -> add("limit: $lim") }
}.joinToString(" · ")
if (params.isNotBlank()) {
leftRow.add(JBLabel(" ($params)"))
}
}
}
private fun getProject(): Project? {
return descriptor.projectId?.let { projectId ->
ProjectManager.getInstance().openProjects.find { it.locationHash == projectId }
} ?: ProjectManager.getInstance().openProjects.firstOrNull()
}
}
/**
* Handles streaming output display.
*/
private class ToolCallStreamingPanel : JBPanel<ToolCallStreamingPanel>() {
private val contentPanel = JBPanel<JBPanel<*>>().apply {
isOpaque = false
layout = FlowLayout(FlowLayout.LEFT, 0, 0)
border = JBUI.Borders.empty(2, 20, 0, 0)
isVisible = false
}
private val streamingLabel = JBLabel("")
private val actionsPanel = JPanel(FlowLayout(FlowLayout.LEFT, 6, 0)).apply {
isOpaque = false
border = JBUI.Borders.emptyLeft(12)
add(ActionLink("Show details") { showDetailsDialog() }.apply {
font = JBUI.Fonts.smallFont()
})
add(ActionLink("Copy") { copyToClipboard() }.apply {
font = JBUI.Fonts.smallFont()
})
isVisible = false
}
private val streamingTail: ArrayDeque<Pair<String, Boolean>> = ArrayDeque()
private val streamingAllLines: ArrayDeque<Pair<String, Boolean>> = ArrayDeque()
companion object {
private const val MAX_TAIL_LINES = 3
private const val MAX_DETAIL_LINES = 500
}
init {
layout = BorderLayout()
isOpaque = false
add(contentPanel, BorderLayout.CENTER)
add(actionsPanel, BorderLayout.SOUTH)
}
fun showCompactInfo(lines: List<String>) {
if (lines.isEmpty()) {
contentPanel.isVisible = false
actionsPanel.isVisible = false
return
}
val html = buildString {
append("<html><body style='color:#808080'>")
lines.forEachIndexed { i, line ->
val safe = sanitizeHtml(line.take(300))
append(safe)
if (i < lines.size - 1) append("<br>")
}
append("</body></html>")
}
streamingLabel.font = JBUI.Fonts.smallFont()
streamingLabel.text = html
contentPanel.removeAll()
contentPanel.add(streamingLabel)
contentPanel.isVisible = true
actionsPanel.isVisible = false
revalidate()
repaint()
}
fun appendLine(text: String, isError: Boolean) {
if (text.isBlank()) return
streamingTail.addLast(text to isError)
while (streamingTail.size > MAX_TAIL_LINES) {
streamingTail.removeFirst()
}
streamingAllLines.addLast(text to isError)
while (streamingAllLines.size > MAX_DETAIL_LINES) {
streamingAllLines.removeFirst()
}
updateDisplay()
}
fun onCompletion() {
contentPanel.isVisible = streamingTail.isNotEmpty()
revalidate()
repaint()
}
private fun updateDisplay() {
val html = buildTailHtml()
val font = JBFont.create(Font(Font.MONOSPACED, Font.PLAIN, JBUI.Fonts.smallFont().size))
streamingLabel.font = font
streamingLabel.text = html
contentPanel.removeAll()
contentPanel.add(streamingLabel)
contentPanel.isVisible = true
actionsPanel.isVisible = true
revalidate()
repaint()
}
private fun buildTailHtml(): String {
return buildString {
append("<html><body style='width: 400px'>")
streamingTail.forEachIndexed { idx, (line, isError) ->
val safeText = sanitizeHtml(line.take(200))
val color = if (isError) "#ff5555" else "#808080"
append("<span style='color:$color'>$safeText</span>")
if (idx < streamingTail.size - 1) append("<br>")
}
append("</body></html>")
}
}
private fun sanitizeHtml(text: String): String {
return text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
}
private fun copyToClipboard() {
val content = buildString {
streamingAllLines.forEachIndexed { i, (line, _) ->
append(line)
if (i < streamingAllLines.size - 1) append('\n')
}
}
val selection = StringSelection(content)
Toolkit.getDefaultToolkit().systemClipboard.setContents(selection, null)
}
private fun showDetailsDialog() {
val dialog = JDialog().apply {
title = "Streaming Output"
isModal = true
}
val content = buildString {
streamingAllLines.forEachIndexed { i, (line, _) ->
append(line)
if (i < streamingAllLines.size - 1) append('\n')
}
}
val textArea = JTextArea(content).apply {
isEditable = false
lineWrap = true
wrapStyleWord = true
font = JBUI.Fonts.smallFont()
}
val scrollPane = JScrollPane(textArea).apply {
preferredSize = JBUI.size(700, 400)
border =
JBUI.Borders.customLine(JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground())
}
val footerPanel = JPanel(FlowLayout(FlowLayout.RIGHT)).apply {
isOpaque = false
add(JButton("Copy").apply {
addActionListener { copyToClipboard() }
})
add(JButton("Close").apply {
addActionListener { dialog.dispose() }
})
}
dialog.contentPane = BorderLayoutPanel().apply {
add(scrollPane, BorderLayout.CENTER)
add(footerPanel, BorderLayout.SOUTH)
}
dialog.pack()
dialog.setLocationRelativeTo(null)
dialog.isVisible = true
}
}

Some files were not shown because too many files have changed in this diff Show more