[ADD] migration: announce the countdown, and cycle three panel states

Every prompt now carries « ⏱5s » before the question. One that looks like
it waits forever does not invite you to step away, which is the whole
point of auto-run. The same clock reports the answer afterwards: it
announces the wait, then says what it decided.

Enter on the auto-run question already meant no; it now says so, with an
explicit default instead of a deduction. And the flag is cleared before
asking — an ERPLIBRE_AUTO_EXECUTE inherited from the shell would have
answered yes on someone's behalf, and that answer commits every later one.

« p » cycles three states rather than toggling one: a plain toggle freed
four lines, while what takes the room is the left COLUMN — on a server log
every character of width counts. All → no summary → detail only, and back.
The state is named in the sub-title, which stays visible in all three: a
panel that vanishes without a word reads as a broken screen.

--- FR ---

[ADD] migration : annoncer le compte à rebours, et trois états de panneaux

Chaque invite porte désormais « ⏱5s » devant la question. Une invite qui a
l'air d'attendre indéfiniment n'invite pas à s'absenter, et c'est pourtant
tout l'intérêt du mode auto. Le même chronomètre rend compte ensuite.

Entrée sur la question d'auto-exécution voulait déjà dire non ; elle le
DIT maintenant, par un défaut explicite plutôt qu'une déduction. Et le
drapeau est effacé avant de demander : une variable héritée du shell
aurait répondu « oui » à la place de quelqu'un.

« p » enchaîne trois états au lieu d'en basculer un : ce qui prend la
place, c'est la colonne de gauche. Tout → sans résumé → détail seul, et
l'on revient. L'état est nommé dans le sous-titre.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-19 06:57:19 -04:00
parent e981500c94
commit 6dff7aa304
6 changed files with 274 additions and 26 deletions

View file

@ -86,12 +86,15 @@ def ask(prompt, default="", seconds=None):
return input(prompt) or default
import select
sys.stdout.write(prompt)
attente = delay() if seconds is None else seconds
# DIRE qu'on répondra tout seul, et dans combien de temps. Une invite
# qui a l'air d'attendre indéfiniment n'invite pas à s'absenter, et
# c'est pourtant tout l'intérêt du mode auto. Devant l'invite plutôt
# qu'après : elle se lit avant qu'on décide de partir.
sys.stdout.write(f"⏱{attente:g}s {prompt}")
sys.stdout.flush()
try:
ready, _, _ = select.select(
[sys.stdin], [], [], delay() if seconds is None else seconds
)
ready, _, _ = select.select([sys.stdin], [], [], attente)
except (OSError, ValueError):
# stdin n'est pas sélectionnable : on ne devine pas, on demande.
return input("") or default

View file

@ -154,6 +154,64 @@ def pane_text(dct, row, colour=False, show_log=True):
return "\n".join(lignes)
PANELS = ("all", "no-summary", "detail")
def panel_label(state):
"""Le nom de l'état courant, tel qu'il s'affiche dans le sous-titre."""
return {
"all": t("summary + list + detail"),
"no-summary": t("list + detail"),
"detail": t("detail only"),
}[PANELS[state % len(PANELS)]]
def panel_visibility(state):
"""(résumé visible, liste visible) pour cet état.
Trois états, du plus complet au plus dépouillé. Un simple bascule ne
libérait que quatre lignes ; ce qui prend la place, c'est la COLONNE de
gauche — et sur un journal de serveur, chaque caractère gagné en
largeur compte.
"""
etat = PANELS[state % len(PANELS)]
return etat == "all", etat != "detail"
def current_row(lst_row, index):
"""La ligne choisie, ou None quand il n'y en a aucune."""
return lst_row[index] if lst_row and 0 <= index < len(lst_row) else None
def reread(path):
"""Relire la progression, ou None si l'on ne sait pas d'où."""
return status.read(path) if path else None
def apply_panels(app):
"""Poser la visibilité des panneaux, et NOMMER l'état choisi.
Le sous-titre reste visible dans les trois : un panneau qui disparaît
sans un mot se lit comme un écran cassé.
"""
resume, liste = panel_visibility(app.panel_state)
app.query_one("#head").display = resume
app.query_one("#left").display = liste
app.sub_title = panel_label(app.panel_state)
def apply_width(app, delta):
"""Déplacer la séparation, bornée des deux côtés.
Une colonne de zéro ne se retrouve plus ; une qui mange tout l'écran ne
laisse rien à lire.
"""
app.left_width = max(
app.LEFT_MIN, min(app.LEFT_MAX, app.left_width + delta)
)
app.query_one("#left").styles.width = app.left_width
def build_app(dct, path=None):
"""Textual est importé ICI, pas au chargement du module.
@ -172,7 +230,7 @@ def build_app(dct, path=None):
("q,escape", "quit", t("Quit")),
("r", "refresh", t("Refresh")),
("l", "toggle_log", t("Logs")),
("p", "toggle_head", t("Panel")),
("p", "cycle_panels", t("Panels")),
("plus,equal", "wider", t("Wider")),
("minus,underscore", "narrower", t("Narrower")),
]
@ -189,6 +247,7 @@ def build_app(dct, path=None):
self.index = 0
self.show_log = True
self.left_width = 46
self.panel_state = 0
def compose(self) -> ComposeResult:
yield Header()
@ -202,6 +261,7 @@ def build_app(dct, path=None):
def on_mount(self):
self.title = t("Migration state")
self._fill_table()
apply_panels(self)
self._show()
def _fill_table(self):
@ -214,7 +274,7 @@ def build_app(dct, path=None):
self.query_one("#head", Static).update(head_text(self.dct))
def _show(self):
row = self.lst_row[self.index] if self.lst_row else None
row = current_row(self.lst_row, self.index)
# `from_ansi` fait DEUX choses : il rend les couleurs, et il
# traite le reste comme du texte LITTÉRAL. Sans lui, une
# commande contenant « [1] » passait pour du balisage Rich et
@ -239,9 +299,10 @@ def build_app(dct, path=None):
cela, il fallait le fermer et le rouvrir pour voir le palier
suivant.
"""
if not self.path:
neuf = reread(self.path)
if neuf is None:
return
self.dct = status.read(self.path)
self.dct = neuf
self.lst_row = rows(self.dct)
self.index = min(self.index, max(0, len(self.lst_row) - 1))
self._fill_table()
@ -251,23 +312,26 @@ def build_app(dct, path=None):
self.show_log = not self.show_log
self._show()
def action_toggle_head(self):
head = self.query_one("#head", Static)
head.display = not head.display
def action_cycle_panels(self):
"""Trois états, du plus complet au plus dépouillé.
Un simple bascule ne libérait que quatre lignes. Ce qui prend
la place, c'est la COLONNE de gauche : sur un journal de
serveur, chaque caractère gagné en largeur compte. On enchaîne
donc tout → sans résumé → détail seul, et l'on revient.
L'état est écrit dans le sous-titre, qui reste visible dans les
trois : un panneau qui disparaît sans un mot se lit comme un
écran cassé.
"""
self.panel_state = (self.panel_state + 1) % len(PANELS)
apply_panels(self)
def action_wider(self):
self._resize(self.LEFT_STEP)
apply_width(self, self.LEFT_STEP)
def action_narrower(self):
self._resize(-self.LEFT_STEP)
def _resize(self, delta):
# Bornée des deux côtés : une colonne de zéro ne se retrouve
# plus, et une qui mange tout l'écran ne laisse rien à lire.
self.left_width = max(
self.LEFT_MIN, min(self.LEFT_MAX, self.left_width + delta)
)
self.query_one("#left", DataTable).styles.width = self.left_width
apply_width(self, -self.LEFT_STEP)
return StatusApp(dct, path)

View file

@ -5675,6 +5675,22 @@ TRANSLATIONS = {
"fr": "durée",
"en": "duration",
},
"Panels": {
"fr": "Panneaux",
"en": "Panels",
},
"summary + list + detail": {
"fr": "résumé + liste + détail",
"en": "summary + list + detail",
},
"list + detail": {
"fr": "liste + détail",
"en": "list + detail",
},
"detail only": {
"fr": "détail seul",
"en": "detail only",
},
"Clean the database before testing the pages?": {
"fr": "Nettoyer la base avant de tester les pages ?",
"en": "Clean the database before testing the pages?",

View file

@ -802,10 +802,16 @@ class TodoUpgrade:
Posée AVANT le choix de version, donc avant la première décision :
activée, elle vaut pour toutes les suivantes, celle-là comprise.
"""
# L'auto-exécution ne s'active JAMAIS d'elle-même : on efface le
# drapeau avant de poser la question, sinon une variable héritée du
# shell ferait répondre « oui » à la place de quelqu'un — et cette
# réponse-là engage toutes les suivantes.
auto_ask.export(False)
answer = (
input(
auto_ask.ask(
f"💬 {t('Auto-run: take the default answer after')}"
f" {self.AUTO_DELAY} {t('seconds?')} (y/N) : "
f" {self.AUTO_DELAY} {t('seconds?')} (y/N) : ",
default="n",
)
.strip()
.lower()

View file

@ -1090,6 +1090,72 @@ class TestSeparatingCommandsFromLogs(Base):
)
class TestTheThreePanelStates(Base):
"""Un simple bascule ne libérait que quatre lignes.
Ce qui prend la place, c'est la COLONNE de gauche — et sur un journal
de serveur, chaque caractère gagné en largeur compte. D'où trois états,
du plus complet au plus dépouillé.
"""
def test_the_first_state_shows_everything(self):
self.assertEqual(tui.panel_visibility(0), (True, True))
def test_the_second_drops_the_summary(self):
self.assertEqual(tui.panel_visibility(1), (False, True))
def test_the_third_leaves_the_detail_alone(self):
self.assertEqual(tui.panel_visibility(2), (False, False))
def test_it_comes_back_round(self):
# Sans retour, le troisième état serait un cul-de-sac : la liste
# cachée, on ne peut plus changer d'entrée.
self.assertEqual(tui.panel_visibility(3), tui.panel_visibility(0))
def test_there_are_exactly_three(self):
self.assertEqual(len(tui.PANELS), 3)
def test_each_state_has_a_name(self):
# Un panneau qui disparaît sans un mot se lit comme un écran cassé.
noms = {tui.panel_label(i) for i in range(3)}
self.assertEqual(len(noms), 3)
for nom in noms:
self.assertTrue(nom.strip())
def test_p_is_bound_to_the_cycle(self):
app = tui.build_app(progression())
action = [
entree[1] for entree in app.BINDINGS if "p" in entree[0].split(",")
]
self.assertEqual(action, ["cycle_panels"])
def test_the_name_is_shown_where_it_stays_visible(self):
# Le sous-titre est dans l'en-tête de Textual, qui reste affiché
# dans les trois états — contrairement au panneau de résumé.
import inspect
source = inspect.getsource(tui.apply_panels)
self.assertIn("app.sub_title", source)
class TestTheGuardsThatCameOutOfTheClass(Base):
def test_no_row_selected_is_not_an_error(self):
self.assertIsNone(tui.current_row([], 0))
def test_an_index_past_the_end_is_not_an_error(self):
# Un rafraîchissement peut raccourcir la liste sous le curseur.
self.assertIsNone(tui.current_row([{"a": 1}], 5))
def test_rereading_without_a_path_yields_nothing(self):
self.assertIsNone(tui.reread(None))
def test_the_width_stays_between_its_bounds(self):
app = tui.build_app(progression())
self.assertLess(app.LEFT_MIN, app.LEFT_MAX)
self.assertGreaterEqual(app.left_width, app.LEFT_MIN)
self.assertLessEqual(app.left_width, app.LEFT_MAX)
class TestTheKeyboard(Base):
"""Ce que l'écran promet dans son pied de page doit exister."""
@ -1137,9 +1203,12 @@ class TestTheKeyboard(Base):
"""
import inspect
source = inspect.getsource(tui.build_app)
self.assertIn("status.read(self.path)", source)
self.assertIn("self.lst_row = rows(self.dct)", source)
# La relecture est sortie de la classe pour la rendre testable :
# on vérifie donc les DEUX moitiés, la lecture et le remontage.
self.assertIn("status.read(path)", inspect.getsource(tui.reread))
self.assertIn(
"self.lst_row = rows(self.dct)", inspect.getsource(tui.build_app)
)
def test_refreshing_without_a_path_does_nothing(self):
app = tui.build_app(progression(), path=None)

View file

@ -224,6 +224,96 @@ class TestTheCountdownSpeaksThePromptsLanguage(EnvCase):
self.assertIn("Entrée", texte)
class TestTheQuestionThatCannotAnswerItself(EnvCase):
"""Celle-là engage toutes les suivantes : elle ne se prend pas seule.
Une variable `ERPLIBRE_AUTO_EXECUTE` héritée du shell ferait répondre
« oui » à la place de quelqu'un, et cette réponse-là décide de toutes
les autres pendant des heures.
"""
def answer(self, typed):
import builtins
obj = TodoUpgrade.__new__(TodoUpgrade)
original = builtins.input
builtins.input = lambda prompt="": typed
self.addCleanup(setattr, builtins, "input", original)
with contextlib.redirect_stdout(io.StringIO()):
obj.prompt_auto_execute()
return obj.auto_execute
def test_enter_says_no(self):
self.assertFalse(self.answer(""))
def test_the_default_is_written_down_not_deduced(self):
# `answer == "y"` donnait le bon résultat par accident ; un défaut
# explicite dit ce qu'on veut, et se relit.
import inspect
source = inspect.getsource(TodoUpgrade.prompt_auto_execute)
self.assertIn('default="n"', source)
def test_an_inherited_flag_cannot_switch_it_on(self):
os.environ[auto_ask.ENV_ENABLED] = "1"
os.environ[auto_ask.ENV_DELAY] = "5"
self.assertFalse(self.answer(""))
def test_the_inherited_flag_is_cleared_before_asking(self):
import inspect
source = inspect.getsource(TodoUpgrade.prompt_auto_execute)
self.assertLess(
source.index("auto_ask.export(False)"),
source.index("auto_ask.ask("),
)
def test_yes_still_says_yes(self):
self.assertTrue(self.answer("y"))
class TestEveryPromptAnnouncesTheCountdown(EnvCase):
"""Une invite qui a l'air d'attendre indéfiniment n'invite pas à
s'absenter — et c'est pourtant tout l'intérêt du mode auto."""
def ask(self, default="d", seconds=5):
import select
original = select.select
select.select = lambda r, w, x, t: ([], [], [])
self.addCleanup(setattr, select, "select", original)
auto_ask.export(True, seconds)
out = io.StringIO()
with contextlib.redirect_stdout(out):
auto_ask.ask("💬 q : ", default=default)
return out.getvalue()
def test_the_delay_is_announced_before_the_question(self):
texte = self.ask()
self.assertIn("⏱5s", texte)
self.assertLess(texte.index("⏱5s"), texte.index("💬"))
def test_it_shows_the_real_delay(self):
self.assertIn("⏱2s", self.ask(seconds=2))
def test_nothing_is_announced_outside_auto_mode(self):
import builtins
auto_ask.export(False)
original = builtins.input
vu = []
builtins.input = lambda prompt="": vu.append(prompt) or "x"
self.addCleanup(setattr, builtins, "input", original)
auto_ask.ask("💬 q : ", default="d")
self.assertNotIn("⏱", vu[0])
def test_the_clock_announces_AND_reports(self):
# Le même symbole des deux côtés : il annonce l'attente, puis rend
# compte de la réponse. Deux symboles feraient deux mécanismes.
texte = self.ask()
self.assertEqual(texte.count("⏱"), 2)
class TestNoPromptOfTheMigrationCanHang(unittest.TestCase):
"""Un `input()` nu ne sait rien du mode auto : il attend, pour toujours.