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
parent
4eaae27ed3
commit
20e9788f29
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AgentService>()
|
||||
val inlineCompletionService = service<InlineCompletionService>()
|
||||
val completionScheduler = service<CompletionScheduler>()
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -59,12 +69,46 @@ class Agent : ProcessAdapter() {
|
|||
streamWriter = process.processInput.writer()
|
||||
}
|
||||
|
||||
fun initialize(): CompletableFuture<Boolean> {
|
||||
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<Boolean> {
|
||||
return request("updateConfig", listOf(emptyMap<Any, Any>()))
|
||||
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,27 +128,59 @@ class Agent : ProcessAdapter() {
|
|||
)
|
||||
}
|
||||
|
||||
fun getCompletions(request: CompletionRequest): CompletableFuture<CompletionResponse?> {
|
||||
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<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 =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
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<T>()
|
||||
ongoingRequest[id] = { response ->
|
||||
logger.info("Agent response: $response")
|
||||
val result = gson.fromJson<T>(response, object : TypeToken<T>() {}.type)
|
||||
future.complete(result)
|
||||
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"]}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Agent?> = CompletableFuture<Agent?>()
|
||||
private var agent: Agent = Agent()
|
||||
val scope: CoroutineScope = CoroutineScope(Dispatchers.IO)
|
||||
val status get() = agent.status
|
||||
|
||||
init {
|
||||
val settings = service<ApplicationSettingsState>()
|
||||
scope.launch {
|
||||
try {
|
||||
val instance = Agent()
|
||||
instance.initialize().thenApply {
|
||||
logger.info("Agent init done: $it")
|
||||
agent.complete(instance)
|
||||
}
|
||||
} catch (_: Error) {
|
||||
agent.complete(null)
|
||||
agent.initialize(createAgentConfig(settings.data))
|
||||
logger.info("Agent init done.")
|
||||
} catch (e: Error) {
|
||||
logger.error("Agent init failed: $e")
|
||||
}
|
||||
}
|
||||
|
||||
fun getCompletion(editor: Editor, offset: Int): CompletableFuture<Agent.CompletionResponse>? {
|
||||
return agent.thenCompose {agent ->
|
||||
ReadAction.compute<PsiFile, Throwable> {
|
||||
scope.launch {
|
||||
settings.state.collect {
|
||||
updateConfig(createAgentConfig(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<PsiFile, Throwable> {
|
||||
editor.project?.let { project ->
|
||||
PsiDocumentManager.getInstance(project).getPsiFile(editor.document)
|
||||
}
|
||||
}?.let { file ->
|
||||
agent?.getCompletions(Agent.CompletionRequest(
|
||||
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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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 {
|
||||
val settings = service<ApplicationSettingsState>()
|
||||
clear()
|
||||
val job = agentService.scope.launch {
|
||||
if (!manually && !settings.isAutoCompletionEnabled) {
|
||||
return@launch
|
||||
}
|
||||
logger.info("Schedule completion at $offset after $triggerDelay ms.")
|
||||
|
||||
delay(triggerDelay)
|
||||
if (!manually && !settings.isAutoCompletionEnabled) {
|
||||
return@launch
|
||||
}
|
||||
logger.info("Trigger completion at $offset")
|
||||
agentService.getCompletion(editor, offset)?.let {
|
||||
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()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
scheduled = CompletionContext(editor, offset, job)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
scheduled?.let {
|
||||
it.timer.cancel()
|
||||
scheduled = null
|
||||
}
|
||||
val inlineCompletionService = service<InlineCompletionService>()
|
||||
inlineCompletionService.dismiss()
|
||||
scheduled?.let {
|
||||
it.job.cancel()
|
||||
scheduled = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Editor, MessageBusConnection>()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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
|
||||
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>()
|
||||
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>()
|
||||
agentService.scope.launch {
|
||||
agentService.postEvent(
|
||||
Agent.LogEventRequest(
|
||||
type = Agent.LogEventRequest.EventType.SELECT,
|
||||
completionId = it.completion.id,
|
||||
choiceIndex = it.completion.choices.first().index
|
||||
)
|
||||
)
|
||||
}
|
||||
shownInlineCompletion = null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -14,8 +14,21 @@
|
|||
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 -->
|
||||
<description><![CDATA[
|
||||
Early version of Tabby for IntelliJ.<br>
|
||||
Require Node.js 16.0+ installed and added to PATH.<br>
|
||||
<h1 id="tabby-plugin-for-intellij-platform">Tabby Plugin for IntelliJ Platform</h1>
|
||||
<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 > Editor > 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 > Tabby</code>.</li>
|
||||
</ol>
|
||||
]]></description>
|
||||
|
||||
<!-- Product and plugin compatibility requirements.
|
||||
|
|
@ -25,30 +38,46 @@
|
|||
<!-- Extension points defined by the plugin.
|
||||
Read more: https://plugins.jetbrains.com/docs/intellij/plugin-extension-points.html -->
|
||||
<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"/>
|
||||
<statusBarWidgetFactory implementation="com.tabbyml.intellijtabby.status.StatusBarWidgetFactory"/>
|
||||
</extensions>
|
||||
|
||||
<actions>
|
||||
<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"
|
||||
class="com.tabbyml.intellijtabby.actions.TriggerCompletion"
|
||||
text="Trigger Completion"
|
||||
description="Trigger completion at current position.">
|
||||
text="Show Inline Completion"
|
||||
description="Show inline completion suggestions at the caret's position.">
|
||||
<keyboard-shortcut first-keystroke="alt BACK_SLASH" keymap="$default"/>
|
||||
</action>
|
||||
<action id="Tabby.AcceptCompletion"
|
||||
class="com.tabbyml.intellijtabby.actions.AcceptCompletion"
|
||||
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"/>
|
||||
</action>
|
||||
<action id="Tabby.DismissCompletion"
|
||||
class="com.tabbyml.intellijtabby.actions.DismissCompletion"
|
||||
text="Dismiss Completion"
|
||||
description="Trigger completion at current position.">
|
||||
description="Hide the shown suggestions.">
|
||||
<keyboard-shortcut first-keystroke="ESCAPE" keymap="$default"/>
|
||||
</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>
|
||||
</actions>
|
||||
</idea-plugin>
|
||||
|
|
@ -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 |
Loading…
Reference in New Issue