asher-cli 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- asher/__init__.py +1 -0
- asher/__main__.py +11 -0
- asher/app.py +64 -0
- asher/auth.py +104 -0
- asher/cats.py +40 -0
- asher/commands/__init__.py +350 -0
- asher/connection/__init__.py +120 -0
- asher/helpers.py +48 -0
- asher/monitoring/__init__.py +111 -0
- asher/slash-commands/__init__.py +26 -0
- asher/ui/__init__.py +279 -0
- asher_cli-0.0.1.dist-info/METADATA +156 -0
- asher_cli-0.0.1.dist-info/RECORD +16 -0
- asher_cli-0.0.1.dist-info/WHEEL +4 -0
- asher_cli-0.0.1.dist-info/entry_points.txt +2 -0
- asher_cli-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Whisker cloud API connection and authentication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import keyring
|
|
10
|
+
import keyring.errors
|
|
11
|
+
from dotenv import load_dotenv
|
|
12
|
+
from textual import work
|
|
13
|
+
from textual.widgets import RichLog
|
|
14
|
+
|
|
15
|
+
from ..helpers import ts
|
|
16
|
+
|
|
17
|
+
load_dotenv()
|
|
18
|
+
|
|
19
|
+
_SERVICE = "asher-cli"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _keyring_load() -> tuple[str, str]:
|
|
23
|
+
try:
|
|
24
|
+
email = keyring.get_password(_SERVICE, "email") or ""
|
|
25
|
+
password = keyring.get_password(_SERVICE, "password") or ""
|
|
26
|
+
return email, password
|
|
27
|
+
except Exception:
|
|
28
|
+
return "", ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _keyring_save(email: str, password: str) -> bool:
|
|
32
|
+
try:
|
|
33
|
+
keyring.set_password(_SERVICE, "email", email)
|
|
34
|
+
keyring.set_password(_SERVICE, "password", password)
|
|
35
|
+
return True
|
|
36
|
+
except Exception:
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _keyring_delete() -> None:
|
|
41
|
+
for key in ("email", "password"):
|
|
42
|
+
with contextlib.suppress(Exception):
|
|
43
|
+
keyring.delete_password(_SERVICE, key)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ConnectionMixin:
|
|
47
|
+
# declared for type checkers; assigned in AsherApp.__init__
|
|
48
|
+
_account: Any
|
|
49
|
+
_robot: Any
|
|
50
|
+
_pets: list
|
|
51
|
+
|
|
52
|
+
@work(exclusive=True)
|
|
53
|
+
async def _connect_worker(self) -> None:
|
|
54
|
+
log = self.query_one("#log", RichLog) # type: ignore[attr-defined]
|
|
55
|
+
|
|
56
|
+
email, password = _keyring_load()
|
|
57
|
+
|
|
58
|
+
if not email or not password:
|
|
59
|
+
env_email = os.getenv("LITTER_ROBOT_USER") or os.getenv("LR4_EMAIL") or ""
|
|
60
|
+
env_pass = os.getenv("LITTER_ROBOT_PASSWORD") or os.getenv("LR4_PASSWORD") or ""
|
|
61
|
+
email = email or env_email
|
|
62
|
+
password = password or env_pass
|
|
63
|
+
|
|
64
|
+
if not email or not password:
|
|
65
|
+
from textual.widgets import Static # noqa: PLC0415
|
|
66
|
+
|
|
67
|
+
self._is_loading = False # type: ignore[attr-defined]
|
|
68
|
+
self._log_info("No saved credentials found.") # type: ignore[attr-defined]
|
|
69
|
+
self._log_info("Type /login to sign in.") # type: ignore[attr-defined]
|
|
70
|
+
self._set_cat("idle", "not signed in") # type: ignore[attr-defined]
|
|
71
|
+
self.query_one("#hint-bar", Static).update("/login to sign in") # type: ignore[attr-defined]
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
from pylitterbot import Account # noqa: PLC0415
|
|
76
|
+
|
|
77
|
+
self._account = Account()
|
|
78
|
+
await self._account.connect(
|
|
79
|
+
username=email, password=password, load_robots=True, load_pets=True
|
|
80
|
+
)
|
|
81
|
+
self._pets = list(self._account.pets)
|
|
82
|
+
robots = list(self._account.robots)
|
|
83
|
+
|
|
84
|
+
if not robots:
|
|
85
|
+
self._log_err("No Litter Robots found on this account.") # type: ignore[attr-defined]
|
|
86
|
+
self._set_cat("error", "no robots") # type: ignore[attr-defined]
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
self._robot = robots[0]
|
|
90
|
+
if len(robots) > 1:
|
|
91
|
+
self._log_info( # type: ignore[attr-defined]
|
|
92
|
+
f"{len(robots)} robots found — using '{getattr(robots[0], 'name', 'robot #1')}'"
|
|
93
|
+
)
|
|
94
|
+
for i, rb in enumerate(robots):
|
|
95
|
+
model = type(rb).__name__
|
|
96
|
+
self._log_info( # type: ignore[attr-defined]
|
|
97
|
+
f" [{i}] {getattr(rb, 'name', '?')} ({model} serial={getattr(rb, 'serial', '?')})"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
await self._update_last_cat_seen() # type: ignore[attr-defined]
|
|
101
|
+
await self._refresh_status() # type: ignore[attr-defined]
|
|
102
|
+
|
|
103
|
+
t = ts()
|
|
104
|
+
t.append("✓ Connected to ", style="#3fb950")
|
|
105
|
+
name = getattr(self._robot, "name", "robot")
|
|
106
|
+
model = type(self._robot).__name__
|
|
107
|
+
t.append(name, style="bold #e6edf3")
|
|
108
|
+
t.append(f" ({model})", style="#484f58")
|
|
109
|
+
log.write(t)
|
|
110
|
+
self._set_cat("happy", "connected!") # type: ignore[attr-defined]
|
|
111
|
+
|
|
112
|
+
except ImportError:
|
|
113
|
+
self._is_loading = False # type: ignore[attr-defined]
|
|
114
|
+
self._log_err("pylitterbot not installed. Run: pip install pylitterbot") # type: ignore[attr-defined]
|
|
115
|
+
self._set_cat("error", "missing dep") # type: ignore[attr-defined]
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
self._is_loading = False # type: ignore[attr-defined]
|
|
118
|
+
self._log_err(f"Connection failed: {exc}") # type: ignore[attr-defined]
|
|
119
|
+
self._log_warn("Type '/logout' to clear saved credentials and try again.") # type: ignore[attr-defined]
|
|
120
|
+
self._set_cat("error", "auth error") # type: ignore[attr-defined]
|
asher/helpers.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Pure helper functions — no Textual or pylitterbot imports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
|
|
9
|
+
STATUS_COLORS: dict[str, str] = {
|
|
10
|
+
"Ready": "#3fb950", # green
|
|
11
|
+
"Cycling": "#58a6ff", # blue
|
|
12
|
+
"Cat Detected": "#d29922", # amber
|
|
13
|
+
"Drawer Full": "#f85149", # red
|
|
14
|
+
"Offline": "#f85149", # red
|
|
15
|
+
"Sleeping": "#484f58", # muted
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def fmt_ago(dt: datetime | None) -> str:
|
|
20
|
+
if dt is None:
|
|
21
|
+
return "never"
|
|
22
|
+
if dt.tzinfo is None:
|
|
23
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
24
|
+
s = int((datetime.now(timezone.utc) - dt).total_seconds())
|
|
25
|
+
if s < 60:
|
|
26
|
+
return f"{s}s ago"
|
|
27
|
+
if s < 3600:
|
|
28
|
+
return f"{s // 60}m ago"
|
|
29
|
+
if s < 86400:
|
|
30
|
+
return f"{s // 3600}h ago"
|
|
31
|
+
return f"{s // 86400}d ago"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def drawer_bar(pct: float, width: int = 14) -> Text:
|
|
35
|
+
filled = max(0, min(width, int(width * pct / 100)))
|
|
36
|
+
bar = "█" * filled + "░" * (width - filled)
|
|
37
|
+
color = "#f85149" if pct >= 85 else "#d29922" if pct >= 60 else "#3fb950"
|
|
38
|
+
t = Text()
|
|
39
|
+
t.append("[", style="#484f58")
|
|
40
|
+
t.append(bar, style=color)
|
|
41
|
+
t.append("]", style="#484f58")
|
|
42
|
+
return t
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def ts() -> Text:
|
|
46
|
+
t = Text()
|
|
47
|
+
t.append(f"[{datetime.now().strftime('%H:%M:%S')}] ", style="#484f58")
|
|
48
|
+
return t
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Robot status polling and refresh."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
from textual import work
|
|
9
|
+
from textual.widgets import Static
|
|
10
|
+
|
|
11
|
+
from ..helpers import drawer_bar, fmt_ago
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MonitoringMixin:
|
|
15
|
+
# declared for type checkers; assigned in AsherApp.__init__
|
|
16
|
+
_robot: Any
|
|
17
|
+
_pets: list
|
|
18
|
+
_cat_mode: str
|
|
19
|
+
_last_cat_seen: Any
|
|
20
|
+
|
|
21
|
+
async def _update_last_cat_seen(self) -> None:
|
|
22
|
+
"""Cache the timestamp of the most recent cat-detection event from activity history."""
|
|
23
|
+
if self._robot is None:
|
|
24
|
+
return
|
|
25
|
+
try:
|
|
26
|
+
acts = await self._robot.get_activity_history(limit=10)
|
|
27
|
+
for act in acts:
|
|
28
|
+
w = getattr(act, "weight", None)
|
|
29
|
+
action: Any = getattr(act, "action", None)
|
|
30
|
+
action_text = (
|
|
31
|
+
action.text if hasattr(action, "text") else str(action or "")
|
|
32
|
+
).lower()
|
|
33
|
+
if (w is not None and float(w) > 0) or "cat" in action_text:
|
|
34
|
+
ts_dt = getattr(act, "timestamp", None)
|
|
35
|
+
if ts_dt is not None:
|
|
36
|
+
self._last_cat_seen = ts_dt
|
|
37
|
+
return
|
|
38
|
+
except Exception:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
async def _refresh_status(self) -> None:
|
|
42
|
+
r = self._robot
|
|
43
|
+
if r is None:
|
|
44
|
+
return
|
|
45
|
+
self._is_loading = False
|
|
46
|
+
|
|
47
|
+
name = getattr(r, "name", "—")
|
|
48
|
+
online = getattr(r, "is_online", False)
|
|
49
|
+
drawer = float(getattr(r, "waste_drawer_level", 0) or 0)
|
|
50
|
+
status = getattr(r, "status", None)
|
|
51
|
+
sleeping = getattr(r, "sleeping", False)
|
|
52
|
+
last_seen = self._last_cat_seen or getattr(r, "last_seen", None)
|
|
53
|
+
|
|
54
|
+
status_str = status.value if status else ("Sleeping" if sleeping else "Ready")
|
|
55
|
+
|
|
56
|
+
weight_val = "—"
|
|
57
|
+
try:
|
|
58
|
+
w = getattr(r, "pet_weight", None)
|
|
59
|
+
if w is not None and float(w) > 0:
|
|
60
|
+
weight_val = f"{float(w):.1f} lb"
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
pet_name = self._pets[0].name if self._pets else None
|
|
65
|
+
|
|
66
|
+
self.query_one("#robot-lbl", Static).update(Text(name, style="bold #e6edf3")) # type: ignore[attr-defined]
|
|
67
|
+
|
|
68
|
+
online_lbl = self.query_one("#online-lbl", Static) # type: ignore[attr-defined]
|
|
69
|
+
if online:
|
|
70
|
+
online_lbl.update(Text("● ONLINE", style="bold #3fb950"))
|
|
71
|
+
else:
|
|
72
|
+
online_lbl.update(Text("○ OFFLINE", style="bold #f85149"))
|
|
73
|
+
|
|
74
|
+
self.query_one("#status-lbl", Static).update( # type: ignore[attr-defined]
|
|
75
|
+
Text(f"[{status_str}]", style="#8b949e")
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
bar = drawer_bar(drawer)
|
|
79
|
+
dt = Text()
|
|
80
|
+
dt.append("Drawer ", style="#484f58")
|
|
81
|
+
dt.append_text(bar)
|
|
82
|
+
dt.append(f" {drawer:.0f}%", style="#8b949e")
|
|
83
|
+
self.query_one("#drawer-lbl", Static).update(dt) # type: ignore[attr-defined]
|
|
84
|
+
|
|
85
|
+
visit_label = "Last visit" if self._last_cat_seen else "Last seen"
|
|
86
|
+
self.query_one("#clean-lbl", Static).update( # type: ignore[attr-defined]
|
|
87
|
+
Text(f"{visit_label} {fmt_ago(last_seen)}", style="#484f58")
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
wt_text = Text()
|
|
91
|
+
if pet_name:
|
|
92
|
+
wt_text.append(pet_name, style="#8b949e")
|
|
93
|
+
wt_text.append(" 🐱 ", style="#484f58")
|
|
94
|
+
else:
|
|
95
|
+
wt_text.append("cat ", style="#484f58")
|
|
96
|
+
wt_text.append(weight_val, style="#8b949e")
|
|
97
|
+
self.query_one("#weight-lbl", Static).update(wt_text) # type: ignore[attr-defined]
|
|
98
|
+
|
|
99
|
+
if drawer >= 85 and self._cat_mode not in ("cleaning", "error"):
|
|
100
|
+
self._set_cat("full", "drawer full!") # type: ignore[attr-defined]
|
|
101
|
+
|
|
102
|
+
@work(exclusive=True)
|
|
103
|
+
async def _poll_status_interval(self) -> None:
|
|
104
|
+
if self._robot is None:
|
|
105
|
+
return
|
|
106
|
+
try:
|
|
107
|
+
await self._robot.refresh()
|
|
108
|
+
await self._update_last_cat_seen()
|
|
109
|
+
await self._refresh_status()
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Slash-command registry — app management commands (prefixed with /).
|
|
2
|
+
|
|
3
|
+
Convention
|
|
4
|
+
----------
|
|
5
|
+
- Normal commands (no prefix): robot actions — clean, status, lock, etc.
|
|
6
|
+
- Slash commands (/prefix): app management — /login, /logout, /exit, /reload, …
|
|
7
|
+
- Special cases (no prefix OK): exit, quit — accepted both ways.
|
|
8
|
+
|
|
9
|
+
Dispatch flow
|
|
10
|
+
-------------
|
|
11
|
+
on_input_submitted → raw.startswith("/") → _run_slash_cmd() (CommandsMixin)
|
|
12
|
+
→ otherwise → _run_cmd() (CommandsMixin)
|
|
13
|
+
|
|
14
|
+
Current slash commands (implemented in asher/commands/__init__.py)
|
|
15
|
+
------------------------------------------------------------------
|
|
16
|
+
/login — show login screen; save credentials to keyring; reconnect
|
|
17
|
+
/logout — delete credentials from keyring; exit
|
|
18
|
+
/exit — exit the app
|
|
19
|
+
/help — show help (also available without slash)
|
|
20
|
+
|
|
21
|
+
To add a new slash command
|
|
22
|
+
--------------------------
|
|
23
|
+
1. Add a branch in _run_slash_cmd() in asher/commands/__init__.py.
|
|
24
|
+
2. Implement _cmd_<name>() on CommandsMixin.
|
|
25
|
+
3. Add an entry to the slash_cmds list in _show_help().
|
|
26
|
+
"""
|
asher/ui/__init__.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""UI layout, CSS, cat panel, and log helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError
|
|
6
|
+
from importlib.metadata import version as pkg_version
|
|
7
|
+
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
from textual.app import ComposeResult
|
|
10
|
+
from textual.containers import Container
|
|
11
|
+
from textual.widgets import Input, RichLog, Static
|
|
12
|
+
|
|
13
|
+
from ..cats import CATS
|
|
14
|
+
from ..helpers import ts
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
VERSION = pkg_version("asher-cli")
|
|
18
|
+
except PackageNotFoundError:
|
|
19
|
+
VERSION = "dev"
|
|
20
|
+
|
|
21
|
+
_CSS = """
|
|
22
|
+
Screen {
|
|
23
|
+
background: #0d1117;
|
|
24
|
+
color: #c9d1d9;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* ── Status bar ── */
|
|
28
|
+
#status-bar {
|
|
29
|
+
background: #161b22;
|
|
30
|
+
height: 4;
|
|
31
|
+
border-bottom: solid #30363d;
|
|
32
|
+
dock: top;
|
|
33
|
+
padding: 0;
|
|
34
|
+
layout: vertical;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.srow {
|
|
38
|
+
height: 2;
|
|
39
|
+
layout: horizontal;
|
|
40
|
+
align: left middle;
|
|
41
|
+
padding: 0 2;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.srow:first-child {
|
|
45
|
+
border-bottom: solid #30363d;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.sep {
|
|
49
|
+
color: #30363d;
|
|
50
|
+
width: auto;
|
|
51
|
+
height: 1;
|
|
52
|
+
padding: 0 1;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.chunk {
|
|
56
|
+
width: auto;
|
|
57
|
+
padding: 0 2 0 0;
|
|
58
|
+
height: 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* ── Main ── */
|
|
62
|
+
#main-area {
|
|
63
|
+
layout: horizontal;
|
|
64
|
+
height: 1fr;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#log {
|
|
68
|
+
width: 1fr;
|
|
69
|
+
height: 1fr;
|
|
70
|
+
background: #0d1117;
|
|
71
|
+
padding: 1 2;
|
|
72
|
+
overflow-x: hidden;
|
|
73
|
+
scrollbar-background: #161b22;
|
|
74
|
+
scrollbar-color: #30363d;
|
|
75
|
+
scrollbar-color-hover: #58a6ff;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* ── Cat panel ── */
|
|
79
|
+
#cat-panel {
|
|
80
|
+
width: 30;
|
|
81
|
+
background: #0d1117;
|
|
82
|
+
border-left: solid #21262d;
|
|
83
|
+
align: center middle;
|
|
84
|
+
padding: 1 2;
|
|
85
|
+
height: 1fr;
|
|
86
|
+
layout: vertical;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#cat-art {
|
|
90
|
+
color: #58a6ff;
|
|
91
|
+
text-align: left;
|
|
92
|
+
width: 26;
|
|
93
|
+
content-align: left middle;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#cat-label {
|
|
97
|
+
color: #484f58;
|
|
98
|
+
text-style: italic;
|
|
99
|
+
text-align: left;
|
|
100
|
+
width: 26;
|
|
101
|
+
padding-top: 1;
|
|
102
|
+
height: 3;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/* ── Input area (outer dock + hint below the box) ── */
|
|
106
|
+
#bottom-dock {
|
|
107
|
+
dock: bottom;
|
|
108
|
+
height: 4;
|
|
109
|
+
background: #161b22;
|
|
110
|
+
layout: vertical;
|
|
111
|
+
padding: 0 0 0 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
#input-bar {
|
|
115
|
+
background: #161b22;
|
|
116
|
+
height: 3;
|
|
117
|
+
border-top: solid #30363d;
|
|
118
|
+
border-bottom: solid #30363d;
|
|
119
|
+
layout: vertical;
|
|
120
|
+
padding: 0 2;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#input-row {
|
|
124
|
+
layout: horizontal;
|
|
125
|
+
height: 1;
|
|
126
|
+
align: left middle;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
#prompt {
|
|
130
|
+
color: #3fb950;
|
|
131
|
+
width: auto;
|
|
132
|
+
text-style: bold;
|
|
133
|
+
padding: 0 1 0 0;
|
|
134
|
+
height: 1;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
#hint-bar {
|
|
138
|
+
color: #484f58;
|
|
139
|
+
height: 1;
|
|
140
|
+
padding: 0 2;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#cmd-input {
|
|
144
|
+
width: 1fr;
|
|
145
|
+
background: #161b22;
|
|
146
|
+
color: #e6edf3;
|
|
147
|
+
border: none;
|
|
148
|
+
height: 1;
|
|
149
|
+
padding: 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
Input {
|
|
153
|
+
border: none;
|
|
154
|
+
background: #161b22;
|
|
155
|
+
padding: 0;
|
|
156
|
+
}
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
_SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class UIMixin:
|
|
164
|
+
CSS: str = _CSS
|
|
165
|
+
|
|
166
|
+
# declared for type checkers; assigned in AsherApp.__init__
|
|
167
|
+
_cat_mode: str
|
|
168
|
+
_cat_frame: int
|
|
169
|
+
_is_loading: bool
|
|
170
|
+
_spinner_idx: int
|
|
171
|
+
|
|
172
|
+
def compose(self) -> ComposeResult:
|
|
173
|
+
with Container(id="status-bar"):
|
|
174
|
+
with Container(classes="srow"):
|
|
175
|
+
yield Static("", id="title-lbl", classes="chunk")
|
|
176
|
+
yield Static("", id="robot-lbl", classes="chunk")
|
|
177
|
+
yield Static("", id="online-lbl", classes="chunk")
|
|
178
|
+
yield Static("", id="status-lbl", classes="chunk")
|
|
179
|
+
with Container(classes="srow"):
|
|
180
|
+
yield Static("", id="drawer-lbl", classes="chunk")
|
|
181
|
+
yield Static("│", classes="sep")
|
|
182
|
+
yield Static("", id="weight-lbl", classes="chunk")
|
|
183
|
+
yield Static("│", classes="sep")
|
|
184
|
+
yield Static("", id="clean-lbl", classes="chunk")
|
|
185
|
+
|
|
186
|
+
with Container(id="main-area"):
|
|
187
|
+
yield RichLog(id="log", highlight=True, markup=True, wrap=True, min_width=0)
|
|
188
|
+
with Container(id="cat-panel"):
|
|
189
|
+
yield Static(CATS["idle"], id="cat-art") # type: ignore[arg-type]
|
|
190
|
+
yield Static("idle", id="cat-label")
|
|
191
|
+
|
|
192
|
+
with Container(id="bottom-dock"):
|
|
193
|
+
with Container(id="input-bar"), Container(id="input-row"):
|
|
194
|
+
yield Static(">", id="prompt")
|
|
195
|
+
yield Input(placeholder="type a command (help for list)…", id="cmd-input")
|
|
196
|
+
yield Static(
|
|
197
|
+
"help · clean · status · history · /login · /logout · quit",
|
|
198
|
+
id="hint-bar",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def _refresh_title(self) -> None:
|
|
202
|
+
t = Text()
|
|
203
|
+
t.append("◆ ", style="bold #58a6ff")
|
|
204
|
+
t.append("Asher CLI", style="bold #e6edf3")
|
|
205
|
+
t.append(f" v{VERSION}", style="#484f58")
|
|
206
|
+
self.query_one("#title-lbl", Static).update(t) # type: ignore[attr-defined]
|
|
207
|
+
|
|
208
|
+
def _show_loading_state(self) -> None:
|
|
209
|
+
self.query_one("#online-lbl", Static).update( # type: ignore[attr-defined]
|
|
210
|
+
Text(f"{_SPINNER[0]} connecting…", style="#484f58")
|
|
211
|
+
)
|
|
212
|
+
dash = Text("—", style="#30363d")
|
|
213
|
+
for wid in ("#robot-lbl", "#status-lbl", "#drawer-lbl", "#weight-lbl", "#clean-lbl"):
|
|
214
|
+
self.query_one(wid, Static).update(dash) # type: ignore[attr-defined]
|
|
215
|
+
|
|
216
|
+
def _show_welcome(self) -> None:
|
|
217
|
+
log = self.query_one("#log", RichLog) # type: ignore[attr-defined]
|
|
218
|
+
log.write("")
|
|
219
|
+
log.write(
|
|
220
|
+
Text.from_markup(" [bold #58a6ff]◆ Asher CLI[/] [#484f58]— Litter Robot Dashboard[/]")
|
|
221
|
+
)
|
|
222
|
+
log.write(Text.from_markup(" [#484f58]Connecting to Whisker cloud API…[/]"))
|
|
223
|
+
log.write(
|
|
224
|
+
Text.from_markup(
|
|
225
|
+
" [#484f58]Type [/][#3fb950]help[/][#484f58] to see available commands.[/]"
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
log.write(Text.from_markup(" [#21262d]" + "─" * 52 + "[/]"))
|
|
229
|
+
log.write("")
|
|
230
|
+
|
|
231
|
+
def _set_cat(self, mode: str, label: str = "") -> None:
|
|
232
|
+
self._cat_mode = mode
|
|
233
|
+
self._cat_frame = 0
|
|
234
|
+
cats = CATS.get(mode, CATS["idle"])
|
|
235
|
+
frame = cats[0] if isinstance(cats, list) else cats
|
|
236
|
+
color = "#f85149" if mode == "error" else "#d29922" if mode == "full" else "#58a6ff"
|
|
237
|
+
self.query_one("#cat-art", Static).update(Text(frame, style=color)) # type: ignore[attr-defined]
|
|
238
|
+
self.query_one("#cat-label", Static).update( # type: ignore[attr-defined]
|
|
239
|
+
Text(label or mode, style="italic #484f58")
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
def _tick_cat(self) -> None:
|
|
243
|
+
if self._is_loading:
|
|
244
|
+
self._spinner_idx = (self._spinner_idx + 1) % len(_SPINNER)
|
|
245
|
+
self.query_one("#online-lbl", Static).update( # type: ignore[attr-defined]
|
|
246
|
+
Text(f"{_SPINNER[self._spinner_idx]} connecting…", style="#484f58")
|
|
247
|
+
)
|
|
248
|
+
cats = CATS.get(self._cat_mode, CATS["idle"])
|
|
249
|
+
if not isinstance(cats, list):
|
|
250
|
+
return
|
|
251
|
+
self._cat_frame = (self._cat_frame + 1) % len(cats)
|
|
252
|
+
frame = cats[self._cat_frame]
|
|
253
|
+
self.query_one("#cat-art", Static).update(Text(frame, style="#58a6ff")) # type: ignore[attr-defined]
|
|
254
|
+
|
|
255
|
+
def _log_ok(self, msg: str) -> None:
|
|
256
|
+
t = ts()
|
|
257
|
+
t.append(f"✓ {msg}", style="#3fb950")
|
|
258
|
+
self.query_one("#log", RichLog).write(t) # type: ignore[attr-defined]
|
|
259
|
+
|
|
260
|
+
def _log_err(self, msg: str) -> None:
|
|
261
|
+
t = ts()
|
|
262
|
+
t.append(f"✖ {msg}", style="#f85149")
|
|
263
|
+
self.query_one("#log", RichLog).write(t) # type: ignore[attr-defined]
|
|
264
|
+
|
|
265
|
+
def _log_warn(self, msg: str) -> None:
|
|
266
|
+
t = ts()
|
|
267
|
+
t.append(f"⚠ {msg}", style="#d29922")
|
|
268
|
+
self.query_one("#log", RichLog).write(t) # type: ignore[attr-defined]
|
|
269
|
+
|
|
270
|
+
def _log_info(self, msg: str) -> None:
|
|
271
|
+
t = ts()
|
|
272
|
+
t.append(f" {msg}", style="#8b949e")
|
|
273
|
+
self.query_one("#log", RichLog).write(t) # type: ignore[attr-defined]
|
|
274
|
+
|
|
275
|
+
def action_clear_log(self) -> None:
|
|
276
|
+
self.query_one("#log", RichLog).clear() # type: ignore[attr-defined]
|
|
277
|
+
|
|
278
|
+
def action_blur_input(self) -> None:
|
|
279
|
+
self.query_one("#cmd-input", Input).blur() # type: ignore[attr-defined]
|