From 6ecd5106e33edb39680ac5c93637e7ac3105fbaa Mon Sep 17 00:00:00 2001 From: eshork <1829176+eshork@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:38:59 -0400 Subject: [PATCH 01/32] Fix TUI bugs and UX issues from real hardware testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses 27 user-reported issues from live testing on an RTX 3080 system booting from USB. All changes deployed and verified on hardware. Crash handling: - Override App._handle_exception() to capture Textual runtime crashes - Write crash dumps to persistent disk (/var/lib/neuraldrive/logs/) - Screenshots routed to persistent disk via TEXTUAL_SCREENSHOT_LOCATION - Outer try/except in __main__ catches startup crashes Chat screen: - Fix TypeError from RichLog.write(end='') — removed invalid param - Move streaming response to @work(exclusive=True) to unblock UI - Add on_screen_resume to refresh model list on every screen visit - Add model selector (Select widget) on dedicated row with amber border Models screen: - Rewrite catalog with two-zone keyboard navigation (list + buttons) - Arrow keys navigate, Enter/Space toggle, PgUp/PgDn page jump - Add download cancel button with worker cancellation - Handle asyncio.CancelledError in _start_pull - Add model load/unload via Ollama generate API (keep_alive) - Show both Load and Unload buttons per model (disable irrelevant one) - Fix ModelItem._size/_name collision with Textual Widget internals Services screen: - Fix DuplicateIds crash: await remove_children() before mounting - Use sudo systemctl for service start/stop/restart - Arrow-key service selection with yellow highlight - Use Binding() objects for show/priority params (not 4-element tuples) Dashboard: - Expand GPU StatsBox to show Device, VRAM, Temp, Utilization - Rename 'Loaded Models' to 'Active Models (VRAM)' Wizard: - Rewrite _create_persistence_partition(): fix parted start position, detect actual free space, immediate mount, correct Ollama dirs, proper ownership, restart Ollama after partition creation - Add YAML config persistence (persistent disk with overlay fallback) Navigation: - Replace single-letter hotkeys with F2-F6 function keys (priority=True) - Remove old silent hotkeys entirely - Disable command palette via ENABLE_COMMAND_PALETTE=False (COMMAND_PALETTE_BINDING=None crashes Textual 8.2.4) Security: - Add scoped NOPASSWD sudoers (/etc/sudoers.d/neuraldrive-tui) that survives wizard _finalize() stripping NOPASSWD from neuraldrive-admin - Covers systemctl, parted, mkfs, mount, chpasswd, and file ops New files: - utils/config.py: YAML config read/write with persistent/overlay fallback - utils/hardware.py: Boot device detection, partition enumeration - etc/sudoers.d/neuraldrive-tui: Scoped NOPASSWD rules for TUI ops - dev-reset.sh: Development reset script (password, NOPASSWD, sentinel) Build: - Add pyyaml to TUI venv dependencies - Set neuraldrive-tui sudoers permissions in build hook --- config/hooks/live/01-setup-system.chroot | 6 + .../hooks/live/04-install-python-apps.chroot | 2 +- .../etc/sudoers.d/neuraldrive-tui | 30 ++ .../usr/lib/neuraldrive/dev-reset.sh | 38 ++ .../usr/lib/neuraldrive/tui/main.py | 93 +++- .../usr/lib/neuraldrive/tui/screens/chat.py | 75 ++- .../lib/neuraldrive/tui/screens/dashboard.py | 20 +- .../usr/lib/neuraldrive/tui/screens/models.py | 370 ++++++++++++- .../lib/neuraldrive/tui/screens/services.py | 117 +++-- .../usr/lib/neuraldrive/tui/screens/wizard.py | 490 +++++++++++++++--- .../usr/lib/neuraldrive/tui/styles.tcss | 182 ++++++- .../lib/neuraldrive/tui/utils/api_client.py | 24 + .../usr/lib/neuraldrive/tui/utils/config.py | 84 +++ .../usr/lib/neuraldrive/tui/utils/hardware.py | 92 ++++ .../lib/neuraldrive/tui/widgets/model_item.py | 27 +- 15 files changed, 1476 insertions(+), 174 deletions(-) create mode 100644 config/includes.chroot/etc/sudoers.d/neuraldrive-tui create mode 100755 config/includes.chroot/usr/lib/neuraldrive/dev-reset.sh create mode 100644 config/includes.chroot/usr/lib/neuraldrive/tui/utils/config.py diff --git a/config/hooks/live/01-setup-system.chroot b/config/hooks/live/01-setup-system.chroot index 8f1a8aa..b278e7a 100755 --- a/config/hooks/live/01-setup-system.chroot +++ b/config/hooks/live/01-setup-system.chroot @@ -15,6 +15,12 @@ echo "neuraldrive-admin:neuraldrive" | chpasswd echo "neuraldrive-admin ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/neuraldrive-admin chmod 440 /etc/sudoers.d/neuraldrive-admin +# Scoped NOPASSWD rules for TUI — survives wizard _finalize() which only +# modifies neuraldrive-admin. File is baked in via includes.chroot; just +# ensure correct ownership and permissions here. +chmod 440 /etc/sudoers.d/neuraldrive-tui +chown root:root /etc/sudoers.d/neuraldrive-tui + mkdir -p /etc/neuraldrive/tls \ /var/lib/neuraldrive/models/{manifests,blobs} \ /var/lib/neuraldrive/ollama \ diff --git a/config/hooks/live/04-install-python-apps.chroot b/config/hooks/live/04-install-python-apps.chroot index 6efc76f..80605e4 100755 --- a/config/hooks/live/04-install-python-apps.chroot +++ b/config/hooks/live/04-install-python-apps.chroot @@ -118,7 +118,7 @@ git clone --depth 1 https://github.com/psalias2006/gpu-hot.git /usr/lib/neuraldr # --- TUI (terminal interface) --- python3 -m venv /usr/lib/neuraldrive/tui/venv /usr/lib/neuraldrive/tui/venv/bin/pip install --no-cache-dir --upgrade pip -/usr/lib/neuraldrive/tui/venv/bin/pip install --no-cache-dir textual psutil httpx rich +/usr/lib/neuraldrive/tui/venv/bin/pip install --no-cache-dir textual psutil httpx rich pyyaml cat > /usr/local/bin/neuraldrive-tui << 'LAUNCHER' #!/bin/sh diff --git a/config/includes.chroot/etc/sudoers.d/neuraldrive-tui b/config/includes.chroot/etc/sudoers.d/neuraldrive-tui new file mode 100644 index 0000000..cc2fbf7 --- /dev/null +++ b/config/includes.chroot/etc/sudoers.d/neuraldrive-tui @@ -0,0 +1,30 @@ +# Scoped NOPASSWD rules for NeuralDrive TUI operations. +# This file is NOT modified by the first-boot wizard's _finalize() +# (which only touches /etc/sudoers.d/neuraldrive-admin). +# Processed AFTER neuraldrive-admin (alphabetical), so these NOPASSWD +# rules override the password-required ALL rule for matched commands. + +# Service management +neuraldrive-admin ALL=(ALL) NOPASSWD: /usr/bin/systemctl start neuraldrive-* +neuraldrive-admin ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop neuraldrive-* +neuraldrive-admin ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart neuraldrive-* +neuraldrive-admin ALL=(ALL) NOPASSWD: /usr/bin/systemctl is-active neuraldrive-* + +# Partition creation and storage management +neuraldrive-admin ALL=(ALL) NOPASSWD: /usr/sbin/parted * +neuraldrive-admin ALL=(ALL) NOPASSWD: /usr/sbin/mkfs.ext4 * +neuraldrive-admin ALL=(ALL) NOPASSWD: /usr/bin/mount * +neuraldrive-admin ALL=(ALL) NOPASSWD: /usr/bin/umount * +neuraldrive-admin ALL=(ALL) NOPASSWD: /usr/sbin/partprobe * + +# File operations (wizard config writing, directory setup) +neuraldrive-admin ALL=(ALL) NOPASSWD: /usr/bin/tee * +neuraldrive-admin ALL=(ALL) NOPASSWD: /usr/bin/mkdir * +neuraldrive-admin ALL=(ALL) NOPASSWD: /usr/bin/chmod * +neuraldrive-admin ALL=(ALL) NOPASSWD: /usr/bin/chown * + +# Password management (wizard security step) +neuraldrive-admin ALL=(ALL) NOPASSWD: /usr/sbin/chpasswd + +# Sudoers self-read (wizard _finalize reads neuraldrive-admin to strip NOPASSWD) +neuraldrive-admin ALL=(ALL) NOPASSWD: /usr/bin/cat /etc/sudoers.d/neuraldrive-admin diff --git a/config/includes.chroot/usr/lib/neuraldrive/dev-reset.sh b/config/includes.chroot/usr/lib/neuraldrive/dev-reset.sh new file mode 100755 index 0000000..0b1bd96 --- /dev/null +++ b/config/includes.chroot/usr/lib/neuraldrive/dev-reset.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# /usr/lib/neuraldrive/dev-reset.sh +# Development reset script — restores a post-wizard system to a +# development-friendly state. Included in builds for convenience. +# +# Usage: sudo /usr/lib/neuraldrive/dev-reset.sh + +set -e + +echo "=== NeuralDrive Development Reset ===" +echo "" + +# 1. Reset admin password to the build default +echo "neuraldrive-admin:neuraldrive" | chpasswd +echo "[ok] Admin password reset to 'neuraldrive'" + +# 2. Restore blanket NOPASSWD for development +echo "neuraldrive-admin ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/neuraldrive-admin +chmod 440 /etc/sudoers.d/neuraldrive-admin +echo "[ok] Blanket NOPASSWD sudo restored" + +# 3. Remove wizard sentinel so it runs again on next TUI start +rm -f /etc/neuraldrive/first-boot-complete +echo "[ok] Wizard sentinel removed" + +# 4. Clear config files so wizard starts fresh +rm -f /var/lib/neuraldrive/config/config.yaml +rm -f /etc/neuraldrive/config.yaml +echo "[ok] Config files cleared" + +# 5. Clear generated credentials +rm -f /etc/neuraldrive/api.key +rm -f /etc/neuraldrive/credentials.conf +echo "[ok] API key and credentials cleared" + +echo "" +echo "Development reset complete." +echo "Restart the TUI to re-run the first-boot wizard." diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/main.py b/config/includes.chroot/usr/lib/neuraldrive/tui/main.py index 4eac789..663595a 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/main.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/main.py @@ -10,20 +10,73 @@ from screens.wizard import FirstBootWizard import os +import sys +import traceback +from datetime import datetime + +from utils import config + +PERSIST_DIR = "/var/lib/neuraldrive" +OVERLAY_LOG_DIR = "/var/log/neuraldrive" + + +def _persistent_available() -> bool: + return os.path.ismount(PERSIST_DIR) + + +def _log_dir() -> str: + if _persistent_available(): + p = os.path.join(PERSIST_DIR, "logs") + try: + os.makedirs(p, exist_ok=True) + return p + except PermissionError: + pass + os.makedirs(OVERLAY_LOG_DIR, exist_ok=True) + return OVERLAY_LOG_DIR + + +def _screenshot_dir() -> str: + if _persistent_available(): + p = os.path.join(PERSIST_DIR, "screenshots") + try: + os.makedirs(p, exist_ok=True) + return p + except PermissionError: + pass + os.makedirs(OVERLAY_LOG_DIR, exist_ok=True) + return OVERLAY_LOG_DIR + + +def _write_crash_dump(error: BaseException) -> str | None: + try: + crash_dir = _log_dir() + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + dump_path = os.path.join(crash_dir, f"tui-crash-{ts}.log") + with open(dump_path, "w") as f: + f.write(f"NeuralDrive TUI crash at {ts}\n") + f.write(f"Python: {sys.version}\n") + f.write(f"Args: {sys.argv}\n\n") + traceback.print_exception(type(error), error, error.__traceback__, file=f) + return dump_path + except Exception: + return None class NeuralDriveTUI(App): CSS_PATH = "styles.tcss" TITLE = "NeuralDrive" + ENABLE_COMMAND_PALETTE = False BINDINGS = [ - Binding("m", "switch_screen('models')", "Models"), - Binding("s", "switch_screen('services')", "Services"), - Binding("n", "switch_screen('network')", "Network"), - Binding("l", "switch_screen('logs')", "Logs"), - Binding("c", "switch_screen('chat')", "Chat"), - Binding("d", "switch_screen('dashboard')", "Dashboard"), + Binding("f2", "switch_screen('dashboard')", "F2 Dash", priority=True), + Binding("f3", "switch_screen('models')", "F3 Models", priority=True), + Binding("f4", "switch_screen('services')", "F4 Svc", priority=True), + Binding("f5", "switch_screen('chat')", "F5 Chat", priority=True), + Binding("f6", "switch_screen('logs')", "F6 Logs", priority=True), Binding("q", "quit", "Quit"), + Binding("up", "focus_previous", "Previous", show=False), + Binding("down", "focus_next", "Next", show=False), ] SCREENS = { @@ -37,14 +90,36 @@ class NeuralDriveTUI(App): def on_mount(self) -> None: self.push_screen(DashboardScreen()) - if not os.path.exists("/etc/neuraldrive/first-boot-complete"): + sentinel_exists = os.path.exists("/etc/neuraldrive/first-boot-complete") + if not sentinel_exists and not config.wizard_complete(): self.push_screen(FirstBootWizard()) + def _handle_exception(self, error: Exception) -> None: + dump_path = _write_crash_dump(error) + if dump_path: + self.log(f"Crash dump saved to {dump_path}") + super()._handle_exception(error) + + def action_focus_next(self) -> None: + self.screen.focus_next() + + def action_focus_previous(self) -> None: + self.screen.focus_previous() + def action_switch_screen(self, screen_name: str) -> None: if screen_name in self.SCREENS: self.switch_screen(screen_name) if __name__ == "__main__": - app = NeuralDriveTUI() - app.run(mouse=False) + screenshot_dir = _screenshot_dir() + os.environ["TEXTUAL_SCREENSHOT_LOCATION"] = screenshot_dir + try: + app = NeuralDriveTUI() + app.run(mouse=False) + except Exception as exc: + dump_path = _write_crash_dump(exc) + traceback.print_exc() + if dump_path: + print(f"\nCrash dump saved to {dump_path}") + sys.exit(1) diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/chat.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/chat.py index d5716f4..6f4c88c 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/chat.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/chat.py @@ -2,6 +2,7 @@ import json +from textual import work from textual.app import ComposeResult from textual.containers import Horizontal from textual.screen import Screen @@ -19,9 +20,9 @@ def __init__(self) -> None: def compose(self) -> ComposeResult: yield Header() - with Horizontal(): - yield Static(" Model: ", classes="label") - yield Select([], id="chat-model-select") + yield Static(" Model", classes="heading") + yield Select([], id="chat-model-select", prompt="Choose a model…") + yield Static("", id="chat-notice") yield RichLog(highlight=True, markup=False, id="chat-log") with Horizontal(id="chat-input-row"): yield Input(placeholder="Type a message…", id="chat-input") @@ -31,23 +32,52 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: self.app.call_later(self._load_model_options) + def on_screen_resume(self) -> None: + self.app.call_later(self._load_model_options) + async def _load_model_options(self) -> None: - models = await api_client.list_models() + notice = self.query_one("#chat-notice", Static) select = self.query_one("#chat-model-select", Select) + send_btn = self.query_one("#chat-send", Button) + chat_input = self.query_one("#chat-input", Input) + + available = await api_client.ollama_available() + if not available: + notice.update(" Ollama is not running. Start it from the Services screen.") + notice.add_class("error") + send_btn.disabled = True + chat_input.disabled = True + return + + models = await api_client.list_models() options = [(m.get("name", "?"), m.get("name", "?")) for m in models] select.set_options(options) - if options: + + if not options: + notice.update( + " No models installed. Pull a model from the Models screen (press M)." + ) + notice.add_class("warn") + send_btn.disabled = True + chat_input.disabled = True + return + + notice.update("") + notice.remove_class("error", "warn") + send_btn.disabled = False + chat_input.disabled = False + if select.value is Select.BLANK: select.value = options[0][1] async def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "chat-send": - await self._send_message() + self._do_send() async def on_input_submitted(self, event: Input.Submitted) -> None: if event.input.id == "chat-input": - await self._send_message() + self._do_send() - async def _send_message(self) -> None: + def _do_send(self) -> None: input_widget = self.query_one("#chat-input", Input) text = input_widget.value.strip() if not text: @@ -56,6 +86,8 @@ async def _send_message(self) -> None: select = self.query_one("#chat-model-select", Select) model = str(select.value) if select.value is not Select.BLANK else "" if not model: + log = self.query_one("#chat-log", RichLog) + log.write("[error] No model selected. Choose a model from the dropdown.") return log = self.query_one("#chat-log", RichLog) @@ -63,7 +95,17 @@ async def _send_message(self) -> None: input_widget.value = "" self._messages.append({"role": "user", "content": text}) - log.write(f"\n[{model}] ", end="") + self._stream_response(model) + + @work(exclusive=True) + async def _stream_response(self, model: str) -> None: + log = self.query_one("#chat-log", RichLog) + send_btn = self.query_one("#chat-send", Button) + chat_input = self.query_one("#chat-input", Input) + + send_btn.disabled = True + chat_input.disabled = True + log.write(f"[{model}] ...") assistant_text = "" try: @@ -73,11 +115,22 @@ async def _send_message(self) -> None: chunk = data.get("message", {}).get("content", "") if chunk: assistant_text += chunk - log.write(chunk, end="") except json.JSONDecodeError: pass - log.write("") + if assistant_text: + log.clear() + for msg in self._messages: + role = "You" if msg["role"] == "user" else model + log.write(f"[{role}] {msg['content']}") + log.write(f"[{model}] {assistant_text}") self._messages.append({"role": "assistant", "content": assistant_text}) + else: + log.write(f"[{model}] (no response)") except Exception as exc: log.write(f"\n[error] {exc}") + if self._messages and self._messages[-1]["role"] == "user": + self._messages.pop() + finally: + send_btn.disabled = False + chat_input.disabled = False diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/dashboard.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/dashboard.py index fcf0a5c..0f7c32e 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/dashboard.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/dashboard.py @@ -20,8 +20,12 @@ def compose(self) -> ComposeResult: yield StatsBox("CPU", [("Usage", "…")], id="box-cpu") yield StatsBox("Memory", [("Used", "…"), ("Total", "…")], id="box-mem") yield StatsBox("Disk", [("Used", "…"), ("Free", "…")], id="box-disk") - yield StatsBox("GPU", [("Vendor", "…")], id="box-gpu") - yield Static("Loaded Models", classes="heading") + yield StatsBox( + "GPU", + [("Device", "…"), ("VRAM", "…"), ("Temp", "…"), ("Util", "…")], + id="box-gpu", + ) + yield Static("Active Models (VRAM)", classes="heading") yield Vertical(id="loaded-models") yield Static("Services", classes="heading") yield Vertical(id="service-badges") @@ -56,12 +60,16 @@ def _refresh_system(self) -> None: gpu = hardware.get_gpu_info() box_gpu = self.query_one("#box-gpu", StatsBox) - box_gpu.update_row("Vendor", gpu["vendor"]) if gpu["devices"]: dev = gpu["devices"][0] - box_gpu.update_row( - "Vendor", f"{dev['name']} {dev['temp_c']}°C {dev['util_percent']}%" - ) + box_gpu.update_row("Device", dev["name"]) + vram_total = dev["vram_total_mb"] + vram_used = dev["vram_used_mb"] + box_gpu.update_row("VRAM", f"{vram_used} / {vram_total} MB") + box_gpu.update_row("Temp", f"{dev['temp_c']}\u00b0C") + box_gpu.update_row("Util", f"{dev['util_percent']}%") + else: + box_gpu.update_row("Device", gpu["vendor"]) container = self.query_one("#service-badges", Vertical) container.remove_children() diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py index df73348..195bef3 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py @@ -1,15 +1,236 @@ from __future__ import annotations +import asyncio import json +from textual import work from textual.app import ComposeResult -from textual.containers import Vertical, VerticalScroll +from textual.containers import Horizontal, Vertical, VerticalScroll from textual.screen import Screen -from textual.widgets import Button, Footer, Header, Input, Static +from textual.widgets import Button, Footer, Header, Input, ProgressBar, Static + +from textual.binding import Binding from utils import api_client from widgets.model_item import ModelItem +CURATED_MODELS = [ + ( + "CPU / ≤4 GB VRAM", + [ + ("qwen2.5:3b", "1.9 GB", "Fast general-purpose"), + ("phi3:mini", "2.3 GB", "Microsoft reasoning model"), + ("gemma2:2b", "1.6 GB", "Google lightweight"), + ], + ), + ( + "6 GB VRAM", + [ + ("llama3.2:3b", "2.0 GB", "Meta compact model"), + ("mistral:7b", "4.1 GB", "Mistral AI flagship"), + ("qwen2.5:7b", "4.7 GB", "Strong multilingual"), + ], + ), + ( + "8 GB VRAM", + [ + ("llama3.1:8b", "4.7 GB", "Meta general-purpose"), + ("gemma2:9b", "5.4 GB", "Google mid-range"), + ("deepseek-coder-v2:lite", "5.0 GB", "Code-focused"), + ], + ), + ( + "12 GB VRAM", + [ + ("codestral:latest", "12 GB", "Mistral code generation"), + ("llama3.1:8b-instruct-q8_0", "8.5 GB", "High-quality quantization"), + ("qwen2.5:14b", "9.0 GB", "Strong reasoning"), + ], + ), + ( + "24 GB+ VRAM", + [ + ("llama3.1:70b", "40 GB", "Meta flagship (Q4)"), + ("qwen2.5:32b", "20 GB", "Top-tier multilingual"), + ("deepseek-coder-v2:16b", "8.9 GB", "Full code model"), + ], + ), +] + + +class ModelCatalog(Screen): + BINDINGS = [ + ("escape", "cancel", "Back"), + Binding("up", "nav_up", show=False, priority=True), + Binding("down", "nav_down", show=False, priority=True), + Binding("pageup", "page_up", show=False, priority=True), + Binding("pagedown", "page_down", show=False, priority=True), + Binding("enter", "activate", show=False, priority=True), + Binding("space", "activate", show=False, priority=True), + Binding("tab", "next_zone", show=False, priority=True), + Binding("shift+tab", "prev_zone", show=False, priority=True), + ] + + def __init__(self, installed_names: set[str]) -> None: + super().__init__() + self._installed = installed_names + self._selected: set[str] = set() + self._catalog_buttons: list[Button] = [] + self._highlight_index = 0 + self._zone = "list" + + def compose(self) -> ComposeResult: + yield Header() + yield Static( + " ↑↓ Navigate Enter Select Tab Actions Esc Back", classes="muted" + ) + with VerticalScroll(id="catalog-scroll"): + for tier_label, models in CURATED_MODELS: + yield Static(f" {tier_label}", classes="tier-heading") + for model_name, size, desc in models: + installed = any( + model_name == n or model_name == n.split(":")[0] + for n in self._installed + ) + if installed: + label = f" ✓ {model_name} ({size}) — {desc} [installed]" + btn = Button( + label, + id=f"cat-{model_name.replace(':', '--').replace('.', '-')}", + classes="catalog-item catalog-installed", + disabled=True, + ) + else: + label = f" ○ {model_name} ({size}) — {desc}" + btn = Button( + label, + id=f"cat-{model_name.replace(':', '--').replace('.', '-')}", + classes="catalog-item", + ) + btn.tooltip = model_name + btn.can_focus = False + yield btn + with Horizontal(id="catalog-buttons"): + yield Button("Download Selected", id="download-selected", variant="primary") + yield Button("Cancel", id="catalog-cancel") + yield Footer() + + def on_mount(self) -> None: + self._catalog_buttons = list(self.query("Button.catalog-item")) + self._zone = "list" + self._highlight_index = 0 + self.set_focus(None) + if self._catalog_buttons: + self._apply_highlight() + + def _apply_highlight(self) -> None: + for i, btn in enumerate(self._catalog_buttons): + if i == self._highlight_index: + btn.add_class("catalog-highlighted") + btn.scroll_visible() + else: + btn.remove_class("catalog-highlighted") + + def _clear_highlight(self) -> None: + for btn in self._catalog_buttons: + btn.remove_class("catalog-highlighted") + + def _toggle_highlighted(self) -> None: + if not self._catalog_buttons: + return + btn = self._catalog_buttons[self._highlight_index] + if btn.disabled: + return + model_name = btn.tooltip or "" + if not model_name: + return + if model_name in self._selected: + self._selected.discard(model_name) + btn.label = str(btn.label).replace(" ✓ ", " ○ ") + btn.remove_class("catalog-checked") + else: + self._selected.add(model_name) + btn.label = str(btn.label).replace(" ○ ", " ✓ ") + btn.add_class("catalog-checked") + + def action_nav_up(self) -> None: + if self._zone == "buttons": + self._zone = "list" + self.set_focus(None) + self._apply_highlight() + return + if self._catalog_buttons and self._highlight_index > 0: + self._highlight_index -= 1 + self._apply_highlight() + + def action_nav_down(self) -> None: + if self._zone == "list" and self._catalog_buttons: + if self._highlight_index < len(self._catalog_buttons) - 1: + self._highlight_index += 1 + self._apply_highlight() + + def action_page_up(self) -> None: + if self._zone == "buttons": + self._zone = "list" + self.set_focus(None) + self._apply_highlight() + return + if not self._catalog_buttons: + return + scroll = self.query_one("#catalog-scroll", VerticalScroll) + page_size = max(1, scroll.size.height // 3) + self._highlight_index = max(0, self._highlight_index - page_size) + self._apply_highlight() + + def action_page_down(self) -> None: + if not self._catalog_buttons: + return + if self._zone == "buttons": + return + scroll = self.query_one("#catalog-scroll", VerticalScroll) + page_size = max(1, scroll.size.height // 3) + last = len(self._catalog_buttons) - 1 + self._highlight_index = min(last, self._highlight_index + page_size) + self._apply_highlight() + + def action_activate(self) -> None: + if self._zone == "list": + self._toggle_highlighted() + else: + focused = self.focused + if focused and focused.id == "download-selected": + self.dismiss(list(self._selected)) + elif focused and focused.id == "catalog-cancel": + self.dismiss([]) + + def action_next_zone(self) -> None: + if self._zone == "list": + self._zone = "buttons" + self._clear_highlight() + self.query_one("#download-selected", Button).focus() + else: + focused = self.focused + if focused and focused.id == "download-selected": + self.query_one("#catalog-cancel", Button).focus() + else: + self.query_one("#download-selected", Button).focus() + + def action_prev_zone(self) -> None: + if self._zone == "buttons": + self._zone = "list" + self.set_focus(None) + self._apply_highlight() + + def action_cancel(self) -> None: + self.dismiss([]) + + def on_button_pressed(self, event: Button.Pressed) -> None: + btn_id = event.button.id or "" + if btn_id == "download-selected": + self.dismiss(list(self._selected)) + elif btn_id == "catalog-cancel": + self.dismiss([]) + class ModelsScreen(Screen): BINDINGS = [("r", "refresh", "Refresh")] @@ -19,13 +240,28 @@ def compose(self) -> ComposeResult: with VerticalScroll(): yield Static("Installed Models", classes="heading") yield Vertical(id="model-list") - yield Static("", id="model-status") - yield Static("Pull Model", classes="heading") + yield Static("", classes="heading") + yield Button( + "Browse Available Models", + id="open-catalog", + variant="primary", + classes="primary", + ) + yield Static("", classes="heading") + yield Static("Pull by Name", classes="heading") yield Input(placeholder="e.g. llama3:8b", id="pull-input") - yield Button("Pull", variant="primary", id="pull-btn", classes="primary") + yield Button("Pull", id="pull-btn") + yield Static("", id="model-status") + with Horizontal(id="pull-row"): + yield ProgressBar(total=100, show_eta=True, id="pull-progress") + yield Button("Cancel", id="cancel-pull", variant="error") yield Footer() def on_mount(self) -> None: + self.query_one("#pull-progress", ProgressBar).display = False + self.query_one("#cancel-pull", Button).display = False + self._pull_queue: list[str] = [] + self._pulling = False self.action_refresh() def action_refresh(self) -> None: @@ -41,27 +277,73 @@ async def _load_models(self) -> None: if not all_models: container.mount(Static(" No models installed", classes="muted")) - return - - for m in all_models: - name = m.get("name", "unknown") - size_bytes = m.get("size", 0) - size_str = f"{size_bytes / (1024**3):.1f} GB" if size_bytes else "—" - loaded = name in running_names - container.mount(ModelItem(name, size_str, loaded)) + else: + for m in all_models: + name = m.get("name", "unknown") + size_bytes = m.get("size", 0) + size_str = f"{size_bytes / (1024**3):.1f} GB" if size_bytes else "—" + loaded = name in running_names + container.mount(ModelItem(name, size_str, loaded)) async def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "pull-btn": - await self._pull_model() + btn = event.button + btn_id = btn.id or "" + if btn_id == "pull-btn": + name = self.query_one("#pull-input", Input).value.strip() + if name: + self._start_pull(name) + elif btn_id == "open-catalog": + installed = {m.get("name", "") for m in await api_client.list_models()} + self.app.push_screen(ModelCatalog(installed), self._on_catalog_result) + elif btn_id == "cancel-pull": + self._cancel_pull() + elif btn.has_class("model-load"): + self._load_to_vram(btn.name or "") + elif btn.has_class("model-unload"): + self._unload_from_vram(btn.name or "") - async def _pull_model(self) -> None: - name_input = self.query_one("#pull-input", Input) - model_name = name_input.value.strip() - if not model_name: + def _cancel_pull(self) -> None: + self._pull_queue.clear() + self.workers.cancel_group(self, "default") + self._pulling = False + status = self.query_one("#model-status", Static) + status.update(" Download cancelled") + self.query_one("#pull-progress", ProgressBar).display = False + self.query_one("#cancel-pull", Button).display = False + self.query_one("#pull-btn", Button).disabled = False + self.query_one("#open-catalog", Button).disabled = False + + def _on_catalog_result(self, selected: list[str]) -> None: + if not selected: + return + self._pull_queue = list(selected) + self._pull_next() + + def _pull_next(self) -> None: + if not self._pull_queue: + self.app.call_later(self._load_models) return + model_name = self._pull_queue.pop(0) + self._start_pull(model_name) + @work(exclusive=True) + async def _start_pull(self, model_name: str) -> None: status = self.query_one("#model-status", Static) - status.update(f"Pulling {model_name}...") + progress = self.query_one("#pull-progress", ProgressBar) + cancel_btn = self.query_one("#cancel-pull", Button) + pull_btn = self.query_one("#pull-btn", Button) + catalog_btn = self.query_one("#open-catalog", Button) + + pull_btn.disabled = True + catalog_btn.disabled = True + progress.display = True + cancel_btn.display = True + self._pulling = True + progress.update(total=100, progress=0) + + remaining = len(self._pull_queue) + queue_msg = f" (+{remaining} queued)" if remaining else "" + status.update(f"Pulling {model_name}...{queue_msg}") try: async for line in api_client.pull_model(model_name): @@ -72,13 +354,53 @@ async def _pull_model(self) -> None: completed = data.get("completed", 0) if total: pct = int(completed / total * 100) - status.update(f"{msg} {pct}%") + progress.update(total=100, progress=pct) + size_mb = total / (1024 * 1024) + done_mb = completed / (1024 * 1024) + status.update( + f"{msg} {done_mb:.0f}/{size_mb:.0f} MB ({pct}%){queue_msg}" + ) else: - status.update(msg) + status.update(f"{msg}{queue_msg}") except json.JSONDecodeError: pass status.update(f"✓ {model_name} pulled successfully") - name_input.value = "" - await self._load_models() + self.query_one("#pull-input", Input).value = "" + except asyncio.CancelledError: + status.update(f" Download of {model_name} cancelled") + return except Exception as exc: status.update(f"✗ Pull failed: {exc}") + finally: + self._pulling = False + pull_btn.disabled = False + catalog_btn.disabled = False + progress.display = False + cancel_btn.display = False + + if self._pull_queue: + self._pull_next() + else: + await self._load_models() + + @work() + async def _load_to_vram(self, model_name: str) -> None: + status = self.query_one("#model-status", Static) + status.update(f"Loading {model_name} into VRAM...") + success = await api_client.load_model(model_name) + if success: + status.update(f" \u2713 {model_name} loaded into VRAM") + else: + status.update(f" \u2717 Failed to load {model_name}") + await self._load_models() + + @work() + async def _unload_from_vram(self, model_name: str) -> None: + status = self.query_one("#model-status", Static) + status.update(f"Unloading {model_name}...") + success = await api_client.unload_model(model_name) + if success: + status.update(f" \u2713 {model_name} unloaded from VRAM") + else: + status.update(f" \u2717 Failed to unload {model_name}") + await self._load_models() diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/services.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/services.py index 61fe215..ddca6d3 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/services.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/services.py @@ -2,78 +2,119 @@ import subprocess +from textual import work from textual.app import ComposeResult from textual.containers import Horizontal, Vertical, VerticalScroll from textual.screen import Screen from textual.widgets import Button, Footer, Header, Static -from utils import hardware - +from textual.binding import Binding -class ServiceRow(Horizontal): - def __init__(self, service: str, status: str) -> None: - super().__init__(classes="service-row") - self.service_name = service - self.service_status = status - - def compose(self) -> ComposeResult: - short = self.service_name.replace("neuraldrive-", "") - cls = "ok" if self.service_status == "active" else "error" - yield Static( - f"{'●' if self.service_status == 'active' else '○'} {short}", classes=cls - ) - yield Static("", classes="value") - yield Button("Start", id=f"start-{self.service_name}") - yield Button("Stop", id=f"stop-{self.service_name}") - yield Button("Restart", id=f"restart-{self.service_name}") +from utils import hardware class ServicesScreen(Screen): - BINDINGS = [("r", "refresh", "Refresh")] + BINDINGS = [ + ("r", "refresh", "Refresh"), + Binding("up", "move_up", "Up", show=False), + Binding("down", "move_down", "Down", show=False), + ] def compose(self) -> ComposeResult: yield Header() with VerticalScroll(): yield Static("NeuralDrive Services", classes="heading") yield Vertical(id="service-list") - yield Static("", id="svc-status") + yield Static("", id="svc-status") + with Horizontal(id="svc-actions"): + yield Button("Start", id="svc-start", variant="primary") + yield Button("Stop", id="svc-stop", variant="error") + yield Button("Restart", id="svc-restart") yield Footer() def on_mount(self) -> None: - self._load_services() + self._selected_index = 0 + self._services: list[tuple[str, str]] = [] + self.app.call_later(self._load_services) + + def on_screen_resume(self) -> None: + self.app.call_later(self._load_services) - def _load_services(self) -> None: + async def _load_services(self) -> None: container = self.query_one("#service-list", Vertical) - container.remove_children() + await container.remove_children() + self._services = [] for svc in hardware.NEURALDRIVE_SERVICES: status = hardware.get_service_status(svc) - container.mount(ServiceRow(svc, status)) + self._services.append((svc, status)) + + for i, (svc, status) in enumerate(self._services): + short = svc.replace("neuraldrive-", "") + if status == "active": + indicator = "●" + cls = "svc-row svc-active" + else: + indicator = "○" + cls = "svc-row svc-inactive" + if i == self._selected_index: + cls += " svc-selected" + row = Static( + f" {indicator} {short:<20} {status}", classes=cls, id=f"svc-{i}" + ) + await container.mount(row) + + self._update_action_buttons() + + def _update_action_buttons(self) -> None: + if not self._services: + return + _, status = self._services[self._selected_index] + self.query_one("#svc-start", Button).disabled = status == "active" + self.query_one("#svc-stop", Button).disabled = status != "active" + + def action_move_up(self) -> None: + if self._selected_index > 0: + self._selected_index -= 1 + self.app.call_later(self._load_services) + + def action_move_down(self) -> None: + if self._selected_index < len(self._services) - 1: + self._selected_index += 1 + self.app.call_later(self._load_services) def on_button_pressed(self, event: Button.Pressed) -> None: btn_id = event.button.id or "" - for action in ("start", "stop", "restart"): - prefix = f"{action}-" - if btn_id.startswith(prefix): - svc = btn_id[len(prefix) :] - self._run_systemctl(action, svc) - return - - def _run_systemctl(self, action: str, service: str) -> None: + if btn_id == "svc-start": + self._run_action("start") + elif btn_id == "svc-stop": + self._run_action("stop") + elif btn_id == "svc-restart": + self._run_action("restart") + + @work(exclusive=True) + async def _run_action(self, action: str) -> None: + if not self._services: + return + svc, _ = self._services[self._selected_index] + short = svc.replace("neuraldrive-", "") status_widget = self.query_one("#svc-status", Static) + status_widget.update(f" {action.title()}ing {short}...") + try: res = subprocess.run( - ["systemctl", action, service], + ["sudo", "systemctl", action, svc], capture_output=True, text=True, timeout=15, ) if res.returncode == 0: - status_widget.update(f"✓ {action} {service}") + status_widget.update(f" ✓ {short} {action}ed") else: - status_widget.update(f"✗ {action} {service}: {res.stderr.strip()}") + status_widget.update(f" ✗ {short}: {res.stderr.strip()}") except subprocess.TimeoutExpired: - status_widget.update(f"✗ {action} {service}: timeout") - self._load_services() + status_widget.update(f" ✗ {short}: timeout") + + self.app.call_later(self._load_services) def action_refresh(self) -> None: - self._load_services() + self.app.call_later(self._load_services) diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/wizard.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/wizard.py index d2eed88..3766333 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/wizard.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/wizard.py @@ -9,13 +9,19 @@ from textual.screen import Screen from textual.widgets import Button, Input, Static +from utils import config + SENTINEL = "/etc/neuraldrive/first-boot-complete" CREDENTIALS_PATH = "/etc/neuraldrive/credentials.conf" API_KEY_PATH = "/etc/neuraldrive/api.key" SUDOERS_PATH = "/etc/sudoers.d/neuraldrive-admin" +PERSISTENCE_MOUNT = "/var/lib/neuraldrive" +PERSISTENCE_CONF_CONTENT = "/var/lib/neuraldrive union\n/etc/neuraldrive union\n/var/log/neuraldrive union\n/home union\n" class FirstBootWizard(Screen): + """Step order: Welcome → Storage → Security → Network → Models → Done""" + BINDINGS = [("escape", "cancel_wizard", "Skip")] def __init__(self) -> None: @@ -25,6 +31,10 @@ def __init__(self) -> None: self._wifi_ssid = "" self._wifi_psk = "" self._generated_api_key = "" + self._boot_device: str | None = None + self._unpartitioned_bytes = 0 + self._has_persistence = False + self._awaiting_confirm = False def compose(self) -> ComposeResult: with Center(id="wizard-container"): @@ -37,6 +47,12 @@ def compose(self) -> ComposeResult: yield Button("Next →", id="wiz-next", classes="primary") yield Button("Skip", id="wiz-skip") + def on_input_submitted(self, event: Input.Submitted) -> None: + if self._awaiting_confirm: + self._handle_storage_confirm() + else: + self.focus_next() + def on_mount(self) -> None: self._show_step() @@ -55,18 +71,22 @@ def _show_step(self) -> None: error.update("") inp.value = "" inp2.value = "" + self._awaiting_confirm = False if self._step == 0: title.update("Welcome to NeuralDrive") body.update( "This wizard will configure your system.\n\n" - "Steps: Security → WiFi → Network → Storage → Models → Done" + "Steps: Storage → Security → Network → Models → Done" ) next_btn.label = "Begin →" elif self._step == 1: - title.update("Step 1: Security") - body.update("Set an admin password for the 'neuraldrive' user.") + self._show_storage_step(title, body, inp, next_btn, skip_btn) + + elif self._step == 2: + title.update("Step 2: Security") + body.update("Set an admin password for the 'neuraldrive-admin' user.") inp.display = True inp.placeholder = "New password" inp.password = True @@ -74,8 +94,8 @@ def _show_step(self) -> None: inp2.placeholder = "Confirm password" next_btn.label = "Set Password →" - elif self._step == 2: - title.update("Step 2: WiFi (Optional)") + elif self._step == 3: + title.update("Step 3: Network (Optional)") body.update("Enter WiFi credentials, or skip for wired-only.") inp.display = True inp.placeholder = "SSID" @@ -85,34 +105,8 @@ def _show_step(self) -> None: skip_btn.display = True next_btn.label = "Connect →" - elif self._step == 3: - title.update("Step 3: Network") - from utils import hardware - - ip = hardware.get_ip_address() - hostname = hardware.get_hostname() - body.update( - f"Current configuration:\n" - f" Hostname: {hostname}\n" - f" IP: {ip}\n\n" - "DHCP is active. Static IP can be configured later." - ) - next_btn.label = "Next →" - elif self._step == 4: - title.update("Step 4: Storage") - from utils import hardware - - disk = hardware.get_disk_info() - body.update( - f"Storage: {disk['free_gb']} GB free of {disk['total_gb']} GB\n" - f"Path: {disk['path']}\n\n" - "Models will be stored at /var/lib/neuraldrive/models." - ) - next_btn.label = "Next →" - - elif self._step == 5: - title.update("Step 5: Models") + title.update("Step 4: Models") body.update( "Models can be pulled after setup from:\n" " • This TUI (press M for Models)\n" @@ -121,7 +115,7 @@ def _show_step(self) -> None: ) next_btn.label = "Next →" - elif self._step == 6: + elif self._step == 5: self._generated_api_key = secrets.token_urlsafe(32) title.update("Setup Complete") body.update( @@ -132,28 +126,323 @@ def _show_step(self) -> None: ) next_btn.label = "Finish ✓" + if inp.display: + inp.focus() + else: + next_btn.focus() + + def _show_storage_step( + self, + title: Static, + body: Static, + inp: Input, + next_btn: Button, + skip_btn: Button, + ) -> None: + title.update("Step 1: Storage & Persistence") + + from utils import hardware + + self._boot_device = hardware.get_boot_device() + if not self._boot_device: + body.update( + "Could not detect boot device.\n\n" + "Persistence partition cannot be created automatically.\n" + "Data will be stored on the ephemeral overlay (lost on reboot).\n\n" + "You can create a persistence partition manually later\n" + "using: sudo /usr/lib/neuraldrive/prepare-usb.sh /dev/sdX" + ) + next_btn.label = "Next →" + return + + partitions = hardware.get_disk_partitions(self._boot_device) + self._has_persistence = any(p.get("label") == "persistence" for p in partitions) + total_bytes = hardware.get_device_size(self._boot_device) + total_gb = total_bytes / (1024**3) if total_bytes else 0 + + if self._has_persistence: + pers = next(p for p in partitions if p.get("label") == "persistence") + pers_gb = pers["size_bytes"] / (1024**3) + body.update( + f"Boot device: {self._boot_device} ({total_gb:.0f} GB)\n\n" + f"✓ Persistence partition found: {pers_gb:.1f} GB\n" + f" Models, config, and logs will survive reboots.\n\n" + "No action needed." + ) + next_btn.label = "Next →" + return + + self._unpartitioned_bytes = hardware.get_unpartitioned_space(self._boot_device) + free_gb = self._unpartitioned_bytes / (1024**3) + + if self._unpartitioned_bytes < 1024 * 1024 * 1024: + body.update( + f"Boot device: {self._boot_device} ({total_gb:.0f} GB)\n\n" + "No persistence partition found.\n" + f"Only {free_gb:.1f} GB unpartitioned space available\n" + "(minimum 1 GB required).\n\n" + "Data will be stored on the ephemeral overlay (lost on reboot)." + ) + next_btn.label = "Next →" + return + + body.update( + f"Boot device: {self._boot_device} ({total_gb:.0f} GB)\n\n" + "No persistence partition found.\n" + f"Available space: {free_gb:.1f} GB\n\n" + "A persistence partition stores your models, config,\n" + "and logs so they survive reboots.\n\n" + "Type 'yes' to create it, or skip to use\n" + "ephemeral overlay storage." + ) + inp.display = True + inp.placeholder = "Type 'yes' to create persistence partition" + inp.password = False + self._awaiting_confirm = True + skip_btn.display = True + next_btn.label = "Create Partition →" + def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "wiz-skip": + self._awaiting_confirm = False self._step += 1 self._show_step() return if event.button.id == "wiz-next": - if self._step == 1: + if self._step == 1 and self._awaiting_confirm: + self._handle_storage_confirm() + return + if self._step == 2: if not self._validate_password(): return - elif self._step == 2: + elif self._step == 3: self._configure_wifi() - elif self._step == 6: + elif self._step == 5: self._finalize() return self._step += 1 - if self._step > 6: + if self._step > 5: self._finalize() else: self._show_step() + def _handle_storage_confirm(self) -> None: + inp = self.query_one("#wiz-input", Input) + error = self.query_one("#wiz-error", Static) + + if inp.value.strip().lower() != "yes": + error.update("Type 'yes' to confirm, or press Skip.") + return + + self._awaiting_confirm = False + body = self.query_one("#wiz-body", Static) + body.update("Creating persistence partition...\nThis may take a moment.") + self.query_one("#wiz-next", Button).disabled = True + self.query_one("#wiz-skip", Button).display = False + inp.display = False + + err = self._create_persistence_partition() + self.query_one("#wiz-next", Button).disabled = False + + if err: + error.update(f"Partition creation failed: {err}") + body.update( + "Partition creation failed.\n" + "Data will use the ephemeral overlay.\n" + "You can retry manually later." + ) + self.query_one("#wiz-next", Button).label = "Next →" + else: + body.update( + "✓ Persistence partition created and mounted.\n\n" + "Models, config, and logs will now survive reboots." + ) + self.query_one("#wiz-next", Button).label = "Next →" + + def _create_persistence_partition(self) -> str | None: + if not self._boot_device: + return "No boot device detected" + + try: + res = subprocess.run( + [ + "sudo", + "parted", + "-m", + self._boot_device, + "unit", + "B", + "print", + "free", + ], + capture_output=True, + text=True, + timeout=10, + ) + if res.returncode != 0: + return f"parted print failed: {res.stderr.strip()}" + + free_start = None + free_end = None + for line in res.stdout.strip().splitlines(): + if ":free;" in line: + parts = line.split(":") + if len(parts) >= 3: + start_b = int(parts[1].rstrip("B")) + end_b = int(parts[2].rstrip("B")) + size_b = end_b - start_b + if size_b > 1024 * 1024 * 1024: + free_start = parts[1] + free_end = parts[2] + + if not free_start or not free_end: + return "No free space block large enough found" + + proc = subprocess.run( + [ + "sudo", + "parted", + self._boot_device, + "--script", + "--", + "mkpart", + "primary", + "ext4", + free_start, + free_end, + ], + capture_output=True, + text=True, + timeout=30, + ) + if proc.returncode != 0: + return proc.stderr.strip() + + subprocess.run( + ["sudo", "partprobe", self._boot_device], + capture_output=True, + timeout=10, + ) + + import time + + time.sleep(2) + + res = subprocess.run( + ["lsblk", "-ln", "-o", "NAME", self._boot_device], + capture_output=True, + text=True, + timeout=5, + ) + if res.returncode != 0: + return "Could not determine new partition device" + parts = res.stdout.strip().splitlines() + if not parts: + return "No partitions found after creation" + new_part = f"/dev/{parts[-1].strip()}" + + proc = subprocess.run( + [ + "sudo", + "mkfs.ext4", + "-L", + "persistence", + "-m", + "1", + new_part, + ], + capture_output=True, + text=True, + timeout=120, + ) + if proc.returncode != 0: + return f"mkfs.ext4 failed: {proc.stderr.strip()}" + + subprocess.run( + ["sudo", "mkdir", "-p", "/mnt/persistence"], + capture_output=True, + timeout=5, + ) + proc = subprocess.run( + ["sudo", "mount", new_part, "/mnt/persistence"], + capture_output=True, + text=True, + timeout=10, + ) + if proc.returncode != 0: + return f"Mount failed: {proc.stderr.strip()}" + + proc = subprocess.run( + ["sudo", "tee", "/mnt/persistence/persistence.conf"], + input=PERSISTENCE_CONF_CONTENT.encode(), + capture_output=True, + timeout=5, + ) + if proc.returncode != 0: + return "Failed to write persistence.conf" + + for d in [ + "/mnt/persistence/var/lib/neuraldrive/ollama/.ollama", + "/mnt/persistence/var/lib/neuraldrive/models", + "/mnt/persistence/var/lib/neuraldrive/config", + "/mnt/persistence/var/log/neuraldrive", + "/mnt/persistence/etc/neuraldrive", + "/mnt/persistence/home", + ]: + subprocess.run( + ["sudo", "mkdir", "-p", d], + capture_output=True, + timeout=5, + ) + + subprocess.run( + [ + "sudo", + "chown", + "-R", + "neuraldrive-ollama:neuraldrive-ollama", + "/mnt/persistence/var/lib/neuraldrive/ollama", + ], + capture_output=True, + timeout=5, + ) + + subprocess.run( + ["sudo", "umount", "/mnt/persistence"], + capture_output=True, + timeout=10, + ) + + subprocess.run( + ["sudo", "mkdir", "-p", PERSISTENCE_MOUNT], + capture_output=True, + timeout=5, + ) + proc = subprocess.run( + ["sudo", "mount", new_part, PERSISTENCE_MOUNT], + capture_output=True, + text=True, + timeout=10, + ) + if proc.returncode != 0: + return f"Mount at {PERSISTENCE_MOUNT} failed: {proc.stderr.strip()}" + + subprocess.run( + ["sudo", "systemctl", "restart", "neuraldrive-ollama"], + capture_output=True, + timeout=30, + ) + + self._has_persistence = True + return None + + except subprocess.TimeoutExpired: + return "Operation timed out" + except FileNotFoundError as e: + return f"Required tool not found: {e}" + def _validate_password(self) -> bool: error = self.query_one("#wiz-error", Static) pw = self.query_one("#wiz-input", Input).value @@ -185,44 +474,101 @@ def _configure_wifi(self) -> None: except (subprocess.TimeoutExpired, FileNotFoundError): pass - def _finalize(self) -> None: + def _sudo_write(self, path: str, content: str, mode: str = "0644") -> str | None: try: - if self._admin_password: - proc = subprocess.Popen( - ["chpasswd"], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - proc.communicate( - input=f"neuraldrive:{self._admin_password}\n".encode(), + subprocess.run( + ["sudo", "mkdir", "-p", os.path.dirname(path)], + capture_output=True, + timeout=5, + ) + proc = subprocess.run( + ["sudo", "tee", path], + input=content.encode(), + capture_output=True, + timeout=5, + ) + if proc.returncode != 0: + return f"Failed to write {path}: {proc.stderr.decode().strip()}" + subprocess.run( + ["sudo", "chmod", mode, path], + capture_output=True, + timeout=5, + ) + return None + except (subprocess.TimeoutExpired, FileNotFoundError) as e: + return f"Failed to write {path}: {e}" + + def _finalize(self) -> None: + errors: list[str] = [] + + if self._admin_password: + try: + proc = subprocess.run( + ["sudo", "chpasswd"], + input=f"neuraldrive-admin:{self._admin_password}\n".encode(), + capture_output=True, timeout=10, ) - - if os.path.exists(SUDOERS_PATH): - with open(SUDOERS_PATH, "r") as f: - content = f.read() - content = content.replace("NOPASSWD:", "") - with open(SUDOERS_PATH, "w") as f: - f.write(content) - - if self._generated_api_key: - os.makedirs(os.path.dirname(API_KEY_PATH), exist_ok=True) - with open(API_KEY_PATH, "w") as f: - f.write(self._generated_api_key + "\n") - os.chmod(API_KEY_PATH, 0o600) - - os.makedirs(os.path.dirname(CREDENTIALS_PATH), exist_ok=True) - with open(CREDENTIALS_PATH, "w") as f: - f.write(f"api_key={self._generated_api_key}\n") - os.chmod(CREDENTIALS_PATH, 0o600) - - os.makedirs(os.path.dirname(SENTINEL), exist_ok=True) - with open(SENTINEL, "w") as f: - f.write("") - - except Exception: - pass + if proc.returncode != 0: + errors.append( + f"Password change failed: {proc.stderr.decode().strip()}" + ) + except (subprocess.TimeoutExpired, FileNotFoundError) as e: + errors.append(f"Password change failed: {e}") + + if self._generated_api_key: + err = self._sudo_write(API_KEY_PATH, self._generated_api_key + "\n", "0600") + if err: + errors.append(err) + + err = self._sudo_write( + CREDENTIALS_PATH, + f"api_key={self._generated_api_key}\n", + "0600", + ) + if err: + errors.append(err) + + cfg_data = config.load() + cfg_data["wizard_complete"] = True + if self._admin_password: + cfg_data["security"] = {"password_set": True} + if self._wifi_ssid: + cfg_data["network"] = {"wifi_ssid": self._wifi_ssid} + if self._generated_api_key: + cfg_data["api"] = {"key_generated": True} + if self._has_persistence: + cfg_data["storage"] = {"persistence": True} + cfg_err = config.save(cfg_data) + if cfg_err: + errors.append(cfg_err) + + err = self._sudo_write(SENTINEL, "") + if err: + errors.append(err) + + # Remove NOPASSWD LAST — after all other sudo operations are done, + # since removing it makes subsequent sudo calls require a TTY password prompt + if self._admin_password: + try: + result = subprocess.run( + ["sudo", "cat", SUDOERS_PATH], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0 and "NOPASSWD:" in result.stdout: + new_content = result.stdout.replace("NOPASSWD:", "") + err = self._sudo_write(SUDOERS_PATH, new_content, "0440") + if err: + errors.append(err) + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + + if errors: + error_widget = self.query_one("#wiz-error", Static) + error_widget.update("\n".join(errors)) + return self.app.pop_screen() diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss b/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss index 258ccbf..0737277 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss @@ -104,17 +104,70 @@ Static.muted { width: 10; } -.service-row { - layout: horizontal; +Button.model-load { + background: #1F1F1F; + color: #10B981; + border: solid #10B981; + min-width: 10; + width: 10; + height: 3; +} + +Button.model-load:hover { + background: #0A1F0A; +} + +Button.model-unload { + background: #1F1F1F; + color: #F97316; + border: solid #F97316; + min-width: 10; + width: 10; + height: 3; +} + +Button.model-unload:hover { + background: #1F0A0A; +} + +.svc-row { height: 3; padding: 0 2; border: solid #2E2E2E; - margin: 0 0 1 0; + margin: 0 0 0 0; background: #141414; + content-align: left middle; +} + +.svc-active { + color: #10B981; } -.service-row:hover { +.svc-inactive { + color: #EF4444; +} + +.svc-selected { background: #1F1F1F; + border: solid #F59E0B; +} + +#svc-status { + height: 1; + padding: 0 2; + dock: bottom; + offset: 0 -4; +} + +#svc-actions { + height: auto; + padding: 0 1; + dock: bottom; + align: center middle; +} + +#svc-actions Button { + margin: 0 1; } Button { @@ -165,6 +218,14 @@ Select { color: #FFFFFF; } +#chat-model-select { + width: 100%; + margin: 0 2; + border: solid #F59E0B; + background: #141414; + height: 3; +} + #wizard-container { align: center middle; padding: 2 4; @@ -204,3 +265,116 @@ Select { .badge-offline { color: #EF4444; } + +#pull-progress { + margin: 1 0; + width: 1fr; +} + +#pull-row { + height: auto; + layout: horizontal; +} + +#cancel-pull { + width: 12; + margin: 1 0 1 1; +} + +ProgressBar Bar { + color: #F59E0B; + background: #2E2E2E; +} + +ProgressBar PercentageStatus { + color: #A1A1AA; +} + +#chat-notice { + padding: 0 2; + height: auto; +} + +Static.tier-heading { + color: #F59E0B; + text-style: bold; +} + +Button.model-pick { + background: #141414; + color: #FFFFFF; + border: solid #2E2E2E; + width: 100%; + height: 3; + content-align: left middle; + text-align: left; +} + +Button.model-pick:hover { + background: #1F1F1F; + border: solid #F59E0B; +} + +Button.model-pick:disabled { + background: #0A0A0A; + color: #52525B; + border: solid #1A1A1A; +} + +#catalog-scroll { + height: 1fr; + border: solid #2E2E2E; + scrollbar-background: #141414; + scrollbar-color: #2E2E2E; +} + +Button.catalog-item { + background: #141414; + color: #A1A1AA; + border: solid #2E2E2E; + width: 100%; + height: 3; + content-align: left middle; + text-align: left; +} + +Button.catalog-item:hover { + background: #1F1F1F; + border: solid #F59E0B; +} + +Button.catalog-item:focus { + border: solid #F59E0B; +} + +Button.catalog-highlighted { + background: #1F1F1F; + border: solid #F59E0B; +} + +Button.catalog-highlighted.catalog-installed { + background: #0A0A0A; + border: solid #52525B; +} + +Button.catalog-checked { + color: #10B981; + background: #0A1F0A; + border: solid #10B981; +} + +Button.catalog-installed { + color: #52525B; + background: #0A0A0A; + border: solid #1A1A1A; +} + +#catalog-buttons { + height: auto; + padding: 1 0; + align: center middle; +} + +#catalog-buttons Button { + margin: 0 2; +} diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/utils/api_client.py b/config/includes.chroot/usr/lib/neuraldrive/tui/utils/api_client.py index 11a4fd6..e862a87 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/utils/api_client.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/utils/api_client.py @@ -54,6 +54,30 @@ async def delete_model(name: str) -> bool: return False +async def load_model(name: str, keep_alive: str = "5m") -> bool: + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, read=300.0)) as client: + resp = await client.post( + f"{OLLAMA_URL}/api/generate", + json={"model": name, "prompt": "", "keep_alive": keep_alive}, + ) + return resp.status_code == 200 + except (httpx.ConnectError, httpx.TimeoutException, httpx.HTTPError): + return False + + +async def unload_model(name: str) -> bool: + try: + async with httpx.AsyncClient(timeout=TIMEOUT) as client: + resp = await client.post( + f"{OLLAMA_URL}/api/generate", + json={"model": name, "prompt": "", "keep_alive": 0}, + ) + return resp.status_code == 200 + except (httpx.ConnectError, httpx.TimeoutException, httpx.HTTPError): + return False + + async def chat_stream(model: str, messages: list[dict]): payload = {"model": model, "messages": messages, "stream": True} async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, read=600.0)) as client: diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/utils/config.py b/config/includes.chroot/usr/lib/neuraldrive/tui/utils/config.py new file mode 100644 index 0000000..4e7cd8e --- /dev/null +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/utils/config.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import os +import subprocess +from typing import Any + +import yaml + +PERSISTENT_CONFIG = "/var/lib/neuraldrive/config/config.yaml" +OVERLAY_CONFIG = "/etc/neuraldrive/config.yaml" + + +def _config_path() -> str: + persistent_dir = os.path.dirname(PERSISTENT_CONFIG) + if os.path.isdir(persistent_dir) and os.access(persistent_dir, os.W_OK): + return PERSISTENT_CONFIG + return OVERLAY_CONFIG + + +def load() -> dict[str, Any]: + for path in (PERSISTENT_CONFIG, OVERLAY_CONFIG): + if os.path.exists(path): + try: + with open(path) as f: + data = yaml.safe_load(f) + if isinstance(data, dict): + return data + except (OSError, yaml.YAMLError): + continue + return {} + + +def save(data: dict[str, Any]) -> str | None: + path = _config_path() + content = yaml.dump(data, default_flow_style=False, sort_keys=False) + try: + subprocess.run( + ["sudo", "mkdir", "-p", os.path.dirname(path)], + capture_output=True, + timeout=5, + ) + proc = subprocess.run( + ["sudo", "tee", path], + input=content.encode(), + capture_output=True, + timeout=5, + ) + if proc.returncode != 0: + return f"Failed to write {path}: {proc.stderr.decode().strip()}" + subprocess.run( + ["sudo", "chmod", "0644", path], + capture_output=True, + timeout=5, + ) + return None + except (subprocess.TimeoutExpired, FileNotFoundError) as e: + return f"Failed to write {path}: {e}" + + +def get(key: str, default: Any = None) -> Any: + data = load() + keys = key.split(".") + for k in keys: + if isinstance(data, dict): + data = data.get(k, default) + else: + return default + return data + + +def set_key(key: str, value: Any) -> str | None: + data = load() + keys = key.split(".") + target = data + for k in keys[:-1]: + if k not in target or not isinstance(target[k], dict): + target[k] = {} + target = target[k] + target[keys[-1]] = value + return save(data) + + +def wizard_complete() -> bool: + return get("wizard_complete", False) is True diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/utils/hardware.py b/config/includes.chroot/usr/lib/neuraldrive/tui/utils/hardware.py index e6949c4..e1f3918 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/utils/hardware.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/utils/hardware.py @@ -147,3 +147,95 @@ def get_service_status(service: str) -> str: "neuraldrive-gpu-monitor", "neuraldrive-system-api", ] + + +def get_boot_device() -> str | None: + try: + with open("/proc/cmdline") as f: + cmdline = f.read() + for part in cmdline.split(): + if part.startswith("boot=live") or part.startswith("root="): + pass + if part.startswith("live-media="): + return part.split("=", 1)[1] + res = subprocess.run( + ["findmnt", "-n", "-o", "SOURCE", "/run/live/medium"], + capture_output=True, + text=True, + timeout=5, + ) + if res.returncode == 0 and res.stdout.strip(): + part_dev = res.stdout.strip() + import re + + match = re.match(r"(/dev/[a-z]+)", part_dev) + if match: + return match.group(1) + except (OSError, subprocess.TimeoutExpired, FileNotFoundError): + pass + return None + + +def get_disk_partitions(device: str) -> list[dict]: + try: + res = subprocess.run( + ["lsblk", "-J", "-b", "-o", "NAME,SIZE,FSTYPE,LABEL,MOUNTPOINT", device], + capture_output=True, + text=True, + timeout=5, + ) + if res.returncode != 0: + return [] + import json + + data = json.loads(res.stdout) + partitions = [] + for bd in data.get("blockdevices", []): + for child in bd.get("children", []): + partitions.append( + { + "name": child.get("name", ""), + "size_bytes": int(child.get("size", 0)), + "fstype": child.get("fstype", ""), + "label": child.get("label", ""), + "mountpoint": child.get("mountpoint", ""), + } + ) + if not bd.get("children"): + partitions.append( + { + "name": bd.get("name", ""), + "size_bytes": int(bd.get("size", 0)), + "fstype": bd.get("fstype", ""), + "label": bd.get("label", ""), + "mountpoint": bd.get("mountpoint", ""), + } + ) + return partitions + except (subprocess.TimeoutExpired, FileNotFoundError, ValueError): + return [] + + +def get_device_size(device: str) -> int: + try: + res = subprocess.run( + ["lsblk", "-b", "-d", "-n", "-o", "SIZE", device], + capture_output=True, + text=True, + timeout=5, + ) + if res.returncode == 0: + return int(res.stdout.strip()) + except (subprocess.TimeoutExpired, FileNotFoundError, ValueError): + pass + return 0 + + +def get_unpartitioned_space(device: str) -> int: + total = get_device_size(device) + if not total: + return 0 + parts = get_disk_partitions(device) + used = sum(p["size_bytes"] for p in parts) + free = total - used + return max(0, free) diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/model_item.py b/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/model_item.py index 1df4451..fb6e2d6 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/model_item.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/model_item.py @@ -2,19 +2,28 @@ from textual.app import ComposeResult from textual.containers import Horizontal -from textual.widgets import Static +from textual.widgets import Button, Static class ModelItem(Horizontal): def __init__(self, name: str, size: str, loaded: bool = False) -> None: - super().__init__(classes="model-item") - self._name = name - self._size = size + super().__init__(name=name, classes="model-item") + self._model_name = name + self._model_size = size self._loaded = loaded def compose(self) -> ComposeResult: - yield Static(self._name, classes="model-name") - yield Static(self._size, classes="model-size") - status_cls = "model-status-loaded" if self._loaded else "model-status-cached" - status_txt = "● loaded" if self._loaded else "○ cached" - yield Static(status_txt, classes=status_cls) + yield Static(self._model_name, classes="model-name") + yield Static(self._model_size, classes="model-size") + if self._loaded: + yield Static("● VRAM", classes="model-status-loaded") + else: + yield Static("○ ready", classes="model-status-cached") + load_btn = Button("Load", name=self._model_name, classes="model-load") + unload_btn = Button("Unload", name=self._model_name, classes="model-unload") + if self._loaded: + load_btn.disabled = True + else: + unload_btn.disabled = True + yield load_btn + yield unload_btn From 334ef93860dcda00a4a6db0eeb289c8792c94f09 Mon Sep 17 00:00:00 2001 From: eshork <1829176+eshork@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:42:41 -0400 Subject: [PATCH 02/32] Show model metadata and fix button visibility in model list Display parameter count, quantization level, disk size, and VRAM usage for each installed model. VRAM is cached to persistent config on first load so it remains visible after unloading. Fix model-item height (3->5) so Load/Unload buttons render inside the bordered container instead of being clipped. Show both buttons per model with the irrelevant one disabled. Add disabled button styles. --- .../usr/lib/neuraldrive/tui/screens/models.py | 35 +++++++++++++++--- .../usr/lib/neuraldrive/tui/styles.tcss | 36 +++++++++++++++++-- .../lib/neuraldrive/tui/widgets/model_item.py | 18 ++++++++-- 3 files changed, 81 insertions(+), 8 deletions(-) diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py index 195bef3..4e8951b 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py @@ -11,7 +11,7 @@ from textual.binding import Binding -from utils import api_client +from utils import api_client, config from widgets.model_item import ModelItem CURATED_MODELS = [ @@ -270,7 +270,19 @@ def action_refresh(self) -> None: async def _load_models(self) -> None: all_models = await api_client.list_models() running = await api_client.list_running_models() - running_names = {m.get("name", "") for m in running} + running_map = {m.get("name", ""): m for m in running} + + vram_cache = config.get("vram_cache", {}) + if not isinstance(vram_cache, dict): + vram_cache = {} + cache_changed = False + for name, info in running_map.items(): + vram_bytes = info.get("size_vram", 0) + if vram_bytes and vram_cache.get(name) != vram_bytes: + vram_cache[name] = vram_bytes + cache_changed = True + if cache_changed: + config.set_key("vram_cache", vram_cache) container = self.query_one("#model-list", Vertical) container.remove_children() @@ -282,8 +294,23 @@ async def _load_models(self) -> None: name = m.get("name", "unknown") size_bytes = m.get("size", 0) size_str = f"{size_bytes / (1024**3):.1f} GB" if size_bytes else "—" - loaded = name in running_names - container.mount(ModelItem(name, size_str, loaded)) + details = m.get("details", {}) + params = details.get("parameter_size", "") + quant = details.get("quantization_level", "") + loaded = name in running_map + + if name in running_map: + vb = running_map[name].get("size_vram", 0) + vram_str = f"{vb / (1024**3):.1f} GB" if vb else "—" + elif name in vram_cache: + vb = vram_cache[name] + vram_str = f"~{vb / (1024**3):.1f} GB" if vb else "—" + else: + vram_str = "—" + + container.mount( + ModelItem(name, size_str, params, quant, vram_str, loaded) + ) async def on_button_pressed(self, event: Button.Pressed) -> None: btn = event.button diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss b/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss index 0737277..c9d6e41 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss @@ -72,7 +72,7 @@ Static.muted { .model-item { layout: horizontal; - height: 3; + height: 5; padding: 0 2; border: solid #2E2E2E; margin: 0 0 1 0; @@ -83,14 +83,34 @@ Static.muted { background: #1F1F1F; } +.model-item Static { + height: 100%; + content-align: left middle; +} + .model-item Static.model-name { color: #FFFFFF; text-style: bold; width: 1fr; } -.model-item Static.model-size { +.model-item Static.model-params { + color: #A1A1AA; + width: 8; +} + +.model-item Static.model-quant { + color: #71717A; + width: 10; +} + +.model-item Static.model-disk { color: #A1A1AA; + width: 10; +} + +.model-item Static.model-vram { + color: #F59E0B; width: 12; } @@ -130,6 +150,18 @@ Button.model-unload:hover { background: #1F0A0A; } +Button.model-load:disabled { + background: #0A0A0A; + color: #52525B; + border: solid #1A1A1A; +} + +Button.model-unload:disabled { + background: #0A0A0A; + color: #52525B; + border: solid #1A1A1A; +} + .svc-row { height: 3; padding: 0 2; diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/model_item.py b/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/model_item.py index fb6e2d6..c5eb2f4 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/model_item.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/model_item.py @@ -6,15 +6,29 @@ class ModelItem(Horizontal): - def __init__(self, name: str, size: str, loaded: bool = False) -> None: + def __init__( + self, + name: str, + size: str, + params: str = "", + quant: str = "", + vram_str: str = "", + loaded: bool = False, + ) -> None: super().__init__(name=name, classes="model-item") self._model_name = name self._model_size = size + self._params = params + self._quant = quant + self._vram_str = vram_str self._loaded = loaded def compose(self) -> ComposeResult: yield Static(self._model_name, classes="model-name") - yield Static(self._model_size, classes="model-size") + yield Static(self._params, classes="model-params") + yield Static(self._quant, classes="model-quant") + yield Static(self._model_size, classes="model-disk") + yield Static(self._vram_str, classes="model-vram") if self._loaded: yield Static("● VRAM", classes="model-status-loaded") else: From 927df1ab80dd18c83456967e12c958929fa541f5 Mon Sep 17 00:00:00 2001 From: eshork <1829176+eshork@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:53:18 -0400 Subject: [PATCH 03/32] Save API key to persistent disk alongside overlay Write api.key and credentials.conf to both /etc/neuraldrive/ (overlay) and /var/lib/neuraldrive/config/ (persistent disk) when available. Update wizard completion text to show where the key is stored instead of telling the user to save it manually. --- .../usr/lib/neuraldrive/tui/screens/wizard.py | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/wizard.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/wizard.py index 3766333..b2176b4 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/wizard.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/wizard.py @@ -14,6 +14,8 @@ SENTINEL = "/etc/neuraldrive/first-boot-complete" CREDENTIALS_PATH = "/etc/neuraldrive/credentials.conf" API_KEY_PATH = "/etc/neuraldrive/api.key" +PERSISTENT_CREDENTIALS_PATH = "/var/lib/neuraldrive/config/credentials.conf" +PERSISTENT_API_KEY_PATH = "/var/lib/neuraldrive/config/api.key" SUDOERS_PATH = "/etc/sudoers.d/neuraldrive-admin" PERSISTENCE_MOUNT = "/var/lib/neuraldrive" PERSISTENCE_CONF_CONTENT = "/var/lib/neuraldrive union\n/etc/neuraldrive union\n/var/log/neuraldrive union\n/home union\n" @@ -121,7 +123,8 @@ def _show_step(self) -> None: body.update( "NeuralDrive is ready.\n\n" f"API Key: {self._generated_api_key}\n\n" - "Save this key — it is required for API access.\n" + "This key is stored at /etc/neuraldrive/api.key\n" + "and on persistent storage when available.\n" "Press Finish to start using NeuralDrive." ) next_btn.label = "Finish ✓" @@ -517,18 +520,27 @@ def _finalize(self) -> None: errors.append(f"Password change failed: {e}") if self._generated_api_key: - err = self._sudo_write(API_KEY_PATH, self._generated_api_key + "\n", "0600") + key_content = self._generated_api_key + "\n" + cred_content = f"api_key={self._generated_api_key}\n" + + err = self._sudo_write(API_KEY_PATH, key_content, "0600") if err: errors.append(err) - - err = self._sudo_write( - CREDENTIALS_PATH, - f"api_key={self._generated_api_key}\n", - "0600", - ) + err = self._sudo_write(CREDENTIALS_PATH, cred_content, "0600") if err: errors.append(err) + persist_dir = os.path.dirname(PERSISTENT_API_KEY_PATH) + if os.path.isdir(persist_dir): + err = self._sudo_write(PERSISTENT_API_KEY_PATH, key_content, "0600") + if err: + errors.append(err) + err = self._sudo_write( + PERSISTENT_CREDENTIALS_PATH, cred_content, "0600" + ) + if err: + errors.append(err) + cfg_data = config.load() cfg_data["wizard_complete"] = True if self._admin_password: From a1e82c4de34a657e9d9b4bb66ec24c7b703d3646 Mon Sep 17 00:00:00 2001 From: eshork <1829176+eshork@users.noreply.github.com> Date: Thu, 23 Apr 2026 23:16:58 -0400 Subject: [PATCH 04/32] Add live clock to dashboard top-right corner Updates every 2 seconds alongside the system stats refresh. Shows HH:MM:SS so the user can tell at a glance the dashboard is live. --- .../usr/lib/neuraldrive/tui/screens/dashboard.py | 8 +++++++- .../usr/lib/neuraldrive/tui/styles.tcss | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/dashboard.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/dashboard.py index 0f7c32e..f33e49c 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/dashboard.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/dashboard.py @@ -1,5 +1,7 @@ from __future__ import annotations +from datetime import datetime + from textual.app import ComposeResult from textual.containers import Horizontal, Vertical, VerticalScroll from textual.screen import Screen @@ -15,7 +17,9 @@ class DashboardScreen(Screen): def compose(self) -> ComposeResult: yield Header() with VerticalScroll(): - yield Static("", id="dash-hostname") + with Horizontal(id="dash-topbar"): + yield Static("", id="dash-hostname") + yield Static("", id="dash-clock") with Horizontal(id="stats-panel"): yield StatsBox("CPU", [("Usage", "…")], id="box-cpu") yield StatsBox("Memory", [("Used", "…"), ("Total", "…")], id="box-mem") @@ -44,6 +48,8 @@ def _refresh_system(self) -> None: self.query_one("#dash-hostname", Static).update( f" {hostname} • {ip} • up {uptime}" ) + now = datetime.now().strftime("%H:%M:%S") + self.query_one("#dash-clock", Static).update(now) cpu = hardware.get_cpu_percent() self.query_one("#box-cpu", StatsBox).update_row("Usage", f"{cpu:.0f}%") diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss b/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss index c9d6e41..141255d 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss @@ -56,6 +56,22 @@ Static.muted { padding: 1; } +#dash-topbar { + height: 1; + layout: horizontal; +} + +#dash-hostname { + width: 1fr; +} + +#dash-clock { + width: auto; + color: #A1A1AA; + padding: 0 2; + text-align: right; +} + .stats-box { border: solid #2E2E2E; padding: 1 2; From bdfd6946a1eaed83e6017ea15e6aa78f02d3a98f Mon Sep 17 00:00:00 2001 From: eshork <1829176+eshork@users.noreply.github.com> Date: Thu, 23 Apr 2026 23:50:37 -0400 Subject: [PATCH 05/32] Fix chat screen layout and text wrapping - Compact model selector into horizontal row with inline label - Remove clipping on Select widget (border removed, height auto) - Enable text wrapping in chat log (wrap=True on RichLog) - Remove dock:bottom on input row to prevent footer collision - Center Send button label vertically --- .../usr/lib/neuraldrive/tui/screens/chat.py | 7 ++--- .../usr/lib/neuraldrive/tui/styles.tcss | 27 ++++++++++++++----- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/chat.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/chat.py index 6f4c88c..9699c64 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/chat.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/chat.py @@ -20,10 +20,11 @@ def __init__(self) -> None: def compose(self) -> ComposeResult: yield Header() - yield Static(" Model", classes="heading") - yield Select([], id="chat-model-select", prompt="Choose a model…") + with Horizontal(id="chat-model-row"): + yield Static(" Model ", id="chat-model-label") + yield Select([], id="chat-model-select", prompt="Choose a model…") yield Static("", id="chat-notice") - yield RichLog(highlight=True, markup=False, id="chat-log") + yield RichLog(highlight=True, markup=False, wrap=True, id="chat-log") with Horizontal(id="chat-input-row"): yield Input(placeholder="Type a message…", id="chat-input") yield Button("Send", id="chat-send", classes="primary") diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss b/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss index 141255d..49c00d5 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss @@ -266,12 +266,26 @@ Select { color: #FFFFFF; } -#chat-model-select { - width: 100%; - margin: 0 2; - border: solid #F59E0B; - background: #141414; +#chat-model-row { + layout: horizontal; + height: auto; + padding: 0 1; +} + +#chat-model-label { + color: #F59E0B; + text-style: bold; + width: auto; height: 3; + content-align: left middle; + padding: 0 1; +} + +#chat-model-select { + width: 1fr; + background: #1F1F1F; + color: #FFFFFF; + height: auto; } #wizard-container { @@ -291,7 +305,7 @@ Select { #chat-input-row { layout: horizontal; height: 3; - dock: bottom; + margin: 0 0 0 0; } #chat-input { @@ -300,6 +314,7 @@ Select { #chat-send { width: 10; + content-align: center middle; } #chat-log { From b9edad12a80072b84e8b299e7f2cc8e88f2c14f7 Mon Sep 17 00:00:00 2001 From: eshork <1829176+eshork@users.noreply.github.com> Date: Thu, 23 Apr 2026 23:55:29 -0400 Subject: [PATCH 06/32] Preserve selected model when returning to chat screen Save Select value before refreshing options list, restore it if the model is still available. Falls back to first model only when previous selection is no longer present. --- .../includes.chroot/usr/lib/neuraldrive/tui/screens/chat.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/chat.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/chat.py index 9699c64..621bbdd 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/chat.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/chat.py @@ -52,6 +52,7 @@ async def _load_model_options(self) -> None: models = await api_client.list_models() options = [(m.get("name", "?"), m.get("name", "?")) for m in models] + previous = select.value select.set_options(options) if not options: @@ -67,7 +68,10 @@ async def _load_model_options(self) -> None: notice.remove_class("error", "warn") send_btn.disabled = False chat_input.disabled = False - if select.value is Select.BLANK: + option_values = [v for _, v in options] + if previous is not Select.BLANK and previous in option_values: + select.value = previous + elif select.value is Select.BLANK: select.value = options[0][1] async def on_button_pressed(self, event: Button.Pressed) -> None: From d0f1ca80e8b1b6c2cc00a5d3c83b6428f0461f94 Mon Sep 17 00:00:00 2001 From: eshork <1829176+eshork@users.noreply.github.com> Date: Thu, 23 Apr 2026 23:59:54 -0400 Subject: [PATCH 07/32] Add model delete and fix chat model persistence - Add red Delete button to each installed model item - Auto-unload from VRAM before deleting if model is loaded - Fix httpx DELETE with json body (use client.request instead) - Preserve selected chat model when returning to chat screen --- .../usr/lib/neuraldrive/tui/screens/models.py | 18 ++++++++++++++++++ .../usr/lib/neuraldrive/tui/styles.tcss | 13 +++++++++++++ .../lib/neuraldrive/tui/utils/api_client.py | 4 +++- .../lib/neuraldrive/tui/widgets/model_item.py | 2 ++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py index 4e8951b..a80800b 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py @@ -328,6 +328,8 @@ async def on_button_pressed(self, event: Button.Pressed) -> None: self._load_to_vram(btn.name or "") elif btn.has_class("model-unload"): self._unload_from_vram(btn.name or "") + elif btn.has_class("model-delete"): + self._delete_model(btn.name or "") def _cancel_pull(self) -> None: self._pull_queue.clear() @@ -431,3 +433,19 @@ async def _unload_from_vram(self, model_name: str) -> None: else: status.update(f" \u2717 Failed to unload {model_name}") await self._load_models() + + @work() + async def _delete_model(self, model_name: str) -> None: + status = self.query_one("#model-status", Static) + running = await api_client.list_running_models() + running_names = {m.get("name", "") for m in running} + if model_name in running_names: + status.update(f"Unloading {model_name} from VRAM before delete...") + await api_client.unload_model(model_name) + status.update(f"Deleting {model_name}...") + success = await api_client.delete_model(model_name) + if success: + status.update(f" \u2713 {model_name} deleted") + else: + status.update(f" \u2717 Failed to delete {model_name}") + await self._load_models() diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss b/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss index 49c00d5..daa6da0 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss @@ -178,6 +178,19 @@ Button.model-unload:disabled { border: solid #1A1A1A; } +Button.model-delete { + background: #1F1F1F; + color: #EF4444; + border: solid #EF4444; + min-width: 10; + width: 10; + height: 3; +} + +Button.model-delete:hover { + background: #1F0A0A; +} + .svc-row { height: 3; padding: 0 2; diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/utils/api_client.py b/config/includes.chroot/usr/lib/neuraldrive/tui/utils/api_client.py index e862a87..de61a27 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/utils/api_client.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/utils/api_client.py @@ -48,7 +48,9 @@ async def pull_model(name: str): async def delete_model(name: str) -> bool: try: async with httpx.AsyncClient(timeout=TIMEOUT) as client: - resp = await client.delete(f"{OLLAMA_URL}/api/delete", json={"name": name}) + resp = await client.request( + "DELETE", f"{OLLAMA_URL}/api/delete", json={"name": name} + ) return resp.status_code == 200 except (httpx.ConnectError, httpx.TimeoutException, httpx.HTTPError): return False diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/model_item.py b/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/model_item.py index c5eb2f4..ccb5a0d 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/model_item.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/model_item.py @@ -35,9 +35,11 @@ def compose(self) -> ComposeResult: yield Static("○ ready", classes="model-status-cached") load_btn = Button("Load", name=self._model_name, classes="model-load") unload_btn = Button("Unload", name=self._model_name, classes="model-unload") + delete_btn = Button("Delete", name=self._model_name, classes="model-delete") if self._loaded: load_btn.disabled = True else: unload_btn.disabled = True yield load_btn yield unload_btn + yield delete_btn From ff34cab7fd56c66e075fdbec41205f1df401b4d7 Mon Sep 17 00:00:00 2001 From: eshork <1829176+eshork@users.noreply.github.com> Date: Fri, 24 Apr 2026 00:29:43 -0400 Subject: [PATCH 08/32] Harden wizard finalization, add --wizard flag, and Enter-to-pull - Gate sentinel write behind errors check: sentinel is only written after config.save() and all prior writes succeed, preventing the wizard from being silently skipped after partial failures - Guard partition detection: reject if lsblk returns base device instead of new partition, preventing accidental whole-disk format - Add --wizard CLI flag to force wizard rerun on demand - Add on_input_submitted to ModelsScreen so Enter in the pull-input field triggers model download --- .../usr/lib/neuraldrive/tui/main.py | 15 +++++++++++-- .../usr/lib/neuraldrive/tui/screens/models.py | 6 +++++ .../usr/lib/neuraldrive/tui/screens/wizard.py | 22 +++++++++++++------ 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/main.py b/config/includes.chroot/usr/lib/neuraldrive/tui/main.py index 663595a..ba4b475 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/main.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/main.py @@ -9,6 +9,7 @@ from screens.chat import ChatScreen from screens.wizard import FirstBootWizard +import argparse import os import sys import traceback @@ -88,10 +89,14 @@ class NeuralDriveTUI(App): "chat": ChatScreen, } + def __init__(self, force_wizard: bool = False) -> None: + super().__init__() + self._force_wizard = force_wizard + def on_mount(self) -> None: self.push_screen(DashboardScreen()) sentinel_exists = os.path.exists("/etc/neuraldrive/first-boot-complete") - if not sentinel_exists and not config.wizard_complete(): + if self._force_wizard or (not sentinel_exists and not config.wizard_complete()): self.push_screen(FirstBootWizard()) def _handle_exception(self, error: Exception) -> None: @@ -112,10 +117,16 @@ def action_switch_screen(self, screen_name: str) -> None: if __name__ == "__main__": + parser = argparse.ArgumentParser(description="NeuralDrive TUI") + parser.add_argument( + "--wizard", action="store_true", help="Force the first-boot wizard to run" + ) + args = parser.parse_args() + screenshot_dir = _screenshot_dir() os.environ["TEXTUAL_SCREENSHOT_LOCATION"] = screenshot_dir try: - app = NeuralDriveTUI() + app = NeuralDriveTUI(force_wizard=args.wizard) app.run(mouse=False) except Exception as exc: dump_path = _write_crash_dump(exc) diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py index a80800b..6d54142 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py @@ -331,6 +331,12 @@ async def on_button_pressed(self, event: Button.Pressed) -> None: elif btn.has_class("model-delete"): self._delete_model(btn.name or "") + def on_input_submitted(self, event: Input.Submitted) -> None: + if event.input.id == "pull-input": + name = event.input.value.strip() + if name: + self._start_pull(name) + def _cancel_pull(self) -> None: self._pull_queue.clear() self.workers.cancel_group(self, "default") diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/wizard.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/wizard.py index b2176b4..1a8d704 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/wizard.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/wizard.py @@ -346,6 +346,9 @@ def _create_persistence_partition(self) -> str | None: return "No partitions found after creation" new_part = f"/dev/{parts[-1].strip()}" + if new_part == self._boot_device: + return "Could not identify new partition (got base device)" + proc = subprocess.run( [ "sudo", @@ -555,9 +558,17 @@ def _finalize(self) -> None: if cfg_err: errors.append(cfg_err) + if errors: + error_widget = self.query_one("#wiz-error", Static) + error_widget.update("\n".join(errors)) + return + + # All config writes succeeded — now write sentinel and strip NOPASSWD err = self._sudo_write(SENTINEL, "") if err: - errors.append(err) + error_widget = self.query_one("#wiz-error", Static) + error_widget.update(f"Failed to write sentinel: {err}") + return # Remove NOPASSWD LAST — after all other sudo operations are done, # since removing it makes subsequent sudo calls require a TTY password prompt @@ -573,15 +584,12 @@ def _finalize(self) -> None: new_content = result.stdout.replace("NOPASSWD:", "") err = self._sudo_write(SUDOERS_PATH, new_content, "0440") if err: - errors.append(err) + # Sudoers strip failed but sentinel+config are written — + # wizard is complete, just warn + pass except (subprocess.TimeoutExpired, FileNotFoundError): pass - if errors: - error_widget = self.query_one("#wiz-error", Static) - error_widget.update("\n".join(errors)) - return - self.app.pop_screen() def action_cancel_wizard(self) -> None: From fbac7b67d9c0b2944ac6ef8919262f4a89df0ef3 Mon Sep 17 00:00:00 2001 From: eshork <1829176+eshork@users.noreply.github.com> Date: Fri, 24 Apr 2026 00:42:31 -0400 Subject: [PATCH 09/32] Harden partition detection, wizard source of truth, and subprocess error checking - Launcher now forwards "$@" so neuraldrive-tui --wizard works - Partition detection uses before/after diff instead of fragile last-line - Wizard completion uses sentinel file as single source of truth - config.save() and wizard._sudo_write() check all subprocess return codes --- .../hooks/live/04-install-python-apps.chroot | 2 +- .../usr/lib/neuraldrive/tui/main.py | 4 +- .../usr/lib/neuraldrive/tui/screens/wizard.py | 52 +++++++++++++++---- .../usr/lib/neuraldrive/tui/utils/config.py | 8 ++- 4 files changed, 50 insertions(+), 16 deletions(-) diff --git a/config/hooks/live/04-install-python-apps.chroot b/config/hooks/live/04-install-python-apps.chroot index 80605e4..853fd27 100755 --- a/config/hooks/live/04-install-python-apps.chroot +++ b/config/hooks/live/04-install-python-apps.chroot @@ -122,7 +122,7 @@ python3 -m venv /usr/lib/neuraldrive/tui/venv cat > /usr/local/bin/neuraldrive-tui << 'LAUNCHER' #!/bin/sh -exec /usr/lib/neuraldrive/tui/venv/bin/python /usr/lib/neuraldrive/tui/main.py +exec /usr/lib/neuraldrive/tui/venv/bin/python /usr/lib/neuraldrive/tui/main.py "$@" LAUNCHER chmod +x /usr/local/bin/neuraldrive-tui diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/main.py b/config/includes.chroot/usr/lib/neuraldrive/tui/main.py index ba4b475..27ea2c1 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/main.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/main.py @@ -15,8 +15,6 @@ import traceback from datetime import datetime -from utils import config - PERSIST_DIR = "/var/lib/neuraldrive" OVERLAY_LOG_DIR = "/var/log/neuraldrive" @@ -96,7 +94,7 @@ def __init__(self, force_wizard: bool = False) -> None: def on_mount(self) -> None: self.push_screen(DashboardScreen()) sentinel_exists = os.path.exists("/etc/neuraldrive/first-boot-complete") - if self._force_wizard or (not sentinel_exists and not config.wizard_complete()): + if self._force_wizard or not sentinel_exists: self.push_screen(FirstBootWizard()) def _handle_exception(self, error: Exception) -> None: diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/wizard.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/wizard.py index 1a8d704..3d580ce 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/wizard.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/wizard.py @@ -323,6 +323,21 @@ def _create_persistence_partition(self) -> str | None: if proc.returncode != 0: return proc.stderr.strip() + # Snapshot partition list BEFORE partprobe to detect the new one + pre_res = subprocess.run( + ["lsblk", "-ln", "-o", "NAME", self._boot_device], + capture_output=True, + text=True, + timeout=5, + ) + before_parts = set() + if pre_res.returncode == 0: + before_parts = { + line.strip() + for line in pre_res.stdout.strip().splitlines() + if line.strip() + } + subprocess.run( ["sudo", "partprobe", self._boot_device], capture_output=True, @@ -333,21 +348,34 @@ def _create_persistence_partition(self) -> str | None: time.sleep(2) - res = subprocess.run( + # Snapshot partition list AFTER partprobe + post_res = subprocess.run( ["lsblk", "-ln", "-o", "NAME", self._boot_device], capture_output=True, text=True, timeout=5, ) - if res.returncode != 0: + if post_res.returncode != 0: return "Could not determine new partition device" - parts = res.stdout.strip().splitlines() - if not parts: - return "No partitions found after creation" - new_part = f"/dev/{parts[-1].strip()}" - if new_part == self._boot_device: - return "Could not identify new partition (got base device)" + after_parts = { + line.strip() + for line in post_res.stdout.strip().splitlines() + if line.strip() + } + + new_parts = after_parts - before_parts + # Filter out the base device name itself + base_name = os.path.basename(self._boot_device) + new_parts.discard(base_name) + + if len(new_parts) != 1: + return ( + f"Expected exactly 1 new partition, found {len(new_parts)}: " + f"{new_parts or 'none'}" + ) + + new_part = f"/dev/{new_parts.pop()}" proc = subprocess.run( [ @@ -482,11 +510,13 @@ def _configure_wifi(self) -> None: def _sudo_write(self, path: str, content: str, mode: str = "0644") -> str | None: try: - subprocess.run( + mkdir_proc = subprocess.run( ["sudo", "mkdir", "-p", os.path.dirname(path)], capture_output=True, timeout=5, ) + if mkdir_proc.returncode != 0: + return f"Failed to create dir for {path}: {mkdir_proc.stderr.decode().strip()}" proc = subprocess.run( ["sudo", "tee", path], input=content.encode(), @@ -495,11 +525,13 @@ def _sudo_write(self, path: str, content: str, mode: str = "0644") -> str | None ) if proc.returncode != 0: return f"Failed to write {path}: {proc.stderr.decode().strip()}" - subprocess.run( + chmod_proc = subprocess.run( ["sudo", "chmod", mode, path], capture_output=True, timeout=5, ) + if chmod_proc.returncode != 0: + return f"Failed to chmod {path}: {chmod_proc.stderr.decode().strip()}" return None except (subprocess.TimeoutExpired, FileNotFoundError) as e: return f"Failed to write {path}: {e}" diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/utils/config.py b/config/includes.chroot/usr/lib/neuraldrive/tui/utils/config.py index 4e7cd8e..1a6e6ea 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/utils/config.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/utils/config.py @@ -34,11 +34,13 @@ def save(data: dict[str, Any]) -> str | None: path = _config_path() content = yaml.dump(data, default_flow_style=False, sort_keys=False) try: - subprocess.run( + mkdir_proc = subprocess.run( ["sudo", "mkdir", "-p", os.path.dirname(path)], capture_output=True, timeout=5, ) + if mkdir_proc.returncode != 0: + return f"Failed to create dir for {path}: {mkdir_proc.stderr.decode().strip()}" proc = subprocess.run( ["sudo", "tee", path], input=content.encode(), @@ -47,11 +49,13 @@ def save(data: dict[str, Any]) -> str | None: ) if proc.returncode != 0: return f"Failed to write {path}: {proc.stderr.decode().strip()}" - subprocess.run( + chmod_proc = subprocess.run( ["sudo", "chmod", "0644", path], capture_output=True, timeout=5, ) + if chmod_proc.returncode != 0: + return f"Failed to chmod {path}: {chmod_proc.stderr.decode().strip()}" return None except (subprocess.TimeoutExpired, FileNotFoundError) as e: return f"Failed to write {path}: {e}" From 6efe83a1733b0c4e9ec8a550a5ea5e42b9525f28 Mon Sep 17 00:00:00 2001 From: eshork <1829176+eshork@users.noreply.github.com> Date: Fri, 24 Apr 2026 00:44:42 -0400 Subject: [PATCH 10/32] Move partition snapshot before mkpart to prevent race condition lsblk before-snapshot was taken after mkpart, which could show the new partition if the kernel auto-detected the table change. Snapshot now taken before mkpart so the diff is always reliable. --- .../usr/lib/neuraldrive/tui/screens/wizard.py | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/wizard.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/wizard.py index 3d580ce..66ed5f2 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/wizard.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/wizard.py @@ -303,6 +303,21 @@ def _create_persistence_partition(self) -> str | None: if not free_start or not free_end: return "No free space block large enough found" + # Snapshot partition list BEFORE mkpart so the diff is reliable + pre_res = subprocess.run( + ["lsblk", "-ln", "-o", "NAME", self._boot_device], + capture_output=True, + text=True, + timeout=5, + ) + before_parts = set() + if pre_res.returncode == 0: + before_parts = { + line.strip() + for line in pre_res.stdout.strip().splitlines() + if line.strip() + } + proc = subprocess.run( [ "sudo", @@ -323,21 +338,6 @@ def _create_persistence_partition(self) -> str | None: if proc.returncode != 0: return proc.stderr.strip() - # Snapshot partition list BEFORE partprobe to detect the new one - pre_res = subprocess.run( - ["lsblk", "-ln", "-o", "NAME", self._boot_device], - capture_output=True, - text=True, - timeout=5, - ) - before_parts = set() - if pre_res.returncode == 0: - before_parts = { - line.strip() - for line in pre_res.stdout.strip().splitlines() - if line.strip() - } - subprocess.run( ["sudo", "partprobe", self._boot_device], capture_output=True, From 5534f54cb56e8830dad8cb08b4d64b1798e1b944 Mon Sep 17 00:00:00 2001 From: eshork <1829176+eshork@users.noreply.github.com> Date: Fri, 24 Apr 2026 00:52:37 -0400 Subject: [PATCH 11/32] Harden partition creation safety and boot device detection - Abort before mkpart if pre-lsblk snapshot fails (no disk mutation without a valid baseline) - Check partprobe return code; poll lsblk with bounded retry loop instead of fixed sleep(2) - Replace fragile regex in get_boot_device() with lsblk PKNAME (supports NVMe, MMC, and sd devices) - Guard Enter-to-pull against re-submission during active download --- .../usr/lib/neuraldrive/tui/screens/models.py | 2 +- .../usr/lib/neuraldrive/tui/screens/wizard.py | 69 ++++++++++--------- .../usr/lib/neuraldrive/tui/utils/hardware.py | 13 ++-- 3 files changed, 44 insertions(+), 40 deletions(-) diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py index 6d54142..bdfd37f 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py @@ -332,7 +332,7 @@ async def on_button_pressed(self, event: Button.Pressed) -> None: self._delete_model(btn.name or "") def on_input_submitted(self, event: Input.Submitted) -> None: - if event.input.id == "pull-input": + if event.input.id == "pull-input" and not self._pulling: name = event.input.value.strip() if name: self._start_pull(name) diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/wizard.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/wizard.py index 66ed5f2..b169556 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/wizard.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/wizard.py @@ -310,13 +310,13 @@ def _create_persistence_partition(self) -> str | None: text=True, timeout=5, ) - before_parts = set() - if pre_res.returncode == 0: - before_parts = { - line.strip() - for line in pre_res.stdout.strip().splitlines() - if line.strip() - } + if pre_res.returncode != 0: + return "Cannot list partitions — aborting to avoid unsafe disk changes" + before_parts = { + line.strip() + for line in pre_res.stdout.strip().splitlines() + if line.strip() + } proc = subprocess.run( [ @@ -338,44 +338,45 @@ def _create_persistence_partition(self) -> str | None: if proc.returncode != 0: return proc.stderr.strip() - subprocess.run( + partprobe_proc = subprocess.run( ["sudo", "partprobe", self._boot_device], capture_output=True, + text=True, timeout=10, ) + if partprobe_proc.returncode != 0: + return f"partprobe failed: {partprobe_proc.stderr.strip()}" import time - time.sleep(2) - - # Snapshot partition list AFTER partprobe - post_res = subprocess.run( - ["lsblk", "-ln", "-o", "NAME", self._boot_device], - capture_output=True, - text=True, - timeout=5, - ) - if post_res.returncode != 0: - return "Could not determine new partition device" + new_part = None + for _attempt in range(6): + time.sleep(1) + post_res = subprocess.run( + ["lsblk", "-ln", "-o", "NAME", self._boot_device], + capture_output=True, + text=True, + timeout=5, + ) + if post_res.returncode != 0: + continue - after_parts = { - line.strip() - for line in post_res.stdout.strip().splitlines() - if line.strip() - } + after_parts = { + line.strip() + for line in post_res.stdout.strip().splitlines() + if line.strip() + } - new_parts = after_parts - before_parts - # Filter out the base device name itself - base_name = os.path.basename(self._boot_device) - new_parts.discard(base_name) + new_parts = after_parts - before_parts + base_name = os.path.basename(self._boot_device) + new_parts.discard(base_name) - if len(new_parts) != 1: - return ( - f"Expected exactly 1 new partition, found {len(new_parts)}: " - f"{new_parts or 'none'}" - ) + if len(new_parts) == 1: + new_part = f"/dev/{new_parts.pop()}" + break - new_part = f"/dev/{new_parts.pop()}" + if not new_part: + return "New partition did not appear after partprobe (timed out)" proc = subprocess.run( [ diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/utils/hardware.py b/config/includes.chroot/usr/lib/neuraldrive/tui/utils/hardware.py index e1f3918..b3e599f 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/utils/hardware.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/utils/hardware.py @@ -166,11 +166,14 @@ def get_boot_device() -> str | None: ) if res.returncode == 0 and res.stdout.strip(): part_dev = res.stdout.strip() - import re - - match = re.match(r"(/dev/[a-z]+)", part_dev) - if match: - return match.group(1) + pkname_res = subprocess.run( + ["lsblk", "-no", "PKNAME", part_dev], + capture_output=True, + text=True, + timeout=5, + ) + if pkname_res.returncode == 0 and pkname_res.stdout.strip(): + return f"/dev/{pkname_res.stdout.strip()}" except (OSError, subprocess.TimeoutExpired, FileNotFoundError): pass return None From c0e802c167258ac487c953c698f5545fae736278 Mon Sep 17 00:00:00 2001 From: eshork <1829176+eshork@users.noreply.github.com> Date: Fri, 24 Apr 2026 00:56:17 -0400 Subject: [PATCH 12/32] Guard pull button and Enter against concurrent submissions Set _pulling=True immediately in both user-facing entry points before scheduling the @work worker, closing the race window. Pull button handler now mirrors the Enter-to-pull guard. --- .../includes.chroot/usr/lib/neuraldrive/tui/screens/models.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py index bdfd37f..c7c61a6 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py @@ -317,7 +317,8 @@ async def on_button_pressed(self, event: Button.Pressed) -> None: btn_id = btn.id or "" if btn_id == "pull-btn": name = self.query_one("#pull-input", Input).value.strip() - if name: + if name and not self._pulling: + self._pulling = True self._start_pull(name) elif btn_id == "open-catalog": installed = {m.get("name", "") for m in await api_client.list_models()} @@ -335,6 +336,7 @@ def on_input_submitted(self, event: Input.Submitted) -> None: if event.input.id == "pull-input" and not self._pulling: name = event.input.value.strip() if name: + self._pulling = True self._start_pull(name) def _cancel_pull(self) -> None: From b0d8a88bd7a80645af171daa1883ab6648dfd19b Mon Sep 17 00:00:00 2001 From: eshork <1829176+eshork@users.noreply.github.com> Date: Fri, 24 Apr 2026 01:10:57 -0400 Subject: [PATCH 13/32] Remove dual wizard marker, check all subprocess returns, normalize live-media path, guard _pull_next - Remove wizard_complete config key write from wizard finalize; sentinel file is now the single source of truth for wizard completion - Remove unused wizard_complete() function from config.py - Check return codes for all subprocess calls in partition creation: mkdir, chown, umount, systemctl (warning-only for restart) - Normalize live-media= cmdline path through lsblk PKNAME for NVMe/MMC - Set _pulling=True in _pull_next() before _start_pull() to prevent concurrent pull submissions from all entry points --- .../usr/lib/neuraldrive/tui/screens/models.py | 1 + .../usr/lib/neuraldrive/tui/screens/wizard.py | 33 +++++++++++++++---- .../usr/lib/neuraldrive/tui/utils/config.py | 4 --- .../usr/lib/neuraldrive/tui/utils/hardware.py | 11 ++++++- 4 files changed, 37 insertions(+), 12 deletions(-) diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py index c7c61a6..83c8bce 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py @@ -361,6 +361,7 @@ def _pull_next(self) -> None: self.app.call_later(self._load_models) return model_name = self._pull_queue.pop(0) + self._pulling = True self._start_pull(model_name) @work(exclusive=True) diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/wizard.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/wizard.py index b169556..3812e4b 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/wizard.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/wizard.py @@ -395,11 +395,14 @@ def _create_persistence_partition(self) -> str | None: if proc.returncode != 0: return f"mkfs.ext4 failed: {proc.stderr.strip()}" - subprocess.run( + proc = subprocess.run( ["sudo", "mkdir", "-p", "/mnt/persistence"], capture_output=True, + text=True, timeout=5, ) + if proc.returncode != 0: + return f"mkdir /mnt/persistence failed: {proc.stderr.strip()}" proc = subprocess.run( ["sudo", "mount", new_part, "/mnt/persistence"], capture_output=True, @@ -426,13 +429,16 @@ def _create_persistence_partition(self) -> str | None: "/mnt/persistence/etc/neuraldrive", "/mnt/persistence/home", ]: - subprocess.run( + proc = subprocess.run( ["sudo", "mkdir", "-p", d], capture_output=True, + text=True, timeout=5, ) + if proc.returncode != 0: + return f"mkdir {d} failed: {proc.stderr.strip()}" - subprocess.run( + proc = subprocess.run( [ "sudo", "chown", @@ -441,20 +447,29 @@ def _create_persistence_partition(self) -> str | None: "/mnt/persistence/var/lib/neuraldrive/ollama", ], capture_output=True, + text=True, timeout=5, ) + if proc.returncode != 0: + return f"chown failed: {proc.stderr.strip()}" - subprocess.run( + proc = subprocess.run( ["sudo", "umount", "/mnt/persistence"], capture_output=True, + text=True, timeout=10, ) + if proc.returncode != 0: + return f"umount /mnt/persistence failed: {proc.stderr.strip()}" - subprocess.run( + proc = subprocess.run( ["sudo", "mkdir", "-p", PERSISTENCE_MOUNT], capture_output=True, + text=True, timeout=5, ) + if proc.returncode != 0: + return f"mkdir {PERSISTENCE_MOUNT} failed: {proc.stderr.strip()}" proc = subprocess.run( ["sudo", "mount", new_part, PERSISTENCE_MOUNT], capture_output=True, @@ -464,11 +479,16 @@ def _create_persistence_partition(self) -> str | None: if proc.returncode != 0: return f"Mount at {PERSISTENCE_MOUNT} failed: {proc.stderr.strip()}" - subprocess.run( + proc = subprocess.run( ["sudo", "systemctl", "restart", "neuraldrive-ollama"], capture_output=True, + text=True, timeout=30, ) + if proc.returncode != 0: + self.query_one("#wiz-error", Static).update( + f"Warning: Ollama restart failed: {proc.stderr.strip()}" + ) self._has_persistence = True return None @@ -578,7 +598,6 @@ def _finalize(self) -> None: errors.append(err) cfg_data = config.load() - cfg_data["wizard_complete"] = True if self._admin_password: cfg_data["security"] = {"password_set": True} if self._wifi_ssid: diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/utils/config.py b/config/includes.chroot/usr/lib/neuraldrive/tui/utils/config.py index 1a6e6ea..b6a5496 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/utils/config.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/utils/config.py @@ -82,7 +82,3 @@ def set_key(key: str, value: Any) -> str | None: target = target[k] target[keys[-1]] = value return save(data) - - -def wizard_complete() -> bool: - return get("wizard_complete", False) is True diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/utils/hardware.py b/config/includes.chroot/usr/lib/neuraldrive/tui/utils/hardware.py index b3e599f..2acceb1 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/utils/hardware.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/utils/hardware.py @@ -157,7 +157,16 @@ def get_boot_device() -> str | None: if part.startswith("boot=live") or part.startswith("root="): pass if part.startswith("live-media="): - return part.split("=", 1)[1] + media_dev = part.split("=", 1)[1] + pkname_res = subprocess.run( + ["lsblk", "-no", "PKNAME", media_dev], + capture_output=True, + text=True, + timeout=5, + ) + if pkname_res.returncode == 0 and pkname_res.stdout.strip(): + return f"/dev/{pkname_res.stdout.strip()}" + return media_dev res = subprocess.run( ["findmnt", "-n", "-o", "SOURCE", "/run/live/medium"], capture_output=True, From 64a95148525d9eaec1a60d35c86a6e2082352b3f Mon Sep 17 00:00:00 2001 From: eshork <1829176+eshork@users.noreply.github.com> Date: Fri, 24 Apr 2026 01:15:39 -0400 Subject: [PATCH 14/32] Fall through to findmnt when live-media PKNAME fails Instead of returning the raw live-media= partition path when lsblk PKNAME resolution fails, fall through to the findmnt detection path. This prevents handing an unvalidated partition/symlink path to the storage wizard for partition creation. --- .../includes.chroot/usr/lib/neuraldrive/tui/utils/hardware.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/utils/hardware.py b/config/includes.chroot/usr/lib/neuraldrive/tui/utils/hardware.py index 2acceb1..5556e86 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/utils/hardware.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/utils/hardware.py @@ -166,7 +166,8 @@ def get_boot_device() -> str | None: ) if pkname_res.returncode == 0 and pkname_res.stdout.strip(): return f"/dev/{pkname_res.stdout.strip()}" - return media_dev + # PKNAME failed — fall through to findmnt instead of + # returning an unvalidated partition/symlink path. res = subprocess.run( ["findmnt", "-n", "-o", "SOURCE", "/run/live/medium"], capture_output=True, From b1003b1d44745907523d293bd5026962d14cc897 Mon Sep 17 00:00:00 2001 From: eshork <1829176+eshork@users.noreply.github.com> Date: Fri, 24 Apr 2026 08:29:49 -0400 Subject: [PATCH 15/32] Fix Header crash on screen transitions and simplify --wizard flag Replace Textual's Header with SafeHeader subclass that catches NoMatches during title watcher updates. Textual 8.2.4 only catches NoScreen in the set_title watcher but not NoMatches, causing crashes when screens are pushed/popped and HeaderTitle hasn't recomposed yet. This is a known upstream bug (Textualize/textual#4258, PR #4817). Simplify --wizard: instead of a separate force_wizard constructor flag, --wizard now removes the sentinel file before launch so the existing on_mount check triggers the wizard naturally. --- .../usr/lib/neuraldrive/tui/main.py | 15 +++++++------ .../usr/lib/neuraldrive/tui/screens/chat.py | 6 +++-- .../lib/neuraldrive/tui/screens/dashboard.py | 6 +++-- .../usr/lib/neuraldrive/tui/screens/logs.py | 6 +++-- .../usr/lib/neuraldrive/tui/screens/models.py | 8 ++++--- .../lib/neuraldrive/tui/screens/network.py | 6 +++-- .../lib/neuraldrive/tui/screens/services.py | 6 +++-- .../lib/neuraldrive/tui/widgets/__init__.py | 3 ++- .../neuraldrive/tui/widgets/safe_header.py | 22 +++++++++++++++++++ 9 files changed, 57 insertions(+), 21 deletions(-) create mode 100644 config/includes.chroot/usr/lib/neuraldrive/tui/widgets/safe_header.py diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/main.py b/config/includes.chroot/usr/lib/neuraldrive/tui/main.py index 27ea2c1..ede4187 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/main.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/main.py @@ -11,6 +11,7 @@ import argparse import os +import subprocess import sys import traceback from datetime import datetime @@ -87,14 +88,9 @@ class NeuralDriveTUI(App): "chat": ChatScreen, } - def __init__(self, force_wizard: bool = False) -> None: - super().__init__() - self._force_wizard = force_wizard - def on_mount(self) -> None: self.push_screen(DashboardScreen()) - sentinel_exists = os.path.exists("/etc/neuraldrive/first-boot-complete") - if self._force_wizard or not sentinel_exists: + if not os.path.exists("/etc/neuraldrive/first-boot-complete"): self.push_screen(FirstBootWizard()) def _handle_exception(self, error: Exception) -> None: @@ -121,10 +117,15 @@ def action_switch_screen(self, screen_name: str) -> None: ) args = parser.parse_args() + if args.wizard: + sentinel = "/etc/neuraldrive/first-boot-complete" + if os.path.exists(sentinel): + subprocess.run(["sudo", "rm", "-f", sentinel], timeout=5) + screenshot_dir = _screenshot_dir() os.environ["TEXTUAL_SCREENSHOT_LOCATION"] = screenshot_dir try: - app = NeuralDriveTUI(force_wizard=args.wizard) + app = NeuralDriveTUI() app.run(mouse=False) except Exception as exc: dump_path = _write_crash_dump(exc) diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/chat.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/chat.py index 621bbdd..62cb60d 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/chat.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/chat.py @@ -6,7 +6,9 @@ from textual.app import ComposeResult from textual.containers import Horizontal from textual.screen import Screen -from textual.widgets import Button, Footer, Header, Input, RichLog, Select, Static +from textual.widgets import Button, Footer, Input, RichLog, Select, Static + +from widgets.safe_header import SafeHeader from utils import api_client @@ -19,7 +21,7 @@ def __init__(self) -> None: self._messages: list[dict] = [] def compose(self) -> ComposeResult: - yield Header() + yield SafeHeader() with Horizontal(id="chat-model-row"): yield Static(" Model ", id="chat-model-label") yield Select([], id="chat-model-select", prompt="Choose a model…") diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/dashboard.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/dashboard.py index f33e49c..0bbcaec 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/dashboard.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/dashboard.py @@ -5,7 +5,9 @@ from textual.app import ComposeResult from textual.containers import Horizontal, Vertical, VerticalScroll from textual.screen import Screen -from textual.widgets import Footer, Header, Static +from textual.widgets import Footer, Static + +from widgets.safe_header import SafeHeader from utils import api_client, hardware from widgets.stats_box import StatsBox @@ -15,7 +17,7 @@ class DashboardScreen(Screen): BINDINGS = [("r", "refresh", "Refresh")] def compose(self) -> ComposeResult: - yield Header() + yield SafeHeader() with VerticalScroll(): with Horizontal(id="dash-topbar"): yield Static("", id="dash-hostname") diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/logs.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/logs.py index 7be5812..fdf368d 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/logs.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/logs.py @@ -5,7 +5,9 @@ from textual.app import ComposeResult from textual.containers import Horizontal from textual.screen import Screen -from textual.widgets import Footer, Header, RichLog, Select, Static +from textual.widgets import Footer, RichLog, Select, Static + +from widgets.safe_header import SafeHeader from utils import hardware @@ -19,7 +21,7 @@ class LogsScreen(Screen): BINDINGS = [("r", "refresh", "Refresh")] def compose(self) -> ComposeResult: - yield Header() + yield SafeHeader() with Horizontal(): yield Static(" Service: ", classes="label") yield Select( diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py index 83c8bce..d8c6772 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py @@ -7,7 +7,9 @@ from textual.app import ComposeResult from textual.containers import Horizontal, Vertical, VerticalScroll from textual.screen import Screen -from textual.widgets import Button, Footer, Header, Input, ProgressBar, Static +from textual.widgets import Button, Footer, Input, ProgressBar, Static + +from widgets.safe_header import SafeHeader from textual.binding import Binding @@ -80,7 +82,7 @@ def __init__(self, installed_names: set[str]) -> None: self._zone = "list" def compose(self) -> ComposeResult: - yield Header() + yield SafeHeader() yield Static( " ↑↓ Navigate Enter Select Tab Actions Esc Back", classes="muted" ) @@ -236,7 +238,7 @@ class ModelsScreen(Screen): BINDINGS = [("r", "refresh", "Refresh")] def compose(self) -> ComposeResult: - yield Header() + yield SafeHeader() with VerticalScroll(): yield Static("Installed Models", classes="heading") yield Vertical(id="model-list") diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/network.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/network.py index c12a8a2..bf39f04 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/network.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/network.py @@ -5,7 +5,9 @@ from textual.app import ComposeResult from textual.containers import Vertical, VerticalScroll from textual.screen import Screen -from textual.widgets import Footer, Header, Static +from textual.widgets import Footer, Static + +from widgets.safe_header import SafeHeader from utils import hardware @@ -14,7 +16,7 @@ class NetworkScreen(Screen): BINDINGS = [("r", "refresh", "Refresh")] def compose(self) -> ComposeResult: - yield Header() + yield SafeHeader() with VerticalScroll(): yield Static("Network Configuration", classes="heading") yield Static("", id="net-hostname") diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/services.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/services.py index ddca6d3..d4cda6c 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/services.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/services.py @@ -6,7 +6,9 @@ from textual.app import ComposeResult from textual.containers import Horizontal, Vertical, VerticalScroll from textual.screen import Screen -from textual.widgets import Button, Footer, Header, Static +from textual.widgets import Button, Footer, Static + +from widgets.safe_header import SafeHeader from textual.binding import Binding @@ -21,7 +23,7 @@ class ServicesScreen(Screen): ] def compose(self) -> ComposeResult: - yield Header() + yield SafeHeader() with VerticalScroll(): yield Static("NeuralDrive Services", classes="heading") yield Vertical(id="service-list") diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/__init__.py b/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/__init__.py index 3ae5b2f..10a5e57 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/__init__.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/__init__.py @@ -1,4 +1,5 @@ from widgets.stats_box import StatsBox from widgets.model_item import ModelItem +from widgets.safe_header import SafeHeader -__all__ = ["StatsBox", "ModelItem"] +__all__ = ["StatsBox", "ModelItem", "SafeHeader"] diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/safe_header.py b/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/safe_header.py new file mode 100644 index 0000000..e04ecc8 --- /dev/null +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/safe_header.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from textual.css.query import NoMatches +from textual.widgets import Header +from textual.widgets._header import HeaderTitle + + +class SafeHeader(Header): + + def _on_mount(self, event) -> None: + original_set_title = None + + async def safe_set_title() -> None: + try: + self.query_one(HeaderTitle).update(self.format_title()) + except (NoMatches, Exception): + pass + + self.watch(self.app, "title", safe_set_title) + self.watch(self.app, "sub_title", safe_set_title) + self.watch(self.screen, "title", safe_set_title) + self.watch(self.screen, "sub_title", safe_set_title) From 493fe4ea59a83cc397545fd9064da7be70eaff01 Mon Sep 17 00:00:00 2001 From: eshork <1829176+eshork@users.noreply.github.com> Date: Fri, 24 Apr 2026 08:42:34 -0400 Subject: [PATCH 16/32] Fix GPU acceleration: load nvidia-uvm at boot and remove cgroup device filter - Add ExecStartPre to load nvidia-current-uvm module and create /dev/nvidia-uvm device nodes before Ollama starts (with - prefix for non-fatal failure on non-NVIDIA systems) - Remove DeviceAllow lines that blocked CUDA access under cgroup v2 - Add nvidia-modprobe to NVIDIA package list for device node creation - Add /etc/modules-load.d/nvidia-uvm.conf for early boot module load - Show [GPU]/[CPU] tags with VRAM usage per model on dashboard --- .../etc/modules-load.d/nvidia-uvm.conf | 4 ++++ .../etc/systemd/system/neuraldrive-ollama.service | 4 ++-- .../usr/lib/neuraldrive/tui/screens/dashboard.py | 12 +++++++++--- config/package-lists/gpu-nvidia.list.chroot | 1 + 4 files changed, 16 insertions(+), 5 deletions(-) create mode 100644 config/includes.chroot/etc/modules-load.d/nvidia-uvm.conf diff --git a/config/includes.chroot/etc/modules-load.d/nvidia-uvm.conf b/config/includes.chroot/etc/modules-load.d/nvidia-uvm.conf new file mode 100644 index 0000000..1a5cb35 --- /dev/null +++ b/config/includes.chroot/etc/modules-load.d/nvidia-uvm.conf @@ -0,0 +1,4 @@ +# Load NVIDIA Unified Virtual Memory module at boot. +# Required for CUDA GPU memory allocation (Ollama inference). +# Harmless on systems without NVIDIA GPUs (modprobe fails silently). +nvidia-current-uvm diff --git a/config/includes.chroot/etc/systemd/system/neuraldrive-ollama.service b/config/includes.chroot/etc/systemd/system/neuraldrive-ollama.service index c7558bf..2029529 100644 --- a/config/includes.chroot/etc/systemd/system/neuraldrive-ollama.service +++ b/config/includes.chroot/etc/systemd/system/neuraldrive-ollama.service @@ -7,6 +7,8 @@ Requires=neuraldrive-gpu-detect.service Environment=HOME=/var/lib/neuraldrive/ollama EnvironmentFile=/etc/neuraldrive/ollama.conf ExecStartPre=/usr/bin/mkdir -p /var/lib/neuraldrive/models +ExecStartPre=-/sbin/modprobe nvidia-current-uvm +ExecStartPre=-/usr/bin/nvidia-modprobe -u ExecStart=/usr/local/bin/ollama serve User=neuraldrive-ollama Group=neuraldrive-ollama @@ -26,8 +28,6 @@ PrivateTmp=yes PrivateDevices=no ProtectKernelTunables=yes ProtectControlGroups=yes -DeviceAllow=/dev/nvidia* rw -DeviceAllow=/dev/dri/* rw ReadWritePaths=/var/lib/neuraldrive /var/log/neuraldrive /etc/neuraldrive /run/neuraldrive [Install] diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/dashboard.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/dashboard.py index 0bbcaec..66c4c5e 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/dashboard.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/dashboard.py @@ -31,7 +31,7 @@ def compose(self) -> ComposeResult: [("Device", "…"), ("VRAM", "…"), ("Temp", "…"), ("Util", "…")], id="box-gpu", ) - yield Static("Active Models (VRAM)", classes="heading") + yield Static("Active Models", classes="heading") yield Vertical(id="loaded-models") yield Static("Services", classes="heading") yield Vertical(id="service-badges") @@ -101,9 +101,15 @@ async def _refresh_models_async(self) -> None: else: for m in running: name = m.get("name", "unknown") + size_vram = m.get("size_vram", 0) size_bytes = m.get("size", 0) - size_gb = f"{size_bytes / (1024**3):.1f} GB" if size_bytes else "" - container.mount(Static(f" ● {name} {size_gb}", classes="ok")) + if size_vram and size_vram > 0: + vram_gb = f"{size_vram / (1024**3):.1f} GB" + tag = f"[GPU] {vram_gb}" + else: + ram_gb = f"{size_bytes / (1024**3):.1f} GB" if size_bytes else "" + tag = f"[CPU] {ram_gb}" + container.mount(Static(f" ● {name} {tag}", classes="ok")) def action_refresh(self) -> None: self._refresh_system() diff --git a/config/package-lists/gpu-nvidia.list.chroot b/config/package-lists/gpu-nvidia.list.chroot index 2276e64..5e76837 100644 --- a/config/package-lists/gpu-nvidia.list.chroot +++ b/config/package-lists/gpu-nvidia.list.chroot @@ -11,3 +11,4 @@ nvidia-persistenced firmware-nvidia-gsp libcuda1 libnvidia-ml1 +nvidia-modprobe From 5e1d3762dcc888d6261cad82054a7390e852d8af Mon Sep 17 00:00:00 2001 From: eshork <1829176+eshork@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:00:05 -0400 Subject: [PATCH 17/32] Escape Rich markup in [GPU]/[CPU] tags so they render visibly Rich interprets [GPU] and [CPU] as style tags and silently drops them. Escape with backslash-bracket on dashboard. Also change model_item status from 'VRAM' to 'GPU' for consistency. --- .../usr/lib/neuraldrive/tui/screens/dashboard.py | 4 ++-- .../usr/lib/neuraldrive/tui/widgets/model_item.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/dashboard.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/dashboard.py index 66c4c5e..28113c4 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/dashboard.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/dashboard.py @@ -105,10 +105,10 @@ async def _refresh_models_async(self) -> None: size_bytes = m.get("size", 0) if size_vram and size_vram > 0: vram_gb = f"{size_vram / (1024**3):.1f} GB" - tag = f"[GPU] {vram_gb}" + tag = f"\\[GPU] {vram_gb}" else: ram_gb = f"{size_bytes / (1024**3):.1f} GB" if size_bytes else "" - tag = f"[CPU] {ram_gb}" + tag = f"\\[CPU] {ram_gb}" container.mount(Static(f" ● {name} {tag}", classes="ok")) def action_refresh(self) -> None: diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/model_item.py b/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/model_item.py index ccb5a0d..ee38bd9 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/model_item.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/model_item.py @@ -30,7 +30,7 @@ def compose(self) -> ComposeResult: yield Static(self._model_size, classes="model-disk") yield Static(self._vram_str, classes="model-vram") if self._loaded: - yield Static("● VRAM", classes="model-status-loaded") + yield Static("● GPU", classes="model-status-loaded") else: yield Static("○ ready", classes="model-status-cached") load_btn = Button("Load", name=self._model_name, classes="model-load") From 5f8908e33ff771268bba5cc76d775d113ea6a8b8 Mon Sep 17 00:00:00 2001 From: eshork <1829176+eshork@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:04:47 -0400 Subject: [PATCH 18/32] Add arrow-key navigation with scroll-follow to installed models list Up/Down/PgUp/PgDn navigate between model items with a yellow highlight border. The scroll container follows the highlighted item via scroll_visible(), matching the catalog popup behavior. --- .../usr/lib/neuraldrive/tui/screens/models.py | 60 +++++++++++++++++-- .../usr/lib/neuraldrive/tui/styles.tcss | 11 ++++ 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py index d8c6772..e968587 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py @@ -235,11 +235,17 @@ def on_button_pressed(self, event: Button.Pressed) -> None: class ModelsScreen(Screen): - BINDINGS = [("r", "refresh", "Refresh")] + BINDINGS = [ + ("r", "refresh", "Refresh"), + Binding("up", "nav_up", show=False, priority=True), + Binding("down", "nav_down", show=False, priority=True), + Binding("pageup", "page_up", show=False, priority=True), + Binding("pagedown", "page_down", show=False, priority=True), + ] def compose(self) -> ComposeResult: yield SafeHeader() - with VerticalScroll(): + with VerticalScroll(id="models-scroll"): yield Static("Installed Models", classes="heading") yield Vertical(id="model-list") yield Static("", classes="heading") @@ -264,8 +270,45 @@ def on_mount(self) -> None: self.query_one("#cancel-pull", Button).display = False self._pull_queue: list[str] = [] self._pulling = False + self._model_items: list[ModelItem] = [] + self._highlight_index = 0 self.action_refresh() + def _apply_highlight(self) -> None: + for i, item in enumerate(self._model_items): + if i == self._highlight_index: + item.add_class("model-highlighted") + item.scroll_visible() + else: + item.remove_class("model-highlighted") + + def action_nav_up(self) -> None: + if self._model_items and self._highlight_index > 0: + self._highlight_index -= 1 + self._apply_highlight() + + def action_nav_down(self) -> None: + if self._model_items and self._highlight_index < len(self._model_items) - 1: + self._highlight_index += 1 + self._apply_highlight() + + def action_page_up(self) -> None: + if not self._model_items: + return + scroll = self.query_one("#models-scroll", VerticalScroll) + page_size = max(1, scroll.size.height // 6) + self._highlight_index = max(0, self._highlight_index - page_size) + self._apply_highlight() + + def action_page_down(self) -> None: + if not self._model_items: + return + scroll = self.query_one("#models-scroll", VerticalScroll) + page_size = max(1, scroll.size.height // 6) + last = len(self._model_items) - 1 + self._highlight_index = min(last, self._highlight_index + page_size) + self._apply_highlight() + def action_refresh(self) -> None: self.app.call_later(self._load_models) @@ -288,6 +331,7 @@ async def _load_models(self) -> None: container = self.query_one("#model-list", Vertical) container.remove_children() + self._model_items = [] if not all_models: container.mount(Static(" No models installed", classes="muted")) @@ -310,9 +354,15 @@ async def _load_models(self) -> None: else: vram_str = "—" - container.mount( - ModelItem(name, size_str, params, quant, vram_str, loaded) - ) + item = ModelItem(name, size_str, params, quant, vram_str, loaded) + container.mount(item) + self._model_items.append(item) + + if self._model_items: + self._highlight_index = min( + self._highlight_index, len(self._model_items) - 1 + ) + self._apply_highlight() async def on_button_pressed(self, event: Button.Pressed) -> None: btn = event.button diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss b/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss index daa6da0..49f1c0f 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss @@ -99,6 +99,11 @@ Static.muted { background: #1F1F1F; } +.model-highlighted { + background: #1F1F1F; + border: solid #F59E0B; +} + .model-item Static { height: 100%; content-align: left middle; @@ -454,3 +459,9 @@ Button.catalog-installed { #catalog-buttons Button { margin: 0 2; } + +#models-scroll { + height: 1fr; + scrollbar-background: #141414; + scrollbar-color: #2E2E2E; +} From b555d1f486bee7047353bee50e8cdb9e4443f38e Mon Sep 17 00:00:00 2001 From: eshork <1829176+eshork@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:26:59 -0400 Subject: [PATCH 19/32] Unify models screen focus: zone-based Tab, arrow-key list+button nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab cycles between zones: model list, Browse button, Pull input, Pull button. Within the model list zone, Up/Down navigates models with scroll-follow, Left/Right selects Load/Unload/Delete per model, Enter activates the selected button. All ModelItem buttons are non-focusable — navigation is fully managed by the screen. --- .../usr/lib/neuraldrive/tui/screens/models.py | 154 +++++++++++++++--- .../usr/lib/neuraldrive/tui/styles.tcss | 20 ++- .../lib/neuraldrive/tui/widgets/model_item.py | 9 + 3 files changed, 159 insertions(+), 24 deletions(-) diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py index e968587..927451a 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py @@ -5,7 +5,7 @@ from textual import work from textual.app import ComposeResult -from textual.containers import Horizontal, Vertical, VerticalScroll +from textual.containers import Horizontal, VerticalScroll from textual.screen import Screen from textual.widgets import Button, Footer, Input, ProgressBar, Static @@ -239,30 +239,35 @@ class ModelsScreen(Screen): ("r", "refresh", "Refresh"), Binding("up", "nav_up", show=False, priority=True), Binding("down", "nav_down", show=False, priority=True), + Binding("left", "nav_left", show=False, priority=True), + Binding("right", "nav_right", show=False, priority=True), Binding("pageup", "page_up", show=False, priority=True), Binding("pagedown", "page_down", show=False, priority=True), + Binding("enter", "activate", show=False, priority=True), + Binding("tab", "next_zone", show=False, priority=True), + Binding("shift+tab", "prev_zone", show=False, priority=True), ] + ZONES = ["models", "browse", "pull-input", "pull-btn"] + def compose(self) -> ComposeResult: yield SafeHeader() - with VerticalScroll(id="models-scroll"): - yield Static("Installed Models", classes="heading") - yield Vertical(id="model-list") - yield Static("", classes="heading") - yield Button( - "Browse Available Models", - id="open-catalog", - variant="primary", - classes="primary", - ) - yield Static("", classes="heading") - yield Static("Pull by Name", classes="heading") + yield Static("Installed Models", classes="heading") + yield VerticalScroll(id="model-list") + yield Button( + "Browse Available Models", + id="open-catalog", + variant="primary", + classes="primary", + ) + yield Static("Pull by Name", classes="heading") + with Horizontal(id="pull-input-row"): yield Input(placeholder="e.g. llama3:8b", id="pull-input") yield Button("Pull", id="pull-btn") - yield Static("", id="model-status") - with Horizontal(id="pull-row"): - yield ProgressBar(total=100, show_eta=True, id="pull-progress") - yield Button("Cancel", id="cancel-pull", variant="error") + yield Static("", id="model-status") + with Horizontal(id="pull-row"): + yield ProgressBar(total=100, show_eta=True, id="pull-progress") + yield Button("Cancel", id="cancel-pull", variant="error") yield Footer() def on_mount(self) -> None: @@ -272,43 +277,144 @@ def on_mount(self) -> None: self._pulling = False self._model_items: list[ModelItem] = [] self._highlight_index = 0 + self._btn_index = 0 + self._zone = "models" self.action_refresh() + # ── Zone management ────────────────────────────────────── + + def _enter_zone(self, zone: str) -> None: + self._zone = zone + if zone == "models": + self.set_focus(None) + self._apply_highlight() + elif zone == "browse": + self._clear_highlight() + self.query_one("#open-catalog", Button).focus() + elif zone == "pull-input": + self._clear_highlight() + self.query_one("#pull-input", Input).focus() + elif zone == "pull-btn": + self._clear_highlight() + self.query_one("#pull-btn", Button).focus() + + def action_next_zone(self) -> None: + idx = self.ZONES.index(self._zone) if self._zone in self.ZONES else 0 + idx = (idx + 1) % len(self.ZONES) + self._enter_zone(self.ZONES[idx]) + + def action_prev_zone(self) -> None: + idx = self.ZONES.index(self._zone) if self._zone in self.ZONES else 0 + idx = (idx - 1) % len(self.ZONES) + self._enter_zone(self.ZONES[idx]) + + # ── Model list highlight ───────────────────────────────── + def _apply_highlight(self) -> None: + self._clear_btn_highlight() for i, item in enumerate(self._model_items): if i == self._highlight_index: item.add_class("model-highlighted") item.scroll_visible() + self._apply_btn_highlight() else: item.remove_class("model-highlighted") + def _clear_highlight(self) -> None: + self._clear_btn_highlight() + for item in self._model_items: + item.remove_class("model-highlighted") + + # ── Per-model button highlight ─────────────────────────── + + def _get_active_buttons(self) -> list[Button]: + if not self._model_items: + return [] + item = self._model_items[self._highlight_index] + return item.get_action_buttons() + + def _apply_btn_highlight(self) -> None: + buttons = self._get_active_buttons() + if not buttons: + return + self._btn_index = max(0, min(self._btn_index, len(buttons) - 1)) + for i, btn in enumerate(buttons): + if i == self._btn_index: + btn.add_class("model-btn-active") + else: + btn.remove_class("model-btn-active") + + def _clear_btn_highlight(self) -> None: + for item in self._model_items: + for btn in item.get_action_buttons(): + btn.remove_class("model-btn-active") + + # ── Navigation actions ─────────────────────────────────── + def action_nav_up(self) -> None: + if self._zone != "models": + return if self._model_items and self._highlight_index > 0: self._highlight_index -= 1 self._apply_highlight() def action_nav_down(self) -> None: + if self._zone != "models": + return if self._model_items and self._highlight_index < len(self._model_items) - 1: self._highlight_index += 1 self._apply_highlight() + def action_nav_left(self) -> None: + if self._zone != "models": + return + if self._btn_index > 0: + self._btn_index -= 1 + self._apply_btn_highlight() + + def action_nav_right(self) -> None: + if self._zone != "models": + return + buttons = self._get_active_buttons() + if self._btn_index < len(buttons) - 1: + self._btn_index += 1 + self._apply_btn_highlight() + def action_page_up(self) -> None: - if not self._model_items: + if self._zone != "models" or not self._model_items: return - scroll = self.query_one("#models-scroll", VerticalScroll) + scroll = self.query_one("#model-list", VerticalScroll) page_size = max(1, scroll.size.height // 6) self._highlight_index = max(0, self._highlight_index - page_size) self._apply_highlight() def action_page_down(self) -> None: - if not self._model_items: + if self._zone != "models" or not self._model_items: return - scroll = self.query_one("#models-scroll", VerticalScroll) + scroll = self.query_one("#model-list", VerticalScroll) page_size = max(1, scroll.size.height // 6) last = len(self._model_items) - 1 self._highlight_index = min(last, self._highlight_index + page_size) self._apply_highlight() + def action_activate(self) -> None: + if self._zone == "models": + buttons = self._get_active_buttons() + if buttons and 0 <= self._btn_index < len(buttons): + btn = buttons[self._btn_index] + if not btn.disabled: + btn.press() + elif self._zone == "browse": + self.query_one("#open-catalog", Button).press() + elif self._zone == "pull-input": + inp = self.query_one("#pull-input", Input) + name = inp.value.strip() + if name and not self._pulling: + self._pulling = True + self._start_pull(name) + elif self._zone == "pull-btn": + self.query_one("#pull-btn", Button).press() + def action_refresh(self) -> None: self.app.call_later(self._load_models) @@ -329,7 +435,7 @@ async def _load_models(self) -> None: if cache_changed: config.set_key("vram_cache", vram_cache) - container = self.query_one("#model-list", Vertical) + container = self.query_one("#model-list", VerticalScroll) container.remove_children() self._model_items = [] @@ -362,7 +468,9 @@ async def _load_models(self) -> None: self._highlight_index = min( self._highlight_index, len(self._model_items) - 1 ) - self._apply_highlight() + self._btn_index = 0 + if self._zone == "models": + self._apply_highlight() async def on_button_pressed(self, event: Button.Pressed) -> None: btn = event.button diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss b/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss index 49f1c0f..70c6ef6 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss @@ -196,6 +196,11 @@ Button.model-delete:hover { background: #1F0A0A; } +Button.model-btn-active { + border: tall #F59E0B; + text-style: bold reverse; +} + .svc-row { height: 3; padding: 0 2; @@ -460,8 +465,21 @@ Button.catalog-installed { margin: 0 2; } -#models-scroll { +#model-list { height: 1fr; scrollbar-background: #141414; scrollbar-color: #2E2E2E; } + +#pull-input-row { + height: auto; + layout: horizontal; +} + +#pull-input-row Input { + width: 1fr; +} + +#pull-input-row Button { + width: 12; +} diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/model_item.py b/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/model_item.py index ee38bd9..71e363c 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/model_item.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/model_item.py @@ -6,6 +6,8 @@ class ModelItem(Horizontal): + can_focus = False + def __init__( self, name: str, @@ -36,6 +38,9 @@ def compose(self) -> ComposeResult: load_btn = Button("Load", name=self._model_name, classes="model-load") unload_btn = Button("Unload", name=self._model_name, classes="model-unload") delete_btn = Button("Delete", name=self._model_name, classes="model-delete") + load_btn.can_focus = False + unload_btn.can_focus = False + delete_btn.can_focus = False if self._loaded: load_btn.disabled = True else: @@ -43,3 +48,7 @@ def compose(self) -> ComposeResult: yield load_btn yield unload_btn yield delete_btn + + def get_action_buttons(self) -> list[Button]: + """Return the action buttons in left-to-right order.""" + return list(self.query("Button")) From 3c12f7df7054096ace51584f4f42879c26ffa5f9 Mon Sep 17 00:00:00 2001 From: eshork <1829176+eshork@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:57:55 -0400 Subject: [PATCH 20/32] Models screen: skip disabled buttons, Loading... feedback, column legend Left/Right nav now skips disabled buttons (Unload when not loaded, Load when already loaded). Load button shows 'Loading...' and disables during VRAM load. Added column header row (Params, Quant, Disk, VRAM, Status) aligned with model item columns. --- .../usr/lib/neuraldrive/tui/screens/models.py | 40 ++++++++++++++++--- .../usr/lib/neuraldrive/tui/styles.tcss | 35 ++++++++++++++++ 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py index 927451a..e32c44f 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py @@ -253,6 +253,13 @@ class ModelsScreen(Screen): def compose(self) -> ComposeResult: yield SafeHeader() yield Static("Installed Models", classes="heading") + with Horizontal(id="model-legend"): + yield Static("Model", classes="legend-name") + yield Static("Params", classes="legend-col legend-params") + yield Static("Quant", classes="legend-col legend-quant") + yield Static("Disk", classes="legend-col legend-disk") + yield Static("VRAM", classes="legend-col legend-vram") + yield Static("Status", classes="legend-col legend-status") yield VerticalScroll(id="model-list") yield Button( "Browse Available Models", @@ -331,7 +338,7 @@ def _get_active_buttons(self) -> list[Button]: if not self._model_items: return [] item = self._model_items[self._highlight_index] - return item.get_action_buttons() + return [b for b in item.get_action_buttons() if not b.disabled] def _apply_btn_highlight(self) -> None: buttons = self._get_active_buttons() @@ -585,6 +592,15 @@ async def _start_pull(self, model_name: str) -> None: async def _load_to_vram(self, model_name: str) -> None: status = self.query_one("#model-status", Static) status.update(f"Loading {model_name} into VRAM...") + load_btn = None + try: + load_btn = self.query_one( + f"Button.model-load[name='{model_name}']", Button + ) + load_btn.label = "Loading…" + load_btn.disabled = True + except Exception: + pass success = await api_client.load_model(model_name) if success: status.update(f" \u2713 {model_name} loaded into VRAM") @@ -593,16 +609,28 @@ async def _load_to_vram(self, model_name: str) -> None: await self._load_models() @work() - async def _unload_from_vram(self, model_name: str) -> None: + async def _load_to_vram(self, model_name: str) -> None: status = self.query_one("#model-status", Static) - status.update(f"Unloading {model_name}...") - success = await api_client.unload_model(model_name) + status.update(f"Loading {model_name} into VRAM...") + load_btn = self._find_model_button(model_name, "model-load") + if load_btn: + load_btn.label = "Loading\u2026" + load_btn.disabled = True + success = await api_client.load_model(model_name) if success: - status.update(f" \u2713 {model_name} unloaded from VRAM") + status.update(f" \u2713 {model_name} loaded into VRAM") else: - status.update(f" \u2717 Failed to unload {model_name}") + status.update(f" \u2717 Failed to load {model_name}") await self._load_models() + def _find_model_button(self, model_name: str, btn_class: str) -> Button | None: + for item in self._model_items: + if item.name == model_name: + for btn in item.query("Button"): + if btn.has_class(btn_class): + return btn + return None + @work() async def _delete_model(self, model_name: str) -> None: status = self.query_one("#model-status", Static) diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss b/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss index 70c6ef6..ed3daf3 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss @@ -471,6 +471,41 @@ Button.catalog-installed { scrollbar-color: #2E2E2E; } +#model-legend { + height: 1; + padding: 0 2; + color: #71717A; +} + +#model-legend Static.legend-name { + width: 1fr; + color: #71717A; +} + +#model-legend Static.legend-col { + color: #71717A; +} + +Static.legend-params { + width: 8; +} + +Static.legend-quant { + width: 10; +} + +Static.legend-disk { + width: 10; +} + +Static.legend-vram { + width: 12; +} + +Static.legend-status { + width: 10; +} + #pull-input-row { height: auto; layout: horizontal; From 8064d81143ca9f2b09c463ddd96e8b53a9e9b20c Mon Sep 17 00:00:00 2001 From: eshork <1829176+eshork@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:10:35 -0400 Subject: [PATCH 21/32] Restore _unload_from_vram and add legend column separators --- .../usr/lib/neuraldrive/tui/screens/models.py | 29 ++++++++----------- .../usr/lib/neuraldrive/tui/styles.tcss | 5 ++++ 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py index e32c44f..ac18702 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py @@ -256,9 +256,13 @@ def compose(self) -> ComposeResult: with Horizontal(id="model-legend"): yield Static("Model", classes="legend-name") yield Static("Params", classes="legend-col legend-params") + yield Static("/", classes="legend-sep") yield Static("Quant", classes="legend-col legend-quant") + yield Static("/", classes="legend-sep") yield Static("Disk", classes="legend-col legend-disk") + yield Static("/", classes="legend-sep") yield Static("VRAM", classes="legend-col legend-vram") + yield Static("/", classes="legend-sep") yield Static("Status", classes="legend-col legend-status") yield VerticalScroll(id="model-list") yield Button( @@ -592,15 +596,10 @@ async def _start_pull(self, model_name: str) -> None: async def _load_to_vram(self, model_name: str) -> None: status = self.query_one("#model-status", Static) status.update(f"Loading {model_name} into VRAM...") - load_btn = None - try: - load_btn = self.query_one( - f"Button.model-load[name='{model_name}']", Button - ) - load_btn.label = "Loading…" + load_btn = self._find_model_button(model_name, "model-load") + if load_btn: + load_btn.label = "Loading\u2026" load_btn.disabled = True - except Exception: - pass success = await api_client.load_model(model_name) if success: status.update(f" \u2713 {model_name} loaded into VRAM") @@ -609,18 +608,14 @@ async def _load_to_vram(self, model_name: str) -> None: await self._load_models() @work() - async def _load_to_vram(self, model_name: str) -> None: + async def _unload_from_vram(self, model_name: str) -> None: status = self.query_one("#model-status", Static) - status.update(f"Loading {model_name} into VRAM...") - load_btn = self._find_model_button(model_name, "model-load") - if load_btn: - load_btn.label = "Loading\u2026" - load_btn.disabled = True - success = await api_client.load_model(model_name) + status.update(f"Unloading {model_name}...") + success = await api_client.unload_model(model_name) if success: - status.update(f" \u2713 {model_name} loaded into VRAM") + status.update(f" \u2713 {model_name} unloaded from VRAM") else: - status.update(f" \u2717 Failed to load {model_name}") + status.update(f" \u2717 Failed to unload {model_name}") await self._load_models() def _find_model_button(self, model_name: str, btn_class: str) -> Button | None: diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss b/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss index ed3daf3..29cba6c 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss @@ -486,6 +486,11 @@ Button.catalog-installed { color: #71717A; } +#model-legend Static.legend-sep { + width: 1; + color: #52525B; +} + Static.legend-params { width: 8; } From fe0a28fbb8fc543740a9d383606513b7d496f0c3 Mon Sep 17 00:00:00 2001 From: eshork <1829176+eshork@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:15:31 -0400 Subject: [PATCH 22/32] Fix unload race condition and keep manually loaded models in VRAM Poll /api/ps after unload until model is actually evicted (Ollama returns 200 before eviction completes). Await remove_children() to prevent stale widgets. Use keep_alive=-1 for manual loads so models stay loaded until explicitly unloaded. --- .../usr/lib/neuraldrive/tui/screens/models.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py index ac18702..0e3252f 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py @@ -447,7 +447,7 @@ async def _load_models(self) -> None: config.set_key("vram_cache", vram_cache) container = self.query_one("#model-list", VerticalScroll) - container.remove_children() + await container.remove_children() self._model_items = [] if not all_models: @@ -600,7 +600,7 @@ async def _load_to_vram(self, model_name: str) -> None: if load_btn: load_btn.label = "Loading\u2026" load_btn.disabled = True - success = await api_client.load_model(model_name) + success = await api_client.load_model(model_name, keep_alive="-1") if success: status.update(f" \u2713 {model_name} loaded into VRAM") else: @@ -613,6 +613,14 @@ async def _unload_from_vram(self, model_name: str) -> None: status.update(f"Unloading {model_name}...") success = await api_client.unload_model(model_name) if success: + # Ollama returns 200 before the model is fully evicted from /api/ps. + # Poll until it disappears so the UI refresh sees the real state. + for _ in range(10): + await asyncio.sleep(0.5) + running = await api_client.list_running_models() + running_names = {m.get("name", "") for m in running} + if model_name not in running_names: + break status.update(f" \u2713 {model_name} unloaded from VRAM") else: status.update(f" \u2717 Failed to unload {model_name}") From 4bc2f32123765064f9b7f6a0e2a5c71cdb6682a1 Mon Sep 17 00:00:00 2001 From: eshork <1829176+eshork@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:21:36 -0400 Subject: [PATCH 23/32] Fix keep_alive: pass integer -1 instead of string Ollama rejects "-1" with 'missing unit in duration', but accepts the integer -1 for infinite keep-alive. --- .../includes.chroot/usr/lib/neuraldrive/tui/screens/models.py | 2 +- .../includes.chroot/usr/lib/neuraldrive/tui/utils/api_client.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py index 0e3252f..4a73153 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/models.py @@ -600,7 +600,7 @@ async def _load_to_vram(self, model_name: str) -> None: if load_btn: load_btn.label = "Loading\u2026" load_btn.disabled = True - success = await api_client.load_model(model_name, keep_alive="-1") + success = await api_client.load_model(model_name, keep_alive=-1) if success: status.update(f" \u2713 {model_name} loaded into VRAM") else: diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/utils/api_client.py b/config/includes.chroot/usr/lib/neuraldrive/tui/utils/api_client.py index de61a27..93de154 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/utils/api_client.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/utils/api_client.py @@ -56,7 +56,7 @@ async def delete_model(name: str) -> bool: return False -async def load_model(name: str, keep_alive: str = "5m") -> bool: +async def load_model(name: str, keep_alive: str | int = "5m") -> bool: try: async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, read=300.0)) as client: resp = await client.post( From 78fbc0df166706d0e47356fb539e3c21e1a59104 Mon Sep 17 00:00:00 2001 From: eshork <1829176+eshork@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:30:29 -0400 Subject: [PATCH 24/32] Redesign services screen to match models screen UX Each service gets its own row with inline Start/Stop/Restart buttons. Arrow keys navigate services (Up/Down) and buttons (Left/Right). Disabled buttons are skipped. Enter activates the highlighted button. Service status auto-polls every 5 seconds and updates in place. --- .../lib/neuraldrive/tui/screens/services.py | 180 +++++++++++------- .../usr/lib/neuraldrive/tui/styles.tcss | 108 +++++++++-- .../lib/neuraldrive/tui/widgets/__init__.py | 3 +- .../neuraldrive/tui/widgets/service_item.py | 56 ++++++ 4 files changed, 263 insertions(+), 84 deletions(-) create mode 100644 config/includes.chroot/usr/lib/neuraldrive/tui/widgets/service_item.py diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/services.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/services.py index d4cda6c..f7a7daf 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/services.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/services.py @@ -4,119 +4,169 @@ from textual import work from textual.app import ComposeResult -from textual.containers import Horizontal, Vertical, VerticalScroll +from textual.containers import VerticalScroll from textual.screen import Screen from textual.widgets import Button, Footer, Static +from textual.binding import Binding from widgets.safe_header import SafeHeader +from widgets.service_item import ServiceItem +from utils import hardware -from textual.binding import Binding -from utils import hardware +POLL_INTERVAL = 5 class ServicesScreen(Screen): BINDINGS = [ ("r", "refresh", "Refresh"), - Binding("up", "move_up", "Up", show=False), - Binding("down", "move_down", "Down", show=False), + Binding("up", "nav_up", show=False, priority=True), + Binding("down", "nav_down", show=False, priority=True), + Binding("left", "nav_left", show=False, priority=True), + Binding("right", "nav_right", show=False, priority=True), + Binding("enter", "activate", show=False, priority=True), ] def compose(self) -> ComposeResult: yield SafeHeader() - with VerticalScroll(): - yield Static("NeuralDrive Services", classes="heading") - yield Vertical(id="service-list") + yield Static("NeuralDrive Services", classes="heading") + yield VerticalScroll(id="svc-list") yield Static("", id="svc-status") - with Horizontal(id="svc-actions"): - yield Button("Start", id="svc-start", variant="primary") - yield Button("Stop", id="svc-stop", variant="error") - yield Button("Restart", id="svc-restart") yield Footer() def on_mount(self) -> None: - self._selected_index = 0 - self._services: list[tuple[str, str]] = [] + self._svc_items: list[ServiceItem] = [] + self._highlight_index = 0 + self._btn_index = 0 + self._poll_timer = self.set_interval(POLL_INTERVAL, self._poll_services) self.app.call_later(self._load_services) def on_screen_resume(self) -> None: self.app.call_later(self._load_services) + def on_screen_suspend(self) -> None: + pass + async def _load_services(self) -> None: - container = self.query_one("#service-list", Vertical) + container = self.query_one("#svc-list", VerticalScroll) await container.remove_children() - self._services = [] + self._svc_items = [] for svc in hardware.NEURALDRIVE_SERVICES: status = hardware.get_service_status(svc) - self._services.append((svc, status)) - - for i, (svc, status) in enumerate(self._services): short = svc.replace("neuraldrive-", "") - if status == "active": - indicator = "●" - cls = "svc-row svc-active" - else: - indicator = "○" - cls = "svc-row svc-inactive" - if i == self._selected_index: - cls += " svc-selected" - row = Static( - f" {indicator} {short:<20} {status}", classes=cls, id=f"svc-{i}" + item = ServiceItem(svc, short, status) + container.mount(item) + self._svc_items.append(item) + if self._svc_items: + self._highlight_index = min( + self._highlight_index, len(self._svc_items) - 1 ) - await container.mount(row) + self._btn_index = 0 + self._apply_highlight() + + async def _poll_services(self) -> None: + for item in self._svc_items: + status = hardware.get_service_status(item.name) + item.update_status(status) + if self._svc_items: + self._apply_btn_highlight() + + def _get_active_buttons(self) -> list[Button]: + if not self._svc_items: + return [] + item = self._svc_items[self._highlight_index] + return [b for b in item.get_action_buttons() if not b.disabled] + + def _apply_highlight(self) -> None: + self._clear_btn_highlight() + for i, item in enumerate(self._svc_items): + if i == self._highlight_index: + item.add_class("svc-highlighted") + item.scroll_visible() + self._apply_btn_highlight() + else: + item.remove_class("svc-highlighted") - self._update_action_buttons() + def _clear_highlight(self) -> None: + self._clear_btn_highlight() + for item in self._svc_items: + item.remove_class("svc-highlighted") - def _update_action_buttons(self) -> None: - if not self._services: + def _apply_btn_highlight(self) -> None: + buttons = self._get_active_buttons() + if not buttons: return - _, status = self._services[self._selected_index] - self.query_one("#svc-start", Button).disabled = status == "active" - self.query_one("#svc-stop", Button).disabled = status != "active" - - def action_move_up(self) -> None: - if self._selected_index > 0: - self._selected_index -= 1 - self.app.call_later(self._load_services) - - def action_move_down(self) -> None: - if self._selected_index < len(self._services) - 1: - self._selected_index += 1 - self.app.call_later(self._load_services) + self._btn_index = max(0, min(self._btn_index, len(buttons) - 1)) + for item in self._svc_items: + for btn in item.get_action_buttons(): + btn.remove_class("svc-btn-active") + for i, btn in enumerate(buttons): + if i == self._btn_index: + btn.add_class("svc-btn-active") + else: + btn.remove_class("svc-btn-active") + + def _clear_btn_highlight(self) -> None: + for item in self._svc_items: + for btn in item.get_action_buttons(): + btn.remove_class("svc-btn-active") + + def action_nav_up(self) -> None: + if self._svc_items and self._highlight_index > 0: + self._highlight_index -= 1 + self._btn_index = 0 + self._apply_highlight() + + def action_nav_down(self) -> None: + if self._svc_items and self._highlight_index < len(self._svc_items) - 1: + self._highlight_index += 1 + self._btn_index = 0 + self._apply_highlight() + + def action_nav_left(self) -> None: + if self._btn_index > 0: + self._btn_index -= 1 + self._apply_btn_highlight() + + def action_nav_right(self) -> None: + buttons = self._get_active_buttons() + if self._btn_index < len(buttons) - 1: + self._btn_index += 1 + self._apply_btn_highlight() + + def action_activate(self) -> None: + buttons = self._get_active_buttons() + if buttons and 0 <= self._btn_index < len(buttons): + buttons[self._btn_index].press() def on_button_pressed(self, event: Button.Pressed) -> None: - btn_id = event.button.id or "" - if btn_id == "svc-start": - self._run_action("start") - elif btn_id == "svc-stop": - self._run_action("stop") - elif btn_id == "svc-restart": - self._run_action("restart") + btn = event.button + if btn.has_class("svc-start"): + self._run_action(btn.name or "", "start") + elif btn.has_class("svc-stop"): + self._run_action(btn.name or "", "stop") + elif btn.has_class("svc-restart"): + self._run_action(btn.name or "", "restart") @work(exclusive=True) - async def _run_action(self, action: str) -> None: - if not self._services: - return - svc, _ = self._services[self._selected_index] - short = svc.replace("neuraldrive-", "") + async def _run_action(self, service: str, action: str) -> None: + short = service.replace("neuraldrive-", "") status_widget = self.query_one("#svc-status", Static) status_widget.update(f" {action.title()}ing {short}...") - try: res = subprocess.run( - ["sudo", "systemctl", action, svc], + ["sudo", "systemctl", action, service], capture_output=True, text=True, timeout=15, ) if res.returncode == 0: - status_widget.update(f" ✓ {short} {action}ed") + status_widget.update(f" \u2713 {short} {action}ed") else: - status_widget.update(f" ✗ {short}: {res.stderr.strip()}") + status_widget.update(f" \u2717 {short}: {res.stderr.strip()}") except subprocess.TimeoutExpired: - status_widget.update(f" ✗ {short}: timeout") - - self.app.call_later(self._load_services) + status_widget.update(f" \u2717 {short}: timeout") + await self._poll_services() def action_refresh(self) -> None: self.app.call_later(self._load_services) diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss b/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss index 29cba6c..8ac54d0 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss @@ -201,44 +201,116 @@ Button.model-btn-active { text-style: bold reverse; } -.svc-row { - height: 3; +.svc-item { + layout: horizontal; + height: 5; padding: 0 2; border: solid #2E2E2E; - margin: 0 0 0 0; + margin: 0 0 1 0; background: #141414; +} + +.svc-item:hover { + background: #1F1F1F; +} + +.svc-highlighted { + background: #1F1F1F; + border: solid #F59E0B; +} + +.svc-item Static { + height: 100%; content-align: left middle; } -.svc-active { +.svc-item Static.svc-name { + color: #FFFFFF; + text-style: bold; + width: 1fr; +} + +.svc-item Static.svc-state { + width: 16; +} + +.svc-status-active { + color: #10B981; +} + +.svc-status-inactive { + color: #EF4444; +} + +Button.svc-start { + background: #1F1F1F; color: #10B981; + border: solid #10B981; + min-width: 10; + width: 10; + height: 3; +} + +Button.svc-start:hover { + background: #0A1F0A; +} + +Button.svc-start:disabled { + background: #0A0A0A; + color: #52525B; + border: solid #1A1A1A; } -.svc-inactive { +Button.svc-stop { + background: #1F1F1F; color: #EF4444; + border: solid #EF4444; + min-width: 10; + width: 10; + height: 3; +} + +Button.svc-stop:hover { + background: #1F0A0A; } -.svc-selected { +Button.svc-stop:disabled { + background: #0A0A0A; + color: #52525B; + border: solid #1A1A1A; +} + +Button.svc-restart { background: #1F1F1F; + color: #F59E0B; border: solid #F59E0B; + min-width: 10; + width: 10; + height: 3; } -#svc-status { - height: 1; - padding: 0 2; - dock: bottom; - offset: 0 -4; +Button.svc-restart:hover { + background: #1F1A0A; } -#svc-actions { - height: auto; - padding: 0 1; - dock: bottom; - align: center middle; +Button.svc-restart:disabled { + background: #0A0A0A; + color: #52525B; + border: solid #1A1A1A; } -#svc-actions Button { - margin: 0 1; +Button.svc-btn-active { + border: tall #F59E0B; + text-style: bold reverse; +} + +#svc-list { + height: 1fr; +} + +#svc-status { + height: 1; + padding: 0 2; } Button { diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/__init__.py b/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/__init__.py index 10a5e57..4e1d144 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/__init__.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/__init__.py @@ -1,5 +1,6 @@ from widgets.stats_box import StatsBox from widgets.model_item import ModelItem from widgets.safe_header import SafeHeader +from widgets.service_item import ServiceItem -__all__ = ["StatsBox", "ModelItem", "SafeHeader"] +__all__ = ["StatsBox", "ModelItem", "SafeHeader", "ServiceItem"] diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/service_item.py b/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/service_item.py new file mode 100644 index 0000000..74f7e82 --- /dev/null +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/widgets/service_item.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from textual.app import ComposeResult +from textual.containers import Horizontal +from textual.widgets import Button, Static + + +class ServiceItem(Horizontal): + can_focus = False + + def __init__(self, service: str, display_name: str, status: str) -> None: + super().__init__(name=service, classes="svc-item") + self._service = service + self._display_name = display_name + self._status = status + + def compose(self) -> ComposeResult: + active = self._status == "active" + indicator = "\u25cf" if active else "\u25cb" + status_cls = "svc-status-active" if active else "svc-status-inactive" + yield Static(self._display_name, classes="svc-name") + yield Static(f"{indicator} {self._status}", classes=f"svc-state {status_cls}") + start_btn = Button("Start", name=self._service, classes="svc-start") + stop_btn = Button("Stop", name=self._service, classes="svc-stop") + restart_btn = Button("Restart", name=self._service, classes="svc-restart") + start_btn.can_focus = False + stop_btn.can_focus = False + restart_btn.can_focus = False + if active: + start_btn.disabled = True + else: + stop_btn.disabled = True + restart_btn.disabled = True + yield start_btn + yield stop_btn + yield restart_btn + + def get_action_buttons(self) -> list[Button]: + return list(self.query("Button")) + + def update_status(self, status: str) -> None: + self._status = status + active = status == "active" + indicator = "\u25cf" if active else "\u25cb" + status_cls = "svc-status-active" if active else "svc-status-inactive" + state_widget = self.query_one(".svc-state", Static) + state_widget.update(f"{indicator} {status}") + state_widget.remove_class("svc-status-active", "svc-status-inactive") + state_widget.add_class(status_cls) + for btn in self.query("Button"): + if btn.has_class("svc-start"): + btn.disabled = active + elif btn.has_class("svc-stop"): + btn.disabled = not active + elif btn.has_class("svc-restart"): + btn.disabled = not active From c8e3a710e2d0a9132e12b4ca266e8d4f1e8fee2f Mon Sep 17 00:00:00 2001 From: eshork <1829176+eshork@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:34:03 -0400 Subject: [PATCH 25/32] Remap screen hotkeys to F1-F5: Dash, Models, Svc, Logs, Chat --- config/includes.chroot/usr/lib/neuraldrive/tui/main.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/main.py b/config/includes.chroot/usr/lib/neuraldrive/tui/main.py index ede4187..f563843 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/main.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/main.py @@ -69,11 +69,11 @@ class NeuralDriveTUI(App): ENABLE_COMMAND_PALETTE = False BINDINGS = [ - Binding("f2", "switch_screen('dashboard')", "F2 Dash", priority=True), - Binding("f3", "switch_screen('models')", "F3 Models", priority=True), - Binding("f4", "switch_screen('services')", "F4 Svc", priority=True), + Binding("f1", "switch_screen('dashboard')", "F1 Dash", priority=True), + Binding("f2", "switch_screen('models')", "F2 Models", priority=True), + Binding("f3", "switch_screen('services')", "F3 Svc", priority=True), + Binding("f4", "switch_screen('logs')", "F4 Logs", priority=True), Binding("f5", "switch_screen('chat')", "F5 Chat", priority=True), - Binding("f6", "switch_screen('logs')", "F6 Logs", priority=True), Binding("q", "quit", "Quit"), Binding("up", "focus_previous", "Previous", show=False), Binding("down", "focus_next", "Next", show=False), From ea9fcfcc1476b2c9173a2c4f91455796d01c8ba8 Mon Sep 17 00:00:00 2001 From: eshork <1829176+eshork@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:38:28 -0400 Subject: [PATCH 26/32] Guard service poll timer against widget rebuild race Poll fires every 5s but _load_services clears and remounts items. Skip poll while _loading flag is set to avoid NoMatches on .svc-state. --- .../usr/lib/neuraldrive/tui/screens/services.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/services.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/services.py index f7a7daf..d8e669d 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/services.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/services.py @@ -38,6 +38,7 @@ def on_mount(self) -> None: self._svc_items: list[ServiceItem] = [] self._highlight_index = 0 self._btn_index = 0 + self._loading = False self._poll_timer = self.set_interval(POLL_INTERVAL, self._poll_services) self.app.call_later(self._load_services) @@ -48,6 +49,7 @@ def on_screen_suspend(self) -> None: pass async def _load_services(self) -> None: + self._loading = True container = self.query_one("#svc-list", VerticalScroll) await container.remove_children() self._svc_items = [] @@ -63,13 +65,15 @@ async def _load_services(self) -> None: ) self._btn_index = 0 self._apply_highlight() + self._loading = False async def _poll_services(self) -> None: + if self._loading or not self._svc_items: + return for item in self._svc_items: status = hardware.get_service_status(item.name) item.update_status(status) - if self._svc_items: - self._apply_btn_highlight() + self._apply_btn_highlight() def _get_active_buttons(self) -> list[Button]: if not self._svc_items: From 2a704c6d2262a273a6b81aad857a1e11fcf7c5e3 Mon Sep 17 00:00:00 2001 From: eshork <1829176+eshork@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:46:41 -0400 Subject: [PATCH 27/32] Allow concurrent model loading and persist Ollama config Set OLLAMA_MAX_LOADED_MODELS=0 (auto) so Ollama manages concurrency based on available VRAM. Add persistent EnvironmentFile override so config on /var/lib/neuraldrive/config/ollama.conf survives reboots, falling back to baked-in defaults when persistent disk is unavailable. --- config/hooks/live/05-generate-configs.chroot | 2 +- config/includes.chroot/etc/neuraldrive/ollama.conf | 2 +- .../etc/systemd/system/neuraldrive-ollama.service | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/config/hooks/live/05-generate-configs.chroot b/config/hooks/live/05-generate-configs.chroot index 94557b2..d78e2f9 100755 --- a/config/hooks/live/05-generate-configs.chroot +++ b/config/hooks/live/05-generate-configs.chroot @@ -7,7 +7,7 @@ cat > /etc/neuraldrive/ollama.conf << 'EOF' OLLAMA_HOST=127.0.0.1:11434 OLLAMA_MODELS=/var/lib/neuraldrive/models/ OLLAMA_KEEP_ALIVE=5m -OLLAMA_MAX_LOADED_MODELS=1 +OLLAMA_MAX_LOADED_MODELS=0 OLLAMA_NUM_PARALLEL=1 EOF diff --git a/config/includes.chroot/etc/neuraldrive/ollama.conf b/config/includes.chroot/etc/neuraldrive/ollama.conf index 868447a..1c0ff05 100644 --- a/config/includes.chroot/etc/neuraldrive/ollama.conf +++ b/config/includes.chroot/etc/neuraldrive/ollama.conf @@ -1,5 +1,5 @@ OLLAMA_HOST=127.0.0.1:11434 OLLAMA_MODELS=/var/lib/neuraldrive/models/ OLLAMA_KEEP_ALIVE=5m -OLLAMA_MAX_LOADED_MODELS=1 +OLLAMA_MAX_LOADED_MODELS=0 OLLAMA_NUM_PARALLEL=1 diff --git a/config/includes.chroot/etc/systemd/system/neuraldrive-ollama.service b/config/includes.chroot/etc/systemd/system/neuraldrive-ollama.service index 2029529..27c2ffd 100644 --- a/config/includes.chroot/etc/systemd/system/neuraldrive-ollama.service +++ b/config/includes.chroot/etc/systemd/system/neuraldrive-ollama.service @@ -6,6 +6,7 @@ Requires=neuraldrive-gpu-detect.service [Service] Environment=HOME=/var/lib/neuraldrive/ollama EnvironmentFile=/etc/neuraldrive/ollama.conf +EnvironmentFile=-/var/lib/neuraldrive/config/ollama.conf ExecStartPre=/usr/bin/mkdir -p /var/lib/neuraldrive/models ExecStartPre=-/sbin/modprobe nvidia-current-uvm ExecStartPre=-/usr/bin/nvidia-modprobe -u From 37d0330d7b8730582613ef3b9e4a428d085ad4f7 Mon Sep 17 00:00:00 2001 From: eshork <1829176+eshork@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:48:59 -0400 Subject: [PATCH 28/32] Widen services Restart button to fit label --- config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss b/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss index 8ac54d0..a2480d3 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/styles.tcss @@ -284,8 +284,8 @@ Button.svc-restart { background: #1F1F1F; color: #F59E0B; border: solid #F59E0B; - min-width: 10; - width: 10; + min-width: 11; + width: 11; height: 3; } From 2160fa8f327a45222b6611eefc2517a92cfa40eb Mon Sep 17 00:00:00 2001 From: eshork <1829176+eshork@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:52:22 -0400 Subject: [PATCH 29/32] Create webui data directory on persistence partition Wizard was missing /var/lib/neuraldrive/webui from the directory list, causing systemd NAMESPACE failure (status=226) when ReadWritePaths referenced the missing path. --- .../usr/lib/neuraldrive/tui/screens/wizard.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/wizard.py b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/wizard.py index 3812e4b..fa28378 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/tui/screens/wizard.py +++ b/config/includes.chroot/usr/lib/neuraldrive/tui/screens/wizard.py @@ -425,6 +425,7 @@ def _create_persistence_partition(self) -> str | None: "/mnt/persistence/var/lib/neuraldrive/ollama/.ollama", "/mnt/persistence/var/lib/neuraldrive/models", "/mnt/persistence/var/lib/neuraldrive/config", + "/mnt/persistence/var/lib/neuraldrive/webui", "/mnt/persistence/var/log/neuraldrive", "/mnt/persistence/etc/neuraldrive", "/mnt/persistence/home", @@ -453,6 +454,21 @@ def _create_persistence_partition(self) -> str | None: if proc.returncode != 0: return f"chown failed: {proc.stderr.strip()}" + proc = subprocess.run( + [ + "sudo", + "chown", + "-R", + "neuraldrive-webui:neuraldrive-webui", + "/mnt/persistence/var/lib/neuraldrive/webui", + ], + capture_output=True, + text=True, + timeout=5, + ) + if proc.returncode != 0: + return f"chown webui failed: {proc.stderr.strip()}" + proc = subprocess.run( ["sudo", "umount", "/mnt/persistence"], capture_output=True, From 7692b40738dc2878089c498168b58718edf36917 Mon Sep 17 00:00:00 2001 From: eshork <1829176+eshork@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:07:34 -0400 Subject: [PATCH 30/32] fix git urls --- README.md | 2 +- docs/dev-guide/book.toml | 4 ++-- docs/landing/index.html | 2 +- docs/user-guide/book.toml | 4 ++-- docs/user-guide/src/advanced/custom-images.md | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 5c95fd5..010e4ae 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ NeuralDrive images are built using Debian's `live-build` toolchain inside a Dock ```bash # Clone and build -git clone https://github.com/NeuralDrive/NeuralDrive.git +git clone https://github.com/Rightbracket/NeuralDrive.git cd NeuralDrive docker compose run --rm builder diff --git a/docs/dev-guide/book.toml b/docs/dev-guide/book.toml index 48d9143..e0f3eb9 100644 --- a/docs/dev-guide/book.toml +++ b/docs/dev-guide/book.toml @@ -12,8 +12,8 @@ build-dir = "book" default-theme = "coal" preferred-dark-theme = "coal" site-url = "/NeuralDrive/dev-guide/" -git-repository-url = "https://github.com/NeuralDrive/NeuralDrive" -edit-url-template = "https://github.com/NeuralDrive/NeuralDrive/edit/main/docs/dev-guide/src/{path}" +git-repository-url = "https://github.com/Rightbracket/NeuralDrive" +edit-url-template = "https://github.com/Rightbracket/NeuralDrive/edit/main/docs/dev-guide/src/{path}" additional-css = ["custom.css"] [output.html.search] diff --git a/docs/landing/index.html b/docs/landing/index.html index 65dddd5..a37872b 100644 --- a/docs/landing/index.html +++ b/docs/landing/index.html @@ -97,7 +97,7 @@