Integrate KDE frontend into main repository

This commit is contained in:
Ollie Agent 2026-08-16 15:08:33 +02:00
parent 11f2137ce4
commit 1e0f9cacd0
53 changed files with 7677 additions and 26 deletions

3
.gitmodules vendored
View File

@ -1,3 +0,0 @@
[submodule "kde"]
path = kde
url = ../ollie-kde.git

View File

@ -9,7 +9,7 @@ Project-level context for AI agents working in this repository.
## Project Overview
Ollie is an AI agent runtime inspired by Plan 9: agent state and behaviors are exposed as files in a 9P namespace. Orchestration, scheduling, and UIs are external — shell scripts, editors, web apps. The core is minimal; capabilities come from composing scripts.
## Repository Layout
Single Go module with one Git submodule (`kde/`).
Single Go module. KDE integration is part of the repository under `kde/`.
```
ollie/ ← you are here
├── cmd/
@ -165,18 +165,5 @@ The registry doesn't distinguish between scripts and binaries.
1. Write the markdown file in `data/prompts/`
2. If it should be loaded by default, reference it in `data/agents/default.json`
3. Run `just install-data` to install
## Submodule workflow
The only remaining submodule is `kde/`. For KDE:
```bash
# Update KDE submodule:
git submodule update --remote --merge kde
# Work in the KDE submodule:
cd kde
# ... make changes, commit ...
git push
cd ..
git add kde
git commit -m "update kde submodule"
```
Each submodule has its own remote at `ssh://lkn@lneely.de:44220/lkn/ollie-{name}.git`.
When cloning, use `--recurse-submodules` or run `git submodule update --init --recursive`.
## KDE development
KDE integration is part of this repository under `kde/`. Build and install it through the root `justfile` targets. The component has its own `kde/justfile` for focused KDE builds.

View File

@ -8,7 +8,7 @@ metadata sidecars — rather than building a monolithic framework.
## Building
```sh
git clone --recurse-submodules https://git.lneely.de/lkn/ollie.git
git clone https://git.lneely.de/lkn/ollie.git
cd ollie
just
```
@ -69,7 +69,7 @@ Everything lives under `$XDG_CONFIG_HOME/ollie/` (default: `~/.config/ollie/`)
## Repository layout
Single Go module with one Git submodule (kde).
Single Go module. KDE integration lives in the `kde/` directory and is built from the same repository.
| Directory | Language | Description |
|-----------|----------|-------------|
@ -84,7 +84,7 @@ Single Go module with one Git submodule (kde).
| `format/` | Go | Output formatting |
| `log/` | Go | Structured logging |
| `paths/` | Go | XDG path resolution |
| `kde/` | C++/Qt6 | KDE plasmoid, GUI, Kate plugin, KRunner *(submodule)* |
| `kde/` | C++/Qt6 | KDE GUI, Kate, KIO, KRunner, and Dolphin integration |
| `contrib/elisp/` | Emacs Lisp | ellie.el — Emacs frontend |
| `data/agents/` | JSON | Agent configs (installed to `~/.config/ollie/agents/`) |
| `data/prompts/` | Markdown | System prompt templates |

View File

@ -10,7 +10,7 @@ Design principles:
- **9P namespace declared via EDSL.** The entire filesystem is a single recursive `FsNodeDecl` tree in `fs/spec.go`, validated and built by `BuildTree()` in `fs/builder.go`. Adding a file means adding a `Leaf()` call — no manual stat/readdir/write wiring.
- **No framework lock-in.** Frontends are decoupled via whichever interface they prefer; the core doesn't know or care.
## Repository Structure
Single Go module (`ollie`) with one Git submodule (`kde/`) for the KDE frontend:
Single Go module. The KDE frontend is maintained in the `kde/` directory and built as part of the same repository.
```
ollie/
├── cmd/
@ -26,7 +26,7 @@ ollie/
├── log/ Structured logging
├── paths/ XDG path resolution
├── format/ Chat log formatting constants
├── kde/ KDE integration (submodule) — standalone GUI, Kate plugin, KRunner
├── kde/ KDE integration — GUI, Kate, KIO, KRunner, Dolphin
├── data/agents/ Agent config JSONs (default, coding, orchestrator, worker, ...)
├── data/prompts/ Prompt templates (markdown)
├── data/tools/ Tool definitions (.meta files + executables)

View File

@ -627,7 +627,7 @@ ollie/ ← single Go module
├── env/ ← environment helpers
├── log/ ← structured logging
├── paths/ ← XDG path resolution
├── kde/ ← KDE Plasma (submodule)
├── kde/ ← KDE Plasma integration (part of the main repository)
├── data/tools/ ← tool executables + .meta sidecar files
├── data/agents/ ← agent configs (JSON)
├── data/prompts/ ← system prompt fragments (markdown)

1
kde

@ -1 +0,0 @@
Subproject commit 2de82e6a7b3c3b8e6aedf78fb239fcd90e25906d

6
kde/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
build/
build-cmake/
build-kf5/
build-kf6/
CMakeFiles/
__pycache__/

2
kde/99-ollie-kf5.sh Normal file
View File

@ -0,0 +1,2 @@
export QT_PLUGIN_PATH=$HOME/.local/lib/qt5/plugins${QT_PLUGIN_PATH:+:$QT_PLUGIN_PATH}
export QML_IMPORT_PATH=$HOME/.local/lib/x86_64-linux-gnu/qml${QML_IMPORT_PATH:+:$QML_IMPORT_PATH}

2
kde/99-ollie.sh Normal file
View File

@ -0,0 +1,2 @@
export QT_PLUGIN_PATH=$HOME/.local/lib64/qt6/plugins${QT_PLUGIN_PATH:+:$QT_PLUGIN_PATH}
export QML_IMPORT_PATH=$HOME/.local/lib64/qml${QML_IMPORT_PATH:+:$QML_IMPORT_PATH}

245
kde/CMakeLists.txt Normal file
View File

@ -0,0 +1,245 @@
cmake_minimum_required(VERSION 3.16)
project(kde-ollie VERSION 0.1.0 LANGUAGES CXX)
option(OLLIE_KF5 "Build against KF5/Qt5 (Plasma 5.27)" OFF)
# Auto-detect: if OLLIE_KF5 not explicitly set, try KF6 first, fall back to KF5.
# Note: Gentoo and some distros don't ship a monolithic KF6Config.cmake; check
# individual components directly instead of find_package(KF6 COMPONENTS ...).
if(NOT DEFINED OLLIE_KF5 OR NOT OLLIE_KF5)
find_package(KF6Runner QUIET)
find_package(KF6CoreAddons QUIET)
if(KF6Runner_FOUND AND KF6CoreAddons_FOUND)
set(KF6_FOUND TRUE)
else()
message(STATUS "KF6 not found, falling back to KF5")
set(OLLIE_KF5 ON)
endif()
endif()
# Suppress CMP0160 warnings from KF5 cmake files (read-only IMPORTED property)
if(OLLIE_KF5)
cmake_policy(SET CMP0160 OLD)
endif()
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
find_package(ECM REQUIRED NO_MODULE)
set(CMAKE_MODULE_PATH ${ECM_MODULE_PATH})
include(KDEInstallDirs)
include(KDECMakeSettings)
include(KDECompilerSettings NO_POLICY_SCOPE)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
if(OLLIE_KF5)
# --- Qt5 / KF5 ---
find_package(Qt5 REQUIRED COMPONENTS Core Concurrent 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.
set(KDE_INSTALL_PLUGINDIR "lib/qt5/plugins" CACHE PATH "" FORCE)
find_package(KF5 REQUIRED COMPONENTS
Runner
CoreAddons
Config
KIO
TextEditor
SyntaxHighlighting
)
# --- KRunner Plugin ---
kcoreaddons_add_plugin(krunner_ollie SOURCES krunner/ollie_runner.cpp INSTALL_NAMESPACE "kf5/krunner")
target_link_libraries(krunner_ollie
KF5::Runner
Qt5::Core
Qt5::Widgets
)
# --- Kate Plugin ---
kcoreaddons_add_plugin(ollie_kate SOURCES
kate/ollie_kate.cpp
kate/ollie_ghost.cpp
gui/lib9pclient.cpp
INSTALL_NAMESPACE "ktexteditor"
)
target_include_directories(ollie_kate PRIVATE
${CMAKE_SOURCE_DIR}/gui
$ENV{HOME}/.local/include
)
target_link_libraries(ollie_kate
KF5::TextEditor
KF5::SyntaxHighlighting
Qt5::Core
Qt5::Widgets
$ENV{HOME}/.local/lib/libollie9p.so
)
# --- KIO Worker (ollie:// protocol) ---
kcoreaddons_add_plugin(kio_ollie SOURCES
kio/ollie_kio.cpp
gui/lib9pclient.cpp
INSTALL_NAMESPACE "kf5/kio"
)
target_include_directories(kio_ollie PRIVATE
${CMAKE_SOURCE_DIR}/gui
${CMAKE_SOURCE_DIR}/kio
$ENV{HOME}/.local/include
)
target_link_libraries(kio_ollie
KF5::KIOCore
Qt5::Core
Qt5::Network
$ENV{HOME}/.local/lib/libollie9p.so
)
install(FILES kio/ollie.protocol DESTINATION ${KDE_INSTALL_KSERVICESDIR})
# --- GUI App (9P-based) ---
# Strip Qt6-only grouped palette syntax (palette.active.*, palette.inactive.*,
# palette.disabled.*) — Qt5 QML doesn't support color-group qualifiers.
# ThemeManager::applyPalette() sets the application palette which Qt Quick
# Controls 2 picks up automatically.
file(READ ${CMAKE_CURRENT_SOURCE_DIR}/gui/main.qml _QML_SRC)
string(REGEX REPLACE "[ \t]*(//[^\n]*palette[^\n]*\n|palette\\.(active|inactive|disabled)\\.[^\n]*\n)" "" _QML_SRC "${_QML_SRC}")
file(WRITE ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/main.qml "${_QML_SRC}")
# Copy component QML files (no palette stripping needed - they don't use grouped palette)
configure_file(gui/SettingsDialog.qml ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/SettingsDialog.qml COPYONLY)
configure_file(gui/NewSessionDialog.qml ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/NewSessionDialog.qml COPYONLY)
configure_file(gui/NewAgentDialog.qml ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/NewAgentDialog.qml COPYONLY)
configure_file(gui/ChatBlockDelegate.qml ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/ChatBlockDelegate.qml COPYONLY)
configure_file(gui/SessionTree.qml ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/SessionTree.qml COPYONLY)
configure_file(gui/ChatPane.qml ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/ChatPane.qml 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/ollie9pclient.cpp
gui/sessionmodel.cpp
gui/lib9pclient.cpp
gui/nativestreamer.cpp
gui/chatblockmodel.cpp
gui/thememanager.cpp
gui/clipboardhelper.cpp
${GUI_KF5_QRC}
)
target_include_directories(ollie-gui PRIVATE
${CMAKE_SOURCE_DIR}/gui
$ENV{HOME}/.local/include
)
target_link_libraries(ollie-gui
Qt5::Core
Qt5::Widgets
Qt5::Quick
Qt5::QuickControls2
Qt5::Network
KF5::ConfigCore
KF5::SyntaxHighlighting
$ENV{HOME}/.local/lib/libollie9p.so
)
# --- Install ---
install(TARGETS ollie-gui DESTINATION ${KDE_INSTALL_BINDIR})
else()
# --- Qt6 / KF6 ---
find_package(Qt6 REQUIRED COMPONENTS Core Widgets Quick QuickControls2 Network)
find_package(KF6 REQUIRED COMPONENTS Runner CoreAddons)
find_package(KF6Config REQUIRED)
find_package(KF6KIO REQUIRED)
# KDE's KDE_INSTALL_PLUGINDIR defaults to lib64/plugins/ but Qt6 plugins
# live under lib64/qt6/plugins/. Override so kcoreaddons_add_plugin installs
# to the correct location.
set(KDE_INSTALL_PLUGINDIR "${QT6_INSTALL_PLUGINS}" CACHE PATH "" FORCE)
# --- KRunner Plugin ---
kcoreaddons_add_plugin(krunner_ollie SOURCES krunner/ollie_runner.cpp INSTALL_NAMESPACE "kf6/krunner")
target_link_libraries(krunner_ollie
KF6::Runner
Qt6::Core
Qt6::Widgets
)
# --- Kate Plugin ---
find_package(KF6TextEditor REQUIRED)
kcoreaddons_add_plugin(ollie_kate SOURCES
kate/ollie_kate.cpp
kate/ollie_ghost.cpp
gui/lib9pclient.cpp
INSTALL_NAMESPACE "kf6/ktexteditor"
)
target_include_directories(ollie_kate PRIVATE
${CMAKE_SOURCE_DIR}/gui
$ENV{HOME}/.local/include
)
target_link_libraries(ollie_kate
KF6::TextEditor
KF6::SyntaxHighlighting
Qt6::Core
Qt6::Widgets
$ENV{HOME}/.local/lib/libollie9p.so
)
# --- KIO Worker (ollie:// protocol) ---
kcoreaddons_add_plugin(kio_ollie SOURCES
kio/ollie_kio.cpp
gui/lib9pclient.cpp
INSTALL_NAMESPACE "kf6/kio"
)
target_include_directories(kio_ollie PRIVATE
${CMAKE_SOURCE_DIR}/gui
${CMAKE_SOURCE_DIR}/kio
$ENV{HOME}/.local/include
)
target_link_libraries(kio_ollie
KF6::KIOCore
Qt6::Core
Qt6::Network
$ENV{HOME}/.local/lib/libollie9p.so
)
# --- GUI App (9P-based, no D-Bus) ---
find_package(KF6SyntaxHighlighting REQUIRED)
add_executable(ollie-gui
gui/main.cpp
gui/ollie9pclient.cpp
gui/sessionmodel.cpp
gui/lib9pclient.cpp
gui/nativestreamer.cpp
gui/chatblockmodel.cpp
gui/thememanager.cpp
gui/clipboardhelper.cpp
)
target_include_directories(ollie-gui PRIVATE
${CMAKE_SOURCE_DIR}/gui
$ENV{HOME}/.local/include
)
target_link_libraries(ollie-gui
Qt6::Core
Qt6::Widgets
Qt6::Quick
Qt6::QuickControls2
Qt6::Network
KF6::ConfigCore
KF6::SyntaxHighlighting
$ENV{HOME}/.local/lib/libollie9p.so
)
qt_add_resources(ollie-gui "gui"
PREFIX "/"
BASE gui
FILES
gui/main.qml
gui/SettingsDialog.qml
gui/NewSessionDialog.qml
gui/NewAgentDialog.qml
gui/ChatBlockDelegate.qml
gui/SessionTree.qml
gui/ChatPane.qml
)
# --- Install ---
install(TARGETS ollie-gui DESTINATION ${KDE_INSTALL_BINDIR})
endif()

219
kde/README.md Normal file
View File

@ -0,0 +1,219 @@
# ollie-kde
KDE-native integration for [ollie](../README.md) — thin KDE clients (GUI application, KRunner plugin, Kate plugin, Dolphin menus) that connect to the Ollie 9P filesystem via `olliesrv`.
## Architecture
```
┌─────────────────────────────────────────────────────────┐
│ Plasma Desktop │
│ GUI App │ KRunner │ Kate Plugin │ Dolphin │
└──────────┬──────────────┬──────────────────┬─────────────┘
│ │ │
│ ┌───────────┴──────────────┐ │
│ │ 9P client tools │ │
│ │ libollie9p │ ollie-9p │ │
│ │ plan9port's 9p │ │
│ └───────────┬──────────────┘ │
│ │ │
└──────────────┼──────────────────┘
│ 9P protocol (Unix socket)
│
┌──────────┴──────────┐
│ olliesrv (../9p) │
│ 9P server │
│ Go │ lib9p │
└─────────────────────┘
```
Every KDE component talks to `olliesrv` via 9P protocol using one of the available 9P clients (`libollie9p.so` directly, `ollie-9p` subprocess, or plan9port's `9p`).
## Components
- **ollie-gui** — Main Qt/QML GUI application with session management and chat interface
- **krunner_ollie.so** — KRunner plugin for quick prompts and session management
- **ollie_kate.so** — Kate plugin with context actions and ghost text completion
- **Dolphin service menus** — Right-click actions for files and directories
**Note**: No system tray component exists.
## Dependencies
- Qt 6 (Core, Widgets, Quick, QuickControls2) or Qt 5 (Core, Widgets, Quick, Qml, QuickControls2)
- KDE Frameworks 6 (Runner, CoreAddons, Config, TextEditor, SyntaxHighlighting) or KF5 equivalents
- ECM (Extra CMake Modules)
- libollie9p.so (from `../lib9p/`)
KF6/Qt6 is the default. To build against KF5/Qt5:
```sh
just kde-kf5
```
## Build
```sh
just kde # builds all components (KF6)
just kde-kf5 # builds all components (KF5)
```
Outputs:
- `build-cmake/ollie-gui` — main GUI application
- `build-cmake/lib/kf6/krunner/krunner_ollie.so` — KRunner plugin
- `build-cmake/lib/kf6/ktexteditor/ollie_kate.so` — Kate plugin
## Install
```sh
just install-kde # installs all KDE components (KF6)
just install-kde-kf5 # installs all KDE components (KF5)
```
The installation:
- Installs binaries to `~/.local/bin/`
- Installs plugins to `~/.local/lib64/qt6/plugins/` (KF6) or `~/.local/lib/qt5/plugins/` (KF5)
- Installs Dolphin service menus to `~/.local/share/kio/servicemenus/`
- Installs desktop entry to `~/.local/share/applications/`
- Installs icon to `~/.local/share/icons/hicolor/scalable/apps/`
- Sets up environment script in `~/.config/plasma-workspace/env/`
**Note**: Restart Plasma (`plasmashell --replace` or log out/in) to pick up new plugins.
## Usage
### GUI Application (`ollie-gui`)
Launch from application menu or terminal:
```sh
ollie-gui
```
Features:
- Session list with state indicators (idle, thinking, tool call)
- Collapsible sidebar (Ctrl+B to toggle)
- Live chat view with auto-scroll
- Inline prompt input
- Real-time updates via 9P polling
- Theme support (light/dark/system)
- Font customization
### KRunner
- `ollie <prompt>` — one-shot generation via `Generate` (no session created); shows result in a popup
- `ollie list` — show active sessions
- `ollie kill <id>` — terminate a session
### Dolphin
Right-click any file or directory:
- **Ask Ollie about this** — one-shot prompt with file context, shows result in kdialog
- **Start Ollie session here** — creates a persistent session in that directory
### Kate
Enable the "Ollie" plugin in Settings → Configure Kate → Plugins. The plugin provides:
**Context menu actions** (right-click in editor):
- **Ask about file** — describe and explain the current file
- **Explain this** — explain selected code (or current line)
- **Fix this** — ask agent to fix selected code
- **Refactor this** — ask agent to refactor selected code
- **Add tests for this** — generate unit tests for selection
- **Document this** — add documentation comments to selection
- **Send verbatim** — send selected text wrapped in a code fence (language auto-detected)
- **Review Diff** — review a diff for issues (visible on Diff-highlighted documents)
**Ghost text completion:**
- Inline AI code completions appear as faded text at end-of-line
- Triggered automatically after 500ms of cursor idle (at end-of-line only)
- Multi-line continuations shown when near EOF
**Keyboard shortcuts:**
| Key | Action |
|-----|--------|
| Tab | Accept ghost suggestion |
| Meta+O, N | Next suggestion |
| Meta+O, P | Previous suggestion |
| Meta+O, C | Toggle completion on/off |
| Meta+O, D | Dismiss suggestion |
| Meta+O, Tab | Request completion manually |
**Agent management:**
- Auto-creates a "kate" session with one agent per project directory
- Auto-selects agent matching the current Kate project
- Connection health monitoring with auto-reconnect (5s heartbeat)
- Status bar shows state:
- Red `✗ disconnected` — server unreachable
- Gray `○ no agent` — connected, no agent
- Green `● kate/ID` — connected with active agent
**Configuration** (via environment variables):
- `OLLIE_COMPLETE_BACKEND` — completion backend override
- `OLLIE_COMPLETE_MODEL` — completion model override
**Git++ integration:**
- "Ollie: Review Diff" action registered in Git++ plugin's diff context menu
**Tip:** Tile the `ollie-gui` window alongside Kate to see the full chat stream from context actions.
## File Layout
```
ollie-kde/
├── justfile # Build orchestration
├── CMakeLists.txt # Main CMake configuration
├── gui/
│ ├── main.cpp # GUI application entry
│ ├── main.qml # QML interface
│ ├── ollie9pclient.h/cpp # 9P client for GUI
│ ├── lib9pclient.h/cpp # Shared 9P client library
│ ├── chatblockmodel.h/cpp # Chat data model
│ ├── thememanager.h/cpp # Theme management
│ ├── streamfsm.h/cpp # Streaming state machine
│ ├── ninepconnection.h/cpp # 9P connection management
│ └── org.ollie.gui.desktop # Desktop entry
├── krunner/
│ ├── metadata.json # Plugin metadata
│ └── ollie_runner.h/cpp # KRunner plugin (9P-based)
├── kate/
│ ├── metadata.json # Plugin metadata
│ ├── ollie_kate.h/cpp # Kate plugin (context actions, agent mgmt)
│ └── ollie_ghost.h/cpp # Ghost text completion provider
├── dolphin/
│ ├── ollie-actions.desktop # Service menu entries (KF6)
│ ├── ollie-actions-kf5.desktop # Service menu entries (KF5)
│ ├── ollie-ask # One-shot helper script (9P-based)
│ └── ollie-session-here # Session creation helper (9P-based)
├── 99-ollie.sh # Environment script (KF6)
├── 99-ollie-kf5.sh # Environment script (KF5)
```
## Development Notes
- **All components use `libollie9p.so` for 9P transport** (direct C library, no subprocess)
- The Kate plugin is lightweight: context actions + ghost text, no chat panel (use ollie-gui for chat)
- The GUI application uses Qt Quick for a modern, responsive interface
- Build system supports both KF6/Qt6 and KF5/Qt5 via CMake option `OLLIE_KF5`
- Installation is user-local (`~/.local/`) by default, no system-wide installation needed
## Troubleshooting
**Plugins not appearing after installation:**
- Run `kbuildsycoca6` (KF6) or `kbuildsycoca5` (KF5) to rebuild system cache
- Restart Plasma: `plasmashell --replace` or log out/in
**"ollie-9p not found" errors:**
- Ensure `ollie-9p` is in your PATH or build/install the main Ollie project first
**Kate plugin crashes:**
- Check that `libollie9p.so` is installed in `~/.local/lib/`
- Verify Kate is using the same Qt version as the plugin (Qt5 vs Qt6)
**Ghost text not appearing:**
- Ensure `olliesrv` is running and the 9P socket is accessible
- Ghost text only shows when cursor is at end-of-line
- Check Meta+O, C hasn't toggled completion off
**Dolphin actions not working:**
- Ensure `ollie-ask` and `ollie-session-here` are installed in `~/.local/bin/`
- Check that the service menu desktop files are in `~/.local/share/kio/servicemenus/`

View File

@ -0,0 +1,16 @@
[Desktop Entry]
Type=Service
X-KDE-ServiceTypes=KonqPopupMenu/Plugin
MimeType=all/allfiles;inode/directory;
Actions=askOllie;sessionHere;
X-KDE-AuthorizeAction=shell_access
[Desktop Action askOllie]
Name=Ask Ollie about this
Icon=system-run
Exec=ollie-ask %f
[Desktop Action sessionHere]
Name=Start Ollie session here
Icon=system-run
Exec=ollie-session-here %f

View File

@ -0,0 +1,15 @@
[Desktop Entry]
Type=Service
MimeType=all/allfiles;inode/directory;
Actions=askOllie;sessionHere;
X-KDE-AuthorizeAction=shell_access
[Desktop Action askOllie]
Name=Ask Ollie about this
Icon=system-run
Exec=ollie-ask %f
[Desktop Action sessionHere]
Name=Start Ollie session here
Icon=system-run
Exec=ollie-session-here %f

29
kde/dolphin/ollie-ask Executable file
View File

@ -0,0 +1,29 @@
#!/bin/sh
# ollie-ask: one-shot prompt helper for Dolphin service menus
# Usage: ollie-ask <file_or_dir> [prompt_prefix]
TARGET="$1"
PREFIX="${2:-Describe and explain this file:}"
PROMPT="$PREFIX $TARGET"
# Use Generate one-shot via 9P (no session required)
RESULT=$(echo "$PROMPT" | ollie-9p rdwr generate 2>/dev/null)
if [ -z "$RESULT" ]; then
notify-send "Ollie" "No response (is olliesrv running?)"
exit 1
fi
# Show result
TMPFILE=$(mktemp /tmp/ollie-ask.XXXXXX)
printf '%s' "$RESULT" > "$TMPFILE"
if command -v kdialog > /dev/null 2>&1; then
kdialog --title "Ollie — $(basename "$TARGET")" --textbox "$TMPFILE" 600 400
elif command -v zenity > /dev/null 2>&1; then
zenity --text-info --title="Ollie — $(basename "$TARGET")" --width=600 --height=400 --filename="$TMPFILE"
else
notify-send -t 30000 "Ollie" "$RESULT"
fi
rm -f "$TMPFILE"

28
kde/dolphin/ollie-session-here Executable file
View File

@ -0,0 +1,28 @@
#!/bin/sh
# ollie-session-here: start a session in the given directory
# Works from both Dolphin (passes %f) and terminal (uses $PWD).
#
# Session is always "default". Agent name is the base directory name.
# Session/agent creation is idempotent.
TARGET="$1"
[ -f "$TARGET" ] && TARGET="$(dirname "$TARGET")"
[ -z "$TARGET" ] && TARGET="$PWD"
TARGET="$(cd "$TARGET" 2>/dev/null && pwd)" || TARGET="$PWD"
# Agent name is base directory name
AGENT_NAME="$(basename "$TARGET")"
# Create default session (idempotent) and named agent
printf 'name=default\ncwd=%s\n' "$TARGET" | ollie-9p write session/new
printf 'name=%s\ncwd=%s\n' "$AGENT_NAME" "$TARGET" | ollie-9p write session/default/agent/new
if [ -t 1 ]; then
# Terminal: print session info
echo "Session: default"
echo "Agent: $AGENT_NAME"
echo "CWD: $TARGET"
else
# Dolphin/non-interactive: desktop notification
notify-send -t 3000 "Ollie" "Agent '$AGENT_NAME' started in $TARGET"
fi

View File

@ -0,0 +1,357 @@
// ChatBlockDelegate.qml — Delegate for rendering chat blocks in the ListView
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
Column {
id: root
spacing: 2
// Required properties from model
required property string blockType
required property string blockName
required property var blockSegments
required property string blockRenderedHtml
// Expand state based on block type
property bool blockExpanded: {
switch (blockType) {
case "user":
case "assistant":
case "tool":
case "info":
case "retry":
case "interrupt":
return true
default:
return false
}
}
// Header color based on block type and theme
function headerColor() {
var bg = theme.baseColor
if (theme.isDark) {
switch (blockType) {
case "user": return Qt.lighter(bg, 1.8)
case "assistant": return Qt.lighter(bg, 1.5)
case "reasoning": return Qt.lighter(bg, 1.3)
case "call": return Qt.lighter(bg, 1.7)
case "tool": return Qt.lighter(bg, 1.2)
case "error": return Qt.rgba(0.6, 0.15, 0.15, 0.7)
case "info": return Qt.lighter(bg, 1.4)
case "retry": return Qt.rgba(0.6, 0.5, 0.0, 0.5)
case "interrupt": return "#3d2e00"
default: return bg
}
} else {
switch (blockType) {
case "user": return Qt.darker(bg, 1.12)
case "assistant": return Qt.darker(bg, 1.06)
case "reasoning": return Qt.darker(bg, 1.03)
case "call": return Qt.darker(bg, 1.1)
case "tool": return Qt.darker(bg, 1.04)
case "error": return Qt.rgba(1.0, 0.8, 0.8, 0.7)
case "info": return Qt.darker(bg, 1.02)
case "retry": return Qt.rgba(1.0, 0.9, 0.6, 0.5)
case "interrupt": return "#fff8e1"
default: return bg
}
}
}
// Header label text based on block type
function headerText() {
switch (blockType) {
case "user": return "\uD83E\uDDD1 You"
case "assistant": return "\uD83E\uDD16 Ollie"
case "reasoning": return "Reasoning"
case "call": return "\u25B6\uFE0F " + blockName
case "tool": return "\uD83D\uDDA5\uFE0F " + blockName
case "error": return "\u274C Error"
case "info": return "\u2139\uFE0F Info"
case "retry": return "Retry"
case "interrupt": return "\u26A0\uFE0F User Interrupt"
default: return ""
}
}
// Clickable header
Rectangle {
width: root.width
height: 22
radius: 3
color: root.headerColor()
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.blockExpanded = !root.blockExpanded
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 6
spacing: 4
Label {
text: root.blockExpanded ? "▼" : "▶"
font.pixelSize: 8
opacity: 0.6
}
Label {
Layout.fillWidth: true
text: root.headerText()
font.pointSize: theme.fontSize > 0 ? theme.fontSize * 0.85 : 11
font.bold: true
color: theme.textColor
opacity: 0.9
}
}
}
// Block content rendered as segments
Column {
width: root.width
visible: root.blockExpanded
spacing: 4
// Fast path: single TextEdit for the concatenated HTML.
// Diagrams are handled separately via the segments list.
TextEdit {
id: mainTextEdit
width: root.width
visible: !hasDiagrams()
text: root.blockRenderedHtml
textFormat: Text.RichText
readOnly: true
selectByMouse: true
persistentSelection: true
wrapMode: TextEdit.Wrap
font.family: theme.fontFamily || Qt.application.font.family
font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12
selectionColor: theme.highlightColor
selectedTextColor: theme.highlightedTextColor
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
cursorShape: Qt.IBeamCursor
onClicked: {
if (!mainTextEdit._menu) {
mainTextEdit._menu = contextMenuComponent.createObject(mainTextEdit)
}
mainTextEdit._menu.popup()
}
}
property var _menu: null
}
// Diagram path: use Repeater for blocks that contain diagrams
Repeater {
model: hasDiagrams() ? root.blockSegments : []
delegate: Loader {
width: root.width
active: modelData !== null && modelData !== undefined
sourceComponent: (modelData && modelData.type === "diagram") ? diagramComponent : textComponent
property var segmentData: modelData || {}
}
}
}
function hasDiagrams() {
if (!root.blockSegments) return false
for (var i = 0; i < root.blockSegments.length; i++) {
if (root.blockSegments[i] && root.blockSegments[i].type === "diagram")
return true
}
return false
}
// Text segment component
Component {
id: textComponent
TextEdit {
id: textEdit
width: root.width
text: segmentData.html || ""
textFormat: Text.RichText
readOnly: true
selectByMouse: true
persistentSelection: true
wrapMode: TextEdit.Wrap
font.family: theme.fontFamily || Qt.application.font.family
font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12
selectionColor: theme.highlightColor
selectedTextColor: theme.highlightedTextColor
// Left-click: disable ListView flick while selecting text
// Right-click: lazy context menu
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
cursorShape: Qt.IBeamCursor
onClicked: {
if (!textEdit._menu) {
textEdit._menu = contextMenuComponent.createObject(textEdit)
}
textEdit._menu.popup()
}
}
property Menu _menu: null
Component.onDestruction: {
if (_menu) _menu.destroy()
}
}
}
// Shared context menu component (created on demand)
Component {
id: contextMenuComponent
Menu {
id: ctxMenu
property TextEdit target: parent
MenuItem {
text: "Copy"
enabled: ctxMenu.target ? ctxMenu.target.selectedText.length > 0 : false
onTriggered: if (ctxMenu.target) ctxMenu.target.copy()
}
MenuItem {
text: "Select All"
onTriggered: if (ctxMenu.target) ctxMenu.target.selectAll()
}
}
}
// Diagram segment component
Component {
id: diagramComponent
Rectangle {
id: diagramRect
width: root.width
height: diagramContent.height + diagramHeader.height + 8
color: "transparent"
border.color: theme.midColor
border.width: 1
radius: 4
property bool showSource: false
RowLayout {
id: diagramHeader
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: 4
height: 24
Label {
text: segmentData.lang || "diagram"
font.pointSize: theme.fontSize > 0 ? theme.fontSize - 1 : 11
color: theme.textColor
opacity: 0.7
}
Item { Layout.fillWidth: true }
Button {
text: diagramRect.showSource ? "📊" : "</>"
font.pointSize: theme.fontSize > 0 ? theme.fontSize - 2 : 10
implicitWidth: 32
implicitHeight: 20
ToolTip.visible: hovered
ToolTip.text: diagramRect.showSource ? "Show diagram" : "Show source"
onClicked: diagramRect.showSource = !diagramRect.showSource
}
Button {
text: "📋"
font.pointSize: theme.fontSize > 0 ? theme.fontSize - 2 : 10
implicitWidth: 32
implicitHeight: 20
ToolTip.visible: hovered
ToolTip.text: diagramRect.showSource ? "Copy source" : "Copy image"
onClicked: {
if (diagramRect.showSource) {
clipboard.copyText(segmentData.source)
} else if (segmentData.svg) {
clipboard.copyImage(segmentData.svg)
}
}
}
}
Item {
id: diagramContent
anchors.top: diagramHeader.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: 4
height: diagramRect.showSource ? sourceEdit.height : imageColumn.height
TextEdit {
id: sourceEdit
width: parent.width
visible: diagramRect.showSource
text: segmentData.html || ""
textFormat: Text.RichText
readOnly: true
selectByMouse: true
persistentSelection: true
wrapMode: TextEdit.Wrap
font.family: theme.fontFamily || Qt.application.font.family
font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12
selectionColor: theme.highlightColor
selectedTextColor: theme.highlightedTextColor
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
cursorShape: Qt.IBeamCursor
onClicked: {
if (!sourceEdit._menu) {
sourceEdit._menu = contextMenuComponent.createObject(sourceEdit)
}
sourceEdit._menu.popup()
}
}
property Menu _menu: null
}
Column {
id: imageColumn
width: parent.width
visible: !diagramRect.showSource
Rectangle {
visible: segmentData.svg && !segmentData.loading
color: "white"
width: diagramImg.width
height: diagramImg.height
Image {
id: diagramImg
source: segmentData.svg || ""
fillMode: Image.PreserveAspectFit
width: Math.min(implicitWidth, imageColumn.width)
}
}
Label {
visible: segmentData.loading
text: "Loading " + (segmentData.lang || "diagram") + "..."
font.italic: true
color: theme.textColor
opacity: 0.7
}
}
}
}
}
}

304
kde/gui/ChatPane.qml Normal file
View File

@ -0,0 +1,304 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
Item {
id: chatPane
property bool sidebarVisible: true
property int maxVisibleBlocks: 80
signal toggleSidebar()
function scrollToBottom() {
chatView.positionViewAtEnd()
}
function trimVisibleBlocks() {
if (chatModel.count > chatPane.maxVisibleBlocks)
chatModel.trimFront(chatPane.maxVisibleBlocks)
}
function loadChat() {
chatModel.reset()
if (ollie.activeSessionId && ollie.activeAgentId) {
var log = ollie.readLogForSession(ollie.activeSessionId, ollie.activeAgentId)
if (log) {
chatModel.appendData(log)
chatModel.trimFront(chatPane.maxVisibleBlocks)
}
}
scrollToBottom()
}
Component.onCompleted: {
loadChat()
scrollToBottom()
}
ColumnLayout {
anchors.fill: parent
spacing: 0
// Header - shows active session/agent and state
RowLayout {
Layout.fillWidth: true
Layout.margins: 6
spacing: 8
// Sidebar toggle button
ToolButton {
text: chatPane.sidebarVisible ? "◀" : "▶"
font.pixelSize: 12
implicitWidth: 24
implicitHeight: 24
onClicked: chatPane.toggleSidebar()
ToolTip.text: chatPane.sidebarVisible ? "Hide sidebar (Ctrl+B)" : "Show sidebar (Ctrl+B)"
ToolTip.visible: hovered
}
// Active session/agent label
Label {
text: {
var sessionName = ""
var agentName = ""
for (var i = 0; i < sessionModel.count; ++i) {
var item = sessionModel.get(i)
if (item.type === "session" && item.id === ollie.activeSessionId) {
sessionName = item.displayName || item.id
}
if (item.type === "agent" && item.agentId === ollie.activeAgentId && item.parentId === ollie.activeSessionId) {
agentName = item.displayName || item.agentId
}
}
if (sessionName && agentName) {
return sessionName + " / " + agentName
} else if (sessionName) {
return sessionName + " (no agent)"
}
return "(no session)"
}
font.bold: true
font.pointSize: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize
elide: Text.ElideMiddle
Layout.fillWidth: true
}
// State indicator
Rectangle {
width: 10; height: 10; radius: 5
color: {
if (!ollie.activeAgentId) return "#9e9e9e" // gray when no agent
if (ollie.activeSessionId && ollie.isSessionPaused(ollie.activeSessionId)) return "#9e9e9e" // gray when paused
if (ollie.activeState === "idle") return "#4caf50"
if (ollie.activeState.startsWith("calling")) return "#ff9800"
return "#2196f3"
}
}
Label {
text: {
if (!ollie.activeAgentId) return ""
if (ollie.activeSessionId && ollie.isSessionPaused(ollie.activeSessionId)) return "inactive"
return ollie.activeState
}
opacity: 0.7
font.pointSize: theme.fontSize > 0 ? theme.fontSize * 0.85 : 11
}
}
Rectangle { Layout.fillWidth: true; height: 1; color: theme.midColor }
// Chat display — block-based ListView with scroll-to-bottom button
Item {
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
// Placeholder when no agent selected or disconnected
Label {
anchors.centerIn: parent
text: {
if (!ollie.daemonConnected) return "Not connected"
if (!ollie.activeSessionId) return "Select a session"
return "Select an agent to view chat"
}
color: theme.midColor
font.italic: true
font.pixelSize: theme.fontSize > 0 ? theme.fontSize : 12
visible: !ollie.activeAgentId || !ollie.daemonConnected
}
ListView {
id: chatView
model: chatModel
spacing: 4
anchors.fill: parent
anchors.margins: 4
visible: ollie.daemonConnected && ollie.activeAgentId !== ""
onCountChanged: {
if (!scrollButton.visible) chatPane.scrollToBottom()
}
onContentHeightChanged: {
if (!scrollButton.visible) chatPane.scrollToBottom()
}
delegate: ChatBlockDelegate {
width: chatView.width - 8
}
}
// Chat overview and navigation minimap
Rectangle {
id: chatMinimap
anchors.top: chatView.top
anchors.right: parent.right
anchors.bottom: chatView.bottom
width: 24
color: Qt.rgba(theme.midColor.r, theme.midColor.g, theme.midColor.b, 0.05)
visible: chatView.visible && chatModel.count > 0
Column {
anchors.fill: parent
spacing: 1
Repeater {
model: chatModel
Rectangle {
width: chatMinimap.width
height: Math.max(2, chatMinimap.height / Math.max(1, chatModel.count) - 1)
opacity: 0.65
color: {
switch (model.blockType) {
case "user": return "#4caf50"
case "assistant": return "#2196f3"
case "error": return "#f44336"
case "tool": return "#ff9800"
default: return theme.midColor
}
}
}
}
}
MouseArea {
anchors.fill: parent
onClicked: chatView.positionViewAtIndex(
Math.floor(mouse.y / height * chatModel.count), ListView.Beginning)
}
}
Button {
id: scrollButton
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.margins: 12
text: "↓"
visible: chatView.visible && !chatView.atYEnd
onClicked: chatPane.scrollToBottom()
ToolTip.text: "Scroll to bottom"
ToolTip.visible: hovered
}
}
Rectangle { Layout.fillWidth: true; height: 1; color: theme.midColor }
// Input
RowLayout {
Layout.fillWidth: true
Layout.margins: 6
spacing: 4
// TextArea in ScrollView for overflow, with context menu overlay
Item {
Layout.fillWidth: true
Layout.maximumHeight: 200
Layout.preferredHeight: Math.min(promptInput.contentHeight + 16, 200)
clip: true
ScrollView {
id: promptScroll
anchors.fill: parent
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
TextArea {
id: promptInput
width: promptScroll.width
placeholderText: {
if (!ollie.daemonConnected) return "Not connected"
if (!ollie.activeSessionId) return "No session"
if (!ollie.activeAgentId) return "No agent"
if (ollie.isSessionPaused(ollie.activeSessionId)) return "Session paused"
return "Message..."
}
enabled: ollie.daemonConnected && ollie.activeSessionId !== "" && ollie.activeAgentId !== "" && !ollie.isSessionPaused(ollie.activeSessionId)
wrapMode: TextEdit.Wrap
selectByMouse: true
persistentSelection: true
property string _lastText: ""
property int _lastCursorPos: 0
onTextChanged: {
_lastText = text
_lastCursorPos = cursorPosition
}
Keys.onReturnPressed: function(event) {
if (event.modifiers & Qt.ShiftModifier) {
event.accepted = false
} else {
if (text.trim() !== "") {
ollie.submit(text.trim())
text = ""
}
event.accepted = true
}
}
}
}
// Context menu overlay - only catches right-click, on top of ScrollView
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
cursorShape: Qt.IBeamCursor
onClicked: promptContextMenu.popup()
}
Menu {
id: promptContextMenu
MenuItem {
text: "Cut"
enabled: promptInput.selectedText.length > 0
onTriggered: promptInput.cut()
}
MenuItem {
text: "Copy"
enabled: promptInput.selectedText.length > 0
onTriggered: promptInput.copy()
}
MenuItem {
text: "Paste"
enabled: promptInput.canPaste
onTriggered: promptInput.paste()
}
MenuSeparator {}
MenuItem {
text: "Select All"
onTriggered: promptInput.selectAll()
}
}
}
}
}
Connections {
target: chatModel
function onCountChanged() { chatPane.trimVisibleBlocks() }
}
Connections {
target: ollie
function onSessionsChanged() { chatPane.loadChat() }
function onActiveSessionIdChanged() { chatPane.loadChat() }
function onActiveAgentIdChanged() { chatPane.loadChat() }
}
}

169
kde/gui/NewAgentDialog.qml Normal file
View File

@ -0,0 +1,169 @@
// NewAgentDialog.qml — Dialog for creating a new agent in a session
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
Dialog {
id: root
title: "New Agent"
anchors.centerIn: parent
width: 420
modal: true
// Session ID to create the agent in
property string targetSessionId: ""
signal agentCreated()
contentItem: ColumnLayout {
spacing: 16
Layout.margins: 16
Label {
text: "Agent"
font.bold: true
font.pointSize: theme.fontSize > 0 ? theme.fontSize + 1 : Qt.application.font.pointSize + 1
}
Label {
id: errorLabel
Layout.fillWidth: true
visible: text !== ""
color: "red"
wrapMode: Text.Wrap
}
RowLayout {
Layout.fillWidth: true
Label { text: "Directory:"; Layout.preferredWidth: 80 }
TextField {
id: dirField
Layout.fillWidth: true
placeholderText: "/path/to/project"
}
Button {
text: "Browse..."
onClicked: {
var folder = theme.chooseFolder("Select Project Directory", dirField.text || "")
if (folder !== "")
dirField.text = folder
}
}
}
RowLayout {
Layout.fillWidth: true
Label { text: "Backend:"; Layout.preferredWidth: 80 }
ComboBox {
id: backendCombo
Layout.fillWidth: true
enabled: ollie.rootBackendsLoaded
model: ollie.availableBackends
currentIndex: 0
editable: true
onAccepted: backendCombo.editText = backendCombo.currentText
}
}
ListModel { id: modelList }
RowLayout {
Layout.fillWidth: true
Label { text: "Model:"; Layout.preferredWidth: 80 }
ComboBox {
id: modelCombo
Layout.fillWidth: true
enabled: backendCombo.currentIndex >= 0 && backendCombo.currentText.trim() !== "" && ollie.rootModelsLoaded
model: modelList
currentIndex: -1
editable: true
onAccepted: modelCombo.editText = modelCombo.currentText
}
}
RowLayout {
Layout.fillWidth: true
Label { text: "Profile:"; Layout.preferredWidth: 80 }
ComboBox {
id: agentCombo
Layout.fillWidth: true
enabled: ollie.rootAgentsLoaded
model: ollie.availableAgents
currentIndex: 0
}
}
RowLayout {
Layout.fillWidth: true
Label { text: "Name:"; Layout.preferredWidth: 80 }
TextField {
id: aliasField
Layout.fillWidth: true
placeholderText: "(auto-generated)"
}
}
Component.onCompleted: {
if (!ollie.rootBackendsLoaded) ollie.loadRootBackends()
if (!ollie.rootAgentsLoaded) ollie.loadRootAgents()
}
Connections {
target: backendCombo
function onCurrentTextChanged() {
var be = backendCombo.currentText.trim()
modelList.clear()
if (be !== "" && ollie.rootModelsLoaded) {
var models = ollie.getAvailableModels(be)
for (var i = 0; i < models.length; ++i) {
modelList.append({ modelData: models[i] })
}
modelCombo.currentIndex = models.length > 0 ? 0 : -1
} else {
modelCombo.currentIndex = -1
}
}
}
}
footer: RowLayout {
Layout.fillWidth: true
Item { Layout.fillWidth: true }
Button {
text: "OK"
onClicked: {
errorLabel.text = ""
var cwd = dirField.text.trim()
if (cwd === "") {
errorLabel.text = "Directory is required"
return
}
var backend = backendCombo.currentText.trim()
var model = modelCombo.currentIndex >= 0 ? modelCombo.currentText.trim() : ""
var agent = agentCombo.currentText
var agentAlias = aliasField.text.trim()
var newAgentId = ollie.createAgent(root.targetSessionId, cwd, backend, model, agent, "", agentAlias)
var err = ollie.lastError()
if (err === "") {
// Success: refresh then switch to the newly created agent.
ollie.refreshSessions()
ollie.switchAgent(root.targetSessionId, newAgentId)
root.agentCreated()
root.close()
} else {
errorLabel.text = err
}
}
}
Button {
text: "Cancel"
onClicked: root.close()
}
}
onOpened: {
errorLabel.text = ""
dirField.forceActiveFocus()
}
}

View File

@ -0,0 +1,80 @@
// NewSessionDialog.qml — Dialog for creating a new session
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
Dialog {
id: root
title: "New Session"
anchors.centerIn: parent
width: 380
modal: true
signal sessionCreated()
contentItem: ColumnLayout {
spacing: 16
Layout.margins: 16
Label {
id: errorLabel
Layout.fillWidth: true
visible: text !== ""
color: "red"
wrapMode: Text.Wrap
}
RowLayout {
Layout.fillWidth: true
Label { text: "Name:"; Layout.preferredWidth: 80 }
TextField {
id: nameField
Layout.fillWidth: true
placeholderText: "(auto-generated)"
}
}
RowLayout {
Layout.fillWidth: true
Label { text: "Remote:"; Layout.preferredWidth: 80 }
TextField {
id: remoteField
Layout.fillWidth: true
placeholderText: "user@host (optional)"
}
}
}
footer: RowLayout {
Layout.fillWidth: true
Item { Layout.fillWidth: true }
Button {
text: "OK"
onClicked: {
errorLabel.text = ""
var name = nameField.text.trim()
var remote = remoteField.text.trim()
var err = ollie.createSession(name, remote)
if (err === "") {
ollie.refreshSessions()
root.sessionCreated()
root.close()
} else {
errorLabel.text = err
}
}
}
Button {
text: "Cancel"
onClicked: root.close()
}
}
onOpened: {
errorLabel.text = ""
nameField.text = ""
remoteField.text = ""
nameField.forceActiveFocus()
}
}

281
kde/gui/SessionTree.qml Normal file
View File

@ -0,0 +1,281 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
Item {
id: tree
property bool visiblePane: true
property int renamingIndex: -1
property string renamingText: ""
property string killTargetSessionId: ""
property string killTargetAgentSessionId: ""
property string killTargetAgentId: ""
signal newSessionRequested()
signal settingsRequested()
signal newAgentRequested(string sessionId)
signal renameFailed(string itemType, string attemptedName)
ColumnLayout {
anchors.fill: parent
id: sidebarPane
visible: tree.visiblePane
spacing: 0
ToolBar {
Layout.fillWidth: true
RowLayout {
anchors.fill: parent
Label { text: "Sessions"; Layout.fillWidth: true; font.bold: true; leftPadding: 8 }
ToolButton {
text: "\u2795"
onClicked: tree.newSessionRequested()
ToolTip.text: "New Session"
ToolTip.visible: hovered
}
ToolButton {
text: "\u21BB"
onClicked: ollie.refreshSessions()
}
ToolButton {
text: "\u2699"
onClicked: tree.settingsRequested()
ToolTip.text: "Settings"
ToolTip.visible: hovered
}
}
}
ListView {
id: sessionList
Layout.fillWidth: true
Layout.fillHeight: true
model: sessionModel
clip: true
focus: false
activeFocusOnTab: false
// Show placeholder when empty or disconnected
Label {
anchors.centerIn: parent
text: ollie.daemonConnected ? "(no sessions)" : "(not connected)"
color: theme.midColor
font.italic: true
font.pixelSize: theme.fontSize > 0 ? theme.fontSize * 0.9 : 12
visible: sessionList.count === 0 || !ollie.daemonConnected
activeFocusOnTab: false
}
delegate: ItemDelegate {
width: sessionList.width
// Guard against undefined during initial render
height: {
if (!type) return 0
if (type === "header") return 24
if (type === "agent" && !parentExpanded)
return 0
return 28
}
visible: height > 0
property int modelIndex: index
property bool isHeader: type === "header"
// Headers are not interactive
enabled: !isHeader
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
hoverEnabled: false
enabled: !parent.isHeader
onClicked: function(mouse) {
if (mouse.button === Qt.RightButton) {
if (type === "session") {
var item = sessionModel.get(parent.modelIndex)
if (item)
tree.killTargetSessionId = item.id
sessionCtxMenu.popup()
} else if (type === "agent") {
var agentItem = sessionModel.get(parent.modelIndex)
if (agentItem) {
tree.killTargetAgentSessionId = agentItem.parentId
tree.killTargetAgentId = agentItem.agentId
}
agentCtxMenu.popup()
}
return
}
if (renamingIndex >= 0) return
if (type === "session") {
// Find first agent in this session and select it
var foundAgent = null
for (var i = 0; i < sessionModel.count; ++i) {
var item = sessionModel.get(i)
if (item.type === "agent" && item.parentId === id) {
foundAgent = item
break
}
}
if (foundAgent) {
// Select the first agent
ollie.switchAgent(id, foundAgent.agentId)
} else {
// No agents - just select session (clears agent)
ollie.setActiveSessionId(id)
}
sessionModel.setExpanded(parent.modelIndex, !expanded)
} else if (type === "agent") {
ollie.switchAgent(parentId, agentId)
}
}
onDoubleClicked: {
if (type === "session" || type === "agent") {
startRename(parent.modelIndex)
}
}
Menu {
id: sessionCtxMenu
MenuItem {
text: "New Agent..."
onClicked: {
tree.newAgentRequested(tree.killTargetSessionId)
}
}
MenuItem {
text: ollie.isSessionPaused(tree.killTargetSessionId) ? "Resume" : "Pause"
onClicked: {
if (tree.killTargetSessionId) {
if (ollie.isSessionPaused(tree.killTargetSessionId))
ollie.resumeSession(tree.killTargetSessionId)
else
ollie.pauseSession(tree.killTargetSessionId)
}
}
}
MenuItem {
text: "Kill Session"
onClicked: {
if (tree.killTargetSessionId)
ollie.killSession(tree.killTargetSessionId)
ollie.refreshSessions()
}
}
}
Menu {
id: agentCtxMenu
MenuItem {
text: "Kill"
onClicked: {
if (tree.killTargetAgentSessionId && tree.killTargetAgentId)
ollie.killAgent(tree.killTargetAgentSessionId, tree.killTargetAgentId)
ollie.refreshSessions()
}
}
}
}
contentItem: RowLayout {
anchors.fill: parent
spacing: 0
// Indent + expand/collapse indicator
Item {
Layout.preferredWidth: type === "header" ? 8 : (level || 0) * 16 + 16
Layout.preferredHeight: type === "header" ? 24 : 28
Label {
anchors.centerIn: parent
text: type === "session" ? (expanded ? "▼" : "▶") : ""
font.pixelSize: theme.fontSize > 0 ? theme.fontSize * 0.7 : 10
color: theme.textColor
opacity: 0.6
visible: type !== "header"
}
}
// Connection status indicator (session rows only)
Rectangle {
Layout.preferredWidth: 8
Layout.preferredHeight: 8
Layout.alignment: Qt.AlignVCenter
Layout.rightMargin: 6
radius: 4
color: connectionColor || "gray"
visible: type === "session"
}
// Name area
Item {
Layout.fillWidth: true
Layout.preferredHeight: type === "header" ? 24 : 28
TextField {
anchors.fill: parent
text: (modelIndex === renamingIndex) ? renamingText : ""
visible: modelIndex === renamingIndex
verticalAlignment: Text.AlignVCenter
font.pointSize: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize
color: theme.textColor
background: Rectangle {
color: theme.baseColor
border.color: theme.highlightColor
radius: 2
}
onTextChanged: {
if (modelIndex === renamingIndex) {
renamingText = text
}
}
Keys.onReturnPressed: commitRename()
Keys.onEscapePressed: cancelRename()
onAccepted: commitRename()
onVisibleChanged: {
if (visible) {
forceActiveFocus()
selectAll()
}
}
}
Label {
anchors.fill: parent
text: displayName || ""
color: type === "header" ? theme.midColor : theme.textColor
font.pointSize: theme.fontSize > 0 ? theme.fontSize * (type === "header" ? 0.85 : 1) : Qt.application.font.pointSize
font.bold: type === "header"
font.capitalization: type === "header" ? Font.AllUppercase : Font.MixedCase
verticalAlignment: Text.AlignVCenter
visible: modelIndex !== renamingIndex
}
}
}
highlighted: (type === "agent" && agentId === ollie.activeAgentId && parentId === ollie.activeSessionId)
}
}
}
function startRename(index) {
var item = sessionModel.get(index)
if (item.type === "session" || item.type === "agent") {
tree.renamingIndex = index
tree.renamingText = item.type === "session" ? (item.baseName || item.displayName) : item.displayName
}
}
function commitRename() {
if (tree.renamingIndex < 0) return
var item = sessionModel.get(tree.renamingIndex)
var name = tree.renamingText.trim()
if (name !== "") {
var ok = item.type === "session" ? ollie.renameSession(item.id, name) : ollie.renameAgent(item.parentId, item.agentId, name)
if (!ok) tree.renameFailed(item.type, name)
}
tree.renamingIndex = -1
tree.renamingText = ""
}
function cancelRename() { tree.renamingIndex = -1; tree.renamingText = "" }
}

118
kde/gui/SettingsDialog.qml Normal file
View File

@ -0,0 +1,118 @@
// SettingsDialog.qml — Theme and font settings dialog
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
Dialog {
id: root
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
}
Label {
text: "Uses KDE Breeze color schemes from /usr/share/color-schemes/"
opacity: 0.6
wrapMode: Text.Wrap
Layout.fillWidth: true
font.pixelSize: Qt.application.font.pixelSize * 0.9
}
ColumnLayout {
spacing: 8
RadioButton {
text: "Use system theme"
checked: theme.mode === "system"
onClicked: theme.mode = "system"
}
RadioButton {
text: "Breeze Light"
checked: theme.mode === "light"
onClicked: theme.mode = "light"
}
RadioButton {
text: "Breeze Dark"
checked: theme.mode === "dark"
onClicked: theme.mode = "dark"
}
}
Label {
text: theme.isDark ? "Current: dark" : "Current: light"
opacity: 0.5
font.pixelSize: Qt.application.font.pixelSize * 0.85
}
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()
}
}
}

926
kde/gui/chatblockmodel.cpp Normal file
View File

@ -0,0 +1,926 @@
#include "chatblockmodel.h"
#include <QTextDocument>
#include <QRegularExpressionMatch>
#include <QApplication>
#include <QPalette>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QUrl>
#include <QUrlQuery>
#include <QCryptographicHash>
#include <KSyntaxHighlighting/Repository>
#include <KSyntaxHighlighting/Definition>
#include <KSyntaxHighlighting/Theme>
#include <KSyntaxHighlighting/AbstractHighlighter>
#include <KSyntaxHighlighting/State>
#include <KSyntaxHighlighting/Format>
// --- Syntax highlighting ---
class HtmlHighlighter : public KSyntaxHighlighting::AbstractHighlighter
{
public:
QString html;
QString defaultColor; // Fallback color for unhighlighted text
using AbstractHighlighter::AbstractHighlighter;
KSyntaxHighlighting::State processLine(const QString &line,
const KSyntaxHighlighting::State &state)
{
m_line = line;
m_offset = 0;
return highlightLine(line, state);
}
void endLine() {
// Emit any remaining unhighlighted text at end of line
if (m_offset < m_line.length()) {
html += escapeHtml(m_line.mid(m_offset));
}
}
protected:
void applyFormat(int offset, int length,
const KSyntaxHighlighting::Format &format) override
{
// Emit any unhighlighted text before this span
if (offset > m_offset) {
const QString gap = m_line.mid(m_offset, offset - m_offset);
html += escapeHtml(gap);
}
m_offset = offset + length;
const QString text = escapeHtml(m_line.mid(offset, length));
if (format.hasTextColor(theme())) {
html += QStringLiteral("<span style=\"color:")
+ format.textColor(theme()).name()
+ QStringLiteral("\">") + text + QStringLiteral("</span>");
} else if (!defaultColor.isEmpty()) {
html += QStringLiteral("<span style=\"color:")
+ defaultColor
+ QStringLiteral("\">") + text + QStringLiteral("</span>");
} else {
html += text;
}
}
private:
QString escapeHtml(const QString &text) const {
return text.toHtmlEscaped()
.replace(QStringLiteral(" "), QStringLiteral("&nbsp;"))
.replace(QStringLiteral("\t"), QStringLiteral("&nbsp;&nbsp;&nbsp;&nbsp;"));
}
QString m_line;
int m_offset = 0;
};
// --- Static helpers ---
static ChatBlock::Type blockTypeFromString(const QString &typeStr)
{
if (typeStr == "user") return ChatBlock::User;
if (typeStr == "assistant") return ChatBlock::Assistant;
if (typeStr == "reasoning") return ChatBlock::Reasoning;
if (typeStr == "call") return ChatBlock::Call;
if (typeStr == "tool") return ChatBlock::Tool;
if (typeStr == "error") return ChatBlock::Error;
if (typeStr == "info") return ChatBlock::Info;
if (typeStr == "retry") return ChatBlock::Retry;
return ChatBlock::Default;
}
// --- Block Parsing ---
const QRegularExpression ChatBlockModel::s_headerRe(
R"(^\[\[\[([^\]]+)\]\]\]$)");
const QRegularExpression ChatBlockModel::s_endRe(
R"(^\[\[\[end\]\]\]$)");
// Matches opening fence: ```lang or just ```
const QRegularExpression ChatBlockModel::s_fenceOpenRe(
R"(^```([A-Za-z0-9_+.-]*).*$)");
// Matches closing fence: ``` on its own line
const QRegularExpression ChatBlockModel::s_fenceCloseRe(
R"(^```$)");
ChatBlockModel::ChatBlockModel(QObject *parent)
: QAbstractListModel(parent)
, m_repo(new KSyntaxHighlighting::Repository())
, m_netManager(new QNetworkAccessManager(this))
{
connect(m_netManager, &QNetworkAccessManager::finished,
this, &ChatBlockModel::onDiagramReply);
}
ChatBlockModel::~ChatBlockModel()
{
delete m_repo;
}
int ChatBlockModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid()) return 0;
return m_blocks.size();
}
QVariant ChatBlockModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() >= m_blocks.size())
return {};
const ChatBlock &b = m_blocks[index.row()];
switch (role) {
case BlockTypeRole:
switch (b.type) {
case ChatBlock::User: return QStringLiteral("user");
case ChatBlock::Assistant: return QStringLiteral("assistant");
case ChatBlock::Reasoning: return QStringLiteral("reasoning");
case ChatBlock::Call: return QStringLiteral("call");
case ChatBlock::Tool: return QStringLiteral("tool");
case ChatBlock::Error: return QStringLiteral("error");
case ChatBlock::Info: return QStringLiteral("info");
case ChatBlock::Retry: return QStringLiteral("retry");
case ChatBlock::Interrupt: return QStringLiteral("interrupt");
case ChatBlock::Default: return QStringLiteral("default");
}
return {};
case BlockNameRole:
return b.name;
case ContentRole:
return b.content;
case RenderedHtmlRole:
return b.renderedHtml;
case SegmentsRole:
return segmentsToVariant(b.segments);
}
return {};
}
QVariantList ChatBlockModel::segmentsToVariant(const QList<BlockSegment> &segments) const
{
QVariantList result;
for (const BlockSegment &seg : segments) {
QVariantMap m;
m[QStringLiteral("type")] = (seg.type == BlockSegment::Text)
? QStringLiteral("text") : QStringLiteral("diagram");
m[QStringLiteral("html")] = seg.html;
if (seg.type == BlockSegment::Diagram) {
m[QStringLiteral("lang")] = seg.diagramLang;
m[QStringLiteral("source")] = seg.diagramSource;
m[QStringLiteral("svg")] = seg.diagramSvg;
m[QStringLiteral("error")] = seg.diagramError;
m[QStringLiteral("loading")] = seg.diagramLoading;
}
result.append(m);
}
return result;
}
QHash<int, QByteArray> ChatBlockModel::roleNames() const
{
return {
{BlockTypeRole, "blockType"},
{BlockNameRole, "blockName"},
{ContentRole, "blockContent"},
{RenderedHtmlRole, "blockRenderedHtml"},
{SegmentsRole, "blockSegments"},
};
}
void ChatBlockModel::appendData(const QString &text)
{
m_lineBuffer += text;
int idx;
while ((idx = m_lineBuffer.indexOf('\n')) >= 0) {
QString line = m_lineBuffer.left(idx);
m_lineBuffer = m_lineBuffer.mid(idx + 1);
processLine(line);
}
// For streaming: update current block with partial line buffer content
// so the UI shows text as it arrives, not just after newlines.
if (!m_lineBuffer.isEmpty() && !m_blocks.isEmpty() && m_state == State::BlockEnd) {
int blockIndex = m_blocks.size() - 1;
ChatBlock &last = m_blocks[blockIndex];
// Save current segment state, render with partial line, then restore
QList<BlockSegment> savedSegments = last.segments;
QString savedHtml = last.renderedHtml;
renderLastSegmentPartial(blockIndex, m_lineBuffer);
// Emit the partial-updated view
QModelIndex idx = index(blockIndex);
emit dataChanged(idx, idx, {RenderedHtmlRole, SegmentsRole});
// Restore — partial line not committed yet
last.segments = savedSegments;
last.renderedHtml = savedHtml;
}
}
void ChatBlockModel::reset()
{
beginResetModel();
m_blocks.clear();
m_lineBuffer.clear();
m_state = State::BlockStart;
m_inCodeFence = false;
m_fenceLanguage.clear();
m_fenceContent.clear();
m_pendingDiagrams.clear();
m_inFlightRequests.clear();
m_diagramCache.clear(); // Clear cache on reset to pick up fixes
endResetModel();
emit countChanged();
}
void ChatBlockModel::trimFront(int keepCount)
{
if (m_blocks.size() <= keepCount)
return;
int removeCount = m_blocks.size() - keepCount;
beginRemoveRows(QModelIndex(), 0, removeCount - 1);
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
m_blocks.remove(0, removeCount);
#else
m_blocks.erase(m_blocks.begin(), m_blocks.begin() + removeCount);
#endif
endRemoveRows();
emit countChanged();
}
void ChatBlockModel::clearDiagramCache()
{
m_diagramCache.clear();
qDebug() << "Diagram cache cleared";
}
QString ChatBlockModel::getDefaultTextColor() const
{
const auto textColor = QApplication::palette().color(QPalette::Text);
return textColor.name();
}
QString ChatBlockModel::highlightCode(const QString &code, const QString &language) const
{
auto definition = m_repo->definitionForName(language);
if (!definition.isValid())
definition = m_repo->definitionForFileName(QStringLiteral("file.") + language);
const bool dark = QApplication::palette().color(QPalette::Window).lightnessF() < 0.5;
const auto theme = dark
? m_repo->defaultTheme(KSyntaxHighlighting::Repository::DarkTheme)
: m_repo->defaultTheme(KSyntaxHighlighting::Repository::LightTheme);
const QString defaultColor = getDefaultTextColor();
// No valid definition — just escape and return
if (!definition.isValid()) {
QString result = QStringLiteral("<pre style=\"white-space:pre-wrap;color:") + defaultColor + QStringLiteral("\">");
result += code.toHtmlEscaped();
result += QStringLiteral("</pre>");
return result;
}
HtmlHighlighter highlighter;
highlighter.setDefinition(definition);
highlighter.setTheme(theme);
highlighter.defaultColor = defaultColor;
KSyntaxHighlighting::State state;
const QStringList lines = code.split('\n');
for (int i = 0; i < lines.size(); ++i) {
state = highlighter.processLine(lines[i], state);
highlighter.endLine();
if (i + 1 < lines.size())
highlighter.html += QStringLiteral("<br/>");
}
QString result = QStringLiteral("<pre style=\"white-space:pre-wrap;\">");
result += highlighter.html;
result += QStringLiteral("</pre>");
return result;
}
QString ChatBlockModel::highlightCodeWrapped(const QString &innerHtml) const
{
return QStringLiteral("<pre style=\"white-space:pre-wrap;\">") + innerHtml + QStringLiteral("</pre>");
}
QString ChatBlockModel::escapeForHtml(const QString &text) const
{
const QString defaultColor = getDefaultTextColor();
QString result = QStringLiteral("<div style=\"white-space:pre-wrap;color:") + defaultColor + QStringLiteral("\">");
result += text.toHtmlEscaped();
result += QStringLiteral("</div>");
return result;
}
void ChatBlockModel::rebuildBlockHtml(int blockIndex)
{
if (blockIndex < 0 || blockIndex >= m_blocks.size())
return;
ChatBlock &block = m_blocks[blockIndex];
QString html;
QList<BlockSegment> segments;
// Parse the block content for code fences
const QStringList lines = block.content.split('\n');
bool inFence = false;
QString fenceLang;
QStringList fenceLines;
QStringList proseLines;
auto flushProse = [&]() {
if (!proseLines.isEmpty()) {
QString proseHtml = escapeForHtml(proseLines.join('\n'));
html += proseHtml;
BlockSegment seg;
seg.type = BlockSegment::Text;
seg.html = proseHtml;
segments.append(seg);
proseLines.clear();
}
};
auto flushFence = [&](bool complete) {
if (fenceLines.isEmpty()) {
fenceLang.clear();
return;
}
const QString code = fenceLines.join('\n');
if (isDiagramLanguage(fenceLang)) {
BlockSegment seg;
seg.type = BlockSegment::Diagram;
seg.diagramLang = fenceLang;
seg.diagramSource = code;
seg.html = highlightCode(code, fenceLang); // Source view HTML
if (!complete) {
// Streaming — show placeholder, don't request yet
html += diagramPlaceholder(fenceLang);
seg.diagramLoading = true;
} else {
// Complete fence — check cache or request diagram
const QString cacheKey = fenceLang + QStringLiteral(":") +
QString::fromLatin1(QCryptographicHash::hash(
code.toUtf8(), QCryptographicHash::Md5).toHex());
if (m_diagramCache.contains(cacheKey)) {
const QByteArray cached = m_diagramCache.value(cacheKey);
if (cached.startsWith("ERROR:")) {
// Error: fall back to source code rendering (not a diagram segment)
QString codeHtml = highlightCode(code, fenceLang);
html += codeHtml;
BlockSegment textSeg;
textSeg.type = BlockSegment::Text;
textSeg.html = codeHtml;
segments.append(textSeg);
fenceLines.clear();
fenceLang.clear();
return; // Don't append diagram segment
} else {
// Check if JPEG, PNG, or SVG
if (cached.startsWith("JPEG:")) {
QByteArray jpegData = cached.mid(5); // Skip "JPEG:" prefix
html += diagramJpegHtml(jpegData);
seg.diagramSvg = QStringLiteral("data:image/jpeg;base64,") +
QString::fromLatin1(jpegData.toBase64());
} else if (cached.startsWith("PNG:")) {
QByteArray pngData = cached.mid(4); // Skip "PNG:" prefix
html += diagramPngHtml(pngData);
seg.diagramSvg = QStringLiteral("data:image/png;base64,") +
QString::fromLatin1(pngData.toBase64());
} else if (cached.startsWith("SVG:")) {
QByteArray svgData = cached.mid(4); // Skip "SVG:" prefix
html += diagramSvgHtml(svgData);
seg.diagramSvg = QStringLiteral("data:image/svg+xml;base64,") +
QString::fromLatin1(svgData.toBase64());
} else {
// Legacy: assume SVG if no prefix
html += diagramSvgHtml(cached);
seg.diagramSvg = QStringLiteral("data:image/svg+xml;base64,") +
QString::fromLatin1(cached.toBase64());
}
}
} else {
html += diagramPlaceholder(fenceLang);
seg.diagramLoading = true;
// Request diagram asynchronously
requestDiagram(fenceLang, code, blockIndex);
}
}
segments.append(seg);
} else {
QString codeHtml = highlightCode(code, fenceLang);
html += codeHtml;
BlockSegment seg;
seg.type = BlockSegment::Text;
seg.html = codeHtml;
segments.append(seg);
}
fenceLines.clear();
fenceLang.clear();
};
for (const QString &line : lines) {
if (!inFence) {
// Check for fence open
auto match = s_fenceOpenRe.match(line);
if (match.hasMatch()) {
flushProse();
inFence = true;
fenceLang = match.captured(1);
continue;
}
proseLines.append(line);
} else {
// Inside fence — check for close
if (s_fenceCloseRe.match(line).hasMatch()) {
flushFence(true); // complete
inFence = false;
continue;
}
fenceLines.append(line);
}
}
// Handle unclosed fence (streaming) or remaining prose
if (inFence) {
// Still inside a fence — render what we have (incomplete)
flushFence(false);
} else {
flushProse();
}
block.renderedHtml = html;
block.segments = segments;
// Reset incremental state to match the full rebuild
block.renderState = BlockRenderState();
}
void ChatBlockModel::appendContent(const QString &line)
{
if (m_blocks.isEmpty())
return;
int blockIndex = m_blocks.size() - 1;
ChatBlock &last = m_blocks[blockIndex];
if (!last.content.isEmpty())
last.content += '\n';
last.content += line;
incrementalAppend(blockIndex, line);
QModelIndex idx = index(blockIndex);
emit dataChanged(idx, idx, {ContentRole, RenderedHtmlRole, SegmentsRole});
}
void ChatBlockModel::rebuildRenderedHtmlFromSegments(ChatBlock &block)
{
QString html;
for (const BlockSegment &seg : block.segments)
html += seg.html;
block.renderedHtml = html;
}
void ChatBlockModel::incrementalAppend(int blockIndex, const QString &line)
{
ChatBlock &block = m_blocks[blockIndex];
BlockRenderState &rs = block.renderState;
rs.committedLineCount++;
if (!rs.inFence) {
// Check for fence open
auto match = s_fenceOpenRe.match(line);
if (match.hasMatch()) {
// Flush any accumulated prose into a finalized text segment
if (!rs.proseLines.isEmpty()) {
// The last segment should already be Text with these prose lines;
// it was being built up incrementally. Mark it done.
rs.proseLines.clear();
}
rs.inFence = true;
rs.fenceLang = match.captured(1);
rs.fenceLines.clear();
// Add a new Text segment for the code fence (will be rebuilt each line)
BlockSegment seg;
seg.type = BlockSegment::Text;
seg.html = highlightCode(QString(), rs.fenceLang);
block.segments.append(seg);
block.renderedHtml += seg.html;
return;
}
// Normal prose line — extend or create last text segment
rs.proseLines.append(line);
// Incremental: escape only the new line and append to existing segment
const QString defaultColor = getDefaultTextColor();
const QString escapedLine = line.toHtmlEscaped();
// Only extend the last segment if it is an in-progress prose segment
// (identified by its trailing </div>). A finalized code fence segment
// ends in </pre> and must not be overwritten.
bool canExtend = !block.segments.isEmpty()
&& block.segments.last().type == BlockSegment::Text
&& block.segments.last().html.endsWith(QStringLiteral("</div>"));
if (canExtend) {
QString &segHtml = block.segments.last().html;
segHtml.chop(6); // remove trailing </div>
segHtml += '\n' + escapedLine + QStringLiteral("</div>");
} else {
// Start a new prose segment
rs.proseLines.clear();
rs.proseLines.append(line);
BlockSegment seg;
seg.type = BlockSegment::Text;
seg.html = QStringLiteral("<div style=\"white-space:pre-wrap;color:") + defaultColor + QStringLiteral("\">")
+ escapedLine + QStringLiteral("</div>");
block.segments.append(seg);
}
// Rebuild renderedHtml from segments (concatenation is cheap)
rebuildRenderedHtmlFromSegments(block);
} else {
// Inside a fence — check for close
if (s_fenceCloseRe.match(line).hasMatch()) {
// Close the fence — finalize the segment
rs.inFence = false;
const QString code = rs.fenceLines.join('\n');
if (isDiagramLanguage(rs.fenceLang)) {
// Replace last segment with a diagram segment
BlockSegment seg;
seg.type = BlockSegment::Diagram;
seg.diagramLang = rs.fenceLang;
seg.diagramSource = code;
seg.html = highlightCode(code, rs.fenceLang);
const QString cacheKey = rs.fenceLang + QStringLiteral(":") +
QString::fromLatin1(QCryptographicHash::hash(
code.toUtf8(), QCryptographicHash::Md5).toHex());
if (m_diagramCache.contains(cacheKey)) {
const QByteArray cached = m_diagramCache.value(cacheKey);
if (cached.startsWith("ERROR:")) {
// Error: keep as text segment with highlighted source
seg.type = BlockSegment::Text;
} else if (cached.startsWith("JPEG:")) {
seg.diagramSvg = QStringLiteral("data:image/jpeg;base64,") +
QString::fromLatin1(cached.mid(5).toBase64());
} else if (cached.startsWith("PNG:")) {
seg.diagramSvg = QStringLiteral("data:image/png;base64,") +
QString::fromLatin1(cached.mid(4).toBase64());
} else if (cached.startsWith("SVG:")) {
seg.diagramSvg = QStringLiteral("data:image/svg+xml;base64,") +
QString::fromLatin1(cached.mid(4).toBase64());
} else {
seg.diagramSvg = QStringLiteral("data:image/svg+xml;base64,") +
QString::fromLatin1(cached.toBase64());
}
} else {
seg.diagramLoading = true;
requestDiagram(rs.fenceLang, code, blockIndex);
}
// Replace the in-progress fence segment
if (!block.segments.isEmpty())
block.segments.last() = seg;
else
block.segments.append(seg);
} else {
// Normal code: finalize highlighted segment
QString codeHtml = highlightCode(code, rs.fenceLang);
if (!block.segments.isEmpty())
block.segments.last().html = codeHtml;
}
rs.fenceLang.clear();
rs.fenceLines.clear();
rebuildRenderedHtmlFromSegments(block);
} else {
// Append line to fence — incrementally highlight only the new line
rs.fenceLines.append(line);
// Get highlighter definition + theme
auto definition = m_repo->definitionForName(rs.fenceLang);
if (!definition.isValid())
definition = m_repo->definitionForFileName(QStringLiteral("file.") + rs.fenceLang);
if (definition.isValid()) {
const bool dark = QApplication::palette().color(QPalette::Window).lightnessF() < 0.5;
const auto hlTheme = dark
? m_repo->defaultTheme(KSyntaxHighlighting::Repository::DarkTheme)
: m_repo->defaultTheme(KSyntaxHighlighting::Repository::LightTheme);
HtmlHighlighter highlighter;
highlighter.setDefinition(definition);
highlighter.setTheme(hlTheme);
highlighter.defaultColor = getDefaultTextColor();
// Add line break before new line (if not the first)
if (!rs.hlHtml.isEmpty())
rs.hlHtml += QStringLiteral("<br/>");
// Process only the new line, continuing from saved state
rs.hlState = highlighter.processLine(line, rs.hlState);
highlighter.endLine();
rs.hlHtml += highlighter.html;
if (!block.segments.isEmpty())
block.segments.last().html = highlightCodeWrapped(rs.hlHtml);
} else {
// No syntax def — just escape
if (!rs.hlHtml.isEmpty())
rs.hlHtml += '\n';
rs.hlHtml += line.toHtmlEscaped();
const QString defaultColor = getDefaultTextColor();
if (!block.segments.isEmpty())
block.segments.last().html = QStringLiteral("<pre style=\"white-space:pre-wrap;color:") + defaultColor + QStringLiteral("\">")
+ rs.hlHtml + QStringLiteral("</pre>");
}
rebuildRenderedHtmlFromSegments(block);
}
}
}
void ChatBlockModel::renderLastSegmentPartial(int blockIndex, const QString &partialLine)
{
if (blockIndex < 0 || blockIndex >= m_blocks.size())
return;
ChatBlock &block = m_blocks[blockIndex];
BlockRenderState &rs = block.renderState;
if (rs.inFence) {
// Show partial line appended to current fence — use cached state
auto definition = m_repo->definitionForName(rs.fenceLang);
if (!definition.isValid())
definition = m_repo->definitionForFileName(QStringLiteral("file.") + rs.fenceLang);
if (definition.isValid()) {
const bool dark = QApplication::palette().color(QPalette::Window).lightnessF() < 0.5;
const auto hlTheme = dark
? m_repo->defaultTheme(KSyntaxHighlighting::Repository::DarkTheme)
: m_repo->defaultTheme(KSyntaxHighlighting::Repository::LightTheme);
HtmlHighlighter highlighter;
highlighter.setDefinition(definition);
highlighter.setTheme(hlTheme);
highlighter.defaultColor = getDefaultTextColor();
// Process partial line from saved state (don't save back)
QString partialHtml = rs.hlHtml;
if (!partialHtml.isEmpty())
partialHtml += QStringLiteral("<br/>");
highlighter.processLine(partialLine, rs.hlState);
highlighter.endLine();
partialHtml += highlighter.html;
if (!block.segments.isEmpty())
block.segments.last().html = highlightCodeWrapped(partialHtml);
} else {
// No definition — simple escape
QString inner = rs.hlHtml;
if (!inner.isEmpty())
inner += '\n';
inner += partialLine.toHtmlEscaped();
const QString defaultColor = getDefaultTextColor();
if (!block.segments.isEmpty())
block.segments.last().html = QStringLiteral("<pre style=\"white-space:pre-wrap;color:") + defaultColor + QStringLiteral("\">")
+ inner + QStringLiteral("</pre>");
}
} else {
// Show partial line appended to current prose — just escape one line
const QString defaultColor = getDefaultTextColor();
const QString escapedPartial = partialLine.toHtmlEscaped();
if (!block.segments.isEmpty() && block.segments.last().type == BlockSegment::Text) {
// Temporarily append to the segment (caller will restore)
QString &segHtml = block.segments.last().html;
if (segHtml.endsWith(QStringLiteral("</div>"))) {
segHtml.chop(6);
segHtml += '\n' + escapedPartial + QStringLiteral("</div>");
} else {
segHtml = escapeForHtml((rs.proseLines + QStringList{partialLine}).join('\n'));
}
} else {
BlockSegment seg;
seg.type = BlockSegment::Text;
seg.html = QStringLiteral("<div style=\"white-space:pre-wrap;color:") + defaultColor + QStringLiteral("\">")
+ escapedPartial + QStringLiteral("</div>");
block.segments.append(seg);
}
}
rebuildRenderedHtmlFromSegments(block);
}
void ChatBlockModel::processLine(const QString &line)
{
switch (m_state) {
case State::BlockStart: {
// [[[blockname]]] starts a new block
QRegularExpressionMatch match = s_headerRe.match(line);
if (match.hasMatch()) {
const QString blockName = match.captured(1);
beginInsertRows(QModelIndex(), m_blocks.size(), m_blocks.size());
ChatBlock newBlock;
newBlock.type = blockTypeFromString(blockName.section(':', 0, 0));
newBlock.name = blockName.section(':', 1);
m_blocks.append(newBlock);
endInsertRows();
emit countChanged();
m_state = State::BlockEnd;
}
// Lines outside blocks are ignored
return;
}
case State::BlockEnd:
// [[[end]]] closes the current block
if (s_endRe.match(line).hasMatch()) {
// Emit final update so QML picks up the completed segment state.
if (!m_blocks.isEmpty()) {
int blockIndex = m_blocks.size() - 1;
QModelIndex idx = index(blockIndex);
emit dataChanged(idx, idx, {ContentRole, RenderedHtmlRole, SegmentsRole});
}
m_state = State::BlockStart;
return;
}
appendContent(line);
return;
}
}
// --- Diagram rendering ---
bool ChatBlockModel::isDiagramLanguage(const QString &lang) const
{
const QString l = lang.toLower();
return l == QStringLiteral("plantuml") ||
l == QStringLiteral("mermaid") ||
l == QStringLiteral("puml");
}
QString ChatBlockModel::diagramPlaceholder(const QString &lang) const
{
const QString defaultColor = getDefaultTextColor();
return QStringLiteral("<div style=\"padding:8px;border:1px dashed ") + defaultColor +
QStringLiteral(";color:") + defaultColor +
QStringLiteral(";font-style:italic;\">Loading ") +
lang.toHtmlEscaped() + QStringLiteral(" diagram...</div>");
}
QString ChatBlockModel::diagramErrorHtml(const QString &error) const
{
return QStringLiteral("<div style=\"padding:8px;border:1px solid red;color:red;\">") +
QStringLiteral("Diagram error: ") + error.toHtmlEscaped() +
QStringLiteral("</div>");
}
QString ChatBlockModel::diagramSvgHtml(const QByteArray &svg) const
{
// Embed SVG inline as data URI
const QString base64 = QString::fromLatin1(svg.toBase64());
return QStringLiteral("<img src=\"data:image/svg+xml;base64,") + base64 +
QStringLiteral("\" style=\"max-width:100%;\" />");
}
QString ChatBlockModel::diagramPngHtml(const QByteArray &png) const
{
// Embed PNG inline as data URI
const QString base64 = QString::fromLatin1(png.toBase64());
return QStringLiteral("<img src=\"data:image/png;base64,") + base64 +
QStringLiteral("\" style=\"max-width:100%;\" />");
}
QString ChatBlockModel::diagramJpegHtml(const QByteArray &jpeg) const
{
// Embed JPEG inline as data URI
const QString base64 = QString::fromLatin1(jpeg.toBase64());
return QStringLiteral("<img src=\"data:image/jpeg;base64,") + base64 +
QStringLiteral("\" style=\"max-width:100%;\" />");
}
void ChatBlockModel::requestDiagram(const QString &lang, const QString &code, int blockIndex)
{
const QString cacheKey = lang + QStringLiteral(":") +
QString::fromLatin1(QCryptographicHash::hash(
code.toUtf8(), QCryptographicHash::Md5).toHex());
// Already have it cached?
if (m_diagramCache.contains(cacheKey))
return;
// Track which blocks need this diagram
if (!m_pendingDiagrams.contains(cacheKey)) {
m_pendingDiagrams[cacheKey] = QList<int>();
}
if (!m_pendingDiagrams[cacheKey].contains(blockIndex)) {
m_pendingDiagrams[cacheKey].append(blockIndex);
}
// Already requesting?
if (m_inFlightRequests.contains(cacheKey))
return;
m_inFlightRequests.insert(cacheKey);
// Build request
QNetworkRequest request;
request.setAttribute(QNetworkRequest::User, cacheKey);
request.setTransferTimeout(10000);
const QString langLower = lang.toLower();
if (langLower == QStringLiteral("plantuml") || langLower == QStringLiteral("puml")) {
// PlantUML server accepts POST with raw source
request.setUrl(QUrl(QStringLiteral("http://localhost:8080/svg")));
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("text/plain"));
m_netManager->post(request, code.toUtf8());
} else if (langLower == QStringLiteral("mermaid")) {
// Mermaid-ink expects base64 in URL path, /img/ for PNG output
// (SVG uses foreignObject which Qt's SVG renderer doesn't support)
const QByteArray encoded = code.toUtf8().toBase64();
request.setUrl(QUrl(QStringLiteral("http://localhost:8081/img/") + QString::fromLatin1(encoded)));
m_netManager->get(request);
} else {
m_inFlightRequests.remove(cacheKey);
return;
}
}
void ChatBlockModel::onDiagramReply(QNetworkReply *reply)
{
reply->deleteLater();
const QString cacheKey = reply->request().attribute(QNetworkRequest::User).toString();
m_inFlightRequests.remove(cacheKey);
QByteArray imageData;
bool success = false;
QString errorMsg;
if (reply->error() == QNetworkReply::NoError) {
imageData = reply->readAll();
// Check for JPEG/PNG (mermaid) or SVG (plantuml)
if (imageData.startsWith("\xff\xd8\xff")) {
// JPEG image
success = true;
imageData.prepend("JPEG:");
} else if (imageData.startsWith("\x89PNG")) {
// PNG image
success = true;
imageData.prepend("PNG:");
} else if (imageData.contains("<svg") || imageData.startsWith("<?xml")) {
// SVG image
success = true;
imageData.prepend("SVG:");
} else {
errorMsg = QStringLiteral("Invalid response (not SVG, PNG, or JPEG)");
}
} else {
errorMsg = reply->errorString();
}
// Update cache
if (success) {
m_diagramCache[cacheKey] = imageData;
} else {
// Cache error as empty data with special marker
m_diagramCache[cacheKey] = QByteArray("ERROR:") + errorMsg.toUtf8();
}
// Rebuild all blocks waiting for this diagram
const QList<int> blocks = m_pendingDiagrams.take(cacheKey);
for (int blockIndex : blocks) {
if (blockIndex < m_blocks.size()) {
rebuildBlockHtml(blockIndex);
QModelIndex idx = index(blockIndex);
emit dataChanged(idx, idx, {RenderedHtmlRole, SegmentsRole});
}
}
}

156
kde/gui/chatblockmodel.h Normal file
View File

@ -0,0 +1,156 @@
#ifndef CHATBLOCKMODEL_H
#define CHATBLOCKMODEL_H
#include <QAbstractListModel>
#include <QList>
#include <KSyntaxHighlighting/State>
#include <QString>
#include <QStringList>
#include <QRegularExpression>
#include <QHash>
#include <QSet>
#include <QVariantList>
class QNetworkAccessManager;
class QNetworkReply;
namespace KSyntaxHighlighting {
class Repository;
}
// A segment within a block — either text or a diagram
struct BlockSegment {
enum Type { Text, Diagram };
Type type = Text;
QString html; // For Text: rendered HTML; for Diagram: highlighted source HTML
QString diagramLang; // For Diagram: language (plantuml, mermaid)
QString diagramSource; // For Diagram: raw source code
QString diagramSvg; // For Diagram: base64 SVG data URI, or empty if loading/error
QString diagramError; // For Diagram: error message if failed
bool diagramLoading = false; // For Diagram: true if still loading
};
// Incremental rendering state for a block — tracks where we left off so
// appendContent() can extend the last segment without reparsing from scratch.
struct BlockRenderState {
bool inFence = false; // Currently inside a code fence
QString fenceLang; // Language of the open fence
QStringList fenceLines; // Accumulated lines in the open fence
QStringList proseLines; // Accumulated prose lines (not yet flushed)
int committedLineCount = 0; // Number of content lines already processed incrementally
// Incremental syntax highlighting state for open code fences
KSyntaxHighlighting::State hlState; // Highlighter state after last processed line
QString hlHtml; // Accumulated highlighted HTML so far (without pre wrapper)
};
struct ChatBlock {
enum Type {
User,
Assistant,
Reasoning,
Call,
Tool,
Error,
Info,
Retry,
Interrupt,
Default
};
Type type = Default;
QString name;
QString content;
QString renderedHtml; // Legacy: full HTML (kept for compatibility)
QList<BlockSegment> segments; // Structured segments for interactive rendering
BlockRenderState renderState; // Incremental rendering state
};
class ChatBlockModel : public QAbstractListModel
{
Q_OBJECT
Q_PROPERTY(int count READ rowCount NOTIFY countChanged)
public:
// Two-state parser for [[[blockname]]] ... [[[end]]] blocks.
enum class State {
BlockStart, // Waiting for a block header
BlockEnd // Collecting content until [[[end]]]
};
enum Roles {
BlockTypeRole = Qt::UserRole + 1,
BlockNameRole,
ContentRole,
RenderedHtmlRole,
SegmentsRole, // QVariantList of segment maps
};
explicit ChatBlockModel(QObject *parent = nullptr);
~ChatBlockModel() override;
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
QHash<int, QByteArray> roleNames() const override;
Q_INVOKABLE void appendData(const QString &text);
Q_INVOKABLE void reset();
Q_INVOKABLE void trimFront(int keepCount);
Q_INVOKABLE void clearDiagramCache();
signals:
void countChanged();
private slots:
void onDiagramReply(QNetworkReply *reply);
private:
void processLine(const QString &line);
void appendContent(const QString &line);
void rebuildBlockHtml(int blockIndex);
void incrementalAppend(int blockIndex, const QString &line);
void renderLastSegmentPartial(int blockIndex, const QString &partialLine);
static void rebuildRenderedHtmlFromSegments(ChatBlock &block);
QString highlightCode(const QString &code, const QString &language) const;
QString highlightCodeWrapped(const QString &innerHtml) const;
QString escapeForHtml(const QString &text) const;
QString getDefaultTextColor() const;
// Diagram rendering
bool isDiagramLanguage(const QString &lang) const;
void requestDiagram(const QString &lang, const QString &code, int blockIndex);
QString diagramPlaceholder(const QString &lang) const;
QString diagramErrorHtml(const QString &error) const;
QString diagramSvgHtml(const QByteArray &svg) const;
QString diagramPngHtml(const QByteArray &png) const;
QString diagramJpegHtml(const QByteArray &jpeg) const;
// Convert segments to QVariantList for QML
QVariantList segmentsToVariant(const QList<BlockSegment> &segments) const;
State m_state = State::BlockStart;
KSyntaxHighlighting::Repository *m_repo = nullptr;
QNetworkAccessManager *m_netManager = nullptr;
QList<ChatBlock> m_blocks;
QString m_lineBuffer;
// Code fence state (for streaming)
bool m_inCodeFence = false;
QString m_fenceLanguage;
QString m_fenceContent;
// Diagram cache: hash of (lang + code) -> SVG data
QHash<QString, QByteArray> m_diagramCache;
// Pending requests: hash -> block indices waiting for this diagram
QHash<QString, QList<int>> m_pendingDiagrams;
// Track which requests are in flight
QSet<QString> m_inFlightRequests;
static const QRegularExpression s_headerRe;
static const QRegularExpression s_endRe;
static const QRegularExpression s_fenceOpenRe;
static const QRegularExpression s_fenceCloseRe;
};
#endif // CHATBLOCKMODEL_H

View File

@ -0,0 +1 @@
#include "clipboardhelper.h"

59
kde/gui/clipboardhelper.h Normal file
View File

@ -0,0 +1,59 @@
#ifndef CLIPBOARDHELPER_H
#define CLIPBOARDHELPER_H
#include <QObject>
#include <QClipboard>
#include <QGuiApplication>
#include <QImage>
#include <QMimeData>
class ClipboardHelper : public QObject
{
Q_OBJECT
public:
explicit ClipboardHelper(QObject *parent = nullptr) : QObject(parent) {}
// Copy image from data URI (data:image/svg+xml;base64,...) to clipboard
Q_INVOKABLE void copyImage(const QString &dataUri) {
if (!dataUri.startsWith(QStringLiteral("data:image/")))
return;
// Parse data URI: data:image/TYPE;base64,DATA
int commaIdx = dataUri.indexOf(',');
if (commaIdx < 0) return;
QString header = dataUri.left(commaIdx);
QString base64Data = dataUri.mid(commaIdx + 1);
QByteArray imageData = QByteArray::fromBase64(base64Data.toLatin1());
QClipboard *clipboard = QGuiApplication::clipboard();
QMimeData *mimeData = new QMimeData();
if (header.contains(QStringLiteral("svg"))) {
// For SVG, set as both image/svg+xml and try to render as raster
mimeData->setData(QStringLiteral("image/svg+xml"), imageData);
// Also try to load as QImage for apps that don't support SVG
QImage img;
if (img.loadFromData(imageData, "SVG")) {
mimeData->setImageData(img);
}
} else {
// PNG, JPEG, etc.
QImage img;
if (img.loadFromData(imageData)) {
mimeData->setImageData(img);
}
}
clipboard->setMimeData(mimeData);
}
// Copy plain text to clipboard
Q_INVOKABLE void copyText(const QString &text) {
QGuiApplication::clipboard()->setText(text);
}
};
#endif // CLIPBOARDHELPER_H

278
kde/gui/lib9pclient.cpp Normal file
View File

@ -0,0 +1,278 @@
#include "lib9pclient.h"
Lib9pClient::Lib9pClient(QObject *parent)
: QObject(parent)
{
}
Lib9pClient::~Lib9pClient()
{
disconnect();
}
void Lib9pClient::updateLastError()
{
char *err = ollie9p_last_error();
if (err) {
m_lastError = QString::fromUtf8(err);
ollie9p_free(err);
} else {
m_lastError.clear();
}
}
bool Lib9pClient::connect(const QString &addr, const QString &user, const QString &aname)
{
if (m_handle > 0) {
disconnect();
}
QByteArray addrUtf8 = addr.toUtf8();
QByteArray userUtf8 = user.toUtf8();
QByteArray anameUtf8 = aname.toUtf8();
m_handle = ollie9p_connect(addrUtf8.data(), userUtf8.data(), anameUtf8.data());
if (m_handle < 0) {
updateLastError();
return false;
}
m_lastError.clear();
return true;
}
bool Lib9pClient::connectDefault()
{
if (m_handle > 0) {
disconnect();
}
m_handle = ollie9p_connect_default();
if (m_handle < 0) {
updateLastError();
return false;
}
m_lastError.clear();
return true;
}
void Lib9pClient::disconnect()
{
if (m_handle > 0) {
ollie9p_close(m_handle);
m_handle = -1;
}
}
QByteArray Lib9pClient::read(const QString &path)
{
if (m_handle <= 0) {
m_lastError = QStringLiteral("not connected");
return {};
}
QByteArray pathUtf8 = path.toUtf8();
int len = 0;
char *data = ollie9p_read(m_handle, pathUtf8.data(), &len);
if (!data) {
updateLastError();
return {};
}
QByteArray result(data, len);
ollie9p_free(data);
m_lastError.clear();
return result;
}
bool Lib9pClient::write(const QString &path, const QByteArray &data)
{
if (m_handle <= 0) {
m_lastError = QStringLiteral("not connected");
return false;
}
QByteArray pathUtf8 = path.toUtf8();
int written = ollie9p_write(m_handle, pathUtf8.data(),
const_cast<char*>(data.data()), data.size());
if (written < 0) {
updateLastError();
return false;
}
m_lastError.clear();
return true;
}
QByteArray Lib9pClient::rdwr(const QString &path, const QByteArray &data)
{
if (m_handle <= 0) {
m_lastError = QStringLiteral("not connected");
return {};
}
QByteArray pathUtf8 = path.toUtf8();
int outLen = 0;
char *resp = ollie9p_rdwr(m_handle, pathUtf8.data(),
const_cast<char*>(data.data()), data.size(), &outLen);
if (!resp) {
updateLastError();
return {};
}
QByteArray result(resp, outLen);
ollie9p_free(resp);
m_lastError.clear();
return result;
}
QStringList Lib9pClient::ls(const QString &path)
{
if (m_handle <= 0) {
m_lastError = QStringLiteral("not connected");
return {};
}
QByteArray pathUtf8 = path.toUtf8();
char *names = ollie9p_ls(m_handle, pathUtf8.data());
if (!names) {
updateLastError();
return {};
}
QString str = QString::fromUtf8(names);
ollie9p_free(names);
m_lastError.clear();
if (str.isEmpty()) {
return {};
}
return str.split(QLatin1Char('\n'), Qt::SkipEmptyParts);
}
Lib9pClient::StatResult Lib9pClient::stat(const QString &path)
{
StatResult result;
if (m_handle <= 0) {
m_lastError = QStringLiteral("not connected");
return result;
}
QByteArray pathUtf8 = path.toUtf8();
char *data = ollie9p_stat(m_handle, pathUtf8.data());
if (!data) {
updateLastError();
return result;
}
// Parse "mode\tsize\tmtime\tname"
QString str = QString::fromUtf8(data);
ollie9p_free(data);
QStringList parts = str.split(QLatin1Char('\t'));
if (parts.size() >= 4) {
result.mode = parts[0];
result.size = parts[1].toLongLong();
result.mtime = parts[2].toLongLong();
result.name = parts[3];
result.valid = true;
}
m_lastError.clear();
return result;
}
bool Lib9pClient::remove(const QString &path)
{
if (m_handle <= 0) {
m_lastError = QStringLiteral("not connected");
return false;
}
QByteArray pathUtf8 = path.toUtf8();
if (ollie9p_remove(m_handle, pathUtf8.data()) < 0) {
updateLastError();
return false;
}
m_lastError.clear();
return true;
}
bool Lib9pClient::create(const QString &path, uint perm)
{
if (m_handle <= 0) {
m_lastError = QStringLiteral("not connected");
return false;
}
QByteArray pathUtf8 = path.toUtf8();
if (ollie9p_create(m_handle, pathUtf8.data(), perm) < 0) {
updateLastError();
return false;
}
m_lastError.clear();
return true;
}
bool Lib9pClient::mkdir(const QString &path)
{
if (m_handle <= 0) {
m_lastError = QStringLiteral("not connected");
return false;
}
QByteArray pathUtf8 = path.toUtf8();
if (ollie9p_mkdir(m_handle, pathUtf8.data()) < 0) {
updateLastError();
return false;
}
m_lastError.clear();
return true;
}
bool Lib9pClient::rename(const QString &oldPath, const QString &newName)
{
if (m_handle <= 0) {
m_lastError = QStringLiteral("not connected");
return false;
}
QByteArray oldPathUtf8 = oldPath.toUtf8();
QByteArray newNameUtf8 = newName.toUtf8();
if (ollie9p_rename(m_handle, oldPathUtf8.data(), newNameUtf8.data()) < 0) {
updateLastError();
return false;
}
m_lastError.clear();
return true;
}
int Lib9pClient::open(const QString &path)
{
if (m_handle <= 0) {
m_lastError = QStringLiteral("not connected");
return -1;
}
QByteArray pathUtf8 = path.toUtf8();
int fid = ollie9p_open(m_handle, pathUtf8.data());
if (fid < 0) {
updateLastError();
return -1;
}
m_lastError.clear();
return fid;
}
int Lib9pClient::readFid(int fid, char *buf, int bufLen)
{
int n = ollie9p_read_fid(fid, buf, bufLen);
if (n < 0) {
updateLastError();
return -1;
}
m_lastError.clear();
return n;
}
void Lib9pClient::closeFid(int fid)
{
ollie9p_close_fid(fid);
}

63
kde/gui/lib9pclient.h Normal file
View File

@ -0,0 +1,63 @@
#ifndef LIB9PCLIENT_H
#define LIB9PCLIENT_H
#include <QObject>
#include <QString>
#include <QByteArray>
#include <libollie9p.h>
// Qt wrapper for libollie9p - provides a clean C++ interface to the Go 9P library.
// Single persistent connection, thread-safe (Go handles internal locking).
class Lib9pClient : public QObject
{
Q_OBJECT
public:
explicit Lib9pClient(QObject *parent = nullptr);
~Lib9pClient() override;
// Connection management
bool connect(const QString &addr, const QString &user, const QString &aname);
bool connectDefault(); // Uses NAMESPACE and USER env vars
void disconnect();
bool isConnected() const { return m_handle > 0; }
// File operations - all return empty/false on error, check lastError()
QByteArray read(const QString &path);
bool write(const QString &path, const QByteArray &data);
QByteArray rdwr(const QString &path, const QByteArray &data); // Write then read
// Directory operations
QStringList ls(const QString &path);
// Metadata
struct StatResult {
QString mode;
qint64 size;
qint64 mtime;
QString name;
bool valid = false;
};
StatResult stat(const QString &path);
// File management
bool remove(const QString &path);
bool create(const QString &path, uint perm = 0666);
bool mkdir(const QString &path);
bool rename(const QString &oldPath, const QString &newName);
// Streaming API - for continuous reads (chat, statewait, etc.)
int open(const QString &path); // Returns fid handle, -1 on error
int readFid(int fid, char *buf, int bufLen); // Returns bytes read, 0 on EOF, -1 on error
void closeFid(int fid);
// Error handling
QString lastError() const { return m_lastError; }
private:
void updateLastError();
int m_handle = -1;
QString m_lastError;
};
#endif // LIB9PCLIENT_H

52
kde/gui/main.cpp Normal file
View File

@ -0,0 +1,52 @@
// ollie-gui-9p: minimal Qt GUI using 9P for all communication.
// Uses ollie-9p subprocess for 9P transport.
#include <QApplication>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QQuickStyle>
#include "ollie9pclient.h"
#include "sessionmodel.h"
#include "chatblockmodel.h"
#include "thememanager.h"
#include "clipboardhelper.h"
int main(int argc, char *argv[])
{
#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
QApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
QApplication::setAttribute(Qt::AA_UseHighDpiPixmaps);
#endif
QApplication app(argc, argv);
app.setApplicationName("ollie-gui");
if (qEnvironmentVariableIsEmpty("QT_QUICK_CONTROLS_STYLE"))
QQuickStyle::setStyle("Basic");
QQmlApplicationEngine engine;
auto *ollie = new Ollie9pClient(&app);
auto *sessionModel = new SessionModel(ollie, &app);
auto *chatModel = new ChatBlockModel(&app);
auto *theme = new ThemeManager(&app);
auto *clipboard = new ClipboardHelper(&app);
QObject::connect(ollie, &Ollie9pClient::chatReceived,
chatModel, &ChatBlockModel::appendData);
engine.rootContext()->setContextProperty("ollie", ollie);
engine.rootContext()->setContextProperty("sessionModel", sessionModel);
engine.rootContext()->setContextProperty("chatModel", chatModel);
engine.rootContext()->setContextProperty("theme", theme);
engine.rootContext()->setContextProperty("clipboard", clipboard);
const QUrl url(QStringLiteral("qrc:/main.qml"));
engine.load(url);
if (engine.rootObjects().isEmpty())
return -1;
return app.exec();
}

145
kde/gui/main.qml Normal file
View File

@ -0,0 +1,145 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
ApplicationWindow {
id: root
visible: true
width: 1000
height: 600
title: "Ollie"
font.family: theme.fontFamily || Qt.application.font.family
font.pointSize: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize
// Apply theme palette to all Qt Quick Controls
palette.active.window: theme.windowColor
palette.active.windowText: theme.windowTextColor
palette.active.base: theme.baseColor
palette.active.text: theme.textColor
palette.active.button: theme.buttonColor
palette.active.buttonText: theme.buttonTextColor
palette.active.highlight: theme.highlightColor
palette.active.highlightedText: theme.highlightedTextColor
palette.active.mid: theme.midColor
palette.active.dark: theme.darkColor
palette.active.light: theme.lightColor
palette.active.shadow: theme.shadowColor
palette.active.link: theme.linkColor
palette.active.toolTipBase: theme.tooltipBaseColor
palette.active.toolTipText: theme.tooltipTextColor
palette.active.placeholderText: theme.placeholderTextColor
palette.active.alternateBase: theme.alternateBaseColor
palette.inactive.window: theme.windowColor
palette.inactive.windowText: theme.windowTextColor
palette.inactive.base: theme.baseColor
palette.inactive.text: theme.textColor
palette.inactive.button: theme.buttonColor
palette.inactive.buttonText: theme.buttonTextColor
palette.inactive.highlight: theme.highlightColor
palette.inactive.highlightedText: theme.highlightedTextColor
palette.inactive.mid: theme.midColor
palette.inactive.dark: theme.darkColor
palette.inactive.light: theme.lightColor
palette.inactive.shadow: theme.shadowColor
palette.inactive.link: theme.linkColor
palette.inactive.toolTipBase: theme.tooltipBaseColor
palette.inactive.toolTipText: theme.tooltipTextColor
palette.inactive.placeholderText: theme.placeholderTextColor
palette.inactive.alternateBase: theme.alternateBaseColor
palette.disabled.window: theme.windowColor
palette.disabled.windowText: theme.midColor
palette.disabled.base: theme.baseColor
palette.disabled.text: theme.midColor
palette.disabled.button: theme.buttonColor
palette.disabled.buttonText: theme.midColor
palette.disabled.highlight: theme.highlightColor
palette.disabled.highlightedText: theme.highlightedTextColor
palette.disabled.mid: theme.midColor
palette.disabled.dark: theme.darkColor
palette.disabled.light: theme.lightColor
palette.disabled.shadow: theme.shadowColor
Shortcut {
sequence: "Ctrl+B"
onActivated: root.sidebarVisible = !root.sidebarVisible
}
Shortcut {
sequence: "Escape"
onActivated: {
if (sessionTree.renamingIndex >= 0) {
sessionTree.cancelRename()
} else {
// Always send stop - server-side Interrupt() is safe to call
// on an idle agent (no-op). This avoids races where the GUI's
// cached activeState is stale.
ollie.interrupt()
}
}
}
property bool sidebarVisible: true
// --- Rename Error Dialog ---
Dialog {
id: renameErrorDialog
title: "Rename Failed"
anchors.centerIn: parent
width: 320
modal: true
standardButtons: Dialog.Ok
property string itemType: "session"
property string attemptedName: ""
contentItem: Label {
text: "Could not rename " + renameErrorDialog.itemType + " to \"" + renameErrorDialog.attemptedName + "\".\n\nThe name may already be in use."
wrapMode: Text.Wrap
}
}
// --- Settings Dialog ---
SettingsDialog {
id: settingsDialog
}
// --- New Session Dialog ---
NewSessionDialog {
id: newSessionDialog
}
// --- New Agent Dialog ---
NewAgentDialog {
id: newAgentDialog
targetSessionId: ""
}
SplitView {
anchors.fill: parent
SessionTree {
id: sessionTree
SplitView.preferredWidth: root.sidebarVisible ? root.width * 0.15 : 0
SplitView.minimumWidth: root.sidebarVisible ? 300 : 0
visiblePane: root.sidebarVisible
onNewSessionRequested: newSessionDialog.open()
onSettingsRequested: settingsDialog.open()
onNewAgentRequested: function(sessionId) {
newAgentDialog.targetSessionId = sessionId
newAgentDialog.open()
}
onRenameFailed: function(itemType, attemptedName) {
renameErrorDialog.itemType = itemType
renameErrorDialog.attemptedName = attemptedName
renameErrorDialog.open()
}
}
ChatPane {
SplitView.fillWidth: true
SplitView.fillHeight: true
sidebarVisible: root.sidebarVisible
onToggleSidebar: root.sidebarVisible = !root.sidebarVisible
}
}
}

134
kde/gui/nativestreamer.cpp Normal file
View File

@ -0,0 +1,134 @@
#include "nativestreamer.h"
#include <QStandardPaths>
#include <QDebug>
QString NativeStreamer::ollie9pBin()
{
return QStandardPaths::findExecutable("ollie-9p");
}
QString NativeStreamer::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";
}
NativeStreamer::NativeStreamer(RestartPolicy policy, QObject *parent)
: QObject(parent)
, m_policy(policy)
{
m_restartTimer = new QTimer(this);
m_restartTimer->setSingleShot(true);
m_restartTimer->setInterval(500); // 500ms delay before restart
connect(m_restartTimer, &QTimer::timeout, this, [this]() {
if (!m_stopping && !m_path.isEmpty()) {
start(m_path);
}
});
}
NativeStreamer::~NativeStreamer()
{
stop();
}
void NativeStreamer::start(const QString &path)
{
if (m_process) {
stop();
}
m_path = path;
m_stopping = false;
m_process = new QProcess(this);
connect(m_process, &QProcess::readyReadStandardOutput, this, &NativeStreamer::onReadyRead);
connect(m_process, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
this, &NativeStreamer::onProcessFinished);
m_process->start(ollie9pBin(), {"-a", serverAddr(), "read", path});
}
void NativeStreamer::stop()
{
m_stopping = true;
m_restartTimer->stop();
if (m_process) {
disconnect(m_process, nullptr, this, nullptr);
if (m_process->state() != QProcess::NotRunning) {
m_process->terminate(); // SIGTERM first
if (!m_process->waitForFinished(200)) {
m_process->kill(); // SIGKILL if still alive
m_process->waitForFinished(100);
}
}
m_process->deleteLater();
m_process = nullptr;
}
}
bool NativeStreamer::isRunning() const
{
return m_process && m_process->state() == QProcess::Running;
}
void NativeStreamer::setGuard(std::function<bool()> guard)
{
m_guard = guard;
}
void NativeStreamer::onReadyRead()
{
if (m_process) {
QByteArray data = m_process->readAllStandardOutput();
if (!data.isEmpty()) {
emit dataReady(data);
}
}
}
void NativeStreamer::onProcessFinished(int exitCode, QProcess::ExitStatus status)
{
Q_UNUSED(exitCode)
Q_UNUSED(status)
if (m_process) {
m_process->deleteLater();
m_process = nullptr;
}
scheduleRestart();
}
void NativeStreamer::scheduleRestart()
{
if (m_stopping) {
return;
}
bool shouldRestart = false;
switch (m_policy) {
case Oneshot:
shouldRestart = false;
break;
case Looping:
shouldRestart = true;
break;
case Guarded:
shouldRestart = m_guard && m_guard();
break;
}
if (shouldRestart) {
m_restartTimer->start();
} else {
emit finished();
}
}

61
kde/gui/nativestreamer.h Normal file
View File

@ -0,0 +1,61 @@
#ifndef NATIVESTREAMER_H
#define NATIVESTREAMER_H
#include <QObject>
#include <QProcess>
#include <QString>
#include <QTimer>
#include <functional>
/**
* NativeStreamer - Streaming file reader using ollie-9p subprocess.
*
* Uses ollie-9p subprocess for streaming reads (blocking wait files like
* chat, statewait, eventwait). The native library is used for request/response
* operations, but subprocess is needed for streaming because:
* - Blocking reads in Go can't be interrupted by closing the fid
* - Subprocess can be killed cleanly
*
* Restart policies:
* Oneshot — runs once, emits finished on EOF/error
* Looping — always restarts after EOF/error
* Guarded — restarts only if guard() returns true
*/
class NativeStreamer : public QObject
{
Q_OBJECT
public:
enum RestartPolicy { Oneshot, Looping, Guarded };
explicit NativeStreamer(RestartPolicy policy, QObject *parent = nullptr);
~NativeStreamer() override;
void start(const QString &path);
void stop();
bool isRunning() const;
void setGuard(std::function<bool()> guard);
signals:
void dataReady(const QByteArray &data);
void finished(); // Only emitted when not restarting
void errorOccurred(const QString &error);
private slots:
void onReadyRead();
void onProcessFinished(int exitCode, QProcess::ExitStatus status);
private:
void scheduleRestart();
static QString ollie9pBin();
static QString serverAddr();
QString m_path;
QProcess *m_process = nullptr;
RestartPolicy m_policy;
std::function<bool()> m_guard;
QTimer *m_restartTimer = nullptr;
bool m_stopping = false;
};
#endif // NATIVESTREAMER_H

5
kde/gui/ollie.svg Normal file
View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<!-- Font Awesome Free 6.5.1 - CC BY 4.0 License - https://fontawesome.com -->
<path fill="#e91e63" d="M 184 0 C 215 0 240 25 240 56 L 240 456 C 240 487 215 512 184 512 C 155 512 131 490 128 462 C 123 463 118 464 112 464 C 77 464 48 435 48 400 C 48 393 49 385 52 379 C 21 367 0 338 0 304 C 0 272 19 244 46 232 C 37 221 32 207 32 192 C 32 161 54 136 82 129 C 81 124 80 118 80 112 C 80 82 101 57 128 50 C 131 22 155 0 184 0 M 328 0 C 357 0 381 22 384 50 C 412 57 432 82 432 112 C 432 118 431 124 430 129 C 458 136 480 161 480 192 C 480 207 475 221 466 232 C 493 244 512 272 512 304 C 512 338 491 367 460 379 C 463 385 464 393 464 400 C 464 435 435 464 400 464 C 394 464 389 463 384 462 C 381 490 357 512 328 512 C 297 512 272 487 272 456 L 272 56 C 272 25 297 0 328 0"/>
</svg>

After

Width:  |  Height:  |  Size: 907 B

720
kde/gui/ollie9pclient.cpp Normal file
View File

@ -0,0 +1,720 @@
#include "ollie9pclient.h"
#include <QDir>
#include <QSet>
#include <QStandardPaths>
#include <QJsonDocument>
#include <QJsonObject>
#include <QDebug>
Ollie9pClient::Ollie9pClient(QObject *parent)
: QObject(parent)
{
// Initialize native 9P client
m_9p = new Lib9pClient(this);
m_9p->connectDefault();
// Chat stream — guarded, reads agent chat log. Runs continuously while
// an agent is selected; stops on agent switch or daemon disconnect.
m_chat = new NativeStreamer(NativeStreamer::Guarded, this);
m_chat->setGuard([this]() {
return m_daemonConnected && !m_activeSessionId.isEmpty() && !m_agentId.isEmpty();
});
connect(m_chat, &NativeStreamer::dataReady, this, [this](const QByteArray &data) {
if (!data.isEmpty())
emit chatReceived(QString::fromUtf8(data));
});
// State stream — guarded, reads agent statewait. Auto-restarts until
// session/agent is deselected or daemon disconnects (guard returns false).
m_state = new NativeStreamer(NativeStreamer::Guarded, this);
m_state->setGuard([this]() {
return m_daemonConnected && !m_activeSessionId.isEmpty() && !m_agentId.isEmpty();
});
connect(m_state, &NativeStreamer::dataReady, this, [this](const QByteArray &data) {
QString state = QString::fromUtf8(data).trimmed();
if (!state.isEmpty()) {
m_activeState = state;
emit activeStateChanged();
}
});
// Event stream — looping, reads server eventwait for session/agent events.
m_event = new NativeStreamer(NativeStreamer::Looping, this);
connect(m_event, &NativeStreamer::dataReady, this, [this](const QByteArray &data) {
// Events may come in batches separated by newlines
QString text = QString::fromUtf8(data);
for (const QString &line : text.split('\n', Qt::SkipEmptyParts)) {
handleEvent(line.trimmed());
}
});
// Heartbeat timer — detects daemon death
m_heartbeatTimer = new QTimer(this);
m_heartbeatTimer->setInterval(2500); // 2.5 seconds
connect(m_heartbeatTimer, &QTimer::timeout, this, &Ollie9pClient::heartbeat);
m_heartbeatTimer->start();
// Initial connection check and data load
heartbeat();
if (m_daemonConnected) {
refreshSessions();
ensureRootDataLoaded();
startEventStream();
}
}
Ollie9pClient::~Ollie9pClient()
{
if (m_heartbeatTimer) m_heartbeatTimer->stop();
stopAgentStreams();
if (m_event) m_event->stop();
}
void Ollie9pClient::setActiveSessionId(const QString &id)
{
qDebug() << "setActiveSessionId" << id << "prev session" << m_activeSessionId << "prev agent" << m_agentId;
// Session selection must be side-effect free for per-agent streams.
// Even re-selecting the same session should clear agent selection and stop
// statewait/chat so the UI can stay in a pure "session selected" state.
stopStreams();
m_activeSessionId = id;
m_agentId.clear();
m_activeState = "idle";
if (id.isEmpty()) {
emit activeSessionIdChanged();
emit activeStateChanged();
return;
}
emit activeSessionIdChanged();
emit activeAgentIdChanged();
emit activeStateChanged();
}
QString Ollie9pClient::agentKey(const QString &sessionId, const QString &agentId) const
{
return sessionId + "\n" + agentId;
}
void Ollie9pClient::setDaemonConnected(bool connected)
{
if (m_daemonConnected == connected) return;
m_daemonConnected = connected;
emit daemonConnectedChanged();
emit sessionsChanged();
}
void Ollie9pClient::heartbeat()
{
// Try to read a cheap file to verify daemon is alive
if (!m_9p->isConnected()) {
m_9p->connectDefault();
}
QByteArray result = m_9p->read(QStringLiteral("help"));
bool alive = !result.isEmpty();
if (alive && !m_daemonConnected) {
// Daemon came up — refresh state and start event stream
setDaemonConnected(true);
refreshSessions();
ensureRootDataLoaded();
startEventStream();
if (!m_activeSessionId.isEmpty() && !m_agentId.isEmpty()) {
startActiveAgentStreams();
}
} else if (!alive && m_daemonConnected) {
// Daemon died — clear state and stop streams
setDaemonConnected(false);
stopAgentStreams();
if (m_event) m_event->stop();
m_sessions.clear();
m_activeSessionId.clear();
m_agentId.clear();
m_activeState = "idle";
emit sessionsChanged();
emit activeSessionIdChanged();
emit activeAgentIdChanged();
emit activeStateChanged();
m_9p->disconnect();
}
}
void Ollie9pClient::startEventStream()
{
if (!m_daemonConnected) return;
if (m_event->isRunning()) return;
m_event->start(QStringLiteral("eventwait"));
}
QString Ollie9pClient::sessionConnectionColor(const QString &sessionId) const
{
for (const QVariant &v : m_sessions) {
const QVariantMap s = v.toMap();
if (s.value("id").toString() != sessionId) continue;
const bool paused = s.value("paused").toBool();
const bool connected = s.value("connected").toBool();
// Color logic:
// - Gray: paused (intentionally stopped)
// - Green: not paused AND connected
// - Red: not paused AND disconnected (crashed/broken)
// - Yellow: paused BUT still connected (zombie - shouldn't happen)
if (paused && connected) return "yellow"; // zombie
if (paused) return "gray"; // intentionally paused
if (connected) return "green"; // healthy
return "red"; // crashed/broken
}
return "gray";
}
// handleEvent processes a single event from eventwait.
// Event format: "topic payload" where topic is "session.{sid}.{action}" or
// "session.{sid}.agent.{aid}.{action}"
// Optimized for the hot path: agent.state changes
void Ollie9pClient::handleEvent(const QString &eventLine)
{
if (eventLine.isEmpty()) return;
// Split topic and payload (avoid QString allocation where possible)
const int spaceIdx = eventLine.indexOf(QLatin1Char(' '));
const QStringView topic = spaceIdx > 0
? QStringView(eventLine).left(spaceIdx)
: QStringView(eventLine);
const QStringView payload = spaceIdx > 0
? QStringView(eventLine).mid(spaceIdx + 1)
: QStringView();
// Fast path: must start with "session."
if (!topic.startsWith(QLatin1String("session."))) return;
// Parse: session.{sid}.{rest...}
const int firstDot = 7; // length of "session"
const int secondDot = topic.indexOf(QLatin1Char('.'), firstDot + 1);
if (secondDot < 0) return;
const QStringView sessionId = topic.mid(firstDot + 1, secondDot - firstDot - 1);
const QStringView rest = topic.mid(secondDot + 1);
// Check for agent events: "agent.{aid}.{action}"
if (rest.startsWith(QLatin1String("agent."))) {
// Parse agent.{aid}.{action}
const int agentDot = 5; // length of "agent"
const int aidEnd = rest.indexOf(QLatin1Char('.'), agentDot + 1);
if (aidEnd < 0) return;
const QStringView agentId = rest.mid(agentDot + 1, aidEnd - agentDot - 1);
const QStringView agentAction = rest.mid(aidEnd + 1);
if (agentAction == QLatin1String("state")) {
// Hot path: agent state change
if (payload.isEmpty()) return;
// Fast path: check if this is the active agent first (avoids toString for non-active)
const bool isActiveAgent = (sessionId == m_activeSessionId && agentId == m_agentId);
if (isActiveAgent) {
// Super fast path: compare payload directly against current active state
const QString payloadStr = payload.toString();
if (m_activeState == payloadStr) return; // No change
// State changed for active agent
m_activeState = payloadStr;
const QString sidStr = sessionId.toString();
const QString aidStr = agentId.toString();
const QString key = agentKey(sidStr, aidStr);
m_agentStateValues[key] = payloadStr;
emit agentStateChanged(sidStr, aidStr, payloadStr);
emit activeStateChanged();
} else {
// Non-active agent: need to check cache
const QString sidStr = sessionId.toString();
const QString aidStr = agentId.toString();
const QString key = agentKey(sidStr, aidStr);
const QString payloadStr = payload.toString();
// Use iterator to avoid double lookup
auto it = m_agentStateValues.find(key);
if (it != m_agentStateValues.end() && it.value() == payloadStr) return;
m_agentStateValues[key] = payloadStr;
emit agentStateChanged(sidStr, aidStr, payloadStr);
}
} else if (agentAction == QLatin1String("new") || agentAction == QLatin1String("kill")) {
refreshSessions();
}
} else {
// Session-level event
if (rest == QLatin1String("pause") || rest == QLatin1String("resume") ||
rest == QLatin1String("new") || rest == QLatin1String("kill") ||
rest == QLatin1String("rename")) {
refreshSessions();
}
}
}
QString Ollie9pClient::agentState(const QString &sessionId, const QString &agentId) const
{
return m_agentStateValues.value(agentKey(sessionId, agentId), "idle");
}
void Ollie9pClient::refreshSessions()
{
if (!m_daemonConnected) return;
if (!m_9p || !m_9p->isConnected()) return;
QByteArray out = m_9p->read(QStringLiteral("session/idx"));
QString raw = QString::fromUtf8(out);
m_sessions.clear();
// Track if active session/agent still exist during parsing
bool activeSessionFound = false;
bool activeAgentFound = false;
if (!raw.isEmpty()) {
for (const QString &line : raw.split('\n', Qt::SkipEmptyParts)) {
QStringList parts = line.split('\t');
// New format: name\tid\tpaused\tconnected\tcwd\tbackend\tmodel\tagents
// Where agents is semicolon-separated: name:id:state;name:id:state
if (parts.size() < 2) continue;
const QString sessionId = parts.value(1);
const bool isConnected = (parts.value(3) == "true");
const bool isActiveSession = (sessionId == m_activeSessionId);
if (isActiveSession) {
activeSessionFound = true;
}
QVariantMap session;
session["name"] = parts.value(0);
session["id"] = sessionId;
session["paused"] = (parts.value(2) == "true");
session["connected"] = isConnected;
session["cwd"] = parts.value(4);
session["backend"] = parts.value(5);
session["model"] = parts.value(6);
// Parse agents from semicolon-separated list
QVariantList agents;
QString agentStr = parts.value(7);
if (!agentStr.isEmpty()) {
for (const QString &agentEntry : agentStr.split(';', Qt::SkipEmptyParts)) {
QStringList agentParts = agentEntry.split(':');
if (agentParts.size() >= 3) {
const QString agentId = agentParts.value(1);
QVariantMap av;
av["name"] = agentParts.value(0);
av["id"] = agentId;
av["state"] = agentParts.value(2);
agents.append(av);
// Check if this is the active agent
if (isActiveSession && agentId == m_agentId) {
activeAgentFound = true;
}
}
}
}
session["agents"] = agents;
m_sessions.append(session);
}
}
emit sessionsChanged();
// Handle active session/agent removal
if (!m_activeSessionId.isEmpty()) {
if (!activeSessionFound) {
// Session was killed — clear both and stop streams
stopStreams();
m_activeSessionId.clear();
m_agentId.clear();
m_activeState = "idle";
emit activeSessionIdChanged();
emit activeAgentIdChanged();
emit activeStateChanged();
} else if (!m_agentId.isEmpty() && !activeAgentFound) {
// Agent was killed but session still exists — clear agent only
stopStreams();
m_agentId.clear();
m_activeState = "idle";
emit activeAgentIdChanged();
emit activeStateChanged();
}
}
// Auto-select an agent for the active session when none is selected.
// This also handles the first refresh after a session was selected before
// its agent list arrived.
if (!m_activeSessionId.isEmpty() && m_agentId.isEmpty()) {
for (const QVariant &value : m_sessions) {
const QVariantMap session = value.toMap();
if (session.value("id").toString() != m_activeSessionId) continue;
const QVariantList agents = session.value("agents").toList();
if (!agents.isEmpty()) {
const QString aid = agents.first().toMap().value("id").toString();
if (!aid.isEmpty()) switchAgent(m_activeSessionId, aid);
}
break;
}
}
// Auto-select first session and its first agent if none active
if (m_activeSessionId.isEmpty() && !m_sessions.isEmpty()) {
const QVariantMap firstSession = m_sessions.first().toMap();
const QString sid = firstSession.value("id").toString();
const QVariantList agents = firstSession.value("agents").toList();
if (!agents.isEmpty()) {
// Select first agent of first session
const QString aid = agents.first().toMap().value("id").toString();
switchAgent(sid, aid);
} else {
// No agents - just select the session
setActiveSessionId(sid);
}
}
}
QString Ollie9pClient::readLog()
{
if (m_activeSessionId.isEmpty()) return {};
if (!m_9p || !m_9p->isConnected()) return {};
return QString::fromUtf8(m_9p->read(agentPath() + "/log"));
}
QString Ollie9pClient::readLogForSession(const QString &sessionId, const QString &agentId)
{
if (sessionId.isEmpty() || agentId.isEmpty()) return {};
if (!m_9p || !m_9p->isConnected()) return {};
// Use immutable IDs — the 9P namespace resolves them via aliases.
QString path = "session/" + sessionId + "/agent/" + agentId + "/log";
return QString::fromUtf8(m_9p->read(path));
}
bool Ollie9pClient::submit(const QString &prompt)
{
if (m_activeSessionId.isEmpty() || m_agentId.isEmpty() || prompt.trimmed().isEmpty()) return false;
if (!m_9p || !m_9p->isConnected()) return false;
return m_9p->write(agentPath() + "/prompt", prompt.toUtf8());
}
bool Ollie9pClient::interrupt()
{
if (m_activeSessionId.isEmpty() || m_agentId.isEmpty()) return false;
if (!m_9p || !m_9p->isConnected()) return false;
return m_9p->write(agentPath() + "/ctl", "stop");
}
bool Ollie9pClient::kill()
{
if (m_activeSessionId.isEmpty() || m_agentId.isEmpty()) return false;
if (!m_9p || !m_9p->isConnected()) return false;
return m_9p->write(agentPath() + "/ctl", "kill");
}
bool Ollie9pClient::killSession(const QString &sessionId)
{
if (sessionId.isEmpty()) return false;
if (!m_9p || !m_9p->isConnected()) return false;
// If killing the active session, shut down its streams and deselect.
if (sessionId == m_activeSessionId) {
stopStreams();
m_activeSessionId.clear();
m_agentId.clear();
emit activeSessionIdChanged();
emit activeAgentIdChanged();
}
// Use immutable ID — the 9P namespace resolves it via alias.
QString path = "session/" + sessionId + "/ctl";
return m_9p->write(path, "kill");
}
bool Ollie9pClient::pauseSession(const QString &sessionId)
{
if (sessionId.isEmpty()) return false;
if (!m_9p || !m_9p->isConnected()) return false;
QString path = "session/" + sessionId + "/ctl";
if (m_9p->write(path, "pause")) {
refreshSessions();
return true;
}
return false;
}
bool Ollie9pClient::resumeSession(const QString &sessionId)
{
if (sessionId.isEmpty()) return false;
if (!m_9p || !m_9p->isConnected()) return false;
QString path = "session/" + sessionId + "/ctl";
if (m_9p->write(path, "resume")) {
refreshSessions();
return true;
}
return false;
}
bool Ollie9pClient::isSessionPaused(const QString &sessionId) const
{
for (const QVariant &v : m_sessions) {
const QVariantMap s = v.toMap();
if (s.value("id").toString() == sessionId)
return s.value("paused").toBool();
}
return false;
}
QString Ollie9pClient::getConfig()
{
if (m_activeSessionId.isEmpty()) return {};
if (!m_9p || !m_9p->isConnected()) return {};
return QString::fromUtf8(m_9p->read(agentPath() + "/cfg"));
}
QStringList Ollie9pClient::getAgents(const QString &sessionId)
{
if (sessionId.isEmpty()) return {};
if (!m_9p || !m_9p->isConnected()) return {};
// Use immutable ID — the 9P namespace resolves it via alias.
QString path = "session/" + sessionId + "/agent";
QStringList all = m_9p->ls(path);
QStringList agents;
for (const QString &a : all) {
QString name = a.trimmed();
// "new" is the agent creation file, not an actual agent
if (name != "new")
agents.append(name);
}
return agents;
}
void Ollie9pClient::setActiveAgentId(const QString &agentId)
{
if (m_agentId == agentId) return;
stopStreams();
m_agentId = agentId;
m_activeState = "idle";
emit activeAgentIdChanged();
emit activeStateChanged();
}
void Ollie9pClient::switchAgent(const QString &sessionId, const QString &agentId)
{
qDebug() << "switchAgent" << sessionId << agentId << "prev session" << m_activeSessionId << "prev agent" << m_agentId;
if (m_activeSessionId == sessionId && m_agentId == agentId) return;
stopStreams();
m_activeSessionId = sessionId;
m_agentId = agentId;
// Use cached state from live tracking if available, otherwise default to idle
m_activeState = m_agentStateValues.value(agentKey(sessionId, agentId), "idle");
emit activeSessionIdChanged();
emit activeAgentIdChanged();
emit activeStateChanged();
startActiveAgentStreams();
}
void Ollie9pClient::loadRootBackends()
{
if (m_rootBackendsLoaded) return;
if (!m_9p || !m_9p->isConnected()) return;
QByteArray out = m_9p->read(QStringLiteral("backends"));
QString raw = QString::fromUtf8(out).trimmed();
if (!raw.isEmpty()) {
m_availableBackends = raw.split('\n', Qt::SkipEmptyParts);
m_rootBackendsLoaded = true;
emit availableBackendsChanged();
}
emit rootBackendsLoadedChanged();
}
void Ollie9pClient::loadRootAgents()
{
if (m_rootAgentsLoaded) return;
if (!m_9p || !m_9p->isConnected()) return;
QByteArray out = m_9p->read(QStringLiteral("agents"));
QString raw = QString::fromUtf8(out).trimmed();
if (!raw.isEmpty()) {
m_availableAgents = raw.split('\n', Qt::SkipEmptyParts);
m_rootAgentsLoaded = true;
emit availableAgentsChanged();
} else {
m_availableAgents = QStringList() << QStringLiteral("default");
m_rootAgentsLoaded = true;
emit availableAgentsChanged();
}
emit rootAgentsLoadedChanged();
}
QStringList Ollie9pClient::getAvailableModels(const QString &backend) const
{
if (!m_rootModelsLoaded) return {};
auto it = m_rootModels.find(backend);
if (it == m_rootModels.end()) return {};
return it.value().toStringList();
}
bool Ollie9pClient::createQuickSession()
{
if (!m_9p || !m_9p->isConnected()) return false;
QByteArray result = m_9p->rdwr(QStringLiteral("session/new"), "\n");
return !result.isEmpty() || m_9p->lastError().isEmpty();
}
QString Ollie9pClient::createSession(const QString &name, const QString &remote)
{
if (!m_9p || !m_9p->isConnected()) return QStringLiteral("not connected");
QStringList args;
if (!name.isEmpty()) args << "name=" + name;
if (!remote.isEmpty()) args << "remote=" + remote;
QByteArray result = m_9p->rdwr(QStringLiteral("session/new"), (args.join(" ") + "\n").toUtf8());
QString err = m_9p->lastError();
if (!err.isEmpty()) {
return err;
}
return QString(); // success
}
QString Ollie9pClient::createAgent(const QString &sessionId, const QString &cwd, const QString &backend, const QString &model, const QString &agent, const QString &remote, const QString &agentAlias)
{
if (sessionId.isEmpty() || cwd.isEmpty()) return QStringLiteral("session ID and directory are required");
if (!m_9p || !m_9p->isConnected()) return QStringLiteral("not connected");
QStringList agentArgs;
agentArgs << "cwd=" + cwd;
if (!backend.isEmpty()) agentArgs << "backend=" + backend;
if (!model.isEmpty()) agentArgs << "model=" + model;
if (!agent.isEmpty()) agentArgs << "agent=" + agent;
if (!remote.isEmpty()) agentArgs << "remote=" + remote;
if (!agentAlias.isEmpty()) agentArgs << "name=" + agentAlias;
// Use immutable ID — the 9P namespace resolves it via alias.
QString path = "session/" + sessionId + "/agent/new";
QByteArray result = m_9p->rdwr(path, (agentArgs.join(" ") + "\n").toUtf8());
QString err = m_9p->lastError();
if (!err.isEmpty()) {
qDebug() << "createAgent failed:" << err;
return err;
}
// Return the new agent's ID (trimmed) so callers can switch to it immediately.
// Format: "agentid\n"
return QString::fromUtf8(result).trimmed();
}
bool Ollie9pClient::killAgent(const QString &sessionId, const QString &agentId)
{
if (sessionId.isEmpty() || agentId.isEmpty()) return false;
if (!m_9p || !m_9p->isConnected()) return false;
// Use immutable IDs — the 9P namespace resolves them via aliases.
QString path = "session/" + sessionId + "/agent/" + agentId + "/ctl";
return m_9p->write(path, "kill");
}
bool Ollie9pClient::renameSession(const QString &sessionId, const QString &newName)
{
if (sessionId.isEmpty() || newName.isEmpty()) return false;
if (!m_9p || !m_9p->isConnected()) return false;
// Session names are mutable display names. Writing the name file triggers
// the rename. Use the immutable ID to address the session.
QString path = "session/" + sessionId + "/name";
if (m_9p->write(path, (newName + "\n").toUtf8())) {
// With immutable-ID paths, renaming no longer invalidates streams or
// the active selection. Just refresh the session list to pick up the
// new display name.
refreshSessions();
return true;
}
qDebug() << "renameSession failed:" << m_9p->lastError();
return false;
}
bool Ollie9pClient::renameAgent(const QString &sessionId, const QString &agentId, const QString &newName)
{
if (sessionId.isEmpty() || agentId.isEmpty() || newName.isEmpty()) return false;
if (!m_9p || !m_9p->isConnected()) return false;
// Agent names are mutable display names. Write the name file directly
// using immutable IDs to address the path.
QString path = "session/" + sessionId + "/agent/" + agentId + "/name";
if (m_9p->write(path, (newName + "\n").toUtf8())) {
// With immutable-ID paths, renaming no longer invalidates streams.
refreshSessions();
return true;
}
qDebug() << "renameAgent failed:" << m_9p->lastError();
return false;
}
// --- Root data loading (lazy) ---
void Ollie9pClient::ensureRootDataLoaded()
{
if (!m_rootBackendsLoaded) loadRootBackends();
if (!m_rootAgentsLoaded) loadRootAgents();
if (!m_rootModelsLoaded && m_9p && m_9p->isConnected()) {
QByteArray out = m_9p->read(QStringLiteral("models"));
QString raw = QString::fromUtf8(out);
if (!raw.isEmpty()) {
for (const QString &line : raw.split('\n')) {
int tab = line.indexOf('\t');
if (tab < 0) continue;
QString be = line.left(tab).trimmed();
QString mo = line.mid(tab + 1).trimmed();
if (!be.isEmpty() && !mo.isEmpty()) {
QStringList list = m_rootModels[be].toStringList();
list.append(mo);
m_rootModels[be] = QVariant(list);
}
}
m_rootModelsLoaded = true;
emit rootModelsLoadedChanged();
}
}
refreshSessions();
}
// --- Streaming ---
void Ollie9pClient::startActiveAgentStreams()
{
if (!m_daemonConnected) return;
if (m_activeSessionId.isEmpty() || m_agentId.isEmpty()) return;
m_chat->start(agentPath() + "/chat.raw");
m_state->start(agentPath() + "/statewait");
}
void Ollie9pClient::stopAgentStreams()
{
if (m_chat) m_chat->stop();
if (m_state) m_state->stop();
}
void Ollie9pClient::stopStreams()
{
// Daemon and per-agent connectivity monitoring are independent of the
// currently selected agent. Selection changes must not tear them down.
stopAgentStreams();
}
// --- Helpers ---
QString Ollie9pClient::agentPath() const
{
// Use immutable IDs — the 9P namespace resolves them via aliases.
return "session/" + m_activeSessionId + "/agent/" + m_agentId;
}

146
kde/gui/ollie9pclient.h Normal file
View File

@ -0,0 +1,146 @@
#ifndef OLLIE9PCLIENT_H
#define OLLIE9PCLIENT_H
#include <QObject>
#include <QVariantList>
#include <QHash>
#include <QSet>
#include <QTimer>
#if QT_VERSION_MAJOR >= 6
#include <QQmlEngine>
#endif
#include "lib9pclient.h"
#include "nativestreamer.h"
// 9P-based client for ollie. Uses native libollie9p for all operations.
// Streaming files (chat, statewait, eventwait) use NativeStreamer threads.
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 activeAgentId READ activeAgentId NOTIFY activeAgentIdChanged)
Q_PROPERTY(QString activeState READ activeState NOTIFY activeStateChanged)
Q_PROPERTY(bool rootBackendsLoaded READ rootBackendsLoaded NOTIFY rootBackendsLoadedChanged)
Q_PROPERTY(QStringList availableBackends READ availableBackends NOTIFY availableBackendsChanged)
Q_PROPERTY(bool rootAgentsLoaded READ rootAgentsLoaded NOTIFY rootAgentsLoadedChanged)
Q_PROPERTY(QStringList availableAgents READ availableAgents NOTIFY availableAgentsChanged)
Q_PROPERTY(bool rootModelsLoaded READ rootModelsLoaded NOTIFY rootModelsLoadedChanged)
Q_PROPERTY(QString currentBackend READ currentBackend WRITE setCurrentBackend NOTIFY currentBackendChanged)
Q_PROPERTY(bool daemonConnected READ daemonConnected NOTIFY daemonConnectedChanged)
public:
explicit Ollie9pClient(QObject *parent = nullptr);
~Ollie9pClient();
QVariantList sessions() const { return m_sessions; }
QString activeSessionId() const { return m_activeSessionId; }
Q_INVOKABLE void setActiveSessionId(const QString &id);
QString activeAgentId() const { return m_agentId; }
QString activeState() const { return m_activeState; }
bool rootBackendsLoaded() const { return m_rootBackendsLoaded; }
QStringList availableBackends() const { return m_availableBackends; }
bool rootAgentsLoaded() const { return m_rootAgentsLoaded; }
QStringList availableAgents() const { return m_availableAgents; }
bool rootModelsLoaded() const { return m_rootModelsLoaded; }
QString currentBackend() const { return m_currentBackend; }
bool daemonConnected() const { return m_daemonConnected; }
Q_INVOKABLE QString agentState(const QString &sessionId, const QString &agentId) const;
Q_INVOKABLE QString sessionConnectionColor(const QString &sessionId) const;
Q_INVOKABLE void setCurrentBackend(const QString &backend) {
if (m_currentBackend == backend) return;
m_currentBackend = backend;
emit currentBackendChanged();
}
Q_INVOKABLE void refreshSessions();
Q_INVOKABLE QString readLog();
Q_INVOKABLE QString readLogForSession(const QString &sessionId, const QString &agentId);
Q_INVOKABLE bool submit(const QString &prompt);
Q_INVOKABLE bool interrupt();
Q_INVOKABLE bool kill();
Q_INVOKABLE bool killSession(const QString &sessionId);
Q_INVOKABLE bool pauseSession(const QString &sessionId);
Q_INVOKABLE bool resumeSession(const QString &sessionId);
Q_INVOKABLE bool isSessionPaused(const QString &sessionId) const;
Q_INVOKABLE QString getConfig();
Q_INVOKABLE QStringList getAgents(const QString &sessionId);
QString agentKey(const QString &sessionId, const QString &agentId) const;
Q_INVOKABLE void setActiveAgentId(const QString &agentId);
Q_INVOKABLE void switchAgent(const QString &sessionId, const QString &agentId);
Q_INVOKABLE void loadRootBackends();
Q_INVOKABLE void loadRootAgents();
Q_INVOKABLE QStringList getAvailableModels(const QString &backend) const;
Q_INVOKABLE bool createQuickSession();
Q_INVOKABLE QString createSession(const QString &name, const QString &remote);
Q_INVOKABLE QString createAgent(const QString &sessionId, const QString &cwd, const QString &backend, const QString &model, const QString &agent, const QString &remote, const QString &agentAlias);
Q_INVOKABLE bool killAgent(const QString &sessionId, const QString &agentId);
Q_INVOKABLE bool renameSession(const QString &sessionId, const QString &newName);
Q_INVOKABLE bool renameAgent(const QString &sessionId, const QString &agentId, const QString &newName);
Q_INVOKABLE QString lastError() const { return m_9p ? m_9p->lastError() : QString(); }
signals:
void sessionsChanged();
void activeSessionIdChanged();
void activeAgentIdChanged();
void activeStateChanged();
void agentStateChanged(const QString &sessionId, const QString &agentId, const QString &state);
void chatReceived(const QString &text);
void rootBackendsLoadedChanged();
void availableBackendsChanged();
void rootAgentsLoadedChanged();
void availableAgentsChanged();
void rootModelsLoadedChanged();
void currentBackendChanged();
void daemonConnectedChanged();
private:
void stopAgentStreams();
void stopStreams();
QString agentPath() const;
void ensureRootDataLoaded();
void setDaemonConnected(bool connected);
void startActiveAgentStreams();
void startEventStream();
void handleEvent(const QString &eventLine);
void heartbeat();
// Native 9P client (persistent connection, no subprocess overhead)
Lib9pClient *m_9p = nullptr;
// Heartbeat timer — detects daemon death
QTimer *m_heartbeatTimer = nullptr;
QVariantList m_sessions;
QString m_activeSessionId;
QString m_activeState = "idle";
QString m_agentId;
// Root data cache
bool m_rootBackendsLoaded = false;
QStringList m_availableBackends;
bool m_rootAgentsLoaded = false;
QStringList m_availableAgents;
bool m_rootModelsLoaded = false;
QVariantMap m_rootModels;
QString m_currentBackend;
bool m_daemonConnected = false;
// Agent state cache — updated via delta events
QHash<QString, QString> m_agentStateValues;
// Native streaming readers (replace StreamFsm/NinePConnection)
NativeStreamer *m_chat = nullptr; // Oneshot — reads agent chat log
NativeStreamer *m_state = nullptr; // Guarded — reads agent statewait
NativeStreamer *m_event = nullptr; // Looping — reads server eventwait
};
#endif // OLLIE9PCLIENT_H

View File

@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=Ollie
Comment=AI assistant
Exec=ollie-gui
Icon=ollie
Categories=Utility;Development;
Terminal=false
StartupWMClass=ollie-gui

11
kde/gui/qml.qrc Normal file
View File

@ -0,0 +1,11 @@
<RCC>
<qresource prefix="/">
<file>main.qml</file>
<file>SettingsDialog.qml</file>
<file>NewSessionDialog.qml</file>
<file>NewAgentDialog.qml</file>
<file>ChatBlockDelegate.qml</file>
<file>SessionTree.qml</file>
<file>ChatPane.qml</file>
</qresource>
</RCC>

160
kde/gui/sessionmodel.cpp Normal file
View File

@ -0,0 +1,160 @@
#include "sessionmodel.h"
#include "ollie9pclient.h"
SessionModel::SessionModel(Ollie9pClient *client, QObject *parent)
: QAbstractListModel(parent), m_client(client)
{
connect(m_client, &Ollie9pClient::sessionsChanged, this, &SessionModel::rebuild);
connect(m_client, &Ollie9pClient::agentStateChanged, this, &SessionModel::updateAgentState);
rebuild();
}
int SessionModel::rowCount(const QModelIndex &parent) const
{
return parent.isValid() ? 0 : m_rows.size();
}
QVariant SessionModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_rows.size()) return {};
const Row &r = m_rows.at(index.row());
switch (role) {
case TypeRole: return r.type;
case IdRole: return r.id;
case ParentIdRole: return r.parentId;
case AgentIdRole: return r.agentId;
case LevelRole: return r.level;
case ExpandedRole: return r.expanded;
case ParentExpandedRole: return r.parentExpanded;
case StateRole: return r.state;
case ModelRole: return r.model;
case DisplayNameRole: return r.displayName;
case BaseNameRole: return r.baseName;
case ConnectionColorRole: return r.connectionColor;
case PausedRole: return r.paused;
default: return {};
}
}
QHash<int, QByteArray> SessionModel::roleNames() const
{
return {{TypeRole, "type"}, {IdRole, "id"}, {ParentIdRole, "parentId"},
{AgentIdRole, "agentId"}, {LevelRole, "level"}, {ExpandedRole, "expanded"},
{ParentExpandedRole, "parentExpanded"}, {StateRole, "state"}, {ModelRole, "model"},
{DisplayNameRole, "displayName"}, {BaseNameRole, "baseName"},
{ConnectionColorRole, "connectionColor"}, {PausedRole, "paused"}};
}
QString SessionModel::connectionColor(const QVariantMap &session)
{
const bool paused = session.value("paused").toBool();
const bool connected = session.value("connected").toBool();
if (paused && connected) return "yellow";
if (paused) return "gray";
return connected ? "green" : "red";
}
void SessionModel::appendSession(const QVariantMap &session)
{
const QString id = session.value("id").toString();
const QString name = session.value("name").toString().isEmpty() ? id : session.value("name").toString();
const QVariantList agents = session.value("agents").toList();
const bool paused = session.value("paused").toBool();
const bool expanded = m_expanded.value(id, true);
m_expanded[id] = expanded;
Row row;
row.type = "session"; row.id = id; row.expanded = expanded; row.paused = paused;
row.state = session.value("state").toString(); row.model = session.value("model").toString();
row.baseName = name;
row.displayName = agents.isEmpty() ? name : name + " (" + QString::number(agents.size()) + ")";
row.connectionColor = connectionColor(session);
m_rows.append(row);
for (const QVariant &value : agents) {
const QVariantMap agent = value.toMap();
Row child;
child.type = "agent"; child.parentId = id; child.agentId = agent.value("id").toString();
child.level = 1; child.parentExpanded = expanded;
child.state = agent.value("state").toString();
if (child.state.isEmpty()) child.state = "idle";
child.displayName = agent.value("name").toString(); child.paused = paused;
m_rows.append(child);
}
}
void SessionModel::rebuild()
{
beginResetModel();
m_rows.clear();
QSet<QString> present;
bool haveActive = false;
bool haveInactive = false;
for (const QVariant &value : m_client->sessions()) {
const QVariantMap session = value.toMap();
const bool paused = session.value("paused").toBool();
if (paused && !haveInactive) {
Row header; header.type = "header"; header.displayName = "Inactive"; m_rows.append(header); haveInactive = true;
} else if (!paused && !haveActive) {
Row header; header.type = "header"; header.displayName = "Active"; m_rows.append(header); haveActive = true;
}
const QString id = session.value("id").toString();
present.insert(id);
appendSession(session);
}
for (auto it = m_expanded.begin(); it != m_expanded.end();) {
if (!present.contains(it.key())) it = m_expanded.erase(it); else ++it;
}
endResetModel();
emit countChanged();
}
void SessionModel::updateVisibility()
{
for (int i = 0; i < m_rows.size(); ++i) {
if (m_rows[i].type != "session") continue;
for (int j = i + 1; j < m_rows.size() && m_rows[j].type != "session"; ++j)
m_rows[j].parentExpanded = m_rows[i].expanded;
}
if (!m_rows.isEmpty()) emit dataChanged(index(0), index(m_rows.size() - 1), {ParentExpandedRole, ExpandedRole});
}
void SessionModel::setExpanded(int index, bool expanded)
{
if (index < 0 || index >= m_rows.size() || m_rows[index].type != "session") return;
m_rows[index].expanded = expanded;
m_expanded[m_rows[index].id] = expanded;
updateVisibility();
}
QVariantMap SessionModel::get(int index) const
{
QVariantMap result;
if (index < 0 || index >= m_rows.size()) return result;
const Row &r = m_rows.at(index);
result["type"] = r.type; result["id"] = r.id; result["parentId"] = r.parentId;
result["agentId"] = r.agentId; result["level"] = r.level; result["expanded"] = r.expanded;
result["parentExpanded"] = r.parentExpanded; result["state"] = r.state;
result["model"] = r.model; result["displayName"] = r.displayName;
result["baseName"] = r.baseName; result["connectionColor"] = r.connectionColor;
result["paused"] = r.paused;
return result;
}
QString SessionModel::firstAgentId(const QString &sessionId) const
{
for (const Row &row : m_rows)
if (row.type == "agent" && row.parentId == sessionId) return row.agentId;
return {};
}
void SessionModel::updateAgentState(const QString &sessionId, const QString &agentId, const QString &state)
{
for (int i = 0; i < m_rows.size(); ++i) {
if (m_rows[i].type == "agent" && m_rows[i].parentId == sessionId && m_rows[i].agentId == agentId) {
m_rows[i].state = state;
emit dataChanged(index(i), index(i), {StateRole});
return;
}
}
}

76
kde/gui/sessionmodel.h Normal file
View File

@ -0,0 +1,76 @@
#ifndef SESSIONMODEL_H
#define SESSIONMODEL_H
#include <QAbstractListModel>
#include <QHash>
#include <QVariantList>
#include <QSet>
#include <QString>
class Ollie9pClient;
class SessionModel : public QAbstractListModel
{
Q_OBJECT
Q_PROPERTY(int count READ rowCount NOTIFY countChanged)
public:
enum Roles {
TypeRole = Qt::UserRole + 1,
IdRole,
ParentIdRole,
AgentIdRole,
LevelRole,
ExpandedRole,
ParentExpandedRole,
StateRole,
ModelRole,
DisplayNameRole,
BaseNameRole,
ConnectionColorRole,
PausedRole
};
explicit SessionModel(Ollie9pClient *client, QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = {}) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
QHash<int, QByteArray> roleNames() const override;
Q_INVOKABLE QVariantMap get(int index) const;
Q_INVOKABLE void setExpanded(int index, bool expanded);
Q_INVOKABLE QString firstAgentId(const QString &sessionId) const;
signals:
void countChanged();
private slots:
void rebuild();
void updateAgentState(const QString &sessionId, const QString &agentId, const QString &state);
private:
struct Row {
QString type;
QString id;
QString parentId;
QString agentId;
int level = 0;
bool expanded = true;
bool parentExpanded = true;
QString state;
QString model;
QString displayName;
QString baseName;
QString connectionColor;
bool paused = false;
};
static QString connectionColor(const QVariantMap &session);
void appendSession(const QVariantMap &session);
void updateVisibility();
Ollie9pClient *m_client;
QList<Row> m_rows;
QHash<QString, bool> m_expanded;
};
#endif

467
kde/gui/thememanager.cpp Normal file
View File

@ -0,0 +1,467 @@
#include "thememanager.h"
#include <QGuiApplication>
#include <QDir>
#include <QFile>
#include <QStandardPaths>
#include <QFontDatabase>
#include <QFileDialog>
#include <KConfigGroup>
#include <QDebug>
ThemeManager::ThemeManager(QObject *parent)
: QObject(parent)
, m_mode("system")
, m_fontSize(0)
, m_closeToTray(false)
, m_watcher(nullptr)
{
loadSettings();
// Watch KDE global settings for system theme changes (via KConfigWatcher)
auto config = KSharedConfig::openConfig("kdeglobals");
m_configWatcher = KConfigWatcher::create(config);
connect(m_configWatcher.data(), &KConfigWatcher::configChanged,
this, [this](const KConfigGroup &group, const QByteArrayList &names) {
Q_UNUSED(names);
if (group.name() == "General" && m_mode == "system") {
applyPalette();
emit paletteChanged();
}
});
applyPalette();
applyFont();
}
QString ThemeManager::mode() const
{
return m_mode;
}
void ThemeManager::setMode(const QString &mode)
{
if (mode == m_mode)
return;
m_mode = mode;
saveSettings();
applyPalette();
emit modeChanged();
emit paletteChanged();
}
QStringList ThemeManager::availableSchemes() const
{
QStringList schemes;
QStringList dirs = schemeDirs();
for (const QString &dirPath : dirs) {
QDir dir(dirPath);
const auto entries = dir.entryInfoList({"*.colors"}, QDir::Files, QDir::Name);
for (const auto &entry : entries) {
if (!schemes.contains(entry.baseName()))
schemes << entry.baseName();
}
}
schemes.sort(Qt::CaseInsensitive);
return schemes;
}
bool ThemeManager::isDark() const
{
QPalette pal = QGuiApplication::palette();
// Use window background luminance to determine dark/light
QColor bg = pal.color(QPalette::Window);
qreal luminance = 0.2126 * bg.redF() + 0.7152 * bg.greenF() + 0.0722 * bg.blueF();
return luminance < 0.5;
}
void ThemeManager::applyPalette()
{
QString path;
if (m_mode == "system") {
path = systemSchemePath();
} else {
// Mode is a scheme name (e.g. "BreezeLight", "BreezeDark",
// "CatppuccinMochaMauve", etc.)
path = schemePath(m_mode);
}
if (path.isEmpty() || !QFile::exists(path)) {
// Fallback: don't override, let platform theme handle it
return;
}
m_palette = paletteFromScheme(path);
QGuiApplication::setPalette(m_palette);
}
bool ThemeManager::overriding() const
{
// Always provide colors (even in system mode) to fix FolderDialog
return true;
}
QColor ThemeManager::windowColor() const { return m_palette.color(QPalette::Active, QPalette::Window); }
QColor ThemeManager::windowTextColor() const { return m_palette.color(QPalette::Active, QPalette::WindowText); }
QColor ThemeManager::baseColor() const { return m_palette.color(QPalette::Active, QPalette::Base); }
QColor ThemeManager::alternateBaseColor() const { return m_palette.color(QPalette::Active, QPalette::AlternateBase); }
QColor ThemeManager::textColor() const { return m_palette.color(QPalette::Active, QPalette::Text); }
QColor ThemeManager::buttonColor() const { return m_palette.color(QPalette::Active, QPalette::Button); }
QColor ThemeManager::buttonTextColor() const { return m_palette.color(QPalette::Active, QPalette::ButtonText); }
QColor ThemeManager::highlightColor() const { return m_palette.color(QPalette::Active, QPalette::Highlight); }
QColor ThemeManager::highlightedTextColor() const { return m_palette.color(QPalette::Active, QPalette::HighlightedText); }
QColor ThemeManager::tooltipBaseColor() const { return m_palette.color(QPalette::Active, QPalette::ToolTipBase); }
QColor ThemeManager::tooltipTextColor() const { return m_palette.color(QPalette::Active, QPalette::ToolTipText); }
QColor ThemeManager::linkColor() const { return m_palette.color(QPalette::Active, QPalette::Link); }
QColor ThemeManager::midColor() const { return m_palette.color(QPalette::Active, QPalette::Mid); }
QColor ThemeManager::darkColor() const { return m_palette.color(QPalette::Active, QPalette::Dark); }
QColor ThemeManager::lightColor() const { return m_palette.color(QPalette::Active, QPalette::Light); }
QColor ThemeManager::shadowColor() const { return m_palette.color(QPalette::Active, QPalette::Shadow); }
QColor ThemeManager::placeholderTextColor() const { return m_palette.color(QPalette::Active, QPalette::PlaceholderText); }
QPalette ThemeManager::paletteFromScheme(const QString &path) const
{
QSettings scheme(path, QSettings::IniFormat);
QPalette pal;
// Read active/normal colors
// Window
scheme.beginGroup("Colors:Window");
QColor windowBg = parseColor(scheme.value("BackgroundNormal"));
QColor windowFg = parseColor(scheme.value("ForegroundNormal"));
QColor windowInactive = parseColor(scheme.value("ForegroundInactive"));
scheme.endGroup();
// View
scheme.beginGroup("Colors:View");
QColor viewBg = parseColor(scheme.value("BackgroundNormal"));
QColor viewAltBg = parseColor(scheme.value("BackgroundAlternate"));
QColor viewFg = parseColor(scheme.value("ForegroundNormal"));
QColor viewLink = parseColor(scheme.value("ForegroundLink"));
QColor viewVisited = parseColor(scheme.value("ForegroundVisited"));
QColor viewInactive = parseColor(scheme.value("ForegroundInactive"));
scheme.endGroup();
// Button
scheme.beginGroup("Colors:Button");
QColor buttonBg = parseColor(scheme.value("BackgroundNormal"));
QColor buttonFg = parseColor(scheme.value("ForegroundNormal"));
scheme.endGroup();
// Selection
scheme.beginGroup("Colors:Selection");
QColor selBg = parseColor(scheme.value("BackgroundNormal"));
QColor selFg = parseColor(scheme.value("ForegroundNormal"));
scheme.endGroup();
// Tooltip
scheme.beginGroup("Colors:Tooltip");
QColor tipBg = parseColor(scheme.value("BackgroundNormal"));
QColor tipFg = parseColor(scheme.value("ForegroundNormal"));
scheme.endGroup();
// Compute mid/dark/light/shadow from window background
QColor mid = QColor(
(windowBg.red() + windowFg.red()) / 2,
(windowBg.green() + windowFg.green()) / 2,
(windowBg.blue() + windowFg.blue()) / 2
);
QColor dark = windowBg.darker(150);
QColor light = windowBg.lighter(150);
QColor shadow = windowBg.darker(200);
QColor midlight = windowBg.lighter(125);
// Apply to Active group
pal.setColor(QPalette::Active, QPalette::Window, windowBg);
pal.setColor(QPalette::Active, QPalette::WindowText, windowFg);
pal.setColor(QPalette::Active, QPalette::Base, viewBg);
pal.setColor(QPalette::Active, QPalette::AlternateBase, viewAltBg);
pal.setColor(QPalette::Active, QPalette::Text, viewFg);
pal.setColor(QPalette::Active, QPalette::Link, viewLink);
pal.setColor(QPalette::Active, QPalette::LinkVisited, viewVisited);
pal.setColor(QPalette::Active, QPalette::Button, buttonBg);
pal.setColor(QPalette::Active, QPalette::ButtonText, buttonFg);
pal.setColor(QPalette::Active, QPalette::Highlight, selBg);
pal.setColor(QPalette::Active, QPalette::HighlightedText, selFg);
pal.setColor(QPalette::Active, QPalette::ToolTipBase, tipBg);
pal.setColor(QPalette::Active, QPalette::ToolTipText, tipFg);
pal.setColor(QPalette::Active, QPalette::Mid, mid);
pal.setColor(QPalette::Active, QPalette::Dark, dark);
pal.setColor(QPalette::Active, QPalette::Light, light);
pal.setColor(QPalette::Active, QPalette::Shadow, shadow);
pal.setColor(QPalette::Active, QPalette::Midlight, midlight);
pal.setColor(QPalette::Active, QPalette::PlaceholderText, viewInactive);
pal.setColor(QPalette::Active, QPalette::BrightText, selFg);
// Inactive group — same as active but with dimmed foreground
pal.setColor(QPalette::Inactive, QPalette::Window, windowBg);
pal.setColor(QPalette::Inactive, QPalette::WindowText, windowInactive);
pal.setColor(QPalette::Inactive, QPalette::Base, viewBg);
pal.setColor(QPalette::Inactive, QPalette::AlternateBase, viewAltBg);
pal.setColor(QPalette::Inactive, QPalette::Text, viewFg);
pal.setColor(QPalette::Inactive, QPalette::Link, viewLink);
pal.setColor(QPalette::Inactive, QPalette::LinkVisited, viewVisited);
pal.setColor(QPalette::Inactive, QPalette::Button, buttonBg);
pal.setColor(QPalette::Inactive, QPalette::ButtonText, buttonFg);
pal.setColor(QPalette::Inactive, QPalette::Highlight, selBg);
pal.setColor(QPalette::Inactive, QPalette::HighlightedText, selFg);
pal.setColor(QPalette::Inactive, QPalette::ToolTipBase, tipBg);
pal.setColor(QPalette::Inactive, QPalette::ToolTipText, tipFg);
pal.setColor(QPalette::Inactive, QPalette::Mid, mid);
pal.setColor(QPalette::Inactive, QPalette::Dark, dark);
pal.setColor(QPalette::Inactive, QPalette::Light, light);
pal.setColor(QPalette::Inactive, QPalette::Shadow, shadow);
pal.setColor(QPalette::Inactive, QPalette::Midlight, midlight);
pal.setColor(QPalette::Inactive, QPalette::PlaceholderText, viewInactive);
pal.setColor(QPalette::Inactive, QPalette::BrightText, selFg);
// Disabled group — dimmed versions
QColor disabledFg = windowInactive;
pal.setColor(QPalette::Disabled, QPalette::Window, windowBg);
pal.setColor(QPalette::Disabled, QPalette::WindowText, disabledFg);
pal.setColor(QPalette::Disabled, QPalette::Base, viewBg);
pal.setColor(QPalette::Disabled, QPalette::AlternateBase, viewAltBg);
pal.setColor(QPalette::Disabled, QPalette::Text, disabledFg);
pal.setColor(QPalette::Disabled, QPalette::Link, viewLink);
pal.setColor(QPalette::Disabled, QPalette::LinkVisited, viewVisited);
pal.setColor(QPalette::Disabled, QPalette::Button, buttonBg);
pal.setColor(QPalette::Disabled, QPalette::ButtonText, disabledFg);
pal.setColor(QPalette::Disabled, QPalette::Highlight, selBg);
pal.setColor(QPalette::Disabled, QPalette::HighlightedText, selFg);
pal.setColor(QPalette::Disabled, QPalette::ToolTipBase, tipBg);
pal.setColor(QPalette::Disabled, QPalette::ToolTipText, tipFg);
pal.setColor(QPalette::Disabled, QPalette::Mid, mid);
pal.setColor(QPalette::Disabled, QPalette::Dark, dark);
pal.setColor(QPalette::Disabled, QPalette::Light, light);
pal.setColor(QPalette::Disabled, QPalette::Shadow, shadow);
pal.setColor(QPalette::Disabled, QPalette::Midlight, midlight);
pal.setColor(QPalette::Disabled, QPalette::PlaceholderText, disabledFg);
pal.setColor(QPalette::Disabled, QPalette::BrightText, selFg);
return pal;
}
QColor ThemeManager::parseColor(const QVariant &value) const
{
QStringList parts;
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
if (value.typeId() == QMetaType::QStringList) {
#else
if (value.type() == QVariant::StringList) {
#endif
parts = value.toStringList();
} else {
parts = value.toString().split(',');
}
if (parts.size() >= 3) {
return QColor(parts[0].trimmed().toInt(),
parts[1].trimmed().toInt(),
parts[2].trimmed().toInt());
}
return QColor();
}
QString ThemeManager::systemSchemePath() const
{
// Read current scheme from kdeglobals via KConfig
auto config = KSharedConfig::openConfig("kdeglobals");
config->reparseConfiguration(); // Ensure fresh read
KConfigGroup general(config, "General");
QString schemeName = general.readEntry("ColorScheme", QString());
if (!schemeName.isEmpty()) {
QString path = schemePath(schemeName);
if (QFile::exists(path))
return path;
}
// Fallback to BreezeLight
return schemePath("BreezeLight");
}
QString ThemeManager::schemePath(const QString &name) const
{
for (const QString &dir : schemeDirs()) {
QString path = QString("%1/%2.colors").arg(dir, name);
if (QFile::exists(path))
return path;
}
// Fallback to system dir even if file doesn't exist
return QString("%1/%2.colors").arg(SCHEMES_DIR, name);
}
QStringList ThemeManager::schemeDirs() const
{
QStringList dirs;
// User dir first (higher priority)
QString userDir = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation)
+ "/color-schemes";
if (QDir(userDir).exists())
dirs << userDir;
dirs << SCHEMES_DIR;
return dirs;
}
// --- Font methods ---
QString ThemeManager::fontFamily() const
{
return m_fontFamily;
}
void ThemeManager::setFontFamily(const QString &family)
{
if (family == m_fontFamily)
return;
m_fontFamily = family;
saveSettings();
applyFont();
emit fontChanged();
}
int ThemeManager::fontSize() const
{
return m_fontSize;
}
void ThemeManager::setFontSize(int size)
{
if (size < 6) size = 6;
if (size > 72) size = 72;
if (size == m_fontSize)
return;
m_fontSize = size;
saveSettings();
applyFont();
emit fontChanged();
}
QString ThemeManager::fontStyle() const
{
return m_fontStyle;
}
void ThemeManager::setFontStyle(const QString &style)
{
if (style == m_fontStyle)
return;
m_fontStyle = style;
saveSettings();
applyFont();
emit fontChanged();
}
QStringList ThemeManager::availableFamilies() const
{
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
return QFontDatabase::families();
#else
return QFontDatabase().families();
#endif
}
QStringList ThemeManager::availableStyles() const
{
QString family = m_fontFamily.isEmpty()
? QGuiApplication::font().family()
: m_fontFamily;
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
return QFontDatabase::styles(family);
#else
return QFontDatabase().styles(family);
#endif
}
// --- Close-to-tray ---
bool ThemeManager::closeToTray() const
{
return m_closeToTray;
}
void ThemeManager::setCloseToTray(bool enabled)
{
if (enabled == m_closeToTray)
return;
m_closeToTray = enabled;
saveSettings();
emit closeToTrayChanged();
}
void ThemeManager::resetFont()
{
m_fontFamily.clear();
m_fontSize = 0;
m_fontStyle.clear();
saveSettings();
applyFont();
emit fontChanged();
}
QString ThemeManager::chooseFolder(const QString &title, const QString &startDir)
{
QString dir = QFileDialog::getExistingDirectory(
nullptr, title,
startDir.isEmpty() ? QDir::homePath() : startDir,
QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks);
return dir;
}
void ThemeManager::applyFont()
{
QFont font;
if (!m_fontFamily.isEmpty())
font.setFamily(m_fontFamily);
if (m_fontSize > 0)
font.setPointSize(m_fontSize);
if (!m_fontStyle.isEmpty()) {
// Use QFontDatabase to resolve style string to weight + italic
QString family = m_fontFamily.isEmpty() ? font.family() : m_fontFamily;
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
QFont styled = QFontDatabase::font(family, m_fontStyle,
m_fontSize > 0 ? m_fontSize : font.pointSize());
#else
QFont styled = QFontDatabase().font(family, m_fontStyle,
m_fontSize > 0 ? m_fontSize : font.pointSize());
#endif
font.setWeight(styled.weight());
font.setItalic(styled.italic());
}
QGuiApplication::setFont(font);
}
void ThemeManager::loadSettings()
{
QString configDir = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation);
QString configFile = configDir + "/theme.conf";
if (QFile::exists(configFile)) {
QSettings settings(configFile, QSettings::IniFormat);
m_mode = settings.value("General/mode", "system").toString();
m_fontFamily = settings.value("Font/family", "").toString();
m_fontSize = settings.value("Font/size", 0).toInt();
m_fontStyle = settings.value("Font/style", "").toString();
m_closeToTray = settings.value("General/closeToTray", false).toBool();
}
}
void ThemeManager::saveSettings()
{
QString configDir = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation);
QDir().mkpath(configDir);
QString configFile = configDir + "/theme.conf";
QSettings settings(configFile, QSettings::IniFormat);
settings.setValue("General/mode", m_mode);
settings.setValue("Font/family", m_fontFamily);
settings.setValue("Font/size", m_fontSize);
settings.setValue("Font/style", m_fontStyle);
settings.setValue("General/closeToTray", m_closeToTray);
}

133
kde/gui/thememanager.h Normal file
View File

@ -0,0 +1,133 @@
#ifndef OLLIE_THEME_MANAGER_H
#define OLLIE_THEME_MANAGER_H
#include <QObject>
#include <QPalette>
#include <QSettings>
#include <QFileSystemWatcher>
#include <KConfigWatcher>
#include <KSharedConfig>
#include <QColor>
#include <QStringList>
#include <QFont>
#include <QVariant>
class ThemeManager : public QObject
{
Q_OBJECT
Q_PROPERTY(QString mode READ mode WRITE setMode NOTIFY modeChanged)
Q_PROPERTY(QStringList availableSchemes READ availableSchemes CONSTANT)
Q_PROPERTY(bool isDark READ isDark NOTIFY paletteChanged)
Q_PROPERTY(bool overriding READ overriding NOTIFY paletteChanged)
// Palette color properties for QML binding
Q_PROPERTY(QColor windowColor READ windowColor NOTIFY paletteChanged)
Q_PROPERTY(QColor windowTextColor READ windowTextColor NOTIFY paletteChanged)
Q_PROPERTY(QColor baseColor READ baseColor NOTIFY paletteChanged)
Q_PROPERTY(QColor alternateBaseColor READ alternateBaseColor NOTIFY paletteChanged)
Q_PROPERTY(QColor textColor READ textColor NOTIFY paletteChanged)
Q_PROPERTY(QColor buttonColor READ buttonColor NOTIFY paletteChanged)
Q_PROPERTY(QColor buttonTextColor READ buttonTextColor NOTIFY paletteChanged)
Q_PROPERTY(QColor highlightColor READ highlightColor NOTIFY paletteChanged)
Q_PROPERTY(QColor highlightedTextColor READ highlightedTextColor NOTIFY paletteChanged)
Q_PROPERTY(QColor tooltipBaseColor READ tooltipBaseColor NOTIFY paletteChanged)
Q_PROPERTY(QColor tooltipTextColor READ tooltipTextColor NOTIFY paletteChanged)
Q_PROPERTY(QColor linkColor READ linkColor NOTIFY paletteChanged)
Q_PROPERTY(QColor midColor READ midColor NOTIFY paletteChanged)
Q_PROPERTY(QColor darkColor READ darkColor NOTIFY paletteChanged)
Q_PROPERTY(QColor lightColor READ lightColor NOTIFY paletteChanged)
Q_PROPERTY(QColor shadowColor READ shadowColor NOTIFY paletteChanged)
Q_PROPERTY(QColor placeholderTextColor READ placeholderTextColor NOTIFY paletteChanged)
// Font properties
Q_PROPERTY(QString fontFamily READ fontFamily WRITE setFontFamily NOTIFY fontChanged)
Q_PROPERTY(int fontSize READ fontSize WRITE setFontSize NOTIFY fontChanged)
Q_PROPERTY(QString fontStyle READ fontStyle WRITE setFontStyle NOTIFY fontChanged)
Q_PROPERTY(QStringList availableFamilies READ availableFamilies CONSTANT)
Q_PROPERTY(QStringList availableStyles READ availableStyles NOTIFY fontChanged)
// Close-to-tray
Q_PROPERTY(bool closeToTray READ closeToTray WRITE setCloseToTray NOTIFY closeToTrayChanged)
public:
enum Mode {
System, // Follow KDE system color scheme
Light, // Force BreezeLight
Dark // Force BreezeDark
};
Q_ENUM(Mode)
explicit ThemeManager(QObject *parent = nullptr);
QString mode() const;
void setMode(const QString &mode);
QStringList availableSchemes() const;
bool isDark() const;
bool overriding() const;
// Color accessors (from stored palette)
QColor windowColor() const;
QColor windowTextColor() const;
QColor baseColor() const;
QColor alternateBaseColor() const;
QColor textColor() const;
QColor buttonColor() const;
QColor buttonTextColor() const;
QColor highlightColor() const;
QColor highlightedTextColor() const;
QColor tooltipBaseColor() const;
QColor tooltipTextColor() const;
QColor linkColor() const;
QColor midColor() const;
QColor darkColor() const;
QColor lightColor() const;
QColor shadowColor() const;
QColor placeholderTextColor() const;
// Font accessors
QString fontFamily() const;
void setFontFamily(const QString &family);
int fontSize() const;
void setFontSize(int size);
QString fontStyle() const;
void setFontStyle(const QString &style);
QStringList availableFamilies() const;
QStringList availableStyles() const;
// Close-to-tray
bool closeToTray() const;
void setCloseToTray(bool enabled);
Q_INVOKABLE void resetFont();
Q_INVOKABLE QString chooseFolder(const QString &title, const QString &startDir = QString());
void applyPalette();
void applyFont();
signals:
void modeChanged();
void paletteChanged();
void fontChanged();
void closeToTrayChanged();
private:
QPalette paletteFromScheme(const QString &path) const;
QColor parseColor(const QVariant &value) const;
QString systemSchemePath() const;
QString schemePath(const QString &name) const;
QStringList schemeDirs() const;
void loadSettings();
void saveSettings();
QString m_mode; // "system", "light", "dark"
QPalette m_palette;
QString m_fontFamily;
int m_fontSize;
QString m_fontStyle;
bool m_closeToTray;
QFileSystemWatcher *m_watcher;
KConfigWatcher::Ptr m_configWatcher;
static constexpr const char *SCHEMES_DIR = "/usr/share/color-schemes";
};
#endif // OLLIE_THEME_MANAGER_H

81
kde/justfile Normal file
View File

@ -0,0 +1,81 @@
# Ollie KDE integration build system
home := env_var('HOME')
bin := home / '.local/bin'
kde:
@echo '=== Building ollie-kde (KF6) ==='
cmake -B build-cmake -S . -DCMAKE_INSTALL_PREFIX=~/.local
cmake --build build-cmake -j$(nproc)
kde-kf5:
@echo '=== Building ollie-kde (KF5) ==='
cmake -B build-kf5 -S . -DOLLIE_KF5=ON -DCMAKE_INSTALL_PREFIX=~/.local
cmake --build build-kf5 -j$(nproc)
install-kde:
@echo '=== Installing ollie-kde (KF6) ==='
cmake --install build-cmake
mkdir -p ~/.local/share/applications
install -m644 gui/org.ollie.gui.desktop ~/.local/share/applications/
mkdir -p ~/.local/share/icons/hicolor/scalable/apps
install -m644 gui/ollie.svg ~/.local/share/icons/hicolor/scalable/apps/ollie.svg
mkdir -p ~/.local/share/kio/servicemenus
install -m755 dolphin/ollie-actions.desktop ~/.local/share/kio/servicemenus/
install -m755 dolphin/ollie-ask ~/.local/bin/
install -m755 dolphin/ollie-session-here ~/.local/bin/
mkdir -p ~/.config/plasma-workspace/env
install -m755 99-ollie.sh ~/.config/plasma-workspace/env/99-ollie.sh
kbuildsycoca6 --noincremental 2>/dev/null; true
kquitapp6 krunner 2>/dev/null; true
@echo '=== KDE install complete. Restart Plasma to pick up plugins. ==='
install-kde-kf5:
@echo '=== Installing ollie-kde (KF5) ==='
cmake --install build-kf5
mkdir -p ~/.local/share/applications
install -m644 gui/org.ollie.gui.desktop ~/.local/share/applications/
mkdir -p ~/.local/share/icons/hicolor/scalable/apps
install -m644 gui/ollie.svg ~/.local/share/icons/hicolor/scalable/apps/ollie.svg
mkdir -p ~/.local/share/kio/servicemenus
install -m755 dolphin/ollie-actions-kf5.desktop ~/.local/share/kio/servicemenus/ollie-actions.desktop
install -m755 dolphin/ollie-ask ~/.local/bin/
install -m755 dolphin/ollie-session-here ~/.local/bin/
mkdir -p ~/.config/plasma-workspace/env
install -m755 99-ollie-kf5.sh ~/.config/plasma-workspace/env/99-ollie-kf5.sh
kbuildsycoca5 --noincremental 2>/dev/null; true
kquitapp5 krunner 2>/dev/null; true
@echo '=== KDE KF5 install complete. Restart Plasma to pick up plugins. ==='
clean:
rm -rf build-cmake build-kf5
uninstall:
@echo '=== Uninstalling ollie-kde (KF6) ==='
rm -f ~/.local/bin/ollie-gui
rm -f ~/.local/bin/ollie-ask ~/.local/bin/ollie-session-here
rm -f ~/.local/share/applications/org.ollie.gui.desktop
rm -f ~/.local/share/icons/hicolor/scalable/apps/ollie.svg
rm -f ~/.local/lib64/qt6/plugins/kf6/krunner/krunner_ollie.so
rm -f ~/.local/lib64/qt6/plugins/kf6/ktexteditor/ollie_kate.so
rm -f ~/.local/lib64/qt6/plugins/kf6/kio/kio_ollie.so
rm -f ~/.local/share/kio/servicemenus/ollie-actions.desktop
rm -f ~/.config/plasma-workspace/env/99-ollie.sh
kbuildsycoca6 --noincremental 2>/dev/null; true
kquitapp6 krunner 2>/dev/null; true
@echo '=== KDE uninstall complete ==='
uninstall-kf5:
@echo '=== Uninstalling ollie-kde (KF5) ==='
rm -f ~/.local/bin/ollie-gui
rm -f ~/.local/bin/ollie-ask ~/.local/bin/ollie-session-here
rm -f ~/.local/share/applications/org.ollie.gui.desktop
rm -f ~/.local/share/icons/hicolor/scalable/apps/ollie.svg
rm -f ~/.local/lib/qt5/plugins/kf5/krunner/krunner_ollie.so
rm -f ~/.local/lib/qt5/plugins/ktexteditor/ollie_kate.so
rm -f ~/.local/lib/qt5/plugins/kf5/kio/kio_ollie.so
rm -f ~/.local/share/kio/servicemenus/ollie-actions.desktop
rm -f ~/.local/share/kservices5/ollie.protocol
rm -f ~/.config/plasma-workspace/env/99-ollie-kf5.sh
kbuildsycoca5 --noincremental 2>/dev/null; true
kquitapp5 krunner 2>/dev/null; true
@echo '=== KDE KF5 uninstall complete ==='

14
kde/kate/metadata.json Normal file
View File

@ -0,0 +1,14 @@
{
"KPlugin": {
"Id": "ollie-kate",
"Name": "Ollie",
"Description": "AI agent side panel and context actions",
"Icon": "system-run",
"Authors": [
{
"Name": "ollie-kde"
}
]
},
"X-KDE-PluginInfo-EnabledByDefault": true
}

551
kde/kate/ollie_ghost.cpp Normal file
View File

@ -0,0 +1,551 @@
#include "ollie_ghost.h"
#include "ollie_kate.h"
#include <ktexteditor_version.h>
#if KTEXTEDITOR_VERSION_MAJOR < 6
#include <KTextEditor/InlineNoteInterface>
#include <KTextEditor/CodeCompletionInterface>
#include <KTextEditor/ConfigInterface>
#endif
#include <QApplication>
#include <QFileInfo>
#include <QPaintEvent>
#include <QScrollBar>
#include <QProcess>
#include <QJsonDocument>
#include <QJsonObject>
// === GhostOverlay ===
GhostOverlay::GhostOverlay(QWidget *parent)
: QWidget(parent)
{
setAttribute(Qt::WA_TransparentForMouseEvents);
setAutoFillBackground(false);
hide();
}
void GhostOverlay::setLines(const QStringList &lines, const QFont &font)
{
m_lines = lines;
m_font = font;
if (m_lines.isEmpty()) {
hide();
return;
}
QFontMetricsF fm(m_font);
qreal maxWidth = 0;
for (const QString &line : m_lines) {
qreal w = fm.horizontalAdvance(line);
if (w > maxWidth) maxWidth = w;
}
int height = qRound(fm.lineSpacing() * m_lines.size()) + 4;
int width = qRound(maxWidth) + 8;
setFixedSize(width, height);
show();
raise();
update();
}
void GhostOverlay::clear()
{
m_lines.clear();
hide();
}
void GhostOverlay::paintEvent(QPaintEvent *event)
{
Q_UNUSED(event);
if (m_lines.isEmpty()) return;
QPainter painter(this);
// Draw background — editor base color with a tint to distinguish from real text
QColor bgColor = QApplication::palette().color(QPalette::Base);
// Shift slightly toward highlight color to signal "this is a suggestion"
QColor tint = QApplication::palette().color(QPalette::Highlight);
bgColor = QColor(
(bgColor.red() * 4 + tint.red()) / 5,
(bgColor.green() * 4 + tint.green()) / 5,
(bgColor.blue() * 4 + tint.blue()) / 5
);
bgColor.setAlphaF(0.92);
painter.fillRect(rect(), bgColor);
// Draw ghost text
painter.setFont(m_font);
QColor ghostColor = QApplication::palette().color(QPalette::Text);
ghostColor.setAlphaF(0.6);
painter.setPen(ghostColor);
QFontMetricsF fm(m_font);
qreal y = fm.ascent();
for (const QString &line : m_lines) {
painter.drawText(QPointF(2, y), line);
y += fm.lineSpacing();
}
}
// === OllieGhostProvider ===
OllieGhostProvider::OllieGhostProvider(KTextEditor::View *view, OllieKatePlugin *plugin, QObject *parent)
: KTextEditor::InlineNoteProvider() // note: KDE's ctor takes no parent arg
, m_view(view)
, m_plugin(plugin)
, m_debounce(new QTimer(this))
{
// InlineNoteProvider inherits QObject but has no parent-accepting ctor
QObject::setParent(parent);
m_debounce->setSingleShot(true);
m_debounce->setInterval(500);
connect(m_debounce, &QTimer::timeout, this, &OllieGhostProvider::requestCompletion);
connect(view, &KTextEditor::View::cursorPositionChanged,
this, &OllieGhostProvider::onCursorPositionChanged);
connect(view->document(), &KTextEditor::Document::textChanged,
this, &OllieGhostProvider::onTextChanged);
connect(view, &QObject::destroyed, this, [this]() { m_view = nullptr; });
#if KTEXTEDITOR_VERSION_MAJOR >= 6
view->registerInlineNoteProvider(this);
#else
// Use dynamic_cast instead of qobject_cast: during view construction,
// qt_metacast() can access uninitialized KateViewConfig and crash.
// dynamic_cast only needs the vtable which is valid once the C++ ctor runs.
auto *noteIface = dynamic_cast<KTextEditor::InlineNoteInterface *>(view);
if (noteIface)
noteIface->registerInlineNoteProvider(this);
#endif
}
OllieGhostProvider::~OllieGhostProvider()
{
// m_overlay is parented to the view widget and may already be destroyed
// by Qt's child cleanup before we get here — just null it out.
m_overlay = nullptr;
if (m_view) {
#if KTEXTEDITOR_VERSION_MAJOR >= 6
m_view->unregisterInlineNoteProvider(this);
#else
auto *iface = dynamic_cast<KTextEditor::InlineNoteInterface *>(m_view);
if (iface)
iface->unregisterInlineNoteProvider(this);
#endif
}
}
// --- InlineNoteProvider interface ---
#if KTEXTEDITOR_VERSION_MAJOR >= 6
QList<int> OllieGhostProvider::inlineNotes(int line) const
#else
QVector<int> OllieGhostProvider::inlineNotes(int line) const
#endif
{
if (m_suggestionLines.isEmpty() || m_suggestionStartLine < 0)
return {};
// Only show inline note on the FIRST suggestion line
if (line != m_suggestionStartLine)
return {};
if (m_suggestionLines[0].isEmpty())
return {};
// Don't show ghost text if cursor was mid-line — the suggestion would
// visually overlap or conflict with existing text after the cursor.
int lineLen = m_view->document()->lineLength(line);
if (m_suggestionStartCol < lineLen)
return {};
return {lineLen};
}
QSize OllieGhostProvider::inlineNoteSize(const KTextEditor::InlineNote &note) const
{
Q_UNUSED(note);
if (m_suggestionLines.isEmpty())
return QSize(0, 0);
QFont font = note.font();
QFontMetricsF fm(font);
qreal width = fm.horizontalAdvance(m_suggestionLines[0]);
return QSize(qRound(width), note.lineHeight());
}
#if KTEXTEDITOR_VERSION_MAJOR >= 6
void OllieGhostProvider::paintInlineNote(const KTextEditor::InlineNote &note, QPainter &painter, Qt::LayoutDirection direction) const
{
Q_UNUSED(direction);
#else
void OllieGhostProvider::paintInlineNote(const KTextEditor::InlineNote &note, QPainter &painter) const
{
#endif
if (m_suggestionLines.isEmpty())
return;
QFont font = note.font();
painter.setFont(font);
QColor ghostColor = QApplication::palette().color(QPalette::Text);
ghostColor.setAlphaF(0.4);
painter.setPen(ghostColor);
QFontMetricsF fm(font);
qreal y = fm.ascent();
painter.drawText(QPointF(0, y), m_suggestionLines[0]);
}
// --- Public API ---
bool OllieGhostProvider::accept()
{
if (m_suggestionLines.isEmpty() || m_suggestionStartLine < 0)
return false;
auto *doc = m_view->document();
QString text = m_suggestionLines.join(QStringLiteral("\n"));
// Insert at the trigger cursor position
KTextEditor::Cursor insertPos(m_suggestionStartLine, m_suggestionStartCol);
doc->insertText(insertPos, text);
clearSuggestion();
return true;
}
void OllieGhostProvider::dismiss()
{
if (!m_suggestionLines.isEmpty()) {
clearSuggestion();
}
m_debounce->stop();
}
void OllieGhostProvider::cycle(int direction)
{
if (direction > 0) {
// Next: if we have a cached next, show it; otherwise request new
if (m_historyIndex + 1 < m_history.size()) {
m_historyIndex++;
showSuggestionFromHistory();
} else {
// Request a new completion (will append to history)
requestCompletion();
}
} else if (direction < 0) {
// Previous: show earlier suggestion if available
if (m_historyIndex > 0) {
m_historyIndex--;
showSuggestionFromHistory();
}
}
}
void OllieGhostProvider::setEnabled(bool enabled)
{
m_enabled = enabled;
if (!enabled) {
m_debounce->stop();
dismiss();
}
}
bool OllieGhostProvider::hasSuggestion() const
{
return !m_suggestionLines.isEmpty();
}
// --- Private slots ---
void OllieGhostProvider::onCursorPositionChanged()
{
// Don't react to cursor changes while completion popup is active
#if KTEXTEDITOR_VERSION_MAJOR >= 6
if (m_view->isCompletionActive())
return;
#else
auto *ccIface = dynamic_cast<KTextEditor::CodeCompletionInterface *>(m_view);
if (ccIface && ccIface->isCompletionActive())
return;
#endif
if (hasSuggestion()) {
KTextEditor::Cursor cur = m_view->cursorPosition();
if (cur != m_triggerCursor) {
clearSuggestion();
}
}
if (m_enabled)
m_debounce->start();
}
void OllieGhostProvider::onTextChanged()
{
if (hasSuggestion()) {
clearSuggestion();
}
if (m_enabled)
m_debounce->start();
}
void OllieGhostProvider::requestCompletion()
{
if (m_requesting)
return;
auto *doc = m_view->document();
if (!doc || doc->url().toLocalFile().isEmpty())
return;
KTextEditor::Cursor cursor = m_view->cursorPosition();
QString prefix = gatherPrefix();
QString suffix = gatherSuffix();
QString filePath = doc->url().toLocalFile();
QString cwd = QFileInfo(filePath).absolutePath();
m_triggerCursor = cursor;
m_requesting = true;
// Trim prefix/suffix to limits matching agent/complete.go
const int prefixMax = 12000;
const int suffixMax = 1000;
if (prefix.size() > prefixMax)
prefix = prefix.right(prefixMax);
if (suffix.size() > suffixMax)
suffix = suffix.left(suffixMax);
// Build the completion prompt
QString fileHint;
if (!filePath.isEmpty())
fileHint = " in " + filePath;
QString contextBlock;
if (!cwd.isEmpty())
contextBlock = "\nWorking directory: " + cwd;
QString prompt = QString(
"Implement the code at the cursor%2. The prefix ends at the point where new code is needed. Write the implementation — do not echo stubs, TODOs, or placeholder returns from the prefix. Output ONLY raw code. No reasoning, no shell commands, no explanations, no markdown fences, no backticks, no preamble. Your entire response must be valid code that can be inserted directly into the file.%1"
"<prefix>\n%3\n</prefix>\n<suffix>\n%4\n</suffix>"
).arg(contextBlock).arg(fileHint).arg(prefix).arg(suffix);
// Build JSON request for ollie-9p rdwr generate
QJsonObject request;
request["prompt"] = prompt;
QString backendName = m_plugin ? m_plugin->completionBackend() : QString();
QString modelName = m_plugin ? m_plugin->completionModel() : QString();
if (!backendName.isEmpty())
request["backend"] = backendName;
if (!modelName.isEmpty())
request["model"] = modelName;
QJsonDocument docJson(request);
QString jsonStr = QString::fromUtf8(docJson.toJson(QJsonDocument::Compact));
// Run ollie-9p rdwr generate
QProcess *process = new QProcess(this);
process->setProgram("ollie-9p");
process->setArguments({"rdwr", "generate"});
connect(process, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
this, [this, process](int exitCode, QProcess::ExitStatus exitStatus) {
process->deleteLater();
m_requesting = false;
if (exitStatus != QProcess::NormalExit || exitCode != 0) {
return;
}
QString result = QString::fromUtf8(process->readAllStandardOutput());
onCompletionReply(result);
});
process->start();
process->write(jsonStr.toUtf8());
process->closeWriteChannel();
}
// === Completion post-processing helpers (mirrors agent/complete.go) ===
static QString stripCompletionNoise(const QString &s);
static QString stripPrefixEcho(const QString &result, const QString &prefix = QString());
void OllieGhostProvider::onCompletionReply(const QString &result)
{
if (result.trimmed().isEmpty())
return;
if (m_view->cursorPosition() != m_triggerCursor)
return;
// Strip completion noise (safety net — prompt asks for raw code only)
QString stripped = stripCompletionNoise(result);
// Strip prefix echo (model may repeat trailing prefix chars)
stripped = stripPrefixEcho(stripped);
if (stripped.isEmpty())
return;
QStringList lines = stripped.split(QStringLiteral("\n"));
// Append to history and advance index
m_history.append(lines);
m_historyIndex = m_history.size() - 1;
m_suggestionStartLine = m_triggerCursor.line();
m_suggestionStartCol = m_triggerCursor.column();
m_suggestionLines = lines;
// Notify Kate to repaint the first line (inline note)
emit inlineNotesChanged(m_suggestionStartLine);
// Show overlay for continuation lines (multi-line suggestions)
if (m_suggestionLines.size() > 1) {
showOverlay();
}
}
// --- Private helpers ---
void OllieGhostProvider::showOverlay()
{
// Get the editor's internal widget (viewport) to parent the overlay
QWidget *viewWidget = m_view->focusProxy();
if (!viewWidget)
viewWidget = qobject_cast<QWidget *>(m_view);
if (!viewWidget)
return;
if (!m_overlay) {
m_overlay = new GhostOverlay(viewWidget);
connect(m_overlay, &QObject::destroyed, this, [this]() { m_overlay = nullptr; });
}
// Gather continuation lines (skip first, it's the inline note)
QStringList contLines = m_suggestionLines.mid(1);
// Get font from the view's config
#if KTEXTEDITOR_VERSION_MAJOR >= 6
QFont font = m_view->configValue(QStringLiteral("font")).value<QFont>();
#else
QFont font;
auto *cfgIface = dynamic_cast<KTextEditor::ConfigInterface *>(m_view);
if (cfgIface)
font = cfgIface->configValue(QStringLiteral("font")).value<QFont>();
else
font = m_view->font();
#endif
m_overlay->setLines(contLines, font);
// Position the overlay below the cursor line
// Use cursorPositionCoordinates to get pixel position of cursor
QPoint cursorPos = m_view->cursorPositionCoordinates();
QFontMetricsF fm(font);
int lineHeight = qRound(fm.lineSpacing());
// Place overlay one line below the cursor
m_overlay->move(cursorPos.x(), cursorPos.y() + lineHeight);
}
void OllieGhostProvider::hideOverlay()
{
if (m_overlay) {
m_overlay->clear();
}
}
void OllieGhostProvider::showSuggestionFromHistory()
{
if (m_historyIndex < 0 || m_historyIndex >= m_history.size())
return;
// Clear visual state without resetting history
if (!m_suggestionLines.isEmpty()) {
emit inlineNotesChanged(m_suggestionStartLine);
hideOverlay();
}
m_suggestionStartLine = m_triggerCursor.line();
m_suggestionStartCol = m_triggerCursor.column();
m_suggestionLines = m_history[m_historyIndex];
emit inlineNotesChanged(m_suggestionStartLine);
if (m_suggestionLines.size() > 1) {
showOverlay();
}
}
void OllieGhostProvider::clearSuggestion()
{
if (m_suggestionLines.isEmpty())
return;
m_suggestionLines.clear();
m_suggestionStartLine = -1;
m_suggestionStartCol = -1;
m_history.clear();
m_historyIndex = -1;
emit inlineNotesChanged(m_triggerCursor.line());
hideOverlay();
}
QString OllieGhostProvider::gatherPrefix() const
{
auto *doc = m_view->document();
KTextEditor::Cursor cursor = m_view->cursorPosition();
KTextEditor::Range prefixRange(KTextEditor::Cursor(0, 0), cursor);
return doc->text(prefixRange);
}
QString OllieGhostProvider::gatherSuffix() const
{
auto *doc = m_view->document();
KTextEditor::Cursor cursor = m_view->cursorPosition();
KTextEditor::Cursor endCursor(doc->lines() - 1, doc->lineLength(doc->lines() - 1));
KTextEditor::Range suffixRange(cursor, endCursor);
return doc->text(suffixRange);
}
// === Completion post-processing helpers (mirrors agent/complete.go) ===
static QString stripCompletionNoise(const QString &s)
{
QStringList lines = s.split('\n');
QStringList filtered;
for (const QString &line : lines) {
QString trimmed = line.trimmed();
if (trimmed.startsWith("```"))
continue;
if (trimmed == "<prefix>" || trimmed == "</prefix>" ||
trimmed == "<suffix>" || trimmed == "</suffix>")
continue;
if (trimmed.startsWith(":: "))
continue;
filtered.append(line);
}
return filtered.join('\n');
}
static QString stripPrefixEcho(const QString &result, const QString &prefix)
{
if (prefix.isEmpty())
return result;
int tailMax = 200;
if (prefix.size() < tailMax)
tailMax = prefix.size();
for (int i = tailMax; i > 0; --i) {
QString tail = prefix.right(i);
if (result.startsWith(tail))
return result.mid(i);
}
return result;
}

131
kde/kate/ollie_ghost.h Normal file
View File

@ -0,0 +1,131 @@
#ifndef OLLIE_GHOST_H
#define OLLIE_GHOST_H
#include <KTextEditor/InlineNoteProvider>
#include <KTextEditor/View>
#include <KTextEditor/Document>
#include <KTextEditor/Cursor>
#include <ktexteditor_version.h>
#include <QTimer>
#include <QPainter>
#include <QFont>
#include <QFontMetricsF>
#include <QString>
#include <QStringList>
#include <QWidget>
class GhostOverlay;
/**
* OllieGhostProvider renders AI code completions as translucent gray text
* at the cursor position using KTextEditor's InlineNoteProvider API.
*
* The first line of a multi-line suggestion is rendered as an inline note
* at end-of-line. Continuation lines are rendered via a floating overlay
* widget positioned below the cursor line.
*
* Flow:
* 1. Cursor moves or text changes → debounce timer starts (500ms)
* 2. Timer fires → gather prefix/suffix, call 9P Complete asynchronously
* 3. Result arrives → store suggestion lines, emit inlineNotesChanged
* 4. paintInlineNote renders first line; GhostOverlay renders the rest
* 5. Tab → accept (insert text), any other input → dismiss
*/
class OllieGhostProvider : public KTextEditor::InlineNoteProvider
{
Q_OBJECT
public:
explicit OllieGhostProvider(KTextEditor::View *view, class OllieKatePlugin *plugin, QObject *parent = nullptr);
~OllieGhostProvider() override;
// InlineNoteProvider interface
#if KTEXTEDITOR_VERSION_MAJOR >= 6
QList<int> inlineNotes(int line) const override;
#else
QVector<int> inlineNotes(int line) const override;
#endif
QSize inlineNoteSize(const KTextEditor::InlineNote &note) const override;
#if KTEXTEDITOR_VERSION_MAJOR >= 6
void paintInlineNote(const KTextEditor::InlineNote &note, QPainter &painter, Qt::LayoutDirection direction) const override;
#else
void paintInlineNote(const KTextEditor::InlineNote &note, QPainter &painter) const override;
#endif
/// Accept the current suggestion (insert into document). Returns true if accepted.
bool accept();
/// Dismiss the current suggestion without inserting.
void dismiss();
/// Cycle to next (+1) or previous (-1) suggestion.
void cycle(int direction);
/// Returns true if a suggestion is currently visible.
bool hasSuggestion() const;
/// Returns the view this provider is attached to.
KTextEditor::View *view() const { return m_view; }
/// Enable or disable ghost completions.
void setEnabled(bool enabled);
bool isEnabled() const { return m_enabled; }
public slots:
void requestCompletion();
private slots:
void onCursorPositionChanged();
void onTextChanged();
void onCompletionReply(const QString &result);
private:
void clearSuggestion();
void showSuggestionFromHistory();
void showOverlay();
void hideOverlay();
QString gatherPrefix() const;
QString gatherSuffix() const;
KTextEditor::View *m_view;
OllieKatePlugin *m_plugin;
QTimer *m_debounce;
GhostOverlay *m_overlay = nullptr;
// Current suggestion state
QStringList m_suggestionLines; // suggestion split by newline
int m_suggestionStartLine = -1; // document line where suggestion starts
int m_suggestionStartCol = -1; // column (end of line where cursor was)
KTextEditor::Cursor m_triggerCursor; // cursor position when completion was triggered
// Suggestion history for cycling
QList<QStringList> m_history; // all suggestions received at this cursor position
int m_historyIndex = -1; // current index into m_history
bool m_enabled = true; // ghost completions active
bool m_requesting = false; // true while 9P call is in flight
};
/**
* GhostOverlay is a transparent widget that paints continuation lines
* of a multi-line ghost suggestion over the editor viewport.
*/
class GhostOverlay : public QWidget
{
Q_OBJECT
public:
explicit GhostOverlay(QWidget *parent = nullptr);
void setLines(const QStringList &lines, const QFont &font);
void clear();
protected:
void paintEvent(QPaintEvent *event) override;
private:
QStringList m_lines;
QFont m_font;
};
#endif // OLLIE_GHOST_H

648
kde/kate/ollie_kate.cpp Normal file
View File

@ -0,0 +1,648 @@
#include "ollie_kate.h"
#include "ollie_ghost.h"
#include <KPluginFactory>
#include <KSharedConfig>
#include <KConfigGroup>
#include <KTextEditor/Document>
#include <KTextEditor/Editor>
#include <ktexteditor_version.h>
#if KTEXTEDITOR_VERSION_MAJOR < 6
#include <KTextEditor/CodeCompletionInterface>
#endif
#include <QApplication>
#include <QCheckBox>
#include <QDir>
#include <QFileInfo>
#include <QHash>
#include <QKeyEvent>
#include <QMainWindow>
#include <QMenu>
#include <QStatusBar>
#include <QVBoxLayout>
K_PLUGIN_CLASS_WITH_JSON(OllieKatePlugin, "metadata.json")
// --- Plugin ---
#if QT_VERSION_MAJOR >= 6
OllieKatePlugin::OllieKatePlugin(QObject *parent, const KPluginMetaData &data, const QVariantList &args)
: KTextEditor::Plugin(parent)
{
Q_UNUSED(data);
Q_UNUSED(args);
loadConfig();
}
#else
OllieKatePlugin::OllieKatePlugin(QObject *parent, const QVariantList &args)
: KTextEditor::Plugin(parent)
{
Q_UNUSED(args);
loadConfig();
}
#endif
QObject *OllieKatePlugin::createView(KTextEditor::MainWindow *mainWindow)
{
return new OllieKateView(this, mainWindow);
}
void OllieKatePlugin::loadConfig()
{
KConfigGroup grp(KSharedConfig::openConfig(), QStringLiteral("Ollie"));
m_completionBackend = grp.readEntry("CompletionBackend", QString());
m_completionModel = grp.readEntry("CompletionModel", QString());
m_completionEnabled = grp.readEntry("CompletionEnabled", true);
}
void OllieKatePlugin::saveConfig()
{
KConfigGroup grp(KSharedConfig::openConfig(), QStringLiteral("Ollie"));
grp.writeEntry("CompletionEnabled", m_completionEnabled);
grp.sync();
}
void OllieKatePlugin::setCompletionEnabled(bool enabled)
{
m_completionEnabled = enabled;
saveConfig();
}
int OllieKatePlugin::configPages() const
{
return 1;
}
KTextEditor::ConfigPage *OllieKatePlugin::configPage(int number, QWidget *parent)
{
if (number != 0) return nullptr;
class OllieConfigPage : public KTextEditor::ConfigPage
{
public:
OllieConfigPage(OllieKatePlugin *plugin, QWidget *parent)
: KTextEditor::ConfigPage(parent), m_plugin(plugin)
{
auto *layout = new QVBoxLayout(this);
m_enabledCheck = new QCheckBox(QStringLiteral("Automatic completion (ghost text)"), this);
layout->addWidget(m_enabledCheck);
layout->addStretch();
reset();
connect(m_enabledCheck, &QCheckBox::toggled, this, &ConfigPage::changed);
}
QString name() const override { return QStringLiteral("Ollie"); }
QString fullName() const override { return QStringLiteral("Ollie Settings"); }
QIcon icon() const override { return QIcon::fromTheme(QStringLiteral("system-run")); }
void apply() override {
m_plugin->setCompletionEnabled(m_enabledCheck->isChecked());
}
void reset() override {
m_enabledCheck->setChecked(m_plugin->completionEnabled());
}
void defaults() override {
m_enabledCheck->setChecked(true);
}
private:
OllieKatePlugin *m_plugin;
QCheckBox *m_enabledCheck;
};
return new OllieConfigPage(this, parent);
}
// --- View ---
OllieKateView::OllieKateView(OllieKatePlugin *plugin, KTextEditor::MainWindow *mainWindow)
: QObject(mainWindow)
, m_plugin(plugin)
, m_mainWindow(mainWindow)
, m_9p(new Lib9pClient(this))
, m_heartbeatTimer(new QTimer(this))
{
// Connect to 9P server
m_connected = m_9p->connectDefault();
if (!m_connected) {
qWarning() << "Ollie: failed to connect to 9P server:" << m_9p->lastError();
}
// Heartbeat timer — detect server death and reconnect
m_heartbeatTimer->setInterval(5000);
connect(m_heartbeatTimer, &QTimer::timeout, this, &OllieKateView::heartbeat);
m_heartbeatTimer->start();
// Status bar indicator
if (auto *win = qobject_cast<QMainWindow *>(m_mainWindow->window())) {
m_statusLabel = new QLabel();
m_statusLabel->setObjectName(QStringLiteral("ollie_status"));
QFont smallFont = QApplication::font();
smallFont.setPointSizeF(smallFont.pointSizeF() * 0.85);
m_statusLabel->setFont(smallFont);
m_statusLabel->setStyleSheet(QStringLiteral("color: gray; padding: 0px 4px;"));
win->statusBar()->addPermanentWidget(m_statusLabel);
}
// Auto-select agent for current project
selectAgentForProject();
updateStatusLabel();
// Ghost text completion
setupGhostProvider();
// React to view/project changes
connect(mainWindow, &KTextEditor::MainWindow::viewChanged,
this, [this](KTextEditor::View *view) {
setupGhostProvider();
if (view) setupContextMenu(view);
selectAgentForProject();
updateStatusLabel();
});
QObject *projectView = mainWindow->pluginView("kateprojectplugin");
if (projectView) {
QObject::connect(projectView, SIGNAL(projectChanged()),
this, SLOT(onProjectChanged()));
}
#if KTEXTEDITOR_VERSION >= QT_VERSION_CHECK(5, 78, 0)
connect(mainWindow, &KTextEditor::MainWindow::pluginViewCreated,
this, [this](const QString &name, QObject *view) {
if (name == "kateprojectplugin") {
QObject::connect(view, SIGNAL(projectChanged()),
this, SLOT(onProjectChanged()));
selectAgentForProject();
updateStatusLabel();
}
});
#endif
// Context menu on initial view
if (auto *view = mainWindow->activeView())
setupContextMenu(view);
// Git++ "Review Diff" action
setupGitPlusPlusDiffAction();
}
OllieKateView::~OllieKateView()
{
m_heartbeatTimer->stop();
delete m_ghost;
if (m_statusLabel) {
delete m_statusLabel;
}
}
// --- Agent Management ---
void OllieKateView::ensureAgent()
{
if (!m_agentId.isEmpty()) return;
// Ensure kate session exists
if (m_sessionId.isEmpty()) {
m_9p->write("session/new", "name=kate\n");
m_sessionId = "kate";
}
// Look for an existing agent in the kate session
QStringList agents = m_9p->ls("session/kate/agent");
agents.removeAll("new");
agents.removeAll("idx");
if (!agents.isEmpty()) {
m_agentId = agents.first();
} else {
// Create one
QString cwd = projectBaseDir();
if (cwd.isEmpty()) cwd = QDir::homePath();
QString agentCfg = "cwd=" + cwd + "\n";
QByteArray resp = m_9p->rdwr("session/kate/agent/new", agentCfg.toUtf8());
if (!resp.isEmpty()) {
m_agentId = QString::fromUtf8(resp).trimmed();
}
}
updateStatusLabel();
}
bool OllieKateView::selectAgentForProject()
{
// Check if kate session exists
QByteArray sessCheck = m_9p->read("session/kate/id");
if (sessCheck.isEmpty()) return false;
m_sessionId = "kate";
// Find first agent in the session
QStringList agents = m_9p->ls("session/kate/agent");
agents.removeAll("new");
agents.removeAll("idx");
if (agents.isEmpty()) return false;
QString agentId = agents.first();
if (agentId != m_agentId) {
m_agentId = agentId;
}
// Update cwd to match current project
QString baseDir = projectBaseDir();
if (!baseDir.isEmpty() && !m_agentId.isEmpty()) {
m_9p->write(agentPath() + "/ctl", ("cwd " + baseDir).toUtf8());
}
updateStatusLabel();
return true;
}
void OllieKateView::switchToAgent(const QString &agentId)
{
m_sessionId = "kate";
m_agentId = agentId;
updateStatusLabel();
}
void OllieKateView::onProjectChanged()
{
// Update the agent's cwd to the new project directory
QString baseDir = projectBaseDir();
if (!baseDir.isEmpty() && !m_agentId.isEmpty()) {
m_9p->write(agentPath() + "/ctl", ("cwd " + baseDir).toUtf8());
}
updateStatusLabel();
}
QString OllieKateView::projectBaseDir()
{
QObject *projectView = m_mainWindow->pluginView("kateprojectplugin");
if (projectView) {
QString dir = projectView->property("projectBaseDir").toString();
if (!dir.isEmpty()) return dir;
}
auto *view = m_mainWindow->activeView();
if (view && view->document() && !view->document()->url().toLocalFile().isEmpty()) {
return QFileInfo(view->document()->url().toLocalFile()).absolutePath();
}
return QString();
}
QString OllieKateView::agentPath() const
{
if (m_sessionId.isEmpty() || m_agentId.isEmpty()) return {};
return "session/" + m_sessionId + "/agent/" + m_agentId;
}
void OllieKateView::updateStatusLabel()
{
if (!m_statusLabel) return;
if (!m_connected) {
m_statusLabel->setText(QStringLiteral("✗ ollie: disconnected"));
m_statusLabel->setStyleSheet(QStringLiteral("color: red; padding: 0px 4px;"));
} else if (m_agentId.isEmpty()) {
m_statusLabel->setText(QStringLiteral("○ ollie: no agent"));
m_statusLabel->setStyleSheet(QStringLiteral("color: gray; padding: 0px 4px;"));
} else {
m_statusLabel->setText(QStringLiteral("● ollie: kate/") + m_agentId);
m_statusLabel->setStyleSheet(QStringLiteral("color: green; padding: 0px 4px;"));
}
}
// --- Connection Recovery ---
void OllieKateView::heartbeat()
{
// Try a cheap read to verify daemon is alive
if (!m_9p->isConnected()) {
if (!m_9p->connectDefault()) {
if (m_connected) {
m_connected = false;
updateStatusLabel();
}
return;
}
}
QByteArray result = m_9p->read(QStringLiteral("help"));
bool alive = !result.isEmpty();
if (alive && !m_connected) {
// Recovered — re-establish state
m_connected = true;
selectAgentForProject();
updateStatusLabel();
} else if (!alive && m_connected) {
// Lost connection
m_connected = false;
m_9p->disconnect();
updateStatusLabel();
}
}
// --- Context Menu ---
void OllieKateView::setupContextMenu(KTextEditor::View *view)
{
if (!view) return;
connect(view, &KTextEditor::View::contextMenuAboutToShow,
this, [this](KTextEditor::View *v, QMenu *menu) {
// Remove previous ollie actions
const auto actions = menu->actions();
for (auto *a : actions) {
if (a->objectName().startsWith(QStringLiteral("ollie_ctx_"))) {
menu->removeAction(a);
delete a;
}
}
auto *sep = menu->addSeparator();
sep->setObjectName(QStringLiteral("ollie_ctx_sep"));
auto *askAction = menu->addAction(QIcon::fromTheme(QStringLiteral("help-about")),
QStringLiteral("Ollie: Ask about file"));
askAction->setObjectName(QStringLiteral("ollie_ctx_ask"));
connect(askAction, &QAction::triggered, this, &OllieKateView::askAboutFile);
// "Review Diff" when document is a diff
if (v->document()->highlightingMode() == QStringLiteral("Diff")) {
auto *reviewDiffAction = menu->addAction(QIcon::fromTheme(QStringLiteral("document-preview")),
QStringLiteral("Ollie: Review Diff"));
reviewDiffAction->setObjectName(QStringLiteral("ollie_ctx_review_diff"));
connect(reviewDiffAction, &QAction::triggered, this, &OllieKateView::reviewDiff);
}
auto *startAction = menu->addAction(QIcon::fromTheme(QStringLiteral("system-run")),
QStringLiteral("Ollie: Start Session"));
startAction->setObjectName(QStringLiteral("ollie_ctx_start"));
connect(startAction, &QAction::triggered, this, &OllieKateView::startSession);
if (!v->selection()) return;
auto *explainAction = menu->addAction(QIcon::fromTheme(QStringLiteral("help-contents")),
QStringLiteral("Ollie: Explain this"));
explainAction->setObjectName(QStringLiteral("ollie_ctx_explain"));
connect(explainAction, &QAction::triggered, this, &OllieKateView::explainSelection);
auto *fixAction = menu->addAction(QIcon::fromTheme(QStringLiteral("tools-wizard")),
QStringLiteral("Ollie: Fix this"));
fixAction->setObjectName(QStringLiteral("ollie_ctx_fix"));
connect(fixAction, &QAction::triggered, this, [this]() { submitSelectionPrompt(QStringLiteral("Fix")); });
auto *refactorAction = menu->addAction(QIcon::fromTheme(QStringLiteral("edit-rename")),
QStringLiteral("Ollie: Refactor this"));
refactorAction->setObjectName(QStringLiteral("ollie_ctx_refactor"));
connect(refactorAction, &QAction::triggered, this, [this]() { submitSelectionPrompt(QStringLiteral("Refactor")); });
auto *testAction = menu->addAction(QIcon::fromTheme(QStringLiteral("document-new")),
QStringLiteral("Ollie: Add tests for this"));
testAction->setObjectName(QStringLiteral("ollie_ctx_test"));
connect(testAction, &QAction::triggered, this, [this]() { submitSelectionPrompt(QStringLiteral("Write unit tests for")); });
auto *docAction = menu->addAction(QIcon::fromTheme(QStringLiteral("documentation")),
QStringLiteral("Ollie: Document this"));
docAction->setObjectName(QStringLiteral("ollie_ctx_doc"));
connect(docAction, &QAction::triggered, this, [this]() { submitSelectionPrompt(QStringLiteral("Add documentation comments to")); });
auto *verbatimAction = menu->addAction(QIcon::fromTheme(QStringLiteral("document-send")),
QStringLiteral("Ollie: Send verbatim"));
verbatimAction->setObjectName(QStringLiteral("ollie_ctx_verbatim"));
connect(verbatimAction, &QAction::triggered, this, &OllieKateView::sendVerbatim);
});
}
void OllieKateView::setupGitPlusPlusDiffAction()
{
QObject *gitView = m_mainWindow->pluginView(QStringLiteral("gitplusplus"));
if (!gitView) return;
auto *action = new QAction(QIcon::fromTheme(QStringLiteral("document-preview")),
QStringLiteral("Ollie: Review Diff"), this);
connect(action, &QAction::triggered, this, &OllieKateView::reviewDiff);
QMetaObject::invokeMethod(gitView, "registerDiffAction",
Q_ARG(QAction *, action));
}
// --- Actions ---
void OllieKateView::askAboutFile()
{
auto *view = m_mainWindow->activeView();
if (!view || !view->document()) return;
QString filePath = view->document()->url().toLocalFile();
if (filePath.isEmpty()) return;
ensureAgent();
if (m_agentId.isEmpty()) return;
QString prompt = "Describe and explain this file: " + filePath;
m_9p->write(agentPath() + "/prompt", prompt.toUtf8());
}
void OllieKateView::explainSelection()
{
auto *view = m_mainWindow->activeView();
if (!view) return;
QString selection = view->selectionText();
if (selection.isEmpty()) {
auto cursor = view->cursorPosition();
selection = view->document()->line(cursor.line());
}
if (selection.isEmpty()) return;
ensureAgent();
if (m_agentId.isEmpty()) return;
QString filePath = view->document()->url().toLocalFile();
QString context = filePath.isEmpty() ? "" : " (from " + filePath + ")";
QString prompt = "Explain this code" + context + ":\n\n```\n" + selection + "\n```";
m_9p->write(agentPath() + "/prompt", prompt.toUtf8());
}
void OllieKateView::submitSelectionPrompt(const QString &action)
{
auto *view = m_mainWindow->activeView();
if (!view || !view->selection()) return;
QString selection = view->selectionText();
if (selection.isEmpty()) return;
ensureAgent();
if (m_agentId.isEmpty()) return;
QString filePath = view->document()->url().toLocalFile();
auto cursor = view->selectionRange().start();
QString location;
if (!filePath.isEmpty()) {
location = " in `" + filePath + ":" + QString::number(cursor.line() + 1) + "`";
}
QString prompt = action + " this code" + location + ":\n\n```\n" + selection + "\n```";
m_9p->write(agentPath() + "/prompt", prompt.toUtf8());
}
void OllieKateView::sendVerbatim()
{
auto *view = m_mainWindow->activeView();
if (!view || !view->selection()) return;
QString selection = view->selectionText();
if (selection.isEmpty()) return;
ensureAgent();
if (m_agentId.isEmpty()) return;
// Derive fence language from document highlighting mode
QString mode = view->document()->highlightingMode().toLower();
// Map common Kate highlighting names to fence languages
static const QHash<QString, QString> modeMap = {
{"c++", "cpp"}, {"c", "c"}, {"cmake", "cmake"},
{"javascript", "javascript"}, {"typescript", "typescript"},
{"python", "python"}, {"rust", "rust"}, {"go", "go"},
{"bash", "bash"}, {"zsh", "bash"}, {"shell", "bash"},
{"json", "json"}, {"yaml", "yaml"}, {"toml", "toml"},
{"html", "html"}, {"css", "css"}, {"sql", "sql"},
{"java", "java"}, {"kotlin", "kotlin"}, {"ruby", "ruby"},
{"perl", "perl"}, {"lua", "lua"}, {"diff", "diff"},
{"markdown", "markdown"}, {"xml", "xml"},
};
QString lang = modeMap.value(mode, mode);
QString prompt = "```" + lang + "\n" + selection + "\n```";
m_9p->write(agentPath() + "/prompt", prompt.toUtf8());
}
void OllieKateView::reviewDiff()
{
QString diff;
QObject *gitView = m_mainWindow->pluginView(QStringLiteral("gitplusplus"));
if (gitView) {
QByteArray data = gitView->property("lastDiff").toByteArray();
if (!data.isEmpty())
diff = QString::fromUtf8(data);
}
if (diff.isEmpty()) {
auto *view = m_mainWindow->activeView();
if (view && view->document())
diff = view->document()->text();
}
if (diff.trimmed().isEmpty()) return;
ensureAgent();
if (m_agentId.isEmpty()) return;
QString prompt = QStringLiteral("Review this diff. Point out potential issues, bugs, "
"or improvements:\n\n```diff\n") + diff + QStringLiteral("\n```");
m_9p->write(agentPath() + "/prompt", prompt.toUtf8());
}
void OllieKateView::startSession()
{
ensureAgent();
// Update cwd to current project
QString baseDir = projectBaseDir();
if (!baseDir.isEmpty() && !m_agentId.isEmpty()) {
m_9p->write(agentPath() + "/ctl", ("cwd " + baseDir).toUtf8());
}
updateStatusLabel();
}
// --- Ghost Text ---
void OllieKateView::setupGhostProvider()
{
auto *view = m_mainWindow->activeView();
if (!view) return;
if (m_ghost && m_ghost->view() == view) return;
if (m_ghost) {
delete m_ghost;
m_ghost = nullptr;
}
m_ghost = new OllieGhostProvider(view, m_plugin, this);
m_ghost->setEnabled(m_plugin->completionEnabled());
if (QWidget *proxy = view->focusProxy()) {
proxy->installEventFilter(this);
}
}
// --- Event Filter (ghost text key handling) ---
bool OllieKateView::eventFilter(QObject *obj, QEvent *event)
{
if (event->type() == QEvent::KeyPress && m_ghost) {
auto *keyEvent = static_cast<QKeyEvent *>(event);
// Second key of Meta+O chord
if (m_ghostPrefix) {
m_ghostPrefix = false;
switch (keyEvent->key()) {
case Qt::Key_N:
m_ghost->cycle(+1);
return true;
case Qt::Key_P:
m_ghost->cycle(-1);
return true;
case Qt::Key_C:
m_ghost->setEnabled(!m_ghost->isEnabled());
if (m_statusLabel) {
// Update status to reflect completion state
updateStatusLabel();
}
return true;
case Qt::Key_D:
m_ghost->dismiss();
return true;
case Qt::Key_Tab:
m_ghost->requestCompletion();
return true;
default:
break;
}
}
// Meta+O starts the chord prefix
if (keyEvent->key() == Qt::Key_O && keyEvent->modifiers() == Qt::MetaModifier) {
m_ghostPrefix = true;
return true;
}
if (m_ghost->hasSuggestion()) {
auto *activeView = m_mainWindow->activeView();
#if KTEXTEDITOR_VERSION_MAJOR >= 6
if (QApplication::activePopupWidget() ||
(activeView && activeView->isCompletionActive()))
return QObject::eventFilter(obj, event);
#else
auto *ccIface = activeView ? dynamic_cast<KTextEditor::CodeCompletionInterface *>(activeView) : nullptr;
if (QApplication::activePopupWidget() ||
(ccIface && ccIface->isCompletionActive()))
return QObject::eventFilter(obj, event);
#endif
if (keyEvent->key() == Qt::Key_Tab && keyEvent->modifiers() == Qt::NoModifier) {
if (m_ghost->accept()) {
return true;
}
}
if (keyEvent->key() == Qt::Key_Escape) {
m_ghost->dismiss();
return true;
}
}
}
return QObject::eventFilter(obj, event);
}
#include "ollie_kate.moc"

109
kde/kate/ollie_kate.h Normal file
View File

@ -0,0 +1,109 @@
#ifndef OLLIE_KATE_PLUGIN_H
#define OLLIE_KATE_PLUGIN_H
#include <KTextEditor/Plugin>
#include <KTextEditor/ConfigPage>
#include <KTextEditor/MainWindow>
#include <KTextEditor/View>
#if QT_VERSION_MAJOR >= 6
#include <KPluginMetaData>
#endif
#include <QPointer>
#include <QLabel>
#include <QAction>
#include <QEvent>
#include <QTimer>
#include "lib9pclient.h"
class OllieKateView;
class OllieGhostProvider;
class OllieKatePlugin : public KTextEditor::Plugin
{
Q_OBJECT
public:
#if QT_VERSION_MAJOR >= 6
explicit OllieKatePlugin(QObject *parent, const KPluginMetaData &data, const QVariantList &args = {});
#else
explicit OllieKatePlugin(QObject *parent, const QVariantList &args = {});
#endif
~OllieKatePlugin() override = default;
QObject *createView(KTextEditor::MainWindow *mainWindow) override;
int configPages() const override;
KTextEditor::ConfigPage *configPage(int number, QWidget *parent) override;
// Completion config accessors
QString completionBackend() const { return m_completionBackend; }
QString completionModel() const { return m_completionModel; }
bool completionEnabled() const { return m_completionEnabled; }
void setCompletionEnabled(bool enabled);
private:
void loadConfig();
void saveConfig();
QString m_completionBackend;
QString m_completionModel;
bool m_completionEnabled = true;
};
class OllieKateView : public QObject
{
Q_OBJECT
public:
explicit OllieKateView(OllieKatePlugin *plugin, KTextEditor::MainWindow *mainWindow);
~OllieKateView() override;
OllieKatePlugin *plugin() const { return m_plugin; }
protected:
bool eventFilter(QObject *obj, QEvent *event) override;
private slots:
void onProjectChanged();
void heartbeat();
private:
// Actions
void askAboutFile();
void explainSelection();
void submitSelectionPrompt(const QString &action);
void sendVerbatim();
void reviewDiff();
void startSession();
// Agent management
void ensureAgent();
bool selectAgentForProject();
void switchToAgent(const QString &agentId);
QString projectBaseDir();
QString agentPath() const;
void updateStatusLabel();
// Context menu
void setupContextMenu(KTextEditor::View *view);
void setupGitPlusPlusDiffAction();
// Ghost text
void setupGhostProvider();
OllieKatePlugin *m_plugin;
KTextEditor::MainWindow *m_mainWindow;
Lib9pClient *m_9p;
QString m_sessionId;
QString m_agentId;
bool m_connected = false;
QTimer *m_heartbeatTimer;
QLabel *m_statusLabel = nullptr;
QPointer<OllieGhostProvider> m_ghost;
bool m_ghostPrefix = false;
};
#endif // OLLIE_KATE_PLUGIN_H

22
kde/kio/ollie.json Normal file
View File

@ -0,0 +1,22 @@
{
"KDE-KIO-Protocols": {
"ollie": {
"Class": ":local",
"X-DocPath": "kio_ollie/index.html",
"exec": "kio_ollie",
"input": "none",
"output": "filesystem",
"protocol": "ollie",
"reading": true,
"writing": true,
"makedir": true,
"deleting": true,
"renaming": true,
"listing": "Name,Type,Size,Date,Access",
"opening": false,
"Icon": "ollie",
"Description": "Browse the Ollie 9P namespace",
"Description[x-test]": "xxBrowse the Ollie 9P namespacexx"
}
}
}

15
kde/kio/ollie.protocol Normal file
View File

@ -0,0 +1,15 @@
[Protocol]
exec=kio_ollie
protocol=ollie
input=none
output=filesystem
reading=true
writing=true
makedir=true
deleting=true
renaming=true
listing=Name,Type,Size,Date,Access
opening=false
Icon=ollie
Class=:local
Description=Browse the Ollie 9P namespace

339
kde/kio/ollie_kio.cpp Normal file
View File

@ -0,0 +1,339 @@
#include "ollie_kio.h"
#include <QCoreApplication>
#include <QUrl>
#include <QDateTime>
#include <QMimeDatabase>
#include <QMimeType>
#include <KIO/UDSEntry>
#include <KIO/WorkerFactory>
using namespace KIO;
// Plugin factory — KIO loads this .so and calls the appropriate create method
#if QT_VERSION_MAJOR >= 6
class OllieWorkerFactory : public KIO::WorkerFactory
{
Q_OBJECT
Q_PLUGIN_METADATA(IID "org.kde.kio.worker.ollie" FILE "ollie.json")
public:
using WorkerFactory::WorkerFactory;
std::unique_ptr<WorkerBase> createWorker(const QByteArray &pool, const QByteArray &app) override
{
return std::make_unique<OllieWorker>(pool, app);
}
};
#else
class OllieWorkerFactory : public KIO::RealWorkerFactory
{
Q_OBJECT
Q_PLUGIN_METADATA(IID "org.kde.kio.worker.ollie" FILE "ollie.json")
public:
using RealWorkerFactory::RealWorkerFactory;
std::unique_ptr<SlaveBase> createWorker(const QByteArray &pool, const QByteArray &app) override
{
Q_UNUSED(pool); Q_UNUSED(app);
return nullptr; // unused — KIO calls createRealWorker for RealWorkerFactory
}
std::unique_ptr<WorkerBase> createRealWorker(const QByteArray &pool, const QByteArray &app) override
{
return std::make_unique<OllieWorker>(pool, app);
}
};
#endif
// kdemain entry point — KF5 KIO launches workers by calling this
extern "C" Q_DECL_EXPORT int kdemain(int argc, char **argv)
{
QCoreApplication app(argc, argv);
app.setApplicationName(QStringLiteral("kio_ollie"));
if (argc != 4) {
fprintf(stderr, "Usage: kio_ollie protocol pool_socket app_socket\n");
return -1;
}
OllieWorker worker(argv[2], argv[3]);
worker.dispatchLoop();
return 0;
}
OllieWorker::OllieWorker(const QByteArray &pool, const QByteArray &app)
: WorkerBase("ollie", pool, app)
{
}
OllieWorker::~OllieWorker()
{
m_client.disconnect();
}
bool OllieWorker::ensureConnected()
{
if (m_client.isConnected())
return true;
return m_client.connectDefault();
}
QString OllieWorker::ninepPath(const QUrl &url) const
{
QString path = url.path();
// Remove leading slash — 9P root is ""
if (path.startsWith(QLatin1Char('/')))
path = path.mid(1);
// Remove trailing slash
if (path.endsWith(QLatin1Char('/')) && path.length() > 1)
path.chop(1);
return path;
}
bool OllieWorker::isDirectory(const QString &mode) const
{
// 9P mode: starts with 'd' for directory,
// or numeric with Plan 9 DMDIR bit (0x80000000)
if (mode.startsWith(QLatin1Char('d')))
return true;
bool ok;
uint modeVal = mode.toUInt(&ok, 0);
if (ok && (modeVal & 0x80000000))
return true;
return false;
}
mode_t OllieWorker::parseMode(const QString &modeStr) const
{
// Try numeric first
bool ok;
uint modeVal = modeStr.toUInt(&ok, 0);
if (ok) {
return static_cast<mode_t>(modeVal & 0777);
}
// Parse rwx string (e.g. "drwxr-xr-x")
QString perms = modeStr;
if (perms.startsWith(QLatin1Char('d')))
perms = perms.mid(1);
mode_t m = 0;
if (perms.length() >= 9) {
if (perms[0] == QLatin1Char('r')) m |= 0400;
if (perms[1] == QLatin1Char('w')) m |= 0200;
if (perms[2] == QLatin1Char('x')) m |= 0100;
if (perms[3] == QLatin1Char('r')) m |= 0040;
if (perms[4] == QLatin1Char('w')) m |= 0020;
if (perms[5] == QLatin1Char('x')) m |= 0010;
if (perms[6] == QLatin1Char('r')) m |= 0004;
if (perms[7] == QLatin1Char('w')) m |= 0002;
if (perms[8] == QLatin1Char('x')) m |= 0001;
} else {
m = 0644;
}
return m;
}
KIO::UDSEntry OllieWorker::entryFromStat(const QString &path, const QString &name)
{
UDSEntry entry;
auto st = m_client.stat(path);
if (st.valid) {
bool dir = isDirectory(st.mode);
entry.fastInsert(UDSEntry::UDS_NAME, name.isEmpty() ? st.name : name);
entry.fastInsert(UDSEntry::UDS_SIZE, dir ? 0 : st.size);
entry.fastInsert(UDSEntry::UDS_MODIFICATION_TIME, st.mtime);
entry.fastInsert(UDSEntry::UDS_ACCESS_TIME, st.mtime);
mode_t perm = parseMode(st.mode);
if (dir) {
entry.fastInsert(UDSEntry::UDS_FILE_TYPE, S_IFDIR);
entry.fastInsert(UDSEntry::UDS_ACCESS, perm | 0111);
} else {
entry.fastInsert(UDSEntry::UDS_FILE_TYPE, S_IFREG);
entry.fastInsert(UDSEntry::UDS_ACCESS, perm);
}
if (!dir) {
QMimeDatabase db;
QMimeType mime = db.mimeTypeForFile(name.isEmpty() ? st.name : name,
QMimeDatabase::MatchExtension);
if (mime.isValid() && !mime.isDefault()) {
entry.fastInsert(UDSEntry::UDS_MIME_TYPE, mime.name());
} else {
entry.fastInsert(UDSEntry::UDS_MIME_TYPE,
QStringLiteral("text/plain"));
}
}
} else {
entry.fastInsert(UDSEntry::UDS_NAME, name);
}
return entry;
}
WorkerResult OllieWorker::listDir(const QUrl &url)
{
if (!ensureConnected()) {
return WorkerResult::fail(ERR_CANNOT_CONNECT,
QStringLiteral("Cannot connect to Ollie 9P server: ")
+ m_client.lastError());
}
QString path = ninepPath(url);
QStringList entries = m_client.ls(path);
if (entries.isEmpty() && !m_client.lastError().isEmpty()) {
return WorkerResult::fail(ERR_CANNOT_ENTER_DIRECTORY,
m_client.lastError());
}
// Emit "." entry for the directory itself
UDSEntry dot;
dot.fastInsert(UDSEntry::UDS_NAME, QStringLiteral("."));
dot.fastInsert(UDSEntry::UDS_FILE_TYPE, S_IFDIR);
dot.fastInsert(UDSEntry::UDS_ACCESS, 0755);
listEntry(dot);
for (const QString &name : entries) {
QString childPath = path.isEmpty() ? name : (path + QLatin1Char('/') + name);
UDSEntry entry = entryFromStat(childPath, name);
listEntry(entry);
}
return WorkerResult::pass();
}
WorkerResult OllieWorker::stat(const QUrl &url)
{
if (!ensureConnected()) {
return WorkerResult::fail(ERR_CANNOT_CONNECT,
QStringLiteral("Cannot connect to Ollie 9P server: ")
+ m_client.lastError());
}
QString path = ninepPath(url);
QString name = url.fileName();
if (name.isEmpty())
name = QStringLiteral(".");
UDSEntry entry = entryFromStat(path, name);
if (!entry.contains(UDSEntry::UDS_FILE_TYPE)) {
if (path.isEmpty()) {
// Root directory
entry.fastInsert(UDSEntry::UDS_NAME, QStringLiteral("."));
entry.fastInsert(UDSEntry::UDS_FILE_TYPE, S_IFDIR);
entry.fastInsert(UDSEntry::UDS_ACCESS, 0755);
entry.fastInsert(UDSEntry::UDS_MIME_TYPE,
QStringLiteral("inode/directory"));
} else {
return WorkerResult::fail(ERR_DOES_NOT_EXIST,
m_client.lastError());
}
}
statEntry(entry);
return WorkerResult::pass();
}
WorkerResult OllieWorker::get(const QUrl &url)
{
if (!ensureConnected()) {
return WorkerResult::fail(ERR_CANNOT_CONNECT,
QStringLiteral("Cannot connect to Ollie 9P server: ")
+ m_client.lastError());
}
QString path = ninepPath(url);
QByteArray fileData = m_client.read(path);
if (fileData.isNull() && !m_client.lastError().isEmpty()) {
return WorkerResult::fail(ERR_CANNOT_READ,
m_client.lastError());
}
QMimeDatabase db;
QMimeType mime = db.mimeTypeForFileNameAndData(url.fileName(), fileData);
mimeType(mime.name());
totalSize(fileData.size());
data(fileData);
return WorkerResult::pass();
}
WorkerResult OllieWorker::put(const QUrl &url, int /*permissions*/, KIO::JobFlags /*flags*/)
{
if (!ensureConnected()) {
return WorkerResult::fail(ERR_CANNOT_CONNECT,
QStringLiteral("Cannot connect to Ollie 9P server: ")
+ m_client.lastError());
}
// Read incoming data from the job
QByteArray allData;
int result;
do {
QByteArray buffer;
dataReq();
result = readData(buffer);
if (result > 0)
allData.append(buffer);
} while (result > 0);
QString path = ninepPath(url);
if (!m_client.write(path, allData)) {
return WorkerResult::fail(ERR_CANNOT_WRITE,
m_client.lastError());
}
return WorkerResult::pass();
}
WorkerResult OllieWorker::mkdir(const QUrl &url, int /*permissions*/)
{
if (!ensureConnected()) {
return WorkerResult::fail(ERR_CANNOT_CONNECT,
QStringLiteral("Cannot connect to Ollie 9P server: ")
+ m_client.lastError());
}
QString path = ninepPath(url);
if (!m_client.mkdir(path)) {
return WorkerResult::fail(ERR_CANNOT_MKDIR,
m_client.lastError());
}
return WorkerResult::pass();
}
WorkerResult OllieWorker::del(const QUrl &url, bool /*isFile*/)
{
if (!ensureConnected()) {
return WorkerResult::fail(ERR_CANNOT_CONNECT,
QStringLiteral("Cannot connect to Ollie 9P server: ")
+ m_client.lastError());
}
QString path = ninepPath(url);
if (!m_client.remove(path)) {
return WorkerResult::fail(ERR_CANNOT_DELETE,
m_client.lastError());
}
return WorkerResult::pass();
}
WorkerResult OllieWorker::rename(const QUrl &src, const QUrl &dest, KIO::JobFlags /*flags*/)
{
if (!ensureConnected()) {
return WorkerResult::fail(ERR_CANNOT_CONNECT,
QStringLiteral("Cannot connect to Ollie 9P server: ")
+ m_client.lastError());
}
QString srcPath = ninepPath(src);
QString newName = dest.fileName();
if (!m_client.rename(srcPath, newName)) {
return WorkerResult::fail(ERR_CANNOT_RENAME,
m_client.lastError());
}
return WorkerResult::pass();
}
#include "ollie_kio.moc"

33
kde/kio/ollie_kio.h Normal file
View File

@ -0,0 +1,33 @@
#ifndef OLLIE_KIO_H
#define OLLIE_KIO_H
#include <KIO/WorkerBase>
#include "lib9pclient.h"
// KIO worker for ollie:// URLs.
// Maps ollie:///path directly to the Ollie 9P namespace.
class OllieWorker : public KIO::WorkerBase
{
public:
OllieWorker(const QByteArray &pool, const QByteArray &app);
~OllieWorker() override;
KIO::WorkerResult listDir(const QUrl &url) override;
KIO::WorkerResult stat(const QUrl &url) override;
KIO::WorkerResult get(const QUrl &url) override;
KIO::WorkerResult put(const QUrl &url, int permissions, KIO::JobFlags flags) override;
KIO::WorkerResult mkdir(const QUrl &url, int permissions) override;
KIO::WorkerResult del(const QUrl &url, bool isFile) override;
KIO::WorkerResult rename(const QUrl &src, const QUrl &dest, KIO::JobFlags flags) override;
private:
bool ensureConnected();
QString ninepPath(const QUrl &url) const;
KIO::UDSEntry entryFromStat(const QString &path, const QString &name);
bool isDirectory(const QString &mode) const;
mode_t parseMode(const QString &mode) const;
Lib9pClient m_client;
};
#endif // OLLIE_KIO_H

15
kde/krunner/metadata.json Normal file
View File

@ -0,0 +1,15 @@
{
"KPlugin": {
"Id": "org.ollie.krunner",
"Name": "Ollie",
"Description": "AI agent prompts and session management",
"Icon": "system-run",
"Authors": [
{
"Name": "ollie-kde"
}
]
},
"X-Plasma-API-Minimum-Version": "6.0",
"X-KDE-PluginInfo-EnabledByDefault": true
}

View File

@ -0,0 +1,200 @@
#include "ollie_runner.h"
#include <KPluginFactory>
#include <QDialog>
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QTextEdit>
#include <QPushButton>
#include <QClipboard>
#include <QApplication>
#include <QDir>
#include <QStandardPaths>
K_PLUGIN_CLASS_WITH_JSON(OllieRunner, "metadata.json")
static QString ninepBin()
{
QString bin = QStandardPaths::findExecutable("9p");
if (!bin.isEmpty()) return bin;
QString plan9 = qEnvironmentVariable("PLAN9");
if (!plan9.isEmpty() && QFileInfo::exists(plan9 + "/bin/9p"))
return plan9 + "/bin/9p";
return "9p";
}
#if QT_VERSION_MAJOR >= 6
OllieRunner::OllieRunner(QObject *parent, const KPluginMetaData &data)
: AbstractRunner(parent, data)
#else
OllieRunner::OllieRunner(QObject *parent, const KPluginMetaData &data, const QVariantList &args)
: AbstractRunner(parent, data, args)
#endif
{
}
QString OllieRunner::serverAddr()
{
QString ns = qEnvironmentVariable("NAMESPACE");
if (ns.isEmpty()) {
QString user = qEnvironmentVariable("USER");
QString display = qEnvironmentVariable("DISPLAY");
ns = "/tmp/ns." + user + "." + display;
}
return "unix!" + ns + "/ollie";
}
QByteArray OllieRunner::run9p(const QStringList &args)
{
QProcess proc;
QStringList fullArgs = {"-a", serverAddr()};
fullArgs.append(args);
proc.start(ninepBin(), fullArgs);
if (!proc.waitForFinished(5000)) return {};
return proc.readAllStandardOutput();
}
void OllieRunner::write9p(const QString &path, const QByteArray &data)
{
QProcess proc;
proc.start(ninepBin(), {"-a", serverAddr(), "write", path});
proc.waitForStarted(3000);
proc.write(data);
proc.closeWriteChannel();
proc.waitForFinished(3000);
}
QStringList OllieRunner::listSessions()
{
QByteArray out = run9p({"read", "session/idx"});
QString raw = QString::fromUtf8(out).trimmed();
if (raw.isEmpty()) return {};
return raw.split('\n');
}
void OllieRunner::killSession(const QString &id)
{
write9p("session/" + id + "/ctl", "kill");
}
void OllieRunner::match(RunnerNS::RunnerContext &context)
{
const QString query = context.query().trimmed();
if (!query.startsWith(QLatin1String("ollie ")) && query != QLatin1String("ollie"))
return;
QString args = query.mid(6).trimmed();
if (args.isEmpty() || args == QLatin1String("list")) {
QStringList sessions = listSessions();
if (sessions.isEmpty()) {
RunnerNS::QueryMatch match(this);
match.setId("ollie-no-sessions");
match.setText("No active sessions");
match.setIconName("dialog-information");
match.setRelevance(0.5);
context.addMatch(match);
} else {
for (const QString &entry : sessions) {
QStringList parts = entry.split('\t');
if (parts.size() < 4) continue;
RunnerNS::QueryMatch match(this);
match.setId("ollie-session-" + parts[0]);
QString state = parts[1] == "idle" ? "○" : "●";
match.setText(state + " " + parts[0].left(14) + " [" + parts[3] + "]");
match.setSubtext(parts[2] + " — " + parts[1]);
match.setIconName("system-run");
match.setRelevance(0.7);
match.setData(parts[0]);
context.addMatch(match);
}
}
return;
}
if (args.startsWith(QLatin1String("kill "))) {
QString target = args.mid(5).trimmed();
RunnerNS::QueryMatch match(this);
match.setId("ollie-kill-" + target);
match.setText("Kill session: " + target);
match.setSubtext("Terminate ollie session");
match.setIconName("process-stop");
match.setRelevance(0.8);
match.setData("kill:" + target);
context.addMatch(match);
return;
}
// Default: one-shot prompt via generate file
RunnerNS::QueryMatch match(this);
match.setId("ollie-prompt");
match.setText("Ask Ollie: " + args);
match.setSubtext("One-shot generation");
match.setIconName("system-run");
match.setRelevance(0.9);
match.setData("prompt:" + args);
context.addMatch(match);
}
void OllieRunner::run(const RunnerNS::RunnerContext &context, const RunnerNS::QueryMatch &match)
{
Q_UNUSED(context);
QString data = match.data().toString();
if (data.startsWith("kill:")) {
killSession(data.mid(5));
return;
}
if (data.startsWith("prompt:")) {
QString prompt = data.mid(7);
// Use the generate rdwr file for one-shot
QProcess proc;
proc.start(ninepBin(), {"-a", serverAddr(), "rdwr", "generate"});
proc.waitForStarted(3000);
proc.write(prompt.toUtf8());
proc.closeWriteChannel();
proc.waitForFinished(30000);
QString result = QString::fromUtf8(proc.readAllStandardOutput()).trimmed();
if (result.isEmpty()) result = "(no response)";
// Show popup
auto *dialog = new QDialog(nullptr);
dialog->setWindowTitle("Ollie — " + prompt.left(40));
dialog->setAttribute(Qt::WA_DeleteOnClose);
dialog->resize(520, 380);
auto *layout = new QVBoxLayout(dialog);
auto *textEdit = new QTextEdit(dialog);
textEdit->setReadOnly(true);
textEdit->setPlainText(result);
QFont monoFont("monospace");
monoFont.setPointSize(QApplication::font().pointSize());
textEdit->setFont(monoFont);
layout->addWidget(textEdit);
auto *buttonLayout = new QHBoxLayout();
buttonLayout->addStretch();
auto *copyBtn = new QPushButton("Copy", dialog);
QObject::connect(copyBtn, &QPushButton::clicked, [result]() {
QApplication::clipboard()->setText(result);
});
buttonLayout->addWidget(copyBtn);
auto *closeBtn = new QPushButton("Close", dialog);
QObject::connect(closeBtn, &QPushButton::clicked, dialog, &QDialog::close);
buttonLayout->addWidget(closeBtn);
layout->addLayout(buttonLayout);
dialog->show();
dialog->raise();
dialog->activateWindow();
return;
}
// Session selected — launch GUI
QProcess::startDetached("ollie-gui", {});
}
#include "ollie_runner.moc"

View File

@ -0,0 +1,37 @@
#ifndef OLLIE_RUNNER_H
#define OLLIE_RUNNER_H
#include <KRunner/AbstractRunner>
#include <KRunner/RunnerContext>
#include <KRunner/QueryMatch>
#include <QProcess>
#if QT_VERSION_MAJOR >= 6
namespace RunnerNS = KRunner;
#else
namespace RunnerNS = Plasma;
#endif
class OllieRunner : public RunnerNS::AbstractRunner
{
Q_OBJECT
public:
#if QT_VERSION_MAJOR >= 6
OllieRunner(QObject *parent, const KPluginMetaData &data);
#else
OllieRunner(QObject *parent, const KPluginMetaData &data, const QVariantList &args);
#endif
void match(RunnerNS::RunnerContext &context) override;
void run(const RunnerNS::RunnerContext &context, const RunnerNS::QueryMatch &match) override;
private:
QByteArray run9p(const QStringList &args);
void write9p(const QString &path, const QByteArray &data);
QString serverAddr();
QStringList listSessions();
void killSession(const QString &id);
};
#endif // OLLIE_RUNNER_H