# 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**: ```bash # 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 ``` 2. **Create minimal sidebar** (`minimal_poc.py`): ```python #!/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) ``` 3. **Test basic functionality**: ```bash # Ensure Ollama is running with a model ollama pull llama3.2 # Run the sidebar python minimal_poc.py ``` 4. **Add Niri configuration** (`~/.config/niri/config.kdl`): ```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`): ```python 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() ``` 2. **Conversation Manager** (`conversation_manager.py`): ```python 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) ``` 3. **Model Selector Widget**: Add to sidebar window: ```python 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`): ```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: ```python 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 ) ``` 2. **Keyboard Shortcuts**: ```python 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 ``` 3. **Niri Toggle Script** (`toggle-sidebar.sh`): ```bash #!/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: ```kdl 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] [^10][^11][^12][^13][^14][^15][^16][^17][^18][^19][^20][^21][^22][^4][^5][^6][^7][^8][^9]
[^1]: https://pygobject.gnome.org/guide/threading.html [^2]: https://github.com/wmww/gtk4-layer-shell [^3]: https://github.com/Jeffser/Alpaca [^4]: https://stackoverflow.com/questions/73665239/implementing-threading-in-a-python-gtk-application-pygobject-to-prevent-ui-fre [^5]: https://gitlab.gnome.org/GNOME/pygobject/-/blob/3.49.0/docs/guide/threading.rst [^6]: https://discourse.gnome.org/t/gtk-threading-problem-with-glib-idle-add/13597 [^7]: https://gist.github.com/bossjones/e21b53c6dff04e8fdb3d [^8]: https://dunkelstern.de/articles/2025-01-24/index.html [^9]: https://www.glukhov.org/post/2025/10/ollama-python-examples/ [^10]: https://www.reddit.com/r/learnpython/comments/fa9612/pygtk_glade_threading/ [^11]: https://github.com/YaLTeR/niri/wiki/Configuration:-Introduction [^12]: https://github.com/ollama/ollama-python [^13]: https://pygtk.daa.com.narkive.com/QpH3Y5ky/idle-add-vs-threads-enter-threads-leave [^14]: https://github.com/YaLTeR/niri/discussions/674 [^15]: https://ollama.com/blog/streaming-tool [^16]: https://docs.gtk.org/glib/func.idle_add.html [^17]: https://yalter.github.io/niri/Configuration:-Window-Rules.html [^18]: https://www.cohorte.co/blog/using-ollama-with-python-step-by-step-guide [^19]: https://gnulinux.ch/ein-kleines-gtk4-programm-in-python [^20]: https://yalter.github.io/niri/Getting-Started.html [^21]: https://www.reddit.com/r/Python/comments/1ael05l/ollama_python_library_chat_method_system_message/ [^22]: https://git.yaroslavps.com/configs/swayrice/tree/dotfiles/.config/niri/config.kdl?id=dd00aee82134d4f1b41463c5371f1ee943a9ec7a