feat: Add file suggestion edit tool and chat UI (#240)

* feat: Add settings for write to system tool access
This commit is contained in:
Petr Mironychev
2025-10-20 11:48:18 +02:00
committed by GitHub
parent 238ca00227
commit 8a338ecb69
15 changed files with 1400 additions and 22 deletions

View File

@ -18,6 +18,7 @@ qt_add_qml_module(QodeAssistChatView
qml/parts/AttachedFilesPlace.qml
qml/parts/ErrorToast.qml
qml/ToolStatusItem.qml
qml/FileEditChangesItem.qml
RESOURCES
icons/attach-file-light.svg
@ -34,6 +35,7 @@ qt_add_qml_module(QodeAssistChatView
icons/window-unlock.svg
icons/chat-icon.svg
icons/chat-pause-icon.svg
SOURCES
ChatWidget.hpp ChatWidget.cpp
ChatModel.hpp ChatModel.cpp
@ -44,6 +46,7 @@ qt_add_qml_module(QodeAssistChatView
ChatSerializer.hpp ChatSerializer.cpp
ChatView.hpp ChatView.cpp
ChatData.hpp
FileEditItem.hpp FileEditItem.cpp
)
target_link_libraries(QodeAssistChatView
@ -58,6 +61,7 @@ target_link_libraries(QodeAssistChatView
QodeAssistSettings
Context
QodeAssistUIControlsplugin
QodeAssistLogger
)
target_include_directories(QodeAssistChatView

View File

@ -19,7 +19,8 @@
#include "ChatModel.hpp"
#include <utils/aspects.h>
#include <QtCore/qjsonobject.h>
#include <QJsonDocument>
#include <QJsonObject>
#include <QtQml>
#include "ChatAssistantSettings.hpp"
@ -126,7 +127,6 @@ 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();
@ -134,10 +134,19 @@ QList<MessagePart> ChatModel::processMessageContent(const QString &content) cons
QString textBetween
= content.mid(lastIndex, match.capturedStart() - lastIndex).trimmed();
if (!textBetween.isEmpty()) {
parts.append({MessagePartType::Text, textBetween, ""});
MessagePart part;
part.type = MessagePartType::Text;
part.text = textBetween;
parts.append(part);
}
}
parts.append({MessagePartType::Code, match.captured(2).trimmed(), match.captured(1)});
MessagePart codePart;
codePart.type = MessagePartType::Code;
codePart.text = match.captured(2).trimmed();
codePart.language = match.captured(1);
parts.append(codePart);
lastIndex = match.capturedEnd();
}
@ -150,15 +159,22 @@ QList<MessagePart> ChatModel::processMessageContent(const QString &content) cons
if (unclosedMatch.hasMatch()) {
QString beforeCodeBlock = remainingText.left(unclosedMatch.capturedStart()).trimmed();
if (!beforeCodeBlock.isEmpty()) {
parts.append({MessagePartType::Text, beforeCodeBlock, ""});
MessagePart part;
part.type = MessagePartType::Text;
part.text = beforeCodeBlock;
parts.append(part);
}
parts.append(
{MessagePartType::Code,
unclosedMatch.captured(2).trimmed(),
unclosedMatch.captured(1)});
MessagePart codePart;
codePart.type = MessagePartType::Code;
codePart.text = unclosedMatch.captured(2).trimmed();
codePart.language = unclosedMatch.captured(1);
parts.append(codePart);
} else if (!remainingText.isEmpty()) {
parts.append({MessagePartType::Text, remainingText, ""});
MessagePart part;
part.type = MessagePartType::Text;
part.text = remainingText;
parts.append(part);
}
}
@ -266,17 +282,60 @@ void ChatModel::updateToolResult(
.arg(requestId, toolId, toolName)
.arg(result.length()));
bool toolMessageFound = false;
for (int i = m_messages.size() - 1; i >= 0; --i) {
if (m_messages[i].id == toolId && m_messages[i].role == ChatRole::Tool) {
m_messages[i].content = toolName + "\n" + result;
emit dataChanged(index(i), index(i));
toolMessageFound = true;
LOG_MESSAGE(QString("Updated tool result at index %1").arg(i));
return;
break;
}
}
LOG_MESSAGE(QString("WARNING: Tool message with requestId=%1 toolId=%2 not found!")
.arg(requestId, toolId));
if (!toolMessageFound) {
LOG_MESSAGE(QString("WARNING: Tool message with requestId=%1 toolId=%2 not found!")
.arg(requestId, toolId));
}
const QString marker = "QODEASSIST_FILE_EDIT:";
if (result.contains(marker)) {
LOG_MESSAGE(QString("File edit marker detected in tool result"));
int markerPos = result.indexOf(marker);
int jsonStart = markerPos + marker.length();
if (jsonStart < result.length()) {
QString jsonStr = result.mid(jsonStart);
QJsonParseError parseError;
QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8(), &parseError);
if (parseError.error != QJsonParseError::NoError) {
LOG_MESSAGE(QString("ERROR: Failed to parse file edit JSON at offset %1: %2")
.arg(parseError.offset)
.arg(parseError.errorString()));
} else if (!doc.isObject()) {
LOG_MESSAGE(
QString("ERROR: Parsed JSON is not an object, is array=%1").arg(doc.isArray()));
} else {
QJsonObject editData = doc.object();
QString editId = editData["edit_id"].toString();
LOG_MESSAGE(QString("Adding FileEdit message, editId=%1").arg(editId));
beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size());
Message fileEditMsg;
fileEditMsg.role = ChatRole::FileEdit;
fileEditMsg.content = result;
fileEditMsg.id = editId.isEmpty() ? QString("edit_%1").arg(requestId) : editId;
m_messages.append(fileEditMsg);
endInsertRows();
LOG_MESSAGE(QString("Added FileEdit message with editId=%1").arg(editId));
}
}
}
}
} // namespace QodeAssist::Chat

View File

@ -37,7 +37,7 @@ class ChatModel : public QAbstractListModel
QML_ELEMENT
public:
enum ChatRole { System, User, Assistant, Tool };
enum ChatRole { System, User, Assistant, Tool, FileEdit };
Q_ENUM(ChatRole)
enum Roles { RoleType = Qt::UserRole, Content, Attachments };

460
ChatView/FileEditItem.cpp Normal file
View File

@ -0,0 +1,460 @@
/*
* 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/>.
*/
#include "FileEditItem.hpp"
#include "Logger.hpp"
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonObject>
#include <QMutexLocker>
#include <QTextStream>
#include <QTimer>
namespace QodeAssist::Chat {
QMutex FileEditItem::s_fileLockMutex;
QSet<QString> FileEditItem::s_lockedFiles;
FileEditItem::FileEditItem(QQuickItem *parent)
: QQuickItem(parent)
{}
void FileEditItem::parseFromContent(const QString &content)
{
static const QLatin1String marker(EDIT_MARKER);
int markerPos = content.indexOf(marker);
if (markerPos == -1) {
LOG_MESSAGE(QString("FileEditItem: ERROR - no marker found"));
return;
}
int jsonStart = markerPos + marker.size();
QString jsonStr = content.mid(jsonStart);
QJsonParseError parseError;
QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8(), &parseError);
if (parseError.error != QJsonParseError::NoError) {
LOG_MESSAGE(QString("FileEditItem: JSON parse error at offset %1: %2")
.arg(parseError.offset)
.arg(parseError.errorString()));
return;
}
if (!doc.isObject()) {
LOG_MESSAGE(QString("FileEditItem: ERROR - parsed JSON is not an object"));
return;
}
QJsonObject editData = doc.object();
m_editId = editData["edit_id"].toString();
m_filePath = editData["file_path"].toString();
m_editMode = editData["mode"].toString();
m_originalContent = editData["original_content"].toString();
m_newContent = editData["new_content"].toString();
m_contextBefore = editData["context_before"].toString();
m_contextAfter = editData["context_after"].toString();
m_searchText = editData["search_text"].toString();
m_lineNumber = editData["line_number"].toInt(-1);
m_addedLines = m_newContent.split('\n').size();
m_removedLines = m_originalContent.split('\n').size();
LOG_MESSAGE(QString("FileEditItem: parsed successfully, editId=%1, filePath=%2")
.arg(m_editId, m_filePath));
emit editIdChanged();
emit filePathChanged();
emit editModeChanged();
emit originalContentChanged();
emit newContentChanged();
emit addedLinesChanged();
emit removedLinesChanged();
applyEditInternal(true);
}
void FileEditItem::applyEdit()
{
applyEditInternal(false, 0);
}
void FileEditItem::applyEditInternal(bool isAutomatic, int retryCount)
{
if (!isAutomatic && m_status != EditStatus::Reverted && m_status != EditStatus::Rejected) {
return;
}
if (!acquireFileLock(m_filePath)) {
if (retryCount >= MAX_RETRY_COUNT) {
rejectWithError(QString("File %1 is locked, exceeded retry limit").arg(m_filePath));
return;
}
const int retryDelay = isAutomatic ? AUTO_APPLY_RETRY_DELAY_MS : RETRY_DELAY_MS;
QTimer::singleShot(retryDelay, this, [this, isAutomatic, retryCount]() {
applyEditInternal(isAutomatic, retryCount + 1);
});
return;
}
performApply();
releaseFileLock(m_filePath);
}
void FileEditItem::revertEdit()
{
if (m_status != EditStatus::Applied) {
return;
}
if (!acquireFileLock(m_filePath)) {
QTimer::singleShot(RETRY_DELAY_MS, this, &FileEditItem::revertEdit);
return;
}
performRevert();
releaseFileLock(m_filePath);
}
void FileEditItem::performApply()
{
LOG_MESSAGE(QString("FileEditItem: applying edit %1 to %2").arg(m_editId, m_filePath));
QString currentContent = readFile(m_filePath);
if (currentContent.isNull()) {
rejectWithError(QString("Failed to read file: %1").arg(m_filePath));
return;
}
bool success = false;
QString editedContent = applyEditToContent(currentContent, success);
if (!success) {
rejectWithError("Failed to apply edit: could not find context. File may have been modified.");
return;
}
if (!writeFile(m_filePath, editedContent)) {
rejectWithError(QString("Failed to write file: %1").arg(m_filePath));
return;
}
finishWithSuccess(EditStatus::Applied, QString("Successfully applied edit to: %1").arg(m_filePath));
}
void FileEditItem::performRevert()
{
LOG_MESSAGE(QString("FileEditItem: reverting edit %1 for %2").arg(m_editId, m_filePath));
QString currentContent = readFile(m_filePath);
if (currentContent.isNull()) {
rejectWithError(QString("Failed to read file for revert: %1").arg(m_filePath));
return;
}
bool success = false;
QString revertedContent = applyReverseEdit(currentContent, success);
if (!success) {
rejectWithError("Failed to revert edit: could not find changes in current file.");
return;
}
if (!writeFile(m_filePath, revertedContent)) {
rejectWithError(QString("Failed to write reverted file: %1").arg(m_filePath));
return;
}
finishWithSuccess(EditStatus::Reverted, QString("Successfully reverted edit to: %1").arg(m_filePath));
}
void FileEditItem::rejectWithError(const QString &errorMessage)
{
LOG_MESSAGE(errorMessage);
setStatus(EditStatus::Rejected);
setStatusMessage(errorMessage);
}
void FileEditItem::finishWithSuccess(EditStatus status, const QString &message)
{
LOG_MESSAGE(message);
setStatus(status);
setStatusMessage(message);
}
void FileEditItem::setStatus(EditStatus status)
{
if (m_status == status)
return;
m_status = status;
emit statusChanged();
}
void FileEditItem::setStatusMessage(const QString &message)
{
if (m_statusMessage == message)
return;
m_statusMessage = message;
emit statusMessageChanged();
}
bool FileEditItem::writeFile(const QString &filePath, const QString &content)
{
QFile file(filePath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
LOG_MESSAGE(QString("Could not open file for writing: %1").arg(filePath));
return false;
}
QTextStream stream(&file);
stream.setAutoDetectUnicode(true);
stream << content;
file.close();
if (stream.status() != QTextStream::Ok) {
LOG_MESSAGE(QString("Error writing to file: %1").arg(filePath));
return false;
}
return true;
}
QString FileEditItem::readFile(const QString &filePath)
{
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
LOG_MESSAGE(QString("Could not open file for reading: %1").arg(filePath));
return QString();
}
QTextStream stream(&file);
stream.setAutoDetectUnicode(true);
QString content = stream.readAll();
file.close();
return content;
}
QString FileEditItem::applyEditToContent(const QString &content, bool &success)
{
success = false;
QStringList lines = content.split('\n');
if (m_editMode == "replace") {
QString searchPattern = m_contextBefore + m_searchText + m_contextAfter;
int pos = content.indexOf(searchPattern);
if (pos == -1 && !m_contextBefore.isEmpty()) {
pos = content.indexOf(m_searchText);
}
if (pos != -1) {
QString result = content;
int searchPos = result.indexOf(m_searchText, pos);
if (searchPos != -1) {
result.replace(searchPos, m_searchText.length(), m_newContent);
success = true;
return result;
}
}
return content;
} else if (m_editMode == "insert_before" || m_editMode == "insert_after") {
int targetLine = -1;
if (!m_contextBefore.isEmpty() || !m_contextAfter.isEmpty()) {
for (int i = 0; i < lines.size(); ++i) {
bool contextMatches = true;
if (!m_contextBefore.isEmpty()) {
QStringList beforeLines = m_contextBefore.split('\n');
if (i >= beforeLines.size()) {
bool allMatch = true;
for (int j = 0; j < beforeLines.size(); ++j) {
if (lines[i - beforeLines.size() + j].trimmed()
!= beforeLines[j].trimmed()) {
allMatch = false;
break;
}
}
if (!allMatch)
contextMatches = false;
} else {
contextMatches = false;
}
}
if (contextMatches && !m_contextAfter.isEmpty()) {
QStringList afterLines = m_contextAfter.split('\n');
if (i + afterLines.size() < lines.size()) {
bool allMatch = true;
for (int j = 0; j < afterLines.size(); ++j) {
if (lines[i + 1 + j].trimmed() != afterLines[j].trimmed()) {
allMatch = false;
break;
}
}
if (!allMatch)
contextMatches = false;
} else {
contextMatches = false;
}
}
if (contextMatches && targetLine == -1) {
targetLine = i;
break;
}
}
}
if (targetLine == -1 && m_lineNumber > 0 && m_lineNumber <= lines.size()) {
targetLine = m_lineNumber - 1;
}
if (targetLine != -1) {
if (m_editMode == "insert_before") {
lines.insert(targetLine, m_newContent);
} else {
lines.insert(targetLine + 1, m_newContent);
}
success = true;
return lines.join('\n');
}
return content;
} else if (m_editMode == "append") {
success = true;
return content + (content.endsWith('\n') ? "" : "\n") + m_newContent + "\n";
}
return content;
}
QString FileEditItem::applyReverseEdit(const QString &content, bool &success)
{
success = false;
QStringList lines = content.split('\n');
if (m_editMode == "replace") {
int pos = content.indexOf(m_newContent);
if (pos != -1) {
QString result = content;
result.replace(pos, m_newContent.length(), m_originalContent);
success = true;
return result;
}
return content;
} else if (m_editMode == "insert_before" || m_editMode == "insert_after") {
for (int i = 0; i < lines.size(); ++i) {
if (lines[i].trimmed() == m_newContent.trimmed()) {
bool contextMatches = true;
if (!m_contextBefore.isEmpty()) {
QStringList beforeLines = m_contextBefore.split('\n');
if (i >= beforeLines.size()) {
for (int j = 0; j < beforeLines.size(); ++j) {
if (lines[i - beforeLines.size() + j].trimmed() != beforeLines[j].trimmed()) {
contextMatches = false;
break;
}
}
} else {
contextMatches = false;
}
}
if (contextMatches && !m_contextAfter.isEmpty()) {
QStringList afterLines = m_contextAfter.split('\n');
if (i + 1 + afterLines.size() <= lines.size()) {
for (int j = 0; j < afterLines.size(); ++j) {
if (lines[i + 1 + j].trimmed() != afterLines[j].trimmed()) {
contextMatches = false;
break;
}
}
} else {
contextMatches = false;
}
}
if (contextMatches) {
lines.removeAt(i);
success = true;
return lines.join('\n');
}
}
}
return content;
} else if (m_editMode == "append") {
QString suffix1 = m_newContent + "\n";
QString suffix2 = "\n" + m_newContent + "\n";
if (content.endsWith(suffix1)) {
QString result = content.left(content.length() - suffix1.length());
success = true;
return result;
} else if (content.endsWith(suffix2)) {
QString result = content.left(content.length() - suffix2.length()) + "\n";
success = true;
return result;
} else if (content.endsWith(m_newContent)) {
QString result = content.left(content.length() - m_newContent.length());
success = true;
return result;
}
return content;
}
return content;
}
bool FileEditItem::acquireFileLock(const QString &filePath)
{
QMutexLocker locker(&s_fileLockMutex);
if (s_lockedFiles.contains(filePath)) {
return false;
}
s_lockedFiles.insert(filePath);
LOG_MESSAGE(QString("FileEditItem: acquired lock for %1").arg(filePath));
return true;
}
void FileEditItem::releaseFileLock(const QString &filePath)
{
QMutexLocker locker(&s_fileLockMutex);
if (s_lockedFiles.remove(filePath)) {
LOG_MESSAGE(QString("FileEditItem: released lock for %1").arg(filePath));
}
}
} // namespace QodeAssist::Chat

122
ChatView/FileEditItem.hpp Normal file
View File

@ -0,0 +1,122 @@
/*
* 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/>.
*/
#pragma once
#include <QMutex>
#include <QQuickItem>
#include <QSet>
#include <QString>
#include <QtQmlIntegration>
namespace QodeAssist::Chat {
class FileEditItem : public QQuickItem
{
Q_OBJECT
QML_ELEMENT
public:
enum class EditStatus {
Pending,
Applied,
Rejected,
Reverted
};
Q_ENUM(EditStatus)
static constexpr const char *EDIT_MARKER = "QODEASSIST_FILE_EDIT:";
static constexpr int RETRY_DELAY_MS = 100;
static constexpr int AUTO_APPLY_RETRY_DELAY_MS = 50;
static constexpr int MAX_RETRY_COUNT = 10;
Q_PROPERTY(QString editId READ editId NOTIFY editIdChanged FINAL)
Q_PROPERTY(QString filePath READ filePath NOTIFY filePathChanged FINAL)
Q_PROPERTY(QString editMode READ editMode NOTIFY editModeChanged FINAL)
Q_PROPERTY(QString originalContent READ originalContent NOTIFY originalContentChanged FINAL)
Q_PROPERTY(QString newContent READ newContent NOTIFY newContentChanged FINAL)
Q_PROPERTY(int addedLines READ addedLines NOTIFY addedLinesChanged FINAL)
Q_PROPERTY(int removedLines READ removedLines NOTIFY removedLinesChanged FINAL)
Q_PROPERTY(EditStatus status READ status NOTIFY statusChanged FINAL)
Q_PROPERTY(QString statusMessage READ statusMessage NOTIFY statusMessageChanged FINAL)
public:
explicit FileEditItem(QQuickItem *parent = nullptr);
QString editId() const { return m_editId; }
QString filePath() const { return m_filePath; }
QString editMode() const { return m_editMode; }
QString originalContent() const { return m_originalContent; }
QString newContent() const { return m_newContent; }
int addedLines() const { return m_addedLines; }
int removedLines() const { return m_removedLines; }
EditStatus status() const { return m_status; }
QString statusMessage() const { return m_statusMessage; }
Q_INVOKABLE void parseFromContent(const QString &content);
Q_INVOKABLE void applyEdit();
Q_INVOKABLE void revertEdit();
signals:
void editIdChanged();
void filePathChanged();
void editModeChanged();
void originalContentChanged();
void newContentChanged();
void addedLinesChanged();
void removedLinesChanged();
void statusChanged();
void statusMessageChanged();
private:
void setStatus(EditStatus status);
void setStatusMessage(const QString &message);
void applyEditInternal(bool isAutomatic, int retryCount = 0);
void performApply();
void performRevert();
void rejectWithError(const QString &errorMessage);
void finishWithSuccess(EditStatus status, const QString &message);
bool writeFile(const QString &filePath, const QString &content);
QString readFile(const QString &filePath);
QString applyEditToContent(const QString &content, bool &success);
QString applyReverseEdit(const QString &content, bool &success);
static bool acquireFileLock(const QString &filePath);
static void releaseFileLock(const QString &filePath);
static QMutex s_fileLockMutex;
static QSet<QString> s_lockedFiles;
QString m_editId;
QString m_filePath;
QString m_editMode;
QString m_originalContent;
QString m_newContent;
QString m_contextBefore;
QString m_contextAfter;
QString m_searchText;
int m_lineNumber = -1;
int m_addedLines = 0;
int m_removedLines = 0;
EditStatus m_status = EditStatus::Pending;
QString m_statusMessage;
};
} // namespace QodeAssist::Chat

View File

@ -0,0 +1,254 @@
/*
* 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.Layouts
import ChatView
import UIControls
import "./parts"
FileEditItem {
id: root
implicitHeight: fileEditView.implicitHeight
Component.onCompleted: {
root.parseFromContent(model.content)
}
readonly property int borderRadius: 4
readonly property int contentMargin: 10
readonly property int contentBottomPadding: 20
readonly property int headerPadding: 8
readonly property int statusIndicatorWidth: 4
readonly property var originalLines: originalContent.split('\n')
readonly property var newLines: newContent.split('\n')
readonly property string firstOriginalLine: originalLines[0] || ""
readonly property string firstNewLine: newLines[0] || ""
readonly property bool hasMultipleOriginalLines: originalLines.length > 1
readonly property bool hasMultipleNewLines: newLines.length > 1
readonly property bool isPending: status === FileEditItem.Pending
readonly property bool isApplied: status === FileEditItem.Applied
readonly property bool isReverted: status === FileEditItem.Reverted
readonly property bool isRejected: status === FileEditItem.Rejected
readonly property color appliedColor: Qt.rgba(0.2, 0.8, 0.2, 0.8)
readonly property color revertedColor: Qt.rgba(0.8, 0.6, 0.2, 0.8)
readonly property color rejectedColor: palette.mid
readonly property color pendingColor: palette.highlight
readonly property color appliedBgColor: Qt.rgba(0.2, 0.8, 0.2, 0.3)
readonly property color revertedBgColor: Qt.rgba(0.8, 0.6, 0.2, 0.3)
readonly property color rejectedBgColor: Qt.rgba(0.8, 0.2, 0.2, 0.3)
readonly property string codeFontFamily: {
switch (Qt.platform.os) {
case "windows": return "Consolas"
case "osx": return "Menlo"
case "linux": return "DejaVu Sans Mono"
default: return "monospace"
}
}
readonly property int codeFontSize: Qt.application.font.pointSize
readonly property color statusColor: {
if (isApplied) return appliedColor
if (isReverted) return revertedColor
if (isRejected) return rejectedColor
return pendingColor
}
readonly property color statusBgColor: {
if (isApplied) return appliedBgColor
if (isReverted) return revertedBgColor
if (isRejected) return rejectedBgColor
return palette.button
}
readonly property string statusText: {
if (isApplied) return qsTr("APPLIED")
if (isReverted) return qsTr("REVERTED")
if (isRejected) return qsTr("REJECTED")
return ""
}
Rectangle {
id: fileEditView
anchors.fill: parent
implicitHeight: expanded ? headerArea.height + contentColumn.height + root.contentBottomPadding
: headerArea.height
radius: root.borderRadius
property bool expanded: false
color: palette.base
border.width: 1
border.color: root.isPending
? (color.hslLightness > 0.5 ? Qt.darker(color, 1.3) : Qt.lighter(color, 1.3))
: Qt.alpha(root.statusColor, 0.6)
clip: true
states: [
State {
name: "expanded"
when: fileEditView.expanded
PropertyChanges { target: contentColumn; opacity: 1 }
},
State {
name: "collapsed"
when: !fileEditView.expanded
PropertyChanges { target: contentColumn; opacity: 0 }
}
]
transitions: Transition {
NumberAnimation {
properties: "implicitHeight,opacity"
duration: 200
easing.type: Easing.InOutQuad
}
}
MouseArea {
id: headerArea
width: parent.width
height: headerRow.height + 16
cursorShape: Qt.PointingHandCursor
onClicked: fileEditView.expanded = !fileEditView.expanded
RowLayout {
id: headerRow
anchors {
verticalCenter: parent.verticalCenter
left: parent.left
right: actionButtons.left
leftMargin: root.contentMargin
rightMargin: root.contentMargin
}
spacing: root.headerPadding
Rectangle {
width: root.statusIndicatorWidth
height: headerText.height
radius: 2
color: root.statusColor
}
Text {
id: headerText
Layout.fillWidth: true
text: qsTr("File Edit: %1 (+%2 -%3)")
.arg(root.filePath)
.arg(root.addedLines)
.arg(root.removedLines)
font.pixelSize: 12
font.bold: true
color: palette.text
elide: Text.ElideMiddle
}
Text {
text: fileEditView.expanded ? "▼" : "▶"
font.pixelSize: 10
color: palette.mid
}
Badge {
visible: !root.isPending
text: root.statusText
color: root.statusBgColor
}
}
Row {
id: actionButtons
anchors {
right: parent.right
rightMargin: 5
verticalCenter: parent.verticalCenter
}
spacing: 6
QoAButton {
text: qsTr("Apply")
enabled: root.isReverted || root.isRejected
visible: !root.isApplied
onClicked: root.applyEdit()
}
QoAButton {
text: qsTr("Revert")
enabled: root.isApplied
visible: !root.isReverted && !root.isRejected
onClicked: root.revertEdit()
}
}
}
ColumnLayout {
id: contentColumn
anchors {
left: parent.left
right: parent.right
top: headerArea.bottom
margins: root.contentMargin
}
spacing: 4
visible: opacity > 0
Text {
Layout.fillWidth: true
text: "Old: " + root.firstOriginalLine + (root.hasMultipleOriginalLines ? "..." : "")
font.family: root.codeFontFamily
font.pixelSize: root.codeFontSize
color: Qt.rgba(1, 0.2, 0.2, 0.9)
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
text: "New: " + root.firstNewLine + (root.hasMultipleNewLines ? "..." : "")
font.family: root.codeFontFamily
font.pixelSize: root.codeFontSize
color: Qt.rgba(0.2, 0.8, 0.2, 0.9)
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
visible: root.statusMessage.length > 0
text: root.statusMessage
font.pixelSize: 11
font.italic: true
color: root.isApplied
? Qt.rgba(0.2, 0.6, 0.2, 1)
: Qt.rgba(0.8, 0.2, 0.2, 1)
wrapMode: Text.WordWrap
}
}
}
}

View File

@ -102,7 +102,15 @@ ChatRootView {
width: ListView.view.width - scroll.width
sourceComponent: model.roleType === ChatModel.Tool ? toolMessageComponent : chatItemComponent
sourceComponent: {
if (model.roleType === ChatModel.Tool) {
return toolMessageComponent
} else if (model.roleType === ChatModel.FileEdit) {
return fileEditSuggestionComponent
} else {
return chatItemComponent
}
}
}
header: Item {
@ -128,6 +136,7 @@ ChatRootView {
id: chatItemComponent
ChatItem {
id: chatItemInstance
msgModel: root.chatModel.processMessageContent(model.content)
messageAttachments: model.attachments
isUserMessage: model.roleType === ChatModel.User
@ -153,6 +162,16 @@ ChatRootView {
toolContent: model.content
}
}
Component {
id: fileEditSuggestionComponent
FileEditChangesItem {
id: fileEditItem
width: chatListView.width - 10
}
}
}
ScrollView {