[IMP] script todo: télémétrie navigation — vue Liste (F3) + sections + icônes

- F3 fait maintenant défiler Arbre -> Kanban -> Liste -> Arbre. Le résumé
  et le footer annoncent la PROCHAINE vue (« F3 → Liste »).
- Nouvelle vue Liste : tous les menus empilés verticalement, chacun avec
  ses sections et ses commandes exécutables.
- Les colonnes Kanban et la vue Liste affichent les SECTIONS (── … ──)
  pour guider le choix, et chaque commande porte son ICÔNE.

L'icône vient de t() : l'AST extrait la clé i18n (prompt_description /
prompt_description_key), et _disp() la résout en libellé traduit + icône.
Les sections sont capturées via _choice_entries (marqueurs {"section": …}).
Validé headless : cycle F3, 11 en-têtes de menu, sections en Liste/Kanban.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mathieu Benoit 2026-07-29 11:22:16 +00:00
parent 4bebf5a397
commit ec88b5f1f0

View file

@ -114,9 +114,10 @@ def _str_of(node) -> str | None:
return None return None
def _choice_labels(func) -> list: def _choice_entries(func) -> list:
"""Libellés NUMÉROTÉS d'un menu « choices = [...] » (dans l'ordre, sections """Entrées NUMÉROTÉES d'un menu « choices = [...] », dans l'ordre : chaque
exclues car non numérotées par fill_help_info).""" commande est {« label », « section »}, la section étant le dernier marqueur
{"section": } rencontré (fill_help_info ne numérote pas les sections)."""
for node in ast.walk(func): for node in ast.walk(func):
if ( if (
isinstance(node, ast.Assign) isinstance(node, ast.Assign)
@ -126,16 +127,23 @@ def _choice_labels(func) -> list:
) )
and isinstance(node.value, ast.List) and isinstance(node.value, ast.List)
): ):
labels = [] entries, section = [], None
for el in node.value.elts: for el in node.value.elts:
if not isinstance(el, ast.Dict): if not isinstance(el, ast.Dict):
continue continue
d = {}
for k, v in zip(el.keys, el.values): for k, v in zip(el.keys, el.values):
if isinstance(k, ast.Constant) and k.value == "prompt_description": if isinstance(k, ast.Constant):
s = _str_of(v) d[k.value] = _str_of(v)
if s: if d.get("section"):
labels.append(s) section = d["section"]
return labels continue
lab = d.get("prompt_description") or d.get(
"prompt_description_key"
)
if lab:
entries.append({"label": lab, "section": section})
return entries
return [] return []
@ -236,15 +244,20 @@ def build_code_tree(todo_path=None) -> dict | None:
seen.add(method) seen.add(method)
func = methods[method] func = methods[method]
disp = _dispatch(func) disp = _dispatch(func)
clabels = _choice_labels(func) centries = _choice_entries(func)
for num in sorted(disp): for num in sorted(disp):
target, kwargs = disp[num] target, kwargs = disp[num]
if target in labels: # sous-menu if target in labels: # sous-menu
node["children"].append(build(target)) node["children"].append(build(target))
else: # commande (feuille) exécutable else: # commande (feuille) exécutable
entry = (
centries[num - 1]
if 0 <= num - 1 < len(centries)
else None
)
lab = ( lab = (
clabels[num - 1] entry["label"]
if 0 <= num - 1 < len(clabels) if entry
else target.lstrip("_").replace("_", " ") else target.lstrip("_").replace("_", " ")
) )
node["children"].append( node["children"].append(
@ -254,6 +267,7 @@ def build_code_tree(todo_path=None) -> dict | None:
"children": [], "children": [],
"method": target, "method": target,
"kwargs": kwargs, "kwargs": kwargs,
"section": entry["section"] if entry else None,
} }
) )
seen.discard(method) seen.discard(method)
@ -480,6 +494,41 @@ def _fmt_uptime(secs):
# Modes d'affichage du Kanban (F4 les fait défiler). # Modes d'affichage du Kanban (F4 les fait défiler).
KANBAN_MODES = ("columns", "swimlanes", "grid") KANBAN_MODES = ("columns", "swimlanes", "grid")
# Les vues défilent avec F3 ; chaque vue affiche le NOM de la suivante.
VIEWS = ("tree", "kanban", "list")
VIEW_LABELS = {
"tree": "Arbre",
"kanban": "Kanban",
"list": "Liste",
"system": "Système",
}
def _disp(label: str) -> str:
"""Libellé d'affichage d'une commande : passe par t() pour récupérer la
traduction ET l'icône (les valeurs i18n portent l'icône). Renvoie le
libellé inchangé s'il n'est pas une clé de traduction."""
return t(label) if label else label
_SENTINEL = object()
def _group_by_section(cmds):
"""Itère les commandes en groupes (section, [cmds]) dans l'ordre, la
section pouvant être None (aucune)."""
groups, cur, bucket = [], _SENTINEL, []
for c in cmds:
sec = c.get("section")
if sec != cur:
if bucket:
groups.append((cur, bucket))
cur, bucket = sec, []
bucket.append(c)
if bucket:
groups.append((cur, bucket))
return groups
def run_tui(run_app: bool = True, state: dict | None = None): def run_tui(run_app: bool = True, state: dict | None = None):
"""TUI de télémétrie : vue Arbre (issue du code) et vue Kanban (F3), la """TUI de télémétrie : vue Arbre (issue du code) et vue Kanban (F3), la
@ -523,9 +572,13 @@ def run_tui(run_app: bool = True, state: dict | None = None):
#summary { height: 1; color: $text-muted; } #summary { height: 1; color: $text-muted; }
Tree { border: solid $accent; } Tree { border: solid $accent; }
#kanban { display: none; height: 1fr; } #kanban { display: none; height: 1fr; }
#list { display: none; height: 1fr; }
#system { display: none; height: 1fr; padding: 1 2; } #system { display: none; height: 1fr; padding: 1 2; }
.kcol { width: 40; border: solid $accent; margin: 0 1 0 0; } .kcol { width: 40; border: solid $accent; margin: 0 1 0 0; }
.ktitle { height: 1; color: $accent; } .ktitle { height: 1; color: $accent; }
.ksec { height: 1; color: $secondary; text-style: italic; }
.listmenu { height: 1; color: $accent; text-style: bold; }
.listsec { height: 1; color: $secondary; text-style: italic; }
.lane { height: auto; } .lane { height: auto; }
.lanetitle { height: 1; color: $secondary; } .lanetitle { height: 1; color: $secondary; }
.kgrid { grid-size: 3; grid-gutter: 1; } .kgrid { grid-size: 3; grid-gutter: 1; }
@ -534,7 +587,7 @@ def run_tui(run_app: bool = True, state: dict | None = None):
BINDINGS = [ BINDINGS = [
("q", "quit", "Quitter"), ("q", "quit", "Quitter"),
("f2", "system", "Système"), ("f2", "system", "Système"),
("f3", "toggle_view", "Arbre/Kanban"), ("f3", "toggle_view", "Vue"),
("f4", "kanban_layout", "Disposition Kanban"), ("f4", "kanban_layout", "Disposition Kanban"),
("e", "expand_all", "Tout déplier"), ("e", "expand_all", "Tout déplier"),
("i", "install_sensors", "Installer capteurs"), ("i", "install_sensors", "Installer capteurs"),
@ -550,20 +603,59 @@ def run_tui(run_app: bool = True, state: dict | None = None):
# -- helpers de construction de widgets ------------------------------ # # -- helpers de construction de widgets ------------------------------ #
def _col_widget(self, label, cnt, cmds): def _col_widget(self, label, cnt, cmds):
items = [ # Regroupe par section pour aider au choix ; icônes via _disp().
CmdItem( children = [Static(f"{label} ({cnt})", classes="ktitle")]
f"· {c['label']}", for section, group in _group_by_section(cmds):
c.get("method"), if section:
c.get("kwargs"), children.append(
c.get("path"), Static(f"── {_disp(section)} ──", classes="ksec")
)
children.append(
ListView(
*[
CmdItem(
f"· {_disp(c['label'])}",
c.get("method"),
c.get("kwargs"),
c.get("path"),
)
for c in group
]
)
) )
for c in cmds return VerticalScroll(*children, classes="kcol")
]
return VerticalScroll( def _list_view_widget(self):
Static(f"{label} ({cnt})", classes="ktitle"), # Vue Liste : tous les menus empilés verticalement, chaque menu
ListView(*items), # avec ses sections et ses commandes (icônes), pour aider le choix.
classes="kcol", blocks = []
) for (label, path, cnt, cmds) in columns:
blocks.append(
Static(f"{path} ({cnt})", classes="listmenu")
)
for section, group in _group_by_section(cmds):
if section:
blocks.append(
Static(
f" ── {_disp(section)} ──", classes="listsec"
)
)
blocks.append(
ListView(
*[
CmdItem(
f" · {_disp(c['label'])}",
c.get("method"),
c.get("kwargs"),
c.get("path"),
)
for c in group
]
)
)
if not blocks:
return Static(t("No command found."))
return VerticalScroll(*blocks)
def _kanban_layout_widget(self): def _kanban_layout_widget(self):
cols = [ cols = [
@ -604,6 +696,7 @@ def run_tui(run_app: bool = True, state: dict | None = None):
yield Static("", id="summary") yield Static("", id="summary")
yield Tree("📍 TODO", id="nav") yield Tree("📍 TODO", id="nav")
yield Container(id="kanban") yield Container(id="kanban")
yield Container(id="list")
yield Static("", id="system") yield Static("", id="system")
yield Footer() yield Footer()
@ -619,11 +712,14 @@ def run_tui(run_app: bool = True, state: dict | None = None):
def _apply_visibility(self): def _apply_visibility(self):
self.query_one("#nav").display = self._mode == "tree" self.query_one("#nav").display = self._mode == "tree"
self.query_one("#kanban").display = self._mode == "kanban" self.query_one("#kanban").display = self._mode == "kanban"
self.query_one("#list").display = self._mode == "list"
self.query_one("#system").display = self._mode == "system" self.query_one("#system").display = self._mode == "system"
async def _restore(self): async def _restore(self):
if self._mode == "kanban": if self._mode == "kanban":
await self._enter_kanban() await self._enter_kanban()
elif self._mode == "list":
await self._enter_list()
elif self._mode == "tree": elif self._mode == "tree":
self._focus_tree_path(state.get("path")) self._focus_tree_path(state.get("path"))
self._apply_visibility() self._apply_visibility()
@ -637,6 +733,14 @@ def run_tui(run_app: bool = True, state: dict | None = None):
self._apply_visibility() self._apply_visibility()
self._focus_kanban_path(state.get("path")) self._focus_kanban_path(state.get("path"))
# -- vue Liste ------------------------------------------------------- #
async def _enter_list(self):
box = self.query_one("#list", Container)
await box.remove_children()
await box.mount(self._list_view_widget())
self._apply_visibility()
self._focus_kanban_path(state.get("path"))
def _focus_kanban_path(self, path): def _focus_kanban_path(self, path):
if not path: if not path:
return return
@ -667,15 +771,32 @@ def run_tui(run_app: bool = True, state: dict | None = None):
tree.move_cursor(node) tree.move_cursor(node)
tree.scroll_to_node(node) tree.scroll_to_node(node)
def _next_view(self):
cur = self._mode if self._mode in VIEWS else "tree"
return VIEWS[(VIEWS.index(cur) + 1) % len(VIEWS)]
def _update_f3_hint(self):
# Le footer F3 annonce la PROCHAINE vue (ex. « → Liste »).
nxt = VIEW_LABELS.get(self._next_view(), self._next_view())
try:
self.bind("f3", "toggle_view", description=f"{nxt}")
self.refresh_bindings()
except Exception:
pass
def _update_summary(self): def _update_summary(self):
total = sum(paths.values()) total = sum(paths.values())
src = t("tree from code") if code_tree else t("visited paths only") src = t("tree from code") if code_tree else t("visited paths only")
extra = ( extra = (
f" [{self._kanban_mode}]" if self._mode == "kanban" else "" f" [{self._kanban_mode}]" if self._mode == "kanban" else ""
) )
cur_lbl = VIEW_LABELS.get(self._mode, self._mode)
nxt_lbl = VIEW_LABELS.get(self._next_view(), self._next_view())
self._update_f3_hint()
self.query_one("#summary", Static).update( self.query_one("#summary", Static).update(
f" {total} {t('navigations')} · {len(paths)} {t('menus')} · " f" {total} {t('navigations')} · {len(paths)} {t('menus')} · "
f"{src} · {t('view')}: {self._mode}{extra} · " f"{src} · {t('view')}: {cur_lbl}{extra} · "
f"F3 → {nxt_lbl} · "
f"{t('F2 system · F3/F4 views · Enter run')}" f"{t('F2 system · F3/F4 views · Enter run')}"
) )
@ -820,11 +941,14 @@ def run_tui(run_app: bool = True, state: dict | None = None):
# -- actions --------------------------------------------------------- # # -- actions --------------------------------------------------------- #
async def action_toggle_view(self): async def action_toggle_view(self):
if self._mode == "tree": # Cycle Arbre -> Kanban -> Liste -> Arbre (depuis Système on
self._mode = "kanban" # revient dans le cycle sur la vue précédente).
cur = self._mode if self._mode in VIEWS else "tree"
self._mode = VIEWS[(VIEWS.index(cur) + 1) % len(VIEWS)]
if self._mode == "kanban":
await self._enter_kanban() await self._enter_kanban()
else: elif self._mode == "list":
self._mode = "tree" await self._enter_list()
self._apply_visibility() self._apply_visibility()
self._update_summary() self._update_summary()