aicp-cli 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
aicp/menu.py ADDED
@@ -0,0 +1,1013 @@
1
+ """``--config`` settings menu and ``--swap-ai`` picker.
2
+
3
+ Ported from ``_aicp_config``/``_aicp_config_fallback``/``_aicp_cfg_*`` and
4
+ ``_aicp_swap_ai``/``_aicp_print_cli_order`` in ``~/scripts/bin/aicp``.
5
+
6
+ **Saves as you go.** Every pick writes to ``.aicprc`` immediately, through
7
+ :func:`~aicp.config.persist_key`, so there is no save key to forget and
8
+ quitting can never discard a change. That also means every write goes
9
+ through the same preserve-unknown-lines, temp-file-then-rename path the
10
+ config layer owns — this module never writes the file itself.
11
+
12
+ **Two surfaces, one row model.** An arrow-key TUI on a terminal and a
13
+ numbered typed-choice fallback anywhere else (a pipe, CI, this repo's
14
+ suite). The fallback is not a lesser copy bolted on: it is the reference
15
+ implementation's own design, and the surface the tests drive, since a
16
+ harness has no TTY to press keys on.
17
+
18
+ .. rubric:: Adding a row
19
+
20
+ :data:`ROWS` is data, not parallel ``case`` arms. To add a setting, append
21
+ one :class:`Row` to that tuple and nothing else changes: numbering, drawing,
22
+ prompt text ("1-N"), bounds checking and dispatch are all derived from the
23
+ tuple's length and each row's own callables. A row needs a config ``key``,
24
+ a ``group`` heading (``None`` to share the previous row's), ``label``/``help``
25
+ as ``(msgid, english)`` pairs, a ``value`` renderer and a ``cycle`` mutator
26
+ that returns the string to persist. Nothing in this module hardcodes a count.
27
+
28
+ The Skills and Doctor rows were added exactly that way, and are the reason
29
+ a row may carry an ``action`` instead of a ``cycle``: they *do* something
30
+ (install skills, print a health report) rather than persist a setting, and
31
+ they show live status in their value column. They are rows and not
32
+ subcommands on purpose — ``aicp`` and ``aicp --config`` are the only two
33
+ things this tool ever asks anyone to remember.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import contextlib
39
+ import io
40
+ import os
41
+ import sys
42
+ import time
43
+ from collections.abc import Callable, Sequence
44
+ from dataclasses import dataclass
45
+ from pathlib import Path
46
+ from typing import IO
47
+
48
+ from . import __version__, gitflow, i18n, skills
49
+ from ._keyreader import is_interactive, key_session, pending, read_key, read_line
50
+ from ._utils import BLUE, BOLD, CYAN, DIM, GREEN, RED, RESET, YELLOW, color_supported
51
+
52
+ # _KEY_RE and _SYSTEM_RESOLVED are read, not copied: the doctor reports on the
53
+ # loader's own verdict about a line, so it has to ask with the loader's own key
54
+ # rule, and has to name the keys that may never come from a file at all.
55
+ from .config import (
56
+ _KEY_RE,
57
+ _SYSTEM_RESOLVED,
58
+ DENYLIST,
59
+ Settings,
60
+ _read_json_object,
61
+ load_config,
62
+ persist_key,
63
+ resolve,
64
+ timeout_bin,
65
+ )
66
+ from .contracts import ROSTER
67
+ from .present import render_panel, width
68
+
69
+ __all__ = ["ROWS", "MenuState", "Row", "config_menu", "swap_ai"]
70
+
71
+ _ROSTER_NAMES: tuple[str, ...] = tuple(c.name for c in ROSTER)
72
+ #: What the AI CLI order row puts between two names, and the unit the order
73
+ #: animation slides by — one name plus one separator is exactly one rotation.
74
+ _SEPARATOR = " → "
75
+ _ELLIPSIS = "…"
76
+ _MIN_VALUE_COLUMNS = 12
77
+ #: Long enough to read as motion rather than a jump, short enough that it is
78
+ #: over before a held-down arrow key feels laggy.
79
+ _MOTION_SECONDS = 0.15
80
+
81
+
82
+ def _t(lang: str, msgid: str, english: str, *args: object) -> str:
83
+ """:func:`aicp.i18n.t` with the language passed in rather than resolved.
84
+
85
+ ``i18n.t`` resolves ``AICP_LANG`` once at import time, which is right for
86
+ a one-shot run and wrong here: the language row has to repaint the menu
87
+ in the language just picked, in the same process, before anything else
88
+ happens. Same catalogue, same fallback — a missing msgid degrades to the
89
+ English at the call site, never to a blank line.
90
+ """
91
+ text = i18n.CATALOG.get(msgid, english) if lang != "en" else english
92
+ return text % args if args else text
93
+
94
+
95
+ @dataclass
96
+ class MenuState:
97
+ """The live values a menu session is editing, plus where they persist."""
98
+
99
+ path: Path
100
+ do_commit: bool
101
+ do_push: bool
102
+ lang: str
103
+ chain: list[str]
104
+ #: Probe caches for the two action rows. The panel repaints on every
105
+ #: keypress, and both of those rows show live status in their value
106
+ #: column — walking the filesystem and shelling out to git once per
107
+ #: repaint would be a real, per-keystroke cost. Probed once per session
108
+ #: instead, and dropped by the Skills action whenever it changes anything.
109
+ skills_status: list[skills.SkillStatus] | None = None
110
+ health: list[tuple[str, str]] | None = None
111
+
112
+ @classmethod
113
+ def from_settings(cls, settings: Settings) -> MenuState:
114
+ return cls(
115
+ path=settings.path,
116
+ do_commit=settings.do_commit,
117
+ do_push=settings.do_push,
118
+ lang=settings.lang,
119
+ chain=list(settings.cli_chain),
120
+ )
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class Row:
125
+ """One setting, or one action. See the module docstring for how to add one.
126
+
127
+ A row either *persists* (``key`` + ``cycle``) or *acts* (``action``, with
128
+ an empty ``key``): the Skills and Doctor rows run something and write no
129
+ setting at all. Everything else — numbering, the value column, bounds,
130
+ dispatch — is identical either way, which is the whole point of keeping
131
+ the menu a list of data rather than a switch statement.
132
+ """
133
+
134
+ key: str
135
+ group: tuple[str, str] | None
136
+ label: tuple[str, str]
137
+ help: tuple[str, str]
138
+ value: Callable[[MenuState], str]
139
+ accent: Callable[[MenuState], str]
140
+ cycle: Callable[[MenuState, int], str] | None = None
141
+ #: ``(state, stdin, stdout)``. Must never block on a non-TTY stdin —
142
+ #: ``read_line`` returns ``None`` at EOF and every prompt here reads that
143
+ #: as "no", because aicp runs in CI.
144
+ action: Callable[[MenuState, IO[str], IO[str]], None] | None = None
145
+
146
+
147
+ def _on_off(state: MenuState, flag: bool) -> str:
148
+ return _t(state.lang, "config_on", "On") if flag else _t(state.lang, "config_off", "Off")
149
+
150
+
151
+ def _toggle_commit(state: MenuState, _direction: int) -> str:
152
+ state.do_commit = not state.do_commit
153
+ return "1" if state.do_commit else "0"
154
+
155
+
156
+ def _toggle_push(state: MenuState, _direction: int) -> str:
157
+ state.do_push = not state.do_push
158
+ return "1" if state.do_push else "0"
159
+
160
+
161
+ def _toggle_lang(state: MenuState, _direction: int) -> str:
162
+ state.lang = "zh-TW" if state.lang == "en" else "en"
163
+ return state.lang
164
+
165
+
166
+ def _rotate_chain(state: MenuState, direction: int) -> str:
167
+ """Rotation, not a swap: ``--swap-ai`` trades the pick with whoever holds
168
+ #1, which is right for "jump straight to this one" and wrong for an arrow
169
+ key, where pressing → down the list should walk the whole chain and come
170
+ back to where it started rather than ping-pong between two names."""
171
+ if direction > 0:
172
+ state.chain = [*state.chain[1:], state.chain[0]]
173
+ else:
174
+ state.chain = [state.chain[-1], *state.chain[:-1]]
175
+ return " ".join(state.chain)
176
+
177
+
178
+ # ── Skills and Doctor: the two rows that act instead of persisting ───────────
179
+
180
+ _WARN = "⚠"
181
+ _OK = "✓"
182
+ _SKIP = "·"
183
+
184
+
185
+ def _ask(state: MenuState, stdin: IO[str], out: IO[str], msgid: str, english: str, *args: object) -> bool:
186
+ """A yes/no prompt that defaults to NO — including at EOF.
187
+
188
+ ``read_line`` returns ``None`` on a dead or empty pipe, so an unattended
189
+ run answers "no" to everything instead of blocking: the safe default for
190
+ both questions asked here (install nothing, overwrite nothing).
191
+ """
192
+ print(_t(state.lang, msgid, english, *args), file=out)
193
+ answer = read_line(stdin)
194
+ return answer is not None and answer.strip().lower() in ("y", "yes")
195
+
196
+
197
+ def _skill_states(state: MenuState) -> list[skills.SkillStatus]:
198
+ if state.skills_status is None:
199
+ state.skills_status = skills.full_status()
200
+ return state.skills_status
201
+
202
+
203
+ def _skills_value(state: MenuState) -> str:
204
+ """The Skills row's inline status, e.g. ``⚠ codex missing``."""
205
+ live = [s for s in _skill_states(state) if s.state != skills.NOT_INSTALLED]
206
+ if not live:
207
+ return _t(state.lang, "skills_none", "no CLI configured")
208
+ missing = sorted({s.cli for s in live if s.state == skills.MISSING})
209
+ older = sorted({s.cli for s in live if s.state == skills.OURS_OLDER})
210
+ if missing:
211
+ return _t(state.lang, "skills_missing", f"{_WARN} %s missing", ", ".join(missing))
212
+ if older:
213
+ return _t(state.lang, "skills_older", f"{_WARN} %s outdated", ", ".join(older))
214
+ if any(s.state == skills.FOREIGN for s in live):
215
+ return _t(state.lang, "skills_yours", f"{_OK} yours, kept")
216
+ return _t(state.lang, "skills_ok", f"{_OK} installed")
217
+
218
+
219
+ def _skills_accent(state: MenuState) -> str:
220
+ return YELLOW if _WARN in _skills_value(state) else GREEN
221
+
222
+
223
+ def _report(state: MenuState, results: list[skills.InstallResult], out: IO[str]) -> None:
224
+ """Say what actually happened — and never call a kept file a failure."""
225
+ texts = {
226
+ skills.INSTALLED: ("skills_installed", "%s installed into %s"),
227
+ skills.UPGRADED: ("skills_upgraded", "%s upgraded for %s"),
228
+ skills.KEPT: ("skills_kept", "%s: kept your own copy — aicp will use it"),
229
+ }
230
+ for result in results:
231
+ if result.action == skills.OVERWRITTEN:
232
+ print(
233
+ f" {GREEN}{_OK}{RESET} "
234
+ + _t(
235
+ state.lang,
236
+ "skills_overwritten",
237
+ "%s replaced for %s — your copy is at %s",
238
+ result.skill,
239
+ result.cli,
240
+ result.backup,
241
+ ),
242
+ file=out,
243
+ )
244
+ elif result.action in texts:
245
+ msgid, english = texts[result.action]
246
+ args = (
247
+ (result.skill, result.cli)
248
+ if result.action != skills.KEPT
249
+ else (f"{result.cli}/{result.skill}",)
250
+ )
251
+ print(f" {GREEN}{_OK}{RESET} " + _t(state.lang, msgid, english, *args), file=out)
252
+ # The install just changed what is on disk, so both rows' cached probes
253
+ # are stale — including the Doctor row's, which reports on skills too.
254
+ state.skills_status = None
255
+ state.health = None
256
+
257
+
258
+ def _skills_action(state: MenuState, stdin: IO[str], out: IO[str]) -> None:
259
+ """Per-CLI status, then the installer's own two questions.
260
+
261
+ Both default to no. The second one only exists because a same-name skill
262
+ with no aicp sidecar is the user's own file: aicp keeps it, and says so
263
+ as the good outcome it is.
264
+ """
265
+ every = _skill_states(state)
266
+ live = [s for s in every if s.state != skills.NOT_INSTALLED]
267
+ labels = {
268
+ skills.MISSING: (_WARN, YELLOW, "skills_state_missing", "not installed yet"),
269
+ skills.FOREIGN: (_OK, GREEN, "skills_state_foreign", "your own file — aicp will use it"),
270
+ }
271
+ print(file=out)
272
+ # The Skills row's own label, on purpose: one word, one msgid. A second id
273
+ # for the same English is how a report and the row it belongs to drift.
274
+ print(_t(state.lang, "config_skills", "Skills"), file=out)
275
+ for status in live:
276
+ if status.state == skills.CURRENT:
277
+ mark, color, text = _OK, GREEN, _t(state.lang, "skills_state_current", "installed (%s)", status.version)
278
+ elif status.state == skills.OURS_OLDER:
279
+ mark, color, text = _WARN, YELLOW, _t(
280
+ state.lang, "skills_state_older", "from an older aicp (%s) — can be upgraded", status.version
281
+ )
282
+ else:
283
+ mark, color, msgid, english = labels[status.state]
284
+ text = _t(state.lang, msgid, english)
285
+ print(f" {color}{mark}{RESET} {status.cli:<8} {status.skill:<14} {text}", file=out)
286
+ # One line for every CLI that has no config dir, rather than two rows
287
+ # each: they are not a problem to solve, and on a six-CLI roster they
288
+ # would otherwise be most of the list.
289
+ skipped = sorted({s.cli for s in every if s.state == skills.NOT_INSTALLED})
290
+ if skipped:
291
+ print(
292
+ f" {DIM}{_SKIP} "
293
+ + _t(state.lang, "skills_skipped", "not installed here: %s", ", ".join(skipped))
294
+ + f"{RESET}",
295
+ file=out,
296
+ )
297
+ print(file=out)
298
+
299
+ stale = [s for s in live if s.state in (skills.MISSING, skills.OURS_OLDER)]
300
+ foreign = [s for s in live if s.state == skills.FOREIGN]
301
+ if stale and _ask(
302
+ state, stdin, out, "skills_install_q", "Install/upgrade %s skill(s)? [y/N]: ", len(stale)
303
+ ):
304
+ _report(state, skills.install(), out)
305
+ if foreign:
306
+ print(
307
+ f"{DIM}"
308
+ + _t(
309
+ state.lang,
310
+ "skills_keep_note",
311
+ "Keeping your own %s skill(s) — aicp will use them, which is fine.",
312
+ len(foreign),
313
+ )
314
+ + f"{RESET}",
315
+ file=out,
316
+ )
317
+ if _ask(
318
+ state,
319
+ stdin,
320
+ out,
321
+ "skills_force_q",
322
+ "Replace them with aicp's copies? Yours are backed up first [y/N]: ",
323
+ ):
324
+ _report(state, skills.install(force=True), out)
325
+ if not stale and not foreign:
326
+ print(f"{DIM}" + _t(state.lang, "skills_nothing", "Nothing to do.") + f"{RESET}", file=out)
327
+ print(file=out)
328
+
329
+
330
+ def _config_health(state: MenuState) -> list[tuple[str, str]]:
331
+ """Which ``config.json`` keys the loader refused — otherwise entirely
332
+ silent.
333
+
334
+ Derived from :func:`~aicp.config.load_config`'s own verdict rather than a
335
+ second copy of its rules: a key that is in the file and not in what it
336
+ returned is, by definition, a key that did not take effect.
337
+ """
338
+ if not state.path.exists():
339
+ return [(_OK, _t(state.lang, "health_no_config", "no %s yet — built-in defaults apply", state.path))]
340
+ raw = _read_json_object(state.path)
341
+ # The loader announces a denied key on stderr as it goes; this report is
342
+ # about to say the same thing in the panel, so the second copy is noise —
343
+ # and the panel repaints, which would print it again on every keypress.
344
+ with contextlib.redirect_stderr(io.StringIO()):
345
+ accepted = load_config(state.path)
346
+ refused = [key for key in raw if _KEY_RE.match(key) and key not in accepted]
347
+ if not refused:
348
+ return [(_OK, _t(state.lang, "health_config_ok", "%s: %s setting(s) read", state.path.name, len(accepted)))]
349
+ return [
350
+ (
351
+ _WARN,
352
+ _t(
353
+ state.lang,
354
+ "health_env_only",
355
+ "%s is ignored in %s — that knob is environment/PATH only",
356
+ key,
357
+ state.path.name,
358
+ )
359
+ if key in DENYLIST or key in _SYSTEM_RESOLVED
360
+ else _t(
361
+ state.lang,
362
+ "health_dropped",
363
+ "%s was dropped from %s — its value has characters that are not allowed",
364
+ key,
365
+ state.path.name,
366
+ ),
367
+ )
368
+ for key in refused
369
+ ]
370
+
371
+
372
+ def _skills_health(state: MenuState) -> list[tuple[str, str]]:
373
+ live = [s for s in _skill_states(state) if s.state != skills.NOT_INSTALLED]
374
+ if not live:
375
+ return [
376
+ (_OK, _t(state.lang, "health_skills_none", "no AI CLI config dir found — nothing to install skills into"))
377
+ ]
378
+ lines = [
379
+ (
380
+ _WARN if status.state in (skills.MISSING, skills.OURS_OLDER) else _OK,
381
+ _t(
382
+ state.lang,
383
+ {
384
+ skills.MISSING: "health_skill_missing",
385
+ skills.OURS_OLDER: "health_skill_older",
386
+ skills.FOREIGN: "health_skill_foreign",
387
+ }[status.state],
388
+ {
389
+ skills.MISSING: "%s: the %s skill is not installed — the Skills row installs it",
390
+ skills.OURS_OLDER: "%s: the %s skill is from an older aicp — the Skills row upgrades it",
391
+ skills.FOREIGN: "%s: the %s skill is your own — aicp will use it",
392
+ }[status.state],
393
+ status.cli,
394
+ status.skill,
395
+ ),
396
+ )
397
+ for status in live
398
+ if status.state != skills.CURRENT
399
+ ]
400
+ return lines or [(_OK, _t(state.lang, "health_skills_ok", "skills are installed for every configured CLI"))]
401
+
402
+
403
+ def _git_health(state: MenuState) -> list[tuple[str, str]]:
404
+ branch = gitflow.current_branch()
405
+ if branch is None:
406
+ return [
407
+ (
408
+ _WARN,
409
+ _t(
410
+ state.lang,
411
+ "health_no_branch",
412
+ "not on a branch here (detached HEAD, or not a git repo) — aicp needs one to push",
413
+ ),
414
+ )
415
+ ]
416
+ # gitflow._out is this project's one git-subprocess call site (it already
417
+ # swallows a missing git and a vanished cwd); a second copy here would be
418
+ # the same three lines with worse error handling.
419
+ if not gitflow._out(None, "remote"):
420
+ return [
421
+ (_WARN, _t(state.lang, "health_no_remote", "this repo has no git remote — nothing to push to"))
422
+ ]
423
+ return [(_OK, _t(state.lang, "health_git_ok", "on %s, remote %s", branch, gitflow.remote_for(branch)))]
424
+
425
+
426
+ def _health(state: MenuState) -> list[tuple[str, str]]:
427
+ """Everything that is otherwise silent, as ``(mark, message)`` pairs."""
428
+ if state.health is None:
429
+ binary = timeout_bin()
430
+ state.health = [
431
+ (_OK, _t(state.lang, "health_timeout_ok", "per-CLI timeout uses %s", binary))
432
+ if binary
433
+ else (
434
+ _WARN,
435
+ _t(
436
+ state.lang,
437
+ "health_timeout_missing",
438
+ "no timeout/gtimeout on PATH — each AI CLI call runs with no time limit",
439
+ ),
440
+ ),
441
+ *_config_health(state),
442
+ *_skills_health(state),
443
+ *_git_health(state),
444
+ ]
445
+ return state.health
446
+
447
+
448
+ def _doctor_value(state: MenuState) -> str:
449
+ """The Doctor row's inline status, e.g. ``⚠ 3 warnings``."""
450
+ count = sum(1 for mark, _ in _health(state) if mark == _WARN)
451
+ if not count:
452
+ return _t(state.lang, "doctor_clear", f"{_OK} all clear")
453
+ if count == 1:
454
+ return _t(state.lang, "doctor_one", f"{_WARN} 1 warning")
455
+ return _t(state.lang, "doctor_many", f"{_WARN} %s warnings", count)
456
+
457
+
458
+ def _doctor_action(state: MenuState, _stdin: IO[str], out: IO[str]) -> None:
459
+ """Print the report. Asks nothing, so there is nothing to block on."""
460
+ lines = _health(state)
461
+ print(file=out)
462
+ print(_t(state.lang, "config_doctor", "Health check"), file=out) # the row's own label
463
+ for mark, text in lines:
464
+ print(f" {YELLOW if mark == _WARN else GREEN}{mark}{RESET} {text}", file=out)
465
+ if any(mark == _WARN for mark, _ in lines):
466
+ print(
467
+ f"{DIM}"
468
+ + _t(
469
+ state.lang,
470
+ "health_footer",
471
+ "Warnings are things to know about, not failures — aicp runs either way.",
472
+ )
473
+ + f"{RESET}",
474
+ file=out,
475
+ )
476
+ print(file=out)
477
+
478
+
479
+ #: The menu, in display order. Append to extend — see the module docstring.
480
+ ROWS: tuple[Row, ...] = (
481
+ Row(
482
+ key="AICP_DO_COMMIT",
483
+ group=("config_group_steps", "Steps"),
484
+ label=("config_do_commit", "Run the /commit step"),
485
+ help=(
486
+ "config_help_commit",
487
+ "Off: never stage or commit — only push what is already committed.",
488
+ ),
489
+ value=lambda s: _on_off(s, s.do_commit),
490
+ accent=lambda s: GREEN if s.do_commit else DIM,
491
+ cycle=_toggle_commit,
492
+ ),
493
+ Row(
494
+ key="AICP_DO_PUSH",
495
+ group=None,
496
+ label=("config_do_push", "Run the /safe-git-push step"),
497
+ help=(
498
+ "config_help_push",
499
+ "Off: commit and stop, leaving the branch ahead of its remote.",
500
+ ),
501
+ value=lambda s: _on_off(s, s.do_push),
502
+ accent=lambda s: GREEN if s.do_push else DIM,
503
+ cycle=_toggle_push,
504
+ ),
505
+ Row(
506
+ key="AICP_LANG",
507
+ group=("config_group_general", "General"),
508
+ # Named in its own script in both languages, so the row stays
509
+ # readable to someone who just switched to a language they cannot
510
+ # read — the one moment they most need to find this row again.
511
+ label=("config_language", "Language"),
512
+ help=(
513
+ "config_help_lang",
514
+ "Language of every message, Telegram notifications included.",
515
+ ),
516
+ value=lambda s: "繁體中文" if s.lang == "zh-TW" else "English",
517
+ accent=lambda _s: CYAN,
518
+ cycle=_toggle_lang,
519
+ ),
520
+ Row(
521
+ key="AICP_CLI_ORDER",
522
+ group=None,
523
+ label=("config_cli_order", "AI CLI order"),
524
+ help=(
525
+ "config_help_cli",
526
+ "Full fallback order, tried left to right; ←/→ rotates it. Missing CLIs are skipped.",
527
+ ),
528
+ value=lambda s: _SEPARATOR.join(s.chain),
529
+ accent=lambda _s: CYAN,
530
+ cycle=_rotate_chain,
531
+ ),
532
+ # Rows, not subcommands: only `aicp` and `aicp --config` are ever meant to
533
+ # be memorised, so skills management and the health check live here.
534
+ Row(
535
+ key="",
536
+ group=("config_group_tools", "Tools"),
537
+ label=("config_skills", "Skills"),
538
+ help=(
539
+ "config_help_skills",
540
+ "Installs aicp's /commit and /safe-git-push into each AI CLI. Your own files are kept.",
541
+ ),
542
+ value=_skills_value,
543
+ accent=_skills_accent,
544
+ action=_skills_action,
545
+ ),
546
+ Row(
547
+ key="",
548
+ group=None,
549
+ label=("config_doctor", "Health check"),
550
+ help=(
551
+ "config_help_doctor",
552
+ "What is otherwise silent: timeout, skipped config.json keys, skills, git remote.",
553
+ ),
554
+ value=_doctor_value,
555
+ accent=lambda s: YELLOW if _WARN in _doctor_value(s) else GREEN,
556
+ action=_doctor_action,
557
+ ),
558
+ )
559
+
560
+
561
+ def _terminal_size(out: IO[str]) -> os.terminal_size:
562
+ """The terminal *out* draws on; 80×24 when it is not one.
563
+
564
+ Measured from the stream being written to rather than through
565
+ ``shutil.get_terminal_size``: the repaint arithmetic below is only
566
+ correct against the surface it actually draws on, and a captured stdout
567
+ (a pipe, this project's suite) has to resolve the same way on every run
568
+ instead of inheriting whatever terminal happened to launch it.
569
+ """
570
+ try:
571
+ return os.get_terminal_size(out.fileno())
572
+ except (AttributeError, OSError, ValueError):
573
+ return os.terminal_size((80, 24))
574
+
575
+
576
+ def _fit(text: str, columns: int) -> str:
577
+ """*text* clipped to *columns* visible columns, ending in … when clipped."""
578
+ if width(text) <= columns:
579
+ return text
580
+ kept: list[str] = []
581
+ used = 0
582
+ for char in text:
583
+ used += width(char)
584
+ if used > columns - 1:
585
+ break
586
+ kept.append(char)
587
+ return "".join(kept) + _ELLIPSIS
588
+
589
+
590
+ def _fit_columns(state: MenuState, out: IO[str]) -> tuple[int, int]:
591
+ """``(frame, value)`` column budgets for a panel that fits this terminal.
592
+
593
+ The panel sizes itself to its content, and that content is routinely
594
+ wider than the 80 columns a terminal gives by default: the Chinese help
595
+ line and the six-CLI chain put it at 87-89. A frame wider than the
596
+ terminal is not merely ugly — every one of its lines wraps, so a frame
597
+ with 15 lines occupies 30 rows, the repaint below walks the cursor up 15
598
+ and lands in the middle of its own last frame, and each keypress leaves
599
+ another half-frame behind until the screen is a column of borders. So
600
+ the content is fitted to the terminal first and the repaint arithmetic
601
+ stays exact.
602
+ """
603
+ frame = _terminal_size(out).columns - 2
604
+ labels = [f" {i}) {_t(state.lang, *row.label)}" for i, row in enumerate(ROWS, start=1)]
605
+ labels += [_t(state.lang, *row.group) for row in ROWS if row.group]
606
+ return frame, max(frame - 6 - max(width(label) for label in labels), _MIN_VALUE_COLUMNS)
607
+
608
+
609
+ def _panel(
610
+ state: MenuState,
611
+ selected: int | None = None,
612
+ out: IO[str] | None = None,
613
+ order_value: str | None = None,
614
+ ) -> list[str]:
615
+ """The framed settings box, numbered for the typed-choice surface.
616
+
617
+ Always titled with aicp's own version, so a bug report or a screenshot
618
+ names the build it came from. ``selected`` (only ever given by the
619
+ arrow-key TUI, which is the one surface with a single current row) adds
620
+ two more lines inside the frame: that row's own help text — 各項目說明,
621
+ reusing exactly the ``help`` every row already carries, never a second
622
+ copy of it — and the arrow-key hint (操作說明). The numbered fallback has
623
+ no single current row, so it gets only the digit-choice hint instead.
624
+
625
+ ``order_value`` swaps in an already-rendered motion frame for the AI CLI
626
+ order row (see :func:`_order_motion`); everything else about the panel is
627
+ drawn exactly as it is at rest, so a frame mid-slide is the same shape as
628
+ the frame it settles into.
629
+ """
630
+ out = out if out is not None else sys.stdout
631
+ frame_columns, value_columns = _fit_columns(state, out)
632
+ rows: list[tuple[str, str]] = []
633
+ for i, row in enumerate(ROWS, start=1):
634
+ if row.group:
635
+ rows.append((_t(state.lang, *row.group), ""))
636
+ marker = "›" if selected == i else " "
637
+ value = (
638
+ order_value
639
+ if order_value is not None and row.key == "AICP_CLI_ORDER"
640
+ else _fit(row.value(state), value_columns)
641
+ )
642
+ label = f"{marker} {i}) {_t(state.lang, *row.label)}"
643
+ # The cursor row stands out by weight, not a new hue: RESET cancels
644
+ # render_panel's own DIM before BOLD applies, matching the group
645
+ # headings above and keeping every hue's existing meaning intact.
646
+ if selected == i:
647
+ label = f"{RESET}{BOLD}{label}{RESET}"
648
+ rows.append(
649
+ (
650
+ label,
651
+ f"{row.accent(state)}{value}{RESET}",
652
+ )
653
+ )
654
+ title = f"{_t(state.lang, 'config_title', 'aicp config')} (v{__version__})"
655
+ if selected is not None:
656
+ texts = [
657
+ _t(state.lang, *ROWS[selected - 1].help),
658
+ "",
659
+ _t(state.lang, "config_keys_tui", "↑↓ select · ←→ change · ⏎ change/run · q/Ctrl-C quit · saves as you go"),
660
+ ]
661
+ else:
662
+ texts = [_t(state.lang, "config_keys_plain", "1-%s change · q quit · saves as you go", len(ROWS))]
663
+ notes = [f"{DIM}{_fit(text, frame_columns - 4)}{RESET}" for text in texts]
664
+ # A frame taller than the terminal cannot be repainted in place either —
665
+ # its top scrolls off, and the cursor can never walk back up to it. The
666
+ # notes are what a short window gives up, help text first: the key hints
667
+ # are the line someone stuck in an unfamiliar menu actually needs.
668
+ while notes and len(rows) + len(notes) + 4 > _terminal_size(out).lines:
669
+ notes.pop(0)
670
+ return render_panel(rows, title, BLUE, notes=notes)
671
+
672
+
673
+ def _write(
674
+ state: MenuState,
675
+ row: Row,
676
+ direction: int,
677
+ stdin: IO[str],
678
+ out: IO[str],
679
+ typed: Callable[[], contextlib.AbstractContextManager[None]] = contextlib.nullcontext,
680
+ ) -> bool:
681
+ if row.action is not None:
682
+ # An action row asks its questions with read_line, which needs the
683
+ # line discipline the arrow-key session holds suspended.
684
+ with typed():
685
+ row.action(state, stdin, out)
686
+ return True # an action row has nothing to persist
687
+ if row.cycle is None: # a row is either cycle or action, never neither
688
+ return True
689
+ value = row.cycle(state, direction)
690
+ if persist_key(row.key, value, state.path):
691
+ # Every persisting row lands here, and the Doctor row reports on the
692
+ # very file that was just written — so the invalidation belongs at
693
+ # this one choke point, not next to whichever row happened to change
694
+ # something (which is how it went stale the first time).
695
+ state.health = None
696
+ return True
697
+ print(
698
+ f"{RED}{_t(state.lang, 'persist_failed', '✗ failed to write %s', state.path)}{RESET}",
699
+ file=out,
700
+ )
701
+ return False
702
+
703
+
704
+ def config_menu(
705
+ *,
706
+ settings: Settings | None = None,
707
+ path: Path | None = None,
708
+ stdin: IO[str] | None = None,
709
+ stdout: IO[str] | None = None,
710
+ ) -> int:
711
+ """Run the settings menu; 0 on a clean exit, 1 on a failed write."""
712
+ state = MenuState.from_settings(settings or resolve())
713
+ if path is not None:
714
+ state.path = path
715
+ out = stdout or sys.stdout
716
+ stream = stdin or sys.stdin
717
+ if is_interactive(stream, out):
718
+ return _tui(state, stream, out)
719
+ return _numbered(state, stream, out)
720
+
721
+
722
+ def _numbered(state: MenuState, stdin: IO[str], out: IO[str]) -> int:
723
+ """Typed-choice surface. EOF is "quit" — never a block, so CI is safe."""
724
+ while True:
725
+ for line in _panel(state, out=out):
726
+ print(line, file=out)
727
+ print(
728
+ _t(
729
+ state.lang,
730
+ "config_prompt",
731
+ "Pick a setting to change (1-%s, q to quit): ",
732
+ len(ROWS),
733
+ ),
734
+ file=out,
735
+ )
736
+ choice = read_line(stdin)
737
+ if choice is None or choice == "" or choice.lower() == "q":
738
+ return 0
739
+ if choice.isdigit() and 1 <= int(choice) <= len(ROWS):
740
+ if not _write(state, ROWS[int(choice) - 1], 1, stdin, out):
741
+ return 1
742
+ else:
743
+ print(
744
+ f"{RED}{_t(state.lang, 'config_bad_number', '⚠ Enter one of the setting numbers shown above.')}{RESET}",
745
+ file=out,
746
+ )
747
+ print(file=out)
748
+
749
+
750
+ def _tape(before: Sequence[str], after: Sequence[str]) -> tuple[str, int, int]:
751
+ """The order before and after a rotation, laid end to end, plus the window
752
+ offsets that read as each of them.
753
+
754
+ A rotation moves every name along by one slot and wraps the one that
755
+ falls off the end back round to the other side. Writing the name that
756
+ wraps next to the order it left makes a strip where **one window of it is
757
+ always a real order**, and sliding that window by one name turns the old
758
+ order into the new one — every other name displaced on the way, the
759
+ wrapping one leaving by one edge and arriving at the other. That is the
760
+ entire animation: one strip, one offset, no per-name bookkeeping.
761
+ """
762
+ if after[0] == before[1]: # → : the chain walks left, the head wraps to the tail
763
+ return _SEPARATOR.join((*before, before[0])), 0, width(before[0] + _SEPARATOR)
764
+ return _SEPARATOR.join((after[0], *before)), width(after[0] + _SEPARATOR), 0
765
+
766
+
767
+ def _lit(piece: str, bright: Sequence[bool], accent: str) -> str:
768
+ """*piece* with the *bright* columns picked out, back to *accent* after."""
769
+ parts: list[str] = []
770
+ on = False
771
+ for char, hot in zip(piece, bright):
772
+ if hot != on:
773
+ parts.append(f"{GREEN}{BOLD}" if hot else f"{RESET}{accent}")
774
+ on = hot
775
+ parts.append(char)
776
+ return "".join(parts) + (f"{RESET}{accent}" if on else "")
777
+
778
+
779
+ def _eased(progress: float) -> float:
780
+ """How much of the slide's time is spent by the time it is *progress*
781
+ along. A shallow curve: the chain leaves briskly and eases into its new
782
+ order instead of stopping dead on the last column."""
783
+ return 1 - (1 - progress) ** (2 / 3)
784
+
785
+
786
+ def _order_motion(
787
+ before: Sequence[str], after: Sequence[str], columns: int, accent: str
788
+ ) -> list[tuple[str, float]]:
789
+ """The chain sliding *before* → *after*: one frame per column it travels,
790
+ each paired with how long to hold it.
791
+
792
+ One column per frame is what makes this read as motion rather than as a
793
+ jump, so the easing lives in the timing and not in the distance — a
794
+ curve applied to the offset instead would round several frames onto the
795
+ same column and stall there. Every frame is exactly *columns* wide, so
796
+ the panel never changes shape mid-slide.
797
+ """
798
+ tape, start, end = _tape(before, after)
799
+ bright = [False] * len(tape)
800
+ promoted = after[0]
801
+ at = tape.find(promoted)
802
+ while at != -1: # the wrapping name is on the strip twice — light both
803
+ bright[at : at + len(promoted)] = [True] * len(promoted)
804
+ at = tape.find(promoted, at + len(promoted))
805
+ travel = abs(end - start)
806
+ step = 1 if end > start else -1
807
+ frames = []
808
+ for moved in range(travel + 1):
809
+ offset = start + step * moved
810
+ # The settled frame is the last one and is held by whatever comes
811
+ # next, not by the slide.
812
+ held = (
813
+ _MOTION_SECONDS * (_eased((moved + 1) / travel) - _eased(moved / travel))
814
+ if moved < travel
815
+ else 0.0
816
+ )
817
+ frames.append((_lit(tape[offset : offset + columns], bright[offset:], accent), held))
818
+ return frames
819
+
820
+
821
+ def _order_line() -> int:
822
+ """Which line of :func:`_panel`'s output carries the AI CLI order row.
823
+
824
+ Derived from :data:`ROWS` rather than counted by hand, for the same
825
+ reason nothing else in this module hardcodes a row number: the panel is
826
+ one title line, then each row preceded by its group heading when it
827
+ opens one.
828
+ """
829
+ line = 1
830
+ for row in ROWS:
831
+ if row.group:
832
+ line += 1
833
+ if row.key == "AICP_CLI_ORDER":
834
+ return line
835
+ line += 1
836
+ raise AssertionError("no AICP_CLI_ORDER row") # pragma: no cover - ROWS is a constant
837
+
838
+
839
+ def _animates(lines: list[str], out: IO[str]) -> bool:
840
+ """Whether motion can be drawn at all.
841
+
842
+ Colour, and a frame that is not wrapping: a wrapped line cannot be
843
+ rewritten on its own (``\\033[K`` clears one screen row, not one logical
844
+ line), and a terminal too narrow to hold the panel has a redraw problem
845
+ to fix before it has an animation to watch.
846
+ """
847
+ return color_supported(out) and _frame_rows(lines, out) == len(lines)
848
+
849
+
850
+ def _repaint_line(out: IO[str], lines: list[str], index: int, text: str) -> None:
851
+ """Rewrite one line of the frame already on screen, leaving the rest be.
852
+
853
+ An animation step changes one row, and erasing the whole frame to redraw
854
+ it 12 times a second is what makes motion flicker — so the cursor walks
855
+ up to just that line, overwrites it, and comes straight back. Nothing is
856
+ scrolled: no newline is ever written.
857
+ """
858
+ up = _frame_rows(lines[index:], out)
859
+ out.write(f"\033[{up}A\r\033[K{text}\033[{up}B\r")
860
+
861
+
862
+ def _frame_rows(lines: list[str], out: IO[str]) -> int:
863
+ """Physical terminal rows *lines* occupies once wrapped at the real
864
+ terminal width.
865
+
866
+ Not the same as ``len(lines)``: a row's visible width (CJK glyphs count
867
+ two columns) routinely exceeds an 80-column terminal, so the terminal
868
+ itself wraps that one logical line into two on-screen rows. Erasing by
869
+ ``len(lines)`` then moves the cursor up too few rows, leaves the old
870
+ frame's wrapped tail on screen, and the next redraw piles another tail
871
+ on top of that one — the "whole panel smears down the screen" bug.
872
+ """
873
+ columns = max(_terminal_size(out).columns, 1)
874
+ return sum(-(-width(line) // columns) or 1 for line in lines)
875
+
876
+
877
+ def _tui(state: MenuState, stdin: IO[str], out: IO[str]) -> int:
878
+ """Arrow-key surface. Repaints in place by walking back up the frame it
879
+ just drew; ``\\033[J`` erases to the end of the screen because switching
880
+ to a language with narrower rows would otherwise leave the previous,
881
+ wider frame's right-hand border on screen as a second column of │."""
882
+ selected = 1
883
+ lines = _panel(state, selected, out)
884
+ for line in lines:
885
+ print(line, file=out)
886
+ try:
887
+ with key_session(stdin, out) as typed:
888
+ while True:
889
+ key = read_key(stdin, out)
890
+ if key == "quit":
891
+ return 0
892
+ if key == "up":
893
+ selected = selected - 1 if selected > 1 else len(ROWS)
894
+ elif key == "down":
895
+ selected = selected + 1 if selected < len(ROWS) else 1
896
+ elif key in ("left", "right", "enter"):
897
+ row = ROWS[selected - 1]
898
+ before = list(state.chain)
899
+ if not _write(state, row, -1 if key == "left" else 1, stdin, out, typed):
900
+ return 1
901
+ if row.action is not None:
902
+ # An action prints below the frame; redraw under its
903
+ # output rather than scrolling back up over what it
904
+ # just said.
905
+ lines = _panel(state, selected, out)
906
+ for line in lines:
907
+ print(line, file=out)
908
+ continue
909
+ if row.key == "AICP_CLI_ORDER" and _animates(lines, out):
910
+ # The whole chain slides one name over, so the change
911
+ # is watched rather than noticed after the fact. Only
912
+ # the order row is rewritten per step — the rest of
913
+ # the frame is already right, and redrawing it is
914
+ # what flickers.
915
+ index = _order_line()
916
+ columns = width(
917
+ _fit(_SEPARATOR.join(state.chain), _fit_columns(state, out)[1])
918
+ )
919
+ for frame, held in _order_motion(
920
+ before, state.chain, columns, row.accent(state)
921
+ ):
922
+ lines = _panel(state, selected, out, order_value=frame)
923
+ _repaint_line(out, lines, index, lines[index])
924
+ out.flush()
925
+ if pending(stdin):
926
+ break # a key is already waiting; land on the
927
+ # settled frame now rather than making it queue
928
+ # behind a slide nobody is still watching
929
+ time.sleep(held)
930
+ else:
931
+ continue
932
+ out.write(f"\033[{_frame_rows(lines, out)}A\033[J")
933
+ lines = _panel(state, selected, out)
934
+ for line in lines:
935
+ print(line, file=out)
936
+ except KeyboardInterrupt:
937
+ # cbreak leaves Ctrl+C a signal rather than a byte, and quitting a
938
+ # menu that saves as it goes has nothing to roll back.
939
+ print(file=out)
940
+ return 0
941
+
942
+
943
+ def swap_ai(
944
+ *,
945
+ settings: Settings | None = None,
946
+ path: Path | None = None,
947
+ stdin: IO[str] | None = None,
948
+ stdout: IO[str] | None = None,
949
+ ) -> int:
950
+ """``--swap-ai``: move the picked CLI to #1, trading places with whoever
951
+ holds it. Never runs an AI CLI and never commits or pushes — the swap
952
+ only; the next ordinary ``aicp`` run is what applies the new priority.
953
+
954
+ The menu always lists the full roster, not just what is installed, so a
955
+ prior swap or a missing binary never hides a choice (missing CLIs are
956
+ skipped at call time by the runner instead).
957
+ """
958
+ state = MenuState.from_settings(settings or resolve())
959
+ if path is not None:
960
+ state.path = path
961
+ out = stdout or sys.stdout
962
+ chain = state.chain
963
+
964
+ print(_t(state.lang, "swap_current_order", "Current fallback order:"), file=out)
965
+ for i, name in enumerate(chain, start=1):
966
+ first = _t(state.lang, "swap_current_first", " ← current #1") if i == 1 else ""
967
+ print(f" {i}) {name}{DIM}{first}{RESET}", file=out)
968
+ print(_t(state.lang, "swap_prompt", "Pick a CLI to move to #1 (1-%s): ", len(chain)), file=out)
969
+
970
+ choice = read_line(stdin)
971
+ if choice is None or choice == "":
972
+ return 0
973
+ if not choice.isdigit() or not 1 <= int(choice) <= len(chain):
974
+ print(
975
+ f"{RED}{_t(state.lang, 'swap_invalid', '✗ invalid choice: %s', choice)}{RESET}",
976
+ file=out,
977
+ )
978
+ return 1
979
+ picked = int(choice)
980
+ if picked == 1:
981
+ print(
982
+ f"{DIM}{_t(state.lang, 'swap_already_first', '▸ %s is already #1 — no change', chain[0])}{RESET}",
983
+ file=out,
984
+ )
985
+ return 0
986
+
987
+ chain[picked - 1], chain[0] = chain[0], chain[picked - 1]
988
+ if not persist_key("AICP_CLI_ORDER", " ".join(chain), state.path):
989
+ print(
990
+ f"{RED}{_t(state.lang, 'persist_failed', '✗ failed to write %s', state.path)}{RESET}",
991
+ file=out,
992
+ )
993
+ return 1
994
+ print(
995
+ f"{GREEN}{_t(state.lang, 'swap_new_order', '✓ new order:')}{RESET} {' -> '.join(chain)}",
996
+ file=out,
997
+ )
998
+ print(
999
+ f"{DIM}{_t(state.lang, 'swap_saved', ' saved to %s — the next aicp run uses it', state.path)}{RESET}",
1000
+ file=out,
1001
+ )
1002
+ return 0
1003
+
1004
+
1005
+ def cli_chain(settings: Settings | None = None) -> Sequence[str]:
1006
+ """The resolved fallback chain, for callers that already have Settings.
1007
+
1008
+ Thin alias over ``Settings.cli_chain`` so a caller never has to know
1009
+ whether the order came from the environment, ``.aicprc`` or the default
1010
+ roster — see :func:`aicp.config.resolve_cli_chain`, the contract T2 and
1011
+ T4 consume.
1012
+ """
1013
+ return (settings or resolve()).cli_chain