Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e949c1ec8c | ||
|
|
1791f7655b | ||
|
|
ae8d644438 |
+301
-25
@@ -16,6 +16,9 @@ Usage:
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import subprocess
|
||||
import re
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
@@ -101,6 +104,7 @@ try:
|
||||
except ImportError:
|
||||
BACKEND_AVAILABLE = False
|
||||
|
||||
from textual import events
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||||
from textual.widgets import Button, Header, Input, ProgressBar, RichLog, Static
|
||||
@@ -634,6 +638,23 @@ class ModelItem(Static):
|
||||
self._set_tooltip()
|
||||
|
||||
def _set_tooltip(self):
|
||||
"""Short one-line hover tooltip — full details move to the info
|
||||
panel (F1 in the model selector). The old 5-line tooltip covered
|
||||
adjacent list entries, blocking comparison."""
|
||||
name = self.model.get("name", "")
|
||||
try:
|
||||
import model_profiles
|
||||
|
||||
profile = model_profiles.load_profile(name)
|
||||
if profile:
|
||||
self.tooltip = f"{name} | {profile.family} | {profile.parameters} — F1 for tips"
|
||||
else:
|
||||
self.tooltip = name
|
||||
except Exception:
|
||||
self.tooltip = name
|
||||
|
||||
def _detail_info(self) -> str:
|
||||
"""Full model detail for the info panel (F1 in the selector)."""
|
||||
name = self.model.get("name", "")
|
||||
try:
|
||||
import model_profiles
|
||||
@@ -641,16 +662,14 @@ class ModelItem(Static):
|
||||
profile = model_profiles.load_profile(name)
|
||||
if profile:
|
||||
tips = (
|
||||
"\n".join(f"• {t}" for t in profile.prompting_tips[:3])
|
||||
"\n".join(f"• {t}" for t in profile.prompting_tips[:5])
|
||||
if profile.prompting_tips
|
||||
else "None"
|
||||
)
|
||||
self.tooltip = f"[bold]{name}[/bold]\nFamily: {profile.family} | Size: {profile.parameters}\nStrengths: {', '.join(profile.strengths[:3])}\nTips:\n{tips}"
|
||||
else:
|
||||
self.tooltip = name
|
||||
return f"[bold]{name}[/bold]\nFamily: {profile.family} | Size: {profile.parameters}\nStrengths: {', '.join(profile.strengths[:3])}\nTips:\n{tips}"
|
||||
except Exception:
|
||||
self.tooltip = name
|
||||
|
||||
pass
|
||||
return name
|
||||
def _update_classes(self):
|
||||
self.set_class(self._is_selected, "selected")
|
||||
self.set_class(self._is_current, "current-model")
|
||||
@@ -981,6 +1000,76 @@ def _resolve_model_name(setting: str, mode: str = "chat") -> str:
|
||||
return "qwen2.5:3b" if setting == "local" else setting
|
||||
|
||||
|
||||
def _semver_tuple(version: str):
|
||||
"""'0.54.0' -> (0, 54, 0); unparseable -> () so comparisons fail closed."""
|
||||
try:
|
||||
return tuple(int(part) for part in str(version).strip().split("."))
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
return ()
|
||||
|
||||
|
||||
|
||||
|
||||
class UpdateChecker:
|
||||
"""Active update detection for the live deploy (footer + refresh).
|
||||
|
||||
Compares the running ``rune.__version__`` against gitea/master (the
|
||||
release authority) via the repo's own git remote — the credential lives
|
||||
in the remote URL, nothing is hardcoded here. Degrade-not-crash: any
|
||||
failure yields "unknown", never a false update flag.
|
||||
"""
|
||||
|
||||
REPO = "/mnt/workdrive/rune"
|
||||
CHECK_INTERVAL = 300 # seconds between background checks
|
||||
|
||||
def __init__(self):
|
||||
self.update_available = False
|
||||
self.latest_version = None
|
||||
self.last_error = None
|
||||
self._lock = threading.Lock()
|
||||
self._checking = False
|
||||
|
||||
def _git(self, *args: str, timeout: int = 45):
|
||||
return subprocess.run(
|
||||
["git", "-C", self.REPO, *args],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
)
|
||||
|
||||
def check(self) -> bool:
|
||||
"""Fetch gitea/master and compare versions. True when a NEWER
|
||||
version exists remotely. Thread-safe; single-flight."""
|
||||
with self._lock:
|
||||
if self._checking:
|
||||
return self.update_available
|
||||
self._checking = True
|
||||
try:
|
||||
fetch = self._git("fetch", "gitea", "master", timeout=60)
|
||||
if fetch.returncode != 0:
|
||||
raise RuntimeError(f"fetch failed: {fetch.stderr.strip()[:160]}")
|
||||
show = self._git("show", "gitea/master:src/rune/__init__.py", timeout=20)
|
||||
if show.returncode != 0:
|
||||
raise RuntimeError(f"read remote version failed: {show.stderr.strip()[:160]}")
|
||||
match = re.search(r'__version__\s*=\s*"([0-9.]+)"', show.stdout)
|
||||
if not match:
|
||||
raise RuntimeError("remote __version__ unparseable")
|
||||
self.latest_version = match.group(1)
|
||||
import rune as _rune_pkg
|
||||
local = _rune_pkg.__version__
|
||||
remote_t, local_t = _semver_tuple(self.latest_version), _semver_tuple(local)
|
||||
self.update_available = bool(remote_t and local_t and remote_t > local_t)
|
||||
self.last_error = None
|
||||
except Exception as exc:
|
||||
self.last_error = str(exc)[:200]
|
||||
finally:
|
||||
self._checking = False
|
||||
return self.update_available
|
||||
|
||||
|
||||
|
||||
|
||||
_update_checker = UpdateChecker()
|
||||
|
||||
|
||||
class VersionLabel(Static):
|
||||
"""Footer semver display — derived from the canonical __version__."""
|
||||
|
||||
@@ -1093,28 +1182,42 @@ class StatusBar(Static):
|
||||
pass
|
||||
return 0
|
||||
|
||||
def render(self) -> str:
|
||||
channel_str = self._format_channel()
|
||||
model_str = self._format_model()
|
||||
loading_str = " ⏳" if self.is_loading else ""
|
||||
provider_count = self._get_provider_count()
|
||||
|
||||
# Update tooltip with full model details
|
||||
def _detail_tooltip(self) -> str:
|
||||
"""Full model details for the info panel (F1 key on footer).
|
||||
Cached per model -- this content only changes when the model changes."""
|
||||
if getattr(self, "_cached_detail_model", None) == self.model and getattr(
|
||||
self, "_cached_detail_text", None
|
||||
):
|
||||
return self._cached_detail_text
|
||||
try:
|
||||
import model_profiles
|
||||
|
||||
profile = model_profiles.load_profile(self.model)
|
||||
if profile:
|
||||
tips = (
|
||||
"\n".join(f"• {t}" for t in profile.prompting_tips[:3])
|
||||
"\n".join(f"• {t}" for t in profile.prompting_tips[:5])
|
||||
if profile.prompting_tips
|
||||
else "None"
|
||||
)
|
||||
self.tooltip = f"[bold]{self.model}[/bold]\nFamily: {profile.family} | Size: {profile.parameters} | Quant: {profile.quantization}\nStrengths: {', '.join(profile.strengths[:3])}\nTips:\n{tips}"
|
||||
detail = f"[bold]{self.model}[/bold]\nFamily: {profile.family} | Size: {profile.parameters} | Quant: {profile.quantization}\nStrengths: {', '.join(profile.strengths[:3])}\nTips:\n{tips}"
|
||||
else:
|
||||
self.tooltip = self.model
|
||||
detail = self.model
|
||||
except Exception:
|
||||
self.tooltip = self.model
|
||||
detail = self.model
|
||||
self._cached_detail_model = self.model
|
||||
self._cached_detail_text = detail
|
||||
return detail
|
||||
|
||||
def render(self) -> str:
|
||||
channel_str = self._format_channel()
|
||||
model_str = self._format_model()
|
||||
loading_str = " ⏳" if self.is_loading else ""
|
||||
provider_count = self._get_provider_count()
|
||||
|
||||
# Hover tooltip: one short line. Full details live in the info panel
|
||||
# (F1 on the footer) -- the old multi-line tooltip occluded the chat
|
||||
# log on every hover. Detail content cached per model, read on demand.
|
||||
self.tooltip = f"{self.model} -- F1 for details"
|
||||
|
||||
parts = [channel_str, model_str]
|
||||
if provider_count > 1:
|
||||
@@ -1380,9 +1483,51 @@ class WorkPane(Vertical):
|
||||
return text
|
||||
|
||||
|
||||
class InfoPanel(Static):
|
||||
"""Docked overlay showing contextual detail for the focused element.
|
||||
|
||||
Toggled with F1. Does NOT follow the mouse — it appears in a fixed
|
||||
position (bottom-right, above the footer) so it never covers the
|
||||
element being inspected. Press F1 again or Esc to dismiss.
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
InfoPanel {
|
||||
display: none;
|
||||
width: 60;
|
||||
max-height: 12;
|
||||
dock: bottom;
|
||||
offset-y: -3;
|
||||
background: $surface;
|
||||
border: round $accent;
|
||||
padding: 0 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
InfoPanel.visible {
|
||||
display: block;
|
||||
}
|
||||
"""
|
||||
|
||||
def show_info(self, title: str, content: str) -> None:
|
||||
"""Display a detail block. Call from the main thread."""
|
||||
self.update(f"[bold]{title}[/bold]\n{content}")
|
||||
self.add_class("visible")
|
||||
|
||||
def hide(self) -> None:
|
||||
self.remove_class("visible")
|
||||
|
||||
def toggle(self, title: str, content: str) -> None:
|
||||
if self.has_class("visible"):
|
||||
self.hide()
|
||||
else:
|
||||
self.show_info(title, content)
|
||||
|
||||
|
||||
class RuneApp(App):
|
||||
"""Rune's body — the TUI application."""
|
||||
|
||||
TOOLTIP_DELAY = 1.2 # hover must linger before a tooltip appears
|
||||
|
||||
# Theme support — Textual built-in themes
|
||||
# Available: nord, dracula, monokai, solarized-light, solarized-dark,
|
||||
# catppuccin-mocha, catppuccin-latte, tokyo-night, flexoki, gruvbox
|
||||
@@ -1504,6 +1649,7 @@ class RuneApp(App):
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
("f1", "toggle_info_panel", "Info"),
|
||||
Binding("ctrl+1", "focus_task", "Task channel"),
|
||||
Binding("ctrl+2", "focus_chat", "Chat channel"),
|
||||
# P1-07 (#30): Ctrl+3 collapses/expands the work pane (the old
|
||||
@@ -1572,6 +1718,7 @@ class RuneApp(App):
|
||||
yield Static("🔄", id="refresh-btn", classes="refresh-icon")
|
||||
yield CommandSelector(id="command-selector")
|
||||
yield ModelSelector(id="model-selector")
|
||||
yield InfoPanel(id="info-panel")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Initialize the application."""
|
||||
@@ -1584,6 +1731,17 @@ class RuneApp(App):
|
||||
refresh_btn = self.query_one("#refresh-btn", Static)
|
||||
refresh_btn.tooltip = "Click to restart Rune with latest code"
|
||||
|
||||
version_label = self.query_one("#version-label", VersionLabel)
|
||||
version_label.tooltip = "Running version. Checks gitea/master every 5 minutes."
|
||||
def _update_check_worker(initial: bool = False):
|
||||
try:
|
||||
_update_checker.check()
|
||||
self.call_from_thread(version_label.refresh)
|
||||
except Exception:
|
||||
pass
|
||||
self.run_worker(_update_check_worker, thread=True, exclusive=False)
|
||||
self.set_interval(UpdateChecker.CHECK_INTERVAL, _update_check_worker)
|
||||
|
||||
# Welcome messages
|
||||
self.task_channel.add_message(
|
||||
"system",
|
||||
@@ -1717,14 +1875,6 @@ class RuneApp(App):
|
||||
"system", f"[dim]⚠ Autonomy loop failed: {e}[/dim]"
|
||||
)
|
||||
|
||||
def on_static_clicked(self, event: Static.Clicked) -> None:
|
||||
"""Handle refresh button click."""
|
||||
if event.widget.id == "refresh-btn":
|
||||
import subprocess
|
||||
|
||||
self.exit()
|
||||
subprocess.Popen(["rune"])
|
||||
|
||||
# Run independent startup tasks in parallel
|
||||
# Note: These tasks return results; messages are added on the main thread
|
||||
# to avoid Textual widget mutation from parallel threads.
|
||||
@@ -3989,6 +4139,132 @@ class RuneApp(App):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_click(self, event: events.Click) -> None:
|
||||
"""Handle refresh button click (Textual 8.x: Static posts Click, not
|
||||
Static.Clicked — the old on_static_clicked handler NEVER fired)."""
|
||||
if getattr(event.widget, "id", None) != "refresh-btn":
|
||||
return
|
||||
if getattr(self, "_updating", False):
|
||||
self._chat_or_notify("Update already in progress…")
|
||||
return
|
||||
self._updating = True
|
||||
self._chat_or_notify("Checking for updates…")
|
||||
|
||||
def _do_update():
|
||||
backup_branch = None
|
||||
try:
|
||||
fetch = _update_checker._git("fetch", "gitea", "master", timeout=60)
|
||||
if fetch.returncode != 0:
|
||||
self._chat_or_notify(f"Update failed: fetch error ({fetch.stderr.strip()[:120]})")
|
||||
return
|
||||
if not _update_checker.check():
|
||||
import rune as _rune_pkg
|
||||
|
||||
self._chat_or_notify(
|
||||
f"Already up to date (v{_rune_pkg.__version__})"
|
||||
if not _update_checker.last_error
|
||||
else f"Update check failed: {_update_checker.last_error}"
|
||||
)
|
||||
return
|
||||
target = _update_checker.latest_version
|
||||
# Safety: snapshot the current pinned state before moving it
|
||||
backup_branch = f"backup/pinned-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}"
|
||||
_update_checker._git("branch", backup_branch, timeout=20)
|
||||
# Preserve runtime state across the update: the journal is
|
||||
# tracked and locally modified — copy it out, force-checkout
|
||||
# gitea/master (local commits are superseded by the release),
|
||||
# then restore journal continuity.
|
||||
import shutil
|
||||
journal = os.path.join(UpdateChecker.REPO, "memory", "journal", "events.jsonl")
|
||||
stamp = datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')
|
||||
journal_backup = journal + f".pre-update-{stamp}"
|
||||
if os.path.exists(journal):
|
||||
shutil.copy2(journal, journal_backup)
|
||||
# Untracked local files (NIGHT_LOG.md, state/, …) block a
|
||||
# checkout when they exist in the target tree. Move them
|
||||
# aside, checkout, then restore anything the release doesn't
|
||||
# own — nothing is lost.
|
||||
aside = os.path.join(UpdateChecker.REPO, f".pre-update-untracked-{stamp}")
|
||||
os.makedirs(aside, exist_ok=True)
|
||||
st = _update_checker._git("status", "--porcelain", timeout=20)
|
||||
moved = []
|
||||
for line in (st.stdout or "").splitlines():
|
||||
if not line.startswith("??"):
|
||||
continue
|
||||
name = line[3:].strip().rstrip("/")
|
||||
src = os.path.join(UpdateChecker.REPO, name)
|
||||
if os.path.exists(src):
|
||||
dst = os.path.join(aside, os.path.basename(name) or name.replace("/", "_"))
|
||||
shutil.move(src, dst)
|
||||
moved.append((src, dst))
|
||||
co = _update_checker._git("checkout", "-f", "-B", "pinned-live", "gitea/master", timeout=120)
|
||||
for src, dst in moved:
|
||||
if not os.path.exists(src):
|
||||
shutil.move(dst, src)
|
||||
if co.returncode != 0:
|
||||
self._chat_or_notify(
|
||||
f"Update failed: checkout error ({co.stderr.strip()[:120]}). "
|
||||
f"Local state preserved on {backup_branch}."
|
||||
)
|
||||
return
|
||||
if os.path.exists(journal_backup):
|
||||
shutil.copy2(journal_backup, journal)
|
||||
self._chat_or_notify(f"Updated to v{target}. Restarting…")
|
||||
import time as _time
|
||||
_time.sleep(1.0)
|
||||
# Restart: spawn the new instance FIRST (detached), then exit
|
||||
# — if we exit first, a slow shell can kill the whole chain.
|
||||
subprocess.Popen(
|
||||
["bash", "-c", "cd /mnt/workdrive/rune/src && exec /home/nick/.venvs/memory/bin/python3 -m rune"],
|
||||
start_new_session=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
self.exit()
|
||||
except Exception as exc:
|
||||
self._chat_or_notify(f"Update failed: {str(exc)[:160]}")
|
||||
finally:
|
||||
self._updating = False
|
||||
|
||||
self.run_worker(_do_update, thread=True, exclusive=False)
|
||||
|
||||
def _chat_or_notify(self, message: str) -> None:
|
||||
"""Surface update-flow messages without crashing. ALWAYS marshals to
|
||||
the UI thread — workers must never mount widgets directly (a worker
|
||||
thread has no event loop; mounting from it raises)."""
|
||||
def _post():
|
||||
try:
|
||||
self.notify(message, timeout=6)
|
||||
except Exception:
|
||||
try:
|
||||
self.chat_channel.add_message("system", f"[dim]{message}[/dim]")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.call_from_thread(_post)
|
||||
except Exception:
|
||||
# No running app loop (e.g. headless test) — best effort, no raise
|
||||
pass
|
||||
|
||||
def action_toggle_info_panel(self) -> None:
|
||||
"""F1: show contextual detail for the focused element in a fixed
|
||||
panel (never follows the mouse, never covers the inspected widget)."""
|
||||
try:
|
||||
panel = self.query_one("#info-panel", InfoPanel)
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
# Context from the most specific widget that provides detail info
|
||||
if self.focused and hasattr(self.focused, "_detail_info"):
|
||||
title = getattr(self.focused, "model", {}).get("name", "Detail") if hasattr(self.focused, "model") else "Detail"
|
||||
panel.toggle(title, self.focused._detail_info())
|
||||
return
|
||||
status_bar = self.query_one("#status-bar", StatusBar)
|
||||
panel.toggle(self.model, status_bar._detail_tooltip())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def action_focus_task(self) -> None:
|
||||
"""Focus the task channel input."""
|
||||
self.mode = "task"
|
||||
|
||||
Reference in new issue
Block a user