Compare commits

...

9 Commits

14 changed files with 378 additions and 31 deletions

View File

@ -13,7 +13,7 @@
"Linux"
],
"license": "GPLv3",
"version": "0.5.8",
"version": "0.5.11",
"status": "draft",
"is_pack": false,
"released_at": null,
@ -55,8 +55,23 @@
},
{
"version": "0.5.8",
"is_latest": true,
"is_latest": false,
"released_at": "2025-04-17T10:00:00Z"
},
{
"version": "0.5.9",
"is_latest": false,
"released_at": "2025-04-21T10:00:00Z"
},
{
"version": "0.5.10",
"is_latest": false,
"released_at": "2025-04-24T10:00:00Z"
},
{
"version": "0.5.11",
"is_latest": true,
"released_at": "2025-04-24T21:00:00Z"
}
],
"icon": "https://github.com/user-attachments/assets/dc336712-83cb-440d-8761-8d0a31de898d",

View File

@ -17,6 +17,7 @@ qt_add_qml_module(QodeAssistChatView
qml/parts/TopBar.qml
qml/parts/BottomBar.qml
qml/parts/AttachedFilesPlace.qml
qml/parts/ChatPreviewBar.qml
RESOURCES
icons/attach-file-light.svg
icons/attach-file-dark.svg

View File

@ -124,6 +124,7 @@ QList<MessagePart> ChatModel::processMessageContent(const QString &content) cons
QRegularExpression codeBlockRegex("```(\\w*)\\n?([\\s\\S]*?)```");
int lastIndex = 0;
auto blockMatches = codeBlockRegex.globalMatch(content);
bool foundCodeBlock = blockMatches.hasNext();
while (blockMatches.hasNext()) {
auto match = blockMatches.next();
@ -140,7 +141,19 @@ QList<MessagePart> ChatModel::processMessageContent(const QString &content) cons
if (lastIndex < content.length()) {
QString remainingText = content.mid(lastIndex).trimmed();
if (!remainingText.isEmpty()) {
QRegularExpression unclosedBlockRegex("```(\\w*)\\n?([\\s\\S]*)$");
auto unclosedMatch = unclosedBlockRegex.match(remainingText);
if (unclosedMatch.hasMatch()) {
QString beforeCodeBlock = remainingText.left(unclosedMatch.capturedStart()).trimmed();
if (!beforeCodeBlock.isEmpty()) {
parts.append({MessagePart::Text, beforeCodeBlock, ""});
}
parts.append(
{MessagePart::Code, unclosedMatch.captured(2).trimmed(), unclosedMatch.captured(1)});
} else if (!remainingText.isEmpty()) {
parts.append({MessagePart::Text, remainingText, ""});
}
}
@ -202,7 +215,7 @@ void ChatModel::resetModelTo(int index)
if (index < 0 || index >= m_messages.size())
return;
if (index < m_messages.size() - 1) {
if (index < m_messages.size()) {
beginRemoveRows(QModelIndex(), index, m_messages.size() - 1);
m_messages.remove(index, m_messages.size() - index);
endRemoveRows();

View File

@ -140,8 +140,7 @@ Rectangle {
anchors {
right: parent.right
bottom: parent.bottom
bottomMargin: 2
top: parent.top
}
text: qsTr("ResetTo")

View File

@ -76,6 +76,10 @@ ChatRootView {
text: qsTr("Latest chat file name: %1").arg(root.chatFileName.length > 0 ? root.chatFileName : "Unsaved")
}
openChatHistory.onClicked: root.openChatHistoryFolder()
expandScrollbar {
text: scroll.isPreviewMode ? "»" : "«"
onClicked: scroll.isPreviewMode = !scroll.isPreviewMode
}
}
ListView {
@ -114,6 +118,50 @@ ChatRootView {
ScrollBar.vertical: QQC.ScrollBar {
id: scroll
property bool isPreviewMode: false
readonly property int previewWidth: 30
implicitWidth: isPreviewMode ? scroll.previewWidth : 16
contentItem: Rectangle {
implicitWidth: scroll.isPreviewMode ? scroll.previewWidth : 6
implicitHeight: 100
radius: 3
color: scroll.pressed ? palette.dark :
scroll.hovered ? palette.mid :
palette.button
Behavior on implicitWidth {
NumberAnimation { duration: 150 }
}
}
background: Rectangle {
color: scroll.isPreviewMode ? "transparent" :
palette.window.hslLightness > 0.5 ?
Qt.darker(palette.window, 1.1) :
Qt.lighter(palette.window, 1.1)
radius: 3
}
ChatPreviewBar {
anchors.fill: parent
targetView: chatListView
visible: parent.isPreviewMode
opacity: parent.isPreviewMode ? 1 : 0
Behavior on opacity {
NumberAnimation { duration: 150 }
}
}
Behavior on implicitWidth {
NumberAnimation {
duration: 150
easing.type: Easing.InOutQuad
}
}
}
onCountChanged: {

View File

@ -25,7 +25,7 @@ TextEdit {
readOnly: true
selectByMouse: true
wrapMode: Text.WordWrap
textFormat: Text.StyledText
textFormat: Text.MarkdownText
selectionColor: palette.highlight
color: palette.text
}

View File

@ -0,0 +1,135 @@
/*
* Copyright (C) 2025 Petr Mironychev
*
* This file is part of QodeAssist.
*
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*/
import QtQuick
import QtQuick.Controls
Rectangle {
id: root
property ListView targetView: null
property int previewWidth: 50
property color userMessageColor: "#92BD6C"
property color assistantMessageColor: palette.button
width: previewWidth
color: palette.window.hslLightness > 0.5 ?
Qt.darker(palette.window, 1.1) :
Qt.lighter(palette.window, 1.1)
Behavior on opacity {
NumberAnimation {
duration: 150
easing.type: Easing.InOutQuad
}
}
Column {
id: previewContainer
anchors.fill: parent
anchors.margins: 2
spacing: 2
Repeater {
model: targetView ? targetView.model : null
Rectangle {
required property int index
required property var model
width: parent.width
height: {
if (!targetView || !targetView.count) return 0
const availableHeight = root.height - ((targetView.count - 1) * previewContainer.spacing)
return availableHeight / targetView.count
}
radius: 4
color: model.roleType === ChatModel.User ?
userMessageColor :
assistantMessageColor
opacity: root.opacity
transform: Translate {
x: root.opacity * 50 - 50
}
Behavior on transform {
NumberAnimation {
duration: 150
easing.type: Easing.InOutQuad
}
}
MouseArea {
anchors.fill: parent
onClicked: {
if (targetView) {
targetView.positionViewAtIndex(index, ListView.Center)
}
}
HoverHandler {
id: hover
}
}
Rectangle {
anchors.fill: parent
color: palette.highlight
opacity: hover.hovered ? 0.2 : 0
radius: parent.radius
Behavior on opacity {
NumberAnimation { duration: 150 }
}
}
Rectangle {
anchors.fill: parent
color: palette.highlight
opacity: {
if (!targetView) return 0
const viewY = targetView.contentY
const viewHeight = targetView.height
const totalHeight = targetView.contentHeight
const itemPosition = index / targetView.count * totalHeight
const itemHeight = totalHeight / targetView.count
return (itemPosition + itemHeight > viewY &&
itemPosition < viewY + viewHeight) ? 0.2 : 0
}
radius: parent.radius
Behavior on opacity {
NumberAnimation { duration: 150 }
}
}
ToolTip.visible: hover.hovered
ToolTip.text: {
const maxPreviewLength = 100
return model.content.length > maxPreviewLength ?
model.content.substring(0, maxPreviewLength) + "..." :
model.content
}
}
}
}
}

View File

@ -30,6 +30,7 @@ Rectangle {
property alias tokensBadge: tokensBadgeId
property alias recentPath: recentPathId
property alias openChatHistory: openChatHistoryId
property alias expandScrollbar: expandScrollbarId
color: palette.window.hslLightness > 0.5 ?
Qt.darker(palette.window, 1.1) :
@ -84,5 +85,12 @@ Rectangle {
Badge {
id: tokensBadgeId
}
QoAButton {
id: expandScrollbarId
width: 16
height: 16
}
}
}

View File

@ -327,7 +327,9 @@ void LLMClientInterface::sendCompletionToClient(
completionItem[LanguageServerProtocol::textKey] = processedCompletion;
QJsonObject range;
range["start"] = position;
range["end"] = position;
QJsonObject end = position;
end["character"] = position["character"].toInt() + processedCompletion.length();
range["end"] = end;
completionItem[LanguageServerProtocol::rangeKey] = range;
completionItem[LanguageServerProtocol::positionKey] = position;
completions.append(completionItem);

View File

@ -29,6 +29,36 @@
namespace QodeAssist {
QString mergeWithRightText(const QString &suggestion, const QString &rightText)
{
if (suggestion.isEmpty() || rightText.isEmpty()) {
return suggestion;
}
int j = 0;
QString processed = rightText;
QSet<int> matchedPositions;
for (int i = 0; i < suggestion.length() && j < processed.length(); ++i) {
if (suggestion[i] == processed[j]) {
matchedPositions.insert(j);
++j;
}
}
if (matchedPositions.isEmpty()) {
return suggestion + rightText;
}
QList<int> positions = matchedPositions.values();
std::sort(positions.begin(), positions.end(), std::greater<int>());
for (int pos : positions) {
processed.remove(pos, 1);
}
return suggestion;
}
LLMSuggestion::LLMSuggestion(
const QList<Data> &suggestions, QTextDocument *sourceDocument, int currentCompletion)
: TextEditor::CyclicSuggestion(suggestions, sourceDocument, currentCompletion)
@ -43,16 +73,23 @@ LLMSuggestion::LLMSuggestion(
QTextCursor cursor(sourceDocument);
cursor.setPosition(startPos);
cursor.setPosition(endPos, QTextCursor::KeepAnchor);
QTextBlock block = cursor.block();
QString blockText = block.text();
int startPosInBlock = startPos - block.position();
int endPosInBlock = endPos - block.position();
int cursorPositionInBlock = cursor.positionInBlock();
blockText.replace(startPosInBlock, endPosInBlock - startPosInBlock, data.text);
replacementDocument()->setPlainText(blockText);
QString rightText = blockText.mid(cursorPositionInBlock);
if (!data.text.contains('\n')) {
QString processedRightText = mergeWithRightText(data.text, rightText);
processedRightText = processedRightText.mid(data.text.length());
QString displayText = blockText.left(cursorPositionInBlock) + data.text
+ processedRightText;
replacementDocument()->setPlainText(displayText);
} else {
QString displayText = blockText.left(cursorPositionInBlock) + data.text;
replacementDocument()->setPlainText(displayText);
}
}
bool LLMSuggestion::applyWord(TextEditor::TextEditorWidget *widget)
@ -77,31 +114,82 @@ bool LLMSuggestion::applyPart(Part part, TextEditor::TextEditorWidget *widget)
int next = part == Word ? Utils::endOfNextWord(text, startPos) : text.indexOf('\n', startPos);
if (next == -1)
return apply();
if (next == -1) {
if (part == Line) {
next = text.length();
} else {
return apply();
}
}
if (part == Line)
++next;
QString subText = text.mid(startPos, next - startPos);
if (subText.isEmpty())
if (subText.isEmpty()) {
return false;
}
currentCursor.insertText(subText);
QTextBlock currentBlock = currentCursor.block();
QString textAfterCursor = currentBlock.text().mid(currentCursor.positionInBlock());
if (const int seperatorPos = subText.lastIndexOf('\n'); seperatorPos >= 0) {
const QString newCompletionText = text.mid(startPos + seperatorPos + 1);
if (!newCompletionText.isEmpty()) {
const Utils::Text::Position newStart{int(range.begin.line + subText.count('\n')), 0};
const Utils::Text::Position
newEnd{newStart.line, int(subText.length() - seperatorPos - 1)};
const Utils::Text::Range newRange{newStart, newEnd};
const QList<Data> newSuggestion{{newRange, newEnd, newCompletionText}};
widget->insertSuggestion(
std::make_unique<LLMSuggestion>(newSuggestion, widget->document(), 0));
if (!subText.contains('\n')) {
QTextCursor deleteCursor = currentCursor;
deleteCursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
deleteCursor.removeSelectedText();
QString mergedText = mergeWithRightText(subText, textAfterCursor);
currentCursor.insertText(mergedText);
} else {
currentCursor.insertText(subText);
if (const int seperatorPos = subText.lastIndexOf('\n'); seperatorPos >= 0) {
const QString newCompletionText = text.mid(startPos + seperatorPos + 1);
if (!newCompletionText.isEmpty()) {
const Utils::Text::Position newStart{int(range.begin.line + subText.count('\n')), 0};
const Utils::Text::Position newEnd{newStart.line, int(newCompletionText.length())};
const Utils::Text::Range newRange{newStart, newEnd};
const QList<Data> newSuggestion{{newRange, newEnd, newCompletionText}};
widget->insertSuggestion(
std::make_unique<LLMSuggestion>(newSuggestion, widget->document(), 0));
}
}
}
return false;
}
bool LLMSuggestion::apply()
{
const Utils::Text::Range range = suggestions()[currentSuggestion()].range;
const QTextCursor cursor = range.begin.toTextCursor(sourceDocument());
const QString text = suggestions()[currentSuggestion()].text;
QTextBlock currentBlock = cursor.block();
QString textAfterCursor = currentBlock.text().mid(cursor.positionInBlock());
QTextCursor editCursor = cursor;
int firstLineEnd = text.indexOf('\n');
if (firstLineEnd != -1) {
QString firstLine = text.left(firstLineEnd);
QString restOfText = text.mid(firstLineEnd);
editCursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
editCursor.removeSelectedText();
QString mergedFirstLine = mergeWithRightText(firstLine, textAfterCursor);
editCursor.insertText(mergedFirstLine + restOfText);
} else {
editCursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
editCursor.removeSelectedText();
QString mergedText = mergeWithRightText(text, textAfterCursor);
editCursor.insertText(mergedText);
}
return true;
}
} // namespace QodeAssist

View File

@ -40,5 +40,6 @@ public:
bool applyWord(TextEditor::TextEditorWidget *widget) override;
bool applyLine(TextEditor::TextEditorWidget *widget) override;
bool applyPart(Part part, TextEditor::TextEditorWidget *widget);
bool apply() override;
};
} // namespace QodeAssist

View File

@ -1,7 +1,7 @@
{
"Id" : "qodeassist",
"Name" : "QodeAssist",
"Version" : "0.5.9",
"Version" : "0.5.11",
"Vendor" : "Petr Mironychev",
"VendorId" : "petrmironychev",
"Copyright" : "(C) ${IDE_COPYRIGHT_YEAR} Petr Mironychev, (C) ${IDE_COPYRIGHT_YEAR} The Qt Company Ltd",

View File

@ -27,6 +27,7 @@
11. [QtCreator Version Compatibility](#qtcreator-version-compatibility)
12. [Development Progress](#development-progress)
13. [Hotkeys](#hotkeys)
14. [Ignoring Files](#ignoring-files)
14. [Troubleshooting](#troubleshooting)
15. [Support the Development](#support-the-development-of-qodeassist)
16. [How to Build](#how-to-build)
@ -264,6 +265,42 @@ Linked files provide persistent context throughout the conversation:
- on Mac: Option + Command + R
- on Windows: Ctrl + Alt + R
- on Linux with KDE Plasma: Ctrl + Alt + R
## Ignoring Files
QodeAssist supports the ability to ignore files in context using a .qodeassistignore file. This allows you to exclude specific files from the context during code completion and in the chat assistant, which is especially useful for large projects.
### How to Use .qodeassistignore
- Create a .qodeassistignore file in the root directory of your project near CMakeLists.txt or pro.
- Add patterns for files and directories that should be excluded from the context.
- QodeAssist will automatically detect this file and apply the exclusion rules.
### .qodeassistignore File Format
The file format is similar to .gitignore:
- Each pattern is written on a separate line
- Empty lines are ignored
- Lines starting with # are considered comments
- Standard wildcards work the same as in .gitignore
- To negate a pattern, use ! at the beginning of the line
```
# Ignore all files in the build directory
build/
# Ignore all temporary files
*.tmp
*.temp
# Ignore all files with .log extension
*.log
# Ignore a specific file
src/generated/autogen.cpp
# Ignore nested directories
**/node_modules/
# Negation - DO NOT ignore this file
!src/important.cpp
```
## Troubleshooting

View File

@ -47,8 +47,8 @@ ChatAssistantSettings::ChatAssistantSettings()
chatTokensThreshold.setLabelText(Tr::tr("Chat history token limit:"));
chatTokensThreshold.setToolTip(Tr::tr("Maximum number of tokens in chat history. When "
"exceeded, oldest messages will be removed."));
chatTokensThreshold.setRange(1, std::numeric_limits<qint64>::max());
chatTokensThreshold.setDefaultValue(8000);
chatTokensThreshold.setRange(1, 99999999);
chatTokensThreshold.setDefaultValue(20000);
linkOpenFiles.setSettingsKey(Constants::CA_LINK_OPEN_FILES);
linkOpenFiles.setLabelText(Tr::tr("Sync open files with assistant by default"));