A new Device management entry for administering repeaters and room servers. The shared credentialed-first picker (extracted from TX optimize) chooses the node, login runs on remembered passwords with a one-time prompt otherwise (forgotten on rejection), and the editor lands in the local device-configuration screen's exact lanes — but speaking the node's text CLI through a new public Device.send_remote_command, which brings the repeater-only knobs the local editor never had: TX delay, Direct TX delay, RX delay, airtime factor, advert intervals, flood max, repeat, guest password. Reads cost one paced mesh round trip each, so the editor never bulk-reads on open: values show what the last read said, age-stamped, from a new per-node remote.json cache; Read settings refreshes them under an abortable progress dialog and Apply sends the staged sets the same way, folding confirmed values back into the cache and keeping refused ones staged. Advert, clock sync, password change, and reboot run as confirmed popups. The Command line is a readline-style screen onto the node's raw CLI: per-node history persisted across sessions (↑↓ recall, adjacent-dupe suppression, capped), Tab completion over the known verbs plus every catalog get/set spelling with ghost-text preview, one command in flight at a time. The simulator grows a full repeater CLI so all of it drives against --mock, and a scripted one-shot (repeater-admin <node> <command…>) covers automation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
204 lines
8.0 KiB
Python
204 lines
8.0 KiB
Python
"""The remote command line: talk to a repeater's CLI over the mesh, readline-style.
|
||
|
||
A full-screen terminal onto a logged-in remote node (part of the repeater-admin
|
||
feature): type a command, watch it fly, read the reply in a growing transcript. Every
|
||
command is one mesh transmission, so the screen sends exactly what you commit and one
|
||
at a time — a command in flight parks the prompt until the reply lands (or times out;
|
||
repeaters answer tersely and sometimes not at all).
|
||
|
||
The input line behaves like a shell:
|
||
|
||
* **↑/↓** recall the node's command history, which persists per node (see
|
||
:class:`~meshterm.core.remote_store.RemoteStore`) so last week's incantation is one
|
||
keystroke away next session.
|
||
* **Tab** completes the current text against the known repeater commands — the fixed
|
||
verbs plus every ``get``/``set`` spelling in the settings catalog — and the best
|
||
match previews muted, ghost-text style, after the cursor.
|
||
* **Enter** sends. The transcript keeps every exchange of the session, newest at the
|
||
bottom, and the view follows the prompt as it grows.
|
||
|
||
The screen renders state and routes keys; the owning flow injects ``send`` (which owns
|
||
the radio, the history store, and the reply plumbing) and feeds replies back through
|
||
:meth:`reply` / :meth:`failed`.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Callable, Optional
|
||
|
||
from rich.console import Group
|
||
from rich.text import Text
|
||
|
||
from ..core.remote_config import known_commands
|
||
from .tui.prompt import _LineEditor
|
||
from .tui.render import render_lines
|
||
from .tui.screen import Screen
|
||
from .tui.spinner import Spinner
|
||
|
||
#: The transcript's history depth in exchanges (the body scrolls; this caps memory).
|
||
_LOG_CAP = 400
|
||
|
||
|
||
class RemoteCliScreen(Screen):
|
||
"""A shell-like screen onto one remote node's CLI. The owner does the radio work."""
|
||
|
||
floating = False
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
node_label: str,
|
||
history: list[str],
|
||
send: Callable[[str], None],
|
||
session: object,
|
||
) -> None:
|
||
"""Create the command line over a node's persisted history.
|
||
|
||
Args:
|
||
node_label: The remote node's display name (title and prompt chrome).
|
||
history: The node's prior commands, oldest first (recalled with ↑).
|
||
send: Commits one command (owner-guarded; no-op while one is flying).
|
||
session: The running TUI session (for repaints).
|
||
"""
|
||
super().__init__()
|
||
self.title = f"command line · {node_label}"
|
||
self._node_label = node_label
|
||
self._send = send
|
||
self._session = session
|
||
self._editor = _LineEditor()
|
||
self._log: list[Text] = []
|
||
self._history = list(history)
|
||
self._recall: Optional[int] = None # index into history while ↑/↓ browse it
|
||
self._draft = "" # what was typed before recall began, restored on ↓ past the end
|
||
self._completions = known_commands()
|
||
self.busy = False
|
||
self._spinner = Spinner()
|
||
self._prompt_line = 0 # body index of the input line (pinned into view)
|
||
|
||
# --- owner feed ------------------------------------------------------------------
|
||
|
||
def sent(self, command: str) -> None:
|
||
"""Echo a just-sent command into the transcript and park the prompt."""
|
||
row = Text("❯ ", style="brand")
|
||
row.append(command)
|
||
self._append(row)
|
||
self._history.append(command)
|
||
self._recall = None
|
||
self.busy = True
|
||
self._spinner.reset()
|
||
self._session.invalidate()
|
||
|
||
def reply(self, text: str) -> None:
|
||
"""Land the node's reply (possibly multi-line) and free the prompt."""
|
||
for line in text.splitlines() or [""]:
|
||
self._append(Text(f" {line}"))
|
||
self.busy = False
|
||
self._session.invalidate()
|
||
|
||
def failed(self, note: str, *, error: bool = False) -> None:
|
||
"""Land a timeout/error note and free the prompt."""
|
||
self._append(Text(f" {note}", style="err" if error else "muted"))
|
||
self.busy = False
|
||
self._session.invalidate()
|
||
|
||
def tick(self) -> None:
|
||
"""Advance the in-flight spinner (driven by the owner's animation timer)."""
|
||
self._spinner.tick()
|
||
|
||
def _append(self, row: Text) -> None:
|
||
row.no_wrap = False
|
||
self._log.append(row)
|
||
del self._log[:-_LOG_CAP]
|
||
|
||
# --- input -----------------------------------------------------------------------
|
||
|
||
@property
|
||
def footer_hint(self) -> str: # type: ignore[override]
|
||
"""The footer keys, tracking whether a command is in flight."""
|
||
if self.busy:
|
||
return "waiting for the reply… · PgUp/PgDn scroll · Esc back"
|
||
return "Enter send · ↑↓ history · Tab complete · PgUp/PgDn scroll · Esc back"
|
||
|
||
def handle(self, action: str, data: str = "") -> None:
|
||
"""Edit the buffer, recall history, complete, send, scroll, or dismiss."""
|
||
if action == "enter":
|
||
command = self._editor.text.strip()
|
||
if command and not self.busy:
|
||
self._editor = _LineEditor()
|
||
self._send(command)
|
||
elif action == "up":
|
||
self._recall_step(-1)
|
||
elif action == "down":
|
||
self._recall_step(1)
|
||
elif action == "tab":
|
||
completion = self._completion()
|
||
if completion is not None:
|
||
self._editor = _LineEditor(completion)
|
||
elif action == "pageup":
|
||
self.scroll_pages(-1)
|
||
elif action in ("pagedown",):
|
||
self.scroll_pages(1)
|
||
elif action == "escape":
|
||
self.resolve(None)
|
||
elif self._editor.edit(action, data):
|
||
self._recall = None # typing leaves the history walk; the buffer is a draft
|
||
self._session.invalidate()
|
||
|
||
def _recall_step(self, direction: int) -> None:
|
||
"""Walk the history with ↑/↓, keeping the un-sent draft safe at the new end."""
|
||
if not self._history:
|
||
return
|
||
if self._recall is None:
|
||
if direction > 0:
|
||
return # nothing newer than the draft
|
||
self._draft = self._editor.text
|
||
self._recall = len(self._history) - 1
|
||
else:
|
||
self._recall += direction
|
||
if self._recall >= len(self._history):
|
||
self._recall = None
|
||
self._editor = _LineEditor(self._draft)
|
||
return
|
||
self._recall = max(0, self._recall)
|
||
self._editor = _LineEditor(self._history[self._recall])
|
||
|
||
def _completion(self) -> Optional[str]:
|
||
"""The first known command extending the current text, or ``None``."""
|
||
prefix = self._editor.text.lstrip()
|
||
if not prefix:
|
||
return None
|
||
return next((c for c in self._completions if c.startswith(prefix) and c != prefix),
|
||
None)
|
||
|
||
# --- rendering ---------------------------------------------------------------------
|
||
|
||
def render_body(self, width: int) -> list[str]:
|
||
"""Render the intro, the transcript, and the prompt with its ghost completion."""
|
||
intro = Text(
|
||
f"Talking to {self._node_label} over the mesh — every command is one "
|
||
"transmission.",
|
||
style="muted",
|
||
)
|
||
lines = render_lines(Group(intro, Text(), *self._log), width)
|
||
self._prompt_line = len(lines)
|
||
lines.extend(render_lines(self._prompt(), width))
|
||
self._scroll_total = max(1, len(lines))
|
||
return lines
|
||
|
||
def cursor_line(self) -> Optional[int]:
|
||
"""Pin the prompt into view, so the transcript follows itself as it grows."""
|
||
return self._prompt_line
|
||
|
||
def _prompt(self) -> Text:
|
||
"""The input line: spinner while waiting, else the editor and its ghost text."""
|
||
if self.busy:
|
||
line = self._spinner.text()
|
||
line.append(" waiting for the reply…", style="muted")
|
||
return line
|
||
line = Text("❯ ", style="brand")
|
||
line.append_text(self._editor.render())
|
||
completion = self._completion()
|
||
if completion is not None:
|
||
line.append(completion[len(self._editor.text.lstrip()):], style="faint")
|
||
return line
|