From 20e9788f29649a4dfcfa21971d954c08f05a3b7f Mon Sep 17 00:00:00 2001 From: Zhiming Ma Date: Fri, 4 Aug 2023 12:02:32 +0800 Subject: [PATCH] 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. --- clients/intellij/build.gradle.kts | 5 +- .../intellijtabby/actions/OpenSettings.kt | 12 ++ .../actions/ToggleAutoCompletionEnabled.kt | 29 ++++ .../actions/TriggerCompletion.kt | 12 +- .../com/tabbyml/intellijtabby/agent/Agent.kt | 155 +++++++++++++++--- .../intellijtabby/agent/AgentService.kt | 143 +++++++++++++--- .../editor/CompletionScheduler.kt | 59 +++---- .../intellijtabby/editor/EditorListener.kt | 25 +++ .../editor/InlineCompletionService.kt | 34 +++- .../settings/ApplicationConfigurable.kt | 39 +++++ .../settings/ApplicationSettingsPanel.kt | 41 +++++ .../settings/ApplicationSettingsState.kt | 56 +++++++ .../status/StatusBarWidgetFactory.kt | 121 ++++++++++++++ .../src/main/resources/META-INF/plugin.xml | 43 ++++- .../main/resources/META-INF/pluginIcon.svg | 12 -- 15 files changed, 672 insertions(+), 114 deletions(-) create mode 100644 clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/actions/OpenSettings.kt create mode 100644 clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/actions/ToggleAutoCompletionEnabled.kt create mode 100644 clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/settings/ApplicationConfigurable.kt create mode 100644 clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/settings/ApplicationSettingsPanel.kt create mode 100644 clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/settings/ApplicationSettingsState.kt create mode 100644 clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/status/StatusBarWidgetFactory.kt delete mode 100644 clients/intellij/src/main/resources/META-INF/pluginIcon.svg diff --git a/clients/intellij/build.gradle.kts b/clients/intellij/build.gradle.kts index 537ef97..c1e02a9 100644 --- a/clients/intellij/build.gradle.kts +++ b/clients/intellij/build.gradle.kts @@ -5,7 +5,7 @@ plugins { } group = "com.tabbyml" -version = "0.0.1-SNAPSHOT" +version = "0.0.1" repositories { mavenCentral() @@ -41,7 +41,7 @@ tasks { into("build/idea-sandbox/plugins/intellij-tabby/node_scripts") } - buildPlugin { + buildSearchableOptions { dependsOn(copyNodeScripts) } @@ -57,5 +57,6 @@ tasks { publishPlugin { token.set(System.getenv("PUBLISH_TOKEN")) + channels.set(listOf("alpha")) } } diff --git a/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/actions/OpenSettings.kt b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/actions/OpenSettings.kt new file mode 100644 index 0000000..98e01fa --- /dev/null +++ b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/actions/OpenSettings.kt @@ -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) + } +} \ No newline at end of file diff --git a/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/actions/ToggleAutoCompletionEnabled.kt b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/actions/ToggleAutoCompletionEnabled.kt new file mode 100644 index 0000000..d2a96c4 --- /dev/null +++ b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/actions/ToggleAutoCompletionEnabled.kt @@ -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() + settings.isAutoCompletionEnabled = !settings.isAutoCompletionEnabled + } + + override fun update(e: AnActionEvent) { + val settings = service() + 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 + } +} \ No newline at end of file diff --git a/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/actions/TriggerCompletion.kt b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/actions/TriggerCompletion.kt index 9b192f3..66417af 100644 --- a/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/actions/TriggerCompletion.kt +++ b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/actions/TriggerCompletion.kt @@ -5,21 +5,15 @@ import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.CommonDataKeys import com.intellij.openapi.components.service -import com.tabbyml.intellijtabby.agent.AgentService -import com.tabbyml.intellijtabby.editor.InlineCompletionService +import com.tabbyml.intellijtabby.editor.CompletionScheduler class TriggerCompletion : AnAction() { override fun actionPerformed(e: AnActionEvent) { - val agentService = service() - val inlineCompletionService = service() + val completionScheduler = service() val editor = e.getRequiredData(CommonDataKeys.EDITOR) val offset = editor.caretModel.primaryCaret.offset - - inlineCompletionService.dismiss() - agentService.getCompletion(editor, offset)?.thenAccept { - inlineCompletionService.show(editor, offset, it) - } + completionScheduler.schedule(editor, offset, triggerDelay = 0, manually = true) } override fun update(e: AnActionEvent) { diff --git a/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/agent/Agent.kt b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/agent/Agent.kt index 07c88e2..bf23379 100644 --- a/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/agent/Agent.kt +++ b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/agent/Agent.kt @@ -1,6 +1,7 @@ package com.tabbyml.intellijtabby.agent import com.google.gson.Gson +import com.google.gson.annotations.SerializedName import com.google.gson.reflect.TypeToken import com.intellij.execution.configurations.GeneralCommandLine 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.ProcessOutputTypes import com.intellij.ide.plugins.PluginManagerCore +import com.intellij.openapi.application.ApplicationInfo import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.extensions.PluginId import com.intellij.openapi.util.Key import com.intellij.util.EnvironmentUtil 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.util.concurrent.CompletableFuture class Agent : ProcessAdapter() { private val logger = Logger.getInstance(Agent::class.java) @@ -23,11 +27,17 @@ class Agent : ProcessAdapter() { private val process: KillableProcessHandler private val streamWriter: OutputStreamWriter - var status = "notInitialized" - private set + enum class Status { + NOT_INITIALIZED, + READY, + DISCONNECTED, + UNAUTHORIZED, + } + + private val statusFlow = MutableStateFlow(Status.NOT_INITIALIZED) + val status = statusFlow.asStateFlow() init { - logger.info("Agent init.") logger.info("Environment variables: PATH: ${EnvironmentUtil.getValue("PATH")}") val node = PathEnvironmentVariableUtil.findExecutableInPathOnAnyOS("node") @@ -49,7 +59,7 @@ class Agent : ProcessAdapter() { } val cmd = GeneralCommandLine(node.absolutePath, script.absolutePath) - process = object: KillableProcessHandler(cmd) { + process = object : KillableProcessHandler(cmd) { override fun readerOptions(): BaseOutputReader.Options { return BaseOutputReader.Options.forMostlySilentProcess() } @@ -59,12 +69,46 @@ class Agent : ProcessAdapter() { streamWriter = process.processInput.writer() } - fun initialize(): CompletableFuture { - return request("initialize", listOf(mapOf("client" to "intellij-tabby"))) + data class Config( + 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 { - return request("updateConfig", listOf(emptyMap())) + suspend fun initialize(config: Config): Boolean { + 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( @@ -84,28 +128,60 @@ class Agent : ProcessAdapter() { ) } - fun getCompletions(request: CompletionRequest): CompletableFuture { + suspend fun getCompletions(request: CompletionRequest): CompletionResponse? { 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 ongoingRequest = mutableMapOf Unit>() - private inline fun request(func: String, args: List = emptyList()): CompletableFuture { - val id = requestId++ - val data = listOf(id, mapOf("func" to func, "args" to args)) - val json = gson.toJson(data) - streamWriter.write(json + "\n") - streamWriter.flush() - logger.info("Agent request: $json") - val future = CompletableFuture() - ongoingRequest[id] = { response -> - logger.info("Agent response: $response") - val result = gson.fromJson(response, object : TypeToken() {}.type) - future.complete(result) + private suspend inline fun request(func: String, args: List = emptyList()): T = + suspendCancellableCoroutine { continuation -> + val id = requestId++ + ongoingRequest[id] = { response -> + logger.info("Agent response: $response") + val result = gson.fromJson(response, object : TypeToken() {}.type) + continuation.resumeWith(Result.success(result)) + } + val data = listOf(id, mapOf("func" to func, "args" to args)) + val json = gson.toJson(data) + logger.info("Agent request: $json") + 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 = "" @@ -131,7 +207,9 @@ class Agent : ProcessAdapter() { logger.info("Parsed agent output: $data") val id = (data[0] as Number).toInt() if (id == 0) { - handleNotification(gson.toJson(data[1])) + if (data[1] is Map<*, *>) { + handleNotification(data[1] as Map<*, *>) + } } else { ongoingRequest[id]?.let { callback -> callback(gson.toJson(data[1])) @@ -140,7 +218,30 @@ class Agent : ProcessAdapter() { } } - private fun handleNotification(event: String) { - logger.info("Agent notification: $event") + private fun handleNotification(event: Map<*, *>) { + 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"]}") + } + } } } \ No newline at end of file diff --git a/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/agent/AgentService.kt b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/agent/AgentService.kt index 16eea84..18eae99 100644 --- a/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/agent/AgentService.kt +++ b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/agent/AgentService.kt @@ -1,44 +1,145 @@ 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.components.Service +import com.intellij.openapi.components.service import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.editor.Editor import com.intellij.psi.PsiDocumentManager 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 -class AgentService { +class AgentService : Disposable { private val logger = Logger.getInstance(AgentService::class.java) - private val agent: CompletableFuture = CompletableFuture() + private var agent: Agent = Agent() + val scope: CoroutineScope = CoroutineScope(Dispatchers.IO) + val status get() = agent.status init { - try { - val instance = Agent() - instance.initialize().thenApply { - logger.info("Agent init done: $it") - agent.complete(instance) + val settings = service() + scope.launch { + try { + agent.initialize(createAgentConfig(settings.data)) + 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? { - return agent.thenCompose {agent -> - ReadAction.compute { - editor.project?.let { project -> - PsiDocumentManager.getInstance(project).getPsiFile(editor.document) - } - }?.let { file -> - agent?.getCompletions(Agent.CompletionRequest( + private fun createAgentConfig(state: ApplicationSettingsState.State): Agent.Config { + return Agent.Config( + server = if (state.serverEndpoint.isNotBlank()) { + Agent.Config.Server( + endpoint = state.serverEndpoint, + ) + } else { + 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 { + editor.project?.let { project -> + PsiDocumentManager.getInstance(project).getPsiFile(editor.document) + } + }?.let { file -> + agent.getCompletions( + Agent.CompletionRequest( file.virtualFile.path, - file.language.id, // FIXME: map language id + file.getLanguageId(), editor.document.text, 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", + ) + } } \ No newline at end of file diff --git a/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/editor/CompletionScheduler.kt b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/editor/CompletionScheduler.kt index ff29a26..f792e16 100644 --- a/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/editor/CompletionScheduler.kt +++ b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/editor/CompletionScheduler.kt @@ -4,59 +4,50 @@ 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.* +import com.tabbyml.intellijtabby.settings.ApplicationSettingsState +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch @Service class CompletionScheduler { 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 private set - - fun schedule(editor: Editor, offset: Int) { - clear() + fun schedule(editor: Editor, offset: Int, triggerDelay: Long = 150, manually: Boolean = false) { val agentService = service() val inlineCompletionService = service() - 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) - } + val settings = service() + clear() + val job = agentService.scope.launch { + if (!manually && !settings.isAutoCompletionEnabled) { + return@launch } - }, 150) - scheduled = CompletionContext(editor, offset, timer) + logger.info("Schedule completion at $offset after $triggerDelay ms.") - 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() - } - } - ) + delay(triggerDelay) + if (!manually && !settings.isAutoCompletionEnabled) { + return@launch + } + logger.info("Trigger completion at $offset") + agentService.getCompletion(editor, offset)?.let { + inlineCompletionService.show(editor, offset, it) + } } + scheduled = CompletionContext(editor, offset, job) } fun clear() { - scheduled?.let { - it.timer.cancel() - scheduled = null - } val inlineCompletionService = service() inlineCompletionService.dismiss() + scheduled?.let { + it.job.cancel() + scheduled = null + } } } \ No newline at end of file diff --git a/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/editor/EditorListener.kt b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/editor/EditorListener.kt index c3e3605..c6e4df5 100644 --- a/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/editor/EditorListener.kt +++ b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/editor/EditorListener.kt @@ -2,11 +2,16 @@ package com.tabbyml.intellijtabby.editor import com.intellij.openapi.components.service import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.event.* 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 { private val logger = Logger.getInstance(EditorListener::class.java) + private val messagesConnection = mutableMapOf() override fun editorCreated(event: EditorFactoryEvent) { 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() + } } } \ No newline at end of file diff --git a/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/editor/InlineCompletionService.kt b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/editor/InlineCompletionService.kt index fd06faf..8fa4cf6 100644 --- a/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/editor/InlineCompletionService.kt +++ b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/editor/InlineCompletionService.kt @@ -3,6 +3,7 @@ package com.tabbyml.intellijtabby.editor import com.intellij.openapi.application.invokeLater 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.editor.Editor 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.ui.JBColor import com.tabbyml.intellijtabby.agent.Agent +import com.tabbyml.intellijtabby.agent.AgentService +import kotlinx.coroutines.launch import java.awt.Graphics import java.awt.Rectangle @@ -19,7 +22,13 @@ import java.awt.Rectangle class InlineCompletionService { private val logger = Logger.getInstance(InlineCompletionService::class.java) - data class InlineCompletion(val editor: Editor, val text: String, val offset: Int, val inlays: List>) + data class InlineCompletion( + val editor: Editor, + val offset: Int, + val completion: Agent.CompletionResponse, + val text: String, + val inlays: List>, + ) var shownInlineCompletion: InlineCompletion? = null private set @@ -30,13 +39,24 @@ class InlineCompletionService { return } invokeLater { + // FIXME: support multiple choices val text = completion.choices.first().text logger.info("Showing inline completion at $offset: $text") val lines = text.split("\n") val inlays = lines .mapIndexed { index, line -> createInlayLine(editor, offset, line, index) } .filterNotNull() - shownInlineCompletion = InlineCompletion(editor, text, offset, inlays) + shownInlineCompletion = InlineCompletion(editor, offset, completion, text, inlays) + } + val agentService = service() + 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 { it.inlays.forEach(Disposer::dispose) } + val agentService = service() + agentService.scope.launch { + agentService.postEvent( + Agent.LogEventRequest( + type = Agent.LogEventRequest.EventType.SELECT, + completionId = it.completion.id, + choiceIndex = it.completion.choices.first().index + ) + ) + } shownInlineCompletion = null } } diff --git a/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/settings/ApplicationConfigurable.kt b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/settings/ApplicationConfigurable.kt new file mode 100644 index 0000000..d97ac50 --- /dev/null +++ b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/settings/ApplicationConfigurable.kt @@ -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() + return settingsPanel.isAutoCompletionEnabled != settings.isAutoCompletionEnabled + || settingsPanel.serverEndpoint != settings.serverEndpoint + || settingsPanel.isAnonymousUsageTrackingDisabled != settings.isAnonymousUsageTrackingDisabled + } + + override fun apply() { + val settings = service() + settings.isAutoCompletionEnabled = settingsPanel.isAutoCompletionEnabled + settings.serverEndpoint = settingsPanel.serverEndpoint + settings.isAnonymousUsageTrackingDisabled = settingsPanel.isAnonymousUsageTrackingDisabled + } + + override fun reset() { + val settings = service() + settingsPanel.isAutoCompletionEnabled = settings.isAutoCompletionEnabled + settingsPanel.serverEndpoint = settings.serverEndpoint + settingsPanel.isAnonymousUsageTrackingDisabled = settings.isAnonymousUsageTrackingDisabled + } +} \ No newline at end of file diff --git a/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/settings/ApplicationSettingsPanel.kt b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/settings/ApplicationSettingsPanel.kt new file mode 100644 index 0000000..83f8766 --- /dev/null +++ b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/settings/ApplicationSettingsPanel.kt @@ -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 + } +} \ No newline at end of file diff --git a/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/settings/ApplicationSettingsState.kt b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/settings/ApplicationSettingsState.kt new file mode 100644 index 0000000..f444cc6 --- /dev/null +++ b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/settings/ApplicationSettingsState.kt @@ -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 { + 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) + } +} \ No newline at end of file diff --git a/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/status/StatusBarWidgetFactory.kt b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/status/StatusBarWidgetFactory.kt new file mode 100644 index 0000000..34be7db --- /dev/null +++ b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/status/StatusBarWidgetFactory.kt @@ -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() + val agentService = service() + 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 { + 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 + } +} diff --git a/clients/intellij/src/main/resources/META-INF/plugin.xml b/clients/intellij/src/main/resources/META-INF/plugin.xml index 6951d0f..68faedd 100644 --- a/clients/intellij/src/main/resources/META-INF/plugin.xml +++ b/clients/intellij/src/main/resources/META-INF/plugin.xml @@ -14,8 +14,21 @@ Simple HTML elements (text formatting, paragraphs, and lists) can be added inside of tag. Guidelines: https://plugins.jetbrains.com/docs/marketplace/plugin-overview-page.html#plugin-description --> - Require Node.js 16.0+ installed and added to PATH.
+

Tabby Plugin for IntelliJ Platform

+

Tabby is an AI coding assistant that can suggest multi-line code or full functions in real-time.

+

Requirements Tabby plugin requires Node.js 16.0+ installed and added into PATH enviroment variable.

+

For more information, please check out our Website and GitHub. + If you encounter any problem or have any suggestion, please open an issue, or join our Slack community for more support!

+

Demo

+

Try our online demo here.

+

Demo

+

Get Started

+
    +
  1. Please following this guide to setup a self-hosted Tabby server.
  2. +
  3. Open the settings page Settings > Editor > Tabby, or click the Tabby status bar item and Open Settings.... 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.
  4. +
  5. Once setup is complete, Tabby will provide inline suggestions automatically, and you can accept suggestions by just pressing the Tab key.
  6. +
  7. You can find more actions and hotkey in the IDE tools menu Code > Tabby.
  8. +
]]>
+ + - + + text="Show Inline Completion" + description="Show inline completion suggestions at the caret's position."> + description="Accept the shown suggestions and insert the text."> + description="Hide the shown suggestions."> + + + + + \ No newline at end of file diff --git a/clients/intellij/src/main/resources/META-INF/pluginIcon.svg b/clients/intellij/src/main/resources/META-INF/pluginIcon.svg deleted file mode 100644 index dcf6b99..0000000 --- a/clients/intellij/src/main/resources/META-INF/pluginIcon.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - \ No newline at end of file