diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 642955b..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "kde"] - path = kde - url = ../ollie-kde.git diff --git a/AGENTS.md b/AGENTS.md index 4c7f175..b4e90f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index b81cc07..5c87a3d 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/doc/architecture.md b/doc/architecture.md index c03b1aa..3a0414f 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -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) diff --git a/doc/evolution.md b/doc/evolution.md index 2dbd7ae..3acb31b 100644 --- a/doc/evolution.md +++ b/doc/evolution.md @@ -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) diff --git a/kde b/kde deleted file mode 160000 index 2de82e6..0000000 --- a/kde +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 2de82e6a7b3c3b8e6aedf78fb239fcd90e25906d diff --git a/kde/.gitignore b/kde/.gitignore new file mode 100644 index 0000000..a5da12b --- /dev/null +++ b/kde/.gitignore @@ -0,0 +1,6 @@ +build/ +build-cmake/ +build-kf5/ +build-kf6/ +CMakeFiles/ +__pycache__/ diff --git a/kde/99-ollie-kf5.sh b/kde/99-ollie-kf5.sh new file mode 100644 index 0000000..6971071 --- /dev/null +++ b/kde/99-ollie-kf5.sh @@ -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} diff --git a/kde/99-ollie.sh b/kde/99-ollie.sh new file mode 100644 index 0000000..832003c --- /dev/null +++ b/kde/99-ollie.sh @@ -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} diff --git a/kde/CMakeLists.txt b/kde/CMakeLists.txt new file mode 100644 index 0000000..9be0ff6 --- /dev/null +++ b/kde/CMakeLists.txt @@ -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() diff --git a/kde/README.md b/kde/README.md new file mode 100644 index 0000000..ae3d191 --- /dev/null +++ b/kde/README.md @@ -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 ` — one-shot generation via `Generate` (no session created); shows result in a popup +- `ollie list` — show active sessions +- `ollie kill ` — 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/` diff --git a/kde/dolphin/ollie-actions-kf5.desktop b/kde/dolphin/ollie-actions-kf5.desktop new file mode 100644 index 0000000..db2e6be --- /dev/null +++ b/kde/dolphin/ollie-actions-kf5.desktop @@ -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 diff --git a/kde/dolphin/ollie-actions.desktop b/kde/dolphin/ollie-actions.desktop new file mode 100644 index 0000000..6cee70c --- /dev/null +++ b/kde/dolphin/ollie-actions.desktop @@ -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 diff --git a/kde/dolphin/ollie-ask b/kde/dolphin/ollie-ask new file mode 100755 index 0000000..493db8f --- /dev/null +++ b/kde/dolphin/ollie-ask @@ -0,0 +1,29 @@ +#!/bin/sh +# ollie-ask: one-shot prompt helper for Dolphin service menus +# Usage: ollie-ask [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" diff --git a/kde/dolphin/ollie-session-here b/kde/dolphin/ollie-session-here new file mode 100755 index 0000000..faa4ce7 --- /dev/null +++ b/kde/dolphin/ollie-session-here @@ -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 diff --git a/kde/gui/ChatBlockDelegate.qml b/kde/gui/ChatBlockDelegate.qml new file mode 100644 index 0000000..5450aef --- /dev/null +++ b/kde/gui/ChatBlockDelegate.qml @@ -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 + } + } + } + } + } +} diff --git a/kde/gui/ChatPane.qml b/kde/gui/ChatPane.qml new file mode 100644 index 0000000..cdd60f6 --- /dev/null +++ b/kde/gui/ChatPane.qml @@ -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() } +} + +} diff --git a/kde/gui/NewAgentDialog.qml b/kde/gui/NewAgentDialog.qml new file mode 100644 index 0000000..9e12fcc --- /dev/null +++ b/kde/gui/NewAgentDialog.qml @@ -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() + } +} diff --git a/kde/gui/NewSessionDialog.qml b/kde/gui/NewSessionDialog.qml new file mode 100644 index 0000000..a61fba6 --- /dev/null +++ b/kde/gui/NewSessionDialog.qml @@ -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() + } +} diff --git a/kde/gui/SessionTree.qml b/kde/gui/SessionTree.qml new file mode 100644 index 0000000..40e34e5 --- /dev/null +++ b/kde/gui/SessionTree.qml @@ -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 = "" } + +} diff --git a/kde/gui/SettingsDialog.qml b/kde/gui/SettingsDialog.qml new file mode 100644 index 0000000..04221de --- /dev/null +++ b/kde/gui/SettingsDialog.qml @@ -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() + } + } +} diff --git a/kde/gui/chatblockmodel.cpp b/kde/gui/chatblockmodel.cpp new file mode 100644 index 0000000..7f78e12 --- /dev/null +++ b/kde/gui/chatblockmodel.cpp @@ -0,0 +1,926 @@ +#include "chatblockmodel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + + +// --- 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("") + text + QStringLiteral(""); + } else if (!defaultColor.isEmpty()) { + html += QStringLiteral("") + text + QStringLiteral(""); + } else { + html += text; + } + } + +private: + QString escapeHtml(const QString &text) const { + return text.toHtmlEscaped() + .replace(QStringLiteral(" "), QStringLiteral(" ")) + .replace(QStringLiteral("\t"), QStringLiteral("    ")); + } + + 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 &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 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 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("
");
+        result += code.toHtmlEscaped();
+        result += QStringLiteral("
"); + 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("
"); + } + + QString result = QStringLiteral("
");
+    result += highlighter.html;
+    result += QStringLiteral("
"); + return result; +} + +QString ChatBlockModel::highlightCodeWrapped(const QString &innerHtml) const +{ + return QStringLiteral("
") + innerHtml + QStringLiteral("
"); +} + +QString ChatBlockModel::escapeForHtml(const QString &text) const +{ + const QString defaultColor = getDefaultTextColor(); + QString result = QStringLiteral("
"); + result += text.toHtmlEscaped(); + result += QStringLiteral("
"); + return result; +} + +void ChatBlockModel::rebuildBlockHtml(int blockIndex) +{ + if (blockIndex < 0 || blockIndex >= m_blocks.size()) + return; + + ChatBlock &block = m_blocks[blockIndex]; + QString html; + QList 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 ). A finalized code fence segment + // ends in and must not be overwritten. + bool canExtend = !block.segments.isEmpty() + && block.segments.last().type == BlockSegment::Text + && block.segments.last().html.endsWith(QStringLiteral("")); + + if (canExtend) { + QString &segHtml = block.segments.last().html; + segHtml.chop(6); // remove trailing + segHtml += '\n' + escapedLine + QStringLiteral(""); + } else { + // Start a new prose segment + rs.proseLines.clear(); + rs.proseLines.append(line); + BlockSegment seg; + seg.type = BlockSegment::Text; + seg.html = QStringLiteral("
") + + escapedLine + QStringLiteral("
"); + 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("
"); + + // 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("
")
+                        + rs.hlHtml + QStringLiteral("
"); + } + 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("
"); + 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("
")
+                    + inner + QStringLiteral("
"); + } + } 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(""))) { + segHtml.chop(6); + segHtml += '\n' + escapedPartial + QStringLiteral(""); + } else { + segHtml = escapeForHtml((rs.proseLines + QStringList{partialLine}).join('\n')); + } + } else { + BlockSegment seg; + seg.type = BlockSegment::Text; + seg.html = QStringLiteral("
") + + escapedPartial + QStringLiteral("
"); + 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("
Loading ") + + lang.toHtmlEscaped() + QStringLiteral(" diagram...
"); +} + +QString ChatBlockModel::diagramErrorHtml(const QString &error) const +{ + return QStringLiteral("
") + + QStringLiteral("Diagram error: ") + error.toHtmlEscaped() + + QStringLiteral("
"); +} + +QString ChatBlockModel::diagramSvgHtml(const QByteArray &svg) const +{ + // Embed SVG inline as data URI + const QString base64 = QString::fromLatin1(svg.toBase64()); + return QStringLiteral(""); +} + +QString ChatBlockModel::diagramPngHtml(const QByteArray &png) const +{ + // Embed PNG inline as data URI + const QString base64 = QString::fromLatin1(png.toBase64()); + return QStringLiteral(""); +} + +QString ChatBlockModel::diagramJpegHtml(const QByteArray &jpeg) const +{ + // Embed JPEG inline as data URI + const QString base64 = QString::fromLatin1(jpeg.toBase64()); + return QStringLiteral(""); +} + +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(); + } + 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("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 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}); + } + } +} diff --git a/kde/gui/chatblockmodel.h b/kde/gui/chatblockmodel.h new file mode 100644 index 0000000..6a795e4 --- /dev/null +++ b/kde/gui/chatblockmodel.h @@ -0,0 +1,156 @@ +#ifndef CHATBLOCKMODEL_H +#define CHATBLOCKMODEL_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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 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 &segments) const; + + State m_state = State::BlockStart; + KSyntaxHighlighting::Repository *m_repo = nullptr; + QNetworkAccessManager *m_netManager = nullptr; + + QList 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 m_diagramCache; + // Pending requests: hash -> block indices waiting for this diagram + QHash> m_pendingDiagrams; + // Track which requests are in flight + QSet 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 diff --git a/kde/gui/clipboardhelper.cpp b/kde/gui/clipboardhelper.cpp new file mode 100644 index 0000000..d67a107 --- /dev/null +++ b/kde/gui/clipboardhelper.cpp @@ -0,0 +1 @@ +#include "clipboardhelper.h" diff --git a/kde/gui/clipboardhelper.h b/kde/gui/clipboardhelper.h new file mode 100644 index 0000000..e93a075 --- /dev/null +++ b/kde/gui/clipboardhelper.h @@ -0,0 +1,59 @@ +#ifndef CLIPBOARDHELPER_H +#define CLIPBOARDHELPER_H + +#include +#include +#include +#include +#include + +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 diff --git a/kde/gui/lib9pclient.cpp b/kde/gui/lib9pclient.cpp new file mode 100644 index 0000000..8b50ae0 --- /dev/null +++ b/kde/gui/lib9pclient.cpp @@ -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(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(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); +} diff --git a/kde/gui/lib9pclient.h b/kde/gui/lib9pclient.h new file mode 100644 index 0000000..9a4a7f8 --- /dev/null +++ b/kde/gui/lib9pclient.h @@ -0,0 +1,63 @@ +#ifndef LIB9PCLIENT_H +#define LIB9PCLIENT_H + +#include +#include +#include +#include + +// 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 diff --git a/kde/gui/main.cpp b/kde/gui/main.cpp new file mode 100644 index 0000000..cd352d2 --- /dev/null +++ b/kde/gui/main.cpp @@ -0,0 +1,52 @@ +// ollie-gui-9p: minimal Qt GUI using 9P for all communication. +// Uses ollie-9p subprocess for 9P transport. +#include +#include +#include +#include + +#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(); +} diff --git a/kde/gui/main.qml b/kde/gui/main.qml new file mode 100644 index 0000000..c68ae90 --- /dev/null +++ b/kde/gui/main.qml @@ -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 + } + } +} diff --git a/kde/gui/nativestreamer.cpp b/kde/gui/nativestreamer.cpp new file mode 100644 index 0000000..469802f --- /dev/null +++ b/kde/gui/nativestreamer.cpp @@ -0,0 +1,134 @@ +#include "nativestreamer.h" +#include +#include + +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::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 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(); + } +} diff --git a/kde/gui/nativestreamer.h b/kde/gui/nativestreamer.h new file mode 100644 index 0000000..3e63056 --- /dev/null +++ b/kde/gui/nativestreamer.h @@ -0,0 +1,61 @@ +#ifndef NATIVESTREAMER_H +#define NATIVESTREAMER_H + +#include +#include +#include +#include +#include + +/** + * 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 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 m_guard; + QTimer *m_restartTimer = nullptr; + bool m_stopping = false; +}; + +#endif // NATIVESTREAMER_H diff --git a/kde/gui/ollie.svg b/kde/gui/ollie.svg new file mode 100644 index 0000000..3b87f27 --- /dev/null +++ b/kde/gui/ollie.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/kde/gui/ollie9pclient.cpp b/kde/gui/ollie9pclient.cpp new file mode 100644 index 0000000..0bf2fec --- /dev/null +++ b/kde/gui/ollie9pclient.cpp @@ -0,0 +1,720 @@ +#include "ollie9pclient.h" + +#include +#include +#include +#include +#include +#include + +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; +} diff --git a/kde/gui/ollie9pclient.h b/kde/gui/ollie9pclient.h new file mode 100644 index 0000000..3329ce1 --- /dev/null +++ b/kde/gui/ollie9pclient.h @@ -0,0 +1,146 @@ +#ifndef OLLIE9PCLIENT_H +#define OLLIE9PCLIENT_H + +#include +#include +#include +#include +#include +#if QT_VERSION_MAJOR >= 6 +#include +#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 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 \ No newline at end of file diff --git a/kde/gui/org.ollie.gui.desktop b/kde/gui/org.ollie.gui.desktop new file mode 100644 index 0000000..3fffefa --- /dev/null +++ b/kde/gui/org.ollie.gui.desktop @@ -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 diff --git a/kde/gui/qml.qrc b/kde/gui/qml.qrc new file mode 100644 index 0000000..a491fee --- /dev/null +++ b/kde/gui/qml.qrc @@ -0,0 +1,11 @@ + + + main.qml + SettingsDialog.qml + NewSessionDialog.qml + NewAgentDialog.qml + ChatBlockDelegate.qml + SessionTree.qml + ChatPane.qml + + diff --git a/kde/gui/sessionmodel.cpp b/kde/gui/sessionmodel.cpp new file mode 100644 index 0000000..7480926 --- /dev/null +++ b/kde/gui/sessionmodel.cpp @@ -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 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 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; + } + } +} diff --git a/kde/gui/sessionmodel.h b/kde/gui/sessionmodel.h new file mode 100644 index 0000000..374168c --- /dev/null +++ b/kde/gui/sessionmodel.h @@ -0,0 +1,76 @@ +#ifndef SESSIONMODEL_H +#define SESSIONMODEL_H + +#include +#include +#include +#include +#include + +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 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 m_rows; + QHash m_expanded; +}; + +#endif diff --git a/kde/gui/thememanager.cpp b/kde/gui/thememanager.cpp new file mode 100644 index 0000000..43fbcd1 --- /dev/null +++ b/kde/gui/thememanager.cpp @@ -0,0 +1,467 @@ +#include "thememanager.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +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); +} diff --git a/kde/gui/thememanager.h b/kde/gui/thememanager.h new file mode 100644 index 0000000..9f3676e --- /dev/null +++ b/kde/gui/thememanager.h @@ -0,0 +1,133 @@ +#ifndef OLLIE_THEME_MANAGER_H +#define OLLIE_THEME_MANAGER_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 diff --git a/kde/justfile b/kde/justfile new file mode 100644 index 0000000..8c4f7db --- /dev/null +++ b/kde/justfile @@ -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 ===' \ No newline at end of file diff --git a/kde/kate/metadata.json b/kde/kate/metadata.json new file mode 100644 index 0000000..8f5a30d --- /dev/null +++ b/kde/kate/metadata.json @@ -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 +} diff --git a/kde/kate/ollie_ghost.cpp b/kde/kate/ollie_ghost.cpp new file mode 100644 index 0000000..8c0ab56 --- /dev/null +++ b/kde/kate/ollie_ghost.cpp @@ -0,0 +1,551 @@ +#include "ollie_ghost.h" +#include "ollie_kate.h" + +#include +#if KTEXTEDITOR_VERSION_MAJOR < 6 +#include +#include +#include +#endif + +#include +#include +#include +#include +#include +#include +#include + +// === 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(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(m_view); + if (iface) + iface->unregisterInlineNoteProvider(this); +#endif + } +} + +// --- InlineNoteProvider interface --- + +#if KTEXTEDITOR_VERSION_MAJOR >= 6 +QList OllieGhostProvider::inlineNotes(int line) const +#else +QVector 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 ¬e) 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 ¬e, QPainter &painter, Qt::LayoutDirection direction) const +{ + Q_UNUSED(direction); +#else +void OllieGhostProvider::paintInlineNote(const KTextEditor::InlineNote ¬e, 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(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" + "\n%3\n\n\n%4\n" + ).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::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(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(); +#else + QFont font; + auto *cfgIface = dynamic_cast(m_view); + if (cfgIface) + font = cfgIface->configValue(QStringLiteral("font")).value(); + 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 == "" || trimmed == "" || + trimmed == "" || trimmed == "") + 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; +} \ No newline at end of file diff --git a/kde/kate/ollie_ghost.h b/kde/kate/ollie_ghost.h new file mode 100644 index 0000000..028d0a1 --- /dev/null +++ b/kde/kate/ollie_ghost.h @@ -0,0 +1,131 @@ +#ifndef OLLIE_GHOST_H +#define OLLIE_GHOST_H + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +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 inlineNotes(int line) const override; +#else + QVector inlineNotes(int line) const override; +#endif + QSize inlineNoteSize(const KTextEditor::InlineNote ¬e) const override; +#if KTEXTEDITOR_VERSION_MAJOR >= 6 + void paintInlineNote(const KTextEditor::InlineNote ¬e, QPainter &painter, Qt::LayoutDirection direction) const override; +#else + void paintInlineNote(const KTextEditor::InlineNote ¬e, 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 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 diff --git a/kde/kate/ollie_kate.cpp b/kde/kate/ollie_kate.cpp new file mode 100644 index 0000000..4dffef8 --- /dev/null +++ b/kde/kate/ollie_kate.cpp @@ -0,0 +1,648 @@ +#include "ollie_kate.h" +#include "ollie_ghost.h" + +#include +#include +#include +#include +#include +#include +#if KTEXTEDITOR_VERSION_MAJOR < 6 +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(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 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(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(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" diff --git a/kde/kate/ollie_kate.h b/kde/kate/ollie_kate.h new file mode 100644 index 0000000..4ad4922 --- /dev/null +++ b/kde/kate/ollie_kate.h @@ -0,0 +1,109 @@ +#ifndef OLLIE_KATE_PLUGIN_H +#define OLLIE_KATE_PLUGIN_H + +#include +#include +#include +#include +#if QT_VERSION_MAJOR >= 6 +#include +#endif + +#include +#include +#include +#include +#include + +#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 m_ghost; + bool m_ghostPrefix = false; +}; + +#endif // OLLIE_KATE_PLUGIN_H diff --git a/kde/kio/ollie.json b/kde/kio/ollie.json new file mode 100644 index 0000000..e6d358e --- /dev/null +++ b/kde/kio/ollie.json @@ -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" + } + } +} diff --git a/kde/kio/ollie.protocol b/kde/kio/ollie.protocol new file mode 100644 index 0000000..db30a77 --- /dev/null +++ b/kde/kio/ollie.protocol @@ -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 diff --git a/kde/kio/ollie_kio.cpp b/kde/kio/ollie_kio.cpp new file mode 100644 index 0000000..73fb64a --- /dev/null +++ b/kde/kio/ollie_kio.cpp @@ -0,0 +1,339 @@ +#include "ollie_kio.h" + +#include +#include +#include +#include +#include + +#include +#include + +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 createWorker(const QByteArray &pool, const QByteArray &app) override + { + return std::make_unique(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 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 createRealWorker(const QByteArray &pool, const QByteArray &app) override + { + return std::make_unique(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(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" diff --git a/kde/kio/ollie_kio.h b/kde/kio/ollie_kio.h new file mode 100644 index 0000000..d30dc3a --- /dev/null +++ b/kde/kio/ollie_kio.h @@ -0,0 +1,33 @@ +#ifndef OLLIE_KIO_H +#define OLLIE_KIO_H + +#include +#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 diff --git a/kde/krunner/metadata.json b/kde/krunner/metadata.json new file mode 100644 index 0000000..f887577 --- /dev/null +++ b/kde/krunner/metadata.json @@ -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 +} diff --git a/kde/krunner/ollie_runner.cpp b/kde/krunner/ollie_runner.cpp new file mode 100644 index 0000000..8cd5ec7 --- /dev/null +++ b/kde/krunner/ollie_runner.cpp @@ -0,0 +1,200 @@ +#include "ollie_runner.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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" diff --git a/kde/krunner/ollie_runner.h b/kde/krunner/ollie_runner.h new file mode 100644 index 0000000..037f7aa --- /dev/null +++ b/kde/krunner/ollie_runner.h @@ -0,0 +1,37 @@ +#ifndef OLLIE_RUNNER_H +#define OLLIE_RUNNER_H + +#include +#include +#include +#include + +#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