Files
niri-ai-sidebar/sidebar-plan.md
2025-10-24 20:12:51 +02:00

19 KiB

AI Chat Sidebar Development Plan - Direct GTK4 Approach

Based on your comprehensive feasibility assessment and the gathered documentation, here's a production-ready development plan using direct GTK4 + Python instead of Ignis.


Documentation Resources

Core Documentation

  1. gtk4-layer-shell: https://github.com/wmww/gtk4-layer-shell
    • Python examples in examples/ directory
    • API documentation for layer positioning
    • Installation: pacman -S gtk4-layer-shell (Arch) or build from source
  2. PyGObject Threading Guide: https://pygobject.gnome.org/guide/threading.html
    • Essential patterns for GLib.idle_add() usage
    • Thread-safety guidelines for GTK operations
  3. Ollama Python Library: https://github.com/ollama/ollama-python
    • Installation: pip install ollama
    • Streaming chat API with stream=True parameter
  4. Niri Configuration: https://github.com/YaLTeR/niri/wiki/Configuration:-Introduction
    • KDL syntax guide
    • Layer-rules documentation
    • Live reload capabilities
  5. Alpaca Reference Implementation: https://github.com/Jeffser/Alpaca
    • Production GTK4 + Ollama patterns
    • Threading implementation examples
    • UI/UX patterns for chat interfaces

Project Structure

ai-sidebar/
├── main.py                    # Entry point, Gtk.Application setup
├── sidebar_window.py          # Layer-shell window with GTK4
├── ollama_client.py           # Ollama API wrapper with threading
├── message_widget.py          # Individual message bubble
├── conversation_manager.py    # State management, persistence
├── styles.css                 # GTK CSS styling
├── config.py                  # User settings (model, API endpoint)
└── data/
    └── conversations/         # XDG_DATA_HOME/ai-sidebar/
        ├── index.json         # Session index
        └── {uuid}.json        # Individual conversations

Development Phases

Phase 1: Minimal Proof-of-Concept (Days 1-2)

Objective: Validate gtk4-layer-shell works with Niri and basic Ollama connectivity

Tasks:

  1. Install dependencies:
# Arch Linux
sudo pacman -S gtk4 gtk4-layer-shell python-gobject python-pip
pip install ollama

# Clone examples
git clone https://github.com/wmww/gtk4-layer-shell
cd gtk4-layer-shell/examples
# Study Python examples
  1. Create minimal sidebar (minimal_poc.py):
#!/usr/bin/env python3
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Gtk4LayerShell', '1.0')
from gi.repository import Gtk, Gtk4LayerShell, GLib
import ollama
import threading

class MinimalSidebar(Gtk.ApplicationWindow):
    def __init__(self, app):
        super().__init__(application=app, title="AI Sidebar")
        
        # Initialize layer shell
        Gtk4LayerShell.init_for_window(self)
        Gtk4LayerShell.set_namespace(self, "ai-sidebar")
        Gtk4LayerShell.set_layer(self, Gtk4LayerShell.Layer.TOP)
        
        # Anchor to left edge, full height
        Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, True)
        Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.TOP, True)
        Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.BOTTOM, True)
        Gtk4LayerShell.set_margin(self, Gtk4LayerShell.Edge.LEFT, 0)
        
        # Request keyboard input
        Gtk4LayerShell.set_keyboard_mode(self, 
            Gtk4LayerShell.KeyboardMode.ON_DEMAND)
        
        # Set width
        self.set_default_size(350, -1)
        
        # Build UI
        self.setup_ui()
    
    def setup_ui(self):
        main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
        
        # Chat display area
        self.textview = Gtk.TextView()
        self.textview.set_editable(False)
        self.textview.set_wrap_mode(Gtk.WrapMode.WORD)
        self.textview.set_margin_start(10)
        self.textview.set_margin_end(10)
        
        scroll = Gtk.ScrolledWindow()
        scroll.set_child(self.textview)
        scroll.set_vexpand(True)
        scroll.set_hexpand(True)
        
        # Input area
        input_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
        input_box.set_margin_start(10)
        input_box.set_margin_end(10)
        input_box.set_margin_top(5)
        input_box.set_margin_bottom(10)
        
        self.entry = Gtk.Entry()
        self.entry.set_hexpand(True)
        self.entry.set_placeholder_text("Ask something...")
        self.entry.connect('activate', self.on_send)
        
        send_btn = Gtk.Button(label="Send")
        send_btn.connect('clicked', self.on_send)
        
        input_box.append(self.entry)
        input_box.append(send_btn)
        
        main_box.append(scroll)
        main_box.append(input_box)
        
        self.set_child(main_box)
    
    def append_text(self, text):
        """Thread-safe text append"""
        buffer = self.textview.get_buffer()
        end_iter = buffer.get_end_iter()
        buffer.insert(end_iter, text, -1)
        
        # Auto-scroll to bottom
        mark = buffer.create_mark(None, end_iter, False)
        self.textview.scroll_to_mark(mark, 0.0, True, 0.0, 1.0)
    
    def on_send(self, widget):
        prompt = self.entry.get_text().strip()
        if not prompt:
            return
        
        self.entry.set_text("")
        GLib.idle_add(self.append_text, f"\n[You] {prompt}\n")
        
        def worker():
            try:
                GLib.idle_add(self.append_text, "[AI] ")
                
                # Stream response from Ollama
                stream = ollama.chat(
                    model='llama3.2',  # Use installed model
                    messages=[{'role': 'user', 'content': prompt}],
                    stream=True
                )
                
                for chunk in stream:
                    content = chunk['message']['content']
                    GLib.idle_add(self.append_text, content)
                
                GLib.idle_add(self.append_text, "\n")
                
            except Exception as e:
                GLib.idle_add(self.append_text, f"\n[Error] {str(e)}\n")
        
        thread = threading.Thread(target=worker, daemon=True)
        thread.start()

class App(Gtk.Application):
    def do_activate(self):
        window = MinimalSidebar(self)
        window.present()

if __name__ == '__main__':
    app = App()
    app.run(None)
  1. Test basic functionality:
# Ensure Ollama is running with a model
ollama pull llama3.2

# Run the sidebar
python minimal_poc.py
  1. Add Niri configuration (~/.config/niri/config.kdl):
layer-rule {
    match namespace="^ai-sidebar$"
    
    // Optional: Add shadow
    shadow {
        on
        softness 40
        spread 5
        offset x=0 y=5
    }
}

binds {
    // Toggle sidebar with Super+A
    Mod+A { spawn "python" "/path/to/minimal_poc.py"; }
}

AI Coding Tool Prompt for Phase 1:

Create a minimal GTK4 application using gtk4-layer-shell that:
1. Initializes layer-shell with namespace "ai-sidebar"
2. Anchors to left edge, full height, 350px width
3. Uses Gtk4LayerShell.Layer.TOP for z-order
4. Contains a TextView (read-only) and Entry widget
5. Connects Entry's 'activate' signal to send messages
6. Uses threading.Thread + GLib.idle_add for Ollama streaming
7. Calls ollama.chat() with stream=True in background thread
8. Appends each chunk to TextView via GLib.idle_add

Requirements:
- Python 3.11+, GTK4, gtk4-layer-shell, ollama-python
- Thread-safe UI updates only via GLib.idle_add
- Basic error handling for connection failures
- Auto-scroll TextView to bottom after each message

Reference PyGObject threading guide patterns.

Phase 2: Production Features (Days 3-5)

Objective: Add model selection, conversation persistence, better UI

Components:

  1. Ollama Client (ollama_client.py):
import ollama
import threading
from typing import Callable, Optional, List, Dict
from gi.repository import GLib

class OllamaClient:
    def __init__(self):
        self.base_url = "http://localhost:11434"
        self.current_model = "llama3.2"
        self._cancel_event = threading.Event()
    
    def get_models(self) -> List[str]:
        """Get list of installed models"""
        try:
            models = ollama.list()
            return [m['name'] for m in models['models']]
        except Exception as e:
            print(f"Error fetching models: {e}")
            return []
    
    def stream_chat(
        self,
        messages: List[Dict[str, str]],
        on_chunk: Callable[[str], None],
        on_complete: Callable[[], None],
        on_error: Callable[[str], None]
    ):
        """Stream chat response in background thread"""
        self._cancel_event.clear()
        
        def worker():
            try:
                stream = ollama.chat(
                    model=self.current_model,
                    messages=messages,
                    stream=True
                )
                
                for chunk in stream:
                    if self._cancel_event.is_set():
                        break
                    
                    content = chunk['message']['content']
                    GLib.idle_add(on_chunk, content)
                
                if not self._cancel_event.is_set():
                    GLib.idle_add(on_complete)
                    
            except Exception as e:
                GLib.idle_add(on_error, str(e))
        
        thread = threading.Thread(target=worker, daemon=True)
        thread.start()
    
    def cancel(self):
        """Cancel current streaming operation"""
        self._cancel_event.set()
  1. Conversation Manager (conversation_manager.py):
import json
import uuid
from pathlib import Path
from typing import List, Dict, Optional
from datetime import datetime

class ConversationManager:
    def __init__(self):
        # XDG data directory
        self.data_dir = Path.home() / ".local/share/ai-sidebar/conversations"
        self.data_dir.mkdir(parents=True, exist_ok=True)
        
        self.index_file = self.data_dir / "index.json"
        self.current_session_id: Optional[str] = None
        self.messages: List[Dict[str, str]] = []
    
    def new_session(self) -> str:
        """Create new conversation session"""
        session_id = str(uuid.uuid4())
        self.current_session_id = session_id
        self.messages = []
        self._update_index(session_id)
        return session_id
    
    def add_message(self, role: str, content: str):
        """Add message to current session"""
        self.messages.append({
            "role": role,
            "content": content,
            "timestamp": datetime.now().isoformat()
        })
        self._save_session()
    
    def load_session(self, session_id: str) -> List[Dict]:
        """Load conversation from file"""
        session_file = self.data_dir / f"{session_id}.json"
        if session_file.exists():
            with open(session_file, 'r') as f:
                data = json.load(f)
                self.messages = data.get('messages', [])
                self.current_session_id = session_id
                return self.messages
        return []
    
    def _save_session(self):
        """Save current session to disk"""
        if not self.current_session_id:
            return
        
        session_file = self.data_dir / f"{self.current_session_id}.json"
        with open(session_file, 'w') as f:
            json.dump({
                "session_id": self.current_session_id,
                "created": datetime.now().isoformat(),
                "messages": self.messages
            }, f, indent=2)
    
    def _update_index(self, session_id: str):
        """Update session index"""
        index = []
        if self.index_file.exists():
            with open(self.index_file, 'r') as f:
                index = json.load(f)
        
        index.append({
            "id": session_id,
            "created": datetime.now().isoformat()
        })
        
        with open(self.index_file, 'w') as f:
            json.dump(index, f, indent=2)
  1. Model Selector Widget:

Add to sidebar window:

def setup_header(self):
    header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
    header.set_margin_start(10)
    header.set_margin_end(10)
    header.set_margin_top(10)
    
    # Model dropdown
    self.model_combo = Gtk.ComboBoxText()
    self.model_combo.set_hexpand(True)
    self.refresh_models()
    self.model_combo.connect('changed', self.on_model_changed)
    
    # New chat button
    new_btn = Gtk.Button(label="New")
    new_btn.connect('clicked', self.on_new_chat)
    
    header.append(self.model_combo)
    header.append(new_btn)
    
    return header

def refresh_models(self):
    models = self.ollama_client.get_models()
    self.model_combo.remove_all()
    for model in models:
        self.model_combo.append_text(model)
    if models:
        self.model_combo.set_active(0)

AI Coding Tool Prompt for Phase 2:

Extend the minimal sidebar with:

1. OllamaClient class:
   - get_models() method calling ollama.list()
   - stream_chat() with threading.Event for cancellation
   - Callbacks: on_chunk, on_complete, on_error
   - Thread-safe via GLib.idle_add

2. ConversationManager class:
   - XDG data directory: ~/.local/share/ai-sidebar/conversations/
   - new_session() creates UUID, initializes messages list
   - add_message(role, content) appends and saves to JSON
   - load_session(id) loads from {uuid}.json file
   - Auto-save after each message with fsync

3. UI additions:
   - Header box with Gtk.ComboBoxText for model selection
   - "New Chat" button to clear conversation
   - Populate ComboBox from get_models()
   - Update ollama_client.current_model on selection change

Maintain thread safety and error handling patterns from Phase 1.

Phase 3: Polish & Integration (Days 6-7)

Objective: UI improvements, Niri integration, keyboard shortcuts

Tasks:

  1. CSS Styling (styles.css):
window {
    background-color: #1e1e2e;
}

textview {
    background-color: #181825;
    color: #cdd6f4;
    font-family: monospace;
    font-size: 12pt;
}

entry {
    background-color: #313244;
    color: #cdd6f4;
    border-radius: 8px;
    padding: 8px;
}

button {
    background-color: #89b4fa;
    color: #1e1e2e;
    border-radius: 8px;
    padding: 8px 16px;
}

Load in application:

css_provider = Gtk.CssProvider()
css_provider.load_from_path('styles.css')
Gtk.StyleContext.add_provider_for_display(
    self.get_display(),
    css_provider,
    Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
)
  1. Keyboard Shortcuts:
def setup_keybindings(self):
    event_controller = Gtk.EventControllerKey()
    event_controller.connect('key-pressed', self.on_key_pressed)
    self.add_controller(event_controller)

def on_key_pressed(self, controller, keyval, keycode, state):
    # Escape to close
    if keyval == Gdk.KEY_Escape:
        self.close()
        return True
    
    # Ctrl+N for new chat
    if (state & Gdk.ModifierType.CONTROL_MASK and 
        keyval == Gdk.KEY_n):
        self.on_new_chat(None)
        return True
    
    return False
  1. Niri Toggle Script (toggle-sidebar.sh):
#!/bin/bash
PID=$(pgrep -f "python.*main.py")

if [ -z "$PID" ]; then
    # Start sidebar
    python /path/to/ai-sidebar/main.py &
else
    # Kill sidebar
    kill $PID
fi

Update Niri config:

binds {
    Mod+A { spawn "bash" "/path/to/toggle-sidebar.sh"; }
}

Testing Checklist

  • Sidebar appears at left edge with correct dimensions
  • Layer-shell positioning works (stays on top, doesn't block clicks outside)
  • Keyboard input works in Entry widget
  • Messages stream smoothly from Ollama
  • Model selector populates with installed models
  • Model switching changes active model
  • New chat clears conversation
  • Conversations persist across restarts
  • Threading doesn't freeze UI
  • Cancel works (if implemented)
  • No memory leaks during extended use
  • Compatible with Exo shell (no namespace conflicts)
  • CSS styling applies correctly
  • Escape key closes sidebar
  • Toggle script works from Niri keybind

Timeline Summary

Phase Duration Deliverable
Phase 1: POC 2 days Working sidebar with basic chat
Phase 2: Features 3 days Model selection, persistence, better UI
Phase 3: Polish 2 days Styling, keybinds, Niri integration
Total 7 days Production-ready sidebar

Realistic estimate: 10-14 days accounting for debugging and learning curve.


Key Success Factors

  1. Start simple: Phase 1 POC validates everything works before investing time
  2. Reference Alpaca: Study their threading patterns and UI implementations
  3. Test incrementally: Each feature works before moving to next
  4. Use AI tools effectively: Break prompts into discrete components
  5. Follow PyGObject patterns: Threading via GLib.idle_add() is critical

This plan avoids Ignis instability while achieving your goal with mature, well-documented technologies.1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22


  1. https://pygobject.gnome.org/guide/threading.html ↩︎

  2. https://github.com/wmww/gtk4-layer-shell ↩︎

  3. https://github.com/Jeffser/Alpaca ↩︎

  4. https://www.reddit.com/r/learnpython/comments/fa9612/pygtk_glade_threading/ ↩︎

  5. https://github.com/YaLTeR/niri/wiki/Configuration:-Introduction ↩︎

  6. https://github.com/ollama/ollama-python ↩︎

  7. https://pygtk.daa.com.narkive.com/QpH3Y5ky/idle-add-vs-threads-enter-threads-leave ↩︎

  8. https://github.com/YaLTeR/niri/discussions/674 ↩︎

  9. https://ollama.com/blog/streaming-tool ↩︎

  10. https://docs.gtk.org/glib/func.idle_add.html ↩︎

  11. https://yalter.github.io/niri/Configuration:-Window-Rules.html ↩︎

  12. https://www.cohorte.co/blog/using-ollama-with-python-step-by-step-guide ↩︎

  13. https://gnulinux.ch/ein-kleines-gtk4-programm-in-python ↩︎

  14. https://yalter.github.io/niri/Getting-Started.html ↩︎

  15. https://www.reddit.com/r/Python/comments/1ael05l/ollama_python_library_chat_method_system_message/ ↩︎

  16. https://git.yaroslavps.com/configs/swayrice/tree/dotfiles/.config/niri/config.kdl?id=dd00aee82134d4f1b41463c5371f1ee943a9ec7a ↩︎

  17. https://stackoverflow.com/questions/73665239/implementing-threading-in-a-python-gtk-application-pygobject-to-prevent-ui-fre ↩︎

  18. https://gitlab.gnome.org/GNOME/pygobject/-/blob/3.49.0/docs/guide/threading.rst ↩︎

  19. https://discourse.gnome.org/t/gtk-threading-problem-with-glib-idle-add/13597 ↩︎

  20. https://gist.github.com/bossjones/e21b53c6dff04e8fdb3d ↩︎

  21. https://dunkelstern.de/articles/2025-01-24/index.html ↩︎

  22. https://www.glukhov.org/post/2025/10/ollama-python-examples/ ↩︎