feat(intellij): auto trigger completion and add hotkeys to accept/dismiss completion. (#315)
parent
b66a178bb7
commit
67d407779c
|
|
@ -1,4 +1,3 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,15 @@ import com.tabbyml.intellijtabby.editor.InlineCompletionService
|
||||||
class AcceptCompletion : AnAction() {
|
class AcceptCompletion : AnAction() {
|
||||||
override fun actionPerformed(e: AnActionEvent) {
|
override fun actionPerformed(e: AnActionEvent) {
|
||||||
val inlineCompletionService = service<InlineCompletionService>()
|
val inlineCompletionService = service<InlineCompletionService>()
|
||||||
val editor = e.getRequiredData(CommonDataKeys.EDITOR)
|
inlineCompletionService.accept()
|
||||||
inlineCompletionService.accept(editor)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun update(e: AnActionEvent) {
|
override fun update(e: AnActionEvent) {
|
||||||
val inlineCompletionService = service<InlineCompletionService>()
|
val inlineCompletionService = service<InlineCompletionService>()
|
||||||
e.presentation.isEnabled = e.getData(CommonDataKeys.EDITOR) != null
|
e.presentation.isEnabled = e.project != null
|
||||||
&& e.project != null
|
&& e.getData(CommonDataKeys.EDITOR) != null
|
||||||
&& inlineCompletionService.currentText != null
|
&& inlineCompletionService.shownInlineCompletion != null
|
||||||
|
&& e.getData(CommonDataKeys.EDITOR) == inlineCompletionService.shownInlineCompletion?.editor
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getActionUpdateThread(): ActionUpdateThread {
|
override fun getActionUpdateThread(): ActionUpdateThread {
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,10 @@ class DismissCompletion : AnAction() {
|
||||||
|
|
||||||
override fun update(e: AnActionEvent) {
|
override fun update(e: AnActionEvent) {
|
||||||
val inlineCompletionService = service<InlineCompletionService>()
|
val inlineCompletionService = service<InlineCompletionService>()
|
||||||
e.presentation.isEnabled = e.getData(CommonDataKeys.EDITOR) != null
|
e.presentation.isEnabled = e.project != null
|
||||||
&& e.project != null
|
&& e.getData(CommonDataKeys.EDITOR) != null
|
||||||
&& inlineCompletionService.currentText != null
|
&& inlineCompletionService.shownInlineCompletion != null
|
||||||
|
&& e.getData(CommonDataKeys.EDITOR) == inlineCompletionService.shownInlineCompletion?.editor
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getActionUpdateThread(): ActionUpdateThread {
|
override fun getActionUpdateThread(): ActionUpdateThread {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import com.intellij.openapi.actionSystem.ActionUpdateThread
|
||||||
import com.intellij.openapi.actionSystem.AnAction
|
import com.intellij.openapi.actionSystem.AnAction
|
||||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||||
import com.intellij.openapi.actionSystem.CommonDataKeys
|
import com.intellij.openapi.actionSystem.CommonDataKeys
|
||||||
import com.intellij.openapi.application.invokeLater
|
|
||||||
import com.intellij.openapi.components.service
|
import com.intellij.openapi.components.service
|
||||||
import com.tabbyml.intellijtabby.agent.AgentService
|
import com.tabbyml.intellijtabby.agent.AgentService
|
||||||
import com.tabbyml.intellijtabby.editor.InlineCompletionService
|
import com.tabbyml.intellijtabby.editor.InlineCompletionService
|
||||||
|
|
@ -15,23 +14,17 @@ class TriggerCompletion : AnAction() {
|
||||||
val agentService = service<AgentService>()
|
val agentService = service<AgentService>()
|
||||||
val inlineCompletionService = service<InlineCompletionService>()
|
val inlineCompletionService = service<InlineCompletionService>()
|
||||||
val editor = e.getRequiredData(CommonDataKeys.EDITOR)
|
val editor = e.getRequiredData(CommonDataKeys.EDITOR)
|
||||||
val file = e.getRequiredData(CommonDataKeys.PSI_FILE)
|
|
||||||
val offset = editor.caretModel.primaryCaret.offset
|
val offset = editor.caretModel.primaryCaret.offset
|
||||||
|
|
||||||
inlineCompletionService.dismiss()
|
inlineCompletionService.dismiss()
|
||||||
agentService.getCompletion(editor, file, offset)?.thenAccept {
|
agentService.getCompletion(editor, offset)?.thenAccept {
|
||||||
invokeLater {
|
inlineCompletionService.show(editor, offset, it)
|
||||||
inlineCompletionService.show(editor, offset, it)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun update(e: AnActionEvent) {
|
override fun update(e: AnActionEvent) {
|
||||||
val inlineCompletionService = service<InlineCompletionService>()
|
|
||||||
e.presentation.isEnabled = e.project != null
|
e.presentation.isEnabled = e.project != null
|
||||||
&& e.getData(CommonDataKeys.EDITOR) != null
|
&& e.getData(CommonDataKeys.EDITOR) != null
|
||||||
&& e.getData(CommonDataKeys.PSI_FILE) != null
|
|
||||||
&& inlineCompletionService.currentText == null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getActionUpdateThread(): ActionUpdateThread {
|
override fun getActionUpdateThread(): ActionUpdateThread {
|
||||||
|
|
|
||||||
|
|
@ -84,14 +84,14 @@ class Agent : ProcessAdapter() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCompletions(request: CompletionRequest): CompletableFuture<CompletionResponse> {
|
fun getCompletions(request: CompletionRequest): CompletableFuture<CompletionResponse?> {
|
||||||
return request("getCompletions", listOf(request))
|
return request("getCompletions", listOf(request))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var requestId = 1
|
private var requestId = 1
|
||||||
private var ongoingRequest = mutableMapOf<Int, (response: String) -> Unit>()
|
private var ongoingRequest = mutableMapOf<Int, (response: String) -> Unit>()
|
||||||
|
|
||||||
private inline fun <reified T : Any> request(func: String, args: List<Any> = emptyList()): CompletableFuture<T> {
|
private inline fun <reified T : Any?> request(func: String, args: List<Any> = emptyList()): CompletableFuture<T> {
|
||||||
val id = requestId++
|
val id = requestId++
|
||||||
val data = listOf(id, mapOf("func" to func, "args" to args))
|
val data = listOf(id, mapOf("func" to func, "args" to args))
|
||||||
val json = gson.toJson(data)
|
val json = gson.toJson(data)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
package com.tabbyml.intellijtabby.agent
|
package com.tabbyml.intellijtabby.agent
|
||||||
|
|
||||||
|
import com.intellij.openapi.application.ReadAction
|
||||||
import com.intellij.openapi.components.Service
|
import com.intellij.openapi.components.Service
|
||||||
import com.intellij.openapi.diagnostic.Logger
|
import com.intellij.openapi.diagnostic.Logger
|
||||||
import com.intellij.openapi.editor.Editor
|
import com.intellij.openapi.editor.Editor
|
||||||
|
import com.intellij.psi.PsiDocumentManager
|
||||||
import com.intellij.psi.PsiFile
|
import com.intellij.psi.PsiFile
|
||||||
import java.util.concurrent.CompletableFuture
|
import java.util.concurrent.CompletableFuture
|
||||||
|
|
||||||
|
|
@ -23,14 +25,20 @@ class AgentService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCompletion(editor: Editor, file: PsiFile, offset: Int): CompletableFuture<Agent.CompletionResponse>? {
|
fun getCompletion(editor: Editor, offset: Int): CompletableFuture<Agent.CompletionResponse>? {
|
||||||
return agent.thenCompose {
|
return agent.thenCompose {agent ->
|
||||||
it?.getCompletions(Agent.CompletionRequest(
|
ReadAction.compute<PsiFile, Throwable> {
|
||||||
file.virtualFile.path,
|
editor.project?.let { project ->
|
||||||
file.language.id, // FIXME: map language id
|
PsiDocumentManager.getInstance(project).getPsiFile(editor.document)
|
||||||
editor.document.text,
|
}
|
||||||
offset
|
}?.let { file ->
|
||||||
))
|
agent?.getCompletions(Agent.CompletionRequest(
|
||||||
|
file.virtualFile.path,
|
||||||
|
file.language.id, // FIXME: map language id
|
||||||
|
editor.document.text,
|
||||||
|
offset
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
package com.tabbyml.intellijtabby.editor
|
||||||
|
|
||||||
|
import com.intellij.openapi.components.Service
|
||||||
|
import com.intellij.openapi.components.service
|
||||||
|
import com.intellij.openapi.diagnostic.Logger
|
||||||
|
import com.intellij.openapi.editor.Editor
|
||||||
|
import com.intellij.openapi.fileEditor.FileEditorManagerEvent
|
||||||
|
import com.intellij.openapi.fileEditor.FileEditorManagerListener
|
||||||
|
import com.intellij.openapi.project.Project
|
||||||
|
import com.tabbyml.intellijtabby.agent.AgentService
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class CompletionScheduler {
|
||||||
|
private val logger = Logger.getInstance(CompletionScheduler::class.java)
|
||||||
|
|
||||||
|
data class CompletionContext(val editor: Editor, val offset: Int, val timer: Timer)
|
||||||
|
|
||||||
|
private var project: Project? = null
|
||||||
|
var scheduled: CompletionContext? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
|
||||||
|
fun schedule(editor: Editor, offset: Int) {
|
||||||
|
clear()
|
||||||
|
val agentService = service<AgentService>()
|
||||||
|
val inlineCompletionService = service<InlineCompletionService>()
|
||||||
|
inlineCompletionService.dismiss()
|
||||||
|
val timer = Timer()
|
||||||
|
timer.schedule(object : TimerTask() {
|
||||||
|
override fun run() {
|
||||||
|
logger.info("Scheduled completion task running")
|
||||||
|
agentService.getCompletion(editor, offset)?.thenAccept {
|
||||||
|
inlineCompletionService.show(editor, offset, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 150)
|
||||||
|
scheduled = CompletionContext(editor, offset, timer)
|
||||||
|
|
||||||
|
if (project != editor.project) {
|
||||||
|
project = editor.project
|
||||||
|
editor.project?.messageBus?.connect()?.subscribe(
|
||||||
|
FileEditorManagerListener.FILE_EDITOR_MANAGER,
|
||||||
|
object: FileEditorManagerListener {
|
||||||
|
override fun selectionChanged(event: FileEditorManagerEvent) {
|
||||||
|
logger.info("FileEditorManagerListener selectionChanged.")
|
||||||
|
clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
scheduled?.let {
|
||||||
|
it.timer.cancel()
|
||||||
|
scheduled = null
|
||||||
|
}
|
||||||
|
val inlineCompletionService = service<InlineCompletionService>()
|
||||||
|
inlineCompletionService.dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
package com.tabbyml.intellijtabby.editor
|
||||||
|
|
||||||
|
import com.intellij.openapi.components.service
|
||||||
|
import com.intellij.openapi.diagnostic.Logger
|
||||||
|
import com.intellij.openapi.editor.event.*
|
||||||
|
import com.intellij.openapi.fileEditor.FileEditorManager
|
||||||
|
|
||||||
|
class EditorListener : EditorFactoryListener {
|
||||||
|
private val logger = Logger.getInstance(EditorListener::class.java)
|
||||||
|
|
||||||
|
override fun editorCreated(event: EditorFactoryEvent) {
|
||||||
|
val editor = event.editor
|
||||||
|
val editorManager = editor.project?.let { FileEditorManager.getInstance(it) } ?: return
|
||||||
|
val completionScheduler = service<CompletionScheduler>()
|
||||||
|
|
||||||
|
editor.caretModel.addCaretListener(object : CaretListener {
|
||||||
|
override fun caretPositionChanged(event: CaretEvent) {
|
||||||
|
if (editorManager.selectedTextEditor == editor) {
|
||||||
|
completionScheduler.scheduled?.let {
|
||||||
|
if (it.editor != editor || it.offset != editor.caretModel.primaryCaret.offset) {
|
||||||
|
completionScheduler.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
editor.document.addDocumentListener(object : DocumentListener {
|
||||||
|
override fun documentChanged(event: DocumentEvent) {
|
||||||
|
if (editorManager.selectedTextEditor == editor) {
|
||||||
|
val offset = event.offset + event.newFragment.length
|
||||||
|
completionScheduler.schedule(editor, offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
package com.tabbyml.intellijtabby.editor
|
package com.tabbyml.intellijtabby.editor
|
||||||
|
|
||||||
import com.intellij.openapi.application.ReadAction
|
import com.intellij.openapi.application.invokeLater
|
||||||
import com.intellij.openapi.command.WriteCommandAction
|
import com.intellij.openapi.command.WriteCommandAction
|
||||||
import com.intellij.openapi.components.Service
|
import com.intellij.openapi.components.Service
|
||||||
import com.intellij.openapi.diagnostic.Logger
|
import com.intellij.openapi.diagnostic.Logger
|
||||||
|
|
@ -14,50 +14,56 @@ import com.tabbyml.intellijtabby.agent.Agent
|
||||||
import java.awt.Graphics
|
import java.awt.Graphics
|
||||||
import java.awt.Rectangle
|
import java.awt.Rectangle
|
||||||
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class InlineCompletionService {
|
class InlineCompletionService {
|
||||||
private val logger = Logger.getInstance(InlineCompletionService::class.java)
|
private val logger = Logger.getInstance(InlineCompletionService::class.java)
|
||||||
var currentText: String? = null
|
|
||||||
|
data class InlineCompletion(val editor: Editor, val text: String, val offset: Int, val inlays: List<Inlay<*>>)
|
||||||
|
|
||||||
|
var shownInlineCompletion: InlineCompletion? = null
|
||||||
private set
|
private set
|
||||||
var currentOffset: Int? = null
|
|
||||||
private set
|
|
||||||
private var currentInlays: MutableList<Inlay<*>> = mutableListOf()
|
|
||||||
|
|
||||||
fun show(editor: Editor, offset: Int, completion: Agent.CompletionResponse) {
|
fun show(editor: Editor, offset: Int, completion: Agent.CompletionResponse) {
|
||||||
|
dismiss()
|
||||||
if (completion.choices.isEmpty()) {
|
if (completion.choices.isEmpty()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val text = completion.choices.first().text
|
invokeLater {
|
||||||
logger.info("Showing inline completion at $offset: $text")
|
val text = completion.choices.first().text
|
||||||
val lines = text.split("\n")
|
logger.info("Showing inline completion at $offset: $text")
|
||||||
lines.forEachIndexed { index, line -> addInlayLine(editor, offset, line, index) }
|
val lines = text.split("\n")
|
||||||
currentText = text
|
val inlays = lines
|
||||||
currentOffset = offset
|
.mapIndexed { index, line -> createInlayLine(editor, offset, line, index) }
|
||||||
|
.filterNotNull()
|
||||||
|
shownInlineCompletion = InlineCompletion(editor, text, offset, inlays)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun accept(editor: Editor) {
|
fun accept() {
|
||||||
currentText?.let {
|
shownInlineCompletion?.let {
|
||||||
WriteCommandAction.runWriteCommandAction(editor.project) {
|
logger.info("Accept inline completion at ${it.offset}: ${it.text}")
|
||||||
editor.document.insertString(currentOffset!!, it)
|
WriteCommandAction.runWriteCommandAction(it.editor.project) {
|
||||||
editor.caretModel.moveToOffset(currentOffset!! + it.length)
|
it.editor.document.insertString(it.offset, it.text)
|
||||||
|
it.editor.caretModel.moveToOffset(it.offset + it.text.length)
|
||||||
}
|
}
|
||||||
currentText = null
|
invokeLater {
|
||||||
currentOffset = null
|
it.inlays.forEach(Disposer::dispose)
|
||||||
currentInlays.forEach(Disposer::dispose)
|
}
|
||||||
currentInlays = mutableListOf()
|
shownInlineCompletion = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun dismiss() {
|
fun dismiss() {
|
||||||
currentText?.let {
|
shownInlineCompletion?.let {
|
||||||
currentText = null
|
invokeLater {
|
||||||
currentOffset = null
|
it.inlays.forEach(Disposer::dispose)
|
||||||
currentInlays.forEach(Disposer::dispose)
|
}
|
||||||
currentInlays = mutableListOf()
|
shownInlineCompletion = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun addInlayLine(editor: Editor, offset: Int, line: String, index: Int) {
|
private fun createInlayLine(editor: Editor, offset: Int, line: String, index: Int): Inlay<*>? {
|
||||||
val renderer = object : EditorCustomElementRenderer {
|
val renderer = object : EditorCustomElementRenderer {
|
||||||
override fun calcWidthInPixels(inlay: Inlay<*>): Int {
|
override fun calcWidthInPixels(inlay: Inlay<*>): Int {
|
||||||
// FIXME: Calc width?
|
// FIXME: Calc width?
|
||||||
|
|
@ -69,13 +75,10 @@ class InlineCompletionService {
|
||||||
graphics.drawString(line, targetRect.x, targetRect.y + inlay.editor.ascent)
|
graphics.drawString(line, targetRect.x, targetRect.y + inlay.editor.ascent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val inlay = if (index == 0) {
|
return if (index == 0) {
|
||||||
editor.inlayModel.addInlineElement(offset, true, renderer)
|
editor.inlayModel.addInlineElement(offset, true, renderer)
|
||||||
} else {
|
} else {
|
||||||
editor.inlayModel.addBlockElement(offset, true, false, -index, renderer)
|
editor.inlayModel.addBlockElement(offset, true, false, -index, renderer)
|
||||||
}
|
}
|
||||||
inlay?.let {
|
|
||||||
currentInlays.add(it)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -25,26 +25,30 @@
|
||||||
<!-- Extension points defined by the plugin.
|
<!-- Extension points defined by the plugin.
|
||||||
Read more: https://plugins.jetbrains.com/docs/intellij/plugin-extension-points.html -->
|
Read more: https://plugins.jetbrains.com/docs/intellij/plugin-extension-points.html -->
|
||||||
<extensions defaultExtensionNs="com.intellij">
|
<extensions defaultExtensionNs="com.intellij">
|
||||||
|
<editorFactoryListener implementation="com.tabbyml.intellijtabby.editor.EditorListener"/>
|
||||||
</extensions>
|
</extensions>
|
||||||
|
|
||||||
<actions>
|
<actions>
|
||||||
<action id="Tabby.TriggerCompletion"
|
<group id="Tabby.ToolsMenu" popup="true" text="Tabby" description="Tabby AI code assistant">
|
||||||
class="com.tabbyml.intellijtabby.actions.TriggerCompletion"
|
<add-to-group group-id="ToolsMenu" anchor="last"/>
|
||||||
text="TriggerCompletion"
|
<action id="Tabby.TriggerCompletion"
|
||||||
description="Trigger completion at current position.">
|
class="com.tabbyml.intellijtabby.actions.TriggerCompletion"
|
||||||
<add-to-group group-id="EditorPopupMenu" anchor="first"/>
|
text="Trigger Completion"
|
||||||
</action>
|
description="Trigger completion at current position.">
|
||||||
<action id="Tabby.AcceptCompletion"
|
<keyboard-shortcut first-keystroke="alt BACK_SLASH" keymap="$default"/>
|
||||||
class="com.tabbyml.intellijtabby.actions.AcceptCompletion"
|
</action>
|
||||||
text="AcceptCompletion"
|
<action id="Tabby.AcceptCompletion"
|
||||||
description="Trigger completion at current position.">
|
class="com.tabbyml.intellijtabby.actions.AcceptCompletion"
|
||||||
<add-to-group group-id="EditorPopupMenu" anchor="after" relative-to-action="Tabby.TriggerCompletion"/>
|
text="Accept Completion"
|
||||||
</action>
|
description="Trigger completion at current position.">
|
||||||
<action id="Tabby.DismissCompletion"
|
<keyboard-shortcut first-keystroke="TAB" keymap="$default"/>
|
||||||
class="com.tabbyml.intellijtabby.actions.DismissCompletion"
|
</action>
|
||||||
text="DismissCompletion"
|
<action id="Tabby.DismissCompletion"
|
||||||
description="Trigger completion at current position.">
|
class="com.tabbyml.intellijtabby.actions.DismissCompletion"
|
||||||
<add-to-group group-id="EditorPopupMenu" anchor="after" relative-to-action="Tabby.AcceptCompletion"/>
|
text="Dismiss Completion"
|
||||||
</action>
|
description="Trigger completion at current position.">
|
||||||
|
<keyboard-shortcut first-keystroke="ESCAPE" keymap="$default"/>
|
||||||
|
</action>
|
||||||
|
</group>
|
||||||
</actions>
|
</actions>
|
||||||
</idea-plugin>
|
</idea-plugin>
|
||||||
Loading…
Reference in New Issue