feat: shimmer status bar for MCP activity, auto-start MCP, remove "Ready" spam

- Add ShimmerLabel widget with animated glow band for MCP tool activity
- Separate app/MCP status channels (setAppStatus/setMcpStatus/clearMcpStatus)
- 750ms delayed clear so shimmer stays visible after fast tool calls
- MCP auto-starts on launch by default
- Remove "Ready" text that was overwriting useful status info
- Add statusText field to project.state MCP response
This commit is contained in:
IChooseYou
2026-02-24 12:31:25 -07:00
parent 5b46065403
commit c45d51d736
3 changed files with 148 additions and 31 deletions

View File

@@ -454,7 +454,7 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
// Start MCP bridge // Start MCP bridge
m_mcp = new McpBridge(this, this); m_mcp = new McpBridge(this, this);
if (QSettings("Reclass", "Reclass").value("autoStartMcp", false).toBool()) if (QSettings("Reclass", "Reclass").value("autoStartMcp", true).toBool())
m_mcp->start(); m_mcp->start();
connect(m_mdiArea, &QMdiArea::subWindowActivated, connect(m_mdiArea, &QMdiArea::subWindowActivated,
@@ -526,7 +526,7 @@ void MainWindow::createMenus() {
} }
} }
file->addSeparator(); file->addSeparator();
const auto itemName = QSettings("Reclass", "Reclass").value("autoStartMcp", false).toBool() ? "Stop &MCP Server" : "Start &MCP Server"; const auto itemName = QSettings("Reclass", "Reclass").value("autoStartMcp", true).toBool() ? "Stop &MCP Server" : "Start &MCP Server";
m_mcpAction = Qt5Qt6AddAction(file, itemName, QKeySequence::UnknownKey, QIcon(), this, &MainWindow::toggleMcp); m_mcpAction = Qt5Qt6AddAction(file, itemName, QKeySequence::UnknownKey, QIcon(), this, &MainWindow::toggleMcp);
file->addSeparator(); file->addSeparator();
Qt5Qt6AddAction(file, "&Options...", QKeySequence::UnknownKey, makeIcon(":/vsicons/settings-gear.svg"), this, &MainWindow::showOptionsDialog); Qt5Qt6AddAction(file, "&Options...", QKeySequence::UnknownKey, makeIcon(":/vsicons/settings-gear.svg"), this, &MainWindow::showOptionsDialog);
@@ -716,6 +716,80 @@ protected:
void leaveEvent(QEvent*) override { update(); } void leaveEvent(QEvent*) override { update(); }
}; };
// ── Shimmer label — gradient text sweep for MCP activity ──
class ShimmerLabel : public QWidget {
public:
explicit ShimmerLabel(QWidget* parent = nullptr) : QWidget(parent) {
m_timer.setInterval(30);
connect(&m_timer, &QTimer::timeout, this, [this]() {
m_phase += 0.012f;
if (m_phase > 1.0f) m_phase -= 1.0f;
update();
});
}
void setText(const QString& t) { m_text = t; update(); }
QString text() const { return m_text; }
void setShimmerActive(bool on) {
if (m_shimmer == on) return;
m_shimmer = on;
if (on) { m_phase = 0.0f; m_timer.start(); }
else { m_timer.stop(); }
update();
}
bool shimmerActive() const { return m_shimmer; }
void setAlignment(Qt::Alignment a) { m_align = a; update(); }
// Colours configurable from theme
QColor colBase; // dim text (normal)
QColor colBright; // highlight sweep
protected:
void paintEvent(QPaintEvent*) override {
if (m_text.isEmpty()) return;
QPainter p(this);
p.setRenderHint(QPainter::TextAntialiasing);
p.setFont(font());
QRect r = contentsRect();
if (!m_shimmer) {
QColor c = colBase.isValid() ? colBase
: palette().color(QPalette::WindowText);
p.setPen(c);
p.drawText(r, m_align, m_text);
return;
}
// Shimmer: sweeping glow band behind text + bright text
QColor bright = colBright.isValid() ? colBright : QColor(255, 200, 80);
// 1. Sweeping glow band (semi-transparent background highlight)
qreal bandW = width() * 0.20;
qreal bandCenter = -bandW + (width() + 2 * bandW) * m_phase;
QLinearGradient bgGrad(bandCenter - bandW, 0, bandCenter + bandW, 0);
QColor glow = bright;
glow.setAlpha(35);
bgGrad.setColorAt(0.0, Qt::transparent);
bgGrad.setColorAt(0.5, glow);
bgGrad.setColorAt(1.0, Qt::transparent);
p.fillRect(rect(), QBrush(bgGrad));
// 2. Text in bright color
p.setPen(bright);
p.drawText(r, m_align, m_text);
}
private:
QString m_text;
bool m_shimmer = false;
float m_phase = 0.0f;
Qt::Alignment m_align = Qt::AlignLeft | Qt::AlignVCenter;
QTimer m_timer;
};
// ── Borderless status bar with manual child layout ── // ── Borderless status bar with manual child layout ──
// QStatusBarLayout hardcodes 2px margins that can't be overridden. // QStatusBarLayout hardcodes 2px margins that can't be overridden.
// We bypass it entirely: children are placed manually in resizeEvent, // We bypass it entirely: children are placed manually in resizeEvent,
@@ -723,8 +797,8 @@ protected:
// children and call manualLayout() to position them. // children and call manualLayout() to position them.
class FlatStatusBar : public QStatusBar { class FlatStatusBar : public QStatusBar {
public: public:
QWidget* tabRow = nullptr; // set by createStatusBar QWidget* tabRow = nullptr; // set by createStatusBar
QLabel* label = nullptr; // set by createStatusBar ShimmerLabel* label = nullptr; // set by createStatusBar
void setDividerColor(const QColor& c) { m_div = c; update(); } void setDividerColor(const QColor& c) { m_div = c; update(); }
void setTopLineColor(const QColor& c) { m_top = c; update(); } void setTopLineColor(const QColor& c) { m_top = c; update(); }
@@ -802,7 +876,8 @@ void MainWindow::createStatusBar() {
auto* sb = new FlatStatusBar; auto* sb = new FlatStatusBar;
setStatusBar(sb); setStatusBar(sb);
m_statusLabel = new QLabel("Ready", sb); m_statusLabel = new ShimmerLabel(sb);
m_statusLabel->setText("");
m_statusLabel->setContentsMargins(0, 0, 0, 0); m_statusLabel->setContentsMargins(0, 0, 0, 0);
m_statusLabel->setAlignment(Qt::AlignVCenter | Qt::AlignLeft); m_statusLabel->setAlignment(Qt::AlignVCenter | Qt::AlignLeft);
@@ -865,10 +940,42 @@ void MainWindow::createStatusBar() {
}; };
applyViewTabColors(static_cast<ViewTabButton*>(m_btnReclass)); applyViewTabColors(static_cast<ViewTabButton*>(m_btnReclass));
applyViewTabColors(static_cast<ViewTabButton*>(m_btnRendered)); applyViewTabColors(static_cast<ViewTabButton*>(m_btnRendered));
m_statusLabel->colBase = t.textDim;
m_statusLabel->colBright = t.indHoverSpan;
} }
} }
void MainWindow::setAppStatus(const QString& text) {
m_appStatus = text;
if (!m_mcpBusy) {
m_statusLabel->setText(text);
m_statusLabel->setShimmerActive(false);
}
}
void MainWindow::setMcpStatus(const QString& text) {
// Cancel any pending clear — new activity extends the shimmer
if (m_mcpClearTimer) m_mcpClearTimer->stop();
m_mcpBusy = true;
m_statusLabel->setText(text);
m_statusLabel->setShimmerActive(true);
}
void MainWindow::clearMcpStatus() {
// Delay the clear so the shimmer stays visible for at least 750ms
if (!m_mcpClearTimer) {
m_mcpClearTimer = new QTimer(this);
m_mcpClearTimer->setSingleShot(true);
connect(m_mcpClearTimer, &QTimer::timeout, this, [this]() {
m_mcpBusy = false;
m_statusLabel->setText(m_appStatus);
m_statusLabel->setShimmerActive(false);
});
}
m_mcpClearTimer->start(750);
}
void MainWindow::styleTabCloseButtons() { void MainWindow::styleTabCloseButtons() {
auto* tabBar = m_mdiArea->findChild<QTabBar*>(); auto* tabBar = m_mdiArea->findChild<QTabBar*>();
@@ -1033,19 +1140,17 @@ QMdiSubWindow* MainWindow::createTab(RcxDocument* doc) {
auto& node = ctrl->document()->tree.nodes[nodeIdx]; auto& node = ctrl->document()->tree.nodes[nodeIdx];
auto* ap = findActiveSplitPane(); auto* ap = findActiveSplitPane();
if (ap && ap->viewMode == VM_Rendered) if (ap && ap->viewMode == VM_Rendered)
m_statusLabel->setText( setAppStatus(
QString("Rendered: %1 %2") QString("Rendered: %1 %2")
.arg(kindToString(node.kind)) .arg(kindToString(node.kind))
.arg(node.name)); .arg(node.name));
else else
m_statusLabel->setText( setAppStatus(
QString("%1 %2 offset: 0x%3 size: %4 bytes") QString("%1 %2 offset: 0x%3 size: %4 bytes")
.arg(kindToString(node.kind)) .arg(kindToString(node.kind))
.arg(node.name) .arg(node.name)
.arg(node.offset, 4, 16, QChar('0')) .arg(node.offset, 4, 16, QChar('0'))
.arg(node.byteSize())); .arg(node.byteSize()));
} else {
m_statusLabel->setText("Ready");
} }
// Update all rendered panes on selection change // Update all rendered panes on selection change
auto it = m_tabs.find(sub); auto it = m_tabs.find(sub);
@@ -1054,10 +1159,8 @@ QMdiSubWindow* MainWindow::createTab(RcxDocument* doc) {
}); });
connect(ctrl, &RcxController::selectionChanged, connect(ctrl, &RcxController::selectionChanged,
this, [this](int count) { this, [this](int count) {
if (count == 0) if (count > 1)
m_statusLabel->setText("Ready"); setAppStatus(QString("%1 nodes selected").arg(count));
else if (count > 1)
m_statusLabel->setText(QString("%1 nodes selected").arg(count));
}); });
// Update rendered panes and workspace on document changes and undo/redo // Update rendered panes and workspace on document changes and undo/redo
@@ -1524,11 +1627,11 @@ void MainWindow::toggleMcp() {
if (m_mcp->isRunning()) { if (m_mcp->isRunning()) {
m_mcp->stop(); m_mcp->stop();
m_mcpAction->setText("Start &MCP Server"); m_mcpAction->setText("Start &MCP Server");
m_statusLabel->setText("MCP server stopped"); setAppStatus("MCP server stopped");
} else { } else {
m_mcp->start(); m_mcp->start();
m_mcpAction->setText("Stop &MCP Server"); m_mcpAction->setText("Stop &MCP Server");
m_statusLabel->setText("MCP server listening on pipe: ReclassMcpBridge"); setAppStatus("MCP server listening on pipe: ReclassMcpBridge");
} }
} }
@@ -1944,7 +2047,7 @@ void MainWindow::exportCpp() {
return; return;
} }
file.write(text.toUtf8()); file.write(text.toUtf8());
m_statusLabel->setText("Exported to " + QFileInfo(path).fileName()); setAppStatus("Exported to " + QFileInfo(path).fileName());
} }
// ── Export ReClass XML ── // ── Export ReClass XML ──
@@ -1968,7 +2071,7 @@ void MainWindow::exportReclassXmlAction() {
for (const auto& n : tab->doc->tree.nodes) for (const auto& n : tab->doc->tree.nodes)
if (n.parentId == 0 && n.kind == NodeKind::Struct) classCount++; if (n.parentId == 0 && n.kind == NodeKind::Struct) classCount++;
m_statusLabel->setText(QStringLiteral("Exported %1 classes to %2") setAppStatus(QStringLiteral("Exported %1 classes to %2")
.arg(classCount).arg(QFileInfo(path).fileName())); .arg(classCount).arg(QFileInfo(path).fileName()));
} }
@@ -1999,7 +2102,7 @@ void MainWindow::importReclassXml() {
m_mdiArea->closeAllSubWindows(); m_mdiArea->closeAllSubWindows();
createTab(doc); createTab(doc);
rebuildWorkspaceModel(); rebuildWorkspaceModel();
m_statusLabel->setText(QStringLiteral("Imported %1 classes from %2") setAppStatus(QStringLiteral("Imported %1 classes from %2")
.arg(classCount).arg(QFileInfo(filePath).fileName())); .arg(classCount).arg(QFileInfo(filePath).fileName()));
} }
@@ -2049,7 +2152,7 @@ void MainWindow::importFromSource() {
createTab(doc); createTab(doc);
rebuildWorkspaceModel(); rebuildWorkspaceModel();
m_workspaceDock->show(); m_workspaceDock->show();
m_statusLabel->setText(QStringLiteral("Imported %1 classes from source").arg(classCount)); setAppStatus(QStringLiteral("Imported %1 classes from source").arg(classCount));
} }
// ── Import PDB ── // ── Import PDB ──
@@ -2099,7 +2202,7 @@ void MainWindow::importPdb() {
createTab(doc); createTab(doc);
rebuildWorkspaceModel(); rebuildWorkspaceModel();
m_workspaceDock->show(); m_workspaceDock->show();
m_statusLabel->setText(QStringLiteral("Imported %1 classes from %2") setAppStatus(QStringLiteral("Imported %1 classes from %2")
.arg(classCount).arg(QFileInfo(pdbPath).fileName())); .arg(classCount).arg(QFileInfo(pdbPath).fileName()));
} }
@@ -2232,7 +2335,7 @@ QMdiSubWindow* MainWindow::project_open(const QString& path) {
int classCount = 0; int classCount = 0;
for (const auto& n : doc->tree.nodes) for (const auto& n : doc->tree.nodes)
if (n.parentId == 0 && n.kind == NodeKind::Struct) classCount++; if (n.parentId == 0 && n.kind == NodeKind::Struct) classCount++;
m_statusLabel->setText(QStringLiteral("Imported %1 classes from %2") setAppStatus(QStringLiteral("Imported %1 classes from %2")
.arg(classCount).arg(QFileInfo(filePath).fileName())); .arg(classCount).arg(QFileInfo(filePath).fileName()));
return sub; return sub;
} }
@@ -2610,7 +2713,7 @@ void MainWindow::showPluginsDialog() {
if (!path.isEmpty()) { if (!path.isEmpty()) {
if (m_pluginManager.LoadPluginFromPath(path)) { if (m_pluginManager.LoadPluginFromPath(path)) {
refreshList(); refreshList();
m_statusLabel->setText("Plugin loaded successfully"); setAppStatus("Plugin loaded successfully");
} else { } else {
QMessageBox::warning(&dialog, "Failed to Load Plugin", QMessageBox::warning(&dialog, "Failed to Load Plugin",
"Could not load the selected plugin.\nCheck the console for details."); "Could not load the selected plugin.\nCheck the console for details.");
@@ -2636,7 +2739,7 @@ void MainWindow::showPluginsDialog() {
if (reply == QMessageBox::Yes) { if (reply == QMessageBox::Yes) {
if (m_pluginManager.UnloadPlugin(pluginName)) { if (m_pluginManager.UnloadPlugin(pluginName)) {
refreshList(); refreshList();
m_statusLabel->setText("Plugin unloaded"); setAppStatus("Plugin unloaded");
} else { } else {
QMessageBox::warning(&dialog, "Failed to Unload", QMessageBox::warning(&dialog, "Failed to Unload",
"Could not unload the selected plugin."); "Could not unload the selected plugin.");

View File

@@ -14,11 +14,13 @@
#include <QMap> #include <QMap>
#include <QButtonGroup> #include <QButtonGroup>
#include <QPushButton> #include <QPushButton>
#include <QTimer>
#include <Qsci/qsciscintilla.h> #include <Qsci/qsciscintilla.h>
namespace rcx { namespace rcx {
class McpBridge; class McpBridge;
class ShimmerLabel;
class MainWindow : public QMainWindow { class MainWindow : public QMainWindow {
Q_OBJECT Q_OBJECT
@@ -59,6 +61,11 @@ private slots:
void showOptionsDialog(); void showOptionsDialog();
public: public:
// Status bar helpers — separate app / MCP channels
void setAppStatus(const QString& text);
void setMcpStatus(const QString& text);
void clearMcpStatus();
// Project Lifecycle API // Project Lifecycle API
QMdiSubWindow* project_new(const QString& classKeyword = QString()); QMdiSubWindow* project_new(const QString& classKeyword = QString());
QMdiSubWindow* project_open(const QString& path = {}); QMdiSubWindow* project_open(const QString& path = {});
@@ -69,7 +76,10 @@ private:
enum ViewMode { VM_Reclass, VM_Rendered }; enum ViewMode { VM_Reclass, VM_Rendered };
QMdiArea* m_mdiArea; QMdiArea* m_mdiArea;
QLabel* m_statusLabel; ShimmerLabel* m_statusLabel;
QString m_appStatus;
bool m_mcpBusy = false;
QTimer* m_mcpClearTimer = nullptr;
QButtonGroup* m_viewBtnGroup = nullptr; QButtonGroup* m_viewBtnGroup = nullptr;
QPushButton* m_btnReclass = nullptr; QPushButton* m_btnReclass = nullptr;
QPushButton* m_btnRendered = nullptr; QPushButton* m_btnRendered = nullptr;

View File

@@ -170,12 +170,15 @@ void McpBridge::processLine(const QByteArray& line) {
} }
if (method == "initialize") { if (method == "initialize") {
m_mainWindow->m_statusLabel->setText(QStringLiteral("MCP: client connected")); m_mainWindow->setMcpStatus(QStringLiteral("MCP: client connected"));
QCoreApplication::processEvents();
sendJson(handleInitialize(id, req.value("params").toObject())); sendJson(handleInitialize(id, req.value("params").toObject()));
m_mainWindow->clearMcpStatus();
} else if (method == "tools/list") { } else if (method == "tools/list") {
m_mainWindow->m_statusLabel->setText(QStringLiteral("MCP: tools/list")); m_mainWindow->setMcpStatus(QStringLiteral("MCP: tools/list"));
QCoreApplication::processEvents();
sendJson(handleToolsList(id)); sendJson(handleToolsList(id));
m_mainWindow->m_statusLabel->setText(QStringLiteral("Ready")); m_mainWindow->clearMcpStatus();
} else if (method == "tools/call") { } else if (method == "tools/call") {
sendJson(handleToolsCall(id, req.value("params").toObject())); sendJson(handleToolsCall(id, req.value("params").toObject()));
} else { } else {
@@ -403,8 +406,8 @@ QJsonObject McpBridge::handleToolsCall(const QJsonValue& id, const QJsonObject&
QString toolName = params.value("name").toString(); QString toolName = params.value("name").toString();
QJsonObject args = params.value("arguments").toObject(); QJsonObject args = params.value("arguments").toObject();
// Show tool activity in status bar // Show tool activity in status bar (with shimmer)
m_mainWindow->m_statusLabel->setText(QStringLiteral("MCP: %1").arg(toolName)); m_mainWindow->setMcpStatus(QStringLiteral("MCP: %1").arg(toolName));
QCoreApplication::processEvents(); // paint immediately QCoreApplication::processEvents(); // paint immediately
QJsonObject result; QJsonObject result;
@@ -418,7 +421,7 @@ QJsonObject McpBridge::handleToolsCall(const QJsonValue& id, const QJsonObject&
else if (toolName == "tree.search") result = toolTreeSearch(args); else if (toolName == "tree.search") result = toolTreeSearch(args);
else return errReply(id, -32601, "Unknown tool: " + toolName); else return errReply(id, -32601, "Unknown tool: " + toolName);
m_mainWindow->m_statusLabel->setText(QStringLiteral("Ready")); m_mainWindow->clearMcpStatus();
return okReply(id, result); return okReply(id, result);
} }
@@ -526,6 +529,7 @@ QJsonObject McpBridge::toolProjectState(const QJsonObject& args) {
state["modified"] = doc->modified; state["modified"] = doc->modified;
state["undoAvailable"] = doc->undoStack.canUndo(); state["undoAvailable"] = doc->undoStack.canUndo();
state["redoAvailable"] = doc->undoStack.canRedo(); state["redoAvailable"] = doc->undoStack.canRedo();
state["statusText"] = m_mainWindow->m_appStatus;
// Filtered tree: only emit nodes up to maxDepth from the filter root // Filtered tree: only emit nodes up to maxDepth from the filter root
if (includeTree) { if (includeTree) {
@@ -1042,7 +1046,7 @@ QJsonObject McpBridge::toolStatusSet(const QJsonObject& args) {
} }
} }
if (target == "statusBar" || target == "both") { if (target == "statusBar" || target == "both") {
m_mainWindow->m_statusLabel->setText(text); m_mainWindow->setAppStatus(text);
} }
return makeTextResult("Status set: " + text); return makeTextResult("Status set: " + text);