From 284e35edb41b33d2bf34afadbcc5704ca0e8e19d Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Tue, 11 Aug 2026 02:22:07 -0400 Subject: [PATCH] Main menu: five sections, each one an answer to the question MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The menu asks "What would you like to do?" and then grouped its rows by subject, which is not an answer to that. Mesh had grown to seven of the nineteen rows — live watching, passive browsing, and one transmit action in the same bucket — and Tools said nothing at all, since everything in the menu is a tool. So each section now names a doing, and holds what shares it: Message Chat · Channels · Courier · Contacts Watch Dashboard · Live feed · Watchtower · Time machine Explore Map · Mesh walk · Trace target · Trace path · Trophy case This node Device info · Device configuration · Device actions · Send advert Other nodes Repeater admin · TX optimize Four rows moved for a reason. Contacts is the address book the three features above it pick a recipient from, not a mesh view. Time machine is the past tense of the Dashboard, and was stranded in the grab-bag. Send advert is your own node announcing itself — a device action, filed with the mesh views only because it was about the mesh. And Repeater admin and TX optimize are the same job — someone else's radio, over the mesh, with credentials — which the old Device-management/Tools split hid. The two walks join the topology views they walk over, with the boards that score them underneath: see a node, walk to it, get scored. Nothing is renamed (a menu row still names the screen it opens) and no section is now larger than five rows or smaller than two. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 58 ++++++++++++++++++-------------- meshterm/tools/advert.py | 4 +-- meshterm/tools/base.py | 15 +++++---- meshterm/tools/channels.py | 2 +- meshterm/tools/chat.py | 2 +- meshterm/tools/config.py | 4 +-- meshterm/tools/contacts.py | 4 +-- meshterm/tools/courier.py | 2 +- meshterm/tools/dashboard.py | 8 ++--- meshterm/tools/device_actions.py | 4 +-- meshterm/tools/devices.py | 2 +- meshterm/tools/info.py | 4 +-- meshterm/tools/livefeed.py | 4 +-- meshterm/tools/map.py | 4 +-- meshterm/tools/monitor.py | 4 +-- meshterm/tools/records.py | 4 +-- meshterm/tools/repeater_admin.py | 4 +-- meshterm/tools/timemachine.py | 4 +-- meshterm/tools/trace.py | 8 ++--- meshterm/tools/tx_optimize.py | 4 +-- meshterm/tools/walk.py | 4 +-- meshterm/tools/watchtower.py | 4 +-- tests/test_livefeed.py | 7 ++-- tests/test_menus.py | 31 +++++++++++++++++ 24 files changed, 117 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 9e82d03..4245382 100644 --- a/README.md +++ b/README.md @@ -88,47 +88,55 @@ Handheld bring-up (fonts, palette, boot services) lives in ## Features -MeshTerm's menu is organised into four sections. Every interactive screen is listed here -with its scripted equivalent, where one exists. +MeshTerm's menu answers one question — *what would you like to do?* — so its five +sections are named for the doing, not the subject. Every interactive screen is listed +here with its scripted equivalent, where one exists. -### 🛰 Mesh — what's going on out there - -| Feature | What it does | Scripted | -| --- | --- | --- | -| **📊 Dashboard** | The live mesh overview: a two-hour all-packet activity chart with a pulse line, session traffic tallies by packet class, and window RF health beside the radio's own live numbers. Repaints every second. | — | -| **📡 Live feed** | Every packet as it arrives, newest first: time, what the frame *is* (its parsed payload class), and what it is *about* — the node for an advert, the channel for a channel text, sender → recipient for a direct message or a request, the tag for a trace — with reception quality beside it. Enter opens any row in the packet viewer, route graph and all. | — | -| **🌍 Map** | Located nodes plotted over a real OpenStreetMap street basemap rendered as Unicode braille (streets, rivers, place names). Pannable and zoomable; repeaters highlighted and drawn on top. Falls back to a blank grid offline. | `meshterm map` (one-shot render) | -| **🌐 Mesh walk** | The mesh's *observed shape*, walked one node at a time: an evidence graph built from trace walks, firmware routes, overheard relay chains, and repeater neighbour tables. SNR-coloured braille edges, quality bars, Enter to walk, ⌫ to backtrack, type to find any node. The map answers *where*; the walk answers *how it hangs together*. | — | -| **🚨 Watchtower** | A passive sentinel over the nodes you star: silence alarms when a watched node goes quiet, SNR-sag warnings when reception degrades, recovery notes when it returns, and a heads-up when a never-before-seen node appears. Runs in the background all session; unacked alerts show as a header badge. | — | -| **📡 Send advert** | Announce this node to the mesh — a zero-hop or flood advertisement, or share this node's contact card as a QR code. | `meshterm config advert` / `config share` | - -### 💬 Messaging +### 💬 Message — the people on the other end | Feature | What it does | Scripted | | --- | --- | --- | | **💬 Chat** | Live full-screen channel and direct messaging — a scrolling transcript with a pinned input line where sent and received messages stream together. Every message is logged; unread counts show in the menu header. | `meshterm chat send / history / list` | -| **# Channels** | Create, join, reorder, mute, and share mesh channels — with QR codes and `meshcore://` share links. | `meshterm channels list / add / join / import / share / clear` | +| **📻 Channels** | Create, join, reorder, mute, and share mesh channels — with QR codes and `meshcore://` share links. | `meshterm channels list / add / join / import / share / clear` | | **📨 Courier** | Store-and-forward outbox for contacts that aren't reachable yet. Queue a message; it goes out (with ack tracking and polite exponential backoff) the moment the contact is next heard, or at a scheduled time. | `meshterm courier queue / list / send / cancel / clear` | +| **👥 Contacts** | This node and its known contacts — a recency heat-map, overheard packet counts, and full public keys with the path-hash prefix highlighted. | `meshterm contacts` | -### ⚙ Device management +### 📊 Watch — what the mesh is doing, and what it did + +| Feature | What it does | Scripted | +| --- | --- | --- | +| **📊 Dashboard** | The live mesh overview: a two-hour all-packet activity chart with a pulse line, session traffic tallies by packet class, and window RF health beside the radio's own live numbers. Repaints every second. | — | +| **📰 Live feed** | Every packet as it arrives, newest first: time, what the frame *is* (its parsed payload class), and what it is *about* — the node for an advert, the channel for a channel text, sender → recipient for a direct message or a request, the tag for a trace — with reception quality beside it. Enter opens any row in the packet viewer, route graph and all. | — | +| **🚨 Watchtower** | A passive sentinel over the nodes you star: silence alarms when a watched node goes quiet, SNR-sag warnings when reception degrades, recovery notes when it returns, and a heads-up when a never-before-seen node appears. Runs in the background all session; unacked alerts show as a header badge. | — | +| **⏳ Time machine** | Everything the recorder ever heard, as braille charts: reception volume, the median-SNR band, hour-of-day rhythm, packets and nodes per day, and first-ever arrivals — over a switchable 24h / 7d / 30d / all-time window, per node or mesh-wide. | — | +| **🎧 Monitor** | Passive recording is always on in the app. On the CLI, capture a bounded foreground window, tailing each overheard packet and summarising when it ends. It transmits nothing — it only listens. | `meshterm monitor --seconds 60` | + +### 🧭 Explore — where the nodes are and how they reach each other + +| Feature | What it does | Scripted | +| --- | --- | --- | +| **🌍 Map** | Located nodes plotted over a real OpenStreetMap street basemap rendered as Unicode braille (streets, rivers, place names). Pannable and zoomable; repeaters highlighted and drawn on top. Falls back to a blank grid offline. | `meshterm map` (one-shot render) | +| **🌐 Mesh walk** | The mesh's *observed shape*, walked one node at a time: an evidence graph built from trace walks, firmware routes, overheard relay chains, and repeater neighbour tables. SNR-coloured braille edges, quality bars, Enter to walk, ⌫ to backtrack, type to find any node. The map answers *where*; the walk answers *how it hangs together*. | — | +| **🎯 Trace target** | A live trace screen: pick a target and watch each trace stream in hop by hop, with running per-hop medians and reliability. Compose or force a route through specific repeaters. A trace transmits **exactly once** (repeaters can blacklist nodes that burst) — sample more by running it again. | `meshterm trace --target …` | +| **👣 Trace path** | The other half of tracing: compose the whole circuit by hand — out and back whichever way you choose — and walk it. The path composer suggests each next hop from the links actually observed, strongest first, and can fetch a repeater's neighbour table over the mesh when you hold its admin password. | `meshterm trace --path …` | +| **🏆 Trophy case** | Every trace that comes home is scored, on seven boards: longest distance, farthest node, longest single leg, most nodes (with and without revisits), weakest surviving link, and biggest enclosed loop. Records are kept per hash width, and a record walk must be a *trail* — no link crossed twice the same way. | `meshterm records` | + +### 🔧 This node — the radio in your hand | Feature | What it does | Scripted | | --- | --- | --- | | **📋 Device info** | The connected companion's identity and full radio configuration at a glance. | `meshterm info` | +| **🔧 Device configuration** | View and *stage* changes to every setting (name, radio, behaviour, experimental) for review before applying, with TOML backup/restore. | `meshterm config` / `config set ` | | **🔨 Device actions** | Immediate operations on the box itself: clock sync, TOML backup/restore, the identity key, reboot, and factory reset — each acting the moment it's confirmed, with destructive ops gated. | `config backup` / `restore` / `reboot` / … | -| **🔧 Config** | View and *stage* changes to every setting (name, radio, behaviour, experimental) for review before applying, with TOML backup/restore. | `meshterm config` / `config set ` | -| **🗼 Repeater admin** | Set up remote repeaters and room servers over the mesh: log in (remembered or prompted password), then a config-style editor speaking the node's text CLI — including repeater-only knobs (TX delay, airtime factor, advert intervals) — plus one-shot actions and a readline remote command line. | `meshterm repeater-admin ` | +| **📡 Send advert** | Announce this node to the mesh — a zero-hop or flood advertisement, or share this node's contact card as a QR code. | `meshterm config advert` / `config share` | +| **🔌 Device discovery** | Enumerate serial *and* Bluetooth LE companions, pick one interactively (or auto-select the only one present), add a network (TCP) companion by host:port, and remember the last good default. A dropped link is detected live and offers to reconnect. | `meshterm devices` | -### 🧰 Tools — measure and explore +### 🗼 Other nodes — someone else's radio, over the mesh | Feature | What it does | Scripted | | --- | --- | --- | -| **👥 Contacts** | This node and its known contacts — a recency heat-map, overheard packet counts, and full public keys with the path-hash prefix highlighted. | `meshterm contacts` | -| **🧭 Trace** | A live trace screen: pick a target and watch each trace stream in hop by hop, with running per-hop medians and reliability. Compose or force a route through specific repeaters. A trace transmits **exactly once** (repeaters can blacklist nodes that burst) — sample more by running it again. | `meshterm trace --target …` | -| **⚡ TX-power optimization** | Sweep a remote node's transmit power live — coarse, then refine, then verify — watch each level land, and decide whether to apply the winner. | `meshterm tx-optimize --path … [--apply]` | -| **⏳ Time machine** | Everything the recorder ever heard, as braille charts: reception volume, the median-SNR band, hour-of-day rhythm, packets and nodes per day, and first-ever arrivals — over a switchable 24h / 7d / 30d / all-time window, per node or mesh-wide. | — | -| **🎧 Monitor** | Passive recording is always on in the app. On the CLI, capture a bounded foreground window, tailing each overheard packet and summarising when it ends. It transmits nothing — it only listens. | `meshterm monitor --seconds 60` | -| **🔌 Device discovery** | Enumerate serial *and* Bluetooth LE companions, pick one interactively (or auto-select the only one present), add a network (TCP) companion by host:port, and remember the last good default. A dropped link is detected live and offers to reconnect. | `meshterm devices` | +| **🗼 Repeater admin** | Set up remote repeaters and room servers over the mesh: log in (remembered or prompted password), then a config-style editor speaking the node's text CLI — including repeater-only knobs (TX delay, airtime factor, advert intervals) — plus one-shot actions and a readline remote command line. | `meshterm repeater-admin ` | +| **📶 TX optimize** | Sweep a remote node's transmit power live — coarse, then refine, then verify — watch each level land, and decide whether to apply the winner. | `meshterm tx-optimize --path … [--apply]` | ## CLI cookbook diff --git a/meshterm/tools/advert.py b/meshterm/tools/advert.py index 984844d..26b4707 100644 --- a/meshterm/tools/advert.py +++ b/meshterm/tools/advert.py @@ -28,8 +28,8 @@ class SendAdvertTool(Tool): title = "Send advert" icon = "📡" help = "Announce this node to the mesh — zero-hop, flood, or share as QR" - category = "Mesh" - order = 40 # the section's one action, after its views + category = "This node" + order = 40 # your own node announcing itself: a device action, not a view popup = True # dialog-sized: float over the menu instead of replacing it async def prompt_params(self, ctx: AppContext) -> Optional[dict[str, Any]]: diff --git a/meshterm/tools/base.py b/meshterm/tools/base.py index df71e49..2b5a360 100644 --- a/meshterm/tools/base.py +++ b/meshterm/tools/base.py @@ -149,12 +149,15 @@ class Tool(ABC): _REGISTRY: dict[str, Tool] = {} -#: Explicit menu ordering for tool categories. Messaging leads because it holds the -#: first-class, everyday features (chat, channels, the courier); Mesh follows with -#: the views over the mesh itself (dashboard, nodes, map, walk, watchtower, -#: adverts); the rest in a natural workflow order. Categories not listed here sort -#: last, alphabetically, so a new category still appears. -_CATEGORY_ORDER = ["Messaging", "Mesh", "Device management", "Tools"] +#: Explicit menu ordering for tool categories. The menu asks "What would you like to +#: do?", so its sections answer that: each names a *doing*, not a subject, and holds +#: everything that shares it. Message leads (the everyday features, plus the book you +#: pick a recipient from), then Watch (what the mesh is doing, what it did, what to be +#: told about), Explore (where the nodes are, how they reach each other, and the boards +#: that score the walking), then the two scopes a setting can belong to — This node, the +#: radio in your hand, and Other nodes, someone else's over the mesh. Categories not +#: listed here sort last, alphabetically, so a new category still appears. +_CATEGORY_ORDER = ["Message", "Watch", "Explore", "This node", "Other nodes"] def _category_rank(category: str) -> int: diff --git a/meshterm/tools/channels.py b/meshterm/tools/channels.py index 9f7e268..d31122f 100644 --- a/meshterm/tools/channels.py +++ b/meshterm/tools/channels.py @@ -38,7 +38,7 @@ class ChannelsTool(Tool): title = "Channels" icon = "📻" help = "Create, join, and share mesh channels (with QR codes)" - category = "Messaging" + category = "Message" order = 20 async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult: diff --git a/meshterm/tools/chat.py b/meshterm/tools/chat.py index 1d0d998..084bfa4 100644 --- a/meshterm/tools/chat.py +++ b/meshterm/tools/chat.py @@ -58,7 +58,7 @@ class ChatTool(Tool): title = "Chat" icon = "💬" help = "Channel and direct messaging — live chat, with stored history" - category = "Messaging" + category = "Message" order = 10 async def prompt_params(self, ctx: AppContext) -> Optional[dict[str, Any]]: diff --git a/meshterm/tools/config.py b/meshterm/tools/config.py index 7e3bbd3..4b6c977 100644 --- a/meshterm/tools/config.py +++ b/meshterm/tools/config.py @@ -38,8 +38,8 @@ class ConfigTool(Tool): title = "Device configuration" icon = "🔧" help = "View and change device settings — identity, radio, tuning, …" - category = "Device management" - order = 8 + category = "This node" + order = 20 async def prompt_params(self, ctx: AppContext) -> Optional[dict[str, Any]]: """Launch the interactive editor and collect the operations to perform. diff --git a/meshterm/tools/contacts.py b/meshterm/tools/contacts.py index 563aaaf..3dc76d3 100644 --- a/meshterm/tools/contacts.py +++ b/meshterm/tools/contacts.py @@ -18,8 +18,8 @@ class ContactsTool(Tool): title = "Contacts" icon = "👥" help = "List this node and known contacts (last heard, packets, type, key)" - category = "Mesh" - order = 10 # right under the Dashboard: the "who's out there" view + category = "Message" + order = 40 # the address book the three above pick their recipient from async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult: """Query the device and render the contacts list. diff --git a/meshterm/tools/courier.py b/meshterm/tools/courier.py index 4143faa..fa5f0ca 100644 --- a/meshterm/tools/courier.py +++ b/meshterm/tools/courier.py @@ -34,7 +34,7 @@ class CourierTool(Tool): title = "Courier" icon = "📨" help = "Store-and-forward outbox — deliver when the contact is next heard" - category = "Messaging" + category = "Message" order = 30 # after Chat and Channels: the same conversations, minus the waiting around async def prompt_params(self, ctx: AppContext) -> Optional[dict[str, Any]]: diff --git a/meshterm/tools/dashboard.py b/meshterm/tools/dashboard.py index 8c418ca..cb86d08 100644 --- a/meshterm/tools/dashboard.py +++ b/meshterm/tools/dashboard.py @@ -1,9 +1,9 @@ -"""The ``dashboard`` tool: the live mesh overview, at the top of the Mesh section. +"""The ``dashboard`` tool: the live mesh overview, at the top of the Watch section. Opens the full-screen dashboard (see :mod:`meshterm.ui.dashboard_screen`): the two-hour all-packet activity chart with its pulse line, the session's traffic tallies by packet class, and the window's RF health beside the radio's own live numbers. (The per-packet -stream is the Live feed tool, right below.) It leads the Mesh section because it answers +stream is the Live feed tool, right below.) It leads the Watch section because it answers the section's first question — *what's going on out there?* — before any specific tool is reached for. @@ -30,8 +30,8 @@ class DashboardTool(Tool): title = "Dashboard" icon = "📊" help = "Watch the mesh live — activity, traffic, RF health" - category = "Mesh" - order = 5 # the section's overview, above the tools it summarizes + category = "Watch" + order = 10 # the section's overview, above the tools it summarizes async def prompt_params(self, ctx: AppContext) -> Optional[dict[str, Any]]: """Run the live dashboard; there are never parameters to collect. diff --git a/meshterm/tools/device_actions.py b/meshterm/tools/device_actions.py index 052478d..f22ae73 100644 --- a/meshterm/tools/device_actions.py +++ b/meshterm/tools/device_actions.py @@ -30,8 +30,8 @@ class DeviceActionsTool(Tool): title = "Device actions" icon = "🔨" help = "Act on the device itself — clock, backup, identity key, reboot, reset" - category = "Device management" - order = 9 + category = "This node" + order = 30 async def prompt_params(self, ctx: AppContext) -> Optional[dict[str, Any]]: """Run the interactive actions screen; there are never parameters to collect. diff --git a/meshterm/tools/devices.py b/meshterm/tools/devices.py index c8551cc..627964b 100644 --- a/meshterm/tools/devices.py +++ b/meshterm/tools/devices.py @@ -41,7 +41,7 @@ class DevicesTool(Tool): name = "devices" title = "Devices" help = "List attached serial and Bluetooth devices, flagging likely companions" - category = "Device management" + category = "This node" order = 5 menu_visible = False # CLI-only: a startup diagnostic with no place in a connected session diff --git a/meshterm/tools/info.py b/meshterm/tools/info.py index bd342a1..1cadf5d 100644 --- a/meshterm/tools/info.py +++ b/meshterm/tools/info.py @@ -28,8 +28,8 @@ class InfoTool(Tool): title = "Device info" icon = "📋" help = "Show device status, identity, and radio config" - category = "Device management" - order = 11 # after Send advert (10), which slots in behind Device actions + category = "This node" + order = 10 # what it is, before anything that changes it async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult: """Query the device and render its live status plus its full configuration. diff --git a/meshterm/tools/livefeed.py b/meshterm/tools/livefeed.py index 3530da3..9a4dc3a 100644 --- a/meshterm/tools/livefeed.py +++ b/meshterm/tools/livefeed.py @@ -30,8 +30,8 @@ class LiveFeedTool(Tool): title = "Live feed" icon = "📰" help = "Every packet as it arrives, newest first — open any in the viewer" - category = "Mesh" - order = 6 # right under the dashboard it was promoted out of + category = "Watch" + order = 20 # right under the dashboard it was promoted out of async def prompt_params(self, ctx: AppContext) -> Optional[dict[str, Any]]: """Run the live feed; there are never parameters to collect. diff --git a/meshterm/tools/map.py b/meshterm/tools/map.py index b679c34..b22aa37 100644 --- a/meshterm/tools/map.py +++ b/meshterm/tools/map.py @@ -38,8 +38,8 @@ class MapTool(Tool): title = "Map" icon = "🌍" help = "Show mesh nodes on a street map (pannable; repeaters highlighted)" - category = "Mesh" - order = 30 + category = "Explore" + order = 10 async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult: """Gather located nodes and open the interactive map, or render a static one. diff --git a/meshterm/tools/monitor.py b/meshterm/tools/monitor.py index 310b920..441b3a5 100644 --- a/meshterm/tools/monitor.py +++ b/meshterm/tools/monitor.py @@ -38,8 +38,8 @@ class MonitorTool(Tool): title = "Monitor" icon = "🎧" help = "Capture overheard packets live for a while and summarize them" - category = "Tools" - order = 20 + category = "Watch" + order = 50 menu_visible = False # recording is always on; in the menu the header/Nodes/Map show it async def execute(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult: diff --git a/meshterm/tools/records.py b/meshterm/tools/records.py index 4ef5bc3..efb4217 100644 --- a/meshterm/tools/records.py +++ b/meshterm/tools/records.py @@ -32,8 +32,8 @@ class TrophyCaseTool(Tool): title = "Trophy case" icon = "🏆" help = "Record-setting mesh walks, scored from every trace" - category = "Tools" - order = 12 + category = "Explore" + order = 40 # what the two walks above it score into async def prompt_params(self, ctx: AppContext) -> Optional[dict[str, Any]]: """Open the browser; returning ``None`` completes the invocation with no run row. diff --git a/meshterm/tools/repeater_admin.py b/meshterm/tools/repeater_admin.py index bebbf50..d54ddbe 100644 --- a/meshterm/tools/repeater_admin.py +++ b/meshterm/tools/repeater_admin.py @@ -31,8 +31,8 @@ class RepeaterAdminTool(Tool): title = "Repeater admin" icon = "🗼" help = "Set up a remote repeater — settings, actions, command line" - category = "Device management" - order = 12 # after Device info: the remote sibling of the local config tools + category = "Other nodes" + order = 10 # the remote sibling of the local config tools async def prompt_params(self, ctx: AppContext) -> Optional[dict[str, Any]]: """Run the interactive flow; there are never parameters to collect. diff --git a/meshterm/tools/timemachine.py b/meshterm/tools/timemachine.py index 41e535b..e218b75 100644 --- a/meshterm/tools/timemachine.py +++ b/meshterm/tools/timemachine.py @@ -28,8 +28,8 @@ class TimeMachineTool(Tool): title = "Time machine" icon = "⏳" help = "Explore the recorded history — timelines, rhythms, arrivals" - category = "Tools" - order = 18 # after the measurement tools: they make history, this reads it + category = "Watch" + order = 40 # the past tense of the Dashboard above it async def prompt_params(self, ctx: AppContext) -> Optional[dict[str, Any]]: """Run the explorer; there are never parameters to collect. diff --git a/meshterm/tools/trace.py b/meshterm/tools/trace.py index 5b940c6..91e1fbb 100644 --- a/meshterm/tools/trace.py +++ b/meshterm/tools/trace.py @@ -49,8 +49,8 @@ class TraceTool(Tool): title = "Trace target" icon = "🎯" help = "Trace a target over a mirrored route and watch per-hop SNR" - category = "Tools" - order = 10 + category = "Explore" + order = 30 # see a node above, walk to it here async def prompt_params(self, ctx: AppContext) -> Optional[dict[str, Any]]: """Pick the first target for the live screen. @@ -238,8 +238,8 @@ class TracePathTool(Tool): title = "Trace path" icon = "👣" help = "Walk a hand-composed route — out and back your way" - category = "Tools" - order = 11 + category = "Explore" + order = 32 async def prompt_params(self, ctx: AppContext) -> Optional[dict[str, Any]]: """No parameters to gather — a path walk has no target to pick. diff --git a/meshterm/tools/tx_optimize.py b/meshterm/tools/tx_optimize.py index 003e2d8..2f0b521 100644 --- a/meshterm/tools/tx_optimize.py +++ b/meshterm/tools/tx_optimize.py @@ -41,8 +41,8 @@ class TxOptimizeTool(Tool): title = "TX optimize" icon = "📶" help = "Tune a remote node's TX power for the best signal at a target" - category = "Tools" - order = 15 # right after Trace, its measurement sibling + category = "Other nodes" + order = 20 # like Repeater admin: a remote radio, changed over the mesh async def prompt_params(self, ctx: AppContext) -> Optional[dict[str, Any]]: """Pick the tuned link for the live sweep: the admin node, then the target. diff --git a/meshterm/tools/walk.py b/meshterm/tools/walk.py index 6aa8f13..030655f 100644 --- a/meshterm/tools/walk.py +++ b/meshterm/tools/walk.py @@ -36,8 +36,8 @@ class WalkTool(Tool): # the geographic map's 🌍 counterpart. icon = "🌐" help = "Walk the mesh's observed topology — links, SNR, evidence" - category = "Mesh" - order = 32 # beside Map: the logical shape next to the geographic one + category = "Explore" + order = 20 # beside Map: the logical shape next to the geographic one async def prompt_params(self, ctx: AppContext) -> Optional[dict[str, Any]]: """Run the walk; there are never parameters to collect. diff --git a/meshterm/tools/watchtower.py b/meshterm/tools/watchtower.py index 8ce0af6..6078324 100644 --- a/meshterm/tools/watchtower.py +++ b/meshterm/tools/watchtower.py @@ -30,8 +30,8 @@ class WatchtowerTool(Tool): title = "Watchtower" icon = "🚨" help = "Alerts for watched nodes — silence, SNR sag, new arrivals" - category = "Mesh" - order = 35 # after the views: the section's always-on alerting eye + category = "Watch" + order = 30 # after the live views: the section's always-on alerting eye async def prompt_params(self, ctx: AppContext) -> Optional[dict[str, Any]]: """Run the Watchtower screen; there are never parameters to collect. diff --git a/tests/test_livefeed.py b/tests/test_livefeed.py index 15a1c4d..45898ca 100644 --- a/tests/test_livefeed.py +++ b/tests/test_livefeed.py @@ -82,15 +82,16 @@ def _col(line: str, needle: str) -> int: def test_livefeed_tool_registers_under_the_dashboard() -> None: - """The livefeed tool lands in the Mesh section, right below the dashboard.""" + """The livefeed tool lands in the Watch section, right below the dashboard.""" from meshterm.tools import load_all_tools from meshterm.tools.base import all_tools load_all_tools() tools = {t.name: t for t in all_tools()} tool = tools["livefeed"] - assert tool.title == "Live feed" and tool.category == "Mesh" - assert tools["dashboard"].order < tool.order < tools["contacts"].order + assert tool.title == "Live feed" and tool.category == "Watch" + assert tools["dashboard"].category == tool.category + assert tools["dashboard"].order < tool.order < tools["watchtower"].order def test_livefeed_live_events_land_in_the_feed() -> None: diff --git a/tests/test_menus.py b/tests/test_menus.py index 600ebbf..3e2aeca 100644 --- a/tests/test_menus.py +++ b/tests/test_menus.py @@ -141,3 +141,34 @@ def test_fit_cells_measures_display_cells_not_characters() -> None: assert cell_len(fit_cells("日本語の名前", 5)) == 5 # wide chars: truncated by cells assert fit_cells("abcdef", 5).endswith("…") assert fit_cells("ab", 5, align="right") == " ab" + + +def test_main_menu_sections_answer_the_menus_own_question() -> None: + """Each section is a *doing*, in workflow order, and holds only what shares it. + + The menu asks "What would you like to do?", so the grouping is by verb rather than + by subject: the address book sits with the features that message from it, the + recorded history with the live views it is the past tense of, the two walks with the + topology they walk over, and a setting lands under the scope it changes — this radio, + or someone else's over the mesh. + """ + from meshterm.tools import load_all_tools + from meshterm.tools.base import _CATEGORY_ORDER, all_tools + + load_all_tools() + sections: dict[str, list[str]] = {} + for tool in all_tools(): + if tool.menu_visible: + sections.setdefault(tool.category, []).append(tool.name) + + assert list(sections) == _CATEGORY_ORDER # all_tools already sorts them this way + assert sections == { + "Message": ["chat", "channels", "courier", "contacts"], + "Watch": ["dashboard", "livefeed", "watchtower", "timemachine"], + "Explore": ["map", "walk", "trace", "trace-path", "records"], + "This node": ["info", "config", "device-actions", "advert"], + "Other nodes": ["repeater-admin", "tx-optimize"], + } + # No section is so big it stops being a grouping (the old Mesh bucket held seven of + # nineteen rows), and none is a bucket of one. + assert all(2 <= len(names) <= 5 for names in sections.values())