Skip to content

Commit 580dfce

Browse files
committed
feat(intellij): add command history management
1 parent caed1f0 commit 580dfce

File tree

7 files changed

+148
-38
lines changed

7 files changed

+148
-38
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.tabbyml.intellijtabby.inlineChat
2+
3+
import com.intellij.openapi.components.*
4+
5+
class CommandHistoryState: BaseState() {
6+
var history by list<String>()
7+
}
8+
9+
@Service
10+
@State(
11+
name = "com.tabbyml.intellijtabby.inlineChat.CommandHistory", storages = [Storage("intellij-tabby-command-history.xml")]
12+
)
13+
class CommandHistory: SimplePersistentStateComponent<CommandHistoryState>(CommandHistoryState()) {
14+
private val maxHistorySize = 30
15+
16+
fun getHistory(): List<String> {
17+
return state.history.toList()
18+
}
19+
20+
fun addCommand(command: String) {
21+
// Check if the command already exists
22+
val existingIndex = state.history.indexOfFirst { it == command }
23+
24+
// If it exists, remove it (we'll add it to the top)
25+
if (existingIndex != -1) {
26+
state.history.removeAt(existingIndex)
27+
}
28+
29+
// Add the new command at the beginning
30+
state.history.add(0, command)
31+
32+
// Trim list if it exceeds the maximum size
33+
if (state.history.size > maxHistorySize) {
34+
state.history = state.history.take(maxHistorySize).toMutableList()
35+
}
36+
}
37+
38+
fun deleteCommand(value: String) {
39+
state.history.removeAll { it == value }
40+
}
41+
42+
fun clearHistory() {
43+
state.history.clear()
44+
}
45+
}

clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/inlineChat/InlineChatIntentionAction.kt

+65-32
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,19 @@ import java.awt.event.*
2121
import javax.swing.*
2222
import com.intellij.ui.components.IconLabelButton
2323
import com.intellij.openapi.ui.popup.JBPopupFactory
24+
import com.intellij.openapi.ui.popup.JBPopupListener
2425
import com.tabbyml.intellijtabby.lsp.ConnectionService
2526
import com.tabbyml.intellijtabby.lsp.protocol.ChatEditParams
2627
import kotlinx.coroutines.CoroutineScope
2728
import kotlinx.coroutines.Dispatchers
2829
import kotlinx.coroutines.launch
2930
import org.eclipse.lsp4j.Location
31+
import org.eclipse.lsp4j.Range
3032

3133
class InlineChatIntentionAction : BaseIntentionAction(), DumbAware {
3234
private var inlay: Inlay<InlineChatInlayRenderer>? = null
3335
private var inlayRender: InlineChatInlayRenderer? = null
3436
private var project: Project? = null
35-
private var location: Location? = null
3637
private var editor: Editor? = null
3738
override fun getFamilyName(): String {
3839
return "Tabby"
@@ -49,8 +50,8 @@ class InlineChatIntentionAction : BaseIntentionAction(), DumbAware {
4950
this.project = project
5051
this.editor = editor
5152
if (editor != null) {
52-
this.location = getCurrentLocation(editor = editor)
53-
addInputToEditor(editor = editor, offset = editor.caretModel.offset);
53+
inlineChatService.location = getCurrentLocation(editor = editor)
54+
addInputToEditor(project, editor, editor.caretModel.offset);
5455
}
5556

5657
project.messageBus.connect().subscribe(LafManagerListener.TOPIC, LafManagerListener {
@@ -67,9 +68,9 @@ class InlineChatIntentionAction : BaseIntentionAction(), DumbAware {
6768
return IntentionPreviewInfo.EMPTY
6869
}
6970

70-
private fun addInputToEditor(editor: Editor, offset: Int) {
71+
private fun addInputToEditor(project: Project, editor: Editor, offset: Int) {
7172
val inlayModel = editor.inlayModel
72-
inlayRender = InlineChatInlayRenderer(editor, this::onClose, this::onInputSubmit)
73+
inlayRender = InlineChatInlayRenderer(project, editor, this::onClose, this::onInputSubmit)
7374
inlay = inlayModel.addBlockElement(offset, true, true, 0, inlayRender!!)
7475
}
7576

@@ -82,18 +83,17 @@ class InlineChatIntentionAction : BaseIntentionAction(), DumbAware {
8283
private fun onInputSubmit(value: String) {
8384
chatEdit(command = value)
8485
editor?.selectionModel?.removeSelection()
86+
project?.serviceOrNull<CommandHistory>()?.addCommand(value)
8587
}
8688

8789
private fun chatEdit(command: String) {
8890
val scope = CoroutineScope(Dispatchers.IO)
91+
val inlineChatService = project?.serviceOrNull<InlineChatService>() ?: return
8992
scope.launch {
9093
val server = project?.serviceOrNull<ConnectionService>()?.getServerAsync() ?: return@launch
91-
if (location == null) {
92-
return@launch
93-
}
94-
println("chat edit $location")
94+
val location = inlineChatService.location ?: return@launch
9595
val param = ChatEditParams(
96-
location = location!!,
96+
location = location,
9797
command = command
9898
)
9999
server.chatFeature.chatEdit(params = param)
@@ -102,12 +102,13 @@ class InlineChatIntentionAction : BaseIntentionAction(), DumbAware {
102102
}
103103

104104
class InlineChatInlayRenderer(
105+
private val project: Project,
105106
private val editor: Editor,
106107
private val onClose: () -> Unit,
107108
private val onSubmit: (value: String) -> Unit
108109
) :
109110
EditorCustomElementRenderer {
110-
private val inlineChatComponent = InlineChatComponent(onClose = this::removeComponent, onSubmit = onSubmit)
111+
private val inlineChatComponent = InlineChatComponent(project, this::removeComponent, onSubmit)
111112
private var targetRegion: Rectangle? = null
112113

113114
init {
@@ -169,9 +170,9 @@ class InlineChatInlayRenderer(
169170
}
170171
}
171172

172-
class InlineChatComponent(private val onClose: () -> Unit, private val onSubmit: (value: String) -> Unit) : JPanel() {
173+
class InlineChatComponent(private val project: Project, private val onClose: () -> Unit, private val onSubmit: (value: String) -> Unit) : JPanel() {
173174
private val closeButton = createCloseButton()
174-
private val inlineInput = InlineInputComponent(onSubmit = this::handleSubmit, onCancel = this::handleClose)
175+
private val inlineInput = InlineInputComponent(project, this::handleSubmit, this::handleClose)
175176

176177
override fun isOpaque(): Boolean {
177178
return false;
@@ -223,7 +224,12 @@ class InlineChatComponent(private val onClose: () -> Unit, private val onSubmit:
223224
}
224225
}
225226

226-
class InlineInputComponent(private var onSubmit: (value: String) -> Unit, private var onCancel: () -> Unit) : JPanel() {
227+
data class PickItem(var label: String, var value: String, var icon: Icon, var description: String?, val canDelete: Boolean)
228+
data class ChatEditFileContext(val referrer: String, val uri: String, val range: Range)
229+
data class InlineEditCommand(val command: String, val context: List<ChatEditFileContext>?)
230+
231+
class InlineInputComponent(private var project: Project, private var onSubmit: (value: String) -> Unit, private var onCancel: () -> Unit) : JPanel() {
232+
private val history: CommandHistory? = project.serviceOrNull<CommandHistory>()
227233
private val textArea: JTextArea = createTextArea()
228234
private val submitButton: JLabel = createSubmitButton()
229235
private val historyButton: JLabel = createHistoryButton()
@@ -320,9 +326,17 @@ class InlineInputComponent(private var onSubmit: (value: String) -> Unit, privat
320326
return submitButton
321327
}
322328

329+
private fun createHistoryButton(): JLabel {
330+
val historyButton = IconLabelButton(AllIcons.Actions.SearchWithHistory) { handleOpenHistory() }
331+
historyButton.toolTipText = "Select suggested / history Command"
332+
historyButton.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
333+
historyButton.border = BorderFactory.createEmptyBorder(4, 8, 4, 8)
334+
return historyButton
335+
}
336+
323337
private fun handleOpenHistory() {
324-
val historyItems = listOf("History command 1", "History command 2", "/doc")
325-
val popup = JBPopupFactory.getInstance().createPopupChooserBuilder(historyItems)
338+
val commandItems = getCommandList()
339+
val popup = JBPopupFactory.getInstance().createPopupChooserBuilder<PickItem>(commandItems)
326340
.setRenderer(object : DefaultListCellRenderer() {
327341
override fun getListCellRendererComponent(
328342
list: JList<*>?,
@@ -331,19 +345,31 @@ class InlineInputComponent(private var onSubmit: (value: String) -> Unit, privat
331345
isSelected: Boolean,
332346
cellHasFocus: Boolean
333347
): Component {
348+
if (value !is PickItem) {
349+
return super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus)
350+
}
334351
val panel = JPanel(BorderLayout())
335352
panel.preferredSize = Dimension(730, 20)
336-
337-
val label = JLabel(value.toString())
353+
val label = JLabel(value.label)
354+
val desc = JLabel(value.description)
338355
label.border = BorderFactory.createEmptyBorder(0, 10, 0, 10)
339-
340-
val deleteButton = IconLabelButton(AllIcons.Actions.Close) {
341-
// Handle delete action here in a real implementation
356+
panel.add(JLabel(value.icon), BorderLayout.WEST)
357+
val contentPanel = JPanel(BorderLayout())
358+
contentPanel.add(label, BorderLayout.WEST)
359+
desc.foreground = UIUtil.getContextHelpForeground()
360+
contentPanel.add(desc, BorderLayout.CENTER)
361+
contentPanel.isOpaque = false
362+
panel.add(contentPanel, BorderLayout.CENTER)
363+
if (value.canDelete) {
364+
val deleteButton = IconLabelButton(AllIcons.Actions.Close) {
365+
history?.deleteCommand(value.value)
366+
handleOpenHistory()
367+
}
368+
deleteButton.toolTipText = "Delete this command from history"
369+
// deleteButton.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
370+
deleteButton.border = BorderFactory.createEmptyBorder(0, 5, 0, 5)
371+
panel.add(deleteButton, BorderLayout.EAST)
342372
}
343-
deleteButton.border = BorderFactory.createEmptyBorder(0, 5, 0, 5)
344-
345-
panel.add(label, BorderLayout.CENTER)
346-
panel.add(deleteButton, BorderLayout.EAST)
347373

348374
if (isSelected) {
349375
panel.background = UIUtil.getListSelectionBackground(true)
@@ -357,18 +383,25 @@ class InlineInputComponent(private var onSubmit: (value: String) -> Unit, privat
357383
}
358384
})
359385
.setItemChosenCallback { selectedValue ->
360-
textArea.text = selectedValue
386+
textArea.text = selectedValue.value
361387
}
362388
.createPopup()
363389

364390
popup.showUnderneathOf(this)
365391
}
366392

367-
private fun createHistoryButton(): JLabel {
368-
val historyButton = IconLabelButton(AllIcons.Actions.SearchWithHistory) { handleOpenHistory() }
369-
historyButton.toolTipText = "Select predefined / history Command"
370-
historyButton.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
371-
historyButton.border = BorderFactory.createEmptyBorder(4, 8, 4, 8)
372-
return historyButton
393+
private fun getHistoryCommand(): List<InlineEditCommand> {
394+
return history?.getHistory()?.map {
395+
InlineEditCommand(it, null)
396+
} ?: emptyList()
397+
}
398+
399+
private fun getCommandList(): List<PickItem> {
400+
val location = project.serviceOrNull<InlineChatService>()?.location ?: return emptyList()
401+
val suggestedItems = getSuggestedCommands(project, location).get()?.map { PickItem(label = it.label, value = it.command, icon = AllIcons.Debugger.ThreadRunning, description = it.command, canDelete = false) } ?: emptyList()
402+
val historyItems = getHistoryCommand().filter {historyCommand -> suggestedItems.find { it.value == historyCommand.command.replace("\n", "") } == null }.map {
403+
PickItem(label = it.command.replace("\n", ""), value = it.command.replace("\n", ""), icon = AllIcons.Vcs.History, description = null, canDelete = true)
404+
}
405+
return suggestedItems + historyItems
373406
}
374407
}

clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/inlineChat/util.kt

+21
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import com.intellij.openapi.editor.Editor
55
import com.intellij.openapi.fileEditor.FileDocumentManager
66
import com.intellij.openapi.project.Project
77
import com.tabbyml.intellijtabby.lsp.ConnectionService
8+
import com.tabbyml.intellijtabby.lsp.protocol.ChatEditCommand
9+
import com.tabbyml.intellijtabby.lsp.protocol.ChatEditCommandParams
810
import kotlinx.coroutines.CoroutineScope
911
import kotlinx.coroutines.Dispatchers
1012
import kotlinx.coroutines.launch
@@ -52,4 +54,23 @@ fun getCodeLenses(project: Project, uri: String): CompletableFuture<List<CodeLen
5254
}
5355
}
5456
}
57+
}
58+
59+
fun getSuggestedCommands(project: Project, location: Location): CompletableFuture<List<ChatEditCommand>?> {
60+
val scope = CoroutineScope(Dispatchers.IO)
61+
val params = ChatEditCommandParams(location)
62+
return CompletableFuture<List<ChatEditCommand>?>().also { future ->
63+
scope.launch {
64+
try {
65+
val server = project.serviceOrNull<ConnectionService>()?.getServerAsync() ?: run {
66+
future.complete(null)
67+
return@launch
68+
}
69+
val result = server.chatFeature.editCommand(params)
70+
future.complete(result.get())
71+
} catch (e: Exception) {
72+
future.completeExceptionally(e)
73+
}
74+
}
75+
}
5576
}

clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/lsp/protocol/ProtocolData.kt

+5-1
Original file line numberDiff line numberDiff line change
@@ -374,4 +374,8 @@ data class ChatEditFileContext(
374374
data class ChatEditResolveParams(
375375
val location: Location,
376376
var action: String,
377-
)
377+
)
378+
379+
data class ChatEditCommandParams(var location: Location)
380+
381+
data class ChatEditCommand(var label: String, var command: String, var source: String = "preset" )

clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/lsp/protocol/server/ChatFeature.kt

+5-4
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
package com.tabbyml.intellijtabby.lsp.protocol.server
22

33
import com.jetbrains.rd.generator.nova.PredefinedType
4-
import com.tabbyml.intellijtabby.lsp.protocol.ChatEditParams
5-
import com.tabbyml.intellijtabby.lsp.protocol.ChatEditResolveParams
6-
import com.tabbyml.intellijtabby.lsp.protocol.GenerateCommitMessageParams
7-
import com.tabbyml.intellijtabby.lsp.protocol.GenerateCommitMessageResult
4+
import com.tabbyml.intellijtabby.lsp.protocol.*
85
import org.eclipse.lsp4j.jsonrpc.services.JsonRequest
96
import org.eclipse.lsp4j.jsonrpc.services.JsonSegment
107
import java.util.concurrent.CompletableFuture
@@ -17,6 +14,10 @@ interface ChatFeature {
1714
@JsonRequest("edit/resolve")
1815
fun resolveEdit(params: ChatEditResolveParams): CompletableFuture<Boolean>
1916

17+
18+
@JsonRequest("edit/command")
19+
fun editCommand(params: ChatEditCommandParams): CompletableFuture<List<ChatEditCommand>?>
20+
2021
@JsonRequest
2122
fun generateCommitMessage(params: GenerateCommitMessageParams): CompletableFuture<GenerateCommitMessageResult>
2223
}

clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/settings/KeymapSettings.kt

+6
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ class KeymapSettings(private val project: Project) {
8282
"Tabby.InlineCompletion.AcceptNextWord" to listOf(KeyboardShortcut.fromString("ctrl RIGHT")),
8383
"Tabby.InlineCompletion.Dismiss" to listOf(KeyboardShortcut.fromString("ESCAPE")),
8484
"Tabby.Chat.ToggleChatToolWindow" to listOf(KeyboardShortcut.fromString("ctrl L")),
85+
"Tabby.InlineChat.Open" to listOf(KeyboardShortcut.fromString("ctrl I")),
86+
"Tabby.InlineChat.Resolve.Accept" to listOf(KeyboardShortcut.fromString("ctrl ENTER")),
87+
"Tabby.InlineChat.Resolve.Discard" to listOf(KeyboardShortcut.fromString("ESCAPE")),
8588
)
8689
private val TABBY_STYLE_KEYMAP_SCHEMA = mapOf(
8790
"Tabby.InlineCompletion.Trigger" to listOf(
@@ -92,6 +95,9 @@ class KeymapSettings(private val project: Project) {
9295
"Tabby.InlineCompletion.AcceptNextWord" to listOf(KeyboardShortcut.fromString("ctrl RIGHT")),
9396
"Tabby.InlineCompletion.Dismiss" to listOf(KeyboardShortcut.fromString("ESCAPE")),
9497
"Tabby.Chat.ToggleChatToolWindow" to listOf(KeyboardShortcut.fromString("ctrl L")),
98+
"Tabby.InlineChat.Open" to listOf(KeyboardShortcut.fromString("ctrl I")),
99+
"Tabby.InlineChat.Resolve.Accept" to listOf(KeyboardShortcut.fromString("ctrl ENTER")),
100+
"Tabby.InlineChat.Resolve.Discard" to listOf(KeyboardShortcut.fromString("ESCAPE")),
95101
)
96102
}
97103
}

clients/intellij/src/main/resources/META-INF/plugin.xml

+1-1
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@
172172

173173
<group id="Tabby.EditorContext" popup="true" text="Tabby">
174174
<add-to-group group-id="EditorPopupMenu" anchor="last"/>
175-
<action id="Tabby.InlineChat" class="com.tabbyml.intellijtabby.inlineChat.InlineChatAction"
175+
<action id="Tabby.InlineChat.Open" class="com.tabbyml.intellijtabby.inlineChat.InlineChatAction"
176176
text="Open Tabby Inline Chat"
177177
description="Open a inline chat widget to chat with Tabby to modify your code.">
178178
<keyboard-shortcut first-keystroke="ctrl I" keymap="$default"/>

0 commit comments

Comments
 (0)