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:
Levi Neely 2026-07-31 20:06:05 +02:00
parent f00ed7738d
commit f4161fcabc
12 changed files with 725 additions and 3455 deletions

View File

@ -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

View File

@ -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)

View File

@ -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))

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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"

View File

@ -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()
}
}
}

241
gui/ollie9pclient.cpp Normal file
View File

@ -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();
}

70
gui/ollie9pclient.h Normal file
View File

@ -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

View File

@ -1,6 +1,5 @@
<RCC>
<qresource prefix="/">
<file>main.qml</file>
<file>emojis.js</file>
</qresource>
</RCC>

View File

@ -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();
}
}

View File

@ -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);