Compare commits

...
Author SHA1 Message Date
Nick e949c1ec8c feat(tui): non-occluding tooltip redesign
auto-version.yml (push) Invalid workflow file
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / canary (push) Failing after 37s
CI Pipeline / lint (push) Successful in 40s
CI Pipeline / unit-tests (push) Failing after 43s
CI Pipeline / type-check (push) Successful in 36s
CI Pipeline / gitea-offline (push) Successful in 22s
CI Pipeline / required-checks (push) Skipped
- StatusBar/ModelItem hover tooltips shortened to one-line summaries
  (the old 5-line tooltips covered the chat log and model list on every
  hover)
- Detail content (model family, size, strengths, tips) moved to an
  InfoPanel: F1 toggles a docked overlay that appears bottom-right,
  never follows the mouse, never covers the inspected element
- InfoPanel is context-aware: focused widget with _detail_info wins
  (ModelItem in the selector), otherwise StatusBar._detail_tooltip
- TOOLTIP_DELAY raised 0.5s -> 1.2s so quick mouse passes don't trigger
  popups
- StatusBar._detail_tooltip cached per model (was a disk read inside
  render() on every token/latency refresh)
2026-09-06 22:02:20 +01:00
Nick 1791f7655b fix(tui): textual events import in correct block
CI Pipeline / lint (push) Successful in 24s
CI Pipeline / unit-tests (push) Successful in 31s
CI Pipeline / canary (push) Failing after 28s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 22s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / required-checks (push) Skipped
2026-09-06 20:01:45 +01:00
Nick ae8d644438 feat(tui): port update-aware footer + working refresh to the release lineage
CI Pipeline / lint (push) Failing after 17s
CI Pipeline / unit-tests (push) Successful in 31s
CI Pipeline / canary (push) Failing after 29s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 22s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
The update-aware footer (UpdateChecker, red update render, full on_click
update flow) existed only on the old pinned lineage. After refresh updated
pinned-live to gitea/master, the new TUI booted WITHOUT the feature — dead
refresh button again. Ported: UpdateChecker 5-min active checks, red render,
on_click(events.Click) update flow (backup branch, journal preservation,
untracked-file handling, guarded checkout), UI-thread-safe _chat_or_notify,
_run_startup_tasks split. Removed dead on_static_clicked. Headless verified:
click -> checkout gitea/master -> spawn new TUI -> exit.
2026-09-06 20:00:41 +01:00
+301 -25
View File
@@ -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"