feat(intellij): add statusbar and settings (#333)

* fix: intellij job coroutines and cancellation.

* feat: intellij plugin add settings.

* fix: intellij plugin language id map.

* fix: intellij log completion events.

* feat(intellij): add status bar.

* docs: update docs for intellij plugin.
release-0.0
Zhiming Ma 2023-08-04 12:02:32 +08:00 committed by GitHub
parent 4eaae27ed3
commit 20e9788f29
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 672 additions and 114 deletions

View File

@ -5,7 +5,7 @@ plugins {
} }
group = "com.tabbyml" group = "com.tabbyml"
version = "0.0.1-SNAPSHOT" version = "0.0.1"
repositories { repositories {
mavenCentral() mavenCentral()
@ -41,7 +41,7 @@ tasks {
into("build/idea-sandbox/plugins/intellij-tabby/node_scripts") into("build/idea-sandbox/plugins/intellij-tabby/node_scripts")
} }
buildPlugin { buildSearchableOptions {
dependsOn(copyNodeScripts) dependsOn(copyNodeScripts)
} }
@ -57,5 +57,6 @@ tasks {
publishPlugin { publishPlugin {
token.set(System.getenv("PUBLISH_TOKEN")) token.set(System.getenv("PUBLISH_TOKEN"))
channels.set(listOf("alpha"))
} }
} }

View File

@ -0,0 +1,12 @@
package com.tabbyml.intellijtabby.actions
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.options.ShowSettingsUtil
import com.tabbyml.intellijtabby.settings.ApplicationConfigurable
class OpenSettings: AnAction() {
override fun actionPerformed(e: AnActionEvent) {
ShowSettingsUtil.getInstance().showSettingsDialog(e.project, ApplicationConfigurable::class.java)
}
}

View File

@ -0,0 +1,29 @@
package com.tabbyml.intellijtabby.actions
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.components.service
import com.tabbyml.intellijtabby.settings.ApplicationSettingsState
class ToggleAutoCompletionEnabled : AnAction() {
override fun actionPerformed(e: AnActionEvent) {
val settings = service<ApplicationSettingsState>()
settings.isAutoCompletionEnabled = !settings.isAutoCompletionEnabled
}
override fun update(e: AnActionEvent) {
val settings = service<ApplicationSettingsState>()
if (settings.isAutoCompletionEnabled) {
e.presentation.text = "Disable Auto Completion"
e.presentation.description = "Tabby does not show completion suggestions automatically, you can still request them on demand."
} else {
e.presentation.text = "Enable Auto Completion"
e.presentation.description = "Tabby shows inline completion suggestions automatically."
}
}
override fun getActionUpdateThread(): ActionUpdateThread {
return ActionUpdateThread.BGT
}
}

View File

@ -5,21 +5,15 @@ 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.components.service import com.intellij.openapi.components.service
import com.tabbyml.intellijtabby.agent.AgentService import com.tabbyml.intellijtabby.editor.CompletionScheduler
import com.tabbyml.intellijtabby.editor.InlineCompletionService
class TriggerCompletion : AnAction() { class TriggerCompletion : AnAction() {
override fun actionPerformed(e: AnActionEvent) { override fun actionPerformed(e: AnActionEvent) {
val agentService = service<AgentService>() val completionScheduler = service<CompletionScheduler>()
val inlineCompletionService = service<InlineCompletionService>()
val editor = e.getRequiredData(CommonDataKeys.EDITOR) val editor = e.getRequiredData(CommonDataKeys.EDITOR)
val offset = editor.caretModel.primaryCaret.offset val offset = editor.caretModel.primaryCaret.offset
completionScheduler.schedule(editor, offset, triggerDelay = 0, manually = true)
inlineCompletionService.dismiss()
agentService.getCompletion(editor, offset)?.thenAccept {
inlineCompletionService.show(editor, offset, it)
}
} }
override fun update(e: AnActionEvent) { override fun update(e: AnActionEvent) {

View File

@ -1,6 +1,7 @@
package com.tabbyml.intellijtabby.agent package com.tabbyml.intellijtabby.agent
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.annotations.SerializedName
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import com.intellij.execution.configurations.GeneralCommandLine import com.intellij.execution.configurations.GeneralCommandLine
import com.intellij.execution.configurations.PathEnvironmentVariableUtil import com.intellij.execution.configurations.PathEnvironmentVariableUtil
@ -9,13 +10,16 @@ import com.intellij.execution.process.ProcessAdapter
import com.intellij.execution.process.ProcessEvent import com.intellij.execution.process.ProcessEvent
import com.intellij.execution.process.ProcessOutputTypes import com.intellij.execution.process.ProcessOutputTypes
import com.intellij.ide.plugins.PluginManagerCore import com.intellij.ide.plugins.PluginManagerCore
import com.intellij.openapi.application.ApplicationInfo
import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.extensions.PluginId import com.intellij.openapi.extensions.PluginId
import com.intellij.openapi.util.Key import com.intellij.openapi.util.Key
import com.intellij.util.EnvironmentUtil import com.intellij.util.EnvironmentUtil
import com.intellij.util.io.BaseOutputReader import com.intellij.util.io.BaseOutputReader
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.OutputStreamWriter import java.io.OutputStreamWriter
import java.util.concurrent.CompletableFuture
class Agent : ProcessAdapter() { class Agent : ProcessAdapter() {
private val logger = Logger.getInstance(Agent::class.java) private val logger = Logger.getInstance(Agent::class.java)
@ -23,11 +27,17 @@ class Agent : ProcessAdapter() {
private val process: KillableProcessHandler private val process: KillableProcessHandler
private val streamWriter: OutputStreamWriter private val streamWriter: OutputStreamWriter
var status = "notInitialized" enum class Status {
private set NOT_INITIALIZED,
READY,
DISCONNECTED,
UNAUTHORIZED,
}
private val statusFlow = MutableStateFlow(Status.NOT_INITIALIZED)
val status = statusFlow.asStateFlow()
init { init {
logger.info("Agent init.")
logger.info("Environment variables: PATH: ${EnvironmentUtil.getValue("PATH")}") logger.info("Environment variables: PATH: ${EnvironmentUtil.getValue("PATH")}")
val node = PathEnvironmentVariableUtil.findExecutableInPathOnAnyOS("node") val node = PathEnvironmentVariableUtil.findExecutableInPathOnAnyOS("node")
@ -49,7 +59,7 @@ class Agent : ProcessAdapter() {
} }
val cmd = GeneralCommandLine(node.absolutePath, script.absolutePath) val cmd = GeneralCommandLine(node.absolutePath, script.absolutePath)
process = object: KillableProcessHandler(cmd) { process = object : KillableProcessHandler(cmd) {
override fun readerOptions(): BaseOutputReader.Options { override fun readerOptions(): BaseOutputReader.Options {
return BaseOutputReader.Options.forMostlySilentProcess() return BaseOutputReader.Options.forMostlySilentProcess()
} }
@ -59,12 +69,46 @@ class Agent : ProcessAdapter() {
streamWriter = process.processInput.writer() streamWriter = process.processInput.writer()
} }
fun initialize(): CompletableFuture<Boolean> { data class Config(
return request("initialize", listOf(mapOf("client" to "intellij-tabby"))) val server: Server? = null,
val completion: Completion? = null,
val logs: Logs? = null,
val anonymousUsageTracking: AnonymousUsageTracking? = null,
) {
data class Server(
val endpoint: String,
)
data class Completion(
val maxPrefixLines: Int,
val maxSuffixLines: Int,
)
data class Logs(
val level: String,
)
data class AnonymousUsageTracking(
val disabled: Boolean,
)
} }
fun updateConfig(): CompletableFuture<Boolean> { suspend fun initialize(config: Config): Boolean {
return request("updateConfig", listOf(emptyMap<Any, Any>())) val appInfo = ApplicationInfo.getInstance().fullApplicationName
val pluginId = "com.tabbyml.intellij-tabby"
val pluginVersion = PluginManagerCore.getPlugin(PluginId.getId(pluginId))?.version
return request(
"initialize", listOf(
mapOf(
"config" to config,
"client" to "$appInfo $pluginId $pluginVersion",
)
)
)
}
suspend fun updateConfig(config: Config): Boolean {
return request("updateConfig", listOf(config))
} }
data class CompletionRequest( data class CompletionRequest(
@ -84,28 +128,60 @@ class Agent : ProcessAdapter() {
) )
} }
fun getCompletions(request: CompletionRequest): CompletableFuture<CompletionResponse?> { suspend fun getCompletions(request: CompletionRequest): CompletionResponse? {
return request("getCompletions", listOf(request)) return request("getCompletions", listOf(request))
} }
data class LogEventRequest(
val type: EventType,
@SerializedName("completion_id") val completionId: String,
@SerializedName("choice_index") val choiceIndex: Int,
) {
enum class EventType {
@SerializedName("view") VIEW,
@SerializedName("select") SELECT,
}
}
suspend fun postEvent(event: LogEventRequest): Boolean {
return request("postEvent", listOf(event))
}
fun close() {
streamWriter.close()
process.destroyProcess()
}
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 suspend inline fun <reified T : Any?> request(func: String, args: List<Any> = emptyList()): T =
val id = requestId++ suspendCancellableCoroutine { continuation ->
val data = listOf(id, mapOf("func" to func, "args" to args)) val id = requestId++
val json = gson.toJson(data) ongoingRequest[id] = { response ->
streamWriter.write(json + "\n") logger.info("Agent response: $response")
streamWriter.flush() val result = gson.fromJson<T>(response, object : TypeToken<T>() {}.type)
logger.info("Agent request: $json") continuation.resumeWith(Result.success(result))
val future = CompletableFuture<T>() }
ongoingRequest[id] = { response -> val data = listOf(id, mapOf("func" to func, "args" to args))
logger.info("Agent response: $response") val json = gson.toJson(data)
val result = gson.fromJson<T>(response, object : TypeToken<T>() {}.type) logger.info("Agent request: $json")
future.complete(result) streamWriter.write(json + "\n")
streamWriter.flush()
continuation.invokeOnCancellation {
logger.info("Agent request cancelled")
val cancellationId = requestId++
ongoingRequest[cancellationId] = { response ->
logger.info("Agent cancellation response: $response")
}
val cancellationData = listOf(cancellationId, mapOf("func" to "cancelRequest", "args" to listOf(id)))
val cancellationJson = gson.toJson(cancellationData)
logger.info("Agent cancellation request: $cancellationJson")
streamWriter.write(cancellationJson + "\n")
streamWriter.flush()
}
} }
return future
}
private var outputBuffer: String = "" private var outputBuffer: String = ""
@ -131,7 +207,9 @@ class Agent : ProcessAdapter() {
logger.info("Parsed agent output: $data") logger.info("Parsed agent output: $data")
val id = (data[0] as Number).toInt() val id = (data[0] as Number).toInt()
if (id == 0) { if (id == 0) {
handleNotification(gson.toJson(data[1])) if (data[1] is Map<*, *>) {
handleNotification(data[1] as Map<*, *>)
}
} else { } else {
ongoingRequest[id]?.let { callback -> ongoingRequest[id]?.let { callback ->
callback(gson.toJson(data[1])) callback(gson.toJson(data[1]))
@ -140,7 +218,30 @@ class Agent : ProcessAdapter() {
} }
} }
private fun handleNotification(event: String) { private fun handleNotification(event: Map<*, *>) {
logger.info("Agent notification: $event") when (event["event"]) {
"statusChanged" -> {
logger.info("Agent notification $event")
statusFlow.value = when (event["status"]) {
"notInitialized" -> Status.NOT_INITIALIZED
"ready" -> Status.READY
"disconnected" -> Status.DISCONNECTED
"unauthorized" -> Status.UNAUTHORIZED
else -> Status.NOT_INITIALIZED
}
}
"configUpdated" -> {
logger.info("Agent notification $event")
}
"authRequired" -> {
logger.info("Agent notification $event")
}
else -> {
logger.error("Agent notification, unknown event name: ${event["event"]}")
}
}
} }
} }

View File

@ -1,44 +1,145 @@
package com.tabbyml.intellijtabby.agent package com.tabbyml.intellijtabby.agent
import com.intellij.lang.Language
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ReadAction import com.intellij.openapi.application.ReadAction
import com.intellij.openapi.components.Service 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.PsiDocumentManager
import com.intellij.psi.PsiFile import com.intellij.psi.PsiFile
import java.util.concurrent.CompletableFuture import com.tabbyml.intellijtabby.settings.ApplicationSettingsState
import io.ktor.util.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@Service @Service
class AgentService { class AgentService : Disposable {
private val logger = Logger.getInstance(AgentService::class.java) private val logger = Logger.getInstance(AgentService::class.java)
private val agent: CompletableFuture<Agent?> = CompletableFuture<Agent?>() private var agent: Agent = Agent()
val scope: CoroutineScope = CoroutineScope(Dispatchers.IO)
val status get() = agent.status
init { init {
try { val settings = service<ApplicationSettingsState>()
val instance = Agent() scope.launch {
instance.initialize().thenApply { try {
logger.info("Agent init done: $it") agent.initialize(createAgentConfig(settings.data))
agent.complete(instance) logger.info("Agent init done.")
} catch (e: Error) {
logger.error("Agent init failed: $e")
}
}
scope.launch {
settings.state.collect {
updateConfig(createAgentConfig(it))
} }
} catch (_: Error) {
agent.complete(null)
} }
} }
fun getCompletion(editor: Editor, offset: Int): CompletableFuture<Agent.CompletionResponse>? { private fun createAgentConfig(state: ApplicationSettingsState.State): Agent.Config {
return agent.thenCompose {agent -> return Agent.Config(
ReadAction.compute<PsiFile, Throwable> { server = if (state.serverEndpoint.isNotBlank()) {
editor.project?.let { project -> Agent.Config.Server(
PsiDocumentManager.getInstance(project).getPsiFile(editor.document) endpoint = state.serverEndpoint,
} )
}?.let { file -> } else {
agent?.getCompletions(Agent.CompletionRequest( null
},
anonymousUsageTracking = if (state.isAnonymousUsageTrackingDisabled) {
Agent.Config.AnonymousUsageTracking(
disabled = true,
)
} else {
null
},
)
}
private suspend fun waitForInitialized() {
agent.status.first { it != Agent.Status.NOT_INITIALIZED }
}
suspend fun updateConfig(config: Agent.Config) {
waitForInitialized()
agent.updateConfig(config)
}
suspend fun getCompletion(editor: Editor, offset: Int): Agent.CompletionResponse? {
waitForInitialized()
return ReadAction.compute<PsiFile, Throwable> {
editor.project?.let { project ->
PsiDocumentManager.getInstance(project).getPsiFile(editor.document)
}
}?.let { file ->
agent.getCompletions(
Agent.CompletionRequest(
file.virtualFile.path, file.virtualFile.path,
file.language.id, // FIXME: map language id file.getLanguageId(),
editor.document.text, editor.document.text,
offset offset
)) )
} )
} }
} }
suspend fun postEvent(event: Agent.LogEventRequest) {
waitForInitialized()
agent.postEvent(event)
}
override fun dispose() {
agent.close()
}
companion object {
// Language id: https://code.visualstudio.com/docs/languages/identifiers
private fun PsiFile.getLanguageId(): String {
if (this.language != Language.ANY
&& this.language.id.toLowerCasePreservingASCIIRules() !in arrayOf("txt", "text", "textmate")
) {
if (languageIdMap.containsKey(this.language.id)) {
return languageIdMap[this.language.id]!!
}
return this.language.id.toLowerCasePreservingASCIIRules()
.replace("#", "sharp")
.replace("++", "pp")
.replace(" ", "")
}
return if (filetypeMap.containsKey(this.fileType.defaultExtension)) {
filetypeMap[this.fileType.defaultExtension]!!
} else {
this.fileType.defaultExtension.toLowerCasePreservingASCIIRules()
}
}
private val languageIdMap = mapOf(
"ObjectiveC" to "objective-c",
"ObjectiveC++" to "objective-cpp",
)
private val filetypeMap = mapOf(
"py" to "python",
"js" to "javascript",
"cjs" to "javascript",
"mjs" to "javascript",
"jsx" to "javascriptreact",
"ts" to "typescript",
"tsx" to "typescriptreact",
"kt" to "kotlin",
"md" to "markdown",
"cc" to "cpp",
"cs" to "csharp",
"m" to "objective-c",
"mm" to "objective-cpp",
"sh" to "shellscript",
"zsh" to "shellscript",
"bash" to "shellscript",
"txt" to "plaintext",
)
}
} }

View File

@ -4,59 +4,50 @@ import com.intellij.openapi.components.Service
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.openapi.fileEditor.FileEditorManagerEvent
import com.intellij.openapi.fileEditor.FileEditorManagerListener
import com.intellij.openapi.project.Project
import com.tabbyml.intellijtabby.agent.AgentService import com.tabbyml.intellijtabby.agent.AgentService
import java.util.* import com.tabbyml.intellijtabby.settings.ApplicationSettingsState
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Service @Service
class CompletionScheduler { class CompletionScheduler {
private val logger = Logger.getInstance(CompletionScheduler::class.java) private val logger = Logger.getInstance(CompletionScheduler::class.java)
data class CompletionContext(val editor: Editor, val offset: Int, val timer: Timer) data class CompletionContext(val editor: Editor, val offset: Int, val job: Job)
private var project: Project? = null
var scheduled: CompletionContext? = null var scheduled: CompletionContext? = null
private set private set
fun schedule(editor: Editor, offset: Int, triggerDelay: Long = 150, manually: Boolean = false) {
fun schedule(editor: Editor, offset: Int) {
clear()
val agentService = service<AgentService>() val agentService = service<AgentService>()
val inlineCompletionService = service<InlineCompletionService>() val inlineCompletionService = service<InlineCompletionService>()
inlineCompletionService.dismiss() val settings = service<ApplicationSettingsState>()
val timer = Timer() clear()
timer.schedule(object : TimerTask() { val job = agentService.scope.launch {
override fun run() { if (!manually && !settings.isAutoCompletionEnabled) {
logger.info("Scheduled completion task running") return@launch
agentService.getCompletion(editor, offset)?.thenAccept {
inlineCompletionService.show(editor, offset, it)
}
} }
}, 150) logger.info("Schedule completion at $offset after $triggerDelay ms.")
scheduled = CompletionContext(editor, offset, timer)
if (project != editor.project) { delay(triggerDelay)
project = editor.project if (!manually && !settings.isAutoCompletionEnabled) {
editor.project?.messageBus?.connect()?.subscribe( return@launch
FileEditorManagerListener.FILE_EDITOR_MANAGER, }
object: FileEditorManagerListener { logger.info("Trigger completion at $offset")
override fun selectionChanged(event: FileEditorManagerEvent) { agentService.getCompletion(editor, offset)?.let {
logger.info("FileEditorManagerListener selectionChanged.") inlineCompletionService.show(editor, offset, it)
clear() }
}
}
)
} }
scheduled = CompletionContext(editor, offset, job)
} }
fun clear() { fun clear() {
scheduled?.let {
it.timer.cancel()
scheduled = null
}
val inlineCompletionService = service<InlineCompletionService>() val inlineCompletionService = service<InlineCompletionService>()
inlineCompletionService.dismiss() inlineCompletionService.dismiss()
scheduled?.let {
it.job.cancel()
scheduled = null
}
} }
} }

View File

@ -2,11 +2,16 @@ 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.diagnostic.Logger
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.event.* import com.intellij.openapi.editor.event.*
import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.fileEditor.FileEditorManagerEvent
import com.intellij.openapi.fileEditor.FileEditorManagerListener
import com.intellij.util.messages.MessageBusConnection
class EditorListener : EditorFactoryListener { class EditorListener : EditorFactoryListener {
private val logger = Logger.getInstance(EditorListener::class.java) private val logger = Logger.getInstance(EditorListener::class.java)
private val messagesConnection = mutableMapOf<Editor, MessageBusConnection>()
override fun editorCreated(event: EditorFactoryEvent) { override fun editorCreated(event: EditorFactoryEvent) {
val editor = event.editor val editor = event.editor
@ -33,5 +38,25 @@ class EditorListener : EditorFactoryListener {
} }
} }
}) })
editor.project?.messageBus?.connect()?.let {
it.subscribe(
FileEditorManagerListener.FILE_EDITOR_MANAGER,
object: FileEditorManagerListener {
override fun selectionChanged(event: FileEditorManagerEvent) {
logger.info("FileEditorManagerListener selectionChanged.")
completionScheduler.clear()
}
}
)
messagesConnection[editor] = it
}
}
override fun editorReleased(event: EditorFactoryEvent) {
messagesConnection[event.editor]?.let {
it.disconnect()
it.dispose()
}
} }
} }

View File

@ -3,6 +3,7 @@ package com.tabbyml.intellijtabby.editor
import com.intellij.openapi.application.invokeLater 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.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.openapi.editor.EditorCustomElementRenderer import com.intellij.openapi.editor.EditorCustomElementRenderer
@ -11,6 +12,8 @@ import com.intellij.openapi.editor.markup.TextAttributes
import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.Disposer
import com.intellij.ui.JBColor import com.intellij.ui.JBColor
import com.tabbyml.intellijtabby.agent.Agent import com.tabbyml.intellijtabby.agent.Agent
import com.tabbyml.intellijtabby.agent.AgentService
import kotlinx.coroutines.launch
import java.awt.Graphics import java.awt.Graphics
import java.awt.Rectangle import java.awt.Rectangle
@ -19,7 +22,13 @@ import java.awt.Rectangle
class InlineCompletionService { class InlineCompletionService {
private val logger = Logger.getInstance(InlineCompletionService::class.java) private val logger = Logger.getInstance(InlineCompletionService::class.java)
data class InlineCompletion(val editor: Editor, val text: String, val offset: Int, val inlays: List<Inlay<*>>) data class InlineCompletion(
val editor: Editor,
val offset: Int,
val completion: Agent.CompletionResponse,
val text: String,
val inlays: List<Inlay<*>>,
)
var shownInlineCompletion: InlineCompletion? = null var shownInlineCompletion: InlineCompletion? = null
private set private set
@ -30,13 +39,24 @@ class InlineCompletionService {
return return
} }
invokeLater { invokeLater {
// FIXME: support multiple choices
val text = completion.choices.first().text val text = completion.choices.first().text
logger.info("Showing inline completion at $offset: $text") logger.info("Showing inline completion at $offset: $text")
val lines = text.split("\n") val lines = text.split("\n")
val inlays = lines val inlays = lines
.mapIndexed { index, line -> createInlayLine(editor, offset, line, index) } .mapIndexed { index, line -> createInlayLine(editor, offset, line, index) }
.filterNotNull() .filterNotNull()
shownInlineCompletion = InlineCompletion(editor, text, offset, inlays) shownInlineCompletion = InlineCompletion(editor, offset, completion, text, inlays)
}
val agentService = service<AgentService>()
agentService.scope.launch {
agentService.postEvent(
Agent.LogEventRequest(
type = Agent.LogEventRequest.EventType.VIEW,
completionId = completion.id,
choiceIndex = completion.choices.first().index
)
)
} }
} }
@ -50,6 +70,16 @@ class InlineCompletionService {
invokeLater { invokeLater {
it.inlays.forEach(Disposer::dispose) it.inlays.forEach(Disposer::dispose)
} }
val agentService = service<AgentService>()
agentService.scope.launch {
agentService.postEvent(
Agent.LogEventRequest(
type = Agent.LogEventRequest.EventType.SELECT,
completionId = it.completion.id,
choiceIndex = it.completion.choices.first().index
)
)
}
shownInlineCompletion = null shownInlineCompletion = null
} }
} }

View File

@ -0,0 +1,39 @@
package com.tabbyml.intellijtabby.settings
import com.intellij.openapi.components.service
import com.intellij.openapi.options.Configurable
import javax.swing.JComponent
class ApplicationConfigurable : Configurable {
private lateinit var settingsPanel: ApplicationSettingsPanel
override fun getDisplayName(): String {
return "Tabby"
}
override fun createComponent(): JComponent {
settingsPanel = ApplicationSettingsPanel()
return settingsPanel.mainPanel
}
override fun isModified(): Boolean {
val settings = service<ApplicationSettingsState>()
return settingsPanel.isAutoCompletionEnabled != settings.isAutoCompletionEnabled
|| settingsPanel.serverEndpoint != settings.serverEndpoint
|| settingsPanel.isAnonymousUsageTrackingDisabled != settings.isAnonymousUsageTrackingDisabled
}
override fun apply() {
val settings = service<ApplicationSettingsState>()
settings.isAutoCompletionEnabled = settingsPanel.isAutoCompletionEnabled
settings.serverEndpoint = settingsPanel.serverEndpoint
settings.isAnonymousUsageTrackingDisabled = settingsPanel.isAnonymousUsageTrackingDisabled
}
override fun reset() {
val settings = service<ApplicationSettingsState>()
settingsPanel.isAutoCompletionEnabled = settings.isAutoCompletionEnabled
settingsPanel.serverEndpoint = settings.serverEndpoint
settingsPanel.isAnonymousUsageTrackingDisabled = settings.isAnonymousUsageTrackingDisabled
}
}

View File

@ -0,0 +1,41 @@
package com.tabbyml.intellijtabby.settings
import com.intellij.ui.components.JBCheckBox
import com.intellij.ui.components.JBTextField
import com.intellij.util.ui.FormBuilder
import javax.swing.JPanel
class ApplicationSettingsPanel {
private val isAutoCompletionEnabledCheckBox = JBCheckBox("Enable auto completion")
private val serverEndpointTextField = JBTextField()
private val isAnonymousUsageTrackingDisabledCheckBox = JBCheckBox("Disable anonymous usage tracking")
val mainPanel: JPanel = FormBuilder.createFormBuilder()
.addLabeledComponent("Server endpoint", serverEndpointTextField, 1, false)
.addTooltip("A http or https URL of Tabby server endpoint.")
.addTooltip("If leave empty, server endpoint config in `~/.tabby/agent/config.toml` will be used")
.addTooltip("Default to 'http://localhost:8080'.")
.addSeparator()
.addComponent(isAutoCompletionEnabledCheckBox, 1)
.addComponent(isAnonymousUsageTrackingDisabledCheckBox, 1)
.addComponentFillVertically(JPanel(), 0)
.panel
var isAutoCompletionEnabled: Boolean
get() = isAutoCompletionEnabledCheckBox.isSelected
set(value) {
isAutoCompletionEnabledCheckBox.isSelected = value
}
var serverEndpoint: String
get() = serverEndpointTextField.text
set(value) {
serverEndpointTextField.text = value
}
var isAnonymousUsageTrackingDisabled: Boolean
get() = isAnonymousUsageTrackingDisabledCheckBox.isSelected
set(value) {
isAnonymousUsageTrackingDisabledCheckBox.isSelected = value
}
}

View File

@ -0,0 +1,56 @@
package com.tabbyml.intellijtabby.settings
import com.intellij.openapi.components.PersistentStateComponent
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.State
import com.intellij.openapi.components.Storage
import com.intellij.util.xmlb.XmlSerializerUtil
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@Service
@State(
name = "com.tabbyml.intellijtabby.settings.ApplicationSettingsState",
storages = [Storage("intellij-tabby.xml")]
)
class ApplicationSettingsState : PersistentStateComponent<ApplicationSettingsState> {
var isAutoCompletionEnabled: Boolean = true
set(value) {
field = value
stateFlow.value = this.data
}
var serverEndpoint: String = ""
set(value) {
field = value
stateFlow.value = this.data
}
var isAnonymousUsageTrackingDisabled: Boolean = false
set(value) {
field = value
stateFlow.value = this.data
}
data class State(
val isAutoCompletionEnabled: Boolean,
val serverEndpoint: String,
val isAnonymousUsageTrackingDisabled: Boolean,
)
val data: State
get() = State(
isAutoCompletionEnabled = isAutoCompletionEnabled,
serverEndpoint = serverEndpoint,
isAnonymousUsageTrackingDisabled = isAnonymousUsageTrackingDisabled,
)
private val stateFlow = MutableStateFlow(data)
val state = stateFlow.asStateFlow()
override fun getState(): ApplicationSettingsState {
return this
}
override fun loadState(state: ApplicationSettingsState) {
XmlSerializerUtil.copyBean(state, this)
}
}

View File

@ -0,0 +1,121 @@
package com.tabbyml.intellijtabby.status
import com.intellij.icons.AllIcons
import com.intellij.openapi.actionSystem.*
import com.intellij.openapi.application.invokeLater
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.openapi.ui.popup.ListPopup
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.openapi.wm.StatusBarWidget
import com.intellij.openapi.wm.impl.status.EditorBasedStatusBarPopup
import com.intellij.openapi.wm.impl.status.widget.StatusBarEditorBasedWidgetFactory
import com.tabbyml.intellijtabby.agent.Agent
import com.tabbyml.intellijtabby.agent.AgentService
import com.tabbyml.intellijtabby.settings.ApplicationSettingsState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
class StatusBarWidgetFactory : StatusBarEditorBasedWidgetFactory() {
override fun getId(): String {
return StatusBarWidgetFactory::class.java.name
}
override fun getDisplayName(): String {
return "Tabby"
}
override fun createWidget(project: Project): StatusBarWidget {
return object : EditorBasedStatusBarPopup(project, false) {
val scope: CoroutineScope = CoroutineScope(Dispatchers.Main)
val text = "Tabby"
var icon = AllIcons.Actions.Refresh
var tooltip = "Tabby: Initializing"
init {
val settings = service<ApplicationSettingsState>()
val agentService = service<AgentService>()
scope.launch {
settings.state.combine(agentService.status) { settings, agentStatus ->
Pair(settings, agentStatus)
}.collect {
updateStatus(it.first, it.second)
}
}
}
override fun ID(): String {
return "${StatusBarWidgetFactory::class.java.name}.widget"
}
override fun createInstance(project: Project): StatusBarWidget {
return createWidget(project)
}
override fun getWidgetState(file: VirtualFile?): WidgetState {
return WidgetState(tooltip, text, true).also {
it.icon = icon
}
}
override fun createPopup(context: DataContext?): ListPopup? {
if (context == null) {
return null
}
return JBPopupFactory.getInstance().createActionGroupPopup(
tooltip,
object : ActionGroup() {
override fun getChildren(e: AnActionEvent?): Array<AnAction> {
val actionManager = ActionManager.getInstance()
return arrayOf(
actionManager.getAction("Tabby.ToggleAutoCompletionEnabled"),
actionManager.getAction("Tabby.OpenSettings"),
)
}
},
context,
false,
null,
10,
)
}
private fun updateStatus(settingsState: ApplicationSettingsState.State, agentStatus: Agent.Status) {
if (!settingsState.isAutoCompletionEnabled) {
icon = AllIcons.Windows.CloseSmall
tooltip = "Tabby: Auto completion is disabled"
} else {
when(agentStatus) {
Agent.Status.NOT_INITIALIZED -> {
icon = AllIcons.Actions.Refresh
tooltip = "Tabby: Initializing"
}
Agent.Status.READY -> {
icon = AllIcons.Actions.Checked
tooltip = "Tabby: Ready"
}
Agent.Status.DISCONNECTED -> {
icon = AllIcons.General.Error
tooltip = "Tabby: Cannot connect to Server"
}
Agent.Status.UNAUTHORIZED -> {
icon = AllIcons.General.Error
tooltip = "Tabby: Requires authorization"
}
}
}
invokeLater {
update { myStatusBar?.updateWidget(ID()) }
}
}
}
}
override fun disposeWidget(widget: StatusBarWidget) {
// Nothing to do
}
}

View File

@ -14,8 +14,21 @@
Simple HTML elements (text formatting, paragraphs, and lists) can be added inside of <![CDATA[ ]]> tag. Simple HTML elements (text formatting, paragraphs, and lists) can be added inside of <![CDATA[ ]]> tag.
Guidelines: https://plugins.jetbrains.com/docs/marketplace/plugin-overview-page.html#plugin-description --> Guidelines: https://plugins.jetbrains.com/docs/marketplace/plugin-overview-page.html#plugin-description -->
<description><![CDATA[ <description><![CDATA[
Early version of Tabby for IntelliJ.<br> <h1 id="tabby-plugin-for-intellij-platform">Tabby Plugin for IntelliJ Platform</h1>
Require Node.js 16.0+ installed and added to PATH.<br> <p>Tabby is an AI coding assistant that can suggest multi-line code or full functions in real-time.</p>
<p><strong>Requirements</strong> Tabby plugin requires <a href="https://nodejs.org/">Node.js</a> 16.0+ installed and added into <code>PATH</code> enviroment variable. </p>
<p>For more information, please check out our <a href="https://tabbyml.com/">Website</a> and <a href="https://github.com/TabbyML/tabby">GitHub</a>.
If you encounter any problem or have any suggestion, please <a href="https://github.com/TabbyML/tabby/issues/new">open an issue</a>, or join our <a href="https://join.slack.com/t/tabbycommunity/shared_invite/zt-1xeiddizp-bciR2RtFTaJ37RBxr8VxpA">Slack community</a> for more support!</p>
<h2 id="demo">Demo</h2>
<p>Try our online demo <a href="https://tabbyml.github.io/tabby/playground">here</a>.</p>
<p><img src="https://tabbyml.github.io/tabby/img/demo.gif" alt="Demo"></p>
<h2 id="get-started">Get Started</h2>
<ol>
<li>Please following <a href="https://tabbyml.github.io/tabby/docs/self-hosting/">this guide</a> to setup a self-hosted Tabby server.</li>
<li>Open the settings page <code>Settings &gt; Editor &gt; Tabby</code>, or click the <code>Tabby</code> status bar item and <code>Open Settings...</code>. Fill in the server endpoint URL to connect the plugin to your Tabby server. The status bar item will show a checked icon if the connection is successful.</li>
<li>Once setup is complete, Tabby will provide inline suggestions automatically, and you can accept suggestions by just pressing the <code>Tab</code> key.</li>
<li>You can find more actions and hotkey in the IDE tools menu <code>Code &gt; Tabby</code>.</li>
</ol>
]]></description> ]]></description>
<!-- Product and plugin compatibility requirements. <!-- Product and plugin compatibility requirements.
@ -25,30 +38,46 @@
<!-- 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">
<projectConfigurable
parentId="editor"
instance="com.tabbyml.intellijtabby.settings.ApplicationConfigurable"
id="com.tabbyml.intellijtabby.settings.ApplicationConfigurable"
displayName="Tabby"
nonDefaultProject="true"/>
<editorFactoryListener implementation="com.tabbyml.intellijtabby.editor.EditorListener"/> <editorFactoryListener implementation="com.tabbyml.intellijtabby.editor.EditorListener"/>
<statusBarWidgetFactory implementation="com.tabbyml.intellijtabby.status.StatusBarWidgetFactory"/>
</extensions> </extensions>
<actions> <actions>
<group id="Tabby.ToolsMenu" popup="true" text="Tabby" description="Tabby AI code assistant"> <group id="Tabby.ToolsMenu" popup="true" text="Tabby" description="Tabby AI code assistant">
<add-to-group group-id="ToolsMenu" anchor="last"/> <add-to-group group-id="CodeMenu" anchor="after" relative-to-action="CodeCompletionGroup"/>
<action id="Tabby.TriggerCompletion" <action id="Tabby.TriggerCompletion"
class="com.tabbyml.intellijtabby.actions.TriggerCompletion" class="com.tabbyml.intellijtabby.actions.TriggerCompletion"
text="Trigger Completion" text="Show Inline Completion"
description="Trigger completion at current position."> description="Show inline completion suggestions at the caret's position.">
<keyboard-shortcut first-keystroke="alt BACK_SLASH" keymap="$default"/> <keyboard-shortcut first-keystroke="alt BACK_SLASH" keymap="$default"/>
</action> </action>
<action id="Tabby.AcceptCompletion" <action id="Tabby.AcceptCompletion"
class="com.tabbyml.intellijtabby.actions.AcceptCompletion" class="com.tabbyml.intellijtabby.actions.AcceptCompletion"
text="Accept Completion" text="Accept Completion"
description="Trigger completion at current position."> description="Accept the shown suggestions and insert the text.">
<keyboard-shortcut first-keystroke="TAB" keymap="$default"/> <keyboard-shortcut first-keystroke="TAB" keymap="$default"/>
</action> </action>
<action id="Tabby.DismissCompletion" <action id="Tabby.DismissCompletion"
class="com.tabbyml.intellijtabby.actions.DismissCompletion" class="com.tabbyml.intellijtabby.actions.DismissCompletion"
text="Dismiss Completion" text="Dismiss Completion"
description="Trigger completion at current position."> description="Hide the shown suggestions.">
<keyboard-shortcut first-keystroke="ESCAPE" keymap="$default"/> <keyboard-shortcut first-keystroke="ESCAPE" keymap="$default"/>
</action> </action>
<separator/>
<action id="Tabby.ToggleAutoCompletionEnabled"
class="com.tabbyml.intellijtabby.actions.ToggleAutoCompletionEnabled">
</action>
<action id="Tabby.OpenSettings"
class="com.tabbyml.intellijtabby.actions.OpenSettings"
text="Open Settings..."
description="Show settings for Tabby.">
</action>
</group> </group>
</actions> </actions>
</idea-plugin> </idea-plugin>

View File

@ -1,12 +0,0 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M32.0845 7.94025V4H24.0203V7.9896H16.029V4H7.91553V7.94025H4V36H16.0044V32.0045C16.0058 30.9457 16.4274 29.9308 17.1766 29.1826C17.9258 28.4345 18.9412 28.0143 20 28.0143C21.0588 28.0143 22.0743 28.4345 22.8234 29.1826C23.5726 29.9308 23.9942 30.9457 23.9956 32.0045V36H36V7.94025H32.0845Z"
fill="url(#paint0_linear)"/>
<defs>
<linearGradient id="paint0_linear" x1="2.94192" y1="4.89955" x2="37.7772" y2="39.7345"
gradientUnits="userSpaceOnUse">
<stop offset="0.15937" stop-color="#3BEA62"/>
<stop offset="0.5404" stop-color="#3C99CC"/>
<stop offset="0.93739" stop-color="#6B57FF"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 818 B