Multi-monitor jumps, screen OCR, and generated sprite art
petctl gains screen verbs: `jump` (1-based number, name, next/prev/ primary/other, or a direction resolved from real geometry), `monitors`, and `read` for OCR of a monitor's contents. - monitors.py: pure layout model + jump-target resolution. The monitor list is published by PetWindow from QGuiApplication.screens() over a queued signal, so the controller and window agree on what "monitor 2" means; xrandr and Qt order screens differently on the same machine. - screen_text.py: pull-only OCR (mss capture + Tesseract/RapidOCR). Nothing captures unless the server asks, and the text rides back up the tool-result relay so Bolt can read a screen mid-turn. Both deps optional, soft-failing with a reason. SCREEN_TEXT=false removes it. - Query verbs are answered in controller._handle_command rather than pet_actions.describe(), because their output is the point. - scripts/generate_bolt_sprites.py draws every frame; walk/ is a side-view cycle stepped by distance travelled, not by the animation timer, so the planted paw tracks the window exactly. sprite.py loads it via EXTRA_ANIMATIONS keyed by name, with has() so callers can decline a placeholder blob. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+82
-4
@@ -20,8 +20,9 @@ from typing import Optional
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
from . import (
|
||||
config, history as history_mod, notifications, pet_actions, quiet,
|
||||
screen_context, server_client, speech_text, updater,
|
||||
config, history as history_mod, monitors as monitors_mod, notifications,
|
||||
pet_actions, quiet, screen_context, screen_text, server_client,
|
||||
speech_text, updater,
|
||||
)
|
||||
from .audio import barge_in, mic, stt, tts, wake_word
|
||||
from .state import PetState, PetStateMachine
|
||||
@@ -53,6 +54,13 @@ class PetController(QObject):
|
||||
# window. Append-only from this thread; the UI only ever snapshots it.
|
||||
self.history = history_mod.ConversationHistory(limit=config.HISTORY_LIMIT)
|
||||
|
||||
# The screen layout, as published by the UI (see set_monitors). Held
|
||||
# here rather than probed, so "monitor 2" means the same thing to the
|
||||
# controller and to the window that has to jump there — see
|
||||
# monitors.py for why that matters.
|
||||
self._monitors: list[monitors_mod.Monitor] = []
|
||||
self._pet_monitor: Optional[int] = None
|
||||
|
||||
# Wake-word sensitivity is live-tunable (tray tuner), so it's read
|
||||
# through a callable on every frame rather than captured per listen.
|
||||
self._wake_threshold = config.WAKE_WORD_THRESHOLD
|
||||
@@ -243,7 +251,7 @@ class PetController(QObject):
|
||||
# What's focused right now rides along, so "what's this error?"
|
||||
# has a referent without you having to describe the window.
|
||||
reply = server_client.converse(
|
||||
screen_context.context_for(text), on_command=self._handle_command
|
||||
self._with_context(text), on_command=self._handle_command
|
||||
)
|
||||
except server_client.ServerError as exc:
|
||||
self.log.emit(f"Server error: {exc}")
|
||||
@@ -254,6 +262,31 @@ class PetController(QObject):
|
||||
self._speak(reply)
|
||||
self._state.transition(PetState.IDLE)
|
||||
|
||||
def _with_context(self, text: str) -> str:
|
||||
"""Everything the server gets alongside what you actually said: the
|
||||
focused window title, and a one-line note about the screen layout so
|
||||
Bolt knows how many monitors there are and where he's standing
|
||||
without having to ask. Only the *layout* rides along for free — the
|
||||
text on those screens costs an OCR pass, so it stays behind
|
||||
`petctl read`."""
|
||||
text = screen_context.context_for(text)
|
||||
if config.MONITOR_CONTEXT:
|
||||
text = monitors_mod.annotate(text, self._monitors, self._pet_monitor)
|
||||
return text
|
||||
|
||||
# ── screen layout, published by the UI ───────────────────────────────
|
||||
|
||||
def set_monitors(self, monitors: list) -> None:
|
||||
"""Slot: the window telling us what screens exist (queued signal)."""
|
||||
self._monitors = list(monitors)
|
||||
self.log.emit(
|
||||
"Screens: " + (monitors_mod.summary(self._monitors) or "none reported")
|
||||
)
|
||||
|
||||
def set_pet_monitor(self, index: int) -> None:
|
||||
"""Slot: the window telling us which screen the pet is standing on."""
|
||||
self._pet_monitor = int(index)
|
||||
|
||||
def _handle_command(self, command: str) -> str:
|
||||
"""Server-relayed command. `petctl ...` drives the pet's body and
|
||||
never reaches a shell; everything else is a real command, exactly as
|
||||
@@ -266,11 +299,56 @@ class PetController(QObject):
|
||||
if action is None:
|
||||
return server_client.run_local_command(command)
|
||||
self.log.emit(f"Pet action: {action}")
|
||||
if action["action"] == "nap":
|
||||
|
||||
# Queries answer from here rather than from pet_actions.describe():
|
||||
# their output *is* the useful part, and it's what the server reads
|
||||
# back off the tool-result relay.
|
||||
kind = action["action"]
|
||||
if kind == "monitors":
|
||||
return monitors_mod.describe(self._monitors, self._pet_monitor)
|
||||
if kind == "read":
|
||||
return self._read_screen(action["target"])
|
||||
if kind == "jump":
|
||||
try:
|
||||
target = monitors_mod.resolve(
|
||||
self._monitors, action["target"], self._pet_monitor
|
||||
)
|
||||
except ValueError as exc:
|
||||
self.log.emit(f"petctl jump: {exc}")
|
||||
return f"[pet] {exc}"
|
||||
# Hand the window a resolved index, so it can't re-resolve the
|
||||
# spec against a different screen ordering.
|
||||
self.action.emit({"action": "jump", "monitor": target.index})
|
||||
return f"[pet] jumped to monitor {target.label}"
|
||||
|
||||
if kind == "nap":
|
||||
self.set_napping(bool(action["enabled"]))
|
||||
self.action.emit(action)
|
||||
return pet_actions.describe(action)
|
||||
|
||||
def _read_screen(self, target: str) -> str:
|
||||
"""`petctl read` — OCR a screen and hand the text back to the server."""
|
||||
if not config.SCREEN_TEXT:
|
||||
return "[pet] screen reading is disabled (set SCREEN_TEXT=true in .env)"
|
||||
if not self._monitors:
|
||||
return "[pet] no monitor information available"
|
||||
limit = config.SCREEN_TEXT_MAX_CHARS
|
||||
if target in ("all", "everything", "*"):
|
||||
self.log.emit(f"Reading all {len(self._monitors)} screens…")
|
||||
return screen_text.read_monitors(self._monitors, limit)
|
||||
if target in ("here", "", "this", "current"):
|
||||
index = self._pet_monitor if self._pet_monitor is not None else 0
|
||||
monitor = self._monitors[min(index, len(self._monitors) - 1)]
|
||||
else:
|
||||
try:
|
||||
monitor = monitors_mod.resolve(
|
||||
self._monitors, target, self._pet_monitor
|
||||
)
|
||||
except ValueError as exc:
|
||||
return f"[pet] {exc}"
|
||||
self.log.emit(f"Reading monitor {monitor.number} ({monitor.name})…")
|
||||
return screen_text.read_monitor(monitor, limit)
|
||||
|
||||
def _speak(self, text: str) -> None:
|
||||
self._state.transition(PetState.TALKING)
|
||||
# Bubble gets the markdown stripped but emoji kept (it can't render
|
||||
|
||||
Reference in New Issue
Block a user