gui: replace D-Bus with native 9P streaming
Kill the entire D-Bus rendering pipeline. The GUI now uses plan9port's 9p command as a subprocess for all server communication: - Chat streaming: persistent 'read chat' process, readyReadStandardOutput delivers tokens directly to QML (no polling, no signals, no offsets) - State: 'read statewait' process, restarts on each state change - Session list: 'read session/idx' one-shot - Submit/stop/kill: 'write prompt/ctl' one-shot The QML is stripped to fundamentals: session list + monospace streaming text (last 8KB tail) + input field. No ChatBlockModel, no ThemeManager, no OllieDBusClient, no syntax highlighting, no markdown rendering. This eliminates the multi-second GUI freezes caused by: - QTextDocument relayout on 200KB+ accumulated text - loadEarlier cascade creating hundreds of delegates - KSyntaxHighlighting::Repository blocking init - Offset mismatch triggering synchronous D-Bus calls Features will be re-added incrementally on this proven foundation. D-Bus retained only for plasmoid/tray/KRunner (desktop integration).
This commit is contained in:
parent
f00ed7738d
commit
f4161fcabc
|
|
@ -39,7 +39,7 @@ endif()
|
|||
|
||||
if(OLLIE_KF5)
|
||||
# --- Qt5 / KF5 ---
|
||||
find_package(Qt5 REQUIRED COMPONENTS Core DBus Widgets Quick Qml QuickControls2 Network)
|
||||
find_package(Qt5 REQUIRED COMPONENTS Core Concurrent DBus Widgets Quick Qml QuickControls2 Network)
|
||||
|
||||
# Override KDE_INSTALL_PLUGINDIR so kcoreaddons_add_plugin installs under
|
||||
# CMAKE_INSTALL_PREFIX (e.g. ~/.local/lib/qt5/plugins), not the system dir.
|
||||
|
|
@ -114,32 +114,21 @@ if(OLLIE_KF5)
|
|||
# --- Plasmoid Package ---
|
||||
plasma_install_package(plasmoid org.ollie.plasmoid)
|
||||
|
||||
# --- GUI App ---
|
||||
# --- GUI App (9P-based) ---
|
||||
configure_file(gui/main-kf5.qml ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/main.qml COPYONLY)
|
||||
configure_file(gui/emojis.js ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/emojis.js COPYONLY)
|
||||
configure_file(gui/qml.qrc ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/qml.qrc COPYONLY)
|
||||
qt5_add_resources(GUI_KF5_QRC ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/qml.qrc)
|
||||
add_executable(ollie-gui
|
||||
gui/main.cpp
|
||||
gui/thememanager.cpp
|
||||
gui/chatblockmodel.cpp
|
||||
plasmoid/plugin/olliedbusclient.cpp
|
||||
gui/ollie9pclient.cpp
|
||||
${GUI_KF5_QRC}
|
||||
)
|
||||
target_include_directories(ollie-gui PRIVATE
|
||||
${CMAKE_SOURCE_DIR}/plasmoid/plugin
|
||||
${CMAKE_SOURCE_DIR}/gui
|
||||
)
|
||||
target_include_directories(ollie-gui PRIVATE ${CMAKE_SOURCE_DIR}/gui)
|
||||
target_link_libraries(ollie-gui
|
||||
Qt5::Core
|
||||
Qt5::DBus
|
||||
Qt5::Widgets
|
||||
Qt5::Quick
|
||||
Qt5::Qml
|
||||
Qt5::QuickControls2
|
||||
Qt5::Network
|
||||
KF5::ConfigCore
|
||||
KF5::SyntaxHighlighting
|
||||
)
|
||||
|
||||
# --- Install ---
|
||||
|
|
@ -151,7 +140,7 @@ if(OLLIE_KF5)
|
|||
|
||||
else()
|
||||
# --- Qt6 / KF6 ---
|
||||
find_package(Qt6 REQUIRED COMPONENTS Core DBus Widgets Quick QuickControls2 Multimedia Network)
|
||||
find_package(Qt6 REQUIRED COMPONENTS Core Concurrent DBus Widgets Quick QuickControls2 Multimedia Network)
|
||||
find_package(KF6StatusNotifierItem REQUIRED)
|
||||
find_package(KF6 REQUIRED COMPONENTS Runner Notifications CoreAddons Config)
|
||||
find_package(Plasma REQUIRED)
|
||||
|
|
@ -236,6 +225,7 @@ else()
|
|||
)
|
||||
target_link_libraries(ollie-gui
|
||||
Qt6::Core
|
||||
Qt6::Concurrent
|
||||
Qt6::DBus
|
||||
Qt6::Widgets
|
||||
Qt6::Quick
|
||||
|
|
|
|||
|
|
@ -61,11 +61,11 @@ Item {
|
|||
// Listen for D-Bus signals filtered to our session
|
||||
Connections {
|
||||
target: dbus
|
||||
function onChatAppended(sid, offset, text) {
|
||||
function onChatAppended(sid, offset, text, endOffset) {
|
||||
if (sid !== paneRoot.sessionId) return
|
||||
if (offset === paneRoot.chatOffset) {
|
||||
paneModel.rawText = paneModel.rawText + text
|
||||
paneRoot.chatOffset += text.length
|
||||
paneModel.appendRawText(text)
|
||||
paneRoot.chatOffset = endOffset
|
||||
} else {
|
||||
// Offset mismatch - refetch
|
||||
let chat = dbus.getChat(paneRoot.sessionId, 0)
|
||||
|
|
|
|||
|
|
@ -26,19 +26,36 @@
|
|||
#include <QTemporaryFile>
|
||||
#include <QProcess>
|
||||
#include <QStandardPaths>
|
||||
#include <QtConcurrent>
|
||||
#include <mutex>
|
||||
#include <QElapsedTimer>
|
||||
#include <QDebug>
|
||||
|
||||
static KSyntaxHighlighting::Repository *s_repo = nullptr;
|
||||
static std::atomic<bool> s_repoReady{false};
|
||||
|
||||
static KSyntaxHighlighting::Repository &repo()
|
||||
static void initRepo()
|
||||
{
|
||||
if (!s_repo)
|
||||
s_repo = new KSyntaxHighlighting::Repository();
|
||||
return *s_repo;
|
||||
s_repo = new KSyntaxHighlighting::Repository();
|
||||
s_repoReady.store(true, std::memory_order_release);
|
||||
}
|
||||
|
||||
static KSyntaxHighlighting::Repository *repoIfReady()
|
||||
{
|
||||
if (s_repoReady.load(std::memory_order_acquire))
|
||||
return s_repo;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
ChatBlockModel::ChatBlockModel(QObject *parent)
|
||||
: QAbstractListModel(parent)
|
||||
{
|
||||
connect(&m_parseWatcher, &QFutureWatcher<QList<ChatBlock>>::finished,
|
||||
this, &ChatBlockModel::onParseFinished);
|
||||
|
||||
// Eagerly load syntax highlighting definitions on a background thread
|
||||
// so the first highlightedHtml() call doesn't block the UI for ~4 seconds.
|
||||
QtConcurrent::run([]() { initRepo(); });
|
||||
}
|
||||
|
||||
int ChatBlockModel::rowCount(const QModelIndex &parent) const
|
||||
|
|
@ -76,8 +93,12 @@ QVariant ChatBlockModel::data(const QModelIndex &index, int role) const
|
|||
return block.responseId;
|
||||
case SubBlocksRole:
|
||||
if (block.cachedContent != block.content) {
|
||||
QElapsedTimer st; st.start();
|
||||
block.cachedSubBlocks = parseSubBlocks(block.content);
|
||||
block.cachedContent = block.content;
|
||||
qint64 elapsed = st.elapsed();
|
||||
if (elapsed > 5)
|
||||
qDebug() << "parseSubBlocks: row=" << index.row() << "type=" << (int)block.type << "contentLen=" << block.content.size() << "took" << elapsed << "ms";
|
||||
}
|
||||
return block.cachedSubBlocks;
|
||||
case FormattedContentRole: {
|
||||
|
|
@ -120,12 +141,37 @@ void ChatBlockModel::setRawText(const QString &text)
|
|||
{
|
||||
if (text == m_rawText) return;
|
||||
m_rawText = text;
|
||||
reparse();
|
||||
if (!m_filterText.isEmpty())
|
||||
applyFilter();
|
||||
scheduleReparse();
|
||||
emit rawTextChanged();
|
||||
}
|
||||
|
||||
void ChatBlockModel::appendRawText(const QString &text)
|
||||
{
|
||||
if (text.isEmpty()) return;
|
||||
m_rawText += text;
|
||||
|
||||
// Fast path: if we have blocks and the last one is a "tool" block,
|
||||
// just extend its content directly without full reparse.
|
||||
if (!m_allBlocks.isEmpty() && m_allBlocks.last().type == ChatBlock::Tool) {
|
||||
m_allBlocks.last().content += text;
|
||||
// Update visible window's last block too
|
||||
if (!m_blocks.isEmpty() && m_blocks.last().type == ChatBlock::Tool) {
|
||||
m_blocks.last().content += text;
|
||||
m_blocks.last().cachedContent.clear(); // invalidate subBlocks cache
|
||||
// Defer dataChanged to avoid blocking the signal handler
|
||||
QTimer::singleShot(0, this, [this]() {
|
||||
if (!m_blocks.isEmpty()) {
|
||||
QModelIndex idx = index(m_blocks.size() - 1);
|
||||
emit dataChanged(idx, idx);
|
||||
}
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleReparse();
|
||||
}
|
||||
|
||||
void ChatBlockModel::setFilterText(const QString &text)
|
||||
{
|
||||
if (text == m_filterText) return;
|
||||
|
|
@ -151,13 +197,51 @@ void ChatBlockModel::applyFilter()
|
|||
endResetModel();
|
||||
}
|
||||
|
||||
static constexpr int INITIAL_BLOCKS = 20;
|
||||
static constexpr int INITIAL_BLOCKS = 8;
|
||||
static constexpr int LOAD_MORE_COUNT = 30;
|
||||
|
||||
void ChatBlockModel::reparse()
|
||||
void ChatBlockModel::scheduleReparse()
|
||||
{
|
||||
m_allBlocks = parseBlocks(m_rawText);
|
||||
if (m_parseRunning) {
|
||||
// A parse is already in flight — just mark dirty so we re-run when it finishes.
|
||||
m_parseDirty = true;
|
||||
return;
|
||||
}
|
||||
|
||||
m_parseRunning = true;
|
||||
m_parseDirty = false;
|
||||
|
||||
// Capture the current text for the worker thread.
|
||||
QString text = m_rawText;
|
||||
m_parseWatcher.setFuture(QtConcurrent::run([text]() {
|
||||
QElapsedTimer pt; pt.start();
|
||||
auto result = ChatBlockModel::parseBlocks(text);
|
||||
qint64 elapsed = pt.elapsed();
|
||||
if (elapsed > 50)
|
||||
qDebug() << "parseBlocks (worker): textLen=" << text.size() << "blocks=" << result.size() << "took" << elapsed << "ms";
|
||||
return result;
|
||||
}));
|
||||
}
|
||||
|
||||
void ChatBlockModel::onParseFinished()
|
||||
{
|
||||
QElapsedTimer t; t.start();
|
||||
m_parseRunning = false;
|
||||
|
||||
m_allBlocks = m_parseWatcher.result();
|
||||
qint64 e1 = t.elapsed();
|
||||
updateVisibleWindow();
|
||||
qint64 e2 = t.elapsed();
|
||||
|
||||
if (!m_filterText.isEmpty())
|
||||
applyFilter();
|
||||
|
||||
qDebug() << "onParseFinished: getResult=" << e1 << "ms, updateVisible=" << (e2-e1) << "ms, total=" << e2 << "ms, blocks=" << m_allBlocks.size();
|
||||
|
||||
// If rawText changed while we were parsing, re-schedule immediately.
|
||||
if (m_parseDirty) {
|
||||
scheduleReparse();
|
||||
}
|
||||
}
|
||||
|
||||
void ChatBlockModel::updateVisibleWindow()
|
||||
|
|
@ -235,7 +319,7 @@ bool ChatBlockModel::loadEarlier()
|
|||
return true;
|
||||
}
|
||||
|
||||
QList<ChatBlock> ChatBlockModel::parseBlocks(const QString &text) const
|
||||
QList<ChatBlock> ChatBlockModel::parseBlocks(const QString &text)
|
||||
{
|
||||
QList<ChatBlock> blocks;
|
||||
if (text.isEmpty()) return blocks;
|
||||
|
|
@ -503,9 +587,20 @@ QString ChatBlockModel::highlightedHtml(const QString &code, const QString &lang
|
|||
if (cached != m_highlightCache.end())
|
||||
return *cached;
|
||||
|
||||
auto def = repo().definitionForName(language);
|
||||
QElapsedTimer t; t.start();
|
||||
|
||||
auto *repository = repoIfReady();
|
||||
if (!repository) {
|
||||
// Repo still loading — return plain text, will be re-highlighted later
|
||||
QString plain = QStringLiteral("<pre style=\"margin:0;white-space:pre-wrap;\">")
|
||||
+ code.toHtmlEscaped() + QStringLiteral("</pre>");
|
||||
// Don't cache — will re-render when repo is ready
|
||||
return plain;
|
||||
}
|
||||
|
||||
auto def = repository->definitionForName(language);
|
||||
if (!def.isValid()) {
|
||||
def = repo().definitionForFileName("file." + language);
|
||||
def = repository->definitionForFileName("file." + language);
|
||||
}
|
||||
if (!def.isValid()) {
|
||||
QString plain = QStringLiteral("<pre style=\"margin:0;white-space:pre-wrap;\">")
|
||||
|
|
@ -516,8 +611,8 @@ QString ChatBlockModel::highlightedHtml(const QString &code, const QString &lang
|
|||
|
||||
bool dark = QGuiApplication::palette().color(QPalette::Window).lightnessF() < 0.5;
|
||||
auto theme = dark
|
||||
? repo().defaultTheme(KSyntaxHighlighting::Repository::DarkTheme)
|
||||
: repo().defaultTheme(KSyntaxHighlighting::Repository::LightTheme);
|
||||
? repository->defaultTheme(KSyntaxHighlighting::Repository::DarkTheme)
|
||||
: repository->defaultTheme(KSyntaxHighlighting::Repository::LightTheme);
|
||||
|
||||
HtmlBuilder builder;
|
||||
builder.setDefinition(def);
|
||||
|
|
@ -534,6 +629,7 @@ QString ChatBlockModel::highlightedHtml(const QString &code, const QString &lang
|
|||
result += builder.html + QStringLiteral("</pre>");
|
||||
|
||||
m_highlightCache.insert(cacheKey, result);
|
||||
qDebug() << "highlightedHtml: CACHE MISS" << language << "len=" << code.size() << "took" << t.elapsed() << "ms";
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -830,6 +926,7 @@ static bool fetchFromMermaidServer(const QString &source, const QString &outPath
|
|||
|
||||
QString ChatBlockModel::diagramUrl(const QString &source, const QString &language) const
|
||||
{
|
||||
QElapsedTimer t; t.start();
|
||||
if (source.isEmpty()) return QString();
|
||||
|
||||
QString lang = language.toLower().trimmed();
|
||||
|
|
@ -855,6 +952,8 @@ QString ChatBlockModel::diagramUrl(const QString &source, const QString &languag
|
|||
if (QFileInfo::exists(outPath))
|
||||
return QUrl::fromLocalFile(outPath).toString();
|
||||
|
||||
qDebug() << "diagramUrl: CACHE MISS for" << lang << "hash=" << hash << "- fetching...";
|
||||
|
||||
if (lang == QStringLiteral("plantuml")) {
|
||||
// Try local PlantUML server first (localhost:8080)
|
||||
if (fetchFromPlantUmlServer(source, outPath))
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
#include <QAbstractListModel>
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
#include <QFutureWatcher>
|
||||
|
||||
// A single block in the chat output
|
||||
struct ChatBlock {
|
||||
|
|
@ -65,6 +66,8 @@ public:
|
|||
QString rawText() const { return m_rawText; }
|
||||
void setRawText(const QString &text);
|
||||
|
||||
Q_INVOKABLE void appendRawText(const QString &text);
|
||||
|
||||
QString filterText() const { return m_filterText; }
|
||||
void setFilterText(const QString &text);
|
||||
bool filtering() const { return !m_filterText.isEmpty(); }
|
||||
|
|
@ -84,11 +87,14 @@ signals:
|
|||
void rawTextChanged();
|
||||
void filterTextChanged();
|
||||
|
||||
private slots:
|
||||
void onParseFinished();
|
||||
|
||||
private:
|
||||
void reparse();
|
||||
void scheduleReparse();
|
||||
void updateVisibleWindow();
|
||||
void applyFilter();
|
||||
QList<ChatBlock> parseBlocks(const QString &text) const;
|
||||
static QList<ChatBlock> parseBlocks(const QString &text);
|
||||
QVariantList parseSubBlocks(const QString &content) const;
|
||||
|
||||
QString m_rawText;
|
||||
|
|
@ -98,6 +104,11 @@ private:
|
|||
QList<ChatBlock> m_filteredBlocks; // filtered subset when filterText is active
|
||||
int m_visibleStart = 0; // index into m_allBlocks where visible window starts
|
||||
mutable QHash<QString, QString> m_highlightCache;
|
||||
|
||||
// Async reparse state
|
||||
QFutureWatcher<QList<ChatBlock>> m_parseWatcher;
|
||||
bool m_parseDirty = false; // rawText changed while parse was in flight
|
||||
bool m_parseRunning = false; // a parse is currently executing off-thread
|
||||
};
|
||||
|
||||
#endif // CHAT_BLOCK_MODEL_H
|
||||
|
|
|
|||
2533
gui/main-kf5.qml
2533
gui/main-kf5.qml
File diff suppressed because it is too large
Load Diff
195
gui/main.cpp
195
gui/main.cpp
|
|
@ -1,218 +1,39 @@
|
|||
// ollie-gui-9p: minimal Qt GUI using 9P for all communication.
|
||||
// No D-Bus dependency. Uses ollie-9p subprocess for 9P transport.
|
||||
#include <QApplication>
|
||||
#include <QQmlApplicationEngine>
|
||||
#include <QQmlContext>
|
||||
#include <QQuickStyle>
|
||||
#include <QIcon>
|
||||
#include <QPixmap>
|
||||
#include <QPainter>
|
||||
#include <QFont>
|
||||
#include <QFontDatabase>
|
||||
#include <QSystemTrayIcon>
|
||||
#include <QMenu>
|
||||
#include <QAction>
|
||||
#include <QWindow>
|
||||
#include <QDBusConnection>
|
||||
#include <QDBusInterface>
|
||||
|
||||
#include "olliedbusclient.h"
|
||||
#include "thememanager.h"
|
||||
#include "chatblockmodel.h"
|
||||
|
||||
class GuiActivator : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_CLASSINFO("D-Bus Interface", "org.ollie.Gui")
|
||||
|
||||
public:
|
||||
explicit GuiActivator(QQmlApplicationEngine *engine, QObject *parent = nullptr)
|
||||
: QObject(parent)
|
||||
, m_engine(engine)
|
||||
, m_windowCount(0)
|
||||
{
|
||||
}
|
||||
|
||||
void setWindowUrl(const QUrl &url) { m_url = url; }
|
||||
int windowCount() const { return m_windowCount; }
|
||||
|
||||
public slots:
|
||||
void windowOpened() { ++m_windowCount; }
|
||||
void windowClosed() { if (m_windowCount > 0) --m_windowCount; }
|
||||
void Activate()
|
||||
{
|
||||
const auto roots = m_engine->rootObjects();
|
||||
if (roots.isEmpty())
|
||||
return;
|
||||
auto *window = qobject_cast<QWindow *>(roots.last());
|
||||
if (!window)
|
||||
return;
|
||||
window->show();
|
||||
window->raise();
|
||||
window->requestActivate();
|
||||
}
|
||||
|
||||
void NewWindow()
|
||||
{
|
||||
if (m_url.isEmpty())
|
||||
return;
|
||||
m_engine->load(m_url);
|
||||
}
|
||||
|
||||
void SelectSession(const QString &id)
|
||||
{
|
||||
auto *dbus = qobject_cast<OllieDBusClient *>(
|
||||
m_engine->rootContext()->contextProperty("dbus").value<QObject *>());
|
||||
if (dbus)
|
||||
dbus->setActiveSessionId(id);
|
||||
Activate();
|
||||
}
|
||||
|
||||
private:
|
||||
QQmlApplicationEngine *m_engine;
|
||||
QUrl m_url;
|
||||
int m_windowCount;
|
||||
};
|
||||
|
||||
static QIcon makeBrainIcon()
|
||||
{
|
||||
// Use the theme icon if available (from hicolor scalable/apps/ollie.svg)
|
||||
if (QIcon::hasThemeIcon("ollie"))
|
||||
return QIcon::fromTheme("ollie");
|
||||
|
||||
// Fallback: render FA6 brain glyph from installed font
|
||||
QFont fa("Font Awesome 6 Free Solid");
|
||||
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
|
||||
if (!QFontDatabase::hasFamily("Font Awesome 6 Free Solid")) {
|
||||
#else
|
||||
if (!QFontDatabase().families().contains("Font Awesome 6 Free Solid")) {
|
||||
#endif
|
||||
fa = QFont("Font Awesome 6 Free");
|
||||
fa.setWeight(QFont::Black);
|
||||
}
|
||||
|
||||
QIcon icon;
|
||||
for (int size : {16, 22, 32, 48, 64, 128}) {
|
||||
QPixmap pix(size, size);
|
||||
pix.fill(Qt::transparent);
|
||||
QPainter p(&pix);
|
||||
fa.setPixelSize(size - 2);
|
||||
p.setFont(fa);
|
||||
p.setPen(QColor(0xe9, 0x1e, 0x63));
|
||||
p.drawText(pix.rect(), Qt::AlignCenter, QString(QChar(0xf5dc)));
|
||||
p.end();
|
||||
icon.addPixmap(pix);
|
||||
}
|
||||
return icon;
|
||||
}
|
||||
#include "ollie9pclient.h"
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
// HiDPI support
|
||||
#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
|
||||
QApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
|
||||
QApplication::setAttribute(Qt::AA_UseHighDpiPixmaps);
|
||||
#endif
|
||||
QApplication::setHighDpiScaleFactorRoundingPolicy(
|
||||
Qt::HighDpiScaleFactorRoundingPolicy::PassThrough);
|
||||
|
||||
QApplication app(argc, argv);
|
||||
app.setApplicationName("ollie-gui");
|
||||
app.setDesktopFileName("org.ollie.gui");
|
||||
app.setOrganizationName("ollie");
|
||||
app.setWindowIcon(makeBrainIcon());
|
||||
|
||||
// Don't force org.kde.desktop style — it ignores explicit palette overrides.
|
||||
// Let the platform theme provide the style, or fall back to Basic which
|
||||
// properly respects the QML palette {} block.
|
||||
if (qEnvironmentVariableIsEmpty("QT_QUICK_CONTROLS_STYLE"))
|
||||
QQuickStyle::setStyle("Basic");
|
||||
|
||||
// Theme manager — reads KDE color schemes, applies palette
|
||||
auto *theme = new ThemeManager(&app);
|
||||
|
||||
QQmlApplicationEngine engine;
|
||||
|
||||
// Only one GUI may run per desktop session. A second invocation asks the
|
||||
// existing process to show and activate its window, then exits.
|
||||
constexpr auto guiService = "org.ollie.Gui";
|
||||
constexpr auto guiPath = "/org/ollie/Gui";
|
||||
GuiActivator activator(&engine, &app);
|
||||
QDBusConnection sessionBus = QDBusConnection::sessionBus();
|
||||
sessionBus.registerObject(guiPath, &activator, QDBusConnection::ExportAllSlots);
|
||||
if (!sessionBus.registerService(guiService)) {
|
||||
QDBusInterface runningGui(guiService, guiPath, guiService, sessionBus);
|
||||
runningGui.call(QDBus::NoBlock, QStringLiteral("NewWindow"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Register types for QML instantiation
|
||||
qmlRegisterType<ChatBlockModel>("org.ollie.gui", 1, 0, "ChatBlockModel");
|
||||
|
||||
// Register OllieDBusClient as a singleton
|
||||
auto *dbus = new OllieDBusClient(&app);
|
||||
auto *chatModel = new ChatBlockModel(&app);
|
||||
engine.rootContext()->setContextProperty("dbus", dbus);
|
||||
engine.rootContext()->setContextProperty("theme", theme);
|
||||
engine.rootContext()->setContextProperty("chatModel", chatModel);
|
||||
engine.rootContext()->setContextProperty("activator", &activator);
|
||||
engine.rootContext()->setContextProperty("systemTrayAvailable",
|
||||
QSystemTrayIcon::isSystemTrayAvailable());
|
||||
auto *ollie = new Ollie9pClient(&app);
|
||||
engine.rootContext()->setContextProperty("ollie", ollie);
|
||||
|
||||
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
|
||||
const QUrl url(QStringLiteral("qrc:/gui/main.qml"));
|
||||
const QUrl url(QStringLiteral("qrc:/gui/main-9p.qml"));
|
||||
#else
|
||||
const QUrl url(QStringLiteral("qrc:/main.qml"));
|
||||
#endif
|
||||
|
||||
QObject::connect(&engine, &QQmlApplicationEngine::objectCreated,
|
||||
&app, [url](QObject *obj, const QUrl &objUrl) {
|
||||
if (!obj && url == objUrl)
|
||||
QCoreApplication::exit(-1);
|
||||
}, Qt::QueuedConnection);
|
||||
|
||||
activator.setWindowUrl(url);
|
||||
engine.load(url);
|
||||
|
||||
// --- System tray icon (shown when close-to-tray is enabled) ---
|
||||
auto *trayIcon = new QSystemTrayIcon(&app);
|
||||
trayIcon->setIcon(app.windowIcon());
|
||||
trayIcon->setToolTip("Ollie");
|
||||
|
||||
auto *trayMenu = new QMenu();
|
||||
auto *showAction = trayMenu->addAction("Show");
|
||||
trayMenu->addSeparator();
|
||||
auto *quitAction = trayMenu->addAction("Quit");
|
||||
trayIcon->setContextMenu(trayMenu);
|
||||
|
||||
// Toggle window visibility on tray icon activation
|
||||
QObject::connect(trayIcon, &QSystemTrayIcon::activated,
|
||||
[&engine, &activator](QSystemTrayIcon::ActivationReason reason) {
|
||||
if (reason != QSystemTrayIcon::Trigger)
|
||||
return;
|
||||
auto roots = engine.rootObjects();
|
||||
if (roots.isEmpty()) return;
|
||||
auto *window = qobject_cast<QWindow *>(roots.last());
|
||||
if (!window) return;
|
||||
if (window->isVisible()) {
|
||||
window->hide();
|
||||
} else {
|
||||
activator.Activate();
|
||||
}
|
||||
});
|
||||
|
||||
QObject::connect(showAction, &QAction::triggered, [&activator]() {
|
||||
activator.Activate();
|
||||
});
|
||||
|
||||
QObject::connect(quitAction, &QAction::triggered, &app, &QApplication::quit);
|
||||
|
||||
// Show/hide tray icon based on setting
|
||||
auto updateTrayVisibility = [trayIcon, theme]() {
|
||||
trayIcon->setVisible(theme->closeToTray());
|
||||
};
|
||||
updateTrayVisibility();
|
||||
QObject::connect(theme, &ThemeManager::closeToTrayChanged, updateTrayVisibility);
|
||||
if (engine.rootObjects().isEmpty())
|
||||
return -1;
|
||||
|
||||
return app.exec();
|
||||
}
|
||||
|
||||
#include "main.moc"
|
||||
|
|
|
|||
954
gui/main.qml
954
gui/main.qml
|
|
@ -1,857 +1,173 @@
|
|||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import "emojis.js" as Emojis
|
||||
import QtQuick 2.15
|
||||
import QtQuick.Controls 2.15
|
||||
import QtQuick.Layouts 1.15
|
||||
|
||||
ApplicationWindow {
|
||||
id: root
|
||||
title: dbus.activeCwd ? "Ollie — " + dbus.activeCwd : "Ollie"
|
||||
width: 900
|
||||
height: 600
|
||||
visible: true
|
||||
width: 1000
|
||||
height: 600
|
||||
title: "Ollie"
|
||||
|
||||
Component.onCompleted: activator.windowOpened()
|
||||
// Chat tail buffer — only keep last 8KB for display
|
||||
property string chatTail: ""
|
||||
readonly property int maxTail: 8192
|
||||
|
||||
onClosing: function(close) {
|
||||
if (theme.closeToTray && systemTrayAvailable) {
|
||||
close.accepted = false
|
||||
root.hide()
|
||||
}
|
||||
Component.onCompleted: {
|
||||
// Load initial log content
|
||||
var log = ollie.readLog()
|
||||
if (log.length > maxTail)
|
||||
log = log.slice(-maxTail)
|
||||
chatTail = log
|
||||
}
|
||||
|
||||
Component.onDestruction: {
|
||||
if (typeof activator !== 'undefined' && activator) {
|
||||
activator.windowClosed()
|
||||
}
|
||||
}
|
||||
|
||||
color: palette.window
|
||||
font.family: theme.fontFamily || Qt.application.font.family
|
||||
font.pointSize: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize
|
||||
|
||||
palette {
|
||||
window: theme.windowColor
|
||||
windowText: theme.windowTextColor
|
||||
base: theme.baseColor
|
||||
alternateBase: theme.alternateBaseColor
|
||||
text: theme.textColor
|
||||
button: theme.buttonColor
|
||||
buttonText: theme.buttonTextColor
|
||||
highlight: theme.highlightColor
|
||||
highlightedText: theme.highlightedTextColor
|
||||
toolTipBase: theme.tooltipBaseColor
|
||||
toolTipText: theme.tooltipTextColor
|
||||
link: theme.linkColor
|
||||
mid: theme.midColor
|
||||
dark: theme.darkColor
|
||||
light: theme.lightColor
|
||||
shadow: theme.shadowColor
|
||||
placeholderText: theme.placeholderTextColor
|
||||
}
|
||||
|
||||
// --- Settings Dialog ---
|
||||
Dialog {
|
||||
id: settingsDialog
|
||||
title: "Settings"
|
||||
anchors.centerIn: parent
|
||||
width: 380
|
||||
modal: true
|
||||
standardButtons: Dialog.Close
|
||||
|
||||
contentItem: ColumnLayout {
|
||||
spacing: 16
|
||||
Layout.margins: 16
|
||||
|
||||
Label {
|
||||
text: "Color Scheme"
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
ComboBox {
|
||||
id: schemeCombo
|
||||
Layout.fillWidth: true
|
||||
model: ["System"].concat(theme.availableSchemes)
|
||||
currentIndex: {
|
||||
if (theme.mode === "system") return 0
|
||||
let idx = theme.availableSchemes.indexOf(theme.mode)
|
||||
return idx >= 0 ? idx + 1 : 0
|
||||
}
|
||||
onActivated: {
|
||||
if (currentIndex === 0) {
|
||||
theme.mode = "system"
|
||||
} else {
|
||||
theme.mode = theme.availableSchemes[currentIndex - 1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
text: theme.isDark ? "Current: dark" : "Current: light"
|
||||
opacity: 0.5
|
||||
font.pixelSize: Qt.application.font.pixelSize * 0.85
|
||||
}
|
||||
|
||||
// --- Font section ---
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: 1
|
||||
color: palette.mid
|
||||
Layout.topMargin: 8
|
||||
}
|
||||
|
||||
Label {
|
||||
text: "Font"
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
GridLayout {
|
||||
columns: 2
|
||||
columnSpacing: 12
|
||||
rowSpacing: 8
|
||||
Layout.fillWidth: true
|
||||
|
||||
Label { text: "Family:" }
|
||||
ComboBox {
|
||||
id: fontFamilyCombo
|
||||
Layout.fillWidth: true
|
||||
model: theme.availableFamilies
|
||||
editable: true
|
||||
currentIndex: {
|
||||
let fam = theme.fontFamily || Qt.application.font.family
|
||||
let idx = model.indexOf(fam)
|
||||
return idx >= 0 ? idx : 0
|
||||
}
|
||||
onActivated: theme.fontFamily = currentText
|
||||
onAccepted: theme.fontFamily = editText
|
||||
}
|
||||
|
||||
Label { text: "Style:" }
|
||||
ComboBox {
|
||||
id: fontStyleCombo
|
||||
Layout.fillWidth: true
|
||||
model: theme.availableStyles
|
||||
currentIndex: {
|
||||
let st = theme.fontStyle
|
||||
let idx = model.indexOf(st)
|
||||
return idx >= 0 ? idx : 0
|
||||
}
|
||||
onActivated: theme.fontStyle = currentText
|
||||
}
|
||||
|
||||
Label { text: "Size:" }
|
||||
SpinBox {
|
||||
id: fontSizeSpin
|
||||
from: 6
|
||||
to: 72
|
||||
value: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize
|
||||
onValueModified: theme.fontSize = value
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
text: "Reset to system font"
|
||||
onClicked: theme.resetFont()
|
||||
}
|
||||
|
||||
// --- Close to tray section ---
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: 1
|
||||
color: palette.mid
|
||||
Layout.topMargin: 8
|
||||
}
|
||||
|
||||
Label {
|
||||
text: "System Tray"
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
CheckBox {
|
||||
text: "Close to tray instead of quitting"
|
||||
checked: theme.closeToTray
|
||||
onToggled: theme.closeToTray = checked
|
||||
enabled: systemTrayAvailable
|
||||
}
|
||||
|
||||
Label {
|
||||
text: systemTrayAvailable
|
||||
? "Window will hide to the system tray when closed."
|
||||
: "System tray is not available."
|
||||
opacity: 0.5
|
||||
font.pixelSize: Qt.application.font.pixelSize * 0.85
|
||||
wrapMode: Text.Wrap
|
||||
Layout.fillWidth: true
|
||||
visible: theme.closeToTray || !systemTrayAvailable
|
||||
Connections {
|
||||
target: ollie
|
||||
function onChatReceived(text) {
|
||||
chatTail += text
|
||||
if (chatTail.length > maxTail * 2) {
|
||||
chatTail = chatTail.slice(-maxTail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openNewSessionDialog() {
|
||||
nameInput.text = ""
|
||||
newSessionDialog.backendModel = dbus.listBackends()
|
||||
newSessionDialog.agentModel = dbus.listAgents()
|
||||
newSessionDialog.cwdField.text = dbus.activeCwd || ""
|
||||
newSessionDialog.backendCombo.currentIndex = 0
|
||||
newSessionDialog.agentCombo.currentIndex = 0
|
||||
newSessionDialog.modelCombo.editText = ""
|
||||
// Populate model list for the default (first) backend
|
||||
if (newSessionDialog.backendCombo.currentText)
|
||||
newSessionDialog.modelCombo.model = dbus.listModelsByBackend(newSessionDialog.backendCombo.currentText)
|
||||
newSessionDialog.open()
|
||||
}
|
||||
|
||||
Dialog {
|
||||
id: newSessionDialog
|
||||
title: "New Session"
|
||||
modal: true
|
||||
anchors.centerIn: parent
|
||||
width: 450
|
||||
standardButtons: Dialog.Ok | Dialog.Cancel
|
||||
|
||||
property alias cwdField: cwdInput
|
||||
property alias backendCombo: backendSelect
|
||||
property alias agentCombo: agentSelect
|
||||
property alias modelCombo: modelSelect
|
||||
property var backendModel: []
|
||||
property var agentModel: []
|
||||
|
||||
onAccepted: {
|
||||
let cwd = cwdInput.text.trim()
|
||||
if (cwd === "") cwd = dbus.activeCwd || ""
|
||||
let backend = backendSelect.currentText
|
||||
let model = modelSelect.editText.trim() || modelSelect.currentText
|
||||
let agent = agentSelect.currentText
|
||||
let name = nameInput.text.trim()
|
||||
dbus.createSession(cwd, backend, model, name, agent)
|
||||
}
|
||||
|
||||
GridLayout {
|
||||
columns: 2
|
||||
width: parent.width
|
||||
columnSpacing: 10
|
||||
rowSpacing: 8
|
||||
|
||||
Label { text: "Name:" }
|
||||
TextField {
|
||||
id: nameInput
|
||||
Layout.fillWidth: true
|
||||
placeholderText: "(auto-generated)"
|
||||
}
|
||||
|
||||
Label { text: "Working directory:" }
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
TextField {
|
||||
id: cwdInput
|
||||
Layout.fillWidth: true
|
||||
placeholderText: "/home/user/project"
|
||||
}
|
||||
Button {
|
||||
text: "Browse"
|
||||
onClicked: {
|
||||
let path = theme.chooseFolder("Choose working directory", cwdInput.text)
|
||||
if (path !== "")
|
||||
cwdInput.text = path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Label { text: "Backend:" }
|
||||
ComboBox {
|
||||
id: backendSelect
|
||||
Layout.fillWidth: true
|
||||
model: newSessionDialog.backendModel
|
||||
onCurrentTextChanged: {
|
||||
if (currentText)
|
||||
modelSelect.model = dbus.listModelsByBackend(currentText)
|
||||
}
|
||||
}
|
||||
|
||||
Label { text: "Agent:" }
|
||||
ComboBox {
|
||||
id: agentSelect
|
||||
Layout.fillWidth: true
|
||||
model: newSessionDialog.agentModel
|
||||
currentIndex: 0
|
||||
}
|
||||
|
||||
Label { text: "Model:" }
|
||||
ComboBox {
|
||||
id: modelSelect
|
||||
Layout.fillWidth: true
|
||||
editable: true
|
||||
editText: ""
|
||||
}
|
||||
function onActiveSessionIdChanged() {
|
||||
chatTail = ""
|
||||
var log = ollie.readLog()
|
||||
if (log.length > maxTail)
|
||||
log = log.slice(-maxTail)
|
||||
chatTail = log
|
||||
}
|
||||
}
|
||||
|
||||
function openCwdDialog() {
|
||||
let path = theme.chooseFolder("Change working directory",
|
||||
dbus.activeCwd || "")
|
||||
if (path !== "")
|
||||
dbus.setConfig(dbus.activeSessionId, "cwd", path)
|
||||
}
|
||||
|
||||
// --- Session Graph Window ---
|
||||
Window {
|
||||
id: graphWindow
|
||||
title: "Ollie — Session Topology"
|
||||
width: 700
|
||||
height: 500
|
||||
visible: false
|
||||
color: palette.window
|
||||
|
||||
Component.onCompleted: activator.windowOpened()
|
||||
Component.onDestruction: {
|
||||
if (typeof activator !== 'undefined' && activator) {
|
||||
activator.windowClosed()
|
||||
}
|
||||
}
|
||||
|
||||
SessionGraph {
|
||||
id: graphView
|
||||
anchors.fill: parent
|
||||
sessions: dbus.sessions
|
||||
peerListFn: function(sid) { return dbus.peerList(sid) }
|
||||
function onStreamingDone() {
|
||||
// Session was killed
|
||||
chatTail += "\n[session ended]\n"
|
||||
}
|
||||
}
|
||||
|
||||
SplitView {
|
||||
anchors.fill: parent
|
||||
orientation: Qt.Horizontal
|
||||
|
||||
// --- Session sidebar ---
|
||||
Pane {
|
||||
SplitView.preferredWidth: 220
|
||||
SplitView.minimumWidth: 160
|
||||
padding: 0
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
|
||||
// Toolbar
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: 6
|
||||
|
||||
Label {
|
||||
text: "Sessions"
|
||||
font.bold: true
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
icon.name: "list-add"
|
||||
onClicked: root.openNewSessionDialog()
|
||||
ToolTip.text: "New session"
|
||||
ToolTip.visible: hovered
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
icon.name: "configure"
|
||||
onClicked: settingsDialog.open()
|
||||
ToolTip.text: "Settings"
|
||||
ToolTip.visible: hovered
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
icon.name: "network-workgroup"
|
||||
onClicked: graphWindow.visible = !graphWindow.visible
|
||||
ToolTip.text: "Session topology"
|
||||
ToolTip.visible: hovered
|
||||
}
|
||||
}
|
||||
|
||||
// Session list
|
||||
ListView {
|
||||
id: sessionList
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
clip: true
|
||||
model: dbus.sessions
|
||||
|
||||
delegate: ItemDelegate {
|
||||
id: sessionDelegate
|
||||
width: sessionList.width
|
||||
highlighted: modelData.id === dbus.activeSessionId
|
||||
|
||||
property bool renaming: false
|
||||
property string sessionState: modelData.state
|
||||
|
||||
Connections {
|
||||
target: dbus
|
||||
function onSessionStateUpdated(sessionId, newState) {
|
||||
if (sessionId === modelData.id)
|
||||
sessionDelegate.sessionState = newState
|
||||
}
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
color: sessionDelegate.highlighted
|
||||
? palette.highlight : "transparent"
|
||||
radius: 4
|
||||
}
|
||||
|
||||
contentItem: RowLayout {
|
||||
spacing: 6
|
||||
|
||||
Rectangle {
|
||||
width: 8
|
||||
height: 8
|
||||
radius: 4
|
||||
color: {
|
||||
let state = sessionDelegate.sessionState
|
||||
if (state === "idle") return "#4caf50"
|
||||
if (state.startsWith("calling")) return "#ff9800"
|
||||
return "#2196f3"
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
id: sessionLabel
|
||||
text: modelData.id
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
font.family: "monospace"
|
||||
color: sessionDelegate.highlighted
|
||||
? palette.highlightedText : palette.text
|
||||
visible: !sessionDelegate.renaming
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: renameField
|
||||
Layout.fillWidth: true
|
||||
font.family: "monospace"
|
||||
visible: sessionDelegate.renaming
|
||||
onAccepted: {
|
||||
let newName = text.trim()
|
||||
if (newName !== "" && newName !== modelData.id) {
|
||||
dbus.renameSession(modelData.id, newName)
|
||||
}
|
||||
sessionDelegate.renaming = false
|
||||
}
|
||||
Keys.onEscapePressed: sessionDelegate.renaming = false
|
||||
onVisibleChanged: {
|
||||
if (visible) {
|
||||
text = modelData.id
|
||||
selectAll()
|
||||
forceActiveFocus()
|
||||
}
|
||||
}
|
||||
onActiveFocusChanged: {
|
||||
if (!activeFocus && sessionDelegate.renaming) {
|
||||
sessionDelegate.renaming = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
text: modelData.agent
|
||||
opacity: 0.6
|
||||
font.pixelSize: Qt.application.font.pixelSize * 0.85
|
||||
color: sessionDelegate.highlighted
|
||||
? palette.highlightedText : palette.text
|
||||
visible: !sessionDelegate.renaming
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
icon.name: "process-stop"
|
||||
implicitWidth: 24
|
||||
implicitHeight: 24
|
||||
visible: !sessionDelegate.renaming
|
||||
onClicked: dbus.killSession(modelData.id)
|
||||
ToolTip.text: "Kill session"
|
||||
ToolTip.visible: hovered
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: dbus.activeSessionId = modelData.id
|
||||
onDoubleClicked: sessionDelegate.renaming = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Main content: tiling pane area ---
|
||||
Pane {
|
||||
id: mainContent
|
||||
SplitView.fillWidth: true
|
||||
padding: 0
|
||||
|
||||
property int focusedPaneIndex: 0
|
||||
property var panes: []
|
||||
property int paneCount: 0
|
||||
|
||||
// BSP tree: each node is {pane: item} or {split: 'h'|'v', left: node, right: node}
|
||||
property var bspTree: null
|
||||
property real gap: 2
|
||||
|
||||
function layoutTree(node, x, y, w, h) {
|
||||
if (!node) return
|
||||
if (node.pane) {
|
||||
node.pane.x = x
|
||||
node.pane.y = y
|
||||
node.pane.width = w
|
||||
node.pane.height = h
|
||||
} else if (node.split) {
|
||||
if (node.split === 'h') {
|
||||
let lw = (w - gap) / 2
|
||||
layoutTree(node.left, x, y, lw, h)
|
||||
layoutTree(node.right, x + lw + gap, y, w - lw - gap, h)
|
||||
} else {
|
||||
let th = (h - gap) / 2
|
||||
layoutTree(node.left, x, y, w, th)
|
||||
layoutTree(node.right, x, y + th + gap, w, h - th - gap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function relayout() {
|
||||
if (bspTree)
|
||||
layoutTree(bspTree, 0, 0, width, height)
|
||||
}
|
||||
|
||||
// Find and replace a leaf node in the tree
|
||||
function splitNode(tree, targetPane, newPane, direction) {
|
||||
if (!tree) return null
|
||||
if (tree.pane === targetPane) {
|
||||
return {split: direction, left: {pane: targetPane}, right: {pane: newPane}}
|
||||
}
|
||||
if (tree.split) {
|
||||
let newLeft = splitNode(tree.left, targetPane, newPane, direction)
|
||||
if (newLeft) { tree.left = newLeft; return tree }
|
||||
let newRight = splitNode(tree.right, targetPane, newPane, direction)
|
||||
if (newRight) { tree.right = newRight; return tree }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Remove a pane from the tree, collapsing its parent split
|
||||
function removeNode(tree, targetPane) {
|
||||
if (!tree) return null
|
||||
if (tree.pane === targetPane) return null // found it
|
||||
if (tree.split) {
|
||||
if (tree.left && tree.left.pane === targetPane) return tree.right
|
||||
if (tree.right && tree.right.pane === targetPane) return tree.left
|
||||
let newLeft = removeNode(tree.left, targetPane)
|
||||
if (newLeft !== tree.left) { tree.left = newLeft; return tree }
|
||||
let newRight = removeNode(tree.right, targetPane)
|
||||
if (newRight !== tree.right) { tree.right = newRight; return tree }
|
||||
}
|
||||
return tree
|
||||
}
|
||||
|
||||
onWidthChanged: relayout()
|
||||
onHeightChanged: relayout()
|
||||
onPaneCountChanged: relayout()
|
||||
|
||||
// Container for panes (absolute positioning)
|
||||
Item {
|
||||
id: paneContainer
|
||||
anchors.fill: parent
|
||||
|
||||
ChatPane {
|
||||
id: initialPane
|
||||
sessionId: ""
|
||||
Component.onCompleted: {
|
||||
sessionId = dbus.activeSessionId
|
||||
mainContent.panes = [initialPane]
|
||||
mainContent.paneCount = 1
|
||||
mainContent.bspTree = {pane: initialPane}
|
||||
mainContent.relayout()
|
||||
}
|
||||
isFocused: mainContent.focusedPaneIndex === 0
|
||||
onActiveFocusChanged: if (activeFocus) mainContent.focusedPaneIndex = 0
|
||||
onEndedDrag: function(gx, gy) { root.handlePaneSwap(initialPane, gx, gy) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Window shortcuts ---
|
||||
Shortcut {
|
||||
sequence: "Ctrl+Q"
|
||||
onActivated: Qt.quit()
|
||||
}
|
||||
|
||||
Shortcut {
|
||||
sequence: "Ctrl+N"
|
||||
onActivated: activator.NewWindow()
|
||||
}
|
||||
|
||||
// --- Split pane shortcuts ---
|
||||
Shortcut {
|
||||
sequence: "Ctrl+\\"
|
||||
onActivated: {
|
||||
let comp = Qt.createComponent("ChatPane.qml")
|
||||
if (comp.status !== Component.Ready) {
|
||||
console.warn("ChatPane.qml:", comp.errorString())
|
||||
return
|
||||
}
|
||||
let focusedPane = mainContent.panes[mainContent.focusedPaneIndex]
|
||||
if (!focusedPane) return
|
||||
|
||||
let pane = comp.createObject(paneContainer, {
|
||||
sessionId: dbus.activeSessionId,
|
||||
isFocused: false
|
||||
})
|
||||
if (pane) {
|
||||
// Split direction: alternate based on focused pane's aspect ratio
|
||||
let dir = focusedPane.width >= focusedPane.height ? 'h' : 'v'
|
||||
mainContent.bspTree = mainContent.splitNode(mainContent.bspTree, focusedPane, pane, dir)
|
||||
|
||||
let panes = mainContent.panes
|
||||
let insertAt = mainContent.focusedPaneIndex + 1
|
||||
panes.splice(insertAt, 0, pane)
|
||||
mainContent.panes = panes
|
||||
mainContent.paneCount = panes.length
|
||||
mainContent.focusedPaneIndex = insertAt
|
||||
mainContent.relayout()
|
||||
|
||||
pane.activeFocusChanged.connect(function() {
|
||||
if (pane.activeFocus) {
|
||||
mainContent.focusedPaneIndex = mainContent.panes.indexOf(pane)
|
||||
}
|
||||
})
|
||||
pane.endedDrag.connect(function(gx, gy) {
|
||||
root.handlePaneSwap(pane, gx, gy)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Shortcut {
|
||||
sequence: "Ctrl+W"
|
||||
onActivated: {
|
||||
if (mainContent.panes.length <= 1) return
|
||||
let idx = mainContent.focusedPaneIndex
|
||||
if (idx < 0 || idx >= mainContent.panes.length)
|
||||
idx = mainContent.panes.length - 1
|
||||
let pane = mainContent.panes[idx]
|
||||
|
||||
mainContent.bspTree = mainContent.removeNode(mainContent.bspTree, pane)
|
||||
|
||||
let panes = mainContent.panes
|
||||
panes.splice(idx, 1)
|
||||
mainContent.panes = panes
|
||||
mainContent.paneCount = panes.length
|
||||
pane.destroy()
|
||||
mainContent.focusedPaneIndex = Math.min(idx, panes.length - 1)
|
||||
mainContent.relayout()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle pane swap: find target pane at drop coordinates (root-window coords)
|
||||
function handlePaneSwap(sourcePane, rootX, rootY) {
|
||||
for (let i = 0; i < mainContent.panes.length; i++) {
|
||||
let p = mainContent.panes[i]
|
||||
if (p === sourcePane) continue
|
||||
let localPt = p.mapFromItem(null, rootX, rootY)
|
||||
if (localPt.x >= 0 && localPt.x <= p.width &&
|
||||
localPt.y >= 0 && localPt.y <= p.height) {
|
||||
// Swap sessions
|
||||
let tmp = sourcePane.sessionId
|
||||
sourcePane.sessionId = p.sessionId
|
||||
p.sessionId = tmp
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sidebar click updates focused pane's session
|
||||
Connections {
|
||||
target: dbus
|
||||
function onActiveSessionIdChanged() {
|
||||
let idx = mainContent.focusedPaneIndex
|
||||
if (idx >= 0 && idx < mainContent.panes.length) {
|
||||
mainContent.panes[idx].sessionId = dbus.activeSessionId
|
||||
}
|
||||
}
|
||||
function onErrorOccurred(message) {
|
||||
errorDialog.text = message
|
||||
errorDialog.open()
|
||||
}
|
||||
}
|
||||
|
||||
Dialog {
|
||||
id: errorDialog
|
||||
title: "Error"
|
||||
modal: true
|
||||
anchors.centerIn: parent
|
||||
standardButtons: Dialog.Ok
|
||||
property alias text: errorLabel.text
|
||||
Label {
|
||||
id: errorLabel
|
||||
wrapMode: Text.Wrap
|
||||
width: parent.width
|
||||
}
|
||||
}
|
||||
|
||||
// --- Process Manager Window ---
|
||||
Window {
|
||||
id: processManagerWindow
|
||||
title: "Ollie — Detached Processes"
|
||||
width: 600
|
||||
height: 400
|
||||
color: palette.window
|
||||
|
||||
property var processes: []
|
||||
property int selectedPid: -1
|
||||
property string selectedOutput: ""
|
||||
|
||||
Timer {
|
||||
id: procRefreshTimer
|
||||
interval: 1500
|
||||
running: processManagerWindow.visible
|
||||
repeat: true
|
||||
triggeredOnStart: true
|
||||
onTriggered: {
|
||||
if (dbus.activeSessionId === "") return
|
||||
processManagerWindow.processes = dbus.listDetached(dbus.activeSessionId)
|
||||
if (processManagerWindow.selectedPid > 0) {
|
||||
processManagerWindow.selectedOutput =
|
||||
dbus.getDetachedOutput(dbus.activeSessionId, processManagerWindow.selectedPid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Left pane: session list
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
spacing: 8
|
||||
SplitView.preferredWidth: 200
|
||||
SplitView.minimumWidth: 120
|
||||
spacing: 0
|
||||
|
||||
Label {
|
||||
text: "Session: " + (dbus.activeSessionId || "none")
|
||||
font.bold: true
|
||||
ToolBar {
|
||||
Layout.fillWidth: true
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
Label { text: "Sessions"; Layout.fillWidth: true; font.bold: true; leftPadding: 8 }
|
||||
ToolButton {
|
||||
text: "↻"
|
||||
onClicked: ollie.refreshSessions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process list
|
||||
ListView {
|
||||
id: procList
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 140
|
||||
clip: true
|
||||
model: processManagerWindow.processes
|
||||
|
||||
delegate: ItemDelegate {
|
||||
width: procList.width
|
||||
highlighted: modelData.pid === processManagerWindow.selectedPid
|
||||
|
||||
contentItem: RowLayout {
|
||||
spacing: 8
|
||||
|
||||
Rectangle {
|
||||
width: 8; height: 8; radius: 4
|
||||
color: modelData.status === "running" ? "#4caf50" : "#9e9e9e"
|
||||
}
|
||||
|
||||
Label {
|
||||
text: modelData.command
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
font.family: "monospace"
|
||||
}
|
||||
|
||||
Label {
|
||||
text: "pid " + modelData.pid
|
||||
opacity: 0.6
|
||||
font.pixelSize: Qt.application.font.pixelSize * 0.85
|
||||
}
|
||||
|
||||
Label {
|
||||
text: modelData.status
|
||||
opacity: 0.6
|
||||
font.pixelSize: Qt.application.font.pixelSize * 0.85
|
||||
}
|
||||
|
||||
Button {
|
||||
text: "Term"
|
||||
visible: modelData.status === "running"
|
||||
onClicked: dbus.signalDetached(dbus.activeSessionId, modelData.pid, "TERM")
|
||||
}
|
||||
|
||||
Button {
|
||||
text: "Kill"
|
||||
visible: modelData.status === "running"
|
||||
onClicked: dbus.signalDetached(dbus.activeSessionId, modelData.pid, "KILL")
|
||||
}
|
||||
|
||||
Button {
|
||||
text: "Dismiss"
|
||||
visible: modelData.status !== "running"
|
||||
onClicked: dbus.dismissDetached(dbus.activeSessionId, modelData.pid)
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
processManagerWindow.selectedPid = modelData.pid
|
||||
processManagerWindow.selectedOutput =
|
||||
dbus.getDetachedOutput(dbus.activeSessionId, modelData.pid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Separator
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: 1
|
||||
color: palette.mid
|
||||
}
|
||||
|
||||
// Output tail
|
||||
RowLayout {
|
||||
spacing: 8
|
||||
Label {
|
||||
text: processManagerWindow.selectedPid > 0
|
||||
? "——— Output: pid " + processManagerWindow.selectedPid + " ———"
|
||||
: "Select a process to view output"
|
||||
opacity: 0.6
|
||||
font.pixelSize: Qt.application.font.pixelSize * 0.85
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
Button {
|
||||
text: "Copy"
|
||||
visible: processManagerWindow.selectedPid > 0
|
||||
onClicked: {
|
||||
outputArea.selectAll()
|
||||
outputArea.copy()
|
||||
outputArea.deselect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Flickable {
|
||||
id: outputFlick
|
||||
id: sessionList
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
model: ollie.sessions
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: outputArea.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
property bool atBottom: true
|
||||
delegate: ItemDelegate {
|
||||
width: sessionList.width
|
||||
text: modelData.id || ""
|
||||
highlighted: (modelData.id === ollie.activeSessionId)
|
||||
onClicked: ollie.activeSessionId = modelData.id
|
||||
|
||||
onContentYChanged: {
|
||||
let bottom = contentHeight - height
|
||||
atBottom = (bottom <= 0 || contentY >= bottom - 4)
|
||||
Rectangle {
|
||||
width: 8; height: 8; radius: 4
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: {
|
||||
var s = modelData.state || "idle"
|
||||
if (s === "idle") return "#4caf50"
|
||||
if (s.startsWith("calling")) return "#ff9800"
|
||||
return "#2196f3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onContentHeightChanged: {
|
||||
if (atBottom) {
|
||||
contentY = Math.max(0, contentHeight - height)
|
||||
// Right pane: chat + input
|
||||
ColumnLayout {
|
||||
spacing: 0
|
||||
|
||||
// Header
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: 6
|
||||
spacing: 8
|
||||
|
||||
Rectangle {
|
||||
width: 10; height: 10; radius: 5
|
||||
color: {
|
||||
if (ollie.activeState === "idle") return "#4caf50"
|
||||
if (ollie.activeState.startsWith("calling")) return "#ff9800"
|
||||
return "#2196f3"
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
text: ollie.activeState
|
||||
opacity: 0.7
|
||||
font.pixelSize: 11
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
ToolButton {
|
||||
text: "Stop"
|
||||
onClicked: ollie.interrupt()
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { Layout.fillWidth: true; height: 1; color: palette.mid }
|
||||
|
||||
// Chat display — streaming text, last 8KB
|
||||
ScrollView {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
TextArea {
|
||||
id: outputArea
|
||||
width: outputFlick.width
|
||||
id: chatArea
|
||||
readOnly: true
|
||||
wrapMode: TextEdit.NoWrap
|
||||
text: root.chatTail
|
||||
font.family: "monospace"
|
||||
font.pixelSize: Qt.application.font.pixelSize * 0.9
|
||||
text: processManagerWindow.selectedOutput
|
||||
wrapMode: TextEdit.Wrap
|
||||
background: null
|
||||
font.pixelSize: 12
|
||||
onTextChanged: cursorPosition = text.length
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { Layout.fillWidth: true; height: 1; color: palette.mid }
|
||||
|
||||
// Input
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: 6
|
||||
spacing: 4
|
||||
|
||||
TextField {
|
||||
id: promptInput
|
||||
Layout.fillWidth: true
|
||||
placeholderText: ollie.activeSessionId ? "Message..." : "No session"
|
||||
enabled: ollie.activeSessionId !== ""
|
||||
onAccepted: {
|
||||
if (text.trim() !== "") {
|
||||
ollie.submit(text.trim())
|
||||
text = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
text: "Send"
|
||||
enabled: promptInput.text.trim() !== "" && ollie.activeSessionId !== ""
|
||||
onClicked: promptInput.accepted()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,241 @@
|
|||
#include "ollie9pclient.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QStandardPaths>
|
||||
|
||||
// Resolve the 9p binary and server address
|
||||
static QString ninepBin()
|
||||
{
|
||||
// plan9port's 9p command
|
||||
QString bin = QStandardPaths::findExecutable("9p");
|
||||
if (!bin.isEmpty()) return bin;
|
||||
if (QFileInfo::exists("/usr/local/plan9/bin/9p"))
|
||||
return "/usr/local/plan9/bin/9p";
|
||||
return "9p";
|
||||
}
|
||||
|
||||
static QString serverAddr()
|
||||
{
|
||||
QString ns = qEnvironmentVariable("NAMESPACE");
|
||||
if (ns.isEmpty()) {
|
||||
// Plan 9 default: /tmp/ns.$USER.$DISPLAY
|
||||
QString user = qEnvironmentVariable("USER");
|
||||
QString display = qEnvironmentVariable("DISPLAY");
|
||||
ns = "/tmp/ns." + user + "." + display;
|
||||
}
|
||||
return "unix!" + ns + "/ollie";
|
||||
}
|
||||
|
||||
Ollie9pClient::Ollie9pClient(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
refreshSessions();
|
||||
}
|
||||
|
||||
Ollie9pClient::~Ollie9pClient()
|
||||
{
|
||||
stopStreams();
|
||||
}
|
||||
|
||||
void Ollie9pClient::setActiveSessionId(const QString &id)
|
||||
{
|
||||
if (m_activeSessionId == id) return;
|
||||
|
||||
stopStreams();
|
||||
m_activeSessionId = id;
|
||||
m_agentId.clear();
|
||||
m_activeState = "idle";
|
||||
emit activeSessionIdChanged();
|
||||
emit activeStateChanged();
|
||||
|
||||
if (id.isEmpty()) return;
|
||||
|
||||
// Discover agent ID
|
||||
QByteArray out = run9p({"ls", "session/" + id + "/agent"});
|
||||
QString agents = QString::fromUtf8(out).trimmed();
|
||||
if (!agents.isEmpty()) {
|
||||
m_agentId = agents.split('\n').first().trimmed();
|
||||
} else {
|
||||
m_agentId = "0";
|
||||
}
|
||||
|
||||
startChatStream();
|
||||
startStateStream();
|
||||
}
|
||||
|
||||
void Ollie9pClient::refreshSessions()
|
||||
{
|
||||
QByteArray out = run9p({"read", "session/idx"});
|
||||
QString raw = QString::fromUtf8(out).trimmed();
|
||||
|
||||
m_sessions.clear();
|
||||
if (!raw.isEmpty()) {
|
||||
for (const QString &line : raw.split('\n')) {
|
||||
QStringList parts = line.split('\t');
|
||||
if (parts.size() >= 2) {
|
||||
QVariantMap session;
|
||||
session["id"] = parts[0];
|
||||
session["state"] = parts[1];
|
||||
session["model"] = parts.size() > 2 ? parts[2] : "";
|
||||
session["agent"] = parts.size() > 3 ? parts[3] : "";
|
||||
m_sessions.append(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
emit sessionsChanged();
|
||||
|
||||
// Auto-select first session if none active
|
||||
if (m_activeSessionId.isEmpty() && !m_sessions.isEmpty()) {
|
||||
setActiveSessionId(m_sessions.first().toMap()["id"].toString());
|
||||
}
|
||||
}
|
||||
|
||||
QString Ollie9pClient::readLog()
|
||||
{
|
||||
if (m_activeSessionId.isEmpty()) return {};
|
||||
QByteArray out = run9p({"read", agentPath() + "/log"});
|
||||
return QString::fromUtf8(out);
|
||||
}
|
||||
|
||||
bool Ollie9pClient::submit(const QString &prompt)
|
||||
{
|
||||
if (m_activeSessionId.isEmpty() || prompt.trimmed().isEmpty()) return false;
|
||||
QProcess proc;
|
||||
proc.start(ninepBin(), {"-a", serverAddr(), "write", agentPath() + "/prompt"});
|
||||
proc.waitForStarted(3000);
|
||||
proc.write(prompt.toUtf8());
|
||||
proc.closeWriteChannel();
|
||||
proc.waitForFinished(5000);
|
||||
return proc.exitCode() == 0;
|
||||
}
|
||||
|
||||
bool Ollie9pClient::interrupt()
|
||||
{
|
||||
if (m_activeSessionId.isEmpty()) return false;
|
||||
QProcess proc;
|
||||
proc.start(ninepBin(), {"-a", serverAddr(), "write", agentPath() + "/ctl"});
|
||||
proc.waitForStarted(3000);
|
||||
proc.write("stop");
|
||||
proc.closeWriteChannel();
|
||||
proc.waitForFinished(3000);
|
||||
return proc.exitCode() == 0;
|
||||
}
|
||||
|
||||
bool Ollie9pClient::kill()
|
||||
{
|
||||
if (m_activeSessionId.isEmpty()) return false;
|
||||
QProcess proc;
|
||||
proc.start(ninepBin(), {"-a", serverAddr(), "write", agentPath() + "/ctl"});
|
||||
proc.waitForStarted(3000);
|
||||
proc.write("kill");
|
||||
proc.closeWriteChannel();
|
||||
proc.waitForFinished(3000);
|
||||
return proc.exitCode() == 0;
|
||||
}
|
||||
|
||||
QString Ollie9pClient::getConfig()
|
||||
{
|
||||
if (m_activeSessionId.isEmpty()) return {};
|
||||
QByteArray out = run9p({"read", agentPath() + "/cfg"});
|
||||
return QString::fromUtf8(out);
|
||||
}
|
||||
|
||||
// --- Streaming ---
|
||||
|
||||
void Ollie9pClient::startChatStream()
|
||||
{
|
||||
if (m_chatProc) return;
|
||||
m_chatProc = new QProcess(this);
|
||||
connect(m_chatProc, &QProcess::readyReadStandardOutput, this, &Ollie9pClient::onChatReadyRead);
|
||||
connect(m_chatProc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
||||
this, &Ollie9pClient::onChatFinished);
|
||||
m_chatProc->start(ninepBin(), {"-a", serverAddr(), "read", agentPath() + "/chat"});
|
||||
}
|
||||
|
||||
void Ollie9pClient::startStateStream()
|
||||
{
|
||||
if (m_stateProc) return;
|
||||
m_stateProc = new QProcess(this);
|
||||
connect(m_stateProc, &QProcess::readyReadStandardOutput, this, &Ollie9pClient::onStateReadyRead);
|
||||
connect(m_stateProc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
||||
this, &Ollie9pClient::onStateFinished);
|
||||
m_stateProc->start(ninepBin(), {"-a", serverAddr(), "read", agentPath() + "/statewait"});
|
||||
}
|
||||
|
||||
void Ollie9pClient::stopStreams()
|
||||
{
|
||||
if (m_chatProc) {
|
||||
m_chatProc->disconnect();
|
||||
m_chatProc->kill();
|
||||
m_chatProc->waitForFinished(1000);
|
||||
m_chatProc->deleteLater();
|
||||
m_chatProc = nullptr;
|
||||
}
|
||||
if (m_stateProc) {
|
||||
m_stateProc->disconnect();
|
||||
m_stateProc->kill();
|
||||
m_stateProc->waitForFinished(1000);
|
||||
m_stateProc->deleteLater();
|
||||
m_stateProc = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
void Ollie9pClient::onChatReadyRead()
|
||||
{
|
||||
QByteArray data = m_chatProc->readAllStandardOutput();
|
||||
if (!data.isEmpty()) {
|
||||
emit chatReceived(QString::fromUtf8(data));
|
||||
}
|
||||
}
|
||||
|
||||
void Ollie9pClient::onStateReadyRead()
|
||||
{
|
||||
QByteArray data = m_stateProc->readAllStandardOutput();
|
||||
QString state = QString::fromUtf8(data).trimmed();
|
||||
if (!state.isEmpty()) {
|
||||
// statewait returns one line per state change; take the last one
|
||||
QStringList lines = state.split('\n', Qt::SkipEmptyParts);
|
||||
if (!lines.isEmpty()) {
|
||||
m_activeState = lines.last().trimmed();
|
||||
emit activeStateChanged();
|
||||
}
|
||||
}
|
||||
// statewait exits after one read — restart it for continuous monitoring
|
||||
m_stateProc->deleteLater();
|
||||
m_stateProc = nullptr;
|
||||
startStateStream();
|
||||
}
|
||||
|
||||
void Ollie9pClient::onChatFinished(int, QProcess::ExitStatus)
|
||||
{
|
||||
// Chat stream ended — session was killed or connection lost
|
||||
m_chatProc->deleteLater();
|
||||
m_chatProc = nullptr;
|
||||
emit streamingDone();
|
||||
}
|
||||
|
||||
void Ollie9pClient::onStateFinished(int, QProcess::ExitStatus)
|
||||
{
|
||||
// State process exited unexpectedly (not via onStateReadyRead restart)
|
||||
if (m_stateProc) {
|
||||
m_stateProc->deleteLater();
|
||||
m_stateProc = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
QString Ollie9pClient::agentPath() const
|
||||
{
|
||||
return "session/" + m_activeSessionId + "/agent/" + m_agentId;
|
||||
}
|
||||
|
||||
QByteArray Ollie9pClient::run9p(const QStringList &args)
|
||||
{
|
||||
QProcess proc;
|
||||
QStringList fullArgs = {"-a", serverAddr()};
|
||||
fullArgs.append(args);
|
||||
proc.start(ninepBin(), fullArgs);
|
||||
proc.waitForFinished(10000);
|
||||
return proc.readAllStandardOutput();
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
#ifndef OLLIE9PCLIENT_H
|
||||
#define OLLIE9PCLIENT_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QProcess>
|
||||
#include <QVariantList>
|
||||
#if QT_VERSION_MAJOR >= 6
|
||||
#include <QQmlEngine>
|
||||
#endif
|
||||
|
||||
// 9P-based client for ollie. Uses ollie-9p subprocess for transport.
|
||||
// Streaming files (chat, statewait) use persistent QProcess instances.
|
||||
class Ollie9pClient : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
#if QT_VERSION_MAJOR >= 6
|
||||
QML_ELEMENT
|
||||
QML_SINGLETON
|
||||
#endif
|
||||
|
||||
Q_PROPERTY(QVariantList sessions READ sessions NOTIFY sessionsChanged)
|
||||
Q_PROPERTY(QString activeSessionId READ activeSessionId WRITE setActiveSessionId NOTIFY activeSessionIdChanged)
|
||||
Q_PROPERTY(QString activeState READ activeState NOTIFY activeStateChanged)
|
||||
|
||||
public:
|
||||
explicit Ollie9pClient(QObject *parent = nullptr);
|
||||
~Ollie9pClient();
|
||||
|
||||
QVariantList sessions() const { return m_sessions; }
|
||||
QString activeSessionId() const { return m_activeSessionId; }
|
||||
void setActiveSessionId(const QString &id);
|
||||
QString activeState() const { return m_activeState; }
|
||||
|
||||
Q_INVOKABLE void refreshSessions();
|
||||
Q_INVOKABLE QString readLog();
|
||||
Q_INVOKABLE bool submit(const QString &prompt);
|
||||
Q_INVOKABLE bool interrupt();
|
||||
Q_INVOKABLE bool kill();
|
||||
Q_INVOKABLE QString getConfig();
|
||||
|
||||
signals:
|
||||
void sessionsChanged();
|
||||
void activeSessionIdChanged();
|
||||
void activeStateChanged();
|
||||
void chatReceived(const QString &text);
|
||||
void streamingDone(); // chat process exited (session killed)
|
||||
|
||||
private slots:
|
||||
void onChatReadyRead();
|
||||
void onStateReadyRead();
|
||||
void onChatFinished(int exitCode, QProcess::ExitStatus status);
|
||||
void onStateFinished(int exitCode, QProcess::ExitStatus status);
|
||||
|
||||
private:
|
||||
void startChatStream();
|
||||
void startStateStream();
|
||||
void stopStreams();
|
||||
QString agentPath() const;
|
||||
QByteArray run9p(const QStringList &args);
|
||||
|
||||
QVariantList m_sessions;
|
||||
QString m_activeSessionId;
|
||||
QString m_activeState = "idle";
|
||||
QString m_agentId;
|
||||
|
||||
QProcess *m_chatProc = nullptr;
|
||||
QProcess *m_stateProc = nullptr;
|
||||
};
|
||||
|
||||
#endif // OLLIE9PCLIENT_H
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
<RCC>
|
||||
<qresource prefix="/">
|
||||
<file>main.qml</file>
|
||||
<file>emojis.js</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
|
|
|
|||
|
|
@ -571,18 +571,18 @@ void OllieDBusClient::onStateChanged(const QString &session_id, const QString &n
|
|||
|
||||
void OllieDBusClient::onChatUpdated(const QString &session_id, qlonglong offset, const QString &new_text)
|
||||
{
|
||||
// Emit for all panes listening to any session
|
||||
emit chatAppended(session_id, static_cast<int>(offset), new_text);
|
||||
int endOffset = static_cast<int>(offset) + new_text.toUtf8().size();
|
||||
|
||||
// Emit for all panes listening to any session
|
||||
emit chatAppended(session_id, static_cast<int>(offset), new_text, endOffset);
|
||||
|
||||
// Track offset for the active session (but don't accumulate chatText —
|
||||
// the per-pane ChatBlockModel handles rendering now).
|
||||
if (session_id != m_activeSessionId) return;
|
||||
|
||||
// Append directly from the signal — no need to poll
|
||||
if (offset == m_chatOffset) {
|
||||
m_chatText += new_text;
|
||||
m_chatOffset += new_text.toUtf8().size();
|
||||
emit chatTextChanged();
|
||||
} else {
|
||||
// Offset mismatch — do a full fetch
|
||||
fetchChat();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ signals:
|
|||
void activeModelChanged();
|
||||
void activeBackendChanged();
|
||||
void sessionStateUpdated(const QString &sessionId, const QString &newState);
|
||||
void chatAppended(const QString &sessionId, int offset, const QString &text);
|
||||
void chatAppended(const QString &sessionId, int offset, const QString &text, int endOffset);
|
||||
void processDetached(const QString &sessionId);
|
||||
void processExited(const QString &sessionId, int pid, int exitCode);
|
||||
void errorOccurred(const QString &message);
|
||||
|
|
|
|||
Reference in New Issue