"""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) 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._setup_layer_shell() 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) 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) 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