feat: inline edit
Some checks failed
Build / Build (push) Has been cancelled
Build / Verify Plugin (push) Has been cancelled

This commit is contained in:
Carl-Robert Linnupuu 2025-09-16 01:38:17 +01:00
parent 80ba956c5e
commit fdfde4243d
55 changed files with 4274 additions and 696 deletions

View file

@ -94,7 +94,7 @@ git submodule update
**Tailing logs**
```shell
tail -f build/idea-sandbox/system/log/idea.log
tail -f build/idea-sandbox/IC-2024.1.2/log/idea.log
```
## Privacy

View file

@ -1,11 +1,14 @@
package ee.carlrobert.codegpt;
import com.intellij.openapi.util.Key;
import ee.carlrobert.codegpt.inlineedit.InlineEditSession;
import ee.carlrobert.codegpt.inlineedit.InlineEditInlayRenderer;
import ee.carlrobert.codegpt.predictions.CodeSuggestionDiffViewer;
import ee.carlrobert.codegpt.toolwindow.chat.editor.ToolWindowEditorFileDetails;
import ee.carlrobert.llm.client.codegpt.CodeGPTUserDetails;
import ee.carlrobert.service.NextEditResponse;
import ee.carlrobert.service.PartialCodeCompletionResponse;
import javax.swing.JComponent;
public class CodeGPTKeys {
@ -21,6 +24,12 @@ public class CodeGPTKeys {
Key.create("codegpt.isPromptTextFieldDocument");
public static final Key<CodeSuggestionDiffViewer> EDITOR_PREDICTION_DIFF_VIEWER =
Key.create("codegpt.editorPredictionDiffViewer");
public static final Key<InlineEditSession> EDITOR_INLINE_EDIT_SESSION =
Key.create("codegpt.editorInlineEditSession");
public static final Key<InlineEditInlayRenderer> EDITOR_INLINE_EDIT_RENDERER =
Key.create("codegpt.editorInlineEditRenderer");
public static final Key<JComponent> EDITOR_INLINE_EDIT_COMPARE_LINK =
Key.create("codegpt.editorInlineEditCompareLink");
public static final Key<PartialCodeCompletionResponse> REMAINING_CODE_COMPLETION =
Key.create("codegpt.remainingCodeCompletion");
public static final Key<NextEditResponse> REMAINING_PREDICTION_RESPONSE =

View file

@ -37,17 +37,15 @@ public abstract class BaseEditorAction extends AnAction {
var project = event.getProject();
var editor = event.getData(PlatformDataKeys.EDITOR);
if (editor != null && project != null) {
actionPerformed(project, editor, editor.getSelectionModel().getSelectedText());
var selectedText = editor.getSelectionModel().getSelectedText();
actionPerformed(project, editor, selectedText != null ? selectedText : "");
}
}
public void update(AnActionEvent event) {
Project project = event.getProject();
Editor editor = event.getData(PlatformDataKeys.EDITOR);
boolean menuAllowed = false;
if (editor != null && project != null) {
menuAllowed = editor.getSelectionModel().getSelectedText() != null;
}
boolean menuAllowed = editor != null && project != null;
event.getPresentation().setEnabled(menuAllowed);
}

View file

@ -93,15 +93,15 @@ public final class CompletionRequestService {
return getChatCompletionAsync(request, eventListener, serviceType, FeatureType.COMMIT_MESSAGE);
}
public EventSource getEditCodeCompletionAsync(
EditCodeCompletionParameters params,
public EventSource getInlineEditCompletionAsync(
InlineEditCompletionParameters params,
CompletionEventListener<String> eventListener) {
var serviceType =
ModelSelectionService.getInstance().getServiceForFeature(FeatureType.EDIT_CODE);
ModelSelectionService.getInstance().getServiceForFeature(FeatureType.INLINE_EDIT);
var request = CompletionRequestFactory
.getFactory(serviceType)
.createEditCodeRequest(params);
return getChatCompletionAsync(request, eventListener, serviceType, FeatureType.EDIT_CODE);
.createInlineEditRequest(params);
return getChatCompletionAsync(request, eventListener, serviceType, FeatureType.INLINE_EDIT);
}
public EventSource getChatCompletionAsync(

View file

@ -5,7 +5,7 @@ public enum FeatureType {
CODE_COMPLETION,
AUTO_APPLY,
COMMIT_MESSAGE,
EDIT_CODE,
INLINE_EDIT,
NEXT_EDIT,
LOOKUP
}

View file

@ -105,9 +105,12 @@ public class ChatToolWindowTabPanel implements Disposable {
project,
totalTokensPanel,
this,
FeatureType.CHAT,
tagManager,
this::handleSubmit,
this::handleCancel);
this::handleCancel,
true,
true);
userInputPanel.requestFocus();
rootPanel = createRootPanel();

View file

@ -5,6 +5,7 @@ import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.ReadAction
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.progress.ProcessCanceledException
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.vfs.AsyncFileListener
@ -181,8 +182,11 @@ class PsiStructureRepository(
coroutineContext.ensureActive()
try {
PsiManager.getInstance(project).findFile(virtualFile)
} catch (exc: Exception) {
logger.warn("Failed to find file ${virtualFile.name}", exc)
} catch (ex: Exception) {
if (ex is ProcessCanceledException) {
throw ex
}
logger.warn("Failed to find file ${virtualFile.name}", ex)
null
}
}
@ -289,4 +293,4 @@ class PsiStructureRepository(
}
}
.toSet()
}
}

View file

@ -66,12 +66,13 @@ public class ModelComboBoxAction extends ComboBoxAction {
private final Project project;
private final List<ServiceType> availableProviders;
private final boolean showConfigureModels;
private final FeatureType featureType;
public ModelComboBoxAction(
Project project,
Consumer<ServiceType> onModelChange,
ServiceType selectedService) {
this(project, onModelChange, selectedService, Arrays.asList(ServiceType.values()), true);
this(project, onModelChange, selectedService, Arrays.asList(ServiceType.values()), true, FeatureType.CHAT);
}
public ModelComboBoxAction(
@ -80,10 +81,21 @@ public class ModelComboBoxAction extends ComboBoxAction {
ServiceType selectedProvider,
List<ServiceType> availableProviders,
boolean showConfigureModels) {
this(project, onModelChange, selectedProvider, availableProviders, showConfigureModels, FeatureType.CHAT);
}
public ModelComboBoxAction(
Project project,
Consumer<ServiceType> onModelChange,
ServiceType selectedProvider,
List<ServiceType> availableProviders,
boolean showConfigureModels,
FeatureType featureType) {
this.project = project;
this.onModelChange = onModelChange;
this.availableProviders = availableProviders;
this.showConfigureModels = showConfigureModels;
this.featureType = featureType;
setSmallVariant(true);
updateTemplatePresentation(selectedProvider);
@ -92,8 +104,12 @@ public class ModelComboBoxAction extends ComboBoxAction {
ModelChangeNotifier.getTopic(),
new ModelChangeNotifierAdapter() {
@Override
public void chatModelChanged(@NotNull String newModel, @NotNull ServiceType serviceType) {
updateTemplatePresentation(serviceType);
public void modelChanged(@NotNull FeatureType changedFeature,
@NotNull String newModel,
@NotNull ServiceType serviceType) {
if (changedFeature == featureType) {
updateTemplatePresentation(serviceType);
}
}
});
}
@ -275,12 +291,13 @@ public class ModelComboBoxAction extends ComboBoxAction {
var application = ApplicationManager.getApplication();
var templatePresentation = getTemplatePresentation();
var chatModel = application.getService(ModelSettings.class).getState()
.getModelSelection(FeatureType.CHAT);
.getModelSelection(featureType);
var modelCode = chatModel != null ? chatModel.getModel() : null;
switch (selectedService) {
case PROXYAI:
var proxyAIModel = ModelRegistry.getInstance().getProxyAIChatModels().stream()
var proxyAIModel = ModelRegistry.getInstance().getAllModelsForFeature(featureType).stream()
.filter(it -> it.getProvider() == PROXYAI)
.filter(it -> modelCode != null && it.getModel().equals(modelCode))
.findFirst();
templatePresentation.setIcon(
@ -391,7 +408,7 @@ public class ModelComboBoxAction extends ComboBoxAction {
var application = ApplicationManager.getApplication();
application
.getService(ModelSettings.class)
.setModel(FeatureType.CHAT, model.getModel(), PROXYAI);
.setModel(featureType, model.getModel(), PROXYAI);
handleModelChange(PROXYAI);
});
@ -420,7 +437,7 @@ public class ModelComboBoxAction extends ComboBoxAction {
.setModel(model);
application
.getService(ModelSettings.class)
.setModel(FeatureType.CHAT, model, OLLAMA);
.setModel(featureType, model, OLLAMA);
});
}
@ -434,7 +451,7 @@ public class ModelComboBoxAction extends ComboBoxAction {
Icons.OpenAI,
comboBoxPresentation,
() -> ApplicationManager.getApplication().getService(ModelSettings.class)
.setModel(FeatureType.CHAT, model.getCode(), OPENAI));
.setModel(featureType, model.getCode(), OPENAI));
}
private AnAction createCustomOpenAIModelAction(
@ -446,7 +463,7 @@ public class ModelComboBoxAction extends ComboBoxAction {
Icons.OpenAI,
comboBoxPresentation,
() -> ApplicationManager.getApplication().getService(ModelSettings.class)
.setModel(FeatureType.CHAT, model.getName(), CUSTOM_OPENAI));
.setModel(featureType, model.getName(), CUSTOM_OPENAI));
}
private AnAction createGoogleModelAction(GoogleModel model, Presentation comboBoxPresentation) {
@ -457,7 +474,7 @@ public class ModelComboBoxAction extends ComboBoxAction {
Icons.Google,
comboBoxPresentation,
() -> ApplicationManager.getApplication().getService(ModelSettings.class)
.setModel(FeatureType.CHAT, model.getCode(), GOOGLE));
.setModel(featureType, model.getCode(), GOOGLE));
}
private AnAction createAnthropicModelAction(
@ -470,7 +487,7 @@ public class ModelComboBoxAction extends ComboBoxAction {
Icons.Anthropic,
comboBoxPresentation,
() -> ApplicationManager.getApplication().getService(ModelSettings.class)
.setModel(FeatureType.CHAT, modelCode, ANTHROPIC));
.setModel(featureType, modelCode, ANTHROPIC));
}
private AnAction createLlamaModelAction(Presentation comboBoxPresentation) {
@ -480,7 +497,7 @@ public class ModelComboBoxAction extends ComboBoxAction {
Icons.Llama,
comboBoxPresentation,
() -> ApplicationManager.getApplication().getService(ModelSettings.class)
.setModel(FeatureType.CHAT,
.setModel(featureType,
LlamaSettings.getCurrentState().getHuggingFaceModel().getCode(), LLAMA_CPP));
}
@ -492,12 +509,12 @@ public class ModelComboBoxAction extends ComboBoxAction {
Icons.Mistral,
comboBoxPresentation,
() -> ApplicationManager.getApplication().getService(ModelSettings.class)
.setModel(FeatureType.CHAT, modelCode, MISTRAL));
.setModel(featureType, modelCode, MISTRAL));
}
private String getMistralPresentationText() {
var chatModel = ApplicationManager.getApplication().getService(ModelSettings.class).getState()
.getModelSelection(FeatureType.CHAT);
.getModelSelection(featureType);
var modelCode = chatModel != null ? chatModel.getModel() : null;
return ModelRegistry.getInstance().getModelDisplayName(MISTRAL, modelCode);
}

View file

@ -56,4 +56,4 @@ class CodeGPTEditorFactoryListener : EditorFactoryListener {
.syncPublisher(EditorNotifier.Released.TOPIC)
.editorReleased(event.editor)
}
}
}

View file

@ -7,9 +7,7 @@ import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.startup.ProjectActivity
import ee.carlrobert.codegpt.actions.editor.EditorActionsUtil
import ee.carlrobert.codegpt.settings.GeneralSettings
import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings
import ee.carlrobert.codegpt.settings.service.ServiceType
import ee.carlrobert.codegpt.settings.service.codegpt.CodeGPTService
import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.AttachImageNotifier
import ee.carlrobert.codegpt.ui.OverlayUtil

View file

@ -5,24 +5,34 @@ import com.intellij.openapi.actionSystem.ActionPromoter
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.CommonDataKeys
import com.intellij.openapi.actionSystem.DataContext
import ee.carlrobert.codegpt.CodeGPTKeys
import ee.carlrobert.codegpt.codecompletions.AcceptNextLineInlayAction
import ee.carlrobert.codegpt.codecompletions.AcceptNextWordInlayAction
import ee.carlrobert.codegpt.predictions.OpenPredictionAction
import ee.carlrobert.codegpt.predictions.TriggerCustomPredictionAction
import ee.carlrobert.codegpt.inlineedit.AcceptCurrentInlineEditAction
import ee.carlrobert.codegpt.actions.editor.AcceptInlineEditAction
import ee.carlrobert.codegpt.inlineedit.RejectCurrentInlineEditAction
class InlayActionPromoter : ActionPromoter {
override fun promote(actions: List<AnAction>, context: DataContext): List<AnAction> {
val editor = CommonDataKeys.EDITOR.getData(context) ?: return emptyList()
val hasInlineEdit = editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION) != null ||
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER) != null
if (hasInlineEdit) {
actions.filterIsInstance<AcceptInlineEditAction>().takeIf { it.isNotEmpty() }?.let { return it }
actions.filterIsInstance<AcceptCurrentInlineEditAction>().takeIf { it.isNotEmpty() }?.let { return it }
actions.filterIsInstance<RejectCurrentInlineEditAction>().takeIf { it.isNotEmpty() }?.let { return it }
}
actions.filterIsInstance<TriggerCustomPredictionAction>().takeIf { it.isNotEmpty() }?.let { return it }
actions.filterIsInstance<OpenPredictionAction>().takeIf { it.isNotEmpty() }?.let { return it }
if (InlineCompletionContext.getOrNull(editor) == null) {
return emptyList()
}
if (InlineCompletionContext.getOrNull(editor) == null) return emptyList()
actions.filterIsInstance<AcceptNextWordInlayAction>().takeIf { it.isNotEmpty() }?.let { return it }
actions.filterIsInstance<AcceptNextLineInlayAction>().takeIf { it.isNotEmpty() }?.let { return it }
return emptyList()
}
}
}

View file

@ -0,0 +1,37 @@
package ee.carlrobert.codegpt.actions.editor
import com.intellij.codeInsight.hint.HintManagerImpl
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.editor.Caret
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.actionSystem.EditorAction
import com.intellij.openapi.editor.actionSystem.EditorWriteActionHandler
import ee.carlrobert.codegpt.CodeGPTKeys
class AcceptInlineEditAction : EditorAction(Handler()), HintManagerImpl.ActionToIgnore {
companion object {
const val ID = "codegpt.acceptInlineEdit"
}
private class Handler : EditorWriteActionHandler() {
override fun doExecute(editor: Editor, caret: Caret?, dataContext: DataContext?) {
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)?.let { session ->
session.acceptNearestToCaret()
return
}
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER)?.acceptNext()
}
override fun isEnabledForCaret(
editor: Editor,
caret: Caret,
dataContext: DataContext
): Boolean {
return editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION) != null ||
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER) != null
}
}
}

View file

@ -1,143 +0,0 @@
package ee.carlrobert.codegpt.actions.editor
import com.intellij.ide.BrowserUtil
import com.intellij.notification.NotificationAction
import com.intellij.notification.NotificationType
import com.intellij.openapi.application.runInEdt
import com.intellij.openapi.application.runUndoTransparentWriteAction
import com.intellij.openapi.command.WriteCommandAction.runWriteCommandAction
import com.intellij.openapi.components.service
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.markup.*
import com.intellij.openapi.util.TextRange
import com.intellij.openapi.util.text.StringUtil
import com.intellij.psi.PsiDocumentManager
import com.intellij.psi.codeStyle.CodeStyleManager
import com.intellij.ui.JBColor
import ee.carlrobert.codegpt.codecompletions.CompletionProgressNotifier
import ee.carlrobert.codegpt.util.ThinkingOutputParser
import ee.carlrobert.codegpt.ui.ObservableProperties
import ee.carlrobert.codegpt.ui.OverlayUtil
import ee.carlrobert.llm.client.openai.completion.ErrorDetails
import ee.carlrobert.llm.completion.CompletionEventListener
import okhttp3.sse.EventSource
class EditCodeCompletionListener(
private val editor: Editor,
private val observableProperties: ObservableProperties,
private val selectionTextRange: TextRange
) : CompletionEventListener<String> {
private var replacedLength = 0
private var currentHighlighter: RangeHighlighter? = null
private val thinkingOutputParser = ThinkingOutputParser()
override fun onMessage(message: String, eventSource: EventSource) {
val processedChunk = thinkingOutputParser.processChunk(message)
if (processedChunk.isNotEmpty() && thinkingOutputParser.isFinished) {
runInEdt { handleDiff(processedChunk) }
}
}
override fun onComplete(messageBuilder: StringBuilder) {
runInEdt {
if (replacedLength == 0 && messageBuilder.isNotEmpty()) {
handleDiff(messageBuilder.toString())
}
cleanupAndFormat()
}
stopLoading()
}
override fun onError(error: ErrorDetails, ex: Throwable) {
OverlayUtil.showNotification(
error.message,
NotificationType.ERROR,
NotificationAction.createSimpleExpiring("Upgrade plan") {
BrowserUtil.open("https://tryproxy.io/#pricing")
},
)
stopLoading()
}
private fun stopLoading() {
observableProperties.loading.set(false)
editor.project?.let {
CompletionProgressNotifier.update(it, false)
}
}
private fun updateHighlighter(editor: Editor) {
cleanupHighlighter()
val document = editor.document
val lineNumber = document.getLineNumber(editor.caretModel.offset)
currentHighlighter = editor.markupModel.addRangeHighlighter(
document.getLineStartOffset(lineNumber),
document.getLineEndOffset(lineNumber),
HighlighterLayer.SELECTION - 1,
TextAttributes().apply {
effectType = EffectType.BOXED
effectColor =
JBColor.namedColor("PsiViewer.referenceHighlightColor", 0x6A7B15)
errorStripeColor = effectColor
},
HighlighterTargetArea.EXACT_RANGE
)
}
private fun handleDiff(message: String) {
val document = editor.document
val startOffset = selectionTextRange.startOffset
val endOffset = selectionTextRange.endOffset
runUndoTransparentWriteAction {
val remainingOriginalLength = endOffset - (startOffset + replacedLength)
if (remainingOriginalLength > 0) {
document.replaceString(
startOffset + replacedLength,
startOffset + replacedLength + minOf(
message.length,
remainingOriginalLength
),
StringUtil.convertLineSeparators(message)
)
} else {
document.insertString(startOffset + replacedLength, message)
}
}
replacedLength += message.length
editor.caretModel.moveToOffset(startOffset + replacedLength)
updateHighlighter(editor)
}
private fun cleanupAndFormat() {
val project = editor.project ?: return
val document = editor.document
val psiDocumentManager = project.service<PsiDocumentManager>()
val psiFile = psiDocumentManager.getPsiFile(document) ?: return
val startOffset = selectionTextRange.startOffset
val endOffset = selectionTextRange.endOffset
val newEndOffset = startOffset + replacedLength
runWriteCommandAction(project) {
if (newEndOffset < endOffset) {
document.deleteString(newEndOffset, endOffset)
}
psiDocumentManager.commitDocument(document)
project.service<CodeStyleManager>().reformatText(
psiFile,
listOf(TextRange(startOffset, newEndOffset))
)
}
editor.caretModel.moveToOffset(newEndOffset)
psiDocumentManager.doPostponedOperationsAndUnblockDocument(document)
cleanupHighlighter()
}
private fun cleanupHighlighter() {
currentHighlighter?.let { editor.markupModel.removeHighlighter(it) }
currentHighlighter = null
}
}

View file

@ -1,69 +0,0 @@
package ee.carlrobert.codegpt.actions.editor
import com.intellij.openapi.application.readAction
import com.intellij.openapi.application.runInEdt
import com.intellij.openapi.command.WriteCommandAction.runWriteCommandAction
import com.intellij.openapi.components.service
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.util.TextRange
import com.intellij.openapi.util.text.StringUtil
import com.jetbrains.rd.util.AtomicReference
import ee.carlrobert.codegpt.codecompletions.CompletionProgressNotifier
import ee.carlrobert.codegpt.completions.CompletionRequestService
import ee.carlrobert.codegpt.completions.EditCodeCompletionParameters
import ee.carlrobert.codegpt.ui.ObservableProperties
class EditCodeSubmissionHandler(
private val editor: Editor,
private val observableProperties: ObservableProperties,
) {
private val previousSourceRef = AtomicReference<String?>(null)
suspend fun handleSubmit(userPrompt: String) {
editor.project?.let {
CompletionProgressNotifier.update(it, true)
}
observableProperties.loading.set(true)
observableProperties.submitted.set(true)
previousSourceRef.getAndSet(editor.document.text)
val (selectionTextRange, selectedText) = readAction {
editor.selectionModel.run {
Pair(
TextRange(selectionStart, selectionEnd),
editor.selectionModel.selectedText ?: ""
)
}
}
runInEdt { editor.selectionModel.removeSelection() }
service<CompletionRequestService>().getEditCodeCompletionAsync(
EditCodeCompletionParameters(userPrompt, selectedText),
EditCodeCompletionListener(editor, observableProperties, selectionTextRange)
)
}
fun handleAccept() {
observableProperties.accepted.set(true)
observableProperties.submitted.set(false)
}
fun handleReject() {
val prevSource = previousSourceRef.get()
if (!observableProperties.accepted.get() && prevSource != null) {
revertAllChanges(prevSource)
}
}
private fun revertAllChanges(prevSource: String) {
runWriteCommandAction(editor.project) {
editor.document.replaceString(
0,
editor.document.textLength,
StringUtil.convertLineSeparators(prevSource)
)
}
}
}

View file

@ -0,0 +1,166 @@
package ee.carlrobert.codegpt.actions.editor
import com.intellij.openapi.Disposable
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.ex.EditorEx
import com.intellij.openapi.editor.ex.util.EditorUtil
import com.intellij.openapi.editor.impl.EditorEmbeddedComponentManager
import com.intellij.openapi.editor.impl.EditorImpl
import com.intellij.openapi.editor.impl.view.FontLayoutService
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.Key
import com.intellij.ui.components.JBScrollPane
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import java.awt.Dimension
import java.awt.Font
import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent
import javax.swing.JComponent
import kotlin.math.ceil
import kotlin.math.max
import kotlin.math.min
/**
* Manages embedded component inlays in the main editor.
*/
class EditorComponentInlaysManager(val editor: EditorImpl) : Disposable {
private val managedInlays = mutableMapOf<ComponentWrapper, Disposable>()
private val editorWidthWatcher = EditorTextWidthWatcher()
init {
editor.scrollPane.viewport.addComponentListener(editorWidthWatcher)
Disposer.register(this, Disposable {
editor.scrollPane.viewport.removeComponentListener(editorWidthWatcher)
})
EditorUtil.disposeWithEditor(editor, this)
}
@RequiresEdt
fun insert(lineIndex: Int, component: JComponent, showAbove: Boolean = false): Disposable? {
val wrappedComponent = ComponentWrapper(component)
val offset = if (lineIndex < editor.document.lineCount) {
editor.document.getLineStartOffset(lineIndex)
} else {
editor.document.textLength
}
val result = EditorEmbeddedComponentManager.getInstance()
.addComponent(
editor, wrappedComponent,
EditorEmbeddedComponentManager.Properties(
EditorEmbeddedComponentManager.ResizePolicy.none(),
null,
true,
showAbove,
0,
offset
)
)
return result?.also {
managedInlays[wrappedComponent] = it
Disposer.register(it, Disposable {
managedInlays.remove(wrappedComponent)
})
} ?: run {
null
}
}
private inner class ComponentWrapper(val component: JComponent) : JBScrollPane(component) {
init {
isOpaque = false
viewport.isOpaque = false
border = JBUI.Borders.empty()
viewportBorder = JBUI.Borders.empty()
horizontalScrollBarPolicy = HORIZONTAL_SCROLLBAR_NEVER
verticalScrollBar.preferredSize = Dimension(0, 0)
setViewportView(component)
component.addComponentListener(object : ComponentAdapter() {
override fun componentResized(e: ComponentEvent) =
dispatchEvent(ComponentEvent(component, ComponentEvent.COMPONENT_RESIZED))
})
}
override fun getPreferredSize(): Dimension {
return Dimension(editor.contentComponent.width, component.preferredSize.height)
}
}
override fun dispose() {
managedInlays.values.forEach(Disposer::dispose)
}
private inner class EditorTextWidthWatcher : ComponentAdapter() {
var editorTextWidth: Int = 0
private val maximumEditorTextWidth: Int
private val verticalScrollbarFlipped: Boolean
init {
val metrics = editor.getFontMetrics(Font.PLAIN)
val spaceWidth = FontLayoutService.getInstance().charWidth2D(metrics, ' '.code)
maximumEditorTextWidth =
ceil(spaceWidth * (editor.settings.getRightMargin(editor.project)) - 4).toInt()
val scrollbarFlip = editor.scrollPane.getClientProperty(JBScrollPane.Flip::class.java)
verticalScrollbarFlipped =
scrollbarFlip == JBScrollPane.Flip.HORIZONTAL || scrollbarFlip == JBScrollPane.Flip.BOTH
}
override fun componentResized(e: ComponentEvent) = updateWidthForAllInlays()
override fun componentHidden(e: ComponentEvent) = updateWidthForAllInlays()
override fun componentShown(e: ComponentEvent) = updateWidthForAllInlays()
private fun updateWidthForAllInlays() {
val newWidth = calcWidth()
if (editorTextWidth == newWidth) return
editorTextWidth = newWidth
managedInlays.keys.forEach {
it.dispatchEvent(ComponentEvent(it, ComponentEvent.COMPONENT_RESIZED))
it.invalidate()
}
}
private fun calcWidth(): Int {
val visibleEditorTextWidth =
editor.scrollPane.viewport.width - getVerticalScrollbarWidth() - getGutterTextGap()
return min(max(visibleEditorTextWidth, 0), maximumEditorTextWidth)
}
private fun getVerticalScrollbarWidth(): Int {
val width = editor.scrollPane.verticalScrollBar.width
return if (!verticalScrollbarFlipped) width * 2 else width
}
private fun getGutterTextGap(): Int {
return if (verticalScrollbarFlipped) {
val gutter = (editor as EditorEx).gutterComponentEx
gutter.width - gutter.whitespaceSeparatorOffset
} else 0
}
}
companion object {
val INLAYS_KEY: Key<EditorComponentInlaysManager> = Key.create("InlineEditInlaysManager")
fun from(editor: Editor): EditorComponentInlaysManager {
return synchronized(editor) {
val manager = editor.getUserData(INLAYS_KEY)
if (manager == null) {
val newManager = EditorComponentInlaysManager(editor as EditorImpl)
editor.putUserData(INLAYS_KEY, newManager)
newManager
} else manager
}
}
}
}

View file

@ -4,17 +4,17 @@ import com.intellij.openapi.application.runInEdt
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.project.Project
import ee.carlrobert.codegpt.Icons
import ee.carlrobert.codegpt.ui.EditCodePopover
import ee.carlrobert.codegpt.ui.InlineEditPopover
import javax.swing.Icon
open class EditCodeAction(icon: Icon) : BaseEditorAction(icon) {
open class InlineEditAction(icon: Icon) : BaseEditorAction(icon) {
override fun actionPerformed(project: Project, editor: Editor, selectedText: String) {
runInEdt {
EditCodePopover(editor).show()
InlineEditPopover(editor).show()
}
}
}
class EditCodeFloatingMenuAction : EditCodeAction(Icons.DefaultSmall)
class InlineEditFloatingMenuAction : InlineEditAction(Icons.DefaultSmall)
class EditCodeContextMenuAction : EditCodeAction(Icons.Sparkle)
class InlineEditContextMenuAction : InlineEditAction(Icons.Sparkle)

View file

@ -14,9 +14,11 @@ class ShowEditorActionGroupAction : AnAction() {
override fun actionPerformed(e: AnActionEvent) {
val actionManager = ActionManager.getInstance()
val actionGroup = actionManager.getAction("action.editor.group.EditorActionGroup")
JBPopupFactory.getInstance().createActionGroupPopup(
CodeGPTBundle.get("project.label"), (actionGroup as ActionGroup), e.dataContext,
ActionSelectionAid.ALPHA_NUMBERING, true
).show(RelativePoint(MouseInfo.getPointerInfo().location))
JBPopupFactory.getInstance()
.createActionGroupPopup(
CodeGPTBundle.get("project.label"), (actionGroup as ActionGroup), e.dataContext,
ActionSelectionAid.ALPHA_NUMBERING, true
)
.show(RelativePoint(MouseInfo.getPointerInfo().location))
}
}

View file

@ -129,11 +129,17 @@ data class AutoApplyParameters(
val featureType: FeatureType = FeatureType.AUTO_APPLY
)
data class EditCodeCompletionParameters(
data class InlineEditCompletionParameters(
val prompt: String,
val selectedText: String,
val chatMode: ChatMode = ChatMode.EDIT,
val featureType: FeatureType = FeatureType.EDIT_CODE
val selectedText: String? = null,
val filePath: String? = null,
val fileExtension: String? = null,
val projectBasePath: String? = null,
val referencedFiles: List<ReferencedFile>? = null,
val gitDiff: String? = null,
val conversation: Conversation? = null,
val conversationHistory: List<Conversation>? = null,
val diagnosticsInfo: String? = null
) : CompletionParameters
data class ImageDetails(
@ -155,4 +161,4 @@ data class ImageDetails(
result = 31 * result + data.contentHashCode()
return result
}
}
}

View file

@ -1,21 +1,22 @@
package ee.carlrobert.codegpt.completions
import com.intellij.openapi.components.service
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.openapi.vfs.readText
import ee.carlrobert.codegpt.completions.factory.*
import ee.carlrobert.codegpt.psistructure.ClassStructureSerializer
import ee.carlrobert.codegpt.settings.prompts.CoreActionsState
import ee.carlrobert.codegpt.settings.prompts.FilteredPromptsService
import ee.carlrobert.codegpt.settings.prompts.PromptsSettings
import ee.carlrobert.codegpt.settings.service.ServiceType
import ee.carlrobert.codegpt.settings.service.ModelSelectionService
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.util.file.FileUtil
import ee.carlrobert.llm.completion.CompletionRequest
interface CompletionRequestFactory {
fun createChatRequest(params: ChatCompletionParameters): CompletionRequest
fun createEditCodeRequest(params: EditCodeCompletionParameters): CompletionRequest
fun createInlineEditRequest(params: InlineEditCompletionParameters): CompletionRequest
fun createAutoApplyRequest(params: AutoApplyParameters): CompletionRequest
fun createCommitMessageRequest(params: CommitMessageCompletionParameters): CompletionRequest
fun createLookupRequest(params: LookupCompletionParameters): CompletionRequest
@ -49,15 +50,125 @@ abstract class BaseRequestFactory : CompletionRequestFactory {
private const val AUTO_APPLY_MAX_TOKENS = 8192
private const val DEFAULT_MAX_TOKENS = 4096
}
override fun createEditCodeRequest(params: EditCodeCompletionParameters): CompletionRequest {
val prompt = "Code to modify:\n${params.selectedText}\n\nInstructions: ${params.prompt}"
data class InlineEditPrompts(val systemPrompt: String, val userPrompt: String)
protected fun prepareInlineEditPrompts(params: InlineEditCompletionParameters): InlineEditPrompts {
val language = params.fileExtension ?: "txt"
val filePath = params.filePath ?: "untitled"
var systemPrompt =
service<PromptsSettings>().state.coreActions.editCode.instructions
?: CoreActionsState.DEFAULT_EDIT_CODE_PROMPT
if (params.projectBasePath != null) {
val projectContext =
"Project Context:\nProject root: ${params.projectBasePath}\nAll file paths should be relative to this project root."
systemPrompt = systemPrompt.replace("{{PROJECT_CONTEXT}}", projectContext)
} else {
systemPrompt = systemPrompt.replace("\n{{PROJECT_CONTEXT}}\n", "")
}
val currentFileContent = try {
params.filePath?.let { LocalFileSystem.getInstance().findFileByPath(it)?.readText() }
} catch (_: Throwable) {
null
}
val currentFileBlock = buildString {
append("```$language:$filePath\n")
append(currentFileContent ?: "")
append("\n```")
}
systemPrompt = systemPrompt.replace("{{CURRENT_FILE_CONTEXT}}", currentFileBlock)
val externalContext = buildString {
val currentPath = filePath
val unique = mutableSetOf<String>()
val hasRefs = params.referencedFiles
?.filter { it.filePath != currentPath }
?.any { !it.fileContent.isNullOrBlank() } == true
if (hasRefs) {
append("\n\n### Referenced Files")
params.referencedFiles
.filter { it.filePath != currentPath }
.forEach {
if (!it.fileContent.isNullOrBlank() && unique.add(it.filePath)) {
append("\n\n```${it.fileExtension}:${it.filePath}\n")
append(it.fileContent)
append("\n```")
}
}
}
if (!params.gitDiff.isNullOrBlank()) {
append("\n\n### Git Diff\n\n")
append("```diff\n${params.gitDiff}\n```")
}
if (!params.conversationHistory.isNullOrEmpty()) {
append("\n\n### Conversation History\n")
params.conversationHistory.forEach { conversation ->
conversation.messages.forEach { message ->
if (!message.prompt.isNullOrBlank()) {
append("\n**User:** ${message.prompt.trim()}")
}
if (!message.response.isNullOrBlank()) {
append("\n**Assistant:** ${message.response.trim()}")
}
}
}
}
if (!params.diagnosticsInfo.isNullOrBlank()) {
append("\n\n### Diagnostics\n")
append(params.diagnosticsInfo)
}
}
systemPrompt = if (externalContext.isEmpty()) {
systemPrompt.replace(
"{{EXTERNAL_CONTEXT}}",
"## External Context\n\nNo external context selected."
)
} else {
systemPrompt.replace(
"{{EXTERNAL_CONTEXT}}",
"## External Context$externalContext"
)
}
val userPrompt = buildString {
if (!params.selectedText.isNullOrBlank()) {
append("Selected code:\n")
append("```$language\n")
append(params.selectedText)
append("\n```\n\n")
}
append("Request: ${params.prompt}")
}
return InlineEditPrompts(systemPrompt, userPrompt)
}
override fun createInlineEditRequest(params: InlineEditCompletionParameters): CompletionRequest {
val prepared = prepareInlineEditPrompts(params)
return createBasicCompletionRequest(
service<FilteredPromptsService>().getFilteredEditCodePrompt(params.chatMode), prompt, AUTO_APPLY_MAX_TOKENS, true, FeatureType.EDIT_CODE
prepared.systemPrompt,
prepared.userPrompt,
AUTO_APPLY_MAX_TOKENS,
true,
FeatureType.INLINE_EDIT
)
}
override fun createCommitMessageRequest(params: CommitMessageCompletionParameters): CompletionRequest {
return createBasicCompletionRequest(params.systemPrompt, params.gitDiff, 512, true, FeatureType.COMMIT_MESSAGE)
return createBasicCompletionRequest(
params.systemPrompt,
params.gitDiff,
512,
true,
FeatureType.COMMIT_MESSAGE
)
}
override fun createLookupRequest(params: LookupCompletionParameters): CompletionRequest {
@ -74,16 +185,26 @@ abstract class BaseRequestFactory : CompletionRequestFactory {
override fun createAutoApplyRequest(params: AutoApplyParameters): CompletionRequest {
val destination = params.destination
val language = FileUtil.getFileExtension(destination.path)
val formattedSource = CompletionRequestUtil.formatCodeWithLanguage(params.source, language)
val formattedDestination = CompletionRequestUtil.formatCode(destination.readText(), destination.path)
val systemPromptTemplate = service<FilteredPromptsService>().getFilteredAutoApplyPrompt(params.chatMode, params.destination)
val formattedDestination =
CompletionRequestUtil.formatCode(destination.readText(), destination.path)
val systemPromptTemplate = service<FilteredPromptsService>().getFilteredAutoApplyPrompt(
params.chatMode,
params.destination
)
val systemPrompt = systemPromptTemplate
.replace("{{changes_to_merge}}", formattedSource)
.replace("{{destination_file}}", formattedDestination)
return createBasicCompletionRequest(systemPrompt, "Merge the following changes to the destination file.", AUTO_APPLY_MAX_TOKENS, true, FeatureType.AUTO_APPLY)
return createBasicCompletionRequest(
systemPrompt,
"Merge the following changes to the destination file.",
AUTO_APPLY_MAX_TOKENS,
true,
FeatureType.AUTO_APPLY
)
}
abstract fun createBasicCompletionRequest(

View file

@ -7,16 +7,17 @@ import com.intellij.openapi.vfs.VirtualFile
import ee.carlrobert.codegpt.CodeGPTPlugin
import ee.carlrobert.codegpt.completions.BaseRequestFactory
import ee.carlrobert.codegpt.completions.ChatCompletionParameters
import ee.carlrobert.codegpt.completions.InlineEditCompletionParameters
import ee.carlrobert.codegpt.completions.factory.OpenAIRequestFactory.Companion.buildOpenAIMessages
import ee.carlrobert.codegpt.psistructure.ClassStructureSerializer
import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings
import ee.carlrobert.codegpt.settings.models.ModelSettings
import ee.carlrobert.codegpt.settings.service.FeatureType
import ee.carlrobert.codegpt.settings.service.ModelSelectionService
import ee.carlrobert.codegpt.ui.textarea.ConversationTagProcessor
import ee.carlrobert.codegpt.util.file.FileUtil
import ee.carlrobert.llm.client.codegpt.request.chat.*
import ee.carlrobert.llm.client.openai.completion.request.OpenAIChatCompletionStandardMessage
import ee.carlrobert.llm.client.openai.completion.request.OpenAIChatCompletionMessage
class CodeGPTRequestFactory(private val classStructureSerializer: ClassStructureSerializer) :
BaseRequestFactory() {
@ -130,6 +131,28 @@ class CodeGPTRequestFactory(private val classStructureSerializer: ClassStructure
.build()
}
override fun createInlineEditRequest(params: InlineEditCompletionParameters): ChatCompletionRequest {
val model = ModelSelectionService.getInstance().getModelForFeature(FeatureType.INLINE_EDIT)
val prepared = prepareInlineEditPrompts(params)
val messages: MutableList<OpenAIChatCompletionMessage> =
OpenAIRequestFactory.buildInlineEditMessages(prepared, params.conversation)
if (model == "o4-mini") {
val collapsed = messages.joinToString("\n\n") { msg ->
when (msg) {
is OpenAIChatCompletionStandardMessage -> msg.content
else -> ""
}
}
return buildBasicO1Request(model, collapsed, systemPrompt = "", maxCompletionTokens = 4096, stream = true)
}
return ChatCompletionRequest.Builder(messages)
.setModel(model)
.setStream(true)
.build()
}
private fun buildBasicO1Request(
model: String,
prompt: String,

View file

@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper
import com.intellij.openapi.components.service
import ee.carlrobert.codegpt.completions.BaseRequestFactory
import ee.carlrobert.codegpt.completions.ChatCompletionParameters
import ee.carlrobert.codegpt.completions.InlineEditCompletionParameters
import ee.carlrobert.codegpt.credentials.CredentialsStore.CredentialKey
import ee.carlrobert.codegpt.credentials.CredentialsStore.getCredential
import ee.carlrobert.codegpt.settings.service.FeatureType
@ -58,6 +59,19 @@ class CustomOpenAIRequestFactory : BaseRequestFactory() {
return CustomOpenAIRequest(request)
}
override fun createInlineEditRequest(params: InlineEditCompletionParameters): CompletionRequest {
val service = service<CustomServicesSettings>().customServiceStateForFeatureType(FeatureType.INLINE_EDIT)
val prepared = prepareInlineEditPrompts(params)
val messages = OpenAIRequestFactory.buildInlineEditMessages(prepared, params.conversation)
val request = buildCustomOpenAIChatCompletionRequest(
service.chatCompletionSettings,
messages,
true,
getCredential(CredentialKey.CustomServiceApiKey(service.name.orEmpty()))
)
return CustomOpenAIRequest(request)
}
companion object {
fun buildCustomOpenAICompletionRequest(
context: String,

View file

@ -2,6 +2,7 @@ package ee.carlrobert.codegpt.completions.factory
import com.intellij.openapi.components.service
import ee.carlrobert.codegpt.completions.BaseRequestFactory
import ee.carlrobert.codegpt.completions.InlineEditCompletionParameters
import ee.carlrobert.codegpt.completions.ChatCompletionParameters
import ee.carlrobert.codegpt.completions.ConversationType
import ee.carlrobert.codegpt.completions.llama.LlamaModel
@ -12,6 +13,7 @@ import ee.carlrobert.codegpt.settings.prompts.PromptsSettings
import ee.carlrobert.codegpt.settings.prompts.addProjectPath
import ee.carlrobert.codegpt.settings.service.FeatureType
import ee.carlrobert.codegpt.settings.service.llama.LlamaSettings
import ee.carlrobert.codegpt.conversations.message.Message
import ee.carlrobert.llm.client.llama.completion.LlamaCompletionRequest
class LlamaRequestFactory : BaseRequestFactory() {
@ -52,6 +54,14 @@ class LlamaRequestFactory : BaseRequestFactory() {
return buildLlamaRequest(finalPrompt, emptyList(), stream)
}
override fun createInlineEditRequest(params: InlineEditCompletionParameters): LlamaCompletionRequest {
val prepared = prepareInlineEditPrompts(params)
val promptTemplate = getPromptTemplate(FeatureType.INLINE_EDIT)
val history = params.conversation?.messages?.filter { !it.response.isNullOrBlank() } ?: listOf()
val finalPrompt = promptTemplate.buildPrompt(prepared.systemPrompt, prepared.userPrompt, history)
return buildLlamaRequest(finalPrompt, emptyList(), stream = true)
}
private fun getPromptTemplate(featureType: FeatureType? = null): PromptTemplate {
val settings = service<LlamaSettings>().state
return if (settings.isUseCustomModel)

View file

@ -25,7 +25,7 @@ import java.io.IOException
import java.nio.file.Files
import java.nio.file.Path
class OpenAIRequestFactory : CompletionRequestFactory {
class OpenAIRequestFactory : BaseRequestFactory() {
override fun createChatRequest(params: ChatCompletionParameters): OpenAIChatCompletionRequest {
val model = ModelSelectionService.getInstance().getModelForFeature(FeatureType.CHAT)
@ -47,15 +47,27 @@ class OpenAIRequestFactory : CompletionRequestFactory {
return requestBuilder.build()
}
override fun createEditCodeRequest(params: EditCodeCompletionParameters): OpenAIChatCompletionRequest {
val model = ModelSelectionService.getInstance().getModelForFeature(FeatureType.EDIT_CODE)
val prompt = "Code to modify:\n${params.selectedText}\n\nInstructions: ${params.prompt}"
val systemPrompt =
service<FilteredPromptsService>().getFilteredEditCodePrompt(params.chatMode)
if (isReasoningModel(model)) {
return buildBasicO1Request(model, prompt, systemPrompt, stream = true)
override fun createInlineEditRequest(params: InlineEditCompletionParameters): OpenAIChatCompletionRequest {
val model = ModelSelectionService.getInstance().getModelForFeature(FeatureType.INLINE_EDIT)
val prepared = prepareInlineEditPrompts(params)
val messages = buildInlineEditMessages(prepared, params.conversation)
val configuration = service<ConfigurationSettings>().state
return if (isReasoningModel(model)) {
val collapsed = messages.joinToString("\n\n") { msg ->
when (msg) {
is OpenAIChatCompletionStandardMessage -> msg.content
else -> ""
}
}
buildBasicO1Request(model, collapsed, systemPrompt = "", stream = true)
} else {
OpenAIChatCompletionRequest.Builder(messages)
.setModel(model)
.setStream(true)
.setTemperature(configuration.temperature.toDouble())
.build()
}
return createBasicCompletionRequest(systemPrompt, prompt, model, true)
}
override fun createAutoApplyRequest(params: AutoApplyParameters): CompletionRequest {
@ -78,6 +90,29 @@ class OpenAIRequestFactory : CompletionRequestFactory {
return createBasicCompletionRequest(systemPrompt, prompt, model, true)
}
override fun createBasicCompletionRequest(
systemPrompt: String,
userPrompt: String,
maxTokens: Int,
stream: Boolean,
featureType: FeatureType
): CompletionRequest {
val model = ModelSelectionService.getInstance().getModelForFeature(featureType)
return if (isReasoningModel(model)) {
buildBasicO1Request(model, userPrompt, systemPrompt, maxCompletionTokens = maxTokens, stream = stream)
} else {
OpenAIChatCompletionRequest.Builder(
listOf(
OpenAIChatCompletionStandardMessage("system", systemPrompt),
OpenAIChatCompletionStandardMessage("user", userPrompt)
)
)
.setModel(model)
.setStream(stream)
.build()
}
}
override fun createCommitMessageRequest(params: CommitMessageCompletionParameters): OpenAIChatCompletionRequest {
val model =
ModelSelectionService.getInstance().getModelForFeature(FeatureType.COMMIT_MESSAGE)
@ -106,6 +141,22 @@ class OpenAIRequestFactory : CompletionRequestFactory {
}
companion object {
fun buildInlineEditMessages(
prepared: InlineEditPrompts,
conversation: Conversation?
): MutableList<OpenAIChatCompletionMessage> {
val messages = mutableListOf<OpenAIChatCompletionMessage>()
messages.add(OpenAIChatCompletionStandardMessage("system", prepared.systemPrompt))
conversation?.messages?.forEach { m ->
val p = m.prompt?.trim().orEmpty()
if (p.isNotEmpty()) messages.add(OpenAIChatCompletionStandardMessage("user", p))
val r = m.response?.trim().orEmpty()
if (r.isNotEmpty()) messages.add(OpenAIChatCompletionStandardMessage("assistant", r))
}
messages.add(OpenAIChatCompletionStandardMessage("user", prepared.userPrompt))
return messages
}
fun isReasoningModel(model: String?) =
listOf(
O_4_MINI.code,

View file

@ -0,0 +1,47 @@
package ee.carlrobert.codegpt.inlineedit
import com.intellij.openapi.editor.ex.EditorEx
import com.intellij.openapi.util.Key
import ee.carlrobert.codegpt.conversations.Conversation
import ee.carlrobert.codegpt.conversations.message.Message
/**
* Maintains an ephemeral inline-edit conversation (user/assistant turns) per editor.
* This mirrors how regular chat builds provider-friendly message histories.
*/
object InlineEditConversationManager {
private val CONVERSATION_KEY: Key<Conversation> = Key.create("InlineEditConversation")
fun getOrCreate(editor: EditorEx): Conversation {
val existing = editor.getUserData(CONVERSATION_KEY)
if (existing != null) return existing
val conversation = Conversation().apply {
projectPath = editor.project?.basePath
title = "Inline Edit (${editor.virtualFile?.name ?: "untitled"})"
}
editor.putUserData(CONVERSATION_KEY, conversation)
return conversation
}
fun addUserMessage(editor: EditorEx, prompt: String): Message {
val message = Message(prompt)
getOrCreate(editor).addMessage(message)
return message
}
fun addAssistantResponse(message: Message, content: String) {
message.response = content
}
fun clear(editor: EditorEx) {
editor.putUserData(CONVERSATION_KEY, null)
}
fun moveConversation(source: EditorEx?, target: EditorEx?) {
if (source == null || target == null) return
val conversation = source.getUserData(CONVERSATION_KEY) ?: return
target.putUserData(CONVERSATION_KEY, conversation)
source.putUserData(CONVERSATION_KEY, null)
}
}

View file

@ -0,0 +1,51 @@
package ee.carlrobert.codegpt.inlineedit
import ee.carlrobert.codegpt.toolwindow.chat.parser.SearchReplace
internal data class FilterResult(
val pairs: List<Pair<String, String>>,
val filteredCount: Int,
val stats: Map<String, Int>
)
internal object InlineEditFilter {
fun filterSegments(
currentPath: String?,
fileName: String?,
fileExt: String?,
segments: List<SearchReplace>
): FilterResult {
fun fileMatches(path: String?): Boolean {
if (path.isNullOrBlank()) return true
if (currentPath == null) return false
val name = fileName ?: ""
return path == currentPath || path.endsWith("/" + name) || path == name
}
val accepted = mutableListOf<Pair<String, String>>()
var filtered = 0
val reasons = mutableMapOf<String, Int>()
fun bump(reason: String) {
reasons[reason] = (reasons[reason] ?: 0) + 1
}
for (seg in segments) {
val search = seg.search
val replace = seg.replace
if (!fileMatches(seg.filePath)) {
filtered++; bump("wrong-file"); continue
}
val normalizedSearch = search.trim()
if (normalizedSearch.isBlank()) {
filtered++; bump("empty-search"); continue
}
accepted.add(normalizedSearch to replace)
}
return FilterResult(accepted, filtered, reasons)
}
}

View file

@ -0,0 +1,582 @@
package ee.carlrobert.codegpt.inlineedit
import com.intellij.openapi.Disposable
import com.intellij.openapi.actionSystem.KeyboardShortcut
import com.intellij.openapi.actionSystem.Shortcut
import com.intellij.openapi.application.runInEdt
import com.intellij.openapi.command.WriteCommandAction
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.editor.colors.EditorColorsManager
import com.intellij.openapi.editor.colors.EditorFontType
import com.intellij.openapi.editor.ex.EditorEx
import com.intellij.openapi.editor.markup.HighlighterLayer
import com.intellij.openapi.editor.markup.HighlighterTargetArea
import com.intellij.openapi.editor.markup.RangeHighlighter
import com.intellij.openapi.editor.markup.TextAttributes
import com.intellij.openapi.fileTypes.SyntaxHighlighterFactory
import com.intellij.openapi.keymap.KeymapManager
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.SystemInfo
import com.intellij.ui.JBColor
import com.intellij.util.ui.JBUI
import ee.carlrobert.codegpt.CodeGPTKeys
import ee.carlrobert.codegpt.actions.editor.EditorComponentInlaysManager
import ee.carlrobert.codegpt.ui.InlineEditPopover
import ee.carlrobert.codegpt.ui.components.InlineEditChips
import java.awt.BorderLayout
import java.awt.Color
import java.awt.Cursor
import java.awt.Dimension
import java.awt.FlowLayout
import java.awt.Graphics
import java.awt.Graphics2D
import java.awt.Insets
import java.awt.RenderingHints
import java.awt.event.InputEvent
import java.awt.event.KeyEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.JTextPane
import javax.swing.KeyStroke
import javax.swing.text.SimpleAttributeSet
import javax.swing.text.StyleConstants
import kotlin.math.abs
import kotlin.math.max
class InlineEditInlayRenderer(
private val editor: EditorEx,
private val project: Project
) : Disposable {
private val logger = Logger.getInstance(InlineEditInlayRenderer::class.java)
private var interactive: Boolean = true
data class InlineChange(
val startOffset: Int,
val endOffset: Int,
val oldText: String,
val newText: String,
var deletionHighlighter: RangeHighlighter? = null,
var additionInlay: Disposable? = null,
var buttonInlay: Disposable? = null,
var isAccepted: Boolean = false,
var isRejected: Boolean = false
)
private val changes = mutableListOf<InlineChange>()
private val allHighlighters = mutableListOf<RangeHighlighter>()
private data class HunkUI(
val hunk: InlineEditSession.Hunk,
var deletionHighlighter: RangeHighlighter? = null,
var additionInlay: Disposable? = null,
var buttonInlay: Disposable? = null,
)
private val hunkUIs = mutableListOf<HunkUI>()
fun renderHunks(hunks: List<InlineEditSession.Hunk>) {
runInEdt {
hunks.forEach { renderHunk(it) }
showTopPanel()
}
}
fun replaceHunks(hunks: List<InlineEditSession.Hunk>) {
runInEdt {
val prev = hunkUIs.toList()
prev.forEach { removeHunkUI(it) }
hunks.forEach { renderHunk(it) }
showTopPanel()
}
}
private var topPanelDisposable: Disposable? = null
private fun showTopPanel() {
topPanelDisposable?.dispose()
topPanelDisposable = null
}
fun setInteractive(enabled: Boolean) {
interactive = enabled
}
private fun renderHunk(hunk: InlineEditSession.Hunk) {
val start = hunk.baseMarker.startOffset
val end = hunk.baseMarker.endOffset
val baseLen = (end - start).coerceAtLeast(0)
val deletion = if (baseLen > 0) highlightDeletion(start, end) else null
val hasNew = hunk.proposedSlice.isNotBlank()
val showAbove = if (baseLen > 0 && hasNew) true else baseLen == 0
val insertionOffset = if (baseLen == 0) start else end
val addition = if (hasNew) addInlayForAddition(
insertionOffset,
hunk.proposedSlice,
showAbove = showAbove,
onAccept = {
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)
?.accept(hunk)
},
onReject = {
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)
?.reject(hunk)
}
) else null
val header =
if (!hasNew && baseLen > 0) addInlineButtons(start, hunk) else null
hunkUIs.add(HunkUI(hunk, deletion, addition, header))
}
private fun highlightDeletion(startOffset: Int, endOffset: Int): RangeHighlighter? {
try {
val doc = editor.document
val boundedStart = startOffset.coerceIn(0, doc.textLength)
val inclusiveEnd =
(endOffset - 1).coerceAtLeast(boundedStart).coerceAtMost(doc.textLength - 1)
val startLine = doc.getLineNumber(boundedStart)
val endLine = doc.getLineNumber(inclusiveEnd)
val lineStart = doc.getLineStartOffset(startLine)
val lineEnd = doc.getLineEndOffset(endLine)
val attributes = TextAttributes().apply {
backgroundColor = JBColor(
Color(255, 220, 220, 60),
Color(80, 40, 40, 80)
)
foregroundColor = null
effectType = null
effectColor = null
}
val highlighter = editor.markupModel.addRangeHighlighter(
lineStart,
lineEnd,
HighlighterLayer.SELECTION - 1,
attributes,
HighlighterTargetArea.LINES_IN_RANGE
)
allHighlighters.add(highlighter)
return highlighter
} catch (e: Exception) {
logger.error("Error creating deletion highlight", e)
throw e
}
}
private fun addInlayForAddition(
offset: Int,
newText: String,
showAbove: Boolean = true,
onAccept: (() -> Unit)? = null,
onReject: (() -> Unit)? = null,
): Disposable? {
try {
val inlaysManager = EditorComponentInlaysManager.Companion.from(editor)
val leftInset = computeLeftInsetForOffset(offset)
val component = createAdditionComponent(newText, onAccept, onReject, leftInset)
val lineNumber = editor.document.getLineNumber(offset)
return inlaysManager.insert(lineNumber, component, showAbove)
} catch (e: Exception) {
logger.error("Error creating addition inlay", e)
throw e
}
}
private fun createAdditionComponent(
text: String,
onAccept: (() -> Unit)?,
onReject: (() -> Unit)?,
leftInset: Int,
): JComponent {
val displayText = text.trimEnd('\n', '\r')
val panel = JPanel(BorderLayout()).apply {
isOpaque = true
background = JBColor(Color(0, 128, 0, 28), Color(0, 128, 0, 36))
border = JBUI.Borders.empty(0, leftInset, 0, 0)
}
if (onAccept != null || onReject != null) {
val header = JPanel(FlowLayout(FlowLayout.RIGHT, 6, 0)).apply {
isOpaque = false
}
fun badge(textLabel: String, bg: Color, onClick: () -> Unit): JComponent {
return object : JComponent() {
init {
cursor = Cursor(Cursor.HAND_CURSOR)
addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent?) {
onClick()
}
})
}
override fun getPreferredSize(): Dimension {
val fm = getFontMetrics(font)
val w = max(34, fm.stringWidth(textLabel) + 14)
return Dimension(w, 18)
}
override fun paintComponent(g: Graphics) {
val g2 = g as Graphics2D
g2.setRenderingHint(
RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON
)
g2.color = Color(bg.red, bg.green, bg.blue, 200)
g2.fillRoundRect(0, 0, width - 1, height - 1, 8, 8)
g2.color = Color.WHITE
val fm = g2.fontMetrics
val tx = (width - fm.stringWidth(textLabel)) / 2
val ty = (height - fm.height) / 2 + fm.ascent
g2.drawString(textLabel, tx, ty)
}
}
}
val acceptLabel = formatShortcutLabel(
actionId = "CodeGPT.AcceptCurrentInlineEdit",
fallback = if (SystemInfo.isMac) "⌘Y" else "Ctrl+Y"
)
val rejectLabel = formatShortcutLabel(
actionId = "CodeGPT.RejectCurrentInlineEdit",
fallback = if (SystemInfo.isMac) "⌘N" else "Ctrl+N"
)
onAccept?.let { header.add(badge(acceptLabel, Color(0, 153, 0), it)) }
onReject?.let {
header.add(
badge(
rejectLabel,
JBColor(Color(0xD0, 0x36, 0x36), Color(0xD0, 0x36, 0x36)),
it
)
)
}
panel.add(header, BorderLayout.NORTH)
}
val textPane = JTextPane().apply {
isEditable = false
isOpaque = false
font = editor.colorsScheme.getFont(EditorFontType.PLAIN)
foreground = editor.colorsScheme.defaultForeground
border = null
margin = Insets(0, 0, 0, 0)
}
applySyntaxColors(displayText, textPane)
panel.add(textPane, BorderLayout.CENTER)
return panel
}
private fun formatShortcutLabel(actionId: String, fallback: String): String {
return try {
val keymap = KeymapManager.getInstance().activeKeymap
val shortcuts = keymap.getShortcuts(actionId)
val preferred = when (actionId) {
"CodeGPT.AcceptCurrentInlineEdit" -> preferredShortcut(shortcuts, KeyEvent.VK_Y)
"CodeGPT.RejectCurrentInlineEdit" -> preferredShortcut(shortcuts, KeyEvent.VK_N)
else -> shortcuts.firstOrNull()
}
val ks = (preferred as? KeyboardShortcut)?.firstKeyStroke
if (ks != null) keyStrokeToLabel(ks) else fallback
} catch (_: Exception) {
fallback
}
}
private fun preferredShortcut(shortcuts: Array<Shortcut>, keyCode: Int): Shortcut? {
val macKs = KeyStroke.getKeyStroke(keyCode, InputEvent.META_DOWN_MASK)
val winKs = KeyStroke.getKeyStroke(keyCode, InputEvent.CTRL_DOWN_MASK)
return shortcuts.firstOrNull { (it as? KeyboardShortcut)?.firstKeyStroke == macKs }
?: shortcuts.firstOrNull { (it as? KeyboardShortcut)?.firstKeyStroke == winKs }
?: shortcuts.firstOrNull()
}
private fun keyStrokeToLabel(ks: KeyStroke): String {
return if (SystemInfo.isMac) {
buildString {
if (ks.modifiers and InputEvent.META_DOWN_MASK != 0) append('⌘')
if (ks.modifiers and InputEvent.SHIFT_DOWN_MASK != 0) append('⇧')
if (ks.modifiers and InputEvent.ALT_DOWN_MASK != 0) append('⌥')
if (ks.modifiers and InputEvent.CTRL_DOWN_MASK != 0) append('⌃')
append(KeyEvent.getKeyText(ks.keyCode).uppercase())
}
} else {
val parts = mutableListOf<String>()
if (ks.modifiers and InputEvent.CTRL_DOWN_MASK != 0) parts.add("Ctrl")
if (ks.modifiers and InputEvent.SHIFT_DOWN_MASK != 0) parts.add("Shift")
if (ks.modifiers and InputEvent.ALT_DOWN_MASK != 0) parts.add("Alt")
if (ks.modifiers and InputEvent.META_DOWN_MASK != 0) parts.add("Meta")
parts.add(KeyEvent.getKeyText(ks.keyCode).uppercase())
parts.joinToString("+")
}
}
private fun applySyntaxColors(text: String, pane: JTextPane) {
val fileType = editor.virtualFile?.fileType
if (fileType == null) {
pane.text = text; return
}
val highlighter =
SyntaxHighlighterFactory.getSyntaxHighlighter(fileType, project, editor.virtualFile)
val lexer = highlighter?.highlightingLexer ?: return
lexer.start(text)
val doc = pane.styledDocument
val scheme = EditorColorsManager.getInstance().globalScheme
while (lexer.tokenType != null) {
val start = lexer.tokenStart
val end = lexer.tokenEnd
val segment = text.substring(start, end)
val keys = highlighter.getTokenHighlights(lexer.tokenType)
val attrs = keys.firstOrNull()?.let { scheme.getAttributes(it) }
val style = SimpleAttributeSet()
val fg = attrs?.foregroundColor ?: editor.colorsScheme.defaultForeground
StyleConstants.setForeground(style, fg)
StyleConstants.setFontFamily(
style,
editor.colorsScheme.getFont(EditorFontType.PLAIN).family
)
StyleConstants.setFontSize(
style,
editor.colorsScheme.getFont(EditorFontType.PLAIN).size
)
doc.insertString(doc.length, segment, style)
lexer.advance()
}
}
private fun addInlineButtons(
offset: Int,
hunk: InlineEditSession.Hunk,
): Disposable? {
if (!interactive) return null
try {
val inlaysManager = EditorComponentInlaysManager.Companion.from(editor)
val leftInset = computeLeftInsetForOffset(offset)
val panel = createButtonPanel(hunk, leftInset)
val lineNumber = editor.document.getLineNumber(offset)
return inlaysManager.insert(lineNumber, panel, true)
} catch (e: Exception) {
logger.error("Error creating hunk button inlay", e)
throw e
}
}
private fun createButtonPanel(
hunk: InlineEditSession.Hunk,
leftInset: Int = 0
): JComponent {
val container = JPanel(BorderLayout()).apply {
isOpaque = background != null
border = JBUI.Borders.empty(0, leftInset, 0, 0)
}
val row = JPanel(FlowLayout(FlowLayout.RIGHT, 6, 0)).apply {
isOpaque = false
border = JBUI.Borders.empty(0, 8, 0, 0)
}
val accept = InlineEditChips.keyY {
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)?.accept(hunk)
}
val reject = InlineEditChips.keyN {
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)?.reject(hunk)
}
row.add(accept)
row.add(reject)
container.add(row, BorderLayout.EAST)
return container
}
private fun computeLeftInsetForOffset(offset: Int): Int {
return try {
val line = editor.document.getLineNumber(offset)
val lineStart = editor.document.getLineStartOffset(line)
val x = editor.offsetToXY(lineStart).x
val gutter = editor.gutterComponentEx.width
(x - gutter).coerceAtLeast(0)
} catch (e: Exception) {
logger.error("Error computing left inset for offset $offset", e)
0
}
}
private fun acceptChange(change: InlineChange) {
if (change.isAccepted || change.isRejected) return
runInEdt {
WriteCommandAction.runWriteCommandAction(
project,
"Accept Inline Edit Change",
"InlineEdit",
{
try {
editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY)
?.markChangesAsAccepted()
editor.document.replaceString(
change.startOffset,
change.endOffset,
change.newText
)
change.copy(isAccepted = true)
removeChangeVisuals(change)
} catch (e: Exception) {
logger.debug("Error accepting change", e)
}
})
if (changes.isEmpty()) {
editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY)
?.setInlineEditControlsVisible(false)
}
}
}
private fun rejectChange(change: InlineChange) {
if (change.isAccepted || change.isRejected) return
runInEdt {
change.copy(isRejected = true)
removeChangeVisuals(change)
if (changes.isEmpty()) {
editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY)
?.setInlineEditControlsVisible(false)
}
}
}
private fun removeHunkUI(ui: HunkUI) {
ui.deletionHighlighter?.let { editor.markupModel.removeHighlighter(it) }
ui.additionInlay?.dispose()
ui.buttonInlay?.dispose()
hunkUIs.remove(ui)
}
private fun removeChangeVisuals(change: InlineChange) {
change.deletionHighlighter?.let { highlighter ->
editor.markupModel.removeHighlighter(highlighter)
allHighlighters.remove(highlighter)
}
change.additionInlay?.dispose()
change.buttonInlay?.dispose()
changes.remove(change)
}
fun acceptAll() {
val session = editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)
if (session != null) {
session.acceptAll()
return
}
val changesToAccept = changes.filter { !it.isAccepted && !it.isRejected }
.sortedByDescending { it.startOffset }
changesToAccept.forEach { acceptChange(it) }
}
fun rejectAll() {
val session =
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)
if (session != null) {
session.rejectAll()
return
}
val changesToReject = changes.filter { !it.isAccepted && !it.isRejected }
changesToReject.forEach { rejectChange(it) }
editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY)
?.triggerPromptRestoration()
}
fun acceptNext() {
val session =
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)
if (session != null) {
session.acceptNearestToCaret()
return
}
val nextChange = changes
.filter { !it.isAccepted && !it.isRejected }
.minByOrNull { it.startOffset }
nextChange?.let { acceptChange(it) }
}
fun rejectNext() {
val session =
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)
if (session != null) {
val caret = editor.caretModel.offset
val pending = hunkUIs.minByOrNull { abs(it.hunk.startOffset - caret) }
pending?.let {
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)?.reject(it.hunk)
}
return
}
val nextChange = changes
.filter { !it.isAccepted && !it.isRejected }
.minByOrNull { it.startOffset }
nextChange?.let { rejectChange(it) }
}
override fun dispose() {
runInEdt {
disposeAllHighlighters()
disposeChanges()
disposeHunkUIs()
disposeTopPanel()
}
}
private fun disposeAllHighlighters() {
allHighlighters.forEach { highlighter ->
try {
editor.markupModel.removeHighlighter(highlighter)
} catch (e: Exception) {
logger.debug("Error removing highlighter during disposal", e)
}
}
allHighlighters.clear()
}
private fun disposeChanges() {
changes.forEach { change ->
try {
change.additionInlay?.dispose()
change.buttonInlay?.dispose()
} catch (e: Exception) {
logger.debug("Error disposing change inlays during disposal", e)
}
}
changes.clear()
}
private fun disposeHunkUIs() {
hunkUIs.toList().forEach { ui ->
try {
ui.additionInlay?.dispose()
ui.buttonInlay?.dispose()
ui.deletionHighlighter?.let { editor.markupModel.removeHighlighter(it) }
} catch (e: Exception) {
logger.debug("Error disposing hunk UI during disposal", e)
}
}
hunkUIs.clear()
}
private fun disposeTopPanel() {
try {
topPanelDisposable?.dispose()
} catch (e: Exception) {
logger.debug("Error disposing top panel", e)
}
topPanelDisposable = null
}
}

View file

@ -0,0 +1,57 @@
package ee.carlrobert.codegpt.inlineedit
import com.intellij.ide.IdeEventQueue
import com.intellij.openapi.Disposable
import com.intellij.openapi.actionSystem.KeyboardShortcut
import com.intellij.openapi.editor.ex.EditorEx
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.keymap.KeymapManager
import com.intellij.openapi.project.Project
import java.awt.AWTEvent
import java.awt.event.KeyEvent
import javax.swing.KeyStroke
/**
* Editor-scoped key dispatcher to reliably intercept Cmd/Ctrl+Y and Cmd/Ctrl+N
* for Inline Edit while the session is active and the editor has focus.
*/
class InlineEditKeyEventDispatcher(
private val project: Project,
private val editor: EditorEx,
private val onAccept: () -> Unit,
private val onReject: () -> Unit,
) : IdeEventQueue.EventDispatcher, Disposable {
override fun dispatch(e: AWTEvent): Boolean {
if (e !is KeyEvent) return false
if (e.id != KeyEvent.KEY_PRESSED) return false
val selected = FileEditorManager.getInstance(project).selectedTextEditor
if (selected !== editor) return false
val ks = KeyStroke.getKeyStrokeForEvent(e)
val (acceptKeys, rejectKeys) = currentInlineEditKeystrokes()
if (rejectKeys.contains(ks)) { onReject(); e.consume(); return true }
if (acceptKeys.contains(ks)) { onAccept(); e.consume(); return true }
return false
}
fun register(parent: Disposable) {
IdeEventQueue.Companion.getInstance().addDispatcher(this, parent)
}
override fun dispose() {
}
private fun currentInlineEditKeystrokes(): Pair<Set<KeyStroke>, Set<KeyStroke>> {
val km = KeymapManager.getInstance().activeKeymap
fun firstKeyStrokes(actionId: String): Set<KeyStroke> =
km.getShortcuts(actionId)
.mapNotNull { (it as? KeyboardShortcut)?.firstKeyStroke }
.toSet()
val accept = firstKeyStrokes("CodeGPT.AcceptCurrentInlineEdit")
val reject = firstKeyStrokes("CodeGPT.RejectCurrentInlineEdit")
return Pair(accept, reject)
}
}

View file

@ -0,0 +1,158 @@
package ee.carlrobert.codegpt.inlineedit
import com.intellij.openapi.actionSystem.*
import com.intellij.openapi.editor.Editor
import ee.carlrobert.codegpt.CodeGPTKeys
import java.awt.event.InputEvent
import java.awt.event.KeyEvent
import javax.swing.KeyStroke
/**
* Keyboard shortcuts for Inline Edit diff feature.
*/
class AcceptAllInlineEditAction : AnAction() {
init {
val keyStroke = KeyStroke.getKeyStroke(
KeyEvent.VK_ENTER,
InputEvent.META_DOWN_MASK or InputEvent.SHIFT_DOWN_MASK
)
shortcutSet = CustomShortcutSet(KeyboardShortcut(keyStroke, null))
}
override fun actionPerformed(e: AnActionEvent) {
val editor = e.getData(CommonDataKeys.EDITOR) ?: return
val session =
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)
if (session != null) {
session.acceptAll()
} else {
acceptAll(editor)
}
}
override fun update(e: AnActionEvent) {
val project = e.project
val editor = e.getData(CommonDataKeys.EDITOR)
e.presentation.isEnabledAndVisible =
project != null && editor != null &&
(editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER) != null
|| hasActiveInlineEdit(editor))
}
companion object {
fun acceptAll(editor: Editor) {
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)?.acceptAll()
?: editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER)?.acceptAll()
}
fun hasActiveInlineEdit(editor: Editor): Boolean =
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION) != null ||
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER) != null
}
}
class RejectAllInlineEditAction : AnAction() {
init {
val keyStroke = KeyStroke.getKeyStroke(
KeyEvent.VK_BACK_SPACE,
InputEvent.META_DOWN_MASK or InputEvent.SHIFT_DOWN_MASK
)
shortcutSet = CustomShortcutSet(KeyboardShortcut(keyStroke, null))
}
override fun actionPerformed(e: AnActionEvent) {
val editor = e.getData(CommonDataKeys.EDITOR) ?: return
val session =
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)
if (session != null) {
session.rejectAll()
} else {
rejectAll(editor)
}
}
override fun update(e: AnActionEvent) {
val project = e.project
val editor = e.getData(CommonDataKeys.EDITOR)
e.presentation.isEnabledAndVisible = project != null && editor != null
&& AcceptAllInlineEditAction.hasActiveInlineEdit(editor)
}
companion object {
fun rejectAll(editor: Editor) {
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)?.rejectAll()
?: editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER)?.rejectAll()
}
}
}
class RejectInlineEditAction : AnAction() {
init {
val keyStroke = KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0)
shortcutSet = CustomShortcutSet(KeyboardShortcut(keyStroke, null))
}
override fun actionPerformed(e: AnActionEvent) {
val editor = e.getData(CommonDataKeys.EDITOR) ?: return
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER)?.rejectNext()
}
override fun update(e: AnActionEvent) {
val project = e.project
val editor = e.getData(CommonDataKeys.EDITOR)
e.presentation.isEnabledAndVisible = project != null && editor != null &&
(editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER) != null
|| AcceptAllInlineEditAction.hasActiveInlineEdit(editor))
}
}
class AcceptCurrentInlineEditAction : AnAction() {
init {
val keyStroke = KeyStroke.getKeyStroke(
KeyEvent.VK_ENTER,
InputEvent.META_DOWN_MASK
)
shortcutSet = CustomShortcutSet(KeyboardShortcut(keyStroke, null))
}
override fun actionPerformed(e: AnActionEvent) {
val editor = e.getData(CommonDataKeys.EDITOR) ?: return
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER)?.acceptNext()
}
override fun update(e: AnActionEvent) {
val project = e.project
val editor = e.getData(CommonDataKeys.EDITOR)
e.presentation.isEnabledAndVisible = project != null && editor != null &&
(editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER) != null
|| AcceptAllInlineEditAction.hasActiveInlineEdit(editor))
}
}
class RejectCurrentInlineEditAction : AnAction() {
init {
val keyStroke = KeyStroke.getKeyStroke(
KeyEvent.VK_BACK_SPACE,
InputEvent.META_DOWN_MASK
)
shortcutSet = CustomShortcutSet(KeyboardShortcut(keyStroke, null))
}
override fun actionPerformed(e: AnActionEvent) {
val editor = e.getData(CommonDataKeys.EDITOR) ?: return
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER)?.rejectNext()
}
override fun update(e: AnActionEvent) {
val project = e.project
val editor = e.getData(CommonDataKeys.EDITOR)
e.presentation.isEnabledAndVisible = project != null && editor != null &&
(editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER) != null
|| AcceptAllInlineEditAction.hasActiveInlineEdit(editor))
}
}

View file

@ -0,0 +1,842 @@
package ee.carlrobert.codegpt.inlineedit
import com.intellij.diff.DiffManager
import com.intellij.icons.AllIcons
import com.intellij.ide.BrowserUtil
import com.intellij.notification.NotificationAction
import com.intellij.notification.NotificationType
import com.intellij.openapi.application.runInEdt
import com.intellij.openapi.application.runReadAction
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.editor.ScrollType
import com.intellij.openapi.editor.ex.EditorEx
import com.intellij.openapi.editor.markup.*
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Key
import com.intellij.openapi.util.TextRange
import com.intellij.testFramework.LightVirtualFile
import com.intellij.ui.JBColor
import com.intellij.ui.components.ActionLink
import com.intellij.ui.components.JBScrollPane
import com.intellij.util.Alarm
import com.intellij.util.ui.JBUI
import ee.carlrobert.codegpt.CodeGPTBundle
import ee.carlrobert.codegpt.CodeGPTKeys
import ee.carlrobert.codegpt.codecompletions.CompletionProgressNotifier
import ee.carlrobert.codegpt.toolwindow.chat.parser.ReplaceWaiting
import ee.carlrobert.codegpt.toolwindow.chat.parser.SearchReplace
import ee.carlrobert.codegpt.toolwindow.chat.parser.SearchWaiting
import ee.carlrobert.codegpt.toolwindow.chat.parser.SseMessageParser
import ee.carlrobert.codegpt.ui.InlineEditPopover
import ee.carlrobert.codegpt.ui.ObservableProperties
import ee.carlrobert.codegpt.ui.OverlayUtil
import ee.carlrobert.codegpt.util.EditorDiffUtil
import ee.carlrobert.llm.client.openai.completion.ErrorDetails
import ee.carlrobert.llm.completion.CompletionEventListener
import okhttp3.sse.EventSource
import java.awt.Color
import java.awt.Font
import java.util.*
import javax.swing.JComponent
import javax.swing.JLabel
import kotlin.concurrent.schedule
/**
* Simplified completion listener for Inline Edit feature that handles SEARCH/REPLACE blocks
* for the current editor file only.
*/
class InlineEditSearchReplaceListener(
private val editor: EditorEx,
private val observableProperties: ObservableProperties,
private val selectionTextRange: TextRange,
private val requestId: Long,
private val userPrompt: String
) : CompletionEventListener<String> {
private val project: Project = editor.project!!
private val logger = Logger.getInstance(InlineEditSearchReplaceListener::class.java)
private val sseMessageParser = SseMessageParser()
private val accumulatedSearchReplaceSegments = mutableListOf<SearchReplace>()
private var isStreamingComplete = false
private var previewSessionStarted = false
private var hasReceivedMessage = false
private val searchHighlighters = mutableListOf<RangeHighlighter>()
private var currentSearchPattern: String? = null
private val highlightDebounceAlarm = Alarm()
private var hintComponent: JComponent? = null
private val waitingAlarm = Alarm()
private val SEARCH_HIGHLIGHT_COLOR = JBColor(
Color(255, 235, 59, 80),
Color(255, 235, 59, 60)
)
private val REPLACE_READY_COLOR = JBColor(
Color(59, 255, 149, 80),
Color(59, 255, 149, 60)
)
enum class HighlightState {
SEARCHING,
FOUND,
REPLACING,
ERROR
}
sealed class ValidationResult {
object Success : ValidationResult()
data class Error(val message: String) : ValidationResult()
}
private fun validateSearchReplacePattern(search: String, replace: String): ValidationResult {
val searchText = search.trim()
val replaceText = replace.trim()
if (searchText == replaceText) {
return ValidationResult.Error("No changes: search and replace content are identical")
}
if (searchText.isEmpty()) {
return ValidationResult.Error("Empty search pattern")
}
return ValidationResult.Success
}
init {
waitingAlarm.addRequest({
if (!hasReceivedMessage) {
showInlineHint(CodeGPTBundle.get("inlineEdit.status.waiting"))
}
}, 1500)
}
private fun deduplicateSearchReplaceBlocks(blocks: List<Pair<String, String>>): List<Pair<String, String>> {
val deduplicated = mutableListOf<Pair<String, String>>()
val seenPatterns = mutableSetOf<String>()
for ((search, replace) in blocks) {
val normalizedSearch = search.trim()
if (seenPatterns.contains(normalizedSearch)) continue
if (normalizedSearch == replace.trim()) continue
val isSubsumed = deduplicated.any { (existingSearch, _) ->
existingSearch.contains(normalizedSearch) || normalizedSearch.contains(
existingSearch.trim()
)
}
if (!isSubsumed) {
deduplicated.add(Pair(search, replace))
seenPatterns.add(normalizedSearch)
}
}
return deduplicated
}
private fun applySimpleSearchReplace(
originalContent: String,
searchReplaceBlocks: List<Pair<String, String>>
): String {
val deduplicatedBlocks = deduplicateSearchReplaceBlocks(searchReplaceBlocks)
var currentContent = originalContent
var totalReplacements = 0
val docEol = if (originalContent.contains("\r\n")) "\r\n" else "\n"
for ((search, replace) in deduplicatedBlocks) {
val searchText = search.trim().replace("\r\n", "\n").replace("\n", docEol)
val replaceText = replace.trim().replace("\r\n", "\n").replace("\n", docEol)
if (searchText.isEmpty() && originalContent.isNotEmpty()) {
continue
}
var replacementCount =
if (searchText.isEmpty()) 0 else currentContent.split(searchText).size - 1
if (replacementCount == 0 && search.contains("...")) {
val searchStart = searchText.lines().firstOrNull()?.trim() ?: ""
if (searchStart.isNotEmpty() && currentContent.contains(searchStart)) {
val startIndex = currentContent.indexOf(searchStart)
if (startIndex >= 0) {
val lines = currentContent.split(docEol)
val startLine =
currentContent.substring(0, startIndex).split(docEol).size - 1
val searchLines = searchText.split(docEol).size
val endLine = minOf(startLine + searchLines, lines.size)
val actualPattern = lines.subList(startLine, endLine).joinToString(docEol)
if (currentContent.contains(actualPattern)) {
currentContent = currentContent.replace(actualPattern, replaceText)
totalReplacements++
continue
}
}
}
}
if (replacementCount == 0) {
val tokens = searchText.split(Regex("\\s+")).filter { it.isNotEmpty() }
if (tokens.isNotEmpty()) {
val pattern = tokens.joinToString("\\s+") { Regex.escape(it) }
val regex =
Regex(pattern, setOf(RegexOption.DOT_MATCHES_ALL, RegexOption.MULTILINE))
val match = regex.find(currentContent)
if (match != null) {
currentContent = currentContent.replaceRange(match.range, replaceText)
totalReplacements++
continue
}
}
continue
}
currentContent = currentContent.replace(searchText, replaceText)
totalReplacements += replacementCount
}
return currentContent
}
private fun isCurrentRequest(): Boolean {
val keyValue = editor.getUserData(REQUEST_ID_KEY)
return keyValue == null || keyValue == requestId
}
private fun clearAllHighlights() {
searchHighlighters.forEach {
editor.markupModel.removeHighlighter(it)
}
searchHighlighters.clear()
}
private fun createHighlighter(
range: TextRange,
color: JBColor,
tooltip: String
): RangeHighlighter = editor.markupModel.addRangeHighlighter(
range.startOffset,
range.endOffset,
HighlighterLayer.SELECTION,
TextAttributes().apply {
backgroundColor = color
effectType = EffectType.ROUNDED_BOX
effectColor = color.darker()
},
HighlighterTargetArea.EXACT_RANGE
).apply {
errorStripeTooltip = tooltip
}
private fun ensureVisible(offset: Int) {
val logicalPosition = editor.offsetToLogicalPosition(offset)
editor.scrollingModel.scrollTo(logicalPosition, ScrollType.MAKE_VISIBLE)
}
private fun targetRange(): TextRange {
return if (selectionTextRange.startOffset < selectionTextRange.endOffset) {
selectionTextRange
} else {
TextRange(0, editor.document.textLength)
}
}
override fun onMessage(message: String, eventSource: EventSource) {
if (!isCurrentRequest()) return
hasReceivedMessage = true
sseMessageParser.parse(message).forEachIndexed { _, segment ->
when (segment) {
is SearchReplace -> {
runInEdt { clearAllHighlights() }
when (val validation =
validateSearchReplacePattern(segment.search, segment.replace)) {
is ValidationResult.Success -> {
processCurrentFileSearchReplace(segment)
}
is ValidationResult.Error -> {
runInEdt {
OverlayUtil.showNotification(
"Warning: ${validation.message}",
NotificationType.WARNING
)
}
processCurrentFileSearchReplace(segment)
}
}
}
is ReplaceWaiting -> {
if (currentSearchPattern != null) {
runInEdt {
highlightSearchRegions(currentSearchPattern!!, true)
updateHighlightState(
HighlightState.FOUND,
CodeGPTBundle.get("inlineEdit.status.preparingReplacement")
)
}
}
}
is SearchWaiting -> {
currentSearchPattern = segment.search
if (segment.search.isNotEmpty()) {
runInEdt {
highlightSearchRegions(segment.search, false)
}
}
}
else -> {
}
}
}
}
override fun onComplete(completionMessageBuilder: StringBuilder) {
if (!isCurrentRequest()) return
runInEdt {
isStreamingComplete = true
clearAllHighlights()
highlightDebounceAlarm.cancelAllRequests()
hintComponent?.let {
editor.contentComponent.remove(it)
hintComponent = null
}
val userMessage = InlineEditConversationManager.addUserMessage(editor, userPrompt)
val assistantSummary = buildAssistantSummaryForConversation()
if (assistantSummary.isNotBlank()) {
InlineEditConversationManager.addAssistantResponse(userMessage, assistantSummary)
}
val hadChanges = showFinalDiff()
val popover = editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY)
popover?.observableProperties?.hasPendingChanges?.set(hadChanges)
popover?.setThinkingVisible(false)
popover?.setInlineEditControlsVisible(hadChanges)
if (hadChanges) {
val statusComponent = (editor.scrollPane as JBScrollPane).statusComponent
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_COMPARE_LINK)?.let { existing ->
statusComponent.remove(existing)
}
val compareLink = ActionLink("Open in Diff Viewer") {
try {
val originalDocText = runReadAction { editor.document.text }
val usedRange = targetRange()
val originalSelection = runReadAction { editor.document.getText(usedRange) }
val modifiedSelection = applyAllSearchReplaceOperations(
originalSelection,
accumulatedSearchReplaceSegments
)
val newContent =
if (usedRange.startOffset == 0 && usedRange.endOffset == originalDocText.length) {
modifiedSelection
} else buildString(originalDocText.length + modifiedSelection.length) {
append(originalDocText, 0, usedRange.startOffset)
append(modifiedSelection)
append(originalDocText, usedRange.endOffset, originalDocText.length)
}
val originalVf = editor.virtualFile ?: return@ActionLink
val tempFile = LightVirtualFile(originalVf.name, newContent)
val diffRequest =
EditorDiffUtil.createDiffRequest(project, tempFile, originalVf)
DiffManager.getInstance().showDiff(project, diffRequest)
} catch (e: Exception) {
OverlayUtil.showNotification(
"Failed to open diff: ${e.message}",
NotificationType.ERROR
)
}
}.apply {
icon = AllIcons.Actions.Diff
toolTipText = CodeGPTBundle.get("editor.diff.title")
border = JBUI.Borders.empty(0, 6)
}
statusComponent.add(compareLink)
editor.putUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_COMPARE_LINK, compareLink)
statusComponent.revalidate()
statusComponent.repaint()
}
stopLoading()
}
}
private fun buildAssistantSummaryForConversation(): String {
val vf = editor.virtualFile
val language = vf?.extension ?: "txt"
val path = vf?.path ?: "untitled"
if (accumulatedSearchReplaceSegments.isEmpty()) return ""
val sb = StringBuilder()
sb.append("```$language:$path\n")
accumulatedSearchReplaceSegments.forEach { seg ->
val search = seg.search.trim()
val replace = seg.replace.trim()
if (search.isEmpty() && replace.isEmpty()) return@forEach
sb.append("SEARCH\n")
sb.append(search)
sb.append("\nREPLACE\n")
sb.append(replace)
sb.append("\n---\n")
}
sb.append("```\n")
return sb.toString()
}
override fun onError(error: ErrorDetails, ex: Throwable) {
if (!isCurrentRequest()) return
runInEdt {
clearAllHighlights()
highlightDebounceAlarm.cancelAllRequests()
hintComponent?.let {
editor.contentComponent.remove(it)
hintComponent = null
}
val pop = editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY)
pop?.setThinkingVisible(false)
pop?.setInlineEditControlsVisible(false)
}
OverlayUtil.showNotification(
error.message,
NotificationType.ERROR,
NotificationAction.createSimpleExpiring("Upgrade plan") {
BrowserUtil.open("https://tryproxy.io/#pricing")
},
)
unlockEditorOnError()
stopLoading()
}
private fun processCurrentFileSearchReplace(segment: SearchReplace) {
accumulatedSearchReplaceSegments.add(segment)
try {
val result = filterApplicableSegments(accumulatedSearchReplaceSegments)
if (result.filteredCount > 0) {
showFilteredWarning(result)
}
val range = targetRange()
val originalContent = runReadAction { editor.document.getText(range) }
val modifiedSoFar = applySimpleSearchReplace(originalContent, result.pairs)
val existingSession = editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)
if (existingSession == null) {
val session = InlineEditSession.start(
project,
editor,
range,
originalContent,
modifiedSoFar
)
session.setInteractive(true)
previewSessionStarted = true
} else {
existingSession.updateProposedText(modifiedSoFar, interactive = true)
}
} catch (e: Exception) {
logger.error("Error while processing segment", e)
}
}
private fun showFinalDiff(): Boolean {
if (!isStreamingComplete) {
return false
}
try {
val range = targetRange()
val originalContent = runReadAction { editor.document.getText(range) }
val modifiedContent =
applyAllSearchReplaceOperations(originalContent, accumulatedSearchReplaceSegments)
if (modifiedContent == originalContent) {
val noChangesMsg = CodeGPTBundle.get("inlineEdit.status.noChanges")
showInlineHint(noChangesMsg)
OverlayUtil.showNotification(noChangesMsg, NotificationType.INFORMATION)
return false
}
val existingSession = editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)
if (existingSession == null) {
InlineEditSession.start(project, editor, range, originalContent, modifiedContent)
} else {
existingSession.updateProposedText(modifiedContent, interactive = true)
if (!existingSession.hasPendingHunks()) {
val noChangesMsg = CodeGPTBundle.get("inlineEdit.status.noChanges")
showInlineHint(noChangesMsg)
OverlayUtil.showNotification(noChangesMsg, NotificationType.INFORMATION)
return false
}
}
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)?.setInteractive(true)
} catch (e: Exception) {
logger.warn("Failed to build final diff", e)
return false
}
return true
}
private fun applyAllSearchReplaceOperations(
originalContent: String,
searchReplaceSegments: List<SearchReplace>
): String {
val filtered = filterApplicableSegments(searchReplaceSegments)
if (filtered.filteredCount > 0) {
showFilteredWarning(filtered)
}
return applySimpleSearchReplace(originalContent, filtered.pairs)
}
private fun filterApplicableSegments(segments: List<SearchReplace>): FilterResult {
val vf = editor.virtualFile
val currentPath = vf?.path
val fileName = vf?.name
val fileExt = vf?.extension?.lowercase()
return InlineEditFilter.filterSegments(currentPath, fileName, fileExt, segments)
}
private fun showFilteredWarning(result: FilterResult) {
if (result.filteredCount <= 0) return
runInEdt {
showInlineHint("Ignored ${result.filteredCount} invalid block(s)")
}
}
private fun stopLoading() {
observableProperties.loading.set(false)
project.let {
CompletionProgressNotifier.Companion.update(it, false)
}
val popover = editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY)
popover?.onCompletionFinished()
}
private fun unlockEditorOnError() {
if (!editor.document.isWritable) {
editor.document.setReadOnly(false)
}
}
fun dispose() {
clearAllHighlights()
highlightDebounceAlarm.cancelAllRequests()
hintComponent?.let {
editor.contentComponent.remove(it)
}
editor.putUserData(LISTENER_KEY, null)
}
private fun findPatternInContent(
content: String,
pattern: String,
fuzzyMatch: Boolean = true
): List<TextRange> {
val matches = mutableListOf<TextRange>()
val cleanPattern = pattern.trim()
if (cleanPattern.isEmpty()) {
return matches
}
var index = content.indexOf(cleanPattern)
while (index >= 0) {
matches.add(TextRange(index, index + cleanPattern.length))
index = content.indexOf(cleanPattern, index + 1)
}
if (matches.isEmpty() && fuzzyMatch && (pattern.contains("...") || cleanPattern.length < pattern.length)) {
val partialMatches = findPartialMatches(content, cleanPattern)
matches.addAll(partialMatches)
}
return matches
}
private fun findPartialMatches(content: String, pattern: String): List<TextRange> {
val identifiers = extractIdentifiers(pattern)
if (identifiers.isEmpty()) {
val firstLine = pattern.lines().firstOrNull()?.trim()
if (!firstLine.isNullOrEmpty() && content.contains(firstLine)) {
val index = content.indexOf(firstLine)
return listOf(expandToLogicalBlock(content, index))
}
return emptyList()
}
val matches = mutableListOf<TextRange>()
val lines = content.lines()
for (i in lines.indices) {
if (identifiers.all { identifier -> lines[i].contains(identifier) }) {
val blockRange = expandToLogicalBlock(content, getLineStartOffset(content, i))
matches.add(blockRange)
}
}
return matches
}
private fun extractIdentifiers(pattern: String): List<String> {
val identifierRegex =
"\\b(class|function|def|var|val|let|const|public|private)\\s+(\\w+)".toRegex()
val methodRegex = "\\b(\\w+)\\s*\\(".toRegex()
val variableRegex = "\\b[a-zA-Z_][a-zA-Z0-9_]{2,}\\b".toRegex()
val identifiers = mutableSetOf<String>()
identifierRegex.findAll(pattern).forEach { match ->
identifiers.add(match.groupValues[2])
}
methodRegex.findAll(pattern).forEach { match ->
identifiers.add(match.groupValues[1])
}
variableRegex.findAll(pattern).forEach { match ->
val identifier = match.value
if (identifier.length > 2 && !identifier.matches("\\d+".toRegex())) {
identifiers.add(identifier)
}
}
return identifiers.toList().take(3)
}
private fun expandToLogicalBlock(content: String, charOffset: Int): TextRange {
val lines = content.lines()
val lineIndex = getLineIndex(content, charOffset)
if (lineIndex >= lines.size) {
return TextRange(charOffset, charOffset)
}
var startLine = lineIndex
var endLine = lineIndex
var braceCount = 0
for (i in lineIndex downTo 0) {
val line = lines[i]
if (line.contains("{")) braceCount++
if (line.contains("}")) braceCount--
if (braceCount > 0 || isBlockStart(line)) {
startLine = i
break
}
}
braceCount = 0
for (i in lineIndex until lines.size) {
val line = lines[i]
if (line.contains("{")) braceCount++
if (line.contains("}")) braceCount--
if (braceCount == 0 && line.contains("}") && i > lineIndex) {
endLine = i
break
}
}
return convertLinesToTextRange(content, startLine, endLine)
}
private fun isBlockStart(line: String): Boolean {
val trimmed = line.trim()
return trimmed.startsWith("class ") ||
trimmed.startsWith("function ") ||
trimmed.startsWith("def ") ||
trimmed.startsWith("public ") ||
trimmed.startsWith("private ") ||
trimmed.contains("{") ||
trimmed.endsWith(":")
}
private fun getLineIndex(content: String, charOffset: Int): Int {
return content.substring(0, charOffset).count { it == '\n' }
}
private fun getLineStartOffset(content: String, lineIndex: Int): Int {
val lines = content.lines()
var offset = 0
for (i in 0 until lineIndex) {
if (i < lines.size) {
offset += lines[i].length + 1
}
}
return offset
}
private fun convertLinesToTextRange(content: String, startLine: Int, endLine: Int): TextRange {
val lines = content.lines()
var startOffset = 0
for (i in 0 until startLine) {
if (i < lines.size) {
startOffset += lines[i].length + 1
}
}
var endOffset = startOffset
for (i in startLine..endLine) {
if (i < lines.size) {
endOffset += lines[i].length
if (i < lines.size - 1) endOffset += 1
}
}
return TextRange(startOffset, endOffset)
}
private fun highlightSearchRegions(pattern: String, isReplaceReady: Boolean = false) {
runInEdt {
clearAllHighlights()
if (pattern.isEmpty()) {
return@runInEdt
}
val hasSelection = selectionTextRange.startOffset < selectionTextRange.endOffset
val (content, baseOffset) = if (hasSelection) {
editor.document.getText(selectionTextRange) to selectionTextRange.startOffset
} else {
editor.document.text to 0
}
val matches = findPatternInContent(content, pattern)
if (matches.isEmpty()) {
showPatternNotFoundHint(pattern)
return@runInEdt
}
matches.forEach { range ->
val absoluteRange =
TextRange(baseOffset + range.startOffset, baseOffset + range.endOffset)
val color = if (isReplaceReady) REPLACE_READY_COLOR else SEARCH_HIGHLIGHT_COLOR
val tooltip = if (isReplaceReady) CodeGPTBundle.get("inlineEdit.tooltip.ready")
else CodeGPTBundle.get("inlineEdit.tooltip.searching")
searchHighlighters.add(createHighlighter(absoluteRange, color, tooltip))
}
if (matches.isNotEmpty()) {
ensureVisible(baseOffset + matches.first().startOffset)
}
}
}
private fun updateHighlightState(state: HighlightState, message: String? = null) {
val color = when (state) {
HighlightState.SEARCHING -> SEARCH_HIGHLIGHT_COLOR
HighlightState.FOUND -> REPLACE_READY_COLOR
HighlightState.REPLACING -> JBColor(
Color(59, 149, 255, 80),
Color(59, 149, 255, 60)
)
HighlightState.ERROR -> JBColor(Color(255, 59, 59, 80), Color(255, 59, 59, 60))
}
searchHighlighters.forEach { highlighter ->
highlighter.getTextAttributes(editor.colorsScheme)?.backgroundColor = color
}
if (message != null) {
showInlineHint(message)
}
}
private fun showPatternNotFoundHint(pattern: String) {
val shortPattern = if (pattern.length > 30) "${pattern.take(30)}..." else pattern
showInlineHint(
CodeGPTBundle.get("inlineEdit.hint.searchingFor", shortPattern)
)
}
private fun showInlineHint(message: String) {
runInEdt {
hintComponent?.let {
editor.contentComponent.remove(it)
}
val hint = JLabel(message).apply {
foreground = JBColor.GRAY
font = font.deriveFont(Font.ITALIC, 12f)
border = JBUI.Borders.empty(2, 8)
background = editor.backgroundColor
isOpaque = true
}
val targetOffset = if (searchHighlighters.isNotEmpty()) {
searchHighlighters.first().startOffset
} else {
selectionTextRange.startOffset
}
val comp = editor.contentComponent
val point = editor.visualPositionToXY(editor.offsetToVisualPosition(targetOffset))
val visible = comp.visibleRect
val prefW = 300
val prefH = 20
var x = point.x
var y = point.y - 25
if (x < visible.x) x = visible.x + JBUI.scale(8)
if (y < visible.y) y = visible.y + JBUI.scale(8)
if (x + prefW > visible.x + visible.width) x =
visible.x + visible.width - prefW - JBUI.scale(8)
if (y + prefH > visible.y + visible.height) y =
visible.y + visible.height - prefH - JBUI.scale(8)
hint.setBounds(x, y, prefW, prefH)
comp.add(hint)
hintComponent = hint
Timer().schedule(3000) {
runInEdt {
if (hintComponent == hint) {
comp.remove(hint)
comp.repaint()
hintComponent = null
}
}
}
}
}
fun showHint(message: String) {
showInlineHint(message)
}
companion object {
val LISTENER_KEY =
Key.create<InlineEditSearchReplaceListener>("InlineEditSearchReplaceListener")
val REQUEST_ID_KEY = Key.create<Long>("InlineEditRequestId")
}
}

View file

@ -0,0 +1,357 @@
package ee.carlrobert.codegpt.inlineedit
import com.intellij.diff.comparison.ComparisonManager
import com.intellij.diff.comparison.ComparisonPolicy
import com.intellij.openapi.Disposable
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.CustomShortcutSet
import com.intellij.openapi.actionSystem.EmptyAction
import com.intellij.openapi.actionSystem.KeyboardShortcut
import com.intellij.openapi.application.runReadAction
import com.intellij.openapi.command.WriteCommandAction
import com.intellij.openapi.editor.RangeMarker
import com.intellij.openapi.editor.ex.EditorEx
import com.intellij.openapi.keymap.KeymapManager
import com.intellij.openapi.progress.EmptyProgressIndicator
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.SystemInfo
import com.intellij.openapi.util.TextRange
import com.intellij.ui.components.JBScrollPane
import ee.carlrobert.codegpt.CodeGPTKeys
import ee.carlrobert.codegpt.inlineedit.InlineEditInlayRenderer
import ee.carlrobert.codegpt.inlineedit.InlineEditKeyEventDispatcher
import ee.carlrobert.codegpt.ui.InlineEditPopover
import java.awt.event.InputEvent
import java.awt.event.KeyEvent
import javax.swing.KeyStroke
import kotlin.math.abs
class InlineEditSession(
private val project: Project,
private val editor: EditorEx,
private val baseRange: TextRange,
private val initialBaseText: String,
private var proposedText: String
) : Disposable {
data class Hunk(
val baseMarker: RangeMarker,
val proposedSlice: String,
val startOffset: Int,
val endOffset: Int,
var accepted: Boolean = false,
var rejected: Boolean = false
)
private val renderer = InlineEditInlayRenderer(editor, project)
private val hunks = mutableListOf<Hunk>()
private val lockedRanges = mutableListOf<RangeMarker>()
private val rejectedRanges = mutableListOf<RangeMarker>()
private val rootMarker: RangeMarker = runReadAction {
editor.document.createRangeMarker(baseRange.startOffset, baseRange.endOffset, true).apply {
isGreedyToLeft = true
isGreedyToRight = true
}
}
init {
buildAndRenderHunks()
editor.putUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION, this)
editor.putUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER, renderer)
registerEditorScopedShortcuts()
InlineEditKeyEventDispatcher(
project,
editor,
onAccept = { acceptNearestToCaret() },
onReject = { rejectNearestToCaret() }
).register(this)
}
private fun buildAndRenderHunks() {
val newHunks = computeHunks()
hunks.clear()
hunks.addAll(newHunks)
renderer.renderHunks(hunks)
}
private fun computeHunks(): List<Hunk> {
val (baseNow, baseStartOffset) = runReadAction {
val start = rootMarker.startOffset.coerceAtLeast(0)
val end =
rootMarker.endOffset.coerceAtLeast(start).coerceAtMost(editor.document.textLength)
Pair(editor.document.getText(TextRange(start, end)), start)
}
val lineFragments = ComparisonManager.getInstance()
.compareLines(baseNow, proposedText, ComparisonPolicy.DEFAULT, EmptyProgressIndicator())
if (lineFragments.isEmpty()) return emptyList()
val baseLineOffsets = computeLineStartOffsets(baseNow)
val proposedLineOffsets = computeLineStartOffsets(proposedText)
val rawHunks = runReadAction {
val list = mutableListOf<Hunk>()
val docLength = editor.document.textLength
for (frag in lineFragments) {
val baseStart =
if (frag.startLine1 < baseLineOffsets.size) baseLineOffsets[frag.startLine1] else baseNow.length
val baseEnd =
if (frag.endLine1 < baseLineOffsets.size) baseLineOffsets[frag.endLine1] else baseNow.length
val proposedStart =
if (frag.startLine2 < proposedLineOffsets.size) proposedLineOffsets[frag.startLine2] else proposedText.length
val proposedEnd =
if (frag.endLine2 < proposedLineOffsets.size) proposedLineOffsets[frag.endLine2] else proposedText.length
val oldSlice = safeSlice(baseNow, baseStart, baseEnd)
val newSlice = safeSlice(proposedText, proposedStart, proposedEnd)
if (oldSlice == newSlice) continue
val rawStart = baseStartOffset + baseStart
val rawEnd = baseStartOffset + baseEnd
val start = rawStart.coerceIn(0, docLength)
val end = rawEnd.coerceIn(start, docLength)
val marker = editor.document.createRangeMarker(start, end, true).apply {
isGreedyToLeft = true
isGreedyToRight = true
}
list.add(Hunk(marker, newSlice, start, end))
}
list
}
return rawHunks.filter { h ->
lockedRanges.none { lock ->
rangesOverlap(
h.startOffset,
h.endOffset,
lock.startOffset,
lock.endOffset
)
} &&
rejectedRanges.none { rej ->
rangesOverlap(
h.startOffset,
h.endOffset,
rej.startOffset,
rej.endOffset
)
}
}
}
fun updateProposedText(newText: String, interactive: Boolean) {
this.proposedText = newText
val newHunks = computeHunks()
hunks.clear()
hunks.addAll(newHunks)
renderer.setInteractive(interactive)
renderer.replaceHunks(hunks)
}
fun acceptNearestToCaret() {
val caret = editor.caretModel.offset
val next = hunks
.filter { !it.accepted && !it.rejected }
.minByOrNull { abs(it.startOffset - caret) }
if (next != null) acceptHunk(next)
}
fun rejectNearestToCaret() {
val caret = editor.caretModel.offset
val next = hunks
.filter { !it.accepted && !it.rejected }
.minByOrNull { abs(it.startOffset - caret) }
if (next != null) rejectHunk(next)
}
fun acceptAll() {
editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY)?.markChangesAsAccepted()
hunks
.filter { !it.accepted && !it.rejected }
.sortedByDescending { it.baseMarker.startOffset }
.forEach { acceptHunk(it) }
removeCompareLinkIfAny()
dispose()
}
fun rejectAll() {
hunks
.filter { !it.accepted && !it.rejected }
.forEach { rejectHunk(it) }
removeCompareLinkIfAny()
editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY)
?.triggerPromptRestoration()
dispose()
}
private fun acceptHunk(hunk: Hunk) {
if (hunk.accepted || hunk.rejected) return
val start = hunk.baseMarker.startOffset
val end = hunk.baseMarker.endOffset
WriteCommandAction.runWriteCommandAction(project) {
editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY)?.markChangesAsAccepted()
editor.document.replaceString(start, end, hunk.proposedSlice)
hunk.accepted = true
val newEnd = start + hunk.proposedSlice.length
val lock = editor.document.createRangeMarker(start, newEnd, true).apply {
isGreedyToLeft = true
isGreedyToRight = true
}
lockedRanges.add(lock)
val newHunks = computeHunks()
hunks.clear()
hunks.addAll(newHunks)
renderer.replaceHunks(hunks)
if (hunks.isEmpty()) {
editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY)
?.setInlineEditControlsVisible(false)
removeCompareLinkIfAny()
}
}
}
private fun rejectHunk(hunk: Hunk) {
if (hunk.accepted || hunk.rejected) return
hunk.rejected = true
val start = hunk.baseMarker.startOffset
val end = hunk.baseMarker.endOffset
val safeStart = start.coerceIn(0, editor.document.textLength)
val safeEnd = end.coerceIn(safeStart, editor.document.textLength)
val marker = editor.document.createRangeMarker(safeStart, safeEnd, true).apply {
isGreedyToLeft = true
isGreedyToRight = true
}
rejectedRanges.add(marker)
val newHunks = computeHunks()
hunks.clear()
hunks.addAll(newHunks)
renderer.replaceHunks(hunks)
if (hunks.isEmpty()) {
editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY)
?.setInlineEditControlsVisible(false)
removeCompareLinkIfAny()
}
}
fun accept(hunk: Hunk) = acceptHunk(hunk)
fun reject(hunk: Hunk) = rejectHunk(hunk)
fun setInteractive(enabled: Boolean) = renderer.setInteractive(enabled)
fun hasPendingHunks(): Boolean {
return hunks.any { !it.accepted && !it.rejected }
}
private fun rangesOverlap(aStart: Int, aEnd: Int, bStart: Int, bEnd: Int): Boolean {
val start = maxOf(aStart, bStart)
val end = minOf(aEnd, bEnd)
return start < end
}
override fun dispose() {
editor.putUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION, null)
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER)?.dispose()
editor.putUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER, null)
editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY)?.setInlineEditControlsVisible(false)
removeCompareLinkIfAny()
}
private fun removeCompareLinkIfAny() {
val comp = editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_COMPARE_LINK) ?: return
val statusComponent = (editor.scrollPane as JBScrollPane).statusComponent
statusComponent.remove(comp)
statusComponent.revalidate()
statusComponent.repaint()
editor.putUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_COMPARE_LINK, null)
}
private fun computeLineStartOffsets(text: String): IntArray {
val lines = text.split('\n')
val offsets = IntArray(lines.size + 1)
var sum = 0
for (i in lines.indices) {
offsets[i] = sum
sum += lines[i].length + 1
}
offsets[lines.size] = sum
return offsets
}
private fun safeSlice(text: String, start: Int, end: Int): String {
val s = start.coerceIn(0, text.length)
val e = end.coerceIn(s, text.length)
return text.substring(s, e)
}
companion object {
fun start(
project: Project,
editor: EditorEx,
baseRange: TextRange,
baseText: String,
proposedText: String
): InlineEditSession {
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)?.dispose()
return InlineEditSession(project, editor, baseRange, baseText, proposedText)
}
}
private fun registerEditorScopedShortcuts() {
val am = ActionManager.getInstance()
am.getAction("CodeGPT.AcceptCurrentInlineEdit")?.let { action ->
val ks = resolvePreferredKeyStroke("CodeGPT.AcceptCurrentInlineEdit", KeyEvent.VK_Y)
val wrapped = EmptyAction.wrap(action)
wrapped.registerCustomShortcutSet(
CustomShortcutSet(KeyboardShortcut(ks, null)),
editor.contentComponent,
this
)
}
am.getAction("CodeGPT.RejectCurrentInlineEdit")?.let { action ->
val ks = resolvePreferredKeyStroke("CodeGPT.RejectCurrentInlineEdit", KeyEvent.VK_N)
val wrapped = EmptyAction.wrap(action)
wrapped.registerCustomShortcutSet(
CustomShortcutSet(KeyboardShortcut(ks, null)),
editor.contentComponent,
this
)
}
am.getAction("codegpt.acceptInlineEdit")?.let { editorAction ->
val metaEnter = KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, InputEvent.META_DOWN_MASK)
val ctrlEnter = KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, InputEvent.CTRL_DOWN_MASK)
val shortcuts = arrayOf(
KeyboardShortcut(metaEnter, null),
KeyboardShortcut(ctrlEnter, null)
)
val wrapped = EmptyAction.wrap(editorAction)
wrapped.registerCustomShortcutSet(
CustomShortcutSet(*shortcuts),
editor.contentComponent,
this
)
}
}
private fun resolvePreferredKeyStroke(actionId: String, keyCode: Int): KeyStroke {
val keymap = KeymapManager.getInstance().activeKeymap
val shortcuts = keymap.getShortcuts(actionId)
val macKs = KeyStroke.getKeyStroke(keyCode, InputEvent.META_DOWN_MASK)
val winKs = KeyStroke.getKeyStroke(keyCode, InputEvent.CTRL_DOWN_MASK)
val fromKeymap =
shortcuts.firstOrNull { (it as? KeyboardShortcut)?.firstKeyStroke == macKs }
?: shortcuts.firstOrNull { (it as? KeyboardShortcut)?.firstKeyStroke == winKs }
val ks = (fromKeymap as? KeyboardShortcut)?.firstKeyStroke
if (ks != null) return ks
return if (SystemInfo.isMac) macKs else winKs
}
}

View file

@ -0,0 +1,175 @@
package ee.carlrobert.codegpt.inlineedit
import com.intellij.notification.NotificationType
import com.intellij.openapi.application.runInEdt
import com.intellij.openapi.application.runReadAction
import com.intellij.openapi.command.WriteCommandAction
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.ex.EditorEx
import com.intellij.openapi.fileEditor.FileDocumentManager
import com.intellij.openapi.util.TextRange
import com.intellij.openapi.util.text.StringUtil
import ee.carlrobert.codegpt.CodeGPTKeys
import ee.carlrobert.codegpt.ReferencedFile
import ee.carlrobert.codegpt.codecompletions.CompletionProgressNotifier
import ee.carlrobert.codegpt.completions.CompletionRequestService
import ee.carlrobert.codegpt.completions.InlineEditCompletionParameters
import ee.carlrobert.codegpt.conversations.Conversation
import ee.carlrobert.codegpt.ui.InlineEditPopover
import ee.carlrobert.codegpt.ui.ObservableProperties
import ee.carlrobert.codegpt.ui.OverlayUtil
import okhttp3.sse.EventSource
import java.util.concurrent.atomic.AtomicReference
class InlineEditSubmissionHandler(
private val editor: Editor,
private val observableProperties: ObservableProperties,
) {
private val previousSourceRef = AtomicReference<String?>(null)
private val previousPromptRef = AtomicReference<String?>(null)
private val currentEventSourceRef = AtomicReference<EventSource?>(null)
private val logger = Logger.getInstance(InlineEditSubmissionHandler::class.java)
fun handleSubmit(
userPrompt: String,
referencedFiles: List<ReferencedFile>? = null,
gitDiff: String? = null,
conversationHistory: List<Conversation>? = null,
diagnosticsInfo: String? = null
) {
editor.project?.let {
CompletionProgressNotifier.Companion.update(it, true)
}
observableProperties.loading.set(true)
observableProperties.submitted.set(true)
previousPromptRef.getAndSet(userPrompt)
previousSourceRef.getAndSet(editor.document.text)
runInEdt { editor.selectionModel.removeSelection() }
val file = FileDocumentManager.getInstance().getFile(editor.document)
val editorEx = editor as? EditorEx ?: return
val parameters = InlineEditCompletionParameters(
userPrompt,
runReadAction { editor.selectionModel.selectedText },
file?.path,
file?.extension,
editor.project?.basePath,
referencedFiles,
gitDiff,
InlineEditConversationManager.getOrCreate(editorEx),
conversationHistory,
diagnosticsInfo
)
val requestId = System.nanoTime()
editorEx.putUserData(InlineEditSearchReplaceListener.REQUEST_ID_KEY, requestId)
val listener = InlineEditSearchReplaceListener(
editorEx,
observableProperties,
TextRange(
runReadAction { editor.selectionModel.selectionStart },
runReadAction { editor.selectionModel.selectionEnd },
),
requestId,
userPrompt
)
editorEx.putUserData(InlineEditSearchReplaceListener.LISTENER_KEY, listener)
listener.showHint("Submitting inline edit…")
editorEx.getUserData(InlineEditPopover.Companion.POPOVER_KEY)?.apply {
setInlineEditControlsVisible(false)
setThinkingVisible(true)
}
try {
currentEventSourceRef.getAndSet(null)?.cancel()
val eventSource = service<CompletionRequestService>().getInlineEditCompletionAsync(
parameters,
listener
)
currentEventSourceRef.set(eventSource)
} catch (ex: Exception) {
logger.warn("InlineEdit: request dispatch failed", ex)
runInEdt {
OverlayUtil.showNotification(
ex.message ?: "Inline Edit request failed",
NotificationType.ERROR
)
observableProperties.loading.set(false)
observableProperties.submitted.set(false)
editorEx.getUserData(InlineEditPopover.Companion.POPOVER_KEY)
?.setThinkingVisible(false)
}
}
}
fun handleReject(clearConversation: Boolean = false) {
cancelActiveRequest()
(editor as? EditorEx)?.getUserData(InlineEditPopover.Companion.POPOVER_KEY)?.setThinkingVisible(false)
val prevSource = previousSourceRef.get()
if (!observableProperties.accepted.get() && prevSource != null) {
revertAllChanges(prevSource)
}
restorePreviousPrompt()
runInEdt {
val editorEx = editor as? EditorEx
editorEx?.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)?.dispose()
editorEx?.putUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION, null)
editorEx?.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER)?.dispose()
editorEx?.putUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER, null)
if (clearConversation) {
editorEx?.let { InlineEditConversationManager.clear(it) }
}
editorEx?.putUserData(InlineEditSearchReplaceListener.LISTENER_KEY, null)
observableProperties.loading.set(false)
observableProperties.submitted.set(false)
editor.project?.let { project ->
CompletionProgressNotifier.Companion.update(project, false)
}
editorEx?.getUserData(InlineEditPopover.Companion.POPOVER_KEY)?.onCompletionFinished()
}
}
private fun cancelActiveRequest() {
val editorEx = editor as? EditorEx
val newRequestId = System.nanoTime()
editorEx?.putUserData(InlineEditSearchReplaceListener.REQUEST_ID_KEY, newRequestId)
currentEventSourceRef.getAndSet(null)?.cancel()
}
private fun revertAllChanges(prevSource: String) {
editor.project?.let { project ->
WriteCommandAction.runWriteCommandAction(project) {
editor.document.replaceString(
0,
editor.document.textLength,
StringUtil.convertLineSeparators(prevSource)
)
}
}
}
fun restorePreviousPrompt() {
val prevPrompt = previousPromptRef.get()
if (prevPrompt != null) {
(editor as? EditorEx)
?.getUserData(InlineEditPopover.Companion.POPOVER_KEY)
?.restorePromptAndFocus(prevPrompt)
}
}
}

View file

@ -4,7 +4,6 @@ import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.thisLogger
import ee.carlrobert.codegpt.settings.GeneralSettings
import ee.carlrobert.codegpt.settings.models.ModelRegistry
import ee.carlrobert.codegpt.settings.models.ModelSettings
import ee.carlrobert.codegpt.settings.models.ModelSettingsState
import ee.carlrobert.codegpt.settings.service.FeatureType
import ee.carlrobert.codegpt.settings.service.ServiceType
@ -45,7 +44,7 @@ object LegacySettingsMigration {
setModelSelection(FeatureType.CHAT, chatModel, selectedService)
setModelSelection(FeatureType.AUTO_APPLY, chatModel, selectedService)
setModelSelection(FeatureType.COMMIT_MESSAGE, chatModel, selectedService)
setModelSelection(FeatureType.EDIT_CODE, chatModel, selectedService)
setModelSelection(FeatureType.INLINE_EDIT, chatModel, selectedService)
setModelSelection(FeatureType.LOOKUP, chatModel, selectedService)
val codeModel = getLegacyCodeModelForService(selectedService)

View file

@ -48,7 +48,7 @@ class ModelRegistry {
FeatureType.CODE_COMPLETION,
FeatureType.AUTO_APPLY,
FeatureType.COMMIT_MESSAGE,
FeatureType.EDIT_CODE,
FeatureType.INLINE_EDIT,
FeatureType.NEXT_EDIT,
FeatureType.LOOKUP
)
@ -57,49 +57,49 @@ class ModelRegistry {
ServiceType.OPENAI,
setOf(
FeatureType.CHAT, FeatureType.CODE_COMPLETION, FeatureType.AUTO_APPLY,
FeatureType.COMMIT_MESSAGE, FeatureType.EDIT_CODE, FeatureType.LOOKUP
FeatureType.COMMIT_MESSAGE, FeatureType.INLINE_EDIT, FeatureType.LOOKUP
)
),
ServiceType.ANTHROPIC to ModelCapability(
ServiceType.ANTHROPIC,
setOf(
FeatureType.CHAT, FeatureType.AUTO_APPLY, FeatureType.COMMIT_MESSAGE,
FeatureType.EDIT_CODE, FeatureType.LOOKUP
FeatureType.INLINE_EDIT, FeatureType.LOOKUP
)
),
ServiceType.GOOGLE to ModelCapability(
ServiceType.GOOGLE,
setOf(
FeatureType.CHAT, FeatureType.AUTO_APPLY, FeatureType.COMMIT_MESSAGE,
FeatureType.EDIT_CODE, FeatureType.LOOKUP
FeatureType.INLINE_EDIT, FeatureType.LOOKUP
)
),
ServiceType.MISTRAL to ModelCapability(
ServiceType.MISTRAL,
setOf(
FeatureType.CHAT, FeatureType.CODE_COMPLETION, FeatureType.AUTO_APPLY,
FeatureType.COMMIT_MESSAGE, FeatureType.EDIT_CODE, FeatureType.LOOKUP
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.EDIT_CODE, FeatureType.LOOKUP
FeatureType.COMMIT_MESSAGE, FeatureType.INLINE_EDIT, FeatureType.LOOKUP
)
),
ServiceType.LLAMA_CPP to ModelCapability(
ServiceType.LLAMA_CPP,
setOf(
FeatureType.CHAT, FeatureType.CODE_COMPLETION, FeatureType.AUTO_APPLY,
FeatureType.COMMIT_MESSAGE, FeatureType.EDIT_CODE, FeatureType.LOOKUP
FeatureType.COMMIT_MESSAGE, FeatureType.INLINE_EDIT, FeatureType.LOOKUP
)
),
ServiceType.CUSTOM_OPENAI to ModelCapability(
ServiceType.CUSTOM_OPENAI,
setOf(
FeatureType.CHAT, FeatureType.CODE_COMPLETION, FeatureType.AUTO_APPLY,
FeatureType.COMMIT_MESSAGE, FeatureType.EDIT_CODE, FeatureType.LOOKUP
FeatureType.COMMIT_MESSAGE, FeatureType.INLINE_EDIT, FeatureType.LOOKUP
)
)
)
@ -121,7 +121,7 @@ class ModelRegistry {
GPT_5_MINI,
"GPT-5 Mini"
),
FeatureType.EDIT_CODE to ModelSelection(
FeatureType.INLINE_EDIT to ModelSelection(
ServiceType.PROXYAI,
GPT_5_MINI,
"GPT-5 Mini"
@ -150,7 +150,7 @@ class ModelRegistry {
QWEN3_CODER,
"Qwen3 Coder"
),
FeatureType.EDIT_CODE to ModelSelection(
FeatureType.INLINE_EDIT to ModelSelection(
ServiceType.PROXYAI,
QWEN3_CODER,
"Qwen3 Coder"
@ -171,7 +171,7 @@ class ModelRegistry {
),
FeatureType.AUTO_APPLY to ModelSelection(ServiceType.PROXYAI, GPT_5, "GPT-5"),
FeatureType.COMMIT_MESSAGE to ModelSelection(ServiceType.PROXYAI, GPT_5, "GPT-5"),
FeatureType.EDIT_CODE to ModelSelection(
FeatureType.INLINE_EDIT to ModelSelection(
ServiceType.PROXYAI,
CLAUDE_4_SONNET,
"Claude 4 Sonnet"
@ -202,7 +202,7 @@ class ModelRegistry {
GPT_5_MINI,
"GPT-5 Mini"
),
FeatureType.EDIT_CODE to ModelSelection(ServiceType.PROXYAI, GPT_5_MINI, "GPT-5 Mini"),
FeatureType.INLINE_EDIT to ModelSelection(ServiceType.PROXYAI, GPT_5_MINI, "GPT-5 Mini"),
FeatureType.LOOKUP to ModelSelection(ServiceType.PROXYAI, GPT_5_MINI, "GPT-5 Mini"),
FeatureType.CODE_COMPLETION to ModelSelection(
ServiceType.PROXYAI,
@ -215,7 +215,7 @@ class ModelRegistry {
fun getAllModelsForFeature(featureType: FeatureType): List<ModelSelection> {
return when (featureType) {
FeatureType.CHAT, FeatureType.AUTO_APPLY, FeatureType.COMMIT_MESSAGE,
FeatureType.EDIT_CODE, FeatureType.LOOKUP -> getAllChatModels()
FeatureType.INLINE_EDIT, FeatureType.LOOKUP -> getAllChatModels()
FeatureType.CODE_COMPLETION -> getAllCodeModels()
FeatureType.NEXT_EDIT -> getNextEditModels()

View file

@ -2,8 +2,6 @@ package ee.carlrobert.codegpt.settings.models
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.*
import com.intellij.openapi.diagnostic.thisLogger
import ee.carlrobert.codegpt.settings.migration.LegacySettingsMigration
import ee.carlrobert.codegpt.settings.service.FeatureType
import ee.carlrobert.codegpt.settings.service.ModelChangeNotifier
import ee.carlrobert.codegpt.settings.service.ServiceType
@ -32,7 +30,7 @@ class ModelSettings : SimplePersistentStateComponent<ModelSettingsState>(ModelSe
FeatureType.COMMIT_MESSAGE to PublisherMethod { publisher, model, serviceType ->
publisher.commitMessageModelChanged(model, serviceType)
},
FeatureType.EDIT_CODE to PublisherMethod { publisher, model, serviceType ->
FeatureType.INLINE_EDIT to PublisherMethod { publisher, model, serviceType ->
publisher.editCodeModelChanged(model, serviceType)
},
FeatureType.NEXT_EDIT to PublisherMethod { publisher, model, serviceType ->

View file

@ -48,7 +48,7 @@ class ModelSettingsForm(
FeatureType.COMMIT_MESSAGE,
"settings.models.commitMessages.label"
),
FeatureConfig(FeatureType.EDIT_CODE, "settings.models.editCode.label"),
FeatureConfig(FeatureType.INLINE_EDIT, "settings.models.editCode.label"),
FeatureConfig(FeatureType.LOOKUP, "settings.models.nameLookups.label")
)
),
@ -90,7 +90,7 @@ class ModelSettingsForm(
}
override fun editCodeModelChanged(newModel: String, serviceType: ServiceType) {
modelChanged(FeatureType.EDIT_CODE, newModel, serviceType)
modelChanged(FeatureType.INLINE_EDIT, newModel, serviceType)
}
override fun nextEditModelChanged(newModel: String, serviceType: ServiceType) {

View file

@ -9,7 +9,10 @@ class CompleteMessageParser : MessageParser {
private val CODE_BLOCK_PATTERN: Pattern =
Pattern.compile("```([a-zA-Z0-9_+-]*)(?::([^\\n]*))?\\n(.*?)```", Pattern.DOTALL)
private val SEARCH_REPLACE_PATTERN: Pattern =
Pattern.compile("<<<<<<< SEARCH\\n(.*?)\\n=======\\n(.*?)\\n>>>>>>> REPLACE", Pattern.DOTALL)
Pattern.compile(
"<<<<<<< SEARCH\\n(.*?)\\n=======\\n(.*?)\\n>>>>>>> REPLACE",
Pattern.DOTALL
)
private val INCOMPLETE_SEARCH_REPLACE_PATTERN: Pattern =
Pattern.compile("<<<<<<< SEARCH\\n(.*?)(?:\\n=======\\n(.*?))?$", Pattern.DOTALL)
@ -20,6 +23,12 @@ class CompleteMessageParser : MessageParser {
private const val CODE_CONTENT_GROUP_INDEX = 3
private const val SEARCH_CONTENT_GROUP_INDEX = 1
private const val REPLACE_CONTENT_GROUP_INDEX = 2
private val TOLERANT_SEARCH_START =
Regex("""^\s*<{3,}(\s*SEARCH.*)?$""", RegexOption.IGNORE_CASE)
private val TOLERANT_SEPARATOR = Regex("""^\s*={3,}\s*$""")
private val TOLERANT_REPLACE_END =
Regex("""^\s*>{3,}(\s*REPLACE.*)?$""", RegexOption.IGNORE_CASE)
}
var extractedThought: String? = null
@ -137,12 +146,25 @@ class CompleteMessageParser : MessageParser {
while (searchReplaceMatcher.find()) {
foundSearchReplace = true
addCodeSegmentIfExists(codeContent, lastProcessedIndex, searchReplaceMatcher.start(), language, filePath)
addCodeSegmentIfExists(
codeContent,
lastProcessedIndex,
searchReplaceMatcher.start(),
language,
filePath
)
addSearchReplaceSegment(searchReplaceMatcher, language, filePath)
lastProcessedIndex = searchReplaceMatcher.end()
}
if (!foundSearchReplace) {
val tolerantResult = tolerantScanSearchReplace(codeContent, language, filePath)
if (tolerantResult != null) {
addAll(tolerantResult.segments)
lastProcessedIndex = tolerantResult.lastIndex
foundSearchReplace = true
}
val incompleteMatch = findIncompleteSearchReplace(codeContent, language, filePath)
if (incompleteMatch != null) {
addAll(incompleteMatch.segments)
@ -152,7 +174,13 @@ class CompleteMessageParser : MessageParser {
}
if (foundSearchReplace) {
addCodeSegmentIfExists(codeContent, lastProcessedIndex, codeContent.length, language, filePath)
addCodeSegmentIfExists(
codeContent,
lastProcessedIndex,
codeContent.length,
language,
filePath
)
}
}
@ -185,12 +213,14 @@ class CompleteMessageParser : MessageParser {
val searchContent = matcher.group(SEARCH_CONTENT_GROUP_INDEX).orEmpty()
val replaceContent = matcher.group(REPLACE_CONTENT_GROUP_INDEX).orEmpty()
add(SearchReplace(
search = searchContent,
replace = replaceContent,
language = language,
filePath = filePath
))
add(
SearchReplace(
search = searchContent,
replace = replaceContent,
language = language,
filePath = filePath
)
)
}
/**
@ -215,12 +245,14 @@ class CompleteMessageParser : MessageParser {
val searchContent = incompleteMatcher.group(SEARCH_CONTENT_GROUP_INDEX).orEmpty()
val replaceContent = incompleteMatcher.group(REPLACE_CONTENT_GROUP_INDEX).orEmpty()
add(SearchReplace(
search = searchContent,
replace = replaceContent,
language = language,
filePath = filePath
))
add(
SearchReplace(
search = searchContent,
replace = replaceContent,
language = language,
filePath = filePath
)
)
}
IncompleteSearchReplaceResult(segments, incompleteMatcher.end())
@ -236,4 +268,96 @@ class CompleteMessageParser : MessageParser {
val segments: List<Segment>,
val endIndex: Int
)
}
/**
* Tolerant scan for blocks with markers like <<<, ===, >>> (>=3 symbols).
* Returns segments in order and the last processed index, so trailing code can be appended by caller.
*/
private fun tolerantScanSearchReplace(
codeContent: String,
language: String,
filePath: String?
): TolerantScanResult? {
val segments = mutableListOf<Segment>()
var cursor = 0
var progressed = false
fun findNextLineMatching(regex: Regex, startPos: Int): Pair<Int, Int> {
var pos = startPos
val len = codeContent.length
while (pos <= len) {
val lineStart = pos
if (pos >= len) return -1 to -1
val nl = codeContent.indexOf('\n', pos)
val lineEndExclusive = if (nl == -1) len else nl + 1
val rawLine = codeContent.substring(lineStart, lineEndExclusive).trimEnd('\n', '\r')
val trimmed = rawLine.trim()
if (regex.matches(trimmed)) return lineStart to lineEndExclusive
pos = lineEndExclusive
}
return -1 to -1
}
while (cursor < codeContent.length) {
val (startLineStart, startLineEnd) = findNextLineMatching(TOLERANT_SEARCH_START, cursor)
if (startLineStart == -1) break
segments.addCodeSegmentIfExists(codeContent, cursor, startLineStart, language, filePath)
val (sepLineStart, sepLineEnd) = findNextLineMatching(TOLERANT_SEPARATOR, startLineEnd)
if (sepLineStart == -1) {
val search = codeContent.substring(startLineEnd, codeContent.length)
if (search.isNotEmpty()) {
segments.add(
SearchReplace(
search = search,
replace = "",
language = language,
filePath = filePath
)
)
cursor = codeContent.length
progressed = true
}
break
}
val (endLineStart, endLineEnd) = findNextLineMatching(TOLERANT_REPLACE_END, sepLineEnd)
if (endLineStart == -1) {
val search = codeContent.substring(startLineEnd, sepLineStart)
val replace = codeContent.substring(sepLineEnd, codeContent.length)
segments.add(
SearchReplace(
search = search,
replace = replace,
language = language,
filePath = filePath
)
)
cursor = codeContent.length
progressed = true
break
}
val search = codeContent.substring(startLineEnd, sepLineStart)
val replace = codeContent.substring(sepLineEnd, endLineStart)
segments.add(
SearchReplace(
search = search,
replace = replace,
language = language,
filePath = filePath
)
)
cursor = endLineEnd
progressed = true
}
return if (progressed) TolerantScanResult(segments, cursor) else null
}
private data class TolerantScanResult(
val segments: List<Segment>,
val lastIndex: Int
)
}

View file

@ -12,6 +12,10 @@ class SseMessageParser : MessageParser {
const val NEWLINE = "\n"
const val HEADER_DELIMITER = ":"
const val HEADER_PARTS_LIMIT = 2
val SEARCH_START_REGEX = Regex("""^\s*<{3,}(\s*SEARCH.*)?$""", RegexOption.IGNORE_CASE)
val SEPARATOR_REGEX = Regex("""^\s*={3,}\s*$""")
val REPLACE_END_REGEX = Regex("""^\s*>{3,}(\s*REPLACE.*)?$""", RegexOption.IGNORE_CASE)
}
private var parserState: ParserState = ParserState.Outside
@ -119,7 +123,7 @@ class SseMessageParser : MessageParser {
true
}
line.trimStart().startsWith(SEARCH_MARKER) -> {
isSearchStartLine(line) -> {
// Emit accumulated code content before transitioning
if (state.content.isNotEmpty()) {
segments.add(Code(state.content, state.header.language, state.header.filePath))
@ -148,7 +152,7 @@ class SseMessageParser : MessageParser {
val line = buffer.substring(0, nlIdx)
consumeFromBuffer(nlIdx + 1)
return if (line.trim() == SEPARATOR_MARKER) {
return if (isSeparatorLine(line)) {
segments.add(
ReplaceWaiting(
state.searchContent,
@ -179,7 +183,7 @@ class SseMessageParser : MessageParser {
consumeFromBuffer(nlIdx + 1)
return when {
line.trim().startsWith(REPLACE_MARKER) -> {
isReplaceEndLine(line) -> {
segments.add(
SearchReplace(
search = state.searchContent,
@ -318,11 +322,26 @@ class SseMessageParser : MessageParser {
return if (parts.isNotEmpty()) {
CodeHeader(
language = parts.getOrNull(0) ?: "",
filePath = parts.getOrNull(1)
filePath = parts.getOrNull(1)?.trim()?.ifEmpty { null }
)
} else null
}
private fun isSearchStartLine(line: String): Boolean {
val trimmed = line.trim()
return trimmed.startsWith(SEARCH_MARKER) || SEARCH_START_REGEX.matches(trimmed)
}
private fun isSeparatorLine(line: String): Boolean {
val trimmed = line.trim()
return trimmed == SEPARATOR_MARKER || SEPARATOR_REGEX.matches(trimmed)
}
private fun isReplaceEndLine(line: String): Boolean {
val trimmed = line.trim()
return trimmed.startsWith(REPLACE_MARKER) || REPLACE_END_REGEX.matches(trimmed)
}
private sealed class ParserState {
object Outside : ParserState()

View file

@ -1,196 +0,0 @@
package ee.carlrobert.codegpt.ui
import com.intellij.ide.IdeBundle
import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI
import com.intellij.openapi.actionSystem.ActionPlaces
import com.intellij.openapi.application.runInEdt
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.event.SelectionEvent
import com.intellij.openapi.editor.event.SelectionListener
import com.intellij.openapi.observable.properties.AtomicBooleanProperty
import com.intellij.openapi.observable.properties.ObservableProperty
import com.intellij.openapi.observable.util.not
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.openapi.ui.popup.util.MinimizeButton
import com.intellij.ui.DocumentAdapter
import com.intellij.ui.components.JBTextField
import com.intellij.ui.dsl.builder.AlignX
import com.intellij.ui.dsl.builder.Cell
import com.intellij.ui.dsl.builder.Row
import com.intellij.ui.dsl.builder.panel
import com.intellij.ui.layout.ComponentPredicate
import com.intellij.util.ui.AsyncProcessIcon
import com.intellij.util.ui.JBUI
import ee.carlrobert.codegpt.CodeGPTBundle
import ee.carlrobert.codegpt.actions.editor.EditCodeSubmissionHandler
import ee.carlrobert.codegpt.settings.models.ModelSettings
import ee.carlrobert.codegpt.settings.models.SettingsModelComboBoxAction
import ee.carlrobert.codegpt.settings.service.FeatureType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.awt.event.KeyAdapter
import java.awt.event.KeyEvent
import javax.swing.JButton
import javax.swing.JPanel
import javax.swing.event.DocumentEvent
data class ObservableProperties(
val submitted: AtomicBooleanProperty = AtomicBooleanProperty(false),
val accepted: AtomicBooleanProperty = AtomicBooleanProperty(false),
val loading: AtomicBooleanProperty = AtomicBooleanProperty(false),
)
class EditCodePopover(private val editor: Editor) {
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val observableProperties = ObservableProperties()
private val submissionHandler = EditCodeSubmissionHandler(editor, observableProperties)
private val promptTextField = JBTextField("", 40).apply {
emptyText.appendText(CodeGPTBundle.get("editCodePopover.textField.emptyText"))
addKeyListener(object : KeyAdapter() {
override fun keyPressed(e: KeyEvent) {
if (e.keyCode == KeyEvent.VK_ENTER) {
e.consume()
handleSubmit()
}
}
})
}
private val popup = JBPopupFactory.getInstance()
.createComponentPopupBuilder(
createPopupPanel(),
promptTextField
)
.setTitle(CodeGPTBundle.get("editCodePopover.title"))
.setMovable(true)
.setCancelKeyEnabled(true)
.setCancelOnClickOutside(false)
.setCancelOnWindowDeactivation(false)
.setRequestFocus(true)
.setCancelButton(MinimizeButton(IdeBundle.message("tooltip.hide")))
.setCancelCallback {
submissionHandler.handleReject()
true
}
.createPopup()
fun show() {
popup.showInBestPositionFor(editor)
}
private fun createPopupPanel(): JPanel {
return panel {
row {
cell(promptTextField)
}
row {
comment(CodeGPTBundle.get("editCodePopover.textField.comment"))
}
row {
button(
CodeGPTBundle.get("editCodePopover.submitButton.title"),
observableProperties.submitted.not(),
)
button(
CodeGPTBundle.get("editCodePopover.followUpButton.title"),
observableProperties.submitted,
)
button(CodeGPTBundle.get("editCodePopover.acceptButton.title")) {
submissionHandler.handleAccept()
popup.cancel()
}
.visibleIf(observableProperties.submitted)
.enabledIf(observableProperties.loading.not())
cell(AsyncProcessIcon("edit_code_spinner")).visibleIf(observableProperties.loading)
link(CodeGPTBundle.get("shared.discard")) {
submissionHandler.handleReject()
popup.cancel()
}
.align(AlignX.RIGHT)
.visibleIf(observableProperties.submitted)
}
separator()
row {
text(CodeGPTBundle.get("shared.escToCancel"))
.applyToComponent {
font = JBUI.Fonts.smallFont()
}
cell(
SettingsModelComboBoxAction(
FeatureType.EDIT_CODE,
ModelSettings.getInstance().getModelSelection(FeatureType.EDIT_CODE),
{}
).createCustomComponent(ActionPlaces.UNKNOWN)
).align(AlignX.RIGHT)
}
}.apply {
border = JBUI.Borders.empty(8, 8, 2, 8)
}
}
private fun Row.button(title: String, visibleIf: ObservableProperty<Boolean>): Cell<JButton> {
val button = JButton(title).apply {
putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, true)
addActionListener {
handleSubmit()
}
}
return cell(button)
.visibleIf(visibleIf)
.enabledIf(
EnabledButtonComponentPredicate(
button,
editor,
promptTextField,
observableProperties
)
)
}
private fun handleSubmit() {
serviceScope.launch {
submissionHandler.handleSubmit(promptTextField.text)
promptTextField.text = ""
promptTextField.emptyText.text =
CodeGPTBundle.get("editCodePopover.textField.followUp.emptyText")
}
}
private class EnabledButtonComponentPredicate(
private val button: JButton,
private val editor: Editor,
private val promptTextField: JBTextField,
private val observableProperties: ObservableProperties
) : ComponentPredicate() {
override fun invoke(): Boolean {
if (!editor.selectionModel.hasSelection()) {
button.toolTipText = "Please select code to continue"
}
if (promptTextField.text.isEmpty()) {
button.toolTipText = "Please enter a prompt to continue"
}
return editor.selectionModel.hasSelection()
&& promptTextField.text.isNotEmpty()
&& observableProperties.loading.get().not()
}
override fun addListener(listener: (Boolean) -> Unit) {
promptTextField.document.addDocumentListener(object : DocumentAdapter() {
override fun textChanged(e: DocumentEvent) {
runInEdt { listener(invoke()) }
}
})
editor.selectionModel.addSelectionListener(object : SelectionListener {
override fun selectionChanged(e: SelectionEvent) {
runInEdt { listener(invoke()) }
}
})
observableProperties.loading.afterSet {
runInEdt { listener(invoke()) }
}
}
}
}

View file

@ -0,0 +1,348 @@
package ee.carlrobert.codegpt.ui
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.invokeLater
import com.intellij.openapi.application.runInEdt
import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.ex.EditorEx
import com.intellij.openapi.fileEditor.FileDocumentManager
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.fileEditor.FileEditorManagerEvent
import com.intellij.openapi.fileEditor.FileEditorManagerListener
import com.intellij.openapi.observable.properties.AtomicBooleanProperty
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.Key
import com.intellij.ui.awt.RelativePoint
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.components.BorderLayoutPanel
import ee.carlrobert.codegpt.CodeGPTKeys
import ee.carlrobert.codegpt.ReferencedFile
import ee.carlrobert.codegpt.inlineedit.InlineEditConversationManager
import ee.carlrobert.codegpt.inlineedit.InlineEditSubmissionHandler
import ee.carlrobert.codegpt.conversations.Conversation
import ee.carlrobert.codegpt.conversations.message.Message
import ee.carlrobert.codegpt.psistructure.PsiStructureProvider
import ee.carlrobert.codegpt.settings.service.FeatureType
import ee.carlrobert.codegpt.toolwindow.chat.structure.data.PsiStructureRepository
import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.TotalTokensPanel
import ee.carlrobert.codegpt.ui.textarea.UserInputPanel
import ee.carlrobert.codegpt.ui.textarea.header.tag.EditorTagDetails
import ee.carlrobert.codegpt.ui.textarea.header.tag.FileTagDetails
import ee.carlrobert.codegpt.ui.textarea.header.tag.HistoryTagDetails
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagDetails
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager
import ee.carlrobert.codegpt.ui.textarea.header.tag.DiagnosticsTagDetails
import ee.carlrobert.codegpt.ui.textarea.ConversationTagProcessor
import ee.carlrobert.codegpt.ui.textarea.TagProcessorFactory
import ee.carlrobert.codegpt.util.GitUtil
import ee.carlrobert.codegpt.util.coroutines.CoroutineDispatchers
import kotlinx.coroutines.*
import java.awt.BorderLayout
import java.awt.Color
import java.awt.Dimension
import java.awt.Graphics
import javax.swing.BoxLayout
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.SwingUtilities
data class ObservableProperties(
val submitted: AtomicBooleanProperty = AtomicBooleanProperty(false),
val accepted: AtomicBooleanProperty = AtomicBooleanProperty(false),
val loading: AtomicBooleanProperty = AtomicBooleanProperty(false),
val hasPendingChanges: AtomicBooleanProperty = AtomicBooleanProperty(false),
)
class InlineEditPopover(private var editor: Editor) : Disposable {
companion object {
val POPOVER_KEY: Key<InlineEditPopover> = Key.create("InlineEditPopover")
private val logger = thisLogger()
}
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val observableProperties = ObservableProperties()
private val tagManager = TagManager(this)
private var changesAccepted = false
private var submissionHandler = InlineEditSubmissionHandler(editor, observableProperties)
private val project = editor.project!!
private val psiStructureRepository = PsiStructureRepository(
this,
editor.project!!,
tagManager,
PsiStructureProvider(),
CoroutineDispatchers()
)
private val dummyTokensPanel = TotalTokensPanel(
Conversation(),
null,
this,
psiStructureRepository
)
private val userInputPanel = UserInputPanel(
project = editor.project!!,
totalTokensPanel = dummyTokensPanel,
parentDisposable = this,
featureType = FeatureType.INLINE_EDIT,
tagManager = tagManager,
onSubmit = { text ->
handleSubmit(text)
},
onStop = {
submissionHandler.handleReject(clearConversation = false)
},
onAcceptAll = {
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)?.acceptAll()
?: editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER)?.acceptAll()
},
onRejectAll = {
val session = editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)
val renderer = editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER)
if (session != null) {
session.rejectAll()
submissionHandler.restorePreviousPrompt()
} else if (renderer != null) {
renderer.rejectAll()
submissionHandler.restorePreviousPrompt()
}
},
showModeSelector = false,
withRemovableSelectedEditorTag = false
).apply {
isOpaque = true
setInlineEditControlsVisible(false)
setThinkingVisible(false)
}
private val userInputWrapper = BorderLayoutPanel().apply {
isOpaque = false
border = JBUI.Borders.empty()
add(userInputPanel, BorderLayout.CENTER)
}.andTransparent()
private val mainContainer = object : JPanel() {
init {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
isOpaque = false
background = null
add(userInputWrapper)
}
override fun paintComponent(g: Graphics?) {
}
}
private val popup = JBPopupFactory.getInstance()
.createComponentPopupBuilder(mainContainer, userInputPanel)
.setMovable(true)
.setResizable(true)
.setCancelKeyEnabled(true)
.setCancelOnClickOutside(false)
.setCancelOnWindowDeactivation(false)
.setRequestFocus(true)
.setMinSize(Dimension(600, 80))
.setShowBorder(false)
.setShowShadow(false)
.setAdText("")
.setCancelCallback {
if (!changesAccepted) {
submissionHandler.handleReject(clearConversation = true)
}
true
}
.createPopup()
init {
Disposer.register(popup, this)
userInputPanel.requestFocus()
editor.putUserData(POPOVER_KEY, this)
}
fun show() {
val point = computePopupPoint(editor)
popup.show(point)
invokeLater {
userInputPanel.requestFocus()
SwingUtilities.getWindowAncestor(popup.content)?.let { window ->
try {
window.background = Color(0, 0, 0, 0)
if (window is javax.swing.JWindow) {
window.contentPane.background = Color(0, 0, 0, 0)
if (window.contentPane is JComponent) {
(window.contentPane as JComponent).isOpaque = false
}
}
} catch (e: Exception) {
logger.error("Failed to make window transparent: ${e.message}")
}
}
}
project.messageBus.connect(this).subscribe(
FileEditorManagerListener.FILE_EDITOR_MANAGER,
object : FileEditorManagerListener {
override fun selectionChanged(event: FileEditorManagerEvent) {
val newEditor =
FileEditorManager.getInstance(project).selectedTextEditor ?: return
if (newEditor === this@InlineEditPopover.editor) return
attachToEditor(newEditor)
}
}
)
}
override fun dispose() {
serviceScope.cancel()
editor.putUserData(POPOVER_KEY, null)
}
fun onCompletionFinished() {
runInEdt {
userInputPanel.setSubmitEnabled(true)
observableProperties.submitted.set(false)
setThinkingVisible(false)
}
}
fun markChangesAsAccepted() {
changesAccepted = true
observableProperties.accepted.set(true)
FileDocumentManager.getInstance().saveDocument(editor.document)
}
fun setInlineEditControlsVisible(visible: Boolean) {
userInputPanel.setInlineEditControlsVisible(visible)
}
fun setThinkingVisible(visible: Boolean, text: String = "Thinking…") {
userInputPanel.setThinkingVisible(visible, text)
}
fun restorePromptAndFocus(promptText: String) {
runInEdt {
userInputPanel.setTextAndFocus(promptText)
}
}
fun triggerPromptRestoration() {
submissionHandler.restorePreviousPrompt()
}
private fun handleSubmit(text: String) {
if (text.isNotEmpty()) {
observableProperties.submitted.set(true)
userInputPanel.setSubmitEnabled(false)
serviceScope.launch {
try {
val refs = collectSelectedReferencedFiles()
val diff = try {
GitUtil.getCurrentChanges(editor.project!!)
} catch (_: Exception) {
null
}
val conversationHistory = collectConversationHistory()
val diagnosticsInfo = collectDiagnosticsInfo()
submissionHandler.handleSubmit(
text,
refs,
diff,
conversationHistory,
diagnosticsInfo
)
} catch (e: Exception) {
logger.error("Error submitting inline edit", e)
runInEdt {
userInputPanel.setSubmitEnabled(true)
observableProperties.submitted.set(false)
}
}
}
}
}
private fun collectConversationHistory(): List<Conversation> {
val tags: Set<TagDetails> = tagManager.getTags()
return tags
.filter { it.selected && it is HistoryTagDetails }
.map { (it as HistoryTagDetails).conversationId }
.mapNotNull { ConversationTagProcessor.getConversation(it) }
.distinct()
}
private fun collectSelectedReferencedFiles(): List<ReferencedFile> {
val tags: Set<TagDetails> = tagManager.getTags()
val currentPath = editor.virtualFile?.path
val selectedVfs = tags
.filter { it.selected }
.mapNotNull {
when (it) {
is FileTagDetails -> it.virtualFile
is EditorTagDetails -> it.virtualFile
else -> null
}
}
.filter { vf -> vf.path != currentPath }
.distinctBy { it.path }
return selectedVfs.mapNotNull { v ->
try {
ReferencedFile.from(v)
} catch (_: Exception) {
null
}
}
}
private fun collectDiagnosticsInfo(): String? {
val tags: Set<TagDetails> = tagManager.getTags()
val diagnosticsTag =
tags.firstOrNull { it.selected && it is DiagnosticsTagDetails } as? DiagnosticsTagDetails
?: return null
val processor = TagProcessorFactory.getProcessor(project, diagnosticsTag)
val stringBuilder = StringBuilder()
processor.process(Message("", ""), stringBuilder)
return stringBuilder.toString().takeIf { it.isNotBlank() }
}
private fun computePopupPoint(targetEditor: Editor): RelativePoint {
val editorComponent = targetEditor.component
val popupWidth = 600
val popupHeight = mainContainer.preferredSize.height.coerceAtLeast(150)
val editorWidth = editorComponent.width
var x = (editorWidth - popupWidth) / 2
if (x < 0) x = 0
val paddingFromBottom = 20
val y = editorComponent.height - popupHeight - paddingFromBottom
return RelativePoint(editorComponent, java.awt.Point(x, y))
}
private fun attachToEditor(newEditor: Editor) {
val oldEx = this.editor as? EditorEx
val newEx = newEditor as? EditorEx
this.editor.putUserData(POPOVER_KEY, null)
this.editor = newEditor
this.editor.putUserData(POPOVER_KEY, this)
InlineEditConversationManager.moveConversation(oldEx, newEx)
submissionHandler = InlineEditSubmissionHandler(newEditor, observableProperties)
val point = computePopupPoint(newEditor)
popup.setLocation(point.screenPoint)
}
}

View file

@ -0,0 +1,58 @@
package ee.carlrobert.codegpt.ui.components
import com.intellij.ui.JBColor
import com.intellij.util.ui.JBUI
import java.awt.*
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.JComponent
/**
* A small rounded "pill" button used in inline edit UIs.
* Matches the visual feel of the Y/N badges shown in diff blocks.
*/
class BadgeChip(
private val text: String,
private val backgroundColor: JBColor,
private val onClick: () -> Unit,
private val fixedHeight: Int = JBUI.scale(18),
private val horizontalPadding: Int = JBUI.scale(8),
private val cornerRadius: Int = JBUI.scale(8),
private val textColor: JBColor = JBColor(Color(0xDF, 0xE1, 0xE5), Color(0xDF, 0xE1, 0xE5))
) : JComponent() {
init {
cursor = Cursor(Cursor.HAND_CURSOR)
toolTipText = text
addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent?) {
onClick()
}
})
isOpaque = false
}
override fun getPreferredSize(): Dimension {
val fm = getFontMetrics(font)
val w = fm.stringWidth(text) + horizontalPadding * 2
return Dimension(w, fixedHeight)
}
override fun paintComponent(g: Graphics) {
val g2 = g as Graphics2D
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
val r = Rectangle(0, 0, width, height)
g2.color = backgroundColor
g2.fillRoundRect(r.x, r.y, r.width - 1, r.height - 1, cornerRadius, cornerRadius)
g2.color = backgroundColor.darker()
g2.drawRoundRect(r.x, r.y, r.width - 1, r.height - 1, cornerRadius, cornerRadius)
g2.font = font
g2.color = textColor
val fm = g2.fontMetrics
val tx = (width - fm.stringWidth(text)) / 2
val ty = (height - fm.height) / 2 + fm.ascent
g2.drawString(text, tx, ty)
}
}

View file

@ -0,0 +1,96 @@
package ee.carlrobert.codegpt.ui.components
import com.intellij.openapi.actionSystem.KeyboardShortcut
import com.intellij.openapi.keymap.KeymapManager
import com.intellij.openapi.util.SystemInfo
import com.intellij.ui.JBColor
import ee.carlrobert.codegpt.CodeGPTBundle
import java.awt.Color
import java.awt.event.InputEvent
import java.awt.event.KeyEvent
import javax.swing.KeyStroke
object InlineEditChips {
val GREEN = JBColor(Color(0x00, 0x99, 0x00), Color(0x00, 0x99, 0x00))
val RED = JBColor(Color(0xD0, 0x36, 0x36), Color(0xD0, 0x36, 0x36))
val TEXT = JBColor(Color(0xDF, 0xE1, 0xE5), Color(0xDF, 0xE1, 0xE5))
fun keyY(onClick: () -> Unit) = BadgeChip(
currentShortcutLabel(
actionId = "CodeGPT.AcceptCurrentInlineEdit",
preferredKeyCode = KeyEvent.VK_Y,
macFallback = "⌘Y",
otherFallback = "Ctrl+Y"
),
GREEN,
onClick,
textColor = TEXT
)
fun keyN(onClick: () -> Unit) = BadgeChip(
currentShortcutLabel(
actionId = "CodeGPT.RejectCurrentInlineEdit",
preferredKeyCode = KeyEvent.VK_N,
macFallback = "⌘N",
otherFallback = "Ctrl+N"
),
RED,
onClick,
textColor = TEXT
)
private fun currentShortcutLabel(
actionId: String,
preferredKeyCode: Int,
macFallback: String,
otherFallback: String
): String {
val fb = if (SystemInfo.isMac) macFallback else otherFallback
return try {
val keymap = KeymapManager.getInstance().activeKeymap
val shortcuts = keymap.getShortcuts(actionId)
val preferred = preferredShortcut(shortcuts, preferredKeyCode)
?: shortcuts.firstOrNull() as? KeyboardShortcut
if (preferred != null) keyStrokeToLabel(preferred.firstKeyStroke) else fb
} catch (_: Exception) {
fb
}
}
private fun preferredShortcut(
shortcuts: Array<com.intellij.openapi.actionSystem.Shortcut>,
keyCode: Int
): KeyboardShortcut? {
val macKs = KeyStroke.getKeyStroke(keyCode, InputEvent.META_DOWN_MASK)
val winKs = KeyStroke.getKeyStroke(keyCode, InputEvent.CTRL_DOWN_MASK)
return shortcuts.firstOrNull { (it as? KeyboardShortcut)?.firstKeyStroke == macKs } as? KeyboardShortcut
?: shortcuts.firstOrNull { (it as? KeyboardShortcut)?.firstKeyStroke == winKs } as? KeyboardShortcut
}
private fun keyStrokeToLabel(ks: KeyStroke): String {
return if (SystemInfo.isMac) {
buildString {
if (ks.modifiers and InputEvent.META_DOWN_MASK != 0) append('⌘')
if (ks.modifiers and InputEvent.SHIFT_DOWN_MASK != 0) append('⇧')
if (ks.modifiers and InputEvent.ALT_DOWN_MASK != 0) append('⌥')
if (ks.modifiers and InputEvent.CTRL_DOWN_MASK != 0) append('⌃')
append(KeyEvent.getKeyText(ks.keyCode).uppercase())
}
} else {
val parts = mutableListOf<String>()
if (ks.modifiers and InputEvent.CTRL_DOWN_MASK != 0) parts.add("Ctrl")
if (ks.modifiers and InputEvent.SHIFT_DOWN_MASK != 0) parts.add("Shift")
if (ks.modifiers and InputEvent.ALT_DOWN_MASK != 0) parts.add("Alt")
if (ks.modifiers and InputEvent.META_DOWN_MASK != 0) parts.add("Meta")
parts.add(KeyEvent.getKeyText(ks.keyCode).uppercase())
parts.joinToString("+")
}
}
fun acceptAll(onClick: () -> Unit) =
BadgeChip(CodeGPTBundle.get("shared.acceptAll"), GREEN, onClick, textColor = TEXT)
fun rejectAll(onClick: () -> Unit) =
BadgeChip(CodeGPTBundle.get("shared.rejectAll"), RED, onClick, textColor = TEXT)
}

View file

@ -20,6 +20,7 @@ import com.intellij.openapi.wm.ToolWindowManager
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.ui.EditorTextField
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import ee.carlrobert.codegpt.CodeGPTBundle
import ee.carlrobert.codegpt.CodeGPTKeys.IS_PROMPT_TEXT_FIELD_DOCUMENT
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager
@ -60,6 +61,8 @@ class PromptTextField(
isOneLineMode = false
IS_PROMPT_TEXT_FIELD_DOCUMENT.set(document, true)
setPlaceholder(CodeGPTBundle.get("toolwindow.chat.textArea.emptyText"))
putClientProperty(UIUtil.HIDE_EDITOR_FROM_DATA_CONTEXT_PROPERTY, true)
}
override fun onEditorAdded(editor: Editor) {
@ -85,6 +88,13 @@ class PromptTextField(
}
}
fun setTextAndFocus(text: String) {
runInEdt {
this.text = text
requestFocusInWindow()
}
}
suspend fun showGroupLookup() {
val lookupItems = searchManager.getDefaultGroups()
.map { it.createLookupElement() }
@ -349,6 +359,11 @@ class PromptTextField(
}
private fun adjustHeight(editor: EditorEx) {
val toolWindow = project.service<ToolWindowManager>().getToolWindow("ProxyAI")
if (toolWindow == null || !toolWindow.component.isAncestorOf(this)) {
return
}
val contentHeight =
editor.contentComponent.preferredSize.height + PromptTextFieldConstants.HEIGHT_PADDING
val maxHeight = JBUI.scale(getToolWindowHeight() / 2)

View file

@ -23,17 +23,25 @@ class PromptTextFieldEventDispatcher(
override fun dispatch(e: AWTEvent): Boolean {
if ((e is KeyEvent || e is MouseEvent) && findParent() is PromptTextField) {
if (e is KeyEvent) {
if (e.id == KeyEvent.KEY_PRESSED && e.keyCode == KeyEvent.VK_BACK_SPACE) {
onBackSpace()
}
if (e.id == KeyEvent.KEY_PRESSED) {
when (e.keyCode) {
KeyEvent.VK_BACK_SPACE -> {
if (!handleBackspace(e)) {
onBackSpace()
}
}
if (e.id == KeyEvent.KEY_PRESSED && e.keyCode == KeyEvent.VK_ENTER) {
if (e.isShiftDown) {
handleShiftEnter(e)
} else if (e.modifiersEx and InputEvent.ALT_DOWN_MASK == 0
&& e.modifiersEx and InputEvent.CTRL_DOWN_MASK == 0
) {
onSubmit(e)
KeyEvent.VK_DELETE -> handleDelete(e)
KeyEvent.VK_A -> if (e.isControlDown || e.isMetaDown) handleSelectAll(e)
KeyEvent.VK_ENTER -> {
if (e.isShiftDown) {
handleShiftEnter(e)
} else if (e.modifiersEx and InputEvent.ALT_DOWN_MASK == 0
&& e.modifiersEx and InputEvent.CTRL_DOWN_MASK == 0
) {
onSubmit(e)
}
}
}
}
@ -72,4 +80,82 @@ class PromptTextFieldEventDispatcher(
e.consume()
}
}
private fun handleSelectAll(e: KeyEvent) {
val parent = findParent()
if (parent is PromptTextField) {
parent.editor?.let { editor ->
editor.selectionModel.setSelection(0, editor.document.textLength)
e.consume()
}
}
}
private fun handleDelete(e: KeyEvent) {
val parent = findParent()
if (parent is PromptTextField) {
parent.editor?.let { editor ->
runUndoTransparentWriteAction {
val document = editor.document
val caretModel = editor.caretModel
val selectionModel = editor.selectionModel
if (selectionModel.hasSelection()) {
document.deleteString(
selectionModel.selectionStart,
selectionModel.selectionEnd
)
} else {
val offset = caretModel.offset
if (offset < document.textLength) {
document.deleteString(offset, offset + 1)
}
}
}
e.consume()
}
}
}
private fun handleBackspace(e: KeyEvent): Boolean {
val parent = findParent()
if (parent is PromptTextField) {
parent.editor?.let { editor ->
val selectionModel = editor.selectionModel
if (selectionModel.hasSelection()) {
runUndoTransparentWriteAction {
editor.document.deleteString(
selectionModel.selectionStart,
selectionModel.selectionEnd
)
}
e.consume()
return true
} else if (e.isControlDown || e.isMetaDown) {
runUndoTransparentWriteAction {
val document = editor.document
val caretModel = editor.caretModel
val offset = caretModel.offset
if (offset > 0) {
val text = document.text
var wordStart = offset - 1
while (wordStart > 0 && Character.isWhitespace(text[wordStart])) {
wordStart--
}
while (wordStart > 0 && !Character.isWhitespace(text[wordStart - 1])) {
wordStart--
}
document.deleteString(wordStart, offset)
}
}
e.consume()
return true
}
}
}
return false
}
}

View file

@ -369,4 +369,4 @@ class DiagnosticsTagProcessor(
else -> 4
}
}
}
}

View file

@ -18,7 +18,9 @@ import com.intellij.ui.dsl.builder.AlignX
import com.intellij.ui.dsl.builder.RightGap
import com.intellij.ui.dsl.builder.panel
import com.intellij.util.IconUtil
import com.intellij.util.ui.AsyncProcessIcon
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.ReferencedFile
@ -30,6 +32,7 @@ import ee.carlrobert.codegpt.settings.service.ServiceType
import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.ModelComboBoxAction
import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.TotalTokensPanel
import ee.carlrobert.codegpt.ui.IconActionButton
import ee.carlrobert.codegpt.ui.components.InlineEditChips
import ee.carlrobert.codegpt.ui.dnd.FileDragAndDrop
import ee.carlrobert.codegpt.ui.textarea.header.UserInputHeaderPanel
import ee.carlrobert.codegpt.ui.textarea.header.tag.*
@ -41,16 +44,46 @@ import java.awt.*
import java.awt.geom.Area
import java.awt.geom.Rectangle2D
import java.awt.geom.RoundRectangle2D
import javax.swing.JComponent
import javax.swing.JPanel
class UserInputPanel(
class UserInputPanel @JvmOverloads constructor(
private val project: Project,
private val totalTokensPanel: TotalTokensPanel,
parentDisposable: Disposable,
featureType: FeatureType,
private val tagManager: TagManager,
private val onSubmit: (String) -> Unit,
private val onStop: () -> Unit
) : JPanel(BorderLayout()) {
private val onStop: () -> Unit,
private val onAcceptAll: (() -> Unit)? = null,
private val onRejectAll: (() -> Unit)? = null,
private val showModeSelector: Boolean = true,
withRemovableSelectedEditorTag: Boolean = false,
) : BorderLayoutPanel() {
constructor(
project: Project,
totalTokensPanel: TotalTokensPanel,
parentDisposable: Disposable,
featureType: FeatureType,
tagManager: TagManager,
onSubmit: (String) -> Unit,
onStop: () -> Unit,
showModeSelector: Boolean,
withRemovableSelectedEditorTag: Boolean
) : this(
project,
totalTokensPanel,
parentDisposable,
featureType,
tagManager,
onSubmit,
onStop,
null,
null,
showModeSelector,
withRemovableSelectedEditorTag
)
companion object {
private const val CORNER_RADIUS = 16
@ -60,12 +93,12 @@ class UserInputPanel(
private val disposableCoroutineScope = DisposableCoroutineScope()
private val promptTextField =
PromptTextField(
project,
tagManager,
::updateUserTokens,
::handleBackSpace,
::handleLookupAdded,
::handleSubmit,
project = project,
tagManager = tagManager,
onTextChanged = ::updateUserTokens,
onBackSpace = ::handleBackSpace,
onLookupAdded = ::handleLookupAdded,
onSubmit = ::handleSubmit,
onFilesDropped = { files ->
includeFiles(files.toMutableList())
totalTokensPanel.updateReferencedFilesTokens(files.map { ReferencedFile.from(it).fileContent() })
@ -76,8 +109,28 @@ class UserInputPanel(
project,
tagManager,
totalTokensPanel,
promptTextField
promptTextField,
withRemovableSelectedEditorTag
)
private val acceptChip =
InlineEditChips.acceptAll { onAcceptAll?.invoke() }.apply { isVisible = false }
private val rejectChip =
InlineEditChips.rejectAll { onRejectAll?.invoke() }.apply { isVisible = false }
private var inlineEditControls: List<javax.swing.JComponent> = listOf(acceptChip, rejectChip)
private val thinkingIcon = AsyncProcessIcon("inline-edit-thinking").apply { isVisible = false }
private val thinkingLabel = javax.swing.JLabel("Thinking…").apply {
foreground = service<EditorColorsManager>().globalScheme.defaultForeground
isVisible = false
}
private val thinkingPanel =
JPanel(FlowLayout(FlowLayout.LEFT, 4, 0)).apply {
isOpaque = false
add(thinkingIcon)
add(thinkingLabel)
isVisible = false
}
private val submitButton = IconActionButton(
object : AnAction(
CodeGPTBundle.get("smartTextPane.submitButton.title"),
@ -104,6 +157,9 @@ class UserInputPanel(
).apply { isEnabled = false }
private val imageActionSupported = AtomicBooleanProperty(isImageActionSupported())
private lateinit var modelComboBoxComponent: JComponent
private var searchReplaceToggleComponent: JComponent? = null
val text: String
get() = promptTextField.text
@ -115,7 +171,7 @@ class UserInputPanel(
init {
setupDisposables(parentDisposable)
setupLayout()
setupLayout(featureType)
addSelectedEditorContent()
FileDragAndDrop.install(this) { files ->
includeFiles(files.toMutableList())
@ -128,21 +184,18 @@ class UserInputPanel(
Disposer.register(parentDisposable, promptTextField)
}
private fun setupLayout() {
private fun setupLayout(featureType: FeatureType) {
background = service<EditorColorsManager>().globalScheme.defaultBackground
add(userInputHeaderPanel, BorderLayout.NORTH)
add(promptTextField, BorderLayout.CENTER)
add(createFooterPanel(), BorderLayout.SOUTH)
addToTop(userInputHeaderPanel)
addToCenter(promptTextField)
addToBottom(createFooterPanel(featureType))
}
private fun addSelectedEditorContent() {
EditorUtil.getSelectedEditor(project)?.let { editor ->
if (EditorUtil.hasSelection(editor)) {
tagManager.addTag(
EditorSelectionTagDetails(
editor.virtualFile,
editor.selectionModel
)
EditorSelectionTagDetails(editor.virtualFile, editor.selectionModel)
)
}
}
@ -222,6 +275,10 @@ class UserInputPanel(
}
}
fun setTextAndFocus(text: String) {
promptTextField.setTextAndFocus(text)
}
override fun paintComponent(g: Graphics) {
val g2 = g.create() as Graphics2D
try {
@ -295,7 +352,11 @@ class UserInputPanel(
private fun handleBackSpace() {
if (text.isEmpty()) {
userInputHeaderPanel.getLastTag()?.let { tagManager.remove(it) }
userInputHeaderPanel.getLastTag()?.let { last ->
if (last.isRemovable) {
tagManager.remove(last)
}
}
}
}
@ -313,16 +374,25 @@ class UserInputPanel(
}
}
private fun createFooterPanel(): JPanel {
val currentService = ModelSelectionService.getInstance().getServiceForFeature(FeatureType.CHAT)
private fun createFooterPanel(featureType: FeatureType): JPanel {
val currentService =
ModelSelectionService.getInstance().getServiceForFeature(featureType)
val modelComboBox = ModelComboBoxAction(
project,
{ imageActionSupported.set(isImageActionSupported()) },
currentService
currentService,
ServiceType.entries,
true,
featureType
).createCustomComponent(ActionPlaces.UNKNOWN)
modelComboBoxComponent = modelComboBox
val searchReplaceToggle =
val searchReplaceToggle = if (showModeSelector) {
SearchReplaceToggleAction(this).createCustomComponent(ActionPlaces.UNKNOWN)
} else {
null
}
searchReplaceToggleComponent = searchReplaceToggle
return panel {
twoColumnsRow(
@ -330,8 +400,13 @@ class UserInputPanel(
panel {
row {
cell(modelComboBox).gap(RightGap.SMALL)
cell(createToolbarSeparator()).gap(RightGap.SMALL)
cell(searchReplaceToggle)
cell(thinkingPanel).gap(RightGap.SMALL)
cell(acceptChip).gap(RightGap.SMALL)
cell(rejectChip).gap(RightGap.SMALL)
if (showModeSelector) {
cell(createToolbarSeparator()).gap(RightGap.SMALL)
cell(searchReplaceToggle!!)
}
}
}.align(AlignX.LEFT)
},
@ -346,10 +421,27 @@ class UserInputPanel(
}.andTransparent()
}
fun setInlineEditControlsVisible(visible: Boolean) {
inlineEditControls.forEach { it.isVisible = visible }
revalidate()
repaint()
}
fun setThinkingVisible(visible: Boolean, text: String = "Thinking…") {
thinkingLabel.text = text
thinkingIcon.isVisible = visible
thinkingLabel.isVisible = visible
thinkingPanel.isVisible = visible
revalidate()
repaint()
}
private fun isImageActionSupported(): Boolean {
val currentModel = ModelSelectionService.getInstance().getModelForFeature(FeatureType.CHAT)
val currentService = ModelSelectionService.getInstance().getServiceForFeature(FeatureType.CHAT)
val currentService =
ModelSelectionService.getInstance().getServiceForFeature(FeatureType.CHAT)
return when (currentService) {
ServiceType.CUSTOM_OPENAI,
ServiceType.ANTHROPIC,

View file

@ -40,7 +40,8 @@ class UserInputHeaderPanel(
private val project: Project,
private val tagManager: TagManager,
private val totalTokensPanel: TotalTokensPanel,
private val promptTextField: PromptTextField
private val promptTextField: PromptTextField,
private val withRemovableSelectedEditorTag: Boolean
) : JPanel(WrapLayout(FlowLayout.LEFT, 4, 4)), TagManagerListener {
companion object {
@ -171,7 +172,7 @@ class UserInputHeaderPanel(
private fun addInitialTags() {
val selectedFile = getSelectedEditor(project)?.virtualFile
if (selectedFile != null) {
tagManager.addTag(EditorTagDetails(selectedFile))
tagManager.addTag(EditorTagDetails(selectedFile, isRemovable = withRemovableSelectedEditorTag))
}
EditorUtil.getOpenLocalFiles(project)
@ -226,7 +227,8 @@ class UserInputHeaderPanel(
}
override fun paintComponent(g: Graphics) {
PaintUtil.drawRoundedBackground(g, this, true)
val selectedVisually = isEnabled
PaintUtil.drawRoundedBackground(g, this, selectedVisually)
super.paintComponent(g)
}
}
@ -260,12 +262,27 @@ class UserInputHeaderPanel(
private inner class FileSelectionListener : FileEditorManagerListener {
override fun selectionChanged(event: FileEditorManagerEvent) {
event.newFile?.let { newFile ->
val containsTag = tagManager.getTags()
.none { it is EditorTagDetails && it.virtualFile == newFile }
if (containsTag) {
tagManager.addTag(EditorTagDetails(newFile).apply { selected = false })
val hasTag = tagManager.getTags()
.any { it is EditorTagDetails && it.virtualFile == newFile }
if (!hasTag) {
tagManager.addTag(EditorTagDetails(newFile, isRemovable = false))
} else {
tagManager.getTags()
.filterIsInstance<EditorTagDetails>()
.firstOrNull { it.virtualFile == newFile && it.isRemovable }?.let { existing ->
tagManager.remove(existing)
tagManager.addTag(EditorTagDetails(newFile, isRemovable = false))
}
}
tagManager.getTags()
.filterIsInstance<EditorTagDetails>()
.filter { it.virtualFile != newFile && !it.isRemovable }
.forEach { prev ->
tagManager.remove(prev)
tagManager.addTag(EditorTagDetails(prev.virtualFile).apply { selected = false })
}
emptyText.isVisible = false
}
}
@ -340,6 +357,7 @@ class UserInputHeaderPanel(
override fun show(invoker: Component, x: Int, y: Int) {
if (invoker is TagPanel) {
if (!invoker.isEnabled) return
val components = this@UserInputHeaderPanel.components.filterIsInstance<TagPanel>()
val currentIndex = components.indexOf(invoker)

View file

@ -14,7 +14,8 @@ sealed class TagDetails(
val name: String,
val icon: Icon? = null,
val id: UUID = UUID.randomUUID(),
val createdOn: Long = System.currentTimeMillis()
val createdOn: Long = System.currentTimeMillis(),
val isRemovable: Boolean = true
) {
var selected: Boolean = true
@ -30,8 +31,8 @@ sealed class TagDetails(
}
}
class EditorTagDetails(val virtualFile: VirtualFile) :
TagDetails(virtualFile.name, virtualFile.fileType.icon) {
class EditorTagDetails(val virtualFile: VirtualFile, isRemovable: Boolean = true) :
TagDetails(virtualFile.name, virtualFile.fileType.icon, isRemovable = isRemovable) {
private val type: String = "EditorTagDetails"
@ -134,4 +135,4 @@ class EmptyTagDetails : TagDetails("")
class CodeAnalyzeTagDetails : TagDetails("Code Analyze", AllIcons.Actions.DependencyAnalyzer)
data class DiagnosticsTagDetails(val virtualFile: VirtualFile) :
TagDetails("${virtualFile.name} Problems", AllIcons.General.InspectionsEye)
TagDetails("${virtualFile.name} Problems", AllIcons.General.InspectionsEye)

View file

@ -27,7 +27,7 @@ abstract class TagPanel(
private val label = TagLabel(tagDetails.name, tagDetails.icon, tagDetails.selected)
private val closeButton = CloseButton {
isVisible = isSelected
isVisible = isSelected && tagDetails.isRemovable
onClose()
}
private var isRevertingSelection = false
@ -41,7 +41,7 @@ abstract class TagPanel(
abstract fun onClose()
fun update(text: String, icon: Icon? = null) {
closeButton.isVisible = isSelected
closeButton.isVisible = isSelected && tagDetails.isRemovable
label.update(text, icon, isSelected)
revalidate()
repaint()
@ -66,7 +66,7 @@ abstract class TagPanel(
border = JBUI.Borders.empty(2, 6)
cursor = Cursor(Cursor.HAND_CURSOR)
isSelected = tagDetails.selected
closeButton.isVisible = isSelected
closeButton.isVisible = isSelected && tagDetails.isRemovable
val gbc = GridBagConstraints().apply {
gridx = 0
@ -90,7 +90,7 @@ abstract class TagPanel(
isRevertingSelection = false
}
closeButton.isVisible = isSelected
closeButton.isVisible = isSelected && tagDetails.isRemovable
tagDetails.selected = isSelected
tagManager.notifySelectionChanged(tagDetails)
label.update(isSelected)

View file

@ -107,7 +107,8 @@
<statusBarWidgetFactory order="first" id="ee.carlrobert.codegpt.statusbar.widget"
implementation="ee.carlrobert.codegpt.statusbar.CodeGPTStatusBarWidgetFactory"/>
<nameSuggestionProvider implementation="ee.carlrobert.codegpt.refactorings.DefaultNameSuggestionProvider"/>
</extensions>
</extensions>
<resource-bundle>messages.codegpt</resource-bundle>
@ -133,6 +134,13 @@
<keyboard-shortcut first-keystroke="ctrl shift ENTER" keymap="$default"/>
</action>
<action
id="codegpt.acceptInlineEdit"
text="Accept Inline Edit"
class="ee.carlrobert.codegpt.actions.editor.AcceptInlineEditAction">
<keyboard-shortcut first-keystroke="ctrl ENTER" keymap="$default"/>
</action>
<action
id="codegpt.acceptNextInlayWord"
text="Apply next word"
@ -159,10 +167,10 @@
<keyboard-shortcut keymap="$default" first-keystroke="ctrl alt shift N" replace-all="true"/>
</action>
<action
id="CodeGPT.ContextMenuEditCodeAction"
text="Edit Code"
description="Edit code from editor's context menu"
class="ee.carlrobert.codegpt.actions.editor.EditCodeContextMenuAction">
id="CodeGPT.ContextMenuInlineEditAction"
text="Inline Edit"
description="Edit code inline from editor's context menu"
class="ee.carlrobert.codegpt.actions.editor.InlineEditContextMenuAction">
<keyboard-shortcut keymap="$default" first-keystroke="ctrl shift K" replace-all="true"/>
</action>
<action
@ -217,10 +225,10 @@
<group id="CodeGPT.FloatingCodeToolbarMenuRootGroup">
<action
id="CodeGPT.FloatingMenuEditCodeAction"
text="Edit Code"
description="Edit code from editor's floating menu"
class="ee.carlrobert.codegpt.actions.editor.EditCodeFloatingMenuAction">
id="CodeGPT.FloatingMenuInlineEditAction"
text="Inline Edit"
description="Edit code inline from editor's floating menu"
class="ee.carlrobert.codegpt.actions.editor.InlineEditFloatingMenuAction">
<keyboard-shortcut keymap="$default" first-keystroke="ctrl shift K" replace-all="true"/>
</action>
<add-to-group
@ -237,10 +245,54 @@
description="Adds the selected text to the ProxyAI context">
<keyboard-shortcut keymap="$default" first-keystroke="ctrl shift I" replace-all="true"/>
<add-to-group group-id="action.editor.group.EditorActionGroup" anchor="after"
relative-to-action="CodeGPT.NewChat"/>
<add-to-group group-id="CodeGPT.FloatingCodeToolbarMenuRootGroup" anchor="after"
relative-to-action="CodeGPT.FloatingMenuEditCodeAction"/>
<add-to-group group-id="action.editor.group.EditorActionGroup" anchor="after" relative-to-action="CodeGPT.NewChat"/>
<add-to-group group-id="CodeGPT.FloatingCodeToolbarMenuRootGroup" anchor="after" relative-to-action="CodeGPT.FloatingMenuInlineEditAction"/>
</action>
<!-- Inline Edit Keyboard Shortcuts -->
<action
id="CodeGPT.AcceptAllInlineEdit"
class="ee.carlrobert.codegpt.inlineedit.AcceptAllInlineEditAction"
text="Accept All Inline Edit Changes"
description="Accept all changes in the Inline Edit diff view">
<keyboard-shortcut keymap="$default" first-keystroke="ctrl shift ENTER" replace-all="true"/>
<add-to-group group-id="EditorActions" anchor="last"/>
</action>
<action
id="CodeGPT.RejectAllInlineEdit"
class="ee.carlrobert.codegpt.inlineedit.RejectAllInlineEditAction"
text="Reject All Inline Edit Changes"
description="Reject all changes in the Inline Edit diff view">
<keyboard-shortcut keymap="$default" first-keystroke="ctrl shift BACK_SPACE" replace-all="true"/>
<add-to-group group-id="EditorActions" anchor="last"/>
</action>
<action
id="CodeGPT.RejectInlineEdit"
class="ee.carlrobert.codegpt.inlineedit.RejectInlineEditAction"
text="Reject Inline Edit Changes"
description="Reject changes in the Inline Edit diff view">
<keyboard-shortcut keymap="$default" first-keystroke="ESCAPE" replace-all="true"/>
<add-to-group group-id="EditorActions" anchor="last"/>
</action>
<action
id="CodeGPT.AcceptCurrentInlineEdit"
class="ee.carlrobert.codegpt.inlineedit.AcceptCurrentInlineEditAction"
text="Accept Current Inline Edit Change"
description="Accept the current or next pending inline edit change">
<keyboard-shortcut keymap="$default" first-keystroke="ctrl ENTER" replace-all="true"/>
<add-to-group group-id="EditorActions" anchor="last"/>
</action>
<action
id="CodeGPT.RejectCurrentInlineEdit"
class="ee.carlrobert.codegpt.inlineedit.RejectCurrentInlineEditAction"
text="Reject Current Inline Edit Change"
description="Reject the current or next pending inline edit change">
<keyboard-shortcut keymap="$default" first-keystroke="ctrl BACK_SPACE" replace-all="true"/>
<add-to-group group-id="EditorActions" anchor="last"/>
</action>
<action
@ -312,4 +364,4 @@
class="ee.carlrobert.codegpt.actions.ReviewChangesAction"/>
</group>
</actions>
</idea-plugin>
</idea-plugin>

View file

@ -255,6 +255,7 @@ notification.compilationError.okLabel=Resolve errors
notification.completionError.description=Completion failed:<br/>%s
statusBar.widget.tooltip=ProxyAI: Status
shared.acceptAll=Accept All
shared.rejectAll=Reject All
shared.promptTemplate=Prompt template:
shared.infillPromptTemplate=Infill template:
shared.apiVersion=API version:
@ -404,3 +405,9 @@ conversation.status.sortedBy=Sorted by: {0}
conversation.deleteConfirmation.message=Are you sure you want to delete this conversation?
conversation.deleteConfirmation.title=Delete Conversation
chat.message.welcome=Hi <strong>{0}</strong>, I'm ProxyAI! You can ask me anything, but most people request help with their code. Here are a few examples of what you can ask me:
inlineEdit.status.preparingReplacement=Preparing replacement...
inlineEdit.tooltip.ready=Ready to apply changes
inlineEdit.tooltip.searching=Searching for pattern...
inlineEdit.hint.searchingFor=Searching for: {0}
inlineEdit.status.waiting=Waiting for model response…
inlineEdit.status.noChanges=No applicable changes found

View file

@ -1,17 +1,63 @@
You are a code modification assistant. Your task is to modify the provided code based on the user's instructions.
You are a code modification assistant. Generate SEARCH/REPLACE blocks to modify the specified file.
Rules:
1. Return only the modified code, with no additional text or explanations.
2. The first character of your response must be the first character of the code.
3. The last character of your response must be the last character of the code.
4. NEVER use triple backticks (```) or any other markdown formatting in your response.
5. Do not use any code block indicators, syntax highlighting markers, or any other formatting characters.
6. Present the code exactly as it would appear in a plain text editor, preserving all whitespace, indentation, and line breaks.
7. Maintain the original code structure and only make changes as specified by the user's instructions.
8. Ensure that the modified code is syntactically and semantically correct for the given programming language.
9. Use consistent indentation and follow language-specific style guidelines.
10. If the user's request cannot be translated into code changes, respond only with the word NULL (without quotes or any formatting).
11. Do not include any comments or explanations within the code unless specifically requested.
12. Assume that any necessary dependencies or libraries are already imported or available.
{{PROJECT_CONTEXT}}
IMPORTANT: Your response must NEVER begin or end with triple backticks, single backticks, or any other formatting characters.
## Current File (Editable)
{{CURRENT_FILE_CONTEXT}}
{{EXTERNAL_CONTEXT}}
## Task
Analyze the user's request and generate modifications for the current file using SEARCH/REPLACE blocks.
## Format
Use SEARCH/REPLACE blocks to specify exact code changes:
Single change in the same file:
```language:filepath
<<<<<<< SEARCH
[exact code to find]
=======
[replacement code]
>>>>>>> REPLACE
```
Multiple changes in the same file:
```language:filepath
<<<<<<< SEARCH
[first section]
=======
[first replacement]
>>>>>>> REPLACE
<<<<<<< SEARCH
[second section]
=======
[second replacement]
>>>>>>> REPLACE
```
## Requirements
• Match indentation and formatting exactly
• Include 2-4 lines of context for unique pattern matching
• Generate blocks only for the current file path
• Use complete code sections without truncation
• Ensure search and replacement content differ
• Include file path after language identifier using colon
• Make search patterns unique within the file
## Example
```java:src/Example.java
<<<<<<< SEARCH
public int calculate(int x, int y) {
return x + y;
}
=======
public int calculate(int x, int y) {
if (x < 0 || y < 0) {
throw new IllegalArgumentException("Values must be non-negative");
}
return x + y;
}
>>>>>>> REPLACE
```

View file

@ -1,16 +1,22 @@
package ee.carlrobert.codegpt.completions
import com.intellij.openapi.components.service
import ee.carlrobert.codegpt.ReferencedFile
import ee.carlrobert.codegpt.completions.factory.OpenAIRequestFactory
import ee.carlrobert.codegpt.conversations.Conversation
import ee.carlrobert.codegpt.conversations.ConversationService
import ee.carlrobert.codegpt.conversations.message.Message
import ee.carlrobert.codegpt.settings.configuration.ChatMode
import ee.carlrobert.codegpt.settings.prompts.PersonaPromptDetailsState
import ee.carlrobert.codegpt.settings.prompts.PersonasState
import ee.carlrobert.codegpt.settings.prompts.PromptsSettings
import ee.carlrobert.codegpt.settings.service.FeatureType
import ee.carlrobert.codegpt.util.file.FileUtil.getResourceContent
import ee.carlrobert.llm.client.openai.completion.OpenAIChatCompletionModel
import ee.carlrobert.llm.client.openai.completion.request.OpenAIChatCompletionStandardMessage
import org.assertj.core.api.Assertions.assertThat
import testsupport.IntegrationTest
import java.io.File
class OpenAIRequestFactoryIntegrationTest : IntegrationTest() {
@ -297,86 +303,367 @@ class OpenAIRequestFactoryIntegrationTest : IntegrationTest() {
.map { it.content }
assertThat(systemMessages).isNotEmpty()
val systemContent = systemMessages.first()
assertThat(systemContent).isEqualTo("You are an AI programming assistant integrated into a JetBrains IDE plugin. Your role is to answer coding questions, suggest new code, and perform refactoring or editing tasks. You have access to the following project information:\n" +
"\n" +
"Before we proceed with the main instructions, here is the content of relevant files in the project:\n" +
"\n" +
"<project_path>\n" +
"UNDEFINED\n" +
"</project_path>\n" +
"\n" +
"Instructions:\n" +
"\n" +
"1. Detect the intent behind the user's query:\n" +
" - New code suggestion\n" +
" - Technical explanation\n" +
" - Code refactoring or editing\n" +
"\n" +
"2. For queries not related to the codebase or for new files, provide a standard code or text block response.\n" +
"\n" +
"3. For refactoring or editing an existing file, always generate a SEARCH/REPLACE block.\n" +
"\n" +
"4. For any code generation, refactoring, or editing task:\n" +
" a. First, outline an implementation plan describing the steps to address the user's request.\n" +
" b. As you generate code or SEARCH/REPLACE blocks, reference the relevant step(s) from your plan, explaining your approach for each change.\n" +
" c. For complex tasks, break down the plan and code changes into smaller steps, presenting each with its rationale and code diff together.\n" +
" d. If the user's intent is unclear, ask clarifying questions before proceeding.\n" +
"\n" +
"5. When generating SEARCH/REPLACE blocks:\n" +
" a. Ensure each block represents an atomic, non-overlapping change that can be applied independently.\n" +
" b. Provide sufficient context in the SEARCH part to uniquely locate the change.\n" +
" c. Keep SEARCH blocks concise while including necessary surrounding lines.\n" +
"\n" +
"Formatting Guidelines:\n" +
"\n" +
"1. Begin with a brief, impersonal acknowledgment.\n" +
"\n" +
"2. Use the following format for code blocks:\n" +
" ```[language]:[full_file_path]\n" +
" [code content]\n" +
" ```\n" +
"\n" +
" Example:\n" +
" ```java:/path/to/Main.java\n" +
" public class Main {\n" +
" public static void main(String[] args) {\n" +
" System.out.println(\"Hello, World!\");\n" +
" }\n" +
" }\n" +
" ```\n" +
"\n" +
"3. For new files, show the entire file content in a single code fence.\n" +
"\n" +
"4. For editing existing files, use this SEARCH/REPLACE structure:\n" +
" ```[language]:[full_file_path]\n" +
" <<<<<<< SEARCH\n" +
" [exact lines from the file, including whitespace/comments]\n" +
" =======\n" +
" [replacement lines]\n" +
" >>>>>>> REPLACE\n" +
" ```\n" +
"\n" +
" Example:\n" +
" ```java:/path/to/Calculator.java\n" +
" <<<<<<< SEARCH\n" +
" public int add(int a, int b) {\n" +
" return a + b;\n" +
" }\n" +
" =======\n" +
" public int add(int a, int b) {\n" +
" // Added input validation\n" +
" if (a < 0 || b < 0) {\n" +
" throw new IllegalArgumentException(\"Negative numbers not allowed\");\n" +
" }\n" +
" return a + b;\n" +
" }\n" +
" >>>>>>> REPLACE\n" +
" ```\n" +
"\n" +
"5. Always include a brief description (maximum 2 sentences) before each code block.\n" +
"\n" +
"6. Do not provide an implementation plan for pure explanations or general questions.\n" +
"\n" +
"7. When refactoring an entire file, output multiple code blocks as needed, keeping changes concise unless a more extensive update is required.\n")
assertThat(systemContent).isEqualTo(
"You are an AI programming assistant integrated into a JetBrains IDE plugin. Your role is to answer coding questions, suggest new code, and perform refactoring or editing tasks. You have access to the following project information:\n" +
"\n" +
"Before we proceed with the main instructions, here is the content of relevant files in the project:\n" +
"\n" +
"<project_path>\n" +
"UNDEFINED\n" +
"</project_path>\n" +
"\n" +
"Instructions:\n" +
"\n" +
"1. Detect the intent behind the user's query:\n" +
" - New code suggestion\n" +
" - Technical explanation\n" +
" - Code refactoring or editing\n" +
"\n" +
"2. For queries not related to the codebase or for new files, provide a standard code or text block response.\n" +
"\n" +
"3. For refactoring or editing an existing file, always generate a SEARCH/REPLACE block.\n" +
"\n" +
"4. For any code generation, refactoring, or editing task:\n" +
" a. First, outline an implementation plan describing the steps to address the user's request.\n" +
" b. As you generate code or SEARCH/REPLACE blocks, reference the relevant step(s) from your plan, explaining your approach for each change.\n" +
" c. For complex tasks, break down the plan and code changes into smaller steps, presenting each with its rationale and code diff together.\n" +
" d. If the user's intent is unclear, ask clarifying questions before proceeding.\n" +
"\n" +
"5. When generating SEARCH/REPLACE blocks:\n" +
" a. Ensure each block represents an atomic, non-overlapping change that can be applied independently.\n" +
" b. Provide sufficient context in the SEARCH part to uniquely locate the change.\n" +
" c. Keep SEARCH blocks concise while including necessary surrounding lines.\n" +
"\n" +
"Formatting Guidelines:\n" +
"\n" +
"1. Begin with a brief, impersonal acknowledgment.\n" +
"\n" +
"2. Use the following format for code blocks:\n" +
" ```[language]:[full_file_path]\n" +
" [code content]\n" +
" ```\n" +
"\n" +
" Example:\n" +
" ```java:/path/to/Main.java\n" +
" public class Main {\n" +
" public static void main(String[] args) {\n" +
" System.out.println(\"Hello, World!\");\n" +
" }\n" +
" }\n" +
" ```\n" +
"\n" +
"3. For new files, show the entire file content in a single code fence.\n" +
"\n" +
"4. For editing existing files, use this SEARCH/REPLACE structure:\n" +
" ```[language]:[full_file_path]\n" +
" <<<<<<< SEARCH\n" +
" [exact lines from the file, including whitespace/comments]\n" +
" =======\n" +
" [replacement lines]\n" +
" >>>>>>> REPLACE\n" +
" ```\n" +
"\n" +
" Example:\n" +
" ```java:/path/to/Calculator.java\n" +
" <<<<<<< SEARCH\n" +
" public int add(int a, int b) {\n" +
" return a + b;\n" +
" }\n" +
" =======\n" +
" public int add(int a, int b) {\n" +
" // Added input validation\n" +
" if (a < 0 || b < 0) {\n" +
" throw new IllegalArgumentException(\"Negative numbers not allowed\");\n" +
" }\n" +
" return a + b;\n" +
" }\n" +
" >>>>>>> REPLACE\n" +
" ```\n" +
"\n" +
"5. Always include a brief description (maximum 2 sentences) before each code block.\n" +
"\n" +
"6. Do not provide an implementation plan for pure explanations or general questions.\n" +
"\n" +
"7. When refactoring an entire file, output multiple code blocks as needed, keeping changes concise unless a more extensive update is required.\n"
)
}
fun testInlineEditSingleRequestNoHistory() {
useOpenAIService(OpenAIChatCompletionModel.GPT_4_1.code, FeatureType.INLINE_EDIT)
val testFileContent = getResourceContent("/inline/TestClass.java")
val tempFile = File.createTempFile("TestClass", ".java")
tempFile.writeText(testFileContent)
tempFile.deleteOnExit()
val parameters = InlineEditCompletionParameters(
prompt = "add logging to this method",
selectedText = "myTestMethod()",
filePath = tempFile.absolutePath,
fileExtension = "java",
projectBasePath = project.basePath,
referencedFiles = null,
gitDiff = null,
conversation = null,
conversationHistory = null,
diagnosticsInfo = null
)
val request = OpenAIRequestFactory().createInlineEditRequest(parameters)
val systemMessage = request.messages[0] as OpenAIChatCompletionStandardMessage
assertThat(systemMessage.role).isEqualTo("system")
assertThat(systemMessage.content).isEqualTo(
"""
You are a code modification assistant. Generate SEARCH/REPLACE blocks to modify the specified file.
Project Context:
Project root: ${project.basePath}
All file paths should be relative to this project root.
## Current File (Editable)
```java:${tempFile.absolutePath}
$testFileContent
```
## External Context
No external context selected.
## Task
Analyze the user's request and generate modifications for the current file using SEARCH/REPLACE blocks.
## Format
Use SEARCH/REPLACE blocks to specify exact code changes:
Single change in the same file:
```language:filepath
<<<<<<< SEARCH
[exact code to find]
=======
[replacement code]
>>>>>>> REPLACE
```
Multiple changes in the same file:
```language:filepath
<<<<<<< SEARCH
[first section]
=======
[first replacement]
>>>>>>> REPLACE
<<<<<<< SEARCH
[second section]
=======
[second replacement]
>>>>>>> REPLACE
```
## Requirements
Match indentation and formatting exactly
Include 2-4 lines of context for unique pattern matching
Generate blocks only for the current file path
Use complete code sections without truncation
Ensure search and replacement content differ
Include file path after language identifier using colon
Make search patterns unique within the file
## Example
```java:src/Example.java
<<<<<<< SEARCH
public int calculate(int x, int y) {
return x + y;
}
=======
public int calculate(int x, int y) {
if (x < 0 || y < 0) {
throw new IllegalArgumentException("Values must be non-negative");
}
return x + y;
}
>>>>>>> REPLACE
```
""".trimIndent()
)
val userMessage = request.messages[1] as OpenAIChatCompletionStandardMessage
assertThat(userMessage.role).isEqualTo("user")
assertThat(userMessage.content).isEqualTo(
"""
Selected code:
```java
myTestMethod()
```
Request: add logging to this method
""".trimIndent()
)
}
fun testInlineEditFollowUpWithHistory() {
useOpenAIService(OpenAIChatCompletionModel.GPT_4_1.code, FeatureType.INLINE_EDIT)
val testFileContent = getResourceContent("/inline/TestClass.java")
val tempFile = File.createTempFile("TestClass", ".java")
tempFile.writeText(testFileContent)
tempFile.deleteOnExit()
val parameters = InlineEditCompletionParameters(
prompt = "TEST_FOLLOW_UP_PROMPT",
selectedText = "myTestMethod()",
filePath = tempFile.absolutePath,
fileExtension = "java",
projectBasePath = project.basePath,
referencedFiles = mutableListOf(
ReferencedFile(
"TEST_FILE_NAME_1.java",
"/path/to/TEST_FILE_NAME_1.java",
"TEST_FILE_CONTENT_1"
),
ReferencedFile(
"TEST_FILE_NAME_2.java",
"/path/to/TEST_FILE_NAME_2.java",
"TEST_FILE_CONTENT_2"
)
),
gitDiff = "TEST_GIT_DIFF",
conversation = Conversation().apply {
messages = mutableListOf(
Message("PREV_PROMPT").apply {
response = "PREV_RESPONSE"
}
)
},
conversationHistory = listOf(
Conversation().apply {
messages = mutableListOf(
Message("HISTORY_PROMPT_1").apply {
response = "HISTORY_RESPONSE_1"
}
)
},
Conversation().apply {
messages = mutableListOf(
Message("HISTORY_PROMPT_2").apply {
response = "HISTORY_RESPONSE_2"
}
)
}
),
diagnosticsInfo = null
)
val request = OpenAIRequestFactory().createInlineEditRequest(parameters)
val systemMessage = request.messages[0] as OpenAIChatCompletionStandardMessage
assertThat(systemMessage.role).isEqualTo("system")
assertThat(systemMessage.content).isEqualTo(
"""
You are a code modification assistant. Generate SEARCH/REPLACE blocks to modify the specified file.
Project Context:
Project root: ${project.basePath}
All file paths should be relative to this project root.
## Current File (Editable)
```java:${tempFile.absolutePath}
$testFileContent
```
## External Context
### Referenced Files
```java:/path/to/TEST_FILE_NAME_1.java
TEST_FILE_CONTENT_1
```
```java:/path/to/TEST_FILE_NAME_2.java
TEST_FILE_CONTENT_2
```
### Git Diff
```diff
TEST_GIT_DIFF
```
### Conversation History
**User:** HISTORY_PROMPT_1
**Assistant:** HISTORY_RESPONSE_1
**User:** HISTORY_PROMPT_2
**Assistant:** HISTORY_RESPONSE_2
## Task
Analyze the user's request and generate modifications for the current file using SEARCH/REPLACE blocks.
## Format
Use SEARCH/REPLACE blocks to specify exact code changes:
Single change in the same file:
```language:filepath
<<<<<<< SEARCH
[exact code to find]
=======
[replacement code]
>>>>>>> REPLACE
```
Multiple changes in the same file:
```language:filepath
<<<<<<< SEARCH
[first section]
=======
[first replacement]
>>>>>>> REPLACE
<<<<<<< SEARCH
[second section]
=======
[second replacement]
>>>>>>> REPLACE
```
## Requirements
Match indentation and formatting exactly
Include 2-4 lines of context for unique pattern matching
Generate blocks only for the current file path
Use complete code sections without truncation
Ensure search and replacement content differ
Include file path after language identifier using colon
Make search patterns unique within the file
## Example
```java:src/Example.java
<<<<<<< SEARCH
public int calculate(int x, int y) {
return x + y;
}
=======
public int calculate(int x, int y) {
if (x < 0 || y < 0) {
throw new IllegalArgumentException("Values must be non-negative");
}
return x + y;
}
>>>>>>> REPLACE
```
""".trimIndent()
)
val prevUserMessage = request.messages[1] as OpenAIChatCompletionStandardMessage
assertThat(prevUserMessage.role).isEqualTo("user")
assertThat(prevUserMessage.content).isEqualTo("PREV_PROMPT")
val prevResponse = request.messages[2] as OpenAIChatCompletionStandardMessage
assertThat(prevResponse.role).isEqualTo("assistant")
assertThat(prevResponse.content).isEqualTo("PREV_RESPONSE")
val followUpMessage = request.messages[3] as OpenAIChatCompletionStandardMessage
assertThat(followUpMessage.role).isEqualTo("user")
assertThat(followUpMessage.content).isEqualTo(
"""
Selected code:
```java
myTestMethod()
```
Request: TEST_FOLLOW_UP_PROMPT
""".trimIndent()
)
}
}

View file

@ -6,7 +6,6 @@ import com.intellij.util.messages.MessageBusConnection
import ee.carlrobert.codegpt.settings.service.FeatureType
import ee.carlrobert.codegpt.settings.service.ModelChangeNotifier
import ee.carlrobert.codegpt.settings.service.ServiceType
import ee.carlrobert.llm.client.codegpt.PricingPlan
import org.assertj.core.api.Assertions.assertThat
import testsupport.IntegrationTest
import java.util.concurrent.atomic.AtomicReference
@ -42,7 +41,7 @@ class ModelSettingsTest : IntegrationTest() {
lastNotification.set(NotificationData(FeatureType.COMMIT_MESSAGE, newModel, serviceType, "commitMessage"))
}
override fun editCodeModelChanged(newModel: String, serviceType: ServiceType) {
lastNotification.set(NotificationData(FeatureType.EDIT_CODE, newModel, serviceType, "editCode"))
lastNotification.set(NotificationData(FeatureType.INLINE_EDIT, newModel, serviceType, "editCode"))
}
override fun nextEditModelChanged(newModel: String, serviceType: ServiceType) {
lastNotification.set(NotificationData(FeatureType.NEXT_EDIT, newModel, serviceType, "nextEdit"))
@ -121,10 +120,10 @@ class ModelSettingsTest : IntegrationTest() {
fun `test setModelWithProvider with edit code triggers edit code notification`() {
lastNotification.set(null)
modelSettings.setModelWithProvider(FeatureType.EDIT_CODE, "claude-4-sonnet", ServiceType.PROXYAI)
modelSettings.setModelWithProvider(FeatureType.INLINE_EDIT, "claude-4-sonnet", ServiceType.PROXYAI)
val notification = lastNotification.get()
assertThat(notification!!.featureType).isEqualTo(FeatureType.EDIT_CODE)
assertThat(notification!!.featureType).isEqualTo(FeatureType.INLINE_EDIT)
assertThat(notification.model).isEqualTo("claude-4-sonnet")
assertThat(notification.serviceType).isEqualTo(ServiceType.PROXYAI)
}

View file

@ -27,19 +27,16 @@ interface ShortcutsTestMixin {
}
}
fun useOpenAIService(chatModel: String? = "gpt-4o", role: FeatureType = FeatureType.CHAT) {
fun useOpenAIService(chatModel: String? = "gpt-4o", featureType: FeatureType = FeatureType.CHAT) {
setCredential(OpenaiApiKey, "TEST_API_KEY")
val modelSettings = service<ModelSettings>()
when (role) {
FeatureType.CHAT -> {
modelSettings.setModel(FeatureType.CHAT, chatModel ?: "gpt-4o", ServiceType.OPENAI)
}
when (featureType) {
FeatureType.CODE_COMPLETION -> {
modelSettings.setModel(FeatureType.CODE_COMPLETION, "gpt-3.5-turbo-instruct", ServiceType.OPENAI)
}
else -> {
modelSettings.setModel(FeatureType.CHAT, chatModel ?: "gpt-4o", ServiceType.OPENAI)
modelSettings.setModel(featureType, chatModel ?: "gpt-4o", ServiceType.OPENAI)
modelSettings.setModel(FeatureType.CODE_COMPLETION, "gpt-3.5-turbo-instruct", ServiceType.OPENAI)
}
}

View file

@ -0,0 +1,6 @@
public class TestClass {
public static void myTestMethod() {
System.out.println("Hello world");
}
}