feat: improve chat toolwindow UI and streaming/diff handling

This commit is contained in:
Carl-Robert Linnupuu 2026-01-15 11:42:21 +00:00
parent b00593b9bd
commit 65b1ca496e
5 changed files with 131 additions and 73 deletions

View file

@ -10,7 +10,9 @@ import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.editor.SelectionModel;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.ui.AnimatedIcon;
import com.intellij.ui.JBColor;
import com.intellij.ui.components.JBLabel;
import com.intellij.util.concurrency.AppExecutorUtil;
import com.intellij.util.ui.JBUI;
import ee.carlrobert.codegpt.CodeGPTKeys;
@ -39,7 +41,6 @@ import ee.carlrobert.codegpt.toolwindow.chat.editor.actions.CopyAction;
import ee.carlrobert.codegpt.toolwindow.chat.structure.data.PsiStructureRepository;
import ee.carlrobert.codegpt.toolwindow.chat.structure.data.PsiStructureState;
import ee.carlrobert.codegpt.toolwindow.chat.ui.ChatMessageResponseBody;
import ee.carlrobert.codegpt.toolwindow.chat.editor.header.LoadingPanel;
import ee.carlrobert.codegpt.toolwindow.chat.ui.ChatToolWindowScrollablePanel;
import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.TotalTokensDetails;
import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.TotalTokensPanel;
@ -62,6 +63,8 @@ import ee.carlrobert.codegpt.util.coroutines.CoroutineDispatchers;
import ee.carlrobert.llm.client.openai.completion.ErrorDetails;
import git4idea.GitCommit;
import java.awt.BorderLayout;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
@ -95,7 +98,7 @@ public class ChatToolWindowTabPanel implements Disposable {
private final TagManager tagManager;
private final JPanel mcpApprovalContainer;
private @Nullable ToolwindowChatCompletionRequestHandler requestHandler;
private LoadingPanel inputLoadingPanel;
private JBLabel loadingLabel;
private final JPanel queuedMessageContainer;
public ChatToolWindowTabPanel(@NotNull Project project, @NotNull Conversation conversation) {
@ -139,6 +142,9 @@ public class ChatToolWindowTabPanel implements Disposable {
queuedMessageContainer.setBorder(JBUI.Borders.empty());
queuedMessageContainer.setOpaque(false);
loadingLabel = new JBLabel("", new AnimatedIcon.Default(), JBLabel.LEFT);
loadingLabel.setVisible(false);
rootPanel = createRootPanel();
if (conversation.getMessages().isEmpty()) {
@ -224,8 +230,9 @@ public class ChatToolWindowTabPanel implements Disposable {
private void updateUserPromptPanel() {
var userPromptPanel = createUserPromptPanel();
rootPanel.remove(rootPanel.getComponent(rootPanel.getComponentCount() - 1));
rootPanel.add(userPromptPanel, BorderLayout.SOUTH);
rootPanel.add(createSouthPanel(userPromptPanel), BorderLayout.SOUTH);
rootPanel.revalidate();
rootPanel.repaint();
}
@ -603,11 +610,6 @@ public class ChatToolWindowTabPanel implements Disposable {
topContainer.setLayout(new BoxLayout(topContainer, BoxLayout.Y_AXIS));
topContainer.setOpaque(false);
inputLoadingPanel = new LoadingPanel(CodeGPTBundle.get("toolwindow.chat.loading"), null, null);
inputLoadingPanel.setAlignmentX(JComponent.LEFT_ALIGNMENT);
inputLoadingPanel.setVisible(false);
topContainer.add(inputLoadingPanel);
if (queuedMessageContainer.getComponentCount() > 0) {
queuedMessageContainer.setAlignmentX(JComponent.LEFT_ALIGNMENT);
topContainer.add(queuedMessageContainer);
@ -618,30 +620,59 @@ public class ChatToolWindowTabPanel implements Disposable {
topContainer.add(mcpApprovalContainer);
}
var tokenPanelWrapper = JBUI.Panels.simplePanel(totalTokensPanel)
.withBorder(JBUI.Borders.empty(4, 0, 4, 0));
tokenPanelWrapper.setAlignmentX(JComponent.LEFT_ALIGNMENT);
topContainer.add(tokenPanelWrapper);
panel.add(topContainer, BorderLayout.NORTH);
panel.add(userInputPanel, BorderLayout.CENTER);
return panel;
}
private JComponent createStatusPanel() {
var statusPanel = new JPanel(new GridBagLayout());
statusPanel.setBorder(JBUI.Borders.empty(8));
statusPanel.setOpaque(false);
var gbc = new GridBagConstraints();
gbc.gridx = 0;
gbc.gridy = 0;
gbc.anchor = GridBagConstraints.WEST;
gbc.weightx = 0;
gbc.fill = GridBagConstraints.NONE;
statusPanel.add(loadingLabel, gbc);
gbc.gridx = 1;
gbc.weightx = 1;
gbc.fill = GridBagConstraints.HORIZONTAL;
statusPanel.add(Box.createHorizontalGlue(), gbc);
gbc.gridx = 2;
gbc.weightx = 0;
gbc.anchor = GridBagConstraints.EAST;
gbc.fill = GridBagConstraints.NONE;
statusPanel.add(totalTokensPanel, gbc);
return statusPanel;
}
private JComponent createSouthPanel(JComponent userPromptPanel) {
var southPanel = new JPanel(new BorderLayout());
southPanel.add(createStatusPanel(), BorderLayout.NORTH);
southPanel.add(userPromptPanel, BorderLayout.CENTER);
return southPanel;
}
private void showInputLoading(String text) {
if (inputLoadingPanel != null) {
inputLoadingPanel.setText(text);
inputLoadingPanel.setVisible(true);
inputLoadingPanel.revalidate();
inputLoadingPanel.repaint();
if (loadingLabel != null) {
loadingLabel.setText(text);
loadingLabel.setVisible(true);
rootPanel.revalidate();
rootPanel.repaint();
}
}
private void hideInputLoading() {
if (inputLoadingPanel != null) {
inputLoadingPanel.setVisible(false);
inputLoadingPanel.revalidate();
inputLoadingPanel.repaint();
if (loadingLabel != null) {
loadingLabel.setVisible(false);
rootPanel.revalidate();
rootPanel.repaint();
}
}
@ -703,7 +734,7 @@ public class ChatToolWindowTabPanel implements Disposable {
var rootPanel = new JPanel(new BorderLayout());
rootPanel.add(createScrollPaneWithSmartScroller(toolWindowScrollablePanel),
BorderLayout.CENTER);
rootPanel.add(createUserPromptPanel(), BorderLayout.SOUTH);
rootPanel.add(createSouthPanel(createUserPromptPanel()), BorderLayout.SOUTH);
return rootPanel;
}
}

View file

@ -47,7 +47,7 @@ public class ProxyAIChoice(
public class ProxyAIStreamChoice(
public val finishReason: String? = null,
public val nativeFinishReason: String? = null,
public val delta: ProxyAIStreamDelta,
public val delta: ProxyAIStreamDelta?,
public val error: ProxyAIErrorResponse? = null
)

View file

@ -130,8 +130,8 @@ public class ProxyAILLMClient(
override suspend fun StreamFrameFlowBuilder.processStreamingChunk(chunk: ProxyAIChatCompletionStreamResponse) {
chunk.choices.firstOrNull()?.let { choice ->
choice.delta.content?.let { emitAppend(it) }
choice.delta.toolCalls?.forEachIndexed { index, toolCall ->
choice.delta?.content?.let { emitAppend(it) }
choice.delta?.toolCalls?.forEachIndexed { index, toolCall ->
val id = toolCall.id
val name = toolCall.function.name
val arguments = toolCall.function.arguments

View file

@ -1,9 +1,12 @@
package ee.carlrobert.codegpt.toolwindow.chat.editor.diff
import com.intellij.diff.util.Side
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.application.ReadAction
import com.intellij.openapi.application.runInEdt
import com.intellij.openapi.application.runReadAction
import com.intellij.openapi.application.runUndoTransparentWriteAction
import com.intellij.openapi.editor.Document
import com.intellij.openapi.editor.event.DocumentEvent
import com.intellij.openapi.editor.event.DocumentListener
import com.intellij.openapi.editor.ex.EditorEx
@ -11,6 +14,7 @@ import com.intellij.openapi.fileEditor.FileDocumentManager
import com.intellij.openapi.util.text.StringUtil
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.util.application
import com.intellij.util.concurrency.AppExecutorUtil
import ee.carlrobert.codegpt.toolwindow.chat.editor.ResponseEditorPanel.Companion.RESPONSE_EDITOR_DIFF_VIEWER_KEY
import ee.carlrobert.codegpt.toolwindow.chat.editor.ResponseEditorPanel.Companion.RESPONSE_EDITOR_DIFF_VIEWER_VALUE_PAIR_KEY
import java.util.concurrent.ConcurrentHashMap
@ -25,48 +29,41 @@ object DiffSyncManager {
(set ?: mutableSetOf()).apply { add(editor) }
}
if (!fileToListener.containsKey(filePath)) {
val virtualFile = LocalFileSystem.getInstance().findFileByPath(filePath) ?: return
val document =
runReadAction { FileDocumentManager.getInstance().getDocument(virtualFile) }
?: return
val listener = object : DocumentListener {
override fun documentChanged(event: DocumentEvent) {
application.executeOnPooledThread {
val affectedEditors = fileToEditors[filePath] ?: emptyList()
for (editor in affectedEditors) {
val diffViewer = RESPONSE_EDITOR_DIFF_VIEWER_KEY.get(editor)
if (diffViewer != null) {
val leftSideDoc =
runReadAction { diffViewer.getDocument(Side.LEFT) }
val rightSideDoc =
runReadAction { diffViewer.getDocument(Side.RIGHT) }
val listener = object : DocumentListener {
override fun documentChanged(event: DocumentEvent) {
application.executeOnPooledThread {
val affectedEditors = fileToEditors[filePath] ?: emptyList()
for (editor in affectedEditors) {
val diffViewer = RESPONSE_EDITOR_DIFF_VIEWER_KEY.get(editor)
if (diffViewer != null) {
val leftSideDoc =
runReadAction { diffViewer.getDocument(Side.LEFT) }
val rightSideDoc =
runReadAction { diffViewer.getDocument(Side.RIGHT) }
if (leftSideDoc.text == rightSideDoc.text) {
continue
}
if (leftSideDoc.text == rightSideDoc.text) {
continue
}
val entry = RESPONSE_EDITOR_DIFF_VIEWER_VALUE_PAIR_KEY.get(editor)
if (entry != null) {
val (search, replace) = entry
val newText = event.document.text
if (!newText.contains(replace.trim())) {
val replacedText =
newText.replace(search.trim(), replace.trim())
runInEdt {
if (replacedText.length != newText.length) {
runUndoTransparentWriteAction {
rightSideDoc.setText(
StringUtil.convertLineSeparators(
replacedText
)
val entry = RESPONSE_EDITOR_DIFF_VIEWER_VALUE_PAIR_KEY.get(editor)
if (entry != null) {
val (search, replace) = entry
val newText = event.document.text
if (!newText.contains(replace.trim())) {
val replacedText =
newText.replace(search.trim(), replace.trim())
runInEdt {
if (replacedText.length != newText.length) {
runUndoTransparentWriteAction {
rightSideDoc.setText(
StringUtil.convertLineSeparators(
replacedText
)
diffViewer.scheduleRediff()
}
)
diffViewer.scheduleRediff()
}
diffViewer.rediff(true)
}
diffViewer.rediff(true)
}
}
}
@ -74,10 +71,32 @@ object DiffSyncManager {
}
}
}
}
val existing = fileToListener.putIfAbsent(filePath, listener)
if (existing != null) {
return
}
ReadAction.nonBlocking<Document?> {
val virtualFile = LocalFileSystem.getInstance().findFileByPath(filePath)
if (virtualFile == null) {
null
} else {
FileDocumentManager.getInstance().getDocument(virtualFile)
}
}.finishOnUiThread(ModalityState.any()) { document ->
if (document == null || fileToEditors[filePath].isNullOrEmpty()) {
fileToListener.remove(filePath, listener)
return@finishOnUiThread
}
if (fileToListener[filePath] != listener) {
return@finishOnUiThread
}
document.addDocumentListener(listener)
fileToListener[filePath] = listener
}
}.submit(AppExecutorUtil.getAppExecutorService())
}
fun unregisterEditor(filePath: String, editor: EditorEx) {
@ -85,15 +104,23 @@ object DiffSyncManager {
set.remove(editor)
if (set.isEmpty()) {
fileToEditors.remove(filePath)
val virtualFile = LocalFileSystem.getInstance().findFileByPath(filePath)
val document =
virtualFile?.let { FileDocumentManager.getInstance().getDocument(it) }
val listener = fileToListener.remove(filePath)
if (document != null && listener != null) {
document.removeDocumentListener(listener)
if (listener != null) {
ReadAction.nonBlocking<Document?> {
val virtualFile = LocalFileSystem.getInstance().findFileByPath(filePath)
if (virtualFile == null) {
null
} else {
FileDocumentManager.getInstance().getDocument(virtualFile)
}
}.finishOnUiThread(ModalityState.any()) { document ->
if (document != null) {
document.removeDocumentListener(listener)
}
}.submit(AppExecutorUtil.getAppExecutorService())
}
}
}
}
}
}

View file

@ -64,7 +64,7 @@ class TotalTokensPanel(
}
}
border = JBUI.Borders.empty(4)
border = JBUI.Borders.empty()
isOpaque = false
add(JBLabel(AllIcons.General.ContextHelp).apply {
addMouseListener(object : MouseAdapter() {