Files
niri-ai-sidebar/sidebar_window.py
Melvin Ragusa 1a80358ffc Fixes GTK4 Layer Shell and Ollama integration issues
Addresses multiple issues related to GTK4 Layer Shell initialization and Ollama integration.

- Reorders initialization to ensure the layer shell is set up before window properties.
- Adds error detection for layer shell initialization failures.
- Implements a focus event handler to prevent focus-out warnings.
- Introduces a launcher script to activate the virtual environment, force native Wayland, and preload the GTK4 Layer Shell library.
- Warns users of incorrect GDK_BACKEND settings.
- Updates the Ollama client to handle responses from both older and newer versions of the Ollama SDK.

These changes improve the application's stability, compatibility, and functionality on Wayland systems.
2025-10-25 21:23:32 +02:00

224 lines
8.3 KiB
Python

"""GTK sidebar window definitions."""
from __future__ import annotations
import threading
from typing import Iterable
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import GLib, Gtk # noqa: E402
try: # pragma: no cover - optional dependency may not be available in CI
gi.require_version("Gtk4LayerShell", "1.0")
from gi.repository import Gtk4LayerShell # type: ignore[attr-defined]
except (ImportError, ValueError): # pragma: no cover - fallback path
Gtk4LayerShell = None # type: ignore[misc]
from conversation_manager import ConversationManager
from ollama_client import OllamaClient
class SidebarWindow(Gtk.ApplicationWindow):
"""Layer-shell anchored window hosting the chat interface."""
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
# CRITICAL: Layer shell must be initialized BEFORE any window properties
self._setup_layer_shell()
self.set_default_size(360, 720)
self.set_title("Niri AI Sidebar")
self.set_hide_on_close(False)
self._conversation_manager = ConversationManager()
self._ollama_client = OllamaClient()
self._current_model = self._ollama_client.default_model
self._build_ui()
self._populate_initial_messages()
# ------------------------------------------------------------------ UI setup
def _setup_layer_shell(self) -> None:
"""Attach the window to the left edge via gtk4-layer-shell when available."""
if Gtk4LayerShell is None:
return
Gtk4LayerShell.init_for_window(self)
# Verify initialization succeeded before configuring layer shell properties
if not Gtk4LayerShell.is_layer_window(self):
return
Gtk4LayerShell.set_namespace(self, "niri-ai-sidebar")
Gtk4LayerShell.set_layer(self, Gtk4LayerShell.Layer.TOP)
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_anchor(self, Gtk4LayerShell.Edge.RIGHT, False)
Gtk4LayerShell.set_margin(self, Gtk4LayerShell.Edge.LEFT, 0)
Gtk4LayerShell.set_keyboard_mode(
self, Gtk4LayerShell.KeyboardMode.ON_DEMAND
)
Gtk4LayerShell.set_exclusive_zone(self, -1)
def _build_ui(self) -> None:
"""Create the core layout: message history and input entry."""
main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
main_box.set_margin_top(16)
main_box.set_margin_bottom(16)
main_box.set_margin_start(16)
main_box.set_margin_end(16)
main_box.set_hexpand(True)
main_box.set_vexpand(True)
header_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
header_title = Gtk.Label(label="Niri AI Sidebar")
header_title.set_halign(Gtk.Align.START)
header_title.get_style_context().add_class("title-2")
model_name = self._current_model or "No local model detected"
self._model_label = Gtk.Label(label=f"Model: {model_name}")
self._model_label.set_halign(Gtk.Align.START)
self._model_label.get_style_context().add_class("dim-label")
header_box.append(header_title)
header_box.append(self._model_label)
self._message_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
self._message_list.set_hexpand(True)
self._message_list.set_vexpand(True)
self._message_list.set_valign(Gtk.Align.START)
scroller = Gtk.ScrolledWindow()
scroller.set_hexpand(True)
scroller.set_vexpand(True)
scroller.set_child(self._message_list)
scroller.set_min_content_height(300)
self._scroller = scroller
input_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
input_box.set_hexpand(True)
self._entry = Gtk.Entry()
self._entry.set_hexpand(True)
self._entry.set_placeholder_text("Ask a question…")
self._entry.connect("activate", self._on_submit)
# Add focus event controller to properly handle focus-out events
# The handler must return False to propagate the event to GTK's default handler
focus_controller = Gtk.EventControllerFocus()
focus_controller.connect("leave", lambda c: False)
self._entry.add_controller(focus_controller)
self._send_button = Gtk.Button(label="Send")
self._send_button.connect("clicked", self._on_submit)
input_box.append(self._entry)
input_box.append(self._send_button)
main_box.append(header_box)
main_box.append(scroller)
main_box.append(input_box)
self.set_child(main_box)
def _populate_initial_messages(self) -> None:
"""Render conversation history stored on disk."""
for message in self._conversation_manager.messages:
self._append_message(message["role"], message["content"], persist=False)
if not self._conversation_manager.messages:
self._append_message(
"assistant",
"Welcome! Ask a question to start a conversation.",
persist=True,
)
# ------------------------------------------------------------------ helpers
def _append_message(
self, role: str, content: str, *, persist: bool = True
) -> None:
"""Add a message bubble to the history and optionally persist it."""
label_prefix = "You" if role == "user" else "Assistant"
label = Gtk.Label(label=f"{label_prefix}: {content}")
label.set_halign(Gtk.Align.START)
label.set_xalign(0.0)
label.set_wrap(True)
label.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
label.set_justify(Gtk.Justification.LEFT)
self._message_list.append(label)
self._scroll_to_bottom()
if persist:
self._conversation_manager.append_message(role, content)
def _scroll_to_bottom(self) -> None:
"""Ensure the most recent message is visible."""
def _scroll() -> bool:
adjustment = self._scroller.get_vadjustment()
if adjustment is not None:
adjustment.set_value(adjustment.get_upper() - adjustment.get_page_size())
return False
GLib.idle_add(_scroll)
def _set_input_enabled(self, enabled: bool) -> None:
self._entry.set_sensitive(enabled)
self._send_button.set_sensitive(enabled)
# ------------------------------------------------------------------ callbacks
def _on_submit(self, _widget: Gtk.Widget) -> None:
"""Handle send button clicks or entry activation."""
text = self._entry.get_text().strip()
if not text:
return
self._entry.set_text("")
self._append_message("user", text, persist=True)
self._request_response()
def _request_response(self) -> None:
"""Trigger a synchronous Ollama chat call on a worker thread."""
model = self._current_model or self._ollama_client.default_model
if not model:
self._append_message(
"assistant",
"No Ollama models are available. Install a model to continue.",
persist=True,
)
return
history = self._conversation_manager.chat_messages
self._set_input_enabled(False)
def _worker(messages: Iterable[dict[str, str]]) -> None:
response = self._ollama_client.chat(model=model, messages=list(messages))
GLib.idle_add(self._handle_response, response, priority=GLib.PRIORITY_DEFAULT)
thread = threading.Thread(target=_worker, args=(history,), daemon=True)
thread.start()
def _handle_response(self, response: dict[str, str] | None) -> bool:
"""Render the assistant reply and re-enable the entry."""
self._set_input_enabled(True)
if not response:
self._append_message(
"assistant",
"The model returned an empty response.",
persist=True,
)
return False
role = response.get("role", "assistant")
content = response.get("content") or ""
if not content:
content = "[No content received from Ollama]"
self._append_message(role, content, persist=True)
return False