[ADD] script todo: télémétrie de navigation + diagramme arborescent (TUI)

Nouvelle entrée « [5] Télémétrie de navigation (TUI) » dans le menu principal.

- Enregistrement : Todo._menu_header (seul constructeur du fil d'Ariane)
  appelle todo_telemetry.record(fil) à chaque affichage de menu. Dédup des
  ré-affichages consécutifs -> on ne compte que les TRANSITIONS de
  navigation. Persistant dans ~/.erplibre/todo_telemetry.json. Best-effort :
  ne casse jamais la navigation.
- Visualisation : todo_telemetry.run_tui() ouvre un TUI Textual affichant
  l'ARBRE des fonctionnalités visitées (chaque nœud = un menu, avec son
  nombre de visites), trié par usage décroissant. Touches : q (quitter),
  r (réinitialiser), e (tout déplier). Sommaire : total navigations + menus.

Validé headless : enregistrement + dédup, arbre imbriqué à compteurs,
montage du TUI, peuplement, touches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mathieu Benoit 2026-07-29 09:35:31 +00:00
parent 62ea6ebb7f
commit f2cb9be665
3 changed files with 191 additions and 0 deletions

View file

@ -129,6 +129,7 @@ class TODO:
[2] {t("Install")}
[3] {t("Question")}
[4] {t("Fork - Open TODO in a new tab")}
[5] {t("Navigation telemetry (TUI)")}
[0] {t("Quit")}
"""
while True:
@ -160,6 +161,8 @@ class TODO:
# )
cmd = "make todo"
self.execute.exec_command_live(cmd, source_erplibre=True)
elif status == "5":
self._todo_telemetry_tui()
# elif status == "3" or status == "install":
# print("install")
else:
@ -565,8 +568,24 @@ class TODO:
header = ""
if crumbs:
header = "📍 " + " ".join(crumbs) + "\n"
# Télémétrie de navigation (best-effort, ne casse jamais le menu).
try:
from script.todo import todo_telemetry
todo_telemetry.record(" ".join(crumbs))
except Exception:
pass
return header + t("Command:")
def _todo_telemetry_tui(self):
"""Ouvre le TUI arborescent de télémétrie de navigation."""
try:
from script.todo.todo_telemetry import run_tui
run_tui()
except ImportError:
print(t("Install textual for the telemetry TUI (pip)."))
def fill_help_info(self, choices):
# Une entrée {"section": "..."} affiche un titre de section SANS
# consommer de numéro : la numérotation reste continue sur les vraies

View file

@ -1282,6 +1282,30 @@ TRANSLATIONS = {
"fr": "suivi d'installation",
"en": "install monitoring",
},
"Navigation telemetry (TUI)": {
"fr": "Télémétrie de navigation (TUI)",
"en": "Navigation telemetry (TUI)",
},
"TODO navigation telemetry": {
"fr": "TODO — télémétrie de navigation",
"en": "TODO navigation telemetry",
},
"navigations": {
"fr": "navigations",
"en": "navigations",
},
"menus": {
"fr": "menus",
"en": "menus",
},
"Telemetry reset.": {
"fr": "Télémétrie réinitialisée.",
"en": "Telemetry reset.",
},
"Install textual for the telemetry TUI (pip).": {
"fr": "Installez textual pour le TUI de télémétrie (pip).",
"en": "Install textual for the telemetry TUI (pip).",
},
"Resize a VM disk": {
"fr": "Redimensionner le disque d'une VM",
"en": "Resize a VM disk",

View file

@ -0,0 +1,148 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Télémétrie de navigation du CLI TODO.
Enregistre les fils d'Ariane visités (« A B C » -> compteur) et affiche un
DIAGRAMME arborescent des fonctionnalités dans un TUI Textual, trié par usage.
- record(path) : appelé par Todo._menu_header à chaque affichage de menu ; on
dédupe les -affichages consécutifs pour ne compter que les TRANSITIONS.
- run_tui() : ouvre l'arbre de navigation (compteurs par menu).
"""
from __future__ import annotations
import json
import os
import time
from pathlib import Path
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
# Dernier chemin enregistré : évite de recompter chaque ré-affichage du même
# menu (une commande qui revient au menu ne compte pas comme une navigation).
_LAST = [None]
def _path() -> Path:
base = Path(os.path.expanduser("~/.erplibre"))
base.mkdir(parents=True, exist_ok=True)
return base / "todo_telemetry.json"
def load() -> dict:
try:
return json.loads(_path().read_text())
except (OSError, ValueError):
return {"paths": {}}
def _save(data: dict) -> None:
try:
_path().write_text(json.dumps(data, ensure_ascii=False, indent=2))
except OSError:
pass
def record(path: str) -> None:
"""Incrémente le compteur du menu `path`. Best-effort : ne lève jamais
(la télémétrie ne doit jamais casser la navigation)."""
if not path or path == _LAST[0]:
return
_LAST[0] = path
try:
data = load()
paths = data.setdefault("paths", {})
paths[path] = paths.get(path, 0) + 1
data["updated"] = int(time.time())
_save(data)
except Exception:
pass
def reset() -> None:
_save({"paths": {}})
def _nested(paths: dict) -> dict:
"""{« A B C »: count} -> arbre {nom: {'count', 'children'}}. Chaque
menu enregistre son propre chemin, donc chaque nœud a son compteur."""
root: dict = {}
for path, count in paths.items():
cur = root
parts = path.split(" ")
for i, name in enumerate(parts):
node = cur.setdefault(name, {"count": 0, "children": {}})
if i == len(parts) - 1:
node["count"] += count
cur = node["children"]
return root
def run_tui(run_app: bool = True):
"""Ouvre le TUI arborescent. `run_app=False` renvoie l'app (tests)."""
from textual.app import App, ComposeResult
from textual.widgets import Footer, Header, Static, Tree
class Telemetry(App):
CSS = """
#summary { height: 1; color: $text-muted; }
Tree { border: solid $accent; }
"""
BINDINGS = [
("q", "quit", "Quitter"),
("r", "reset", "Réinitialiser"),
("e", "expand_all", "Tout déplier"),
]
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
yield Static("", id="summary")
yield Tree("📍 TODO", id="nav")
yield Footer()
def on_mount(self):
self.title = t("TODO navigation telemetry")
self._populate()
def _populate(self):
data = load()
paths = data.get("paths", {})
total = sum(paths.values())
self.query_one("#summary", Static).update(
f" {total} {t('navigations')} · {len(paths)} {t('menus')}"
)
tree = self.query_one("#nav", Tree)
tree.clear()
nested = _nested(paths)
todo = nested.get("TODO", {"count": 0, "children": nested})
tree.root.set_label(f"📍 TODO ({todo['count']})")
self._add(tree.root, todo["children"])
tree.root.expand()
def _add(self, tnode, children):
# Tri par visites décroissantes : les plus utilisés en tête.
for name, node in sorted(
children.items(), key=lambda kv: -kv[1]["count"]
):
child = tnode.add(f"{name} ({node['count']})", expand=True)
self._add(child, node["children"])
def action_reset(self):
reset()
self._populate()
self.notify(t("Telemetry reset."))
def action_expand_all(self):
self.query_one("#nav", Tree).root.expand_all()
app = Telemetry()
if run_app:
app.run()
return app