Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(intellij): support inline chat #4067

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions clients/intellij/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ build/
out/
!**/src/main/**/out/
!**/src/test/**/out/
*.log

### Eclipse ###
.apt_generated
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package com.tabbyml.intellijtabby.inlineChat

import com.google.gson.JsonObject
import com.intellij.codeInsight.codeVision.*
import com.intellij.codeInsight.codeVision.CodeVisionState.Companion.READY_EMPTY
import com.intellij.codeInsight.codeVision.ui.model.TextCodeVisionEntry
import com.intellij.icons.AllIcons
import com.intellij.ide.DataManager
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.ex.ActionUtil
import com.intellij.openapi.components.serviceOrNull
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.fileEditor.FileDocumentManager
import com.intellij.openapi.keymap.KeymapUtil
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.util.TextRange
import org.eclipse.lsp4j.Location
import org.eclipse.lsp4j.Position
import org.eclipse.lsp4j.Range
import javax.swing.Icon

abstract class InlineChatCodeVisionProvider : CodeVisionProvider<Any>, DumbAware {
override val defaultAnchor: CodeVisionAnchorKind = CodeVisionAnchorKind.Top
// provider id
abstract override val id: String
// action name
abstract val action: String
// execute action id
abstract val actionId: String
abstract val icon: Icon
override val name: String = "Inline Chat Code Vision Provider"

override fun precomputeOnUiThread(editor: Editor): Any {
return Any()
}

override fun computeCodeVision(editor: Editor, uiData: Any): CodeVisionState {
val project = editor.project ?: return READY_EMPTY
val inlineChatService = project.serviceOrNull<InlineChatService>() ?: return READY_EMPTY
val document = editor.document
val virtualFile = FileDocumentManager.getInstance()
.getFile(editor.document)
val uri = virtualFile?.url ?: return READY_EMPTY
val codeLenses = getCodeLenses(project, uri).get() ?: return READY_EMPTY
val codelens = codeLenses.firstOrNull() { it.command != null && (it.command?.arguments?.firstOrNull() as JsonObject?)?.get("action")?.asString == action } ?: return READY_EMPTY
inlineChatService.inlineChatEditing = true
inlineChatService.location = Location(uri, Range(Position(codelens.range.start.line, codelens.range.start.character), Position(codelens.range.end.line, codelens.range.end.character)))
val prefixRegex = Regex("""^\$\(.*?\)""")
val title = codelens.command.title.replace(prefixRegex, "") + " (${KeymapUtil.getFirstKeyboardShortcutText(getAction())})"
val startOffset = document.getLineStartOffset(codelens.range.start.line) + codelens.range.start.character
val endOffset = document.getLineStartOffset(codelens.range.end.line) + codelens.range.end.character
val entry =
TextCodeVisionEntry(title, id, icon)
val textRange = TextRange(startOffset, endOffset)
textRange to entry
// CodeVisionProvider can only display one entry for each line
return CodeVisionState.Ready(listOf(textRange to entry))
}

override fun handleClick(editor: Editor, textRange: TextRange, entry: CodeVisionEntry) {
val editorDataContext = DataManager.getInstance().getDataContext(editor.component)
ActionUtil.invokeAction(getAction(), editorDataContext, "", null, null)
}

private fun getAction() = ActionManager.getInstance().getAction(actionId)
}

class InlineChatAcceptCodeVisionProvider : InlineChatCodeVisionProvider() {
override val id: String = "Tabby.InlineChat.Accept"
override val action: String = "accept"
override val actionId: String = "Tabby.InlineChat.Resolve.Accept"
override val icon: Icon = AllIcons.Actions.Checked
override val relativeOrderings: List<CodeVisionRelativeOrdering> =
listOf(CodeVisionRelativeOrdering.CodeVisionRelativeOrderingBefore("Tabby.InlineChat.Discard"))
}

class InlineChatDiscardCodeVisionProvider : InlineChatCodeVisionProvider() {
override val id: String = "Tabby.InlineChat.Discard"
override val action: String = "discard"
override val actionId: String = "Tabby.InlineChat.Resolve.Discard"
override val icon: Icon = AllIcons.Actions.Close
override val relativeOrderings: List<CodeVisionRelativeOrdering> =
emptyList()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.tabbyml.intellijtabby.inlineChat

import com.intellij.openapi.components.*

class CommandHistoryState: BaseState() {
var history by list<String>()
}

@Service
@State(
name = "com.tabbyml.intellijtabby.inlineChat.CommandHistory", storages = [Storage("intellij-tabby-command-history.xml")]
)
class CommandHistory: SimplePersistentStateComponent<CommandHistoryState>(CommandHistoryState()) {
private val maxHistorySize = 30

fun getHistory(): List<String> {
return state.history.toList()
}

fun addCommand(command: String) {
val existingIndex = state.history.indexOfFirst { it == command }

if (existingIndex != -1) {
state.history.removeAt(existingIndex)
}

state.history.add(0, command)

if (state.history.size > maxHistorySize) {
state.history = state.history.take(maxHistorySize).toMutableList()
}
}

fun deleteCommand(value: String) {
state.history.removeAll { it == value }
}

fun clearHistory() {
state.history.clear()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
package com.tabbyml.intellijtabby.inlineChat

import com.intellij.icons.AllIcons
import com.intellij.openapi.application.ApplicationManager
import com.intellij.ui.CollectionListModel
import com.intellij.ui.components.IconLabelButton
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBList
import com.intellij.ui.components.JBScrollPane
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import java.awt.BorderLayout
import java.awt.Component
import java.awt.Cursor
import java.awt.Dimension
import java.awt.Rectangle
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.util.function.Consumer
import javax.swing.*


data class CommandListItem(
var label: String,
var value: String,
var icon: Icon,
var description: String?,
val canDelete: Boolean
)

class CommandListComponent(
private val title: String = "Commands",
initialData: List<CommandListItem>?,
private val onItemSelected: Consumer<CommandListItem>?,
private val onItemDeleted: Consumer<CommandListItem>?,
private val onClearAll: () -> Unit,
) {
val list: JBList<CommandListItem>
private val model: CollectionListModel<CommandListItem> = CollectionListModel(initialData ?: emptyList())
private val scrollPane: JBScrollPane
private val mainPanel: JPanel = JPanel(BorderLayout())

private var hoveredIndex: Int = -1

init {
list = JBList(model)
list.cellRenderer = CustomListItemRenderer { hoveredIndex }
list.selectionMode = ListSelectionModel.SINGLE_SELECTION
list.setEmptyText("No items")

list.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
handleMouseAction(e)
}

override fun mouseExited(e: MouseEvent) {
if (hoveredIndex != -1) {
hoveredIndex = -1
list.repaint()
}
}
})

list.addMouseMotionListener(object : MouseAdapter() {
override fun mouseMoved(e: MouseEvent) {
val index = list.locationToIndex(e.point)
if (index != hoveredIndex) {
hoveredIndex = index
list.repaint()
}
}
})
val toolbar = createToolbar()
scrollPane = JBScrollPane(list)
mainPanel.add(toolbar, BorderLayout.NORTH)
mainPanel.add(scrollPane, BorderLayout.CENTER)
}

private fun createToolbar(): JPanel {
val toolbar = JPanel(BorderLayout()).apply {
preferredSize = Dimension(730, 20)
}
toolbar.border = JBUI.Borders.empty(3, 5)
val titleLabel = JBLabel(title)
titleLabel.font = JBUI.Fonts.label().deriveFont(JBUI.Fonts.label().size + 1.0f)
val clearAllButton = IconLabelButton(AllIcons.Actions.GC) {
onClearAll()
}
clearAllButton.toolTipText = "Clear all commands"
clearAllButton.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
clearAllButton.border = BorderFactory.createEmptyBorder(4, 8, 4, 8)

toolbar.add(titleLabel, BorderLayout.WEST)
toolbar.add(clearAllButton, BorderLayout.EAST)

return toolbar
}

private fun handleMouseAction(e: MouseEvent) {
val index = list.locationToIndex(e.point)
if (index != -1) {
val item = model.getElementAt(index)
val deleteBounds = CustomListItemRenderer.getDeleteButtonBounds(list, index, item)

if (deleteBounds != null && deleteBounds.contains(e.point)) {
e.consume() // Prevent list selection change on delete click
onItemDeleted?.accept(item)
} else {
val selectedValue = list.selectedValue
if (selectedValue != null) {
onItemSelected?.accept(selectedValue)
}
}
}
}

fun setData(newData: List<CommandListItem>) {
ApplicationManager.getApplication().invokeLater {
val selectedValue = list.selectedValue
model.replaceAll(newData)
if (selectedValue != null) {
val newIndex = model.getElementIndex(selectedValue)
if (newIndex != -1) {
list.setSelectedIndex(newIndex)
} else {
list.clearSelection()
}
} else {
list.clearSelection()
}
}
}

val component: JComponent
get() = mainPanel
}


class CustomListItemRenderer(private val getHoveredIndex: () -> Int) : JPanel(), ListCellRenderer<CommandListItem> {
private val iconLabel: JBLabel
private val contentPanel: JPanel
private val label: JLabel
private val desc: JLabel
private val deleteButton: JLabel

init {
layout = BorderLayout(JBUI.scale(5), 0)
border = JBUI.Borders.empty(2, 5)

iconLabel = JBLabel()
label = JLabel()
desc = JLabel()
contentPanel = JPanel(BorderLayout())
contentPanel.add(desc, BorderLayout.CENTER)
contentPanel.add(label, BorderLayout.WEST)
deleteButton = JLabel(DELETE_ICON).apply {
isOpaque = false
toolTipText = "delete command"
preferredSize =
Dimension(DELETE_ICON.iconWidth, DELETE_ICON.iconHeight)
}

add(deleteButton, BorderLayout.EAST)
add(iconLabel, BorderLayout.WEST)
add(contentPanel, BorderLayout.CENTER)
}

override fun getListCellRendererComponent(
list: JList<out CommandListItem>,
value: CommandListItem,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean
): Component {
iconLabel.icon = value.icon
label.text = value.label
desc.text = value.description
label.border = BorderFactory.createEmptyBorder(0, 10, 0, 10)
desc.foreground = UIUtil.getContextHelpForeground()
contentPanel.isOpaque = false

val isHovered = index == getHoveredIndex()

if (isSelected) {
background = UIUtil.getListSelectionBackground(true) // Use focus-aware color
iconLabel.foreground = UIUtil.getListSelectionForeground(true)
} else if (isHovered) {
background = UIUtil.getListSelectionBackground(false)
iconLabel.foreground = UIUtil.getListForeground()
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
} else {
background = UIUtil.getListBackground()
iconLabel.foreground = UIUtil.getListForeground()
cursor = Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR)
}

isOpaque = true

if (value.canDelete) {
deleteButton.isVisible = true
if (isHovered) {
deleteButton.setIcon(DELETE_ICON_HOVERED);
} else {
deleteButton.setIcon(DELETE_ICON);
}
} else {
deleteButton.isVisible = false
}

return this
}

companion object {
val DELETE_ICON: Icon = AllIcons.Actions.Close
val DELETE_ICON_HOVERED: Icon = AllIcons.Actions.CloseHovered

fun getDeleteButtonBounds(list: JList<out CommandListItem>, index: Int, item: CommandListItem): Rectangle? {
if (index < 0 || index >= list.model.size) {
return null
}
val cellBounds = list.getCellBounds(index, index) ?: return null
val renderer = CustomListItemRenderer {index}
renderer.getListCellRendererComponent(list, item, index, true, false)
val prefSize = renderer.preferredSize
renderer.setBounds(0, 0, cellBounds.width, prefSize.height)
renderer.doLayout()
val deleteBoundsRelativeToPanel = renderer.deleteButton.bounds
return Rectangle(
cellBounds.x + deleteBoundsRelativeToPanel.x,
cellBounds.y + deleteBoundsRelativeToPanel.y,
deleteBoundsRelativeToPanel.width,
deleteBoundsRelativeToPanel.height
)
}
}
}
Loading