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())